package banking import ( "strings" "testing" "finance-duck/internal/domain" ) const scalableHeader = "date;time;status;reference;description;assetType;type;isin;shares;price;amount;fee;tax;currency\n" // Every row below is a real Scalable Capital export line. Together they cover // all ten row types, both sign conventions, a reference shared by two legs of // one event, a six-decimal reinvestment, a zero-price corporate action, a // depot switch, a cancelled retry, and one ISIN whose description changes over // time and whose latest description is the one that names it. var scalableRows = []string{ `2024-11-10;02:00:00;Executed;ABCDEF012345;Scalable Instant Cash Deposit;Cash;Deposit;;;;800,00;;;EUR`, `2026-08-18;02:00:00;Executed;ITLLRRVPRK11ZGNPAD2VNC;Scalable Broker PRIME bis 16.09.2026;Cash;Deposit;;;;4,99;0,00;;EUR`, `2026-08-18;02:00:00;Executed;LZLVVJYLNJY9ARAK;Entgelt PRIME+ Broker;Cash;Fee;;;;-4,99;0,00;;EUR`, `2026-07-16;14:19:06;Executed;O9HNT63GYQVUNPXEXQMSCJ;Scalable Capital Broker Auszahlung;Cash;Withdrawal;;;;-4.458,19;0,00;0,00;EUR`, `2026-01-02;01:00:00;Executed;INTEREST0001;Zinsen;Cash;Interest;;;;12,34;;1,23;EUR`, `2026-01-20;01:00:00;Executed;429776_rrCjP4EcbpefpNiVQeD495;Taiwan Semiconductor Manufact. ADR;Cash;Distribution;US8740391003;;;29,68;0,00;7,43;EUR`, `2026-01-20;01:00:00;Executed;429776_rrCjP4EcbpefpNiVQeD495;Taiwan Semiconductor Manufact. ADR;Security;Reinvestment_Distribution;US8740391003;0,076494;388,00;-29,679672;0,00;0,00;EUR`, `2025-05-07;09:02:29;Executed;SCALTThBbxx6z5Z;Rheinmetall Long 10x Faktor-Zertifikat HVB;Security;Buy;DE000UG4V0Z7;14;26,45;-370,30;0,00;0,00;EUR`, `2025-09-17;15:14:49;Executed;SCALwBaNVPpjf8p;Rheinmetall Long 10x Factor HVB;Security;Buy;DE000UG4V0Z7;203;1,23;-249,69;0,99;0,00;EUR`, `2025-09-18;13:38:08;Executed;SCALSVuyHibZT4w;Rheinmetall Long 10x Factor HVB;Security;Buy;DE000UG4V0Z7;6;1,10;-6,60;0,99;0,00;EUR`, `2025-10-28;01:00:00;Executed;48231_rrCjP4EcbpefpNiVQeD495;Rheinmetall Long 10x Factor HVB;Cash;Distribution;DE000UG4V0Z7;;;32,64;;-1,42;EUR`, `2025-10-28;01:00:00;Executed;48231_rrCjP4EcbpefpNiVQeD495;Rheinmetall Long 10x Factor HVB;Security;Corporate action;DE000UG4V0Z7;-223;0,14;-31,22;;;EUR`, `2025-10-21;02:00:00;Executed;WWUM 00566579567;FR0014012ZX8;Security;Corporate action;FR0014012ZX8;1,14;0,00;0,00;;;EUR`, `2025-12-05;01:00:00;Executed;WWUM 00590038089;Amundi MSCI USA Daily (2x) Leveraged (Acc);Security;Security transfer;FR0010755611;-65;25,235;-1.640,275;;;EUR`, `2025-12-06;01:00:00;Executed;SWITCH-101-rrCjP4EcbpefpNiVQeD495-FR0010755611-WDP;Amundi MSCI USA Daily (2x) Leveraged (Acc);Security;Security transfer;FR0010755611;65;25,59;1.663,35;;;EUR`, `2026-03-17;01:00:00;Executed;SCALmNYgdoA58V;Amundi Core MSCI World (Acc);Security;Sell;IE000BI8OT95;61;158,385;9.661,485;0,00;220,47;EUR`, `2025-01-27;16:26:31;Cancelled;SCALCRFHbTWXN9h;Amundi Leveraged MSCI USA Daily (Acc);Security;Buy;FR0010755611;0;0,00;0,00;0,00;0,00;EUR`, } func brokerAccount() domain.Account { return domain.Account{ ID: "acct_broker", DisplayName: "Scalable", Institution: "Scalable Capital", Currency: "EUR", Kind: domain.AccountInvestment, IBAN: "DE02120300000000202051", ReferenceIBAN: "DE89370400440532013000", Active: true, } } func readBroker(t *testing.T, rows ...string) ScalableImport { t.Helper() file, err := ReadCSV(strings.NewReader(scalableHeader + strings.Join(rows, "\n") + "\n")) if err != nil { t.Fatal(err) } result, err := ParseScalableCSV(file, brokerAccount(), nil) if err != nil { t.Fatal(err) } return result } func TestScalableExportSettlesCashAndPositionsSeparately(t *testing.T) { result := readBroker(t, scalableRows...) if result.Cancelled != 1 { t.Fatalf("cancelled rows imported: %d skipped", result.Cancelled) } if len(result.Facts) != len(scalableRows)-1 { t.Fatalf("imported %d of %d executed rows", len(result.Facts), len(scalableRows)-1) } // The cash a row settles, per row class. A cash row's amount is already // net; a trade settles gross minus fee minus tax; a corporate action or // depot transfer settles nothing at all. wantCash := map[string]string{ "ABCDEF012345": "800.00", "ITLLRRVPRK11ZGNPAD2VNC": "4.99", "LZLVVJYLNJY9ARAK": "-4.99", "O9HNT63GYQVUNPXEXQMSCJ": "-4458.19", "INTEREST0001": "12.34", "SCALTThBbxx6z5Z": "-370.30", "SCALwBaNVPpjf8p": "-250.68", "SCALSVuyHibZT4w": "-7.59", "WWUM 00566579567": "0.00", "WWUM 00590038089": "0.00", "SWITCH-101-rrCjP4EcbpefpNiVQeD495-FR0010755611-WDP": "0.00", "SCALmNYgdoA58V": "9441.015", } 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 && string(f.Amount) != want { t.Errorf("%s settled %s, want %s", f.ExternalID, f.Amount, want) } } if got := string(domain.FormatMoney(total)); got != "5199.2353" { t.Errorf("cash balance %s, want 5199.2353", got) } // Signs: a buy and a reinvestment add, a sell removes, and a corporate // action or depot transfer keeps the sign the export printed. holdings := map[string]int64{} for _, f := range result.Facts { if f.Investment.InstrumentID == "" || 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{ "DE000UG4V0Z7": 0, // 14 + 203 + 6 - 223, the knock-out closing the position "FR0010755611": 0, // a depot switch out and back "FR0014012ZX8": 114000000, // 1.14 free units at no price "US8740391003": 7649400, // 0.076494 reinvested "IE000BI8OT95": -6100000000, } { if got := holdings[domain.InstrumentID(isin)]; got != want { t.Errorf("%s holds %d hundred-millionths, want %d", isin, got, want) } } // One ISIN, several descriptions over the years, and one row that carries // the ISIN as its own description. names := map[string]string{} for _, v := range result.Instruments { names[v.ISIN] = v.Name } for isin, want := range map[string]string{ "DE000UG4V0Z7": "Rheinmetall Long 10x Factor HVB", "FR0014012ZX8": "FR0014012ZX8", "US8740391003": "Taiwan Semiconductor Manufact. ADR", } { if names[isin] != want { t.Errorf("%s named %q, want %q", isin, names[isin], want) } } // Six decimal places do not fit in money. The residue is reported, not hidden. if result.Rounded != 1 || result.Rounding != "0.000028" { t.Errorf("rounding reported as %d rows and %s, want 1 row and 0.000028", result.Rounded, result.Rounding) } // A broker cash amount is already net of tax, so the tax column is // recorded and never subtracted again. if len(result.Unapplied) != 3 { t.Fatalf("unapplied fee/tax notes: %+v", result.Unapplied) } for _, note := range result.Unapplied { if note.Tax == "" { t.Errorf("note without the figure that was not applied: %+v", note) } } } // The broker reuses one reference for every leg of an economic event, so // dedupe on the reference alone silently drops half of each corporate action. func TestSharedBrokerReferenceKeepsEveryLeg(t *testing.T) { result := readBroker(t, scalableRows...) data := domain.NewDataset() data.Accounts = []domain.Account{brokerAccount()} data.Instruments = result.Instruments added, err := NormalizeAndDedupe(data, result.Facts) if err != nil { t.Fatal(err) } if len(added) != len(result.Facts) { t.Fatalf("dedupe kept %d of %d legs", len(added), len(result.Facts)) } data.Transactions = added if err := domain.Validate(data); err != nil { t.Fatal(err) } again, err := NormalizeAndDedupe(data, result.Facts) if err != nil || len(again) != 0 { t.Fatalf("re-import was not idempotent: %v %+v", err, again) } } // Every one of these can move money that never moved, so each rejects the // whole file rather than importing the rest. func TestScalableRejectsRowsItCannotAccountFor(t *testing.T) { for name, row := range map[string]string{ "unknown type": `2026-01-05;01:00:00;Executed;R1;Something;Cash;Vorabpauschale;;;;-12,00;;;EUR`, "unknown status": `2026-01-05;01:00:00;Pending;R1;Something;Cash;Deposit;;;;12,00;;;EUR`, "asset type mismatch": `2026-01-05;01:00:00;Executed;R1;Something;Security;Deposit;;;;12,00;;;EUR`, "foreign currency": `2026-01-05;01:00:00;Executed;R1;Something;Cash;Deposit;;;;12,00;;;USD`, "mismatched gross": `2026-01-05;01:00:00;Executed;R1;Something;Security;Buy;DE000UG4V0Z7;10;2,00;-25,00;0,00;0,00;EUR`, "signed buy": `2026-01-05;01:00:00;Executed;R1;Something;Security;Buy;DE000UG4V0Z7;-10;2,00;20,00;0,00;0,00;EUR`, "paid corporate": `2026-01-05;01:00:00;Executed;R1;Something;Security;Corporate action;DE000UG4V0Z7;-5;2,00;-10,00;1,00;0,00;EUR`, "security without ISIN": `2026-01-05;01:00:00;Executed;R1;Something;Security;Buy;;10;2,00;-20,00;0,00;0,00;EUR`, "invalid ISIN": `2026-01-05;01:00:00;Executed;R1;Something;Security;Buy;NOTANISIN;10;2,00;-20,00;0,00;0,00;EUR`, } { file, err := ReadCSV(strings.NewReader(scalableHeader + row + "\n")) if err != nil { t.Fatalf("%s: %v", name, err) } if _, err := ParseScalableCSV(file, brokerAccount(), nil); err == nil { t.Errorf("%s: accepted a row that can move money it should not", name) } } } // An inconsistent row is caught, and a uniformly mangled one is not. Where the // whole row lost its separator together, shares times price still equals the // amount at every scale, so no check inside the file can see it. This is a // known limit, not an oversight: only a price cross-check against an outside // provider distinguishes 1 x 25,795 from 1 x 25795, and that is deliberately // out of scope. The test exists so nobody claims coverage that is not here. func TestSingleShareRowCatchesOnlyInconsistentArithmetic(t *testing.T) { valid := `2024-12-09;10:48:44;Executed;SCALfhSXRbGWKno;Amundi Leveraged MSCI USA Daily (Acc);Security;Buy;FR0010755611;1;25,795;-25,795;0,99;0,00;EUR` result := readBroker(t, valid) if got := result.Facts[0].Amount; got != "-26.785" { t.Fatalf("one-share buy settled %s, want -26.785", got) } inconsistent := strings.Replace(valid, "25,795;-25,795", "25,795;-257,95", 1) file, err := ReadCSV(strings.NewReader(scalableHeader + inconsistent + "\n")) if err != nil { t.Fatal(err) } if _, err := ParseScalableCSV(file, brokerAccount(), nil); err == nil { t.Fatal("accepted a one-share row whose amount is off by a factor of ten") } uniform := readBroker(t, strings.Replace(valid, "1;25,795;-25,795", "1;25795;-25795", 1)) if got := uniform.Facts[0].Investment.Gross; got != "-25795.00" { t.Fatalf("uniformly mangled row read as %s: the file-internal identity cannot see it, and that must stay visible here", got) } } // A thousands dot and a decimal dot are both present in one share column. func TestBrokerShareColumnDistinguishesGroupingFromDecimals(t *testing.T) { result := readBroker(t, `2026-02-24;17:11:29;Executed;G1;iShares Global Clean Energy Transition (Dist);Security;Buy;IE00B1XNHC34;1.014;9,408;-9.539,712;0,00;0,00;EUR`, `2026-02-25;17:11:29;Executed;G2;iShares Global Clean Energy Transition (Dist);Security;Buy;IE00B1XNHC34;1.14;9,408;-10,7251;0,00;0,00;EUR`, ) if got := result.Facts[0].Investment.Quantity; got != "1014" { t.Errorf("grouped share count read as %s, want 1014", got) } if got := result.Facts[1].Investment.Quantity; got != "1.14" { t.Errorf("fractional share count read as %s, want 1.14", got) } }