package banking import ( "strings" "testing" "finance-duck/internal/domain" ) const tradeRepublicHeader = "datetime;date;account_type;category;type;asset_class;name;symbol;shares;price;amount;fee;tax;currency;original_amount;original_currency;fx_rate;description;transaction_id;counterparty_name;counterparty_iban;payment_reference;mcc_code\n" // Real Trade Republic export lines. Between them they cover every one of the // nine row types, both sign conventions for a transfer, a trade whose notional // does not land on a whole cent, a crypto trade whose identifier is only in the // description, a tax settlement that carries its money in the tax column, a // dividend whose share column is the holding rather than a position change, and // numbers padded with insignificant zeros. var tradeRepublicRows = []string{ `2025-01-10T13:17:25.211420Z;2025-01-10;DEFAULT;CASH;TRANSFER_INBOUND;;;;;;34337.000000;;;EUR;;;;Incoming transfer from LARS NOLDEN;cccf7fb9-f35a-462a-8d2c-162664479274;;;;`, `2025-01-16T13:59:44.872Z;2025-01-16;DEFAULT;TRADING;BUY;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;0.9493860000;64.410000;-61.15;;;EUR;;;;Buy trade IE00B8FHGS14 iShares VI plc, quantity: 0.949386;ebbc70c1-a260-4e59-b499-14dcec7e6f04;;;;`, `2025-01-16T13:59:45.293Z;2025-01-16;DEFAULT;TRADING;BUY;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;485.0000000000;64.410000;-31238.85;-1.00;;EUR;;;;Buy trade IE00B8FHGS14 iShares VI plc, quantity: 485;93aaf560-5d26-4fd3-95ab-3cff4e5f1b12;;;;`, `2025-01-18T00:27:04.446Z;2025-01-18;DEFAULT;TRADING;BUY;CRYPTO;Dogecoin;DOGE;865.7000000000;0.415787;-359.95;-1.00;;EUR;;;;Ausfuehrung Kauf/Verkauf XF000DOGE012;f305e14c-b9a5-43eb-adbc-b8b00f579c80;;;;`, `2025-02-01T12:24:38.795049Z;2025-02-01;DEFAULT;CASH;INTEREST_PAYMENT;;;;;;16.460000;;-4.33;EUR;;;;Interest payment Booking;94ad7cae-6b55-4d11-83ef-668c397e9391;;;;`, `2025-02-10T13:29:45.670Z;2025-02-10;DEFAULT;TRADING;SELL;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;-20.0000000000;67.210000;1344.20;-1.00;-10.14;EUR;;;;Sell trade IE00B8FHGS14 iShares VI plc, quantity: 20;7b647416-c8e8-45bf-beea-2aea65e3950a;;;;`, `2025-03-07T02:29:08.390291Z;2025-03-07;DEFAULT;CASH;TAX_OPTIMIZATION;;;;;;0.000000;;14.95;EUR;;;;Tax Optimisation;b9a02670-b419-42d4-a8d7-d0336d9ae9cb;;;;`, `2025-09-30T12:49:02.644Z;2025-09-30;DEFAULT;TRADING;BUY;STOCK;DroneShield;AU000000DRO2;167.0000000000;2.9800000000;-497.66;-1.00;;EUR;;;;Buy trade AU000000DRO2 DRONESHIELD LTD, quantity: 167.0;9b08e71c-5d85-49c4-bcf3-31bc7671a278;;;;`, `2025-10-06T09:02:07.835Z;2025-10-06;DEFAULT;TRADING;SELL;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;-0.4265810000;63.0600000000;26.90;-1.00;;EUR;;;;Sell trade IE00B8FHGS14 iShares VI plc, quantity: 0.426581;dcd1df8b-3324-4f71-be38-4a2e6cde326d;;;;`, `2025-12-23T12:44:47.627337Z;2025-12-23;DEFAULT;CASH;TRANSFER_INSTANT_OUTBOUND;;;;;;-5700.000000;;;EUR;;;;Outgoing transfer for Lars Nolden (DE04100110012623927730);019b4b3d-9c8b-7e5a-b17f-9c884edc0ae8;;;;`, `2026-01-27T08:44:12.845140Z;2026-01-27;DEFAULT;CASH;TAX_OPTIMIZATION;;;;;;0.000000;;-30.44;EUR;;;;Tax Optimisation;019bfe9f-eead-7321-ac84-d1aac355b444;;;;`, `2026-04-09T09:08:43.203685Z;2026-04-09;DEFAULT;CASH;DIVIDEND;STOCK;TSMC (ADR);US8740391003;24.9110320000;;15.790000;;-3.17;EUR;18.48;USD;0.854263;Cash Dividend for ISIN US8740391003;019d7180-3e43-7de4-bcd1-7a61a118944a;;;;`, `2026-05-11T16:08:03.023362Z;2026-05-11;DEFAULT;CASH;TRANSFER_INSTANT_OUTBOUND;;;;;;-1481.000000;;;EUR;;;;Outgoing transfer for LARS NOLDEN (DE41110101002098897347);019e17cb-a6cf-70f7-b602-886ffa8fdffe;LARS NOLDEN;DE41110101002098897347;;`, `2026-05-26T13:18:23.430Z;2026-05-26;DEFAULT;TRADING;SELL;CRYPTO;Dogecoin;DOGE;-865.7000000000;0.0879270000;76.12;-1.00;;EUR;;;;Sell trade XF000DOGE012 Dogecoin, quantity: 865.7;9a82e774-08a8-49bc-a706-6a65691b71c7;;;;`, } func readTradeRepublic(t *testing.T, rows ...string) BrokerImport { t.Helper() file, err := ReadCSV(strings.NewReader(tradeRepublicHeader + strings.Join(rows, "\n") + "\n")) if err != nil { t.Fatal(err) } result, err := ParseTradeRepublicCSV(file, brokerAccount(), nil) if err != nil { t.Fatal(err) } return result } func TestTradeRepublicSettlesGrossLessItsSignedAdjustments(t *testing.T) { result := readTradeRepublic(t, tradeRepublicRows...) if len(result.Facts) != len(tradeRepublicRows) { t.Fatalf("read %d of %d rows", len(result.Facts), len(tradeRepublicRows)) } // The export writes fee and tax as the signed adjustments it made, and the // amount as the gross. Cash is what is left, and a tax settlement's money // lives entirely in the tax column. wantCash := map[string]string{ "cccf7fb9-f35a-462a-8d2c-162664479274": "34337.00", "ebbc70c1-a260-4e59-b499-14dcec7e6f04": "-61.15", "93aaf560-5d26-4fd3-95ab-3cff4e5f1b12": "-31239.85", "f305e14c-b9a5-43eb-adbc-b8b00f579c80": "-360.95", "94ad7cae-6b55-4d11-83ef-668c397e9391": "12.13", "7b647416-c8e8-45bf-beea-2aea65e3950a": "1333.06", "b9a02670-b419-42d4-a8d7-d0336d9ae9cb": "14.95", "9b08e71c-5d85-49c4-bcf3-31bc7671a278": "-498.66", "dcd1df8b-3324-4f71-be38-4a2e6cde326d": "25.90", "019b4b3d-9c8b-7e5a-b17f-9c884edc0ae8": "-5700.00", "019bfe9f-eead-7321-ac84-d1aac355b444": "-30.44", "019d7180-3e43-7de4-bcd1-7a61a118944a": "12.62", "019e17cb-a6cf-70f7-b602-886ffa8fdffe": "-1481.00", "9a82e774-08a8-49bc-a706-6a65691b71c7": "75.12", } total := int64(0) for _, f := range result.Facts { minor, err := f.Amount.Minor() if err != nil { t.Fatal(err) } total += minor if want, ok := wantCash[f.ExternalID]; !ok { t.Errorf("unexpected record %s", f.ExternalID) } else if string(f.Amount) != want { t.Errorf("%s settled %s, want %s", f.ExternalID, f.Amount, want) } } if got := string(domain.FormatMoney(total)); got != "-3561.27" { t.Errorf("cash balance %s, want -3561.27", got) } holdings := map[string]int64{} for _, f := range result.Facts { if f.Investment.Quantity == "" { continue } units, err := f.Investment.Quantity.Units() if err != nil { t.Fatal(err) } holdings[f.Investment.InstrumentID] += units } for isin, want := range map[string]int64{ "IE00B8FHGS14": 46552280500, // 0.949386 + 485 − 20 − 0.426581 "XF000DOGE012": 0, // bought and sold whole "AU000000DRO2": 16700000000, "US8740391003": 0, // a dividend attributes to a security without moving it } { if got := holdings[domain.InstrumentID(isin)]; got != want { t.Errorf("%s holds %d hundred-millionths, want %d", isin, got, want) } } // Crypto carries a bare ticker in the symbol column, so its identifier // comes from the description, and the security is registered like any other. names := map[string]string{} for _, v := range result.Instruments { names[v.ISIN] = v.Name } for isin, want := range map[string]string{ "XF000DOGE012": "Dogecoin", "IE00B8FHGS14": "Edge MSCI World Min Volatility USD (Acc)", "US8740391003": "TSMC (ADR)", "AU000000DRO2": "DroneShield", } { if names[isin] != want { t.Errorf("%s named %q, want %q", isin, names[isin], want) } } // Padding is not precision: a value written to six or ten places with // trailing zeros needs no rounding at all. if result.Rounded != 0 || result.Rounding != "0" { t.Errorf("rounding reported as %d row(s) and %s, want none", result.Rounded, result.Rounding) } // The export applies its own fee and tax, so nothing is recorded unapplied. if len(result.Unapplied) != 0 { t.Errorf("unapplied notes on an export that nets its own cash: %+v", result.Unapplied) } } // A dividend populates the share column with the holding the dividend was paid // on. Adding it as a position change would double the holding. func TestTradeRepublicDividendDoesNotMoveThePosition(t *testing.T) { result := readTradeRepublic(t, tradeRepublicRows[11]) dividend := result.Facts[0].Investment if dividend.Quantity != "" || dividend.Price != "" { t.Fatalf("dividend moved a position: %+v", dividend) } if dividend.Event != domain.EventDistribution || dividend.InstrumentID == "" { t.Fatalf("dividend lost its attribution: %+v", dividend) } // 18.48 USD at 0.854263 is 15.79 EUR gross, less 3.17 withheld. if result.Facts[0].Amount != "12.62" || dividend.Gross != "15.79" || dividend.Tax != "3.17" { t.Fatalf("dividend settled %s from gross %s less tax %s", result.Facts[0].Amount, dividend.Gross, dividend.Tax) } } // The counterparty comes from the column when the export has one, from the // IBAN the description names when it does not, and from the account's // configured settlement IBAN when neither names anything. Without it a broker // transfer cannot pair with the bank debit that funded it. func TestTradeRepublicResolvesTransferCounterparties(t *testing.T) { result := readTradeRepublic(t, tradeRepublicRows[0], tradeRepublicRows[9], tradeRepublicRows[12]) want := []string{ "DE89370400440532013000", // neither column nor description: the account's own settlement IBAN "DE04100110012623927730", // named in the description only "DE41110101002098897347", // the column } for i, f := range result.Facts { if f.CounterpartyIBAN != want[i] { t.Errorf("record %d settled against %q, want %q", i+1, f.CounterpartyIBAN, want[i]) } } if result.Facts[2].Counterparty != "LARS NOLDEN" { t.Errorf("counterparty name lost: %q", result.Facts[2].Counterparty) } } // A notional that does not land on a whole cent is the normal case here, not an // error: the export states cash to the cent while the product runs longer. func TestTradeRepublicChecksGrossToTheStatedPrecision(t *testing.T) { result := readTradeRepublic(t, tradeRepublicRows[8]) inv := result.Facts[0].Investment // 0.426581 x 63.06 = 26.90019786, stated as 26.90. if inv.Gross != "26.90" || inv.Quantity != "-0.426581" || inv.Price != "63.06" { t.Fatalf("trade read as %+v", inv) } // A factor of ten is still caught: the tolerance is one cent, not one order. broken := strings.Replace(tradeRepublicRows[8], ";26.90;", ";269.00;", 1) file, err := ReadCSV(strings.NewReader(tradeRepublicHeader + broken + "\n")) if err != nil { t.Fatal(err) } if _, err := ParseTradeRepublicCSV(file, brokerAccount(), nil); err == nil { t.Fatal("accepted a gross ten times its own shares times price") } } func TestTradeRepublicRejectsRowsItCannotAccountFor(t *testing.T) { const base = `2026-01-05T10:00:00Z;2026-01-05;DEFAULT;CASH;TRANSFER_INBOUND;;;;;;12.00;;;EUR;;;;Incoming transfer;R1;;;;` for name, row := range map[string]string{ "unknown type": strings.Replace(base, "TRANSFER_INBOUND", "VORABPAUSCHALE", 1), "category mismatch": strings.Replace(base, "CASH;TRANSFER_INBOUND", "TRADING;TRANSFER_INBOUND", 1), "foreign currency": strings.Replace(base, ";EUR;", ";USD;", 1), "other account type": strings.Replace(base, ";DEFAULT;", ";SAVINGS;", 1), "trade without a security": `2026-01-05T10:00:00Z;2026-01-05;DEFAULT;TRADING;BUY;STOCK;Mystery;;1.0;2.00;-2.00;;;EUR;;;;Buy trade of something;R2;;;;`, "unresolvable ticker": `2026-01-05T10:00:00Z;2026-01-05;DEFAULT;TRADING;BUY;CRYPTO;Bitcoin;BTC;1.0;2.00;-2.00;;;EUR;;;;Kauf Bitcoin;R3;;;;`, } { file, err := ReadCSV(strings.NewReader(tradeRepublicHeader + row + "\n")) if err != nil { t.Fatalf("%s: %v", name, err) } if _, err := ParseTradeRepublicCSV(file, brokerAccount(), nil); err == nil { t.Errorf("%s: accepted a row that can move money it should not", name) } } } // Both formats are recognized from the same upload path, and neither is // mistaken for the other. func TestBrokerDetectionDistinguishesTheTwoExports(t *testing.T) { for _, format := range []struct { name, header, row, want string }{ {"trade republic", tradeRepublicHeader, tradeRepublicRows[0], SourceTradeRepublic}, {"scalable", scalableHeader, scalableRows[0], SourceScalable}, } { file, err := ReadCSV(strings.NewReader(format.header + format.row + "\n")) if err != nil { t.Fatalf("%s: %v", format.name, err) } source, label, header, ok := DetectBrokerCSV(file) if !ok || source != format.want || header != 1 || label == "" { t.Fatalf("%s detected as %q/%q at row %d (ok=%v)", format.name, source, label, header, ok) } if _, err := ParseBrokerCSV(file, brokerAccount(), nil); err != nil { t.Errorf("%s: %v", format.name, err) } } }