diff --git a/CLAUDE.md b/CLAUDE.md index b6ad12d..433856d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,37 @@ Card'`) and the DB normalises it on write. The raw extracted value is preserved 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: diff --git a/prisma/migrations/0014_loan_statements/migration.sql b/prisma/migrations/0014_loan_statements/migration.sql new file mode 100644 index 0000000..ae46df3 --- /dev/null +++ b/prisma/migrations/0014_loan_statements/migration.sql @@ -0,0 +1,85 @@ +-- Loan statement support. +-- +-- Migration 0013 taught the system that a statement can be a loan; this gives +-- loans somewhere to put the data that only loans have. +-- +-- The core accounting problem: a loan repayment is not an expense. A $3,000 +-- mortgage repayment is roughly $1,200 of principal (a balance-sheet move that +-- builds equity) and $1,800 of interest (the only part that is genuinely spend). +-- Counting the whole repayment as spending overstates expenses badly. +-- +-- Two statement shapes are handled: +-- (a) The common Australian case — the loan statement lists repayments and +-- "Interest Charged" as separate rows. transaction_type already carries +-- this: 'interest' rows count as spend, 'payment' rows do not. +-- (b) Some lenders itemise principal and interest on the repayment row itself. +-- That's what principal_amount / interest_amount are for: when +-- interest_amount is set, analytics count that instead of the full amount. +-- +-- Idempotent: safe to re-run. + +-- Per-transaction principal/interest split (shape (b) above). +ALTER TABLE transactions + ADD COLUMN IF NOT EXISTS principal_amount NUMERIC(12,2), + ADD COLUMN IF NOT EXISTS interest_amount NUMERIC(12,2); + +COMMENT ON COLUMN transactions.principal_amount IS + 'Principal portion of a loan repayment, when the statement itemises it. Not spend.'; +COMMENT ON COLUMN transactions.interest_amount IS + 'Interest portion of a loan repayment, when the statement itemises it. This is the part that counts as spend.'; + +-- Partial index: only loan repayment rows carry a split. +CREATE INDEX IF NOT EXISTS idx_transactions_interest_amount + ON transactions (interest_amount) + WHERE interest_amount IS NOT NULL; + +-- Loan-level terms, read off the statement header. +ALTER TABLE statements + ADD COLUMN IF NOT EXISTS interest_rate NUMERIC(6,3), + ADD COLUMN IF NOT EXISTS scheduled_repayment NUMERIC(12,2), + ADD COLUMN IF NOT EXISTS repayment_frequency TEXT, + ADD COLUMN IF NOT EXISTS redraw_available NUMERIC(12,2), + ADD COLUMN IF NOT EXISTS loan_term_months INTEGER; + +COMMENT ON COLUMN statements.interest_rate IS 'Annual interest rate as a percentage, e.g. 6.140'; +COMMENT ON COLUMN statements.redraw_available IS 'Funds available to redraw (loans) — not the same as available_credit on a card.'; + +-- Free text varies by lender ("Monthly", "Fortnightly"); normalise the common +-- spellings rather than constraining, so an unexpected value never blocks an import. +ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_repayment_frequency_check; + +CREATE OR REPLACE FUNCTION normalize_repayment_frequency(raw TEXT) +RETURNS TEXT AS $$ +DECLARE + v TEXT := lower(trim(coalesce(raw, ''))); +BEGIN + IF v = '' THEN RETURN NULL; END IF; + -- Check fortnightly spellings before the bare '%week%' match below. + IF v LIKE '%fortnight%' OR v LIKE '%bi-week%' OR v LIKE '%biweek%' + OR v LIKE '%2 week%' OR v LIKE '%two week%' OR v LIKE '%14 day%' + THEN RETURN 'fortnightly'; END IF; + IF v LIKE '%month%' THEN RETURN 'monthly'; END IF; + IF v LIKE '%week%' THEN RETURN 'weekly'; END IF; + IF v LIKE '%quarter%' THEN RETURN 'quarterly'; END IF; + IF v LIKE '%annual%' OR v LIKE '%year%' THEN RETURN 'annually'; END IF; + RETURN v; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION statements_normalize_loan_fields_trigger() +RETURNS TRIGGER AS $$ +BEGIN + NEW.repayment_frequency := normalize_repayment_frequency(NEW.repayment_frequency); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_statements_normalize_loan_fields ON statements; +CREATE TRIGGER trg_statements_normalize_loan_fields + BEFORE INSERT OR UPDATE OF repayment_frequency ON statements + FOR EACH ROW EXECUTE FUNCTION statements_normalize_loan_fields_trigger(); + +UPDATE statements +SET repayment_frequency = normalize_repayment_frequency(repayment_frequency) +WHERE repayment_frequency IS NOT NULL + AND repayment_frequency IS DISTINCT FROM normalize_repayment_frequency(repayment_frequency); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e78a996..f96a354 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,6 +151,11 @@ model statements { account_holder_name String? exchange_rate_to_aud Decimal? @db.Decimal(10, 6) paperless_doc_id Int? @unique + interest_rate Decimal? @db.Decimal(6, 3) + scheduled_repayment Decimal? @db.Decimal(12, 2) + repayment_frequency String? + redraw_available Decimal? @db.Decimal(12, 2) + loan_term_months Int? transactions transactions[] } @@ -172,6 +177,8 @@ model transactions { amount_aud Decimal? @db.Decimal(12, 2) owner_id Int? reconciled_with_id Int? + principal_amount Decimal? @db.Decimal(12, 2) + interest_amount Decimal? @db.Decimal(12, 2) statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade) reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull) reconciled_by transactions[] @relation("reconciled") diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index 4c9ac6c..9cd052a 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -6,6 +6,8 @@ import { STATEMENTS_JOIN, EFFECTIVE_CATEGORY, EXCLUDE_NON_SPEND, + SPEND_ROWS, + SPEND_BASE, } from "@/lib/analytics-sql"; export async function GET(req: NextRequest) { @@ -34,9 +36,9 @@ export async function GET(req: NextRequest) { ${EFFECTIVE_CATEGORY} as category, SUM( CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) + WHEN ts.share_percent IS NOT NULL THEN (${SPEND_BASE}) * ts.share_percent / 100 + WHEN o.my_share_percent IS NOT NULL THEN (${SPEND_BASE}) * o.my_share_percent / 100 + ELSE (${SPEND_BASE}) END )::numeric(12,2) as total_spent, COUNT(*)::int as transaction_count @@ -45,7 +47,7 @@ export async function GET(req: NextRequest) { LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} WHERE ${OWNER_SCOPE} = $1 - AND t.transaction_type IN ('debit', 'fee', 'interest') + AND ${SPEND_ROWS} AND ${EXCLUDE_NON_SPEND} AND t.transaction_date >= $2 AND t.transaction_date < $3 diff --git a/src/app/statements/page.tsx b/src/app/statements/page.tsx index a0feb6a..414654d 100644 --- a/src/app/statements/page.tsx +++ b/src/app/statements/page.tsx @@ -180,6 +180,20 @@ export default function StatementsPage() { {s.card_name && (