Every warning in it was inverted by this week's work:
- "settled is dead data, false on every row" -- there are now 1,266 settled
splits across 657 pre-2026 transactions.
- "do not fix getParticipantBalances to exclude settled splits" -- it now
excludes them, via ACTIVE_OBLIGATION, and must.
- "settlement cannot be attributed per trip" -- migration 0022 added
split_payments.trip_id and it is attributed.
- "splits exist from 2026-01-09 only" -- pre-2026 transactions are now split
deliberately, to stop them inflating spend.
Replaced with what is actually true, including the rule that matters most: the
cutover DATE is the primary balance gate and the settled flag only refines it,
so pre-2026 expenses can be split freely.
One-off analysis, nothing built. Realistic baseline $4,140/mo -> $24,800 for
six months, against $89,770 already accessible ($81,017 loan redraw + $8,753
offset).
Records four corrections the raw data needs before any restatement:
misfiled Raiz/Vanguard/moomoo debits counted as spend, `other` credits read as
negative spend, `government` conflating ATO with rates/rego, and `fees` being
mostly annual.
CLAUDE.md gains two traps found while doing it: partial split coverage inside a
category is usually correct rather than a gap (only shared utilities and
subscriptions are split), and the loan repayment is voluntarily above contracted
($2,500 vs $1,190.54 per fortnight) with the difference recoverable via redraw.
CLAUDE.md gains the traps a new session would otherwise re-discover:
- Rules: a zero-condition rule matches everything (rule 43 would split all ~3,700
transactions); preview-then-apply-by-id is the safe pattern and why it beats
auto-applying on ingestion; how run provenance works.
- Shared expenses: transaction_splits.settled is dead data; getParticipantBalances
is correct and must not be 'fixed'; settlement cannot be attributed per trip.
- The shared loan: separate ledger, fixed 50% with a tracked receivable, why the
share must not be derived from actual payments, and why interest stays as spend.
- Extraction: balance assertions are the check that works, do not derive
opening_balance or add a totals assertion (both would be tautological), Gemini
invents summary fields it was not given, empty statements must not throw, FX is
per-date, and CSV comparisons need millisecond ordering.
The design doc records Phase 0 as done - including that the original Phase 0 plan
was wrong, since reading the code first is what prevented breaking a working
balance page.
Known Gaps lists what is open: the unbuilt phases, 11 failing assertions, the
uncategorised Up rows, and the CSVs sitting in 030490e's history.
getPendingReconciliations treated every unreconciled manual transaction as
awaiting a matching statement row. Cash never appears on a statement, so a cash
entry sat in the queue indefinitely being offered matches within 3 days and 1%
on amount - and accepting one is silently destructive: reconciled manual rows
are filtered out of every query, so the cash spend disappears while the card
transaction it matched claims to be that same spend.
Migration 0016 adds transactions.payment_method (card | cash | bank_transfer |
other, NULL = unknown) with a CHECK constraint and a partial index. The notCash()
fragment excludes cash from both halves of the reconciliation query - the pending
list and the candidate match subquery, which aliases the manual row as m.
Only cash is excluded. Bank transfers do appear on a statement now that
transaction accounts are imported, and NULL means unknown, so both stay
candidates and every pre-existing row behaves exactly as before.
ATM withdrawals deliberately stay categorised as spend rather than transfers.
Treating them as transfers is only correct if every cash purchase is logged;
with partial logging it silently deletes the unlogged remainder from spend.
A loan repayment is not an expense. A $3,000 mortgage repayment is roughly
$1,200 of principal (equity — a balance-sheet move) and $1,800 of interest (the
only part that is genuinely spend).
Migration 0014:
- transactions.principal_amount / interest_amount, populated only when the lender
itemises the split on the repayment row
- statements.interest_rate, scheduled_repayment, repayment_frequency,
redraw_available, loan_term_months
- normalize_repayment_frequency() + trigger, so "Fortnightly", "Bi-Weekly" and
"Every 2 weeks" all land on 'fortnightly'
Two statement shapes are handled. Where the loan statement lists repayments and
"Interest Charged" as separate rows (the common Australian case), transaction_type
already does the work. Where a lender itemises the split on the repayment row,
that row is typed 'payment' and would be skipped entirely — losing the interest.
New SPEND_ROWS / SPEND_BASE fragments in analytics-sql.ts count such rows at
interest_amount instead of amount.
Adds the loan_interest category (+ colour, and the missing fees colour).
Verified against the live DB with a synthetic ANZ home loan statement: a $3,000
itemised repayment plus a $10 service fee moved April spend by exactly $1,810,
with the $1,200 principal excluded and still retained on the row. Test data
removed and the figure confirmed back at its original value.
Groundwork for importing bank and loan statements alongside credit cards.
statement_type was whatever free text Gemini put in account_type ('Credit Card',
'credit card', 'credit_card', 'Business Card', 'ACCESS ADVANTAGE',
'multi-currency account'). The UI coped only by doing .includes("card"), which
breaks as soon as bank and loan statements arrive.
- Migration 0013 adds normalize_statement_type() + a BEFORE INSERT/UPDATE
trigger and a CHECK constraint over credit_card|transaction|savings|loan|
offset|investment|other. The trigger means the N8N workflow keeps working
unchanged while it still sends free text. Raw value stays in account_type.
Backfilled 99 existing rows.
- src/lib/statement-types.ts mirrors the vocabulary for the UI; statements page
now filters by the real types and headlines balance vs amount due per type.
Analytics were scoped with INNER JOIN statements + s.owner_id, which silently
dropped all 180 manual/CSV transactions (statement_id IS NULL) from every
report. Switched all six routes to LEFT JOIN + COALESCE(t.owner_id, s.owner_id)
via shared fragments in src/lib/analytics-sql.ts, so the transfers/investment
exclusion that stops card-payment double counting stays consistent. Also
extended that exclusion to trip analytics, which had none.
Drive-by: /api/analytics/subscriptions was returning 500 on an unserialisable
BigInt from COUNT(*) + 1.
Verified against the live DB: monthly spend picks up the previously invisible
manual transactions (Apr 9,849.91 -> 14,286.70) and all four analytics
endpoints return 200.
- prisma/migrations/0011_trips: trips table, trip_id on overrides, partial
index — idempotent; matches DDL already applied to prod. Applied to
personal_test (integration suite was failing on missing trips relation).
- README/CLAUDE.md: deployment is now push-to-deploy via Komodo
(deploy-finance Procedure, Gitea webhook); compose command is fallback.
- README: migrations table completed through 0011; note that the container
must only be reachable via Traefik (header-trust auth).
- Add reconciled_with_id and created_at columns to transactions table docs
- Document split_payments, expense_metadata, rule_apply_runs tables
- Update /api/transactions route docs with has_split filter and all sort options
- Add /api/transactions/reconcile and /api/split-payments to API table
- Document import date (created_at) behaviour and reconciliation caveat
- Add Prisma regeneration note to CLAUDE.md
- Note schema drift for tables added without migration files