Files
finance-duck/internal/analytics/store.go
T
Lars Nolden cc5912ece2 Rebuild the overview around where the money actually went
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.
2026-09-11 23:35:46 +02:00

293 lines
11 KiB
Go

// Package analytics maintains a disposable DuckDB projection of the plaintext dataset.
package analytics
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"finance-duck/internal/domain"
_ "github.com/duckdb/duckdb-go/v2"
)
type Store struct{ db *sql.DB }
type Filter struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagID string `json:"tag_id"`
MerchantID string `json:"merchant_id"`
}
type Total struct {
Currency string `json:"currency"`
Expenses string `json:"expenses"`
Income string `json:"income"`
Net string `json:"net"`
}
// Amount is signed net movement, not an absolute expense. Category groups overlap
// because each ancestor includes its descendants; totals never sum these groups.
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
Currency string `json:"currency"`
Period string `json:"period"`
Amount string `json:"amount"`
Count int64 `json:"count"`
}
// MonthlyPoint is one calendar month of one currency. Income and Expenses are
// both positive magnitudes so a chart can draw them on either side of zero;
// Net is their signed difference and the only figure that may be negative.
type MonthlyPoint struct {
Period string `json:"period"`
Currency string `json:"currency"`
Income string `json:"income"`
Expenses string `json:"expenses"`
Net string `json:"net"`
Count int64 `json:"count"`
}
type Dashboard struct {
Totals []Total `json:"totals"`
Previous []Total `json:"previous"`
Monthly []MonthlyPoint `json:"monthly"`
// Categories and PreviousCategories share a shape so the two periods can be
// subtracted category by category; PreviousCategories is empty whenever the
// filter has no comparable preceding interval.
Categories []Group `json:"categories"`
PreviousCategories []Group `json:"previous_categories"`
Tags []Group `json:"tags"`
Merchants []Group `json:"merchants"`
Accounts []Group `json:"accounts"`
Recurring []Group `json:"recurring"`
// Largest is the biggest single outflows of the period, one row per
// transaction: Period carries its booking date and Count is always one.
Largest []Group `json:"largest"`
}
func Open(path string) (*Store, error) {
db, err := sql.Open("duckdb", path)
if err != nil {
return nil, fmt.Errorf("open analytics: %w", err)
}
// A single connection bounds native resources and serializes entire dashboard
// snapshots with rebuilds, rather than interleaving individual group queries.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
fail := func(err error) (*Store, error) { db.Close(); return nil, fmt.Errorf("initialize analytics: %w", err) }
for _, statement := range []string{
"SET threads = 2",
"SET memory_limit = '256MB'",
"SET max_temp_directory_size = '1GB'",
"SET autoinstall_known_extensions = false",
"SET autoload_known_extensions = false",
"SET enable_external_access = false",
} {
if _, err := db.Exec(statement); err != nil {
return fail(err)
}
}
tx, err := db.Begin()
if err != nil {
return fail(err)
}
defer tx.Rollback()
for _, statement := range schema {
if _, err := tx.Exec(statement); err != nil {
tx.Rollback()
return fail(err)
}
}
if err := tx.Commit(); err != nil {
return fail(err)
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
var schema = []string{
`CREATE TABLE IF NOT EXISTS accounts (id VARCHAR PRIMARY KEY, display_name VARCHAR NOT NULL, institution VARCHAR NOT NULL, currency VARCHAR NOT NULL, external_account_id VARCHAR NOT NULL, iban VARCHAR NOT NULL, active BOOLEAN NOT NULL)`,
`CREATE TABLE IF NOT EXISTS categories (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, parent_id VARCHAR NOT NULL, kind VARCHAR NOT NULL)`,
`CREATE TABLE IF NOT EXISTS tags (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL)`,
`CREATE TABLE IF NOT EXISTS merchants (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL)`,
`CREATE TABLE IF NOT EXISTS transactions (id VARCHAR PRIMARY KEY, source VARCHAR NOT NULL, account_id VARCHAR NOT NULL, booking_date DATE NOT NULL, value_date DATE, amount DECIMAL(24,4) NOT NULL, currency VARCHAR NOT NULL, raw_description VARCHAR NOT NULL, external_id VARCHAR NOT NULL, fingerprint VARCHAR NOT NULL, counterparty VARCHAR NOT NULL, counterparty_iban VARCHAR NOT NULL, kind VARCHAR NOT NULL, merchant_id VARCHAR NOT NULL, category_id VARCHAR NOT NULL, transfer_peer_id VARCHAR NOT NULL, classification_source VARCHAR NOT NULL, classification_model VARCHAR NOT NULL, classification_timestamp VARCHAR NOT NULL, classification_error VARCHAR NOT NULL)`,
`CREATE TABLE IF NOT EXISTS transaction_tags (transaction_id VARCHAR NOT NULL, tag_id VARCHAR NOT NULL, PRIMARY KEY (transaction_id, tag_id))`,
`CREATE TABLE IF NOT EXISTS category_ancestors (category_id VARCHAR NOT NULL, ancestor_id VARCHAR NOT NULL, depth INTEGER NOT NULL, PRIMARY KEY (category_id, ancestor_id))`,
`CREATE TABLE IF NOT EXISTS postings (transaction_id VARCHAR NOT NULL, line INTEGER NOT NULL, ledger_account VARCHAR NOT NULL, account_id VARCHAR NOT NULL, category_id VARCHAR NOT NULL, currency VARCHAR NOT NULL, amount DECIMAL(24,4) NOT NULL, PRIMARY KEY (transaction_id, line))`,
}
// Rebuild replaces both schema and contents in one transaction. The database is
// only a cache: no previous index contents participate in the derived dataset.
func (s *Store) Rebuild(ctx context.Context, data domain.Dataset) error {
if err := domain.Validate(data); err != nil {
return fmt.Errorf("validate analytics dataset: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, name := range []string{"postings", "category_ancestors", "transaction_tags", "transactions", "merchants", "tags", "categories", "accounts"} {
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+name); err != nil {
return fmt.Errorf("reset analytics: %w", err)
}
}
for _, statement := range schema {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("create analytics schema: %w", err)
}
}
// Prepared statements prevent repeated SQL parsing and keep every dataset
// field, including registry IDs, out of SQL source text.
inserts := []string{
"INSERT INTO accounts VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO categories VALUES (?, ?, ?, ?)",
"INSERT INTO tags VALUES (?, ?)",
"INSERT INTO merchants VALUES (?, ?)",
"INSERT INTO transactions VALUES (?, ?, ?, CAST(? AS DATE), CAST(NULLIF(?, '') AS DATE), CAST(? AS DECIMAL(24,4)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO transaction_tags VALUES (?, ?)",
"INSERT INTO category_ancestors VALUES (?, ?, ?)",
}
statements := make([]*sql.Stmt, 0, len(inserts))
defer func() {
for _, stmt := range statements {
stmt.Close()
}
}()
for _, query := range inserts {
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
return err
}
statements = append(statements, stmt)
}
exec := func(index int, args ...any) error {
_, err := statements[index].ExecContext(ctx, args...)
if err != nil {
return fmt.Errorf("populate analytics: %w", err)
}
return nil
}
for _, a := range data.Accounts {
if err := exec(0, a.ID, a.DisplayName, a.Institution, a.Currency, a.ExternalAccountID, a.IBAN, a.Active); err != nil {
return err
}
}
parents := make(map[string]string, len(data.Categories))
for _, c := range data.Categories {
if err := exec(1, c.ID, c.Name, c.ParentID, c.Kind); err != nil {
return err
}
parents[c.ID] = c.ParentID
}
for _, t := range data.Tags {
if err := exec(2, t.ID, t.Name); err != nil {
return err
}
}
for _, m := range data.Merchants {
if err := exec(3, m.ID, m.Name); err != nil {
return err
}
}
for _, c := range data.Categories {
ancestor := c.ID
for depth := 0; ancestor != ""; depth++ {
if depth >= len(data.Categories) {
return fmt.Errorf("category ancestry cycle at %q", c.ID)
}
if err := exec(6, c.ID, ancestor, depth); err != nil {
return err
}
ancestor = parents[ancestor]
}
}
for _, t := range data.Transactions {
f, e := t.Facts, t.Enrichment
if err := exec(4, f.ID, f.Source, f.AccountID, f.BookingDate, f.ValueDate, string(f.Amount), f.Currency, f.RawDescription, f.ExternalID, f.Fingerprint, f.Counterparty, f.CounterpartyIBAN, e.Kind, e.MerchantID, e.CategoryID, e.TransferPeerID, e.Classification.Source, e.Classification.Model, e.Classification.Timestamp, e.Classification.Error); err != nil {
return err
}
for _, tag := range e.TagIDs {
if err := exec(5, f.ID, tag); err != nil {
return err
}
}
}
// Each bank fact produces a balanced asset/counterpart pair. Own-account
// transfers cancel through one clearing ledger; broker facts cancel through
// another, where the residue left behind is exactly the cash an investment
// account has returned: distributions and interest received, less fees.
if _, err := tx.ExecContext(ctx, `INSERT INTO postings
SELECT id, 1, 'asset:' || account_id, account_id, '', currency, amount FROM transactions
UNION ALL
SELECT id, 2, CASE kind WHEN 'transfer' THEN 'clearing:transfers' WHEN 'investment' THEN 'clearing:investments' ELSE 'category:' || category_id END,
'', CASE WHEN kind IN ('transfer', 'investment') THEN '' ELSE category_id END, currency, -amount FROM transactions`); err != nil {
return fmt.Errorf("derive postings: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit analytics rebuild: %w", err)
}
return nil
}
func (f Filter) validate() error {
for _, item := range []struct{ name, value string }{{"from", f.From}, {"to", f.To}} {
if item.value != "" {
if _, err := time.Parse(time.DateOnly, item.value); err != nil {
return fmt.Errorf("%s must be YYYY-MM-DD", item.name)
}
}
}
if f.From != "" && f.To != "" && f.From > f.To {
return fmt.Errorf("from must not be after to")
}
return nil
}
// where uses EXISTS for many-to-many filters so a transaction carrying several
// selected tags, or several matching ancestors, can never multiply totals.
// Transfers and broker facts are excluded: moving your own money between your
// own cash and your own positions is neither spending nor income, and a broker
// history is large enough to swamp everything else if it leaked in.
func (f Filter) where() (string, []any) {
clauses := []string{"t.kind NOT IN ('transfer', 'investment')"}
args := []any{}
add := func(clause string, value string) {
if value != "" {
clauses = append(clauses, clause)
args = append(args, value)
}
}
add("t.booking_date >= CAST(? AS DATE)", f.From)
add("t.booking_date <= CAST(? AS DATE)", f.To)
add("t.currency = ?", f.Currency)
add("t.account_id = ?", f.AccountID)
add("t.merchant_id = ?", f.MerchantID)
add("EXISTS (SELECT 1 FROM category_ancestors ca WHERE ca.category_id = t.category_id AND ca.ancestor_id = ?)", f.CategoryID)
if f.TagID != "" {
ids := strings.Split(f.TagID, ",")
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
placeholders = append(placeholders, "?")
args = append(args, id)
}
}
if len(placeholders) == 0 {
clauses = append(clauses, "FALSE")
} else {
clauses = append(clauses, "EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
}
}
return strings.Join(clauses, " AND "), args
}