init
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
|
||||
empty := Dashboard{
|
||||
Totals: []Total{}, Previous: []Total{}, Monthly: []Group{},
|
||||
Categories: []Group{}, Tags: []Group{}, Merchants: []Group{},
|
||||
Accounts: []Group{}, Recurring: []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 := filter.where()
|
||||
prefix := "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + where + ") "
|
||||
queries := []struct {
|
||||
output *[]Group
|
||||
query string
|
||||
}{
|
||||
{&result.Monthly, `SELECT strftime(booking_date, '%Y-%m'), strftime(booking_date, '%Y-%m'), currency,
|
||||
strftime(booking_date, '%Y-%m'), CAST(SUM(amount) AS VARCHAR), COUNT(*)
|
||||
FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY 4, 3`},
|
||||
{&result.Categories, `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`},
|
||||
{&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`},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Totals []Total `json:"totals"`
|
||||
Previous []Total `json:"previous"`
|
||||
Monthly []Group `json:"monthly"`
|
||||
Categories []Group `json:"categories"`
|
||||
Tags []Group `json:"tags"`
|
||||
Merchants []Group `json:"merchants"`
|
||||
Accounts []Group `json:"accounts"`
|
||||
Recurring []Group `json:"recurring"`
|
||||
}
|
||||
|
||||
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 use one clearing ledger; both sides cancel there when linked.
|
||||
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 WHEN kind = 'transfer' THEN 'clearing:transfers' ELSE 'category:' || category_id END,
|
||||
'', CASE WHEN kind = 'transfer' 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.
|
||||
func (f Filter) where() (string, []any) {
|
||||
clauses := []string{"t.kind <> 'transfer'"}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user