ci / lint-test (push) Successful in 41s
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.
212 lines
10 KiB
Markdown
212 lines
10 KiB
Markdown
# 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`, database `personal`, user `personal`
|
|
- **Auth**: `X-Forwarded-User` header (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.
|
|
|
|
```bash
|
|
# 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.ts` use raw SQL via `queryRaw` from `src/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:
|
|
```sql
|
|
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 in `transactions.owner_id`
|
|
|
|
The effective merchant and category always prefer overrides:
|
|
```sql
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merchant
|
|
COALESCE(o.category_override, t.category) -- category
|
|
```
|
|
|
|
## Database
|
|
|
|
```bash
|
|
# 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 account
|
|
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx
|
|
- `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 participants
|
|
- `transaction_tags` — many-to-many join to `tags`
|
|
- `rules` — auto-categorisation rules (JSONB conditions + actions)
|
|
- `rule_apply_runs` — audit log of bulk rule-apply runs with full snapshot for revert
|
|
- `expense_metadata` — enrichment from email receipts; `transaction_id` nullable until reconciled
|
|
- `participants` — people; `id=1` is "Me" (the primary user)
|
|
- `account_owner_mappings` — persists bank+account → owner assignments
|
|
|
|
### 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_at` of the original manual/CSV transaction (via `LEFT 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()`).
|
|
|
|
## Development Patterns
|
|
|
|
### Adding a new API route
|
|
|
|
1. Create `src/app/api/<resource>/route.ts`
|
|
2. Always call `getCurrentUser(req)` first; return 403 if null
|
|
3. Write SQL in `src/lib/queries.ts` using `queryRaw`
|
|
4. 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 to `Condition.field` union, `TxFields` interface, and `evaluateCondition()` switch
|
|
- `src/app/rules/page.tsx` — add to `FIELDS` array; add special rendering if needed (e.g. enum dropdown for `transaction_type`)
|
|
|
|
### Modifying queries
|
|
|
|
- All JOINs to `statements` must be `LEFT 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 = $1` silently drops every
|
|
manual/CSV transaction (`statement_id IS NULL`).
|
|
- Spend must exclude the `transfers` and `investment` categories. 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. Excluding `transfers` is what nets it out.
|
|
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category 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 itself
|
|
- `statements.interest_rate`, `scheduled_repayment`, `repayment_frequency`,
|
|
`redraw_available`, `loan_term_months`
|
|
|
|
Two statement shapes, both handled:
|
|
|
|
1. **Separate rows** (the common Australian case) — the loan statement lists
|
|
repayments and "Interest Charged" separately. `transaction_type` alone is
|
|
enough: `interest` rows count as spend, `payment` rows don't. No split columns
|
|
needed.
|
|
2. **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. The `SPEND_ROWS` / `SPEND_BASE` fragments in
|
|
`analytics-sql.ts` handle it: a row with a non-null `interest_amount` counts
|
|
as spend, valued at `interest_amount` rather than `amount`.
|
|
|
|
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:
|
|
```bash
|
|
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.
|