package report import ( "strings" "testing" "git.petrovv.com/nikola/money/internal/model" ) func leg(id int64, amount int64, transferID *int64) model.Transaction { return model.Transaction{ ID: id, AccountSlug: "nlb", Currency: "EUR", MinorDigits: 2, Date: "2026-03-06", AmountMinor: amount, RuleTag: "moving", TransferID: transferID, } } // A matched transfer is money moved, not money spent, so neither leg reaches // the report at all. func TestByTagLeavesTransferLegsOut(t *testing.T) { id := int64(1) rows := ByTag([]model.Transaction{ leg(1, -50000, &id), leg(2, 50000, &id), {Currency: "EUR", MinorDigits: 2, AmountMinor: -2000, RuleTag: "groceries"}, }, OrderOut) if len(rows) != 1 || rows[0].Tag != "groceries" { t.Fatalf("rows = %+v, want only the groceries row", rows) } if rows[0].Out != 2000 { t.Errorf("out = %d, want only the shopping", rows[0].Out) } } // An unpaired leg is not a transfer, so it still counts. Money that left an // account and cannot be shown to have arrived must not quietly vanish from the // report. func TestByTagKeepsUnpairedLegs(t *testing.T) { rows := ByTag([]model.Transaction{leg(1, -50000, nil)}, OrderOut) if len(rows) != 1 || rows[0].Out != 50000 { t.Fatalf("rows = %+v, want the unpaired leg counted", rows) } } // A report scoped to one account or month usually holds one side of a // movement, so the summary counts legs rather than claiming whole transfers. func TestExcludedCountsLegsInView(t *testing.T) { id := int64(1) got := Excluded([]model.Transaction{ leg(1, -50000, &id), {Currency: "EUR", MinorDigits: 2, AmountMinor: -2000, RuleTag: "groceries"}, }) if len(got) != 1 { t.Fatalf("excluded = %+v, want one currency", got) } if got[0].Legs != 1 || got[0].Out != 50000 || got[0].In != 0 || got[0].Currency != "EUR" { t.Errorf("excluded = %+v, want 1 leg and 50000 out in EUR", got[0]) } } // An exchange has its legs in two currencies, so each side reports only what it // saw. Summing the two would be meaningless: there are no rates here. func TestExcludedSplitsAnExchangeByCurrency(t *testing.T) { id := int64(1) arriving := leg(2, 97790, &id) arriving.Currency = "BGN" got := Excluded([]model.Transaction{leg(1, -50000, &id), arriving}) if len(got) != 2 { t.Fatalf("excluded = %+v, want a row per currency", got) } bgn, eur := got[0], got[1] if bgn.Currency != "BGN" || bgn.In != 97790 || bgn.Out != 0 { t.Errorf("BGN row = %+v, want only the arriving leg", bgn) } if eur.Currency != "EUR" || eur.Out != 50000 || eur.In != 0 { t.Errorf("EUR row = %+v, want only the leaving leg", eur) } } // A definition with a tolerance pairs legs that disagree, and the pair leaves // the report with the difference inside it. That difference is real money, so // the summary has to name it or it is simply lost. func TestExcludedReportsTheFee(t *testing.T) { id := int64(1) got := Excluded([]model.Transaction{leg(1, -50000, &id), leg(2, 49500, &id)}) if len(got) != 1 { t.Fatalf("excluded = %+v, want one currency", got) } if got[0].Fee != 500 || got[0].Pairs != 1 || got[0].Legs != 2 { t.Errorf("excluded = %+v, want a 5.00 fee over 1 pair and 2 legs", got[0]) } } // Half a pair cannot say what the other half received, so a report scoped to // one account or month reports the leg it saw and claims no fee at all. func TestExcludedClaimsNoFeeFromHalfAPair(t *testing.T) { id := int64(1) got := Excluded([]model.Transaction{leg(1, -50000, &id)}) if len(got) != 1 { t.Fatalf("excluded = %+v, want one currency", got) } if got[0].Fee != 0 || got[0].Pairs != 0 || got[0].Legs != 1 { t.Errorf("excluded = %+v, want no fee and no complete pair", got[0]) } } // Without a tolerance the two legs are exact opposites, so there is nothing to // report and the line stays as it was. func TestExcludedReportsNoFeeForAnExactPair(t *testing.T) { id := int64(1) got := Excluded([]model.Transaction{leg(1, -50000, &id), leg(2, 50000, &id)}) if len(got) != 1 || got[0].Fee != 0 || got[0].Pairs != 1 { t.Errorf("excluded = %+v, want one pair and no fee", got) } } // The legs of an exchange are in different units, so subtracting one from the // other would produce a number meaning nothing. func TestExcludedClaimsNoFeeAcrossCurrencies(t *testing.T) { id := int64(1) arriving := leg(2, 97790, &id) arriving.Currency = "BGN" for _, row := range Excluded([]model.Transaction{leg(1, -50000, &id), arriving}) { if row.Fee != 0 || row.Pairs != 0 { t.Errorf("%s row = %+v, want no fee across currencies", row.Currency, row) } } } // row is a transaction under one tag, for the ordering tests. func row(tag string, amount int64) model.Transaction { return model.Transaction{Currency: "EUR", MinorDigits: 2, AmountMinor: amount, RuleTag: tag} } // tags is the order the breakdown came out in. func tags(rows []TagTotal) []string { var out []string for _, r := range rows { out = append(out, r.Tag) } return out } func TestByTagOrders(t *testing.T) { txns := []model.Transaction{ row("rent", -100000), row("groceries", -2000), row("groceries", -3000), row("groceries", -1000), row("salary", 250000), row("refund", 500), row("refund", -400), } for _, tc := range []struct { order Order want []string }{ {OrderOut, []string{"rent", "groceries", "refund", "salary"}}, {OrderIn, []string{"salary", "refund", "groceries", "rent"}}, {OrderNet, []string{"rent", "groceries", "refund", "salary"}}, {OrderCount, []string{"groceries", "refund", "rent", "salary"}}, {OrderTag, []string{"groceries", "refund", "rent", "salary"}}, } { got := tags(ByTag(txns, tc.order)) if len(got) != len(tc.want) { t.Errorf("%s: rows = %v, want %v", tc.order, got, tc.want) continue } for i := range got { if got[i] != tc.want[i] { t.Errorf("%s: rows = %v, want %v", tc.order, got, tc.want) break } } } } // Every order falls back to the tag, so rows that tie do not shuffle about // between one reload and the next. func TestByTagTiesBreakOnTheTag(t *testing.T) { txns := []model.Transaction{row("zebra", -1000), row("apple", -1000)} for _, o := range Orders() { if got := tags(ByTag(txns, o)); got[0] != "apple" { t.Errorf("%s: rows = %v, want the tie broken on the tag", o, got) } } } // No order may interleave currencies: with no exchange rates here, two amounts // in different columns are not comparable and must not be sorted against each // other. func TestByTagNeverInterleavesCurrencies(t *testing.T) { txns := []model.Transaction{ {Currency: "EUR", MinorDigits: 2, AmountMinor: -100, RuleTag: "small"}, {Currency: "BGN", MinorDigits: 2, AmountMinor: -900000, RuleTag: "huge"}, {Currency: "EUR", MinorDigits: 2, AmountMinor: -200, RuleTag: "medium"}, } for _, o := range Orders() { rows := ByTag(txns, o) if rows[0].Currency != "BGN" || rows[1].Currency != "EUR" || rows[2].Currency != "EUR" { t.Errorf("%s: currencies = %+v, want them grouped", o, rows) } } } // The cycle visits every order and comes back to where it started. func TestOrderNextCycles(t *testing.T) { seen := map[Order]bool{} o := OrderOut for range len(Orders()) { if seen[o] { t.Fatalf("order %s came round twice in one cycle", o) } seen[o] = true o = o.Next() } if o != OrderOut { t.Errorf("the cycle ended on %s, want it back at %s", o, OrderOut) } } // A misspelt --sort is refused rather than silently reporting in the default // order, and the error names what it would have accepted. func TestParseOrder(t *testing.T) { for _, o := range Orders() { got, err := ParseOrder(o.String()) if err != nil || got != o { t.Errorf("ParseOrder(%q) = %s, %v, want %s", o.String(), got, err, o) } } _, err := ParseOrder("descending") if err == nil { t.Fatal("ParseOrder accepted a name that is not an order") } for _, want := range []string{"descending", "out", "tag"} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err, want) } } } // Each order marks a different column, and only headings the table actually // has, or the marker would land nowhere. func TestOrderColumnsAreDistinct(t *testing.T) { headings := map[string]bool{"Tag": true, "Cur": true, "Out": true, "In": true, "Net": true, "N": true} seen := map[string]bool{} for _, o := range Orders() { c := o.Column() if !headings[c] { t.Errorf("%s sorts on %q, which is not a report column", o, c) } if seen[c] { t.Errorf("%s marks %q, which another order already marks", o, c) } seen[c] = true } }