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) } } } } // Order within a day is not knowable. A broker states a booking date and a // local clock time, and only the date is imported, because the time crosses // midnight for part of the year and would move rows to the wrong day. A // purchase funded by a sale nine seconds earlier then arrives in an arbitrary // order, so a balance that never went negative gets reported as if it had. // The balance is therefore only judged where it is observable: at each day's // close. func TestSameDayTradesDoNotReportAnIntradayDip(t *testing.T) { build := func(funded bool) domain.Dataset { data := domain.NewDataset() data.Accounts = []domain.Account{{ID: "broker", DisplayName: "Scalable", Currency: "EUR", Kind: domain.AccountInvestment, Active: true}} data.Instruments = []domain.Instrument{{ID: "ins_world", ISIN: "IE000BI8OT95", Name: "Amundi Core MSCI World (Acc)", Currency: "EUR"}} row := func(id, date, amount string, inv domain.Investment) domain.Transaction { f := domain.Facts{ ID: id, Source: "scalable_csv", AccountID: "broker", BookingDate: date, Amount: domain.Money(amount), Currency: "EUR", RawDescription: "Amundi Core MSCI World (Acc)", Fingerprint: id, Investment: &inv, } return domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)} } if funded { data.Transactions = append(data.Transactions, row("tx_0", "2025-12-18", "1000.00", domain.Investment{Event: domain.EventDeposit})) } // tx_a sorts before tx_b, so the purchase is applied first even though // the sale that funded it happened nine seconds earlier. data.Transactions = append(data.Transactions, row("tx_a", "2025-12-19", "-30911.145", domain.Investment{Event: domain.EventBuy, InstrumentID: "ins_world", Quantity: "223", Price: "138.615", Gross: "-30911.145"}), row("tx_b", "2025-12-19", "30619.545", domain.Investment{Event: domain.EventSell, InstrumentID: "ins_world", Quantity: "-223", Price: "138.565", Gross: "30899.995", Tax: "280.45"}), ) if err := domain.Validate(data); err != nil { t.Fatal(err) } return data } funded := WealthOf(build(true)).Accounts[0] for _, check := range funded.Checks { if check.Failed { t.Errorf("a day that closed at %s reported %q: %s", funded.Cash, check.Name, check.Detail) } } if funded.Cash != "708.40" { t.Errorf("balance %s, want 708.40", funded.Cash) } // The breakdown accounts for the balance exactly, so a total that // disagrees with a broker's screen points at one class of row. total := int64(0) for _, flow := range funded.Flows { minor, err := flow.Cash.Minor() if err != nil { t.Fatal(err) } total += minor } if domain.FormatMoney(total) != funded.Cash { t.Errorf("flows sum to %s, balance is %s", domain.FormatMoney(total), funded.Cash) } if len(funded.Flows) != 3 { t.Errorf("expected a line per kind of movement, got %+v", funded.Flows) } // A day that really does close negative is still reported. unfunded := WealthOf(build(false)).Accounts[0] found := false for _, check := range unfunded.Checks { if check.Failed && check.Name == "Cash never negative" { found = true if !strings.Contains(check.Detail, "2025-12-19") { t.Errorf("negative close not located: %s", check.Detail) } } } if !found { t.Errorf("a day closing at %s passed: %+v", unfunded.Cash, unfunded.Checks) } } // A page that reports only cash is not reporting wealth. An open position is // valued at its own quote; a closed one needs none; an open one without a quote // is named and left out, because valuing it at cost would report a number the // journal cannot support. func TestWealthValuesHoldingsAtTheirQuote(t *testing.T) { data := domain.NewDataset() data.Accounts = []domain.Account{{ID: "broker", DisplayName: "Scalable", Currency: "EUR", Kind: domain.AccountInvestment, Active: true}} data.Instruments = []domain.Instrument{ {ID: "ins_a", ISIN: "IE00B4L5Y983", Name: "Core World", Currency: "EUR", Symbol: "EUNL.DE", Quote: "110.00", QuotedAt: "2026-09-11"}, {ID: "ins_b", ISIN: "IE00B1XNHC34", Name: "Clean Energy", Currency: "EUR"}, {ID: "ins_c", ISIN: "US67066G1040", Name: "NVIDIA", Currency: "EUR", Symbol: "NVD.DE", Quote: "150.00", QuotedAt: "2026-09-11"}, } row := func(id, date, amount string, inv domain.Investment) domain.Transaction { f := domain.Facts{ ID: id, Source: "scalable_csv", AccountID: "broker", BookingDate: date, Amount: domain.Money(amount), Currency: "EUR", RawDescription: "row", Fingerprint: id, Investment: &inv, } return domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)} } data.Transactions = []domain.Transaction{ row("tx_1", "2026-01-02", "50000.00", domain.Investment{Event: domain.EventDeposit}), row("tx_2", "2026-01-03", "-10000.00", domain.Investment{Event: domain.EventBuy, InstrumentID: "ins_a", Quantity: "100", Price: "100.00", Gross: "-10000.00"}), row("tx_3", "2026-01-04", "-500.00", domain.Investment{Event: domain.EventBuy, InstrumentID: "ins_b", Quantity: "10", Price: "50.00", Gross: "-500.00"}), row("tx_4", "2026-01-05", "-100.00", domain.Investment{Event: domain.EventBuy, InstrumentID: "ins_c", Quantity: "5", Price: "20.00", Gross: "-100.00"}), row("tx_5", "2026-01-06", "125.00", domain.Investment{Event: domain.EventSell, InstrumentID: "ins_c", Quantity: "-5", Price: "25.00", Gross: "125.00"}), } if err := domain.Validate(data); err != nil { t.Fatal(err) } report := WealthOf(data) account := report.Accounts[0] if account.Cash != "39525.00" || account.Positions != "11000.00" || account.Wealth != "50525.00" { t.Fatalf("cash %s, positions %s, wealth %s; want 39525.00, 11000.00, 50525.00", account.Cash, account.Positions, account.Wealth) } if account.Unpriced != 1 { t.Errorf("unpriced holdings %d, want 1", account.Unpriced) } byISIN := map[string]WealthHolding{} for _, h := range account.Holdings { byISIN[h.ISIN] = h } // An open position carries its quote and the day it is from. if open := byISIN["IE00B4L5Y983"]; !open.Priced || open.Value != "11000.00" || open.Result != "1000.00" || open.QuotedAt != "2026-09-11" { t.Errorf("open position valued as %+v", open) } // A position with no quote contributes nothing and says so. if none := byISIN["IE00B1XNHC34"]; none.Priced || none.Value != "" || none.Result != "" { t.Errorf("unquoted position was valued anyway: %+v", none) } // A closed position is worth nothing at any price, and its result is the // cash it settled. if closed := byISIN["US67066G1040"]; !closed.Priced || closed.Value != "0.00" || closed.Result != "25.00" { t.Errorf("closed position valued as %+v", closed) } if total := report.Totals[0]; total.Wealth != "50525.00" || total.Positions != "11000.00" || total.Unpriced != 1 { t.Errorf("totals %+v", total) } // The gap is named rather than hidden in the number. named := false for _, check := range account.Checks { if check.Name == "Holdings priced" { named = true if check.Failed || !strings.Contains(check.Detail, "IE00B1XNHC34") { t.Errorf("unpriced holding not named: %+v", check) } } } if !named { t.Error("no note about the holdings left out of the wealth figure") } } // A wealth figure that ignores the house is not a wealth figure. A hand-valued // asset joins its currency's total, a currency held only in an asset earns its // own line, and a negative value records a liability that subtracts. func TestWealthCountsHandValuedAssets(t *testing.T) { data := domain.NewDataset() data.Accounts = []domain.Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}} f := domain.Facts{ ID: "tx_1", Source: "csv", AccountID: "acc_main", BookingDate: "2026-01-02", Amount: "1000.00", Currency: "EUR", RawDescription: "salary", Fingerprint: "tx_1", } data.Transactions = []domain.Transaction{{Facts: f, Enrichment: domain.Fallback(f)}} data.Assets = []domain.Asset{ {ID: "asset_house", Name: "House", Kind: "Real estate", Currency: "EUR", Value: "250000.00", ValuedAt: "2026-09-01"}, {ID: "asset_loan", Name: "Mortgage", Currency: "EUR", Value: "-150000.00", ValuedAt: "2026-09-01"}, {ID: "asset_cabin", Name: "Cabin", Currency: "USD", Value: "40000.00", ValuedAt: "2026-08-15"}, } if err := domain.Validate(data); err != nil { t.Fatal(err) } report := WealthOf(data) byCurrency := map[string]WealthTotal{} for _, total := range report.Totals { byCurrency[total.Currency] = total } if eur := byCurrency["EUR"]; eur.Cash != "1000.00" || eur.Assets != "100000.00" || eur.Wealth != "101000.00" { t.Errorf("EUR total %+v; want cash 1000.00, assets 100000.00, wealth 101000.00", eur) } if usd, ok := byCurrency["USD"]; !ok || usd.Cash != "0.00" || usd.Assets != "40000.00" || usd.Wealth != "40000.00" { t.Errorf("a currency held only in an asset earned no line of its own: %+v", byCurrency["USD"]) } if len(report.Assets) != 3 || report.Assets[0].Name != "Cabin" || report.Assets[1].ValuedAt != "2026-09-01" { t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets) } }