Files
finance-app/src/lib/analytics-sql.ts
T
siddharthd d6b4ec84f6
ci / lint-test (push) Successful in 39s
feat(integrity): balance assertions, category constraint, refund netting
Four related fixes to spend correctness.

Balance assertions. Nothing checked that a statement's transactions add up to
its closing balance. getStatements now computes opening + movement - closing
and the statements page flags any statement that does not reconcile. Sign
depends on what the balance means: on a credit card or loan it is what you owe,
so spending increases it; on a transaction or offset account it is what you
hold. 11 statements currently fail, $4,177 unexplained - including two adjacent
ANZ statements off by exactly +/-$230.38, a transaction filed against the wrong
one.

Category normalisation (migration 0015). Categories arrive from Gemini (which
writes straight to Postgres from N8N), CSV import and manual edits, so the rule
belongs in the database - same reasoning as normalize_statement_type in 0013.
Adds normalize_category(), triggers on transactions and transaction_overrides,
and CHECK constraints. Backfilled 122 rows: 19 'payment' ($42,569) to transfers,
14 'refund' recovered to the merchant's usual category, and title-case duplicates
folded into their canonical spelling - 'Shopping' and 'shopping' had been
counted as separate categories by every GROUP BY.

Refund netting. Monthly analytics counted only debits, so a refund was counted
nowhere: excluded from spend by type, and not income by category. A $2,888.92
Expedia purchase refunded in full eight days later still read as $2,888.92 of
spend. SPEND_SIGNED and NET_SPEND_ROWS bring refunds in as negatives; 'income'
joins the excluded categories so incoming money cannot leak in as negative
spend. $36,773 across 93 rows now nets correctly.

Drill-down share. The insights drill-down used my_share_percent ?? 100, which
ignored transaction_splits entirely and repeated the bug fixed in ab00f8c one
layer up. getTransactions now returns my_share_pct and my_amount resolved
server-side, and the table shows gross alongside your share.
2026-07-26 10:35:59 +10:00

100 lines
4.3 KiB
TypeScript

// Shared SQL fragments for analytics queries, so spend/income semantics stay
// identical across routes.
//
// Two rules every analytics query must follow:
//
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
// NULL) — which is most of the reconciliation and cash-spend data.
// 2. Exclude `transfers` and `investment` from spend. Once bank statements are
// imported, a credit-card payment appears twice: once as a debit leaving the
// bank account and again as the underlying purchases on the card statement.
// Categorising the money movement as `transfers` and excluding it here is what
// stops the double count. Investments are a balance-sheet move, not spend.
/** Owner scoping that works for both statement-linked and manual transactions. */
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
/** Join clause to pair with OWNER_SCOPE. */
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
/** Transaction types that represent money going out. */
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
/**
* Rows that count towards spend.
*
* The `interest_amount IS NOT NULL` arm is for loan repayments: when a lender
* itemises principal and interest on a single repayment row, that row is usually
* typed 'payment' (money reducing the loan balance) and would otherwise be
* skipped — but its interest portion is real spend.
*/
export const SPEND_ROWS = `(t.transaction_type IN ('debit', 'fee', 'interest') OR t.interest_amount IS NOT NULL)`;
/**
* The amount of a row that counts as spend, before split adjustment.
*
* For an itemised loan repayment only the interest portion is an expense; the
* principal builds equity and is a balance-sheet move, not spending.
*/
export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END`;
/** Effective category, honouring overrides. Never NULL. */
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
/**
* My percentage share of a transaction, 0-100.
*
* Resolution order:
* 1. An explicit `transaction_splits` row for me.
* 2. The `my_share_percent` override.
* 3. Whatever is left after everyone else's shares.
*
* Step 3 is the one that matters. Assuming 100% when no split row exists for me
* is wrong whenever a transaction is allocated entirely to someone else — I paid,
* they owe all of it, and there is no row for me to find. Those rows would
* otherwise land in my spend at full value.
*
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`
* and `transaction_overrides o` joined on the transaction.
*/
export const myShare = (participant = "$1") => `COALESCE(
ts.share_percent,
o.my_share_percent,
100 - COALESCE((
SELECT SUM(x.share_percent) FROM transaction_splits x
WHERE x.transaction_id = t.id AND x.participant_id <> ${participant}
), 0)
)`;
/** `base` scaled to my share. Use for every per-user spend total. */
export const mySplitOf = (base: string, participant = "$1") =>
`((${base}) * ${myShare(participant)} / 100)`;
/**
* Predicate excluding categories that are not spend.
* The COALESCE matters: a bare `category NOT IN (...)` evaluates to NULL for
* uncategorised rows, which silently drops them from spend totals.
*/
export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment', 'income')`;
/**
* Rows that count towards NET spend — outgoings plus the refunds that cancel
* them. Pair with SPEND_SIGNED, which carries the direction.
*/
export const NET_SPEND_ROWS = `(${SPEND_ROWS} OR t.transaction_type IN ('refund', 'credit'))`;
/**
* SPEND_BASE, signed: refunds and credits come back as negatives so they cancel
* the original purchase.
*
* Without this a refund is counted nowhere. It is excluded from spend by type
* and only counted as income if categorised 'income', which a refund is not —
* so it falls through both and never reduces anything. A $2,888.92 Expedia
* purchase refunded in full eight days later still read as $2,888.92 of spend.
*/
export const SPEND_SIGNED = `CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
ELSE (${SPEND_BASE})
END`;