feat(integrity): balance assertions, category constraint, refund netting
ci / lint-test (push) Successful in 39s
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:
+56
-1
@@ -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
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user