package analytics import ( "context" "database/sql" "fmt" "time" ) // filteredPrefix opens the common table expression every group query reads // from; the closing parenthesis is supplied with the WHERE clause. const filteredPrefix = "WITH filtered AS (SELECT t.* FROM transactions t WHERE " // categoryGroups runs twice, once per compared interval, so a period-over-period // delta sees exactly the same ancestor rollup on both sides. const categoryGroups = `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency` func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) { empty := Dashboard{ Totals: []Total{}, Previous: []Total{}, Monthly: []MonthlyPoint{}, Categories: []Group{}, PreviousCategories: []Group{}, Tags: []Group{}, Merchants: []Group{}, Accounts: []Group{}, Recurring: []Group{}, Largest: []Group{}, } if err := filter.validate(); err != nil { return empty, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return empty, err } defer tx.Rollback() result := empty if result.Totals, err = queryTotals(ctx, tx, filter); err != nil { return empty, err } previous, ok, err := previousFilter(ctx, tx, filter) if err != nil { return empty, err } if ok { if result.Previous, err = queryTotals(ctx, tx, previous); err != nil { return empty, err } where, args := previous.where() if result.PreviousCategories, err = queryGroups(ctx, tx, filteredPrefix+where+") "+categoryGroups, args); err != nil { return empty, fmt.Errorf("query previous categories: %w", err) } } where, args := filter.where() prefix := filteredPrefix + where + ") " if result.Monthly, err = queryMonthly(ctx, tx, prefix, args); err != nil { return empty, err } queries := []struct { output *[]Group query string }{ {&result.Categories, categoryGroups}, {&result.Tags, `SELECT tag.id, tag.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) FROM filtered t JOIN transaction_tags tt ON tt.transaction_id = t.id JOIN tags tag ON tag.id = tt.tag_id GROUP BY tag.id, tag.name, t.currency ORDER BY tag.id, t.currency`}, {&result.Merchants, `SELECT t.merchant_id, COALESCE(m.name, 'No merchant'), t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id GROUP BY t.merchant_id, m.name, t.currency ORDER BY t.merchant_id, t.currency`}, {&result.Accounts, `SELECT a.id, a.display_name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) FROM filtered t JOIN accounts a ON a.id = t.account_id GROUP BY a.id, a.display_name, t.currency ORDER BY a.id, t.currency`}, {&result.Recurring, `, spaced AS ( SELECT *, date_diff('day', LAG(booking_date) OVER ( PARTITION BY merchant_id, account_id, currency, amount ORDER BY booking_date, id), booking_date) AS gap FROM filtered WHERE amount < 0 AND merchant_id <> '' ), candidates AS ( SELECT merchant_id, account_id, currency, amount, SUM(amount) AS total, COUNT(*) AS occurrences, CASE WHEN MIN(gap) >= 5 AND MAX(gap) <= 9 THEN 'weekly' WHEN MIN(gap) >= 26 AND MAX(gap) <= 35 THEN 'monthly' WHEN MIN(gap) >= 350 AND MAX(gap) <= 380 THEN 'yearly' ELSE '' END AS cadence FROM spaced GROUP BY merchant_id, account_id, currency, amount HAVING COUNT(*) >= 3 ) SELECT c.merchant_id || ':' || c.account_id || ':' || CAST(c.amount AS VARCHAR), m.name, c.currency, c.cadence, CAST(c.total AS VARCHAR), c.occurrences FROM candidates c JOIN merchants m ON m.id = c.merchant_id WHERE c.cadence <> '' ORDER BY 1, 3`}, // One row per payee: a rent paid on time every month is six identical // rows that explain nothing, so only a merchant's single biggest payment // competes. Ranked per currency rather than by a plain LIMIT, so one // busy currency cannot crowd another out of its own list. Both windows // order by the decimal column, never by its VARCHAR rendering. {&result.Largest, `, payments AS ( SELECT t.id, CASE WHEN COALESCE(m.name, '') <> '' THEN m.name ELSE t.raw_description END AS label, t.currency, CAST(t.booking_date AS VARCHAR) AS day, t.amount AS value, ROW_NUMBER() OVER (PARTITION BY t.currency, CASE WHEN t.merchant_id <> '' THEN 'm:' || t.merchant_id ELSE 'x:' || t.id END ORDER BY t.amount, t.id) AS repeats FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id WHERE t.amount < 0 ), ranked AS ( SELECT id, label, currency, day, value, ROW_NUMBER() OVER (PARTITION BY currency ORDER BY value, id) AS position FROM payments WHERE repeats = 1 ) SELECT id, label, currency, day, CAST(value AS VARCHAR), CAST(1 AS BIGINT) FROM ranked WHERE position <= 8 ORDER BY currency, position`}, } for _, item := range queries { groups, err := queryGroups(ctx, tx, prefix+item.query, args) if err != nil { return empty, fmt.Errorf("query analytics groups: %w", err) } *item.output = groups } if err := tx.Commit(); err != nil { return empty, err } return result, nil } // queryMonthly returns one row per month and currency. Months with no activity // are absent: the caller knows the requested window and fills the gaps. func queryMonthly(ctx context.Context, tx *sql.Tx, prefix string, args []any) ([]MonthlyPoint, error) { rows, err := tx.QueryContext(ctx, prefix+`SELECT strftime(booking_date, '%Y-%m'), currency, CAST(SUM(CASE WHEN amount > 0 THEN amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR), CAST(SUM(CASE WHEN amount < 0 THEN -amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR), CAST(SUM(amount) AS VARCHAR), COUNT(*) FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY currency, 1`, args...) if err != nil { return nil, fmt.Errorf("query analytics months: %w", err) } defer rows.Close() result := []MonthlyPoint{} for rows.Next() { var point MonthlyPoint if err := rows.Scan(&point.Period, &point.Currency, &point.Income, &point.Expenses, &point.Net, &point.Count); err != nil { return nil, err } result = append(result, point) } return result, rows.Err() } func queryTotals(ctx context.Context, tx *sql.Tx, filter Filter) ([]Total, error) { where, args := filter.where() rows, err := tx.QueryContext(ctx, `SELECT t.currency, CAST(SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR), CAST(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR), CAST(SUM(t.amount) AS VARCHAR) FROM transactions t WHERE `+where+` GROUP BY t.currency ORDER BY t.currency`, args...) if err != nil { return nil, fmt.Errorf("query analytics totals: %w", err) } defer rows.Close() result := []Total{} for rows.Next() { var total Total if err := rows.Scan(&total.Currency, &total.Expenses, &total.Income, &total.Net); err != nil { return nil, err } result = append(result, total) } return result, rows.Err() } func queryGroups(ctx context.Context, tx *sql.Tx, query string, args []any) ([]Group, error) { rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() result := []Group{} for rows.Next() { var group Group if err := rows.Scan(&group.ID, &group.Name, &group.Currency, &group.Period, &group.Amount, &group.Count); err != nil { return nil, err } result = append(result, group) } return result, rows.Err() } // Previous is the immediately preceding inclusive interval of equal length. // For one-sided filters, the missing boundary comes from the matching dataset, // not the wall clock. All-time queries intentionally have no previous period. func previousFilter(ctx context.Context, tx *sql.Tx, filter Filter) (Filter, bool, error) { if filter.From == "" && filter.To == "" { return filter, false, nil } if filter.From == "" || filter.To == "" { bounds := filter bounds.From, bounds.To = "", "" where, args := bounds.where() var first, last sql.NullString if err := tx.QueryRowContext(ctx, "SELECT CAST(MIN(t.booking_date) AS VARCHAR), CAST(MAX(t.booking_date) AS VARCHAR) FROM transactions t WHERE "+where, args...).Scan(&first, &last); err != nil { return filter, false, err } if !first.Valid || !last.Valid { return filter, false, nil } if filter.From == "" { filter.From = first.String } if filter.To == "" { filter.To = last.String } if filter.From > filter.To { return filter, false, nil } } from, err := time.Parse(time.DateOnly, filter.From) if err != nil { return filter, false, err } to, err := time.Parse(time.DateOnly, filter.To) if err != nil { return filter, false, err } days := int((to.Unix()-from.Unix())/86400) + 1 previousFrom, previousTo := from.AddDate(0, 0, -days), from.AddDate(0, 0, -1) // ISO date filters cannot describe dates before year zero. if previousFrom.Year() < 0 { return filter, false, fmt.Errorf("previous period falls outside supported date range") } filter.From, filter.To = previousFrom.Format(time.DateOnly), previousTo.Format(time.DateOnly) return filter, true, nil }