A wealth figure that ignores the house is not a wealth figure. Assets without a market feed - a house, a car, a private loan - are now added by hand on the Wealth page with a stated value, a currency and the day the estimate was made; a negative value records a liability. They are registry entities in assets.finance like everything else, join the per-currency totals immediately, and a currency held only in an asset earns its own line.
419 lines
18 KiB
Go
419 lines
18 KiB
Go
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)
|
||
}
|
||
}
|