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.
Selecting a rule now shows the transactions it would alter, so a subset can be
ticked and applied rather than trusting a bulk run. The apply step takes explicit
transaction ids (the existing bulk apply_rule path), so what you tick is exactly
what changes - a rule whose conditions are too broad cannot reach further than
the preview showed.
Matches are split into 'would change' and 'already correct'. A merchant
normalisation rule matching 400 rows where 380 already hold the right value is 20
changes and 380 rows of noise; only the 20 are listed.
Preview is offered for every rule including manual_only quick actions, which
previously had no way to see their reach at all. A rule with no conditions
matches every transaction - that is how apply already behaves, so the preview
reports it prominently rather than hiding it.
Applies still snapshot to rule_apply_runs, so they remain revertable.
A git add -A in 030490e swept the Wise CSV exports into the repo and they were
pushed. They contain account numbers, an IBAN, payer and payee names and full
transaction detail.
This removes them from HEAD and ignores the directory. It does NOT purge them
from history — 030490e still contains them.
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.
The split-adjusted spend expression assumed a transaction with no split row for
me was entirely mine. That is wrong when a transaction is allocated fully to
someone else: I paid, they owe all of it, and there is no row for me to match.
Both branches of the CASE missed and the ELSE charged me the full amount.
24 transactions were affected, all travel bookings between 2026-01-09 and
2026-06-26, overstating my spend by $8,579.07 across every analytics view.
Adds myShare/mySplitOf to analytics-sql.ts, which fall back to
100 - (sum of everyone else's shares) instead of 100, and applies them to all
five analytics routes. Centralised for the same reason as EXCLUDE_NON_SPEND:
the expression was duplicated five times and had already drifted.
The transactions page rendered the statement's native amount through a
formatter hardcoded to AUD, so a USD row displayed its USD figure labelled as
dollars while every analytics query counted the converted amount_aud. Same
transaction, two different numbers depending on the page.
getTransactions now returns the statement currency, the amount column shows
amount_aud with the native figure beneath it when the two differ, and the split
and duplicate modals seed from the converted amount (a duplicate becomes a
manual AUD row, so the native figure would be wrong there).
Settlement balances had the same split: getParticipantBalances and the
per-participant balance route summed raw amount while trip totals summed
amount_aud, so a shared foreign expense would net a USD figure against AUD
ones. All three now agree on amount_aud. No change to current balances - every
statement in the database is AUD today - but correct once Wise data lands.
Regression from the analytics scoping work. Chain:
1. Three transactions have a NULL category. The old WHERE clause
`COALESCE(o.category_override, t.category) NOT IN ('transfers','investment')`
evaluated to NULL for those rows, so they were silently dropped — which is
the bug EXCLUDE_NON_SPEND fixed by defaulting to 'other'.
2. Including them exposed that the SELECT still used the non-null-safe
COALESCE, so MODE() returned NULL for the affected merchant
("Shared expenses carryover (SplitMyExpenses)", a manual transaction that
the LEFT JOIN fix also newly included).
3. formatCategory(null) threw "Cannot read properties of null (reading
'split')" and took down the whole page render.
Fixed at both layers:
- Both merchants routes now use the EFFECTIVE_CATEGORY fragment, so the API
cannot emit a null category.
- formatCategory tolerates null/undefined and returns "Uncategorised". An
uncategorised transaction should never be able to crash a page.
Verified against the live DB: 200 merchants, zero null categories, and
/merchants /transactions /insights /budget /statements all render 200.
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.
App-wide retheme done at the token layer: Tailwind's zinc scale is remapped
to warm ink/paper neutrals and indigo to copper in globals.css, so every
page inherits the palette. Fraunces (serif display) added for page titles
and hero figures; all figures now render in mono with tabular numerals.
Analytics page redesigned around a month spine — twelve clickable columns
scaled to each month's spend that act as hero, context, and period
navigation. Adds a 'What changed' top-movers panel vs the previous month,
replaces the 8-line category trend chart with per-category sparkline small
multiples, heat-tints the six-month ledger table, and restyles the Pareto,
pace chart, and cashflow strip. Kept: Pareto, cumulative-vs-typical pace,
drill-downs, regular/occasional split.
Insights and Merchants restyled to the same kit; chart tokens centralised
in category-colors.ts (CHART). Cleared the pre-existing lint errors in
insights (typed tooltip, removed any-casts).
- 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).
Ten routes accepted requests with no getCurrentUser check (transactions/[id],
bulk, splits, tags-on-tx, splits/settle, statements/[id], tags, tags/[id],
merchants, participants/[id]/balance), and by-id routes did no ownership
check at all — any participant could read or modify another's data.
Adds canAccessTransactions() (owner via statement/direct, or split
participant), applies it to every transaction-scoped route, owner-scopes
statements/[id], and rescopes splits/settle in raw SQL so settlement only
touches splits the caller is party to.
Also: all trip analytics now sum COALESCE(amount_aud, amount) instead of raw
amount, matching every other analytics query — trip totals previously added
foreign-currency amounts to AUD ones unit-less.
And rules apply_split no longer delete+reinserts splits (which reset settled
flags on every run) — it upserts share_percent and removes only participants
no longer in the rule.
Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
Move splits, tags and overrides from manual to statement side on reconcile
(delete from manual after copying) instead of just copying. Add read-time
filter to exclude reconciled manual transactions from balance and shared
transaction queries. Also adds participant filter to shared expenses page.
- 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
Add missing models: statements, transactions, expense_metadata, rule_apply_runs.
Fixes pre-existing type errors on split_payments and my_share_percent which
were caused by a stale generated client (regenerate with npx prisma generate).
- Show created_at as "Imported" column in transactions and shared views
- For reconciled transactions, show original CSV import date (not statement processing date) via LEFT JOIN on reconciled_with_id
- Add has_split filter (all/split only/unsplit only) to transactions page
- Transactions table: sortable by imported date; split filter dropdown
- Shared table: client-side sort by date, imported, and amount
- 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
- Extract evaluateCondition + rule types into src/lib/rules.ts for testability
- 48 unit tests for evaluateCondition (all fields/operators) and formatCategory
- 21 integration tests for getTransactions filters and getParticipantBalances
- Vitest configs for unit (vitest.config.ts) and integration (vitest.integration.config.ts)
- setup-test-db.sh creates personal_test DB from production schema via pg_dump
- Use vi.doMock + dynamic import pattern to isolate test DB from Prisma singleton
Participant id=1 was named "Me" in the DB, causing Sonu and other users
to see "Me" referring to Siddharth when viewing splits and shared expenses.
- Rename participant id=1 from "Me" to "Siddharth" in the DB
- /api/participants now substitutes "Me" for whichever participant matches
the current user, so the label is always relative to the viewer
- split-modal: default split uses currentUser.id instead of name === "Me"
- transactions/page: filter and display logic uses participant ID not name
- shared/page: split chips show "Me" when participant_id === current user
Also includes add-transaction-modal tags support (pre-existing staged change).
- Sidebar: hidden on mobile, opens as slide-out drawer with hamburger
toggle; auto-closes on navigation; desktop layout unchanged
- Layout: responsive padding accounting for mobile header bar
- Rules: add tag as a condition field (has/not-has tag)
- Rules: apply a single rule via per-rule Apply button
- Rules: splits-from defaults to 2026-01-09
- 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
- Query bar parses >500, >=500, <500, <=500, 500-1500 into amount_min/max filters
- Parsed tokens shown as dismissable chips below the query bar
- Category, Bank, Tag, Type filters upgraded from single-select to multi-select
- MultiSelect dropdown component with checkbox list and active-state border
- Backend: TransactionFilters uses string[] for categories/bank_names/tag_ids/transaction_types
- SQL: ANY($n::text[]) / ANY($n::int[]) for array filters
- Monthly spend chart with category breakdown drill-down
- Merchant frequency and spend analytics with per-merchant history
- Subscription detection and recurring charge tracking
- Fee and interest analytics endpoint
- Expanded category list with formatCategory display helper
- POST /api/rules/apply — run all enabled rules against unmatched transactions
- POST /api/rules/apply/:id — apply a single rule by id
- DELETE /api/rules/apply/:id — revert a rule run (remove applied splits)
- Rules page: show run history with revert button, apply individual rules
- Rewrite participant balance to UNION both directions (they owe me + I owe them)
- Credits/refunds subtract from owed amount for correct net balance
- Allow secondary users to see transactions split with them
- Add participant balance cards with colour-coded owe direction
- Add inline AddParticipantForm with name + optional email
- 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
- Checkbox in split modal: 'Also save as rule for <merchant>'
- Creates a rule with apply_split action storing the participant shares
- Rules engine now handles apply_split: deletes existing splits and re-applies
- Bulk split mode hides the checkbox (rule wouldn't make sense for ad-hoc bulk)
After changing a merchant name or category inline, a toast-style prompt
appears offering to save it as a rule. Shows a preview of the condition
and action before saving. Dismissable without creating a rule.
- Merchant totals now show net spend (gross debits minus refunds)
- Refund count and amount shown in profile drawer and table
- Scatter plot Y-axis uses net_spend, X-axis uses debit_count
- Per-merchant transaction history includes refunds (shown as negative)
- Monthly trend chart reflects net spend per month
- TypeBadge is now clickable — opens inline select to change debit/credit/fee/interest/etc.
- PATCH /api/transactions/[id] now accepts transaction_type, updates transactions table directly
- Analytics monthly query includes fee and interest types as spend (not just debit)
- fee and interest amounts show red in transaction list (same as debit)
- Add fees category to taxonomy
- Add owner <select> dropdown per row using useUpdateStatement + useParticipants
- Detect CC by statement_type.includes('card') instead of credit_limit/payment_due_date
(Wise multi-currency account had payment_due_date set but is not a CC)
- Amount: remove 'due'/'balance' label; color green for positive bank balances, red for CC/overdraft
- Add statement_type to StatementRow type
- Statements page: replace card grid with compact table showing bank,
account, period, due date, currency, amount (due for CC / balance for
bank), transaction count, and View button
- Transactions page: wrap in Suspense, read statement_id from URL search
params on load; show a dismissible indigo banner with bank name and
billing period when filtering by statement; × Clear filter removes it
- Replace grouped cashflow BarChart with ComposedChart: expense categories
as colour-coded stacked bars + amber savings-rate % line on right Y-axis
- Add category colour legend below chart (matches stacked bars)
- Horizontal category bar chart now uses per-category colours
- Breakdown table: click any category row to expand/collapse individual
transactions; each transaction has an inline category dropdown that
calls PATCH /api/transactions/:id → transaction_overrides, then
invalidates analytics query so totals update immediately
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