package analytics import ( "context" "os" "path/filepath" "reflect" "testing" "finance-duck/internal/domain" ) func fixture() domain.Dataset { data := domain.NewDataset() data.Accounts = []domain.Account{ {ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true}, {ID: "acc_savings", DisplayName: "Savings", Currency: "EUR", Active: true}, {ID: "acc_usd", DisplayName: "Dollar", Currency: "USD", Active: true}, } data.Categories = append(data.Categories, domain.Category{ID: "cat_living", Name: "Living", ParentID: "cat_expenses", Kind: "expense"}, domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_living", Kind: "expense"}, ) data.Tags = []domain.Tag{{ID: "tag_shared", Name: "Shared"}, {ID: "tag_work", Name: "Work"}} data.Merchants = []domain.Merchant{{ID: "mer_shop", Name: "Shop"}} add := func(id, account, date, amount, currency, kind, category string, tags ...string) { merchant := "mer_shop" if kind == "transfer" { merchant = "" } data.Transactions = append(data.Transactions, domain.Transaction{ Facts: domain.Facts{ID: id, Source: "csv", AccountID: account, BookingDate: date, Amount: domain.Money(amount), Currency: currency, RawDescription: id, Fingerprint: "fp_" + id}, Enrichment: domain.Enrichment{Kind: kind, CategoryID: category, MerchantID: merchant, TagIDs: tags}, }) } add("tx_large", "acc_eur", "2026-02-10", "-900719925474.0991", "EUR", "expense", "cat_food", "tag_shared", "tag_work") add("tx_small", "acc_eur", "2026-02-11", "-0.0009", "EUR", "expense", "cat_food", "tag_work") add("tx_salary", "acc_eur", "2026-02-12", "100.1234", "EUR", "income", domain.IncomeFallback) add("tx_refund", "acc_eur", "2026-02-13", "0.0001", "EUR", "expense", "cat_food") add("tx_usd", "acc_usd", "2026-02-10", "-4.2500", "USD", "expense", "cat_food", "tag_shared") add("tx_previous", "acc_eur", "2026-01-15", "-25.0000", "EUR", "expense", "cat_food") add("tx_out", "acc_eur", "2026-02-14", "-500.0000", "EUR", "transfer", "") add("tx_in", "acc_savings", "2026-02-14", "500.0000", "EUR", "transfer", "") data.Transactions[len(data.Transactions)-2].Enrichment.TransferPeerID = "tx_in" data.Transactions[len(data.Transactions)-1].Enrichment.TransferPeerID = "tx_out" return data } func openFixture(t *testing.T, data domain.Dataset) *Store { t.Helper() s, err := Open("") if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := s.Close(); err != nil { t.Error(err) } }) if err := s.Rebuild(context.Background(), data); err != nil { t.Fatal(err) } return s } func queryFixture(t *testing.T, s *Store, filter Filter) Dashboard { t.Helper() result, err := s.Query(context.Background(), filter) if err != nil { t.Fatal(err) } return result } func TestExactTotalsCurrenciesAndTransferExclusion(t *testing.T) { s := openFixture(t, fixture()) filter := Filter{From: "2026-02-01", To: "2026-02-28"} got := queryFixture(t, s, filter) want := []Total{ {Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}, {Currency: "USD", Expenses: "4.2500", Income: "0.0000", Net: "-4.2500"}, } if !reflect.DeepEqual(got.Totals, want) { t.Fatalf("totals: got %#v, want %#v", got.Totals, want) } previous := []Total{{Currency: "EUR", Expenses: "25.0000", Income: "0.0000", Net: "-25.0000"}} if !reflect.DeepEqual(got.Previous, previous) { t.Fatalf("previous: got %#v, want %#v", got.Previous, previous) } filter.AccountID = "acc_eur" if totals := queryFixture(t, s, filter).Totals; !reflect.DeepEqual(totals, want[:1]) { t.Fatalf("one transfer side must not affect totals: %#v", totals) } filter.AccountID = "acc_savings" if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 { t.Fatalf("transfer-only account must have no spending: %#v", totals) } } func TestTagUnionNeverDuplicatesTransactions(t *testing.T) { s := openFixture(t, fixture()) filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"} got := queryFixture(t, s, filter) want := []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "0.0000", Net: "-900719925474.1000"}} if !reflect.DeepEqual(got.Totals, want) { t.Fatalf("tag union multiplied or dropped spending: %#v", got.Totals) } if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 { t.Fatalf("tag union count: %#v", got.Monthly) } filter.TagID = "tag_shared" got = queryFixture(t, s, filter) if len(got.Totals) != 1 || got.Totals[0].Expenses != "900719925474.0991" { t.Fatalf("single tag filter: %#v", got.Totals) } filter.TagID = "tag_shared') OR TRUE --" if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 { t.Fatalf("tag input altered SQL predicate: %#v", totals) } } func TestAncestorFilteringAndRollups(t *testing.T) { s := openFixture(t, fixture()) filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", CategoryID: "cat_living"} got := queryFixture(t, s, filter) want := "-900719925474.0999" if len(got.Totals) != 1 || got.Totals[0].Net != want { t.Fatalf("ancestor did not include descendants: %#v", got.Totals) } groups := map[string]Group{} for _, group := range got.Categories { groups[group.ID] = group } for _, id := range []string{"cat_food", "cat_living", "cat_expenses"} { group, ok := groups[id] if !ok || group.Amount != want || group.Count != 3 { t.Fatalf("ancestor %s: %#v", id, group) } } if len(groups) != 3 { t.Fatalf("unrelated category included: %#v", got.Categories) } } func TestDeletedIndexRebuildIsIdentical(t *testing.T) { data := fixture() path := filepath.Join(t.TempDir(), "analytics.duckdb") s, err := Open(path) if err != nil { t.Fatal(err) } if err := s.Rebuild(context.Background(), data); err != nil { s.Close() t.Fatal(err) } before := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"}) if err := s.Close(); err != nil { t.Fatal(err) } if err := os.Remove(path); err != nil { t.Fatal(err) } s, err = Open(path) if err != nil { t.Fatal(err) } defer s.Close() if err := s.Rebuild(context.Background(), data); err != nil { t.Fatal(err) } after := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"}) if !reflect.DeepEqual(before, after) { t.Fatalf("rebuilding a deleted index changed dashboard:\nbefore %#v\nafter %#v", before, after) } if err := s.Rebuild(context.Background(), data); err != nil { t.Fatal(err) } again := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"}) if !reflect.DeepEqual(after, again) { t.Fatalf("repeat rebuild changed dashboard: %#v", again) } } func TestPostingsBalancePerTransactionAndTransferClearing(t *testing.T) { s := openFixture(t, fixture()) rows, err := s.db.Query(`SELECT transaction_id FROM postings GROUP BY transaction_id, currency HAVING COUNT(*) <> 2 OR SUM(amount) <> 0`) if err != nil { t.Fatal(err) } if rows.Next() { rows.Close() t.Fatal("unbalanced transaction postings") } if err := rows.Err(); err != nil { rows.Close() t.Fatal(err) } rows.Close() var amount string if err := s.db.QueryRow(`SELECT CAST(SUM(amount) AS VARCHAR) FROM postings WHERE ledger_account = 'clearing:transfers'`).Scan(&amount); err != nil { t.Fatal(err) } if amount != "0.0000" { t.Fatalf("transfer clearing did not cancel: %s", amount) } if err := s.db.QueryRow(`SELECT CAST(SUM(amount) AS VARCHAR) FROM postings WHERE ledger_account = 'asset:acc_savings'`).Scan(&amount); err != nil { t.Fatal(err) } if amount != "500.0000" { t.Fatalf("transfer asset posting lost: %s", amount) } } func TestRejectedRebuildPreservesPublishedDashboard(t *testing.T) { data := fixture() s := openFixture(t, data) before := queryFixture(t, s, Filter{}) invalid := domain.Clone(data) invalid.Transactions[0].Facts.Amount = "invalid" if err := s.Rebuild(context.Background(), invalid); err == nil { t.Fatal("accepted invalid money") } if after := queryFixture(t, s, Filter{}); !reflect.DeepEqual(before, after) { t.Fatal("failed rebuild replaced previous index") } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := s.Rebuild(ctx, data); err == nil { t.Fatal("cancelled rebuild succeeded") } if after := queryFixture(t, s, Filter{}); !reflect.DeepEqual(before, after) { t.Fatal("cancelled rebuild replaced previous index") } } func TestRecurringRequiresStableCadenceAndSeparatesCurrencies(t *testing.T) { data := fixture() data.Transactions = nil for i, date := range []string{"2026-01-15", "2026-02-15", "2026-03-15"} { for _, account := range []struct{ id, currency string }{{"acc_eur", "EUR"}, {"acc_usd", "USD"}} { id := account.id + "_subscription_" + string(rune('a'+i)) data.Transactions = append(data.Transactions, domain.Transaction{ Facts: domain.Facts{ID: id, Source: "csv", AccountID: account.id, BookingDate: date, Amount: "-12.3400", Currency: account.currency, RawDescription: "Subscription", Fingerprint: id}, Enrichment: domain.Enrichment{Kind: "expense", CategoryID: "cat_food", MerchantID: "mer_shop", TagIDs: []string{}}, }) } } s := openFixture(t, data) got := queryFixture(t, s, Filter{}) if len(got.Recurring) != 2 { t.Fatalf("expected separate currency streams: %#v", got.Recurring) } for _, group := range got.Recurring { if group.Amount != "-37.0200" || group.Count != 3 || group.Period != "monthly" { t.Fatalf("recurring payment: %#v", group) } } // One out-of-cadence payment invalidates the apparent regular schedule. data.Transactions[4].Facts.BookingDate = "2026-06-15" if err := s.Rebuild(context.Background(), data); err != nil { t.Fatal(err) } got = queryFixture(t, s, Filter{}) if len(got.Recurring) != 1 || got.Recurring[0].Currency != "USD" { t.Fatalf("irregular series counted as recurring: %#v", got.Recurring) } } func TestEmptyIndexAndInvalidDates(t *testing.T) { s := openFixture(t, domain.NewDataset()) got := queryFixture(t, s, Filter{}) if got.Totals == nil || got.Previous == nil || got.Monthly == nil || got.Categories == nil || got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil { t.Fatal("empty collections must encode as arrays") } for _, filter := range []Filter{{From: "2026-02-30"}, {From: "2026-03-01", To: "2026-02-01"}} { if _, err := s.Query(context.Background(), filter); err == nil { t.Fatalf("accepted invalid date filter: %#v", filter) } } }