-- 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);