package parser import ( "os" "path/filepath" "strings" "testing" "git.petrovv.com/nikola/money/internal/config" ) // writeFile drops a statement in a temporary directory and returns its path. func writeFile(t *testing.T, name, content string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } // A Revolut export: out of date order, mixed currencies, a pending row, a row // with a fee, and a transfer carrying an IBAN. const revolutCSV = `Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance CARD_PAYMENT,Current,2026-01-06 09:12:00,2026-01-06 09:12:00,LIDL SOFIA 4412,-45.20,0.00,EUR,COMPLETED,1234.56 TOPUP,Current,2026-01-05 08:00:00,2026-01-05 08:00:00,Payment from ACME,2500.00,0.00,EUR,COMPLETED,1279.76 TRANSFER,Current,2026-01-10 10:00:00,2026-01-10 10:00:00,To savings SI56123456789012345,-500.00,0.35,EUR,COMPLETED,734.41 CARD_PAYMENT,Current,2026-01-11 10:00:00,,Pending coffee,-3.50,0.00,EUR,PENDING, EXCHANGE,Current,2026-01-12 10:00:00,2026-01-12 10:00:00,Tokyo hotel,-15000,0,JPY,COMPLETED,50000 ` func revolutAccount(currency string, digits int) *config.Account { return &config.Account{Slug: "revolut", Currency: currency, MinorDigits: &digits, Parser: "revolut"} } func TestRevolutParse(t *testing.T) { path := writeFile(t, "account-statement.csv", revolutCSV) acc := revolutAccount("EUR", 2) p, err := For(acc) if err != nil { t.Fatal(err) } txns, err := p.Parse(path, acc) if err != nil { t.Fatal(err) } if len(txns) != 3 { t.Fatalf("got %d transactions, want 3: %+v", len(txns), txns) } // Rows come back in completed-date order, not file order. wantDates := []string{"2026-01-05", "2026-01-06", "2026-01-10"} for i, want := range wantDates { if txns[i].Date != want { t.Errorf("txn %d date = %q, want %q", i, txns[i].Date, want) } } if txns[0].Type != "TOPUP" { t.Errorf("type = %q, want TOPUP", txns[0].Type) } if txns[1].AmountMinor != -4520 { t.Errorf("card payment = %d, want -4520", txns[1].AmountMinor) } // The fee is folded into the amount and noted in the description. transfer := txns[2] if transfer.AmountMinor != -50035 { t.Errorf("transfer with fee = %d, want -50035 (amount minus fee)", transfer.AmountMinor) } if !strings.Contains(transfer.Description, "(fee 0.35)") { t.Errorf("description = %q, want a fee note", transfer.Description) } if transfer.BalanceMinor == nil || *transfer.BalanceMinor != 73441 { t.Errorf("balance = %v, want 73441", transfer.BalanceMinor) } // Pending and foreign-currency rows are skipped, and said so. warnings := strings.Join(p.(Warner).Warnings(), "\n") if !strings.Contains(warnings, "1 PENDING") { t.Errorf("warnings = %q, want a note about the pending row", warnings) } if !strings.Contains(warnings, "JPY") { t.Errorf("warnings = %q, want a note about the JPY row", warnings) } } // The same export read as a JPY account picks up the row the EUR account skipped. func TestRevolutOtherCurrencyViaSecondAccount(t *testing.T) { path := writeFile(t, "account-statement.csv", revolutCSV) acc := revolutAccount("JPY", 0) p, err := For(acc) if err != nil { t.Fatal(err) } txns, err := p.Parse(path, acc) if err != nil { t.Fatal(err) } if len(txns) != 1 { t.Fatalf("got %d transactions, want 1: %+v", len(txns), txns) } // JPY has no minor unit, so the amount is whole yen. if txns[0].AmountMinor != -15000 { t.Errorf("amount = %d, want -15000", txns[0].AmountMinor) } if txns[0].BalanceMinor == nil || *txns[0].BalanceMinor != 50000 { t.Errorf("balance = %v, want 50000", txns[0].BalanceMinor) } } func TestRevolutMissingColumn(t *testing.T) { path := writeFile(t, "bad.csv", "Type,Description,Amount\nCARD_PAYMENT,X,-1.00\n") acc := revolutAccount("EUR", 2) p, err := For(acc) if err != nil { t.Fatal(err) } _, err = p.Parse(path, acc) if err == nil { t.Fatal("expected an error for a statement missing required columns") } if !strings.Contains(err.Error(), "Balance") { t.Errorf("error = %v, want it to name the missing columns", err) } }