package parser import ( "strings" "testing" ) // place builds a fixed-width line by putting each string at a given column, // which keeps these fixtures honest about the layout the parser measures. func place(cells map[int]string) string { width := 0 for col, text := range cells { if end := col + len(text); end > width { width = end } } line := []byte(strings.Repeat(" ", width)) for col, text := range cells { copy(line[col:], text) } return string(line) } // Column starts, spaced as a real statement is: wide enough that every token // of "05 Jan 2026" stays closest to DATE rather than drifting into TYPE. const ( colDate = 2 colType = 20 colDesc = 40 colMoneyIn = 75 colMoneyOut = 90 colBalance = 105 ) func trHeaderLine() string { return place(map[int]string{ colDate: "DATE", colType: "TYPE", colDesc: "DESCRIPTION", colMoneyIn: "MONEY IN", colMoneyOut: "MONEY OUT", colBalance: "BALANCE", }) } // A statement mixing both layouts: one transaction on a single line, one with // its fields stacked across three, and one whose description wraps. func trStatement() string { lines := []string{ " Trade Republic Bank GmbH", "", trHeaderLine(), "", // Everything on one line. place(map[int]string{ colDate: "05 Jan 2026", colType: "Deposit", colDesc: "Payment from ACME", colMoneyIn: "€2,500.00", colBalance: "€2,500.00", }), "", // Stacked across three lines. place(map[int]string{ colDate: "06 Jan 2026", colMoneyOut: "€45.20", colBalance: "€2,454.80", }), place(map[int]string{colType: "Card"}), place(map[int]string{colDesc: "LIDL SOFIA 4412"}), "", // Wrapped description, hyphen-broken on the first line, plus an IBAN. place(map[int]string{ colDate: "10 Jan 2026", colType: "Transfer", colDesc: "Standing order to sav-", colMoneyOut: "€500.00", colBalance: "€1,954.80", }), place(map[int]string{colDesc: "ings SI56123456789012345"}), "", " Page 1 of 2", } return strings.Join(lines, "\n") } func TestParseTradeRepublicText(t *testing.T) { txns, err := parseTradeRepublicText(trStatement(), 2) if err != nil { t.Fatal(err) } if len(txns) != 3 { t.Fatalf("got %d transactions, want 3: %+v", len(txns), txns) } deposit := txns[0] if deposit.Date != "2026-01-05" { t.Errorf("date = %q, want 2026-01-05", deposit.Date) } if deposit.AmountMinor != 250000 { t.Errorf("money in = %d, want 250000", deposit.AmountMinor) } if deposit.Type != "Deposit" { t.Errorf("type = %q, want Deposit", deposit.Type) } if deposit.Description != "Payment from ACME" { t.Errorf("description = %q", deposit.Description) } // The stacked layout must produce the same shape as the single-line one. card := txns[1] if card.Date != "2026-01-06" { t.Errorf("date = %q, want 2026-01-06", card.Date) } if card.AmountMinor != -4520 { t.Errorf("money out = %d, want -4520", card.AmountMinor) } if card.Type != "Card" { t.Errorf("type = %q, want Card", card.Type) } if card.Description != "LIDL SOFIA 4412" { t.Errorf("description = %q", card.Description) } if card.BalanceMinor == nil || *card.BalanceMinor != 245480 { t.Errorf("balance = %v, want 245480", card.BalanceMinor) } // A hyphen-broken word rejoins without a space. The hyphen itself is kept, // as the original script does, because descriptions contain real hyphens // that must not be swallowed. transfer := txns[2] if transfer.Description != "Standing order to sav-ings SI56123456789012345" { t.Errorf("description = %q, want the wrapped fragment joined without a space", transfer.Description) } if transfer.Counterparty != "SI56123456789012345" { t.Errorf("counterparty = %q", transfer.Counterparty) } if transfer.AmountMinor != -50000 { t.Errorf("amount = %d, want -50000", transfer.AmountMinor) } } // Pages with no transaction table (cover pages, disclaimers) are skipped. func TestParseTradeRepublicSkipsPagesWithoutTable(t *testing.T) { text := " Some cover page with no table at all\n" + "\f" + trStatement() txns, err := parseTradeRepublicText(text, 2) if err != nil { t.Fatal(err) } if len(txns) != 3 { t.Errorf("got %d transactions, want 3", len(txns)) } } // A figure inside the description must not be mistaken for a money column. func TestParseTradeRepublicIgnoresAmountsInDescription(t *testing.T) { text := strings.Join([]string{ trHeaderLine(), "", place(map[int]string{ colDate: "05 Jan 2026", colType: "Card", colDesc: "Refund of €12.00 order", colMoneyIn: "€12.00", colBalance: "€100.00", }), }, "\n") txns, err := parseTradeRepublicText(text, 2) if err != nil { t.Fatal(err) } if len(txns) != 1 { t.Fatalf("got %d transactions, want 1", len(txns)) } if txns[0].AmountMinor != 1200 { t.Errorf("amount = %d, want 1200", txns[0].AmountMinor) } if !strings.Contains(txns[0].Description, "€12.00") { t.Errorf("description = %q, want the inline figure kept", txns[0].Description) } }