feat(loans): principal/interest split so repayments stop distorting spend
ci / lint-test (push) Successful in 41s
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.
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user