Files
finance-app/CLAUDE.md
T
siddharthd 25ef504574
ci / lint-test (push) Successful in 38s
feat(statements+analytics): normalise statement_type; fix analytics scoping
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.
2026-07-26 00:00:55 +10:00

8.7 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, 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.

# 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:

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:

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 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.

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.mdKnown 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.