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.
21 KiB
CLAUDE.md
Guidance for Claude Code when working in this repository.
Project Overview
Personal finance tracker. Bank statements are ingested via an N8N workflow (in the smarthome repo at docker/automation/workflows/cc-statement-processor-paperless.json) that sends PDFs to Gemini 2.5 Flash for extraction, then inserts into PostgreSQL.
- App: Next.js 16 App Router, TypeScript, Tailwind CSS
- DB: PostgreSQL container
postgres-personal, databasepersonal, userpersonal - Auth:
X-Forwarded-Userheader (email) set by Traefik →participants.email. In dev/fallback: participant id=1 ("Me") - Runs at: port 3000 inside container, exposed on host port 4100, proxied at
https://finance.bosecamp.com
Common Commands
Deployment is push-to-deploy via Komodo (since 2026-07-19): pushing to main on
Gitea triggers the deploy-finance Procedure, which runs DeployStack --build on the
finance stack (files_on_host over docker/finance/ in the smarthome repo). Just
commit and push — no manual deploy needed.
# Manual fallback only (from smarthome repo root), e.g. if Komodo is down
docker compose --env-file docker/common.env --env-file docker/finance/.env \
-f docker/finance/docker-compose.yml up -d --build
# IMPORTANT: docker restart does NOT pick up a new image — push to main (or use the compose command above)
# DB access
docker exec postgres-personal psql -U personal -d personal
# View logs
docker logs finance -f
Architecture
Key Files
| File | Purpose |
|---|---|
src/lib/db.ts |
queryRaw<T>() — the only DB query function; uses pg directly |
src/lib/queries.ts |
All SQL query functions (no ORM); import queryRaw from @/lib/db |
src/lib/hooks.ts |
TanStack Query hooks for all API calls |
src/lib/auth.ts |
getCurrentUser() — reads X-Forwarded-User header |
src/lib/categories.ts |
Canonical category list (CATEGORIES array + formatCategory()) |
src/app/api/*/route.ts |
API route handlers |
src/components/ |
Shared UI components |
Data Flow
- All queries in
src/lib/queries.tsuse raw SQL viaqueryRawfromsrc/lib/db.ts - API routes call query functions and return
NextResponse.json() - Frontend uses hooks from
src/lib/hooks.ts(TanStack Query) — never fetches directly - Auth is always checked first in every API route:
const user = await getCurrentUser(req)
Owner Scoping
All data is scoped by owner_id. The effective owner of a transaction is:
COALESCE(t.owner_id, s.owner_id)
- Statement-linked transactions: owner comes from
statements.owner_id - Manual transactions:
statement_id IS NULL, owner stored directly intransactions.owner_id
The effective merchant and category always prefer overrides:
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merchant
COALESCE(o.category_override, t.category) -- category
Database
# Schema inspection
docker exec postgres-personal psql -U personal -d personal -c "\d transactions"
# Apply a migration SQL file
docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<name>/migration.sql
Key Tables
statements— one row per billing period per bank accounttransactions— line items;statement_idis nullable (NULL = manual entry);reconciled_with_idlinks a manual tx to its matched statement tx;payment_method(migration 0016) iscard | cash | bank_transfer | other, NULL = unknown
Cash and reconciliation
payment_method = 'cash' excludes a transaction from reconciliation via the
notCash() fragment in queries.ts. Cash never appears on a statement, so
without it a cash entry sits in the pending queue forever 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.
Only cash is excluded. Bank transfers do appear on a statement now that transaction accounts are imported, and NULL means unknown — both stay candidates, preserving the behaviour of every pre-existing row.
ATM withdrawals stay categorised as spend rather than transfers. Treating them
as transfers only works if every cash purchase is logged; with partial logging
it silently deletes the unlogged remainder from spend totals.
transaction_overrides— user corrections to AI-extracted data (category, merchant, notes)transaction_splits— shared expense tracking (participant, share_percent, settled)split_payments— recorded cash settlements between participantstransaction_tags— many-to-many join totagsrules— auto-categorisation rules (JSONB conditions + actions)rule_apply_runs— audit log of bulk rule-apply runs with full snapshot for revertexpense_metadata— enrichment from email receipts;transaction_idnullable until reconciledparticipants— people;id=1is "Me" (the primary user)account_owner_mappings— persists bank+account → owner assignments
Shared expenses and settlement — read before touching
The model is under active redesign. See docs/shared-expenses-design.md for the
proposal and what is already decided. Three traps:
transaction_splits.settled is dead data. It is false on every row. Its
only writer was /api/splits/settle, removed in 3f04cbd because nothing called
it and one request could mark all of a participant's splits settled. Do not build
on this flag until settlement contexts exist.
getParticipantBalances computes splits − payments and is correct. Do not
"fix" it to exclude settled splits — the payments that settled them are still
subtracted, so you would double-count. The two settlement models (running tab vs
per-split flag) must not be mixed.
Settlement cannot be attributed per trip. split_payments records only
from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the
trip view used to show one and always reported 100% unsettled. Trips show share
only, and point at /shared for real balances.
Also: settlements already exist twice. Four of eight split_payments match an
offset-account credit exactly on amount and date, with linked_transaction_id
populated on only one. And Sonu's loan contributions (…emi in the offset
account, 39 rows, $37,980.24) are categorised transfers, indistinguishable from
ordinary internal transfers.
Partial split coverage inside a category is usually correct, not a gap. Only
shared items are split. utilities sits at 69% yours because Globird, OVO, GWW
and home telecoms are split while Telstra, Vodafone, Optus and JB Hi-Fi Mobile
are personal. subscriptions is 91% because Uber One, Amazon Prime and OnePass
are shared while Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero,
Billdu, Spotify and Patreon are not. fees and charity are 100% yours and
correct. Check the merchants before concluding a rule was never applied — a
category-level ratio that "looks wrong" usually is not.
Splits exist in this app from 2026-01-09 only; earlier splits lived in SplitMyExpenses. So a trailing-12-month per-person series splices six months of gross onto six months of net. Use Feb–Jun 2026 for anything per-person.
The shared loan
The loan is a separate ledger, not a shared expense and not a settlement context — a contribution must never be able to settle a dinner. Sonu's obligation is a fixed 50% of the repayment; actual contributions vary, and the difference is a tracked receivable ($4,000.00 over 2025-07 → 2026-06).
Do not derive the share from actual payments. During her leave the obligation did not change, only the payment did — a percentage-of-actual model would silently redefine her share as 30% and make the shortfall vanish.
Loan interest reconciles exactly: repayments − interest − fees = balance reduction. It stays categorised loan_interest and counts as spend — over 12
months $63,500 of cash left and debt fell $44,127.36, and the $16,523.64
difference bought nothing. Excluding it would leave the balance sheet unable to
reconcile cash out against equity gained.
The repayment is voluntarily above contracted, and the gap is the largest
flexible cost in the whole picture. Contracted is $1,190.54/fortnight
($2,579.50/mo annualised); the actual direct debit is $2,500.00/fortnight
($5,416.67/mo). That is $2,837.17/mo of overpayment, and it is not sunk — it
shows up as statements.redraw_available, which grew $62,387.17 → $81,017.42
across the two most recent loan statements. Sonu returned to $1,250/fortnight in
July 2026 after the reduced $750 period during her leave.
Treat the repayment as two figures whenever asking "what does this cost me":
the contracted floor and the actual. scheduled_repayment holds the actual
($2,500), not the contracted minimum — the contracted figure is not in the DB at
all. See docs/expense-baseline.md.
Import Date (created_at)
transactions.created_at is the import timestamp (DB default now()). In the transactions and shared views, the "Imported" column shows:
- For statement transactions: when the statement was processed by N8N
- For reconciled transactions: the
created_atof the original manual/CSV transaction (viaLEFT JOIN transactions src ON src.reconciled_with_id = t.id) — so the original import date is preserved post-reconciliation
Use created_at (not transaction_date) to answer "what was added since the last settlement?". Sort by created_at is supported server-side in getTransactions and client-side in the shared view.
Rules System
Conditions are AND-evaluated. Fields: merchant_normalized, description, category, bank_name, amount, transaction_type. Operators: contains, equals, starts_with, gt, lt, not_equals. Actions: set_category, set_merchant, add_tag_ids, apply_split.
contains and equals operators are case-insensitive (both sides .toLowerCase()).
A rule with zero conditions matches every transaction. Both apply paths use
conditions.length === 0 || conditions.every(...). Rule 43 "Home 50/50 Sonu" has
no conditions and a 50/50 split action — applying it blindly would split all
~3,700 transactions with another participant. That is what manual_only is for:
those rules are excluded from bulk runs and fire from the transactions page
against a hand-picked selection.
Previewing a rule before applying it
GET /api/rules/[id]/matches is a dry run — it writes nothing and returns only
the transactions a rule would actually change, with already-correct rows
summarised as a count. The Preview button on the rules page uses it.
Apply then goes through POST /api/transactions/bulk with action: "apply_rule"
and explicit transaction ids, not the conditions. That is the safety
property: a rule whose conditions are too broad cannot reach further than what
the preview showed and the user ticked.
Prefer this over auto-applying rules on ingestion. It fails safe, works retroactively, and tells you which rules are consistent enough to automate later.
Rule apply history
rule_apply_runs snapshots the before-state so a run can be reverted, and since
migration 0017 also records rule_id, rule_name and source
(all | rule | selection). rule_name is denormalised deliberately and
there is no FK to rules — history must stay readable after a rule is renamed or
deleted, and deleting a rule must not cascade away the audit trail.
GET /api/rules/runs/[id] diffs that snapshot against current values. Rows
changed by something else since the run are flagged, because reverting restores
the pre-run value and discards the later edit.
Trusting extracted statement data
Balance assertions are the check that works. getStatements computes
opening + movement − closing; 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 pre-existing statements currently fail, ~$4,177
unexplained, including two adjacent ANZ statements off by exactly ±$230.38 (a
transaction filed against the wrong one).
Do not derive opening_balance from closing − movement. It is an accounting
identity, so every statement would reconcile and the check would go permanently
green. A null that reads "unverified" is worth more than a number that is right
by construction. For the same reason, do not add a totals assertion comparing
total_debits to the summed rows — those totals are now computed from the rows
(see the N8N Parse Gemini Result node), so that check can never fail.
Gemini invents summary fields the statement does not print. Wise PDFs show only a closing balance; asked for an opening balance anyway, the model produced 11,277.08 against a truth of 0.00, and on another statement read the running balance of the oldest row. Every transaction was extracted perfectly in both cases — verified row for row against the CSV exports. When a balance assertion fails, suspect the summary before the transactions.
Gemini drops rows silently on long tables. finishReason was STOP, not
MAX_TOKENS, so raising maxOutputTokens does not help. This did not actually
occur on the Wise imports (that was the summary bug above), but it is why an
empty statement must not throw: a document that errors never gets tagged, so it
is re-fetched every poll forever and blocks everything behind it in the queue
(ordering=-created, page_size=1).
FX is per transaction date, via Frankfurter (ECB daily, free, no key), with weekends resolving to the prior publication. A single spot rate across a 15-month statement is wrong by up to 20%. Wise's own rates are more accurate in principle but differ by only 0.05% and exist on 44 of 194 rows, so mixing bases is not worth it.
When comparing CSV exports to extracted data, order by full timestamp including milliseconds. Two of one statement's rows are 1ms apart; dropping the fraction reversed them and produced a bogus opening balance.
Development Patterns
Adding a new API route
- Create
src/app/api/<resource>/route.ts - Always call
getCurrentUser(req)first; return 403 if null - Write SQL in
src/lib/queries.tsusingqueryRaw - Add a TanStack Query hook in
src/lib/hooks.ts
Adding a new condition field to rules
Two files only:
src/app/api/rules/apply/route.ts— add toCondition.fieldunion,TxFieldsinterface, andevaluateCondition()switchsrc/app/rules/page.tsx— add toFIELDSarray; add special rendering if needed (e.g. enum dropdown fortransaction_type)
Modifying queries
- All JOINs to
statementsmust beLEFT JOIN(manual transactions have no statement) - Owner filter pattern:
WHERE COALESCE(t.owner_id, s.owner_id) = $1 - Bank name pattern:
COALESCE(s.bank_name, 'Manual') as bank_name
Analytics queries must import the fragments from src/lib/analytics-sql.ts
(STATEMENTS_JOIN, OWNER_SCOPE, EFFECTIVE_CATEGORY, EXCLUDE_NON_SPEND)
rather than hand-rolling them. Two failure modes they exist to prevent:
- An
INNER JOIN statements+WHERE s.owner_id = $1silently drops every manual/CSV transaction (statement_id IS NULL). - Spend must exclude the
transfersandinvestmentcategories. Once bank statements are imported alongside card statements, a credit-card payment appears twice — as a debit leaving the bank account and as the underlying purchases on the card statement. Excludingtransfersis what nets it out. Use theEXCLUDE_NON_SPENDfragment: a barecategory NOT IN (...)evaluates to NULL for uncategorised rows and drops them from totals.
Statement types
statements.statement_type is constrained to credit_card | transaction | savings | loan | offset | investment | other. Migration 0013 added a
normalize_statement_type() SQL function plus a BEFORE INSERT/UPDATE trigger, so
the N8N workflow can keep sending raw free text ('ACCESS ADVANTAGE', 'Business Card') and the DB normalises it on write. The raw extracted value is preserved in
account_type.
The TypeScript mirror is src/lib/statement-types.ts — keep the list, the SQL
function, and the CHECK constraint in sync when adding a type.
Loans
A loan repayment is not an expense. It is part principal (equity, a balance-sheet move) and part interest (the only part that is spend). Migration 0014 adds:
transactions.principal_amount/interest_amount— populated only when the lender itemises the split on the repayment row itselfstatements.interest_rate,scheduled_repayment,repayment_frequency,redraw_available,loan_term_months
Two statement shapes, both handled:
- Separate rows (the common Australian case) — the loan statement lists
repayments and "Interest Charged" separately.
transaction_typealone is enough:interestrows count as spend,paymentrows don't. No split columns needed. - Itemised repayment row — some lenders print principal and interest on the
repayment line. That row is typed
payment, so it would be skipped entirely and its interest lost. TheSPEND_ROWS/SPEND_BASEfragments inanalytics-sql.tshandle it: a row with a non-nullinterest_amountcounts as spend, valued atinterest_amountrather thanamount.
The N8N Parse Gemini Result node only accepts a split when both parts are
present and they sum to the row amount (±2c) — a half-extracted split would
silently misreport spend, so it is discarded rather than trusted.
Loan interest uses the loan_interest category; principal repayments use
investment (excluded from spend, surfaced on the investments line in monthly
analytics).
Prisma
The schema at prisma/schema.prisma covers all tables. The generated client (gitignored) must be regenerated after schema changes:
cd /mnt/m2cache/appdata/finance-app && npx prisma generate
Docker builds run npx prisma generate automatically. Do not commit src/generated/prisma/ — it is gitignored.
Agent / MCP Access
Agents read this DB through the read-only postgres-personal MCP server (lives in the
personal-agent-gateway repo, not here): agent_ro role, SELECT-only, SQLGlot guardrail,
100-row cap, every call audited to mcp_query_log. See docs/agent-access.md for the tool
list, the five analysis views, and per-client setup (Claude Code, Codex, Hermes).
Two things to remember when changing the schema: the agent views are created by
smarthome/personal-agent/migrations/006_agent_read_role_views.sql (not Prisma) and read
transactions/statements/expense_metadata columns directly — rename a column and they
break or go stale. And the views are not owner-scoped and do not merge
transaction_overrides, so agent numbers can differ from the UI.
Known Gaps / TODOs
See README.md → Known Gaps / TODOs for full details.
Payment provider tracking: merchant_normalized currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add payment_provider column, update Gemini prompt to extract it separately, backfill from merchant_name patterns, surface in UI filters.
Open as of 2026-07-26
- Shared expenses redesign —
docs/shared-expenses-design.md. Phase 0 done; Phases 1–4 unbuilt. Deliberately paused to live with the current behaviour before committing to a model designed in one session. - Expense baseline / emergency reserve —
docs/expense-baseline.md. One-off analysis, nothing built. Records four data corrections the raw numbers need (misfiled Raiz/super/brokerage debits,othercredits read as negative spend,governmentconflating ATO with rates/rego,feesbeing mostly annual) and why only Feb–Jun 2026 is trustworthy for per-person figures. - 11 statements fail the balance assertion, ~$4,177 unexplained. Predates this work. One ANZ statement is off by exactly $0.50, traced to a misread digit in fee rows ($5.00 vs $5.50).
- 28 Up Bank debits are categorised
other($3,760.74). Up only categorised 16 of 88 rows. ThePayeefield is populated throughout, so merchant rules plus the rule preview should clear most of it. - Up item sales are categorised
income($4,238.04 across 19 credits — iPad, drone, camera). Correct in that they are excluded from spend, but it mixes asset disposals into the income line alongside salary. payment_methodis not shown in the transactions list — settable on create and edit only. Worth a column or filter if cash becomes routine.- Raw statement exports live in
dump/, gitignored since31a8177. They were committed by accident in030490eand remain in that commit's history; the repo has no GitHub remote, so exposure is limited to the local Gitea. Purging history was offered and not actioned.