feat(integrity): balance assertions, category constraint, refund netting
ci / lint-test (push) Successful in 39s

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.
This commit is contained in:
2026-07-26 10:35:59 +10:00
parent ab00f8c592
commit d6b4ec84f6
10 changed files with 607 additions and 10 deletions
+22 -2
View File
@@ -72,8 +72,28 @@ export const mySplitOf = (base: string, participant = "$1") =>
`((${base}) * ${myShare(participant)} / 100)`;
/**
* Predicate excluding money-movement categories.
* 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')`;
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`;
+56 -1
View File
@@ -32,6 +32,9 @@ export interface TransactionRow {
my_share_percent: number | null;
effective_category: string;
effective_merchant: string;
// My share of this transaction, resolved server-side so the UI matches analytics.
my_share_pct: number;
my_amount: number;
// statement context (null for manual transactions)
bank_name: string;
// Native currency of the statement this row came from ('AUD' for manual rows).
@@ -81,6 +84,10 @@ export interface StatementRow {
owner_name: string;
created_at: string;
transaction_count: number;
// Balance assertion (see BALANCE_DELTA). Null when the statement has no
// opening/closing balance to check against.
expected_closing: number | null;
balance_diff: number | null;
}
interface TransactionFilters {
@@ -202,6 +209,24 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(s.currency, 'AUD') as currency,
-- My share, resolved the same way analytics does it (see myShare in
-- analytics-sql.ts): explicit split row, then override, then whatever is
-- left after everyone else. Computed here so the UI cannot drift from
-- the totals it is drilling into.
COALESCE(
(SELECT x.share_percent FROM transaction_splits x
WHERE x.transaction_id = t.id AND x.participant_id = $1),
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 <> $1), 0)
)::numeric(5,2) as my_share_pct,
(COALESCE(t.amount_aud, t.amount) * COALESCE(
(SELECT x.share_percent FROM transaction_splits x
WHERE x.transaction_id = t.id AND x.participant_id = $1),
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 <> $1), 0)
) / 100)::numeric(12,2) as my_amount,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at,
@@ -279,13 +304,43 @@ export async function getTransactionById(id: number) {
return rows[0] || null;
}
/**
* Does opening_balance + the period's transactions equal closing_balance?
*
* The single cheapest check on extraction quality: it catches missed rows,
* duplicates, sign errors and transactions filed against the wrong statement,
* none of which are visible by eye. Borrowed from double-entry accounting,
* where it is called a balance assertion.
*
* Sign depends on what the balance means. On a liability (credit card, loan)
* the balance is what you OWE, so spending increases it and payments reduce it.
* On an asset (transaction, savings, offset) the balance is what you HOLD, so
* the signs invert.
*/
export const BALANCE_DELTA = `SUM(CASE
WHEN s.statement_type IN ('credit_card', 'loan')
THEN CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END
ELSE CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN -t.amount ELSE t.amount END
END)`;
export async function getStatements(ownerId: number) {
const sql = `
SELECT s.*,
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
p.name as owner_name
p.name as owner_name,
recon.expected_closing,
recon.balance_diff
FROM statements s
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN LATERAL (
SELECT
(s.opening_balance + ${BALANCE_DELTA})::numeric(12,2) as expected_closing,
(s.opening_balance + ${BALANCE_DELTA} - s.closing_balance)::numeric(12,2) as balance_diff
FROM transactions t
WHERE t.statement_id = s.id
AND s.opening_balance IS NOT NULL
AND s.closing_balance IS NOT NULL
) recon ON true
WHERE s.owner_id = $1
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
`;