The page reported three totals, a net-per-month bar chart and six ranked lists. That answers how much moved, never where it went, and the one chart carrying a shape printed its full formatted amount above every 43px column: eleven values collided into a single line of text, the dates read as 2025-11, and negative months were grey while positive ones were green, so the sign of a month was the one thing the colour did not say. The dashboard now answers four questions in the order a person asks them - am I ahead, where did it go, what changed, and what is committed - and every panel is a click into the transactions behind it. Monthly cash flow becomes a measured SVG chart: income drawn above the zero line in the brand green, spending below it in the danger red, and net as a line whose dot takes the colour of its sign. Exact figures move into a hover tooltip that names the month, both directions, the net and the transaction count, so the plot area carries a y-axis of about three gridlines a side instead of eleven overlapping labels, and the month axis prints Nov with the year only where the year changes. The chart measures its own content box through a ResizeObserver and draws at real pixel size rather than scaling a viewBox, because scaled axis text is the wrong weight at every width except one. A month with no activity is filled in as an explicit zero: it is a real answer, not a gap to close. A six-month window is the default view, long enough to show a trend and a seasonal bill and short enough that the current month still matters. The window starts on the first of a month so the buckets are whole, leaves its upper bound open so it always reaches today, and lives in the shared filter bar next to 1M/3M/12M/YTD/All, so the transactions page inherits the same framing. Reset returns to six months rather than to all of history. Where the money went is a Sankey, because the question is literally a flow: the income categories a user named, through one trunk, into the categories that consumed it. Both columns balance by construction - a surplus is a node called Left over on the right, a deficit is one called Drawn from reserves feeding the trunk from the left - so an overspend is visible as money entering from outside the period rather than as a total that silently fails to add up. Ancestor rollups already include their descendants, so a root's unexplained remainder becomes its own slice and the columns stay honest. Beside it, a donut ranks the same spending by share, and the category tree keeps the drill-down it had. What changed compares spending per leaf category against the preceding interval, which required the analytics index to return that interval's categories as well: categoryGroups is now a constant run over both filters, so the two sides of a delta cannot disagree about how a parent rolls up. Monthly stops being a list of Group rows carrying only a net and becomes MonthlyPoint, with income and spending as separate positive magnitudes and net as the only signed figure, which is what a two-sided chart needs and what a single SUM could not give. Biggest payments keeps one row per payee. Ranking outflows by amount returned the same rent six times, which explains nothing; the window now picks each merchant's single largest payment before the per-currency ranking, and a fact with no merchant competes as itself. Both windows order by the DECIMAL column rather than by its VARCHAR rendering, which would sort -0.0009 ahead of -900719925474.0991. The per-currency partition stays: one busy currency must not crowd another out of its own list. Two figures are withheld rather than printed wrong. A savings rate is net over income, and a part-month carrying only an interest credit read -10268% kept; below -100% the outflow was more than twice the income and that sentence is the answer, so the ratio is replaced by it. Period-over-period change truncates instead of rounding, because a 99.6% fall rendered as -100% claims the figure went to zero. Charts are per currency by their nature, and four copies of every panel is not a dashboard, so the busiest currency leads and a chip row switches between them. That is a view choice and not a filter: it never narrows the data the totals or the ranked lists were computed from. Verified against a running instance on a generated twelve-month, three-account, two-currency journal. The June tooltip reports in 5,701.80, out 2,611.95, net 3,089.85 over 26 transactions, matching /api/dashboard exactly. A single-month window with 16.99 of income against 1,761.59 of spending shows the net in red below the axis, withholds the savings rate, and puts 1.7k of Drawn from reserves into the trunk against 1.6k of Housing. Clicking the Housing node lands on the transactions page filtered to Expenses / Housing with eighteen rows and the period intact, and the whole page stacks and stays legible at 430px.
315 lines
12 KiB
Go
315 lines
12 KiB
Go
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 TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
|
|
s := openFixture(t, fixture())
|
|
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"})
|
|
months := []MonthlyPoint{
|
|
{Period: "2026-02", Currency: "EUR", Income: "100.1235", Expenses: "900719925474.1000", Net: "-900719925373.9765", Count: 4},
|
|
{Period: "2026-02", Currency: "USD", Income: "0.0000", Expenses: "4.2500", Net: "-4.2500", Count: 1},
|
|
}
|
|
if !reflect.DeepEqual(got.Monthly, months) {
|
|
t.Fatalf("monthly: got %#v, want %#v", got.Monthly, months)
|
|
}
|
|
// A repeat payee contributes only its biggest payment, and one currency's
|
|
// outflows never crowd another currency out of the list.
|
|
largest := []Group{
|
|
{ID: "tx_large", Name: "Shop", Currency: "EUR", Period: "2026-02-10", Amount: "-900719925474.0991", Count: 1},
|
|
{ID: "tx_usd", Name: "Shop", Currency: "USD", Period: "2026-02-10", Amount: "-4.2500", Count: 1},
|
|
}
|
|
if !reflect.DeepEqual(got.Largest, largest) {
|
|
t.Fatalf("largest: got %#v, want %#v", got.Largest, largest)
|
|
}
|
|
// The comparison period rolls up through the same ancestors as the current one.
|
|
previous := []Group{
|
|
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-25.0000", Count: 1},
|
|
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-25.0000", Count: 1},
|
|
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-25.0000", Count: 1},
|
|
}
|
|
if !reflect.DeepEqual(got.PreviousCategories, previous) {
|
|
t.Fatalf("previous categories: got %#v, want %#v", got.PreviousCategories, previous)
|
|
}
|
|
if all := queryFixture(t, s, Filter{}); len(all.PreviousCategories) != 0 {
|
|
t.Fatalf("all-time query must have no comparison period: %#v", all.PreviousCategories)
|
|
}
|
|
}
|
|
|
|
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.PreviousCategories == nil ||
|
|
got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil || got.Largest == 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)
|
|
}
|
|
}
|
|
}
|