package app import ( "context" "strings" "testing" "finance-duck/internal/analytics" "finance-duck/internal/domain" ) const brokerHeader = "date;time;status;reference;description;assetType;type;isin;shares;price;amount;fee;tax;currency\n" // A broker history end to end: money in, three purchases averaging down, the // distribution that came with a knock-out, the position row that closed it, and // a reinvested fraction of a share. Cash and holdings are what the user // compares against the broker's own screen, so they are asserted exactly. var brokerRows = []string{ `2025-05-06;02:00:00;Executed;DEP1;Scalable Capital Broker Einzahlung;Cash;Deposit;;;;800,00;;;EUR`, `2025-05-07;09:02:13;Cancelled;SCAL9RdFWnYpi5T;Rheinmetall Long 10x Faktor-Zertifikat HVB;Security;Buy;DE000UG4V0Z7;0;0,00;0,00;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`, `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`, } func brokerApp(t *testing.T, rows []string) (*App, State, string) { t.Helper() a, s := testApp(t) s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { return SaveAccount(d, domain.Account{ ID: "broker", DisplayName: "Scalable", Institution: "Scalable Capital", Currency: "EUR", Kind: domain.AccountInvestment, Active: true, }) }) if err != nil { t.Fatal(err) } statement := brokerHeader + strings.Join(rows, "\n") + "\n" prepared, err := a.PrepareCSVImport(context.Background(), s.Revision, "broker", strings.NewReader(statement)) if err != nil { t.Fatal(err) } result, err := a.ConfirmCSVImport(context.Background(), prepared.ID, prepared.Revision) if err != nil { t.Fatal(err) } return a, result.State, prepared.ID } func TestBrokerImportReconcilesCashAndHoldings(t *testing.T) { a, s, _ := brokerApp(t, brokerRows) // Seven executed rows; the cancelled retry is all zeros and must not // import as a phantom trade. broker := WealthOf(s.Data).Accounts[1] if broker.Records != 7 { t.Fatalf("imported %d records, want 7", broker.Records) } // 800.00 − 370.30 − 250.68 − 7.59 + 32.64 − 29.6797 if broker.Cash != "174.3903" { t.Errorf("cash %s, want 174.3903", broker.Cash) } if broker.FirstBooking != "2025-05-06" || broker.LastBooking != "2026-01-20" { t.Errorf("history spans %s..%s", broker.FirstBooking, broker.LastBooking) } holdings := map[string]domain.Quantity{} for _, h := range broker.Holdings { holdings[h.ISIN] = h.Quantity } // 14 + 203 + 6 − 223, the knock-out closing the position exactly. if holdings["DE000UG4V0Z7"] != "0" { t.Errorf("certificate holds %s, want 0", holdings["DE000UG4V0Z7"]) } if holdings["US8740391003"] != "0.076494" { t.Errorf("reinvested fraction holds %s, want 0.076494", holdings["US8740391003"]) } for _, check := range broker.Checks { if check.Failed { t.Errorf("check %q failed: %s", check.Name, check.Detail) } } // The distribution's refunded tax is recorded and not applied, because the // broker's cash amount already includes it. note := false for _, check := range broker.Checks { if strings.HasPrefix(check.Name, "Fee and tax") { note = true if !strings.Contains(check.Detail, "-1.42") { t.Errorf("unapplied tax not reported: %s", check.Detail) } } } if !note { t.Error("no note about the tax that was recorded but not applied") } // Instruments are registered from the export, and the latest description // names one whose text changed between May and October. names := map[string]string{} for _, v := range s.Data.Instruments { names[v.ISIN] = v.Name } if names["DE000UG4V0Z7"] != "Rheinmetall Long 10x Factor HVB" { t.Errorf("certificate named %q", names["DE000UG4V0Z7"]) } // The broker history must not reach spending analytics: a closed position // and a reinvested dividend are neither income nor expenditure. dashboard, err := a.Dashboard(context.Background(), analytics.Filter{}) if err != nil { t.Fatal(err) } for _, total := range dashboard.Totals { if total.Expenses != "0.0000" || total.Income != "0.0000" { t.Errorf("broker rows leaked into spending: %+v", total) } } // Re-importing the same export changes nothing, including the two legs // that share one reference. again, err := a.PrepareCSVImport(context.Background(), s.Revision, "broker", strings.NewReader(brokerHeader+strings.Join(brokerRows, "\n")+"\n")) if err != nil { t.Fatal(err) } if again.New != 0 || again.Duplicates != 7 { t.Fatalf("re-import proposed %d new and %d duplicate records", again.New, again.Duplicates) } } // A partial export sells or closes a position that was never opened in it. The // journal accepts the facts, because they are facts, and the report says so. func TestPartialBrokerExportReportsNegativeHolding(t *testing.T) { partial := []string{brokerRows[0], brokerRows[5], brokerRows[6]} _, s, _ := brokerApp(t, partial) broker := WealthOf(s.Data).Accounts[1] failed := map[string]string{} for _, check := range broker.Checks { if check.Failed { failed[check.Name] = check.Detail } } detail, found := failed["Holdings never negative"] if !found { t.Fatalf("a position closed without ever being opened passed every check: %+v", broker.Checks) } if !strings.Contains(detail, "DE000UG4V0Z7") || !strings.Contains(detail, "2025-10-28") { t.Errorf("negative holding not located: %s", detail) } if len(failed) != 1 { t.Errorf("unexpected additional failures: %+v", failed) } } // A broker fact never reaches the sign-based fallback. This is the single rule // that stops an unmatched deposit from being counted as income and a broker fee // from being counted as household spending. func TestBrokerFactsNeverClassifyBySign(t *testing.T) { _, s, _ := brokerApp(t, brokerRows) for _, tx := range s.Data.Transactions { if tx.Facts.Investment == nil { continue } if tx.Enrichment.Kind != domain.KindInvestment { t.Fatalf("%s classified as %q", tx.Facts.ID, tx.Enrichment.Kind) } if tx.Enrichment.CategoryID != "" || tx.Enrichment.MerchantID != "" { t.Fatalf("%s acquired a category or merchant: %+v", tx.Facts.ID, tx.Enrichment) } } } // Linking is one commit over both pairs, because reciprocity is validated: a // half-applied relink is an invalid dataset. func TestManualTransferLinkRewritesBothPairsAtOnce(t *testing.T) { a, s, _ := brokerApp(t, brokerRows) s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { facts := domain.Facts{ Source: "test", AccountID: "n26", BookingDate: "2025-05-06", Amount: "-800.00", Currency: "EUR", RawDescription: "Uberweisung Scalable", Fingerprint: "manual_fixture", ID: "tx_bank_out", } d.Transactions = append(d.Transactions, domain.Transaction{Facts: facts, Enrichment: domain.Fallback(facts)}) return nil }) if err != nil { t.Fatal(err) } deposit := "" for _, tx := range s.Data.Transactions { if tx.Facts.Investment != nil && tx.Facts.Investment.Event == domain.EventDeposit { deposit = tx.Facts.ID } } if deposit == "" { t.Fatal("no broker deposit to link") } s, err = a.LinkTransfer(context.Background(), s.Revision, "tx_bank_out", deposit) if err != nil { t.Fatal(err) } linked := map[string]domain.Enrichment{} for _, tx := range s.Data.Transactions { linked[tx.Facts.ID] = tx.Enrichment } if linked["tx_bank_out"].TransferPeerID != deposit || linked[deposit].TransferPeerID != "tx_bank_out" { t.Fatalf("link is not reciprocal: %+v", linked) } if linked["tx_bank_out"].Kind != "transfer" || linked[deposit].Kind != "transfer" { t.Fatalf("linked pair is not a transfer: %+v", linked) } // Unlinking returns the broker leg to the investment ledger and the bank // leg to the fallback, both stamped manual so the next import's matcher // leaves the decision alone. s, err = a.LinkTransfer(context.Background(), s.Revision, "tx_bank_out", "") if err != nil { t.Fatal(err) } for _, tx := range s.Data.Transactions { switch tx.Facts.ID { case "tx_bank_out": if tx.Enrichment.Kind != "expense" || tx.Enrichment.Classification.Source != "manual" { t.Errorf("bank leg after unlink: %+v", tx.Enrichment) } case deposit: if tx.Enrichment.Kind != domain.KindInvestment || tx.Enrichment.Classification.Source != "manual" { t.Errorf("broker leg after unlink: %+v", tx.Enrichment) } } } }