None of these were caught by 105 green tests, because the code they live in was
barely tested and the HTTP path was not tested at all.
1. reconcilePendingOrders hardcoded category 'dining', so any order resolved
through the deferred path booked as dining regardless of merchant — a
Woolworths grocery order that parks and later reconciles was misfiled.
That reintroduced, through the back door, exactly the misfiling
resolveCategory() exists to prevent. Now calls it.
2. reconcileCardLeg never marked a statement line as consumed, so two orders on
the same card inside the +/-4 day window both bound to the same charge and
each booked its own credits remainder — double-counting spend. At 10-15
orders a month on one card that is not a corner case. Migration 0020 adds
matched_transaction_id with a unique index; the matcher now excludes lines
already claimed.
3. The ingest API returned HTTP 200 for every parse failure, and the Slack
alert fires only on non-200. So the single most likely production failure —
a provider template change breaking every order at once — was completely
silent. Split into NotAReceiptError (promotions, delivery updates, refund
and adjustment notices: 200, silent, expected traffic) and OrderParseError
(it IS a receipt and would not parse: 422, alerts).
Also: order_reference now anchors on Uber's own tripReference cell rather than
'first UUID in the document'. I had claimed to verify that the first UUID was
always the order UUID; that check compared against zero samples and was
vacuous. tripReference is present in all 29 captured receipts and, for ue-00,
equals the UUID the PDF redirect resolves to. The positional fallback remains
but only flags when there is genuine ambiguity.
Adds the API route's first tests — auth gate and error taxonomy — plus
anchoring regressions. 63 unit + 53 integration green on five consecutive runs;
corpus holds at 63/65.
Ingestion now runs on the rebuilt parser. Three substantive changes.
Deferred card reconciliation. A 'MasterCard 8032 and/or credits' receipt never
states the split, but the card leg lands on the statement — Subway's $29.08
order shows $13.06 on 8032, so $16.02 was credits. For a live order that
statement is weeks away, so the split cannot be settled at ingest time. Such
orders are now parked with provenance and no transaction, and
reconcilePendingOrders() resolves them once the statement arrives. Backfill
takes the same path and resolves immediately. Migration 0019 adds the columns
that make an order resumable; applied to personal_test only, prod untouched.
Payment detection bug, found by the new tests: the old regex delimited the
'Paid with' line on a double space, which whitespace collapsing removes. Every
card and mixed receipt fell through to the credits branch — the Woolworths
receipt booked $60.93 of credits spend that never happened.
Category resolution reversed deliberately. Correction 1 said never default to
dining; the implementation of that sent everything unrecognised to 'other', and
knowing six merchants meant Carl's Jr, Taco Bell, Chilli India, Oporto, Schnitz
and Souvlaki GR all landed there. Grocers are an enumerable set and restaurants
are not, so match groceries explicitly and let the residual be dining.
Tests rebuilt on real captured receipts; the synthetic fixtures are deleted.
60 unit + 41 integration green on three consecutive runs.
Apply History listed only counts - '13 matches · 13 transactions' - which reads
identically whether the run renamed a merchant or split every transaction with
another participant. Revert is destructive, so that is not enough to decide on.
Two additions. Migration 0017 records rule_id, rule_name and source on each run:
rule_name is denormalised so history stays readable after a rule is edited or
deleted, and there is no FK so deleting a rule cannot cascade away the audit
trail. Both write paths now populate it - the condition-matched run and the
selection-based quick action.
And rows expand to show the run's snapshot set against current values: which
transactions were touched and what changed on each. Rows changed by something
else since the run are called out, because reverting restores the pre-run value
and would discard that later edit.
Runs recorded before this show 'Unknown rule' - the rule they came from is not
recoverable.
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.
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.
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.
A rule flagged manual_only never runs in the apply-all pass; instead it shows
as a button in the transactions bulk bar and applies its actions to the current
selection (conditions ignored — the selection is the condition). Recorded as a
rule_apply_run, so it reverts from Rules -> Apply History like any other run.
Motivation: tagging Home + splitting 50/50 with Sonu was two bulk actions every
time. Now it is one click, and any other combo can be defined the same way.
Extracts the action-application and snapshot logic from the apply route into
src/lib/rule-actions.ts so both callers share one implementation — splits upsert
rather than delete+reinsert, so settled flags survive.
- 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 column to transactions (links manual → statement tx)
- CSV import wizard: 4-step modal (upload → map columns → review → done)
- Handles any bank format via column mapping with localStorage presets
- Single signed or separate debit/credit column modes
- Editable preview table before committing
- Auto-tags all imported rows with 'csv-import'
- Batch reconcile page: shows all unreconciled manual transactions with
potential statement matches (date ±3 days, amount ±1%) pre-fetched
- Select matches across multiple rows, apply all at once
- Copies overrides/tags/splits from manual → statement tx atomically
- Manual tx marked reconciled (linked), hidden from main transactions view
- Transactions with no matches shown separately
- Import CSV button on transactions page
- Reconcile nav item in sidebar
- New split_payments table records actual payments between participants
- Balance = total split obligations - total payments (splits never marked settled)
- Record Payment modal per participant: direction toggle, amount pre-filled with balance, date, notes
- Payment history inline on each balance card with +/- display and delete
- Per-transaction Settle button removed; Action column removed from shared table
- Splits always show the true cost breakdown regardless of payment state
- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly)
- Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible
- COALESCE(t.owner_id, s.owner_id) throughout for owner resolution
- Add "Manual" bank filter option in getTransactions
- Search extended to include merchant_normalized override
- Split data fetched via lateral subquery on every transaction row
- getParticipantBalances rewritten as UNION for bidirectional net balances
(credits/refunds negate, split from either side of the relationship)
- getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error),
WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn)
- getTransactions: OR EXISTS condition so split participants see shared transactions
- add-transaction-modal component for creating manual transactions with splits
- 0008_my_share_percent migration adds my_share_percent to transaction_overrides
Add rules engine with CRUD API, condition/action evaluation, and apply-all endpoint.
UI: rule builder form with field/operator/value conditions, tag multi-select, apply button with result stats.
- tags table: name, color; transaction_tags junction table
- GET/POST /api/tags; DELETE /api/tags/:id
- POST/DELETE /api/transactions/:id/tags for per-transaction tagging
- Bulk tag/untag via /api/transactions/bulk (action: tag/untag)
- Tags returned inline with transaction list via LATERAL join
- Tag filter on Transactions page
- Bulk "Tag as..." in bulk action bar
- Tag pills + "+" picker on each transaction row
- /tags page: create with color picker, list with counts, delete