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:
@@ -21,6 +21,24 @@ export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`
|
||||
/** Transaction types that represent money going out. */
|
||||
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
|
||||
|
||||
/**
|
||||
* Rows that count towards spend.
|
||||
*
|
||||
* The `interest_amount IS NOT NULL` arm is for loan repayments: when a lender
|
||||
* itemises principal and interest on a single repayment row, that row is usually
|
||||
* typed 'payment' (money reducing the loan balance) and would otherwise be
|
||||
* skipped — but its interest portion is real spend.
|
||||
*/
|
||||
export const SPEND_ROWS = `(t.transaction_type IN ('debit', 'fee', 'interest') OR t.interest_amount IS NOT NULL)`;
|
||||
|
||||
/**
|
||||
* The amount of a row that counts as spend, before split adjustment.
|
||||
*
|
||||
* For an itemised loan repayment only the interest portion is an expense; the
|
||||
* principal builds equity and is a balance-sheet move, not spending.
|
||||
*/
|
||||
export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END`;
|
||||
|
||||
/** Effective category, honouring overrides. Never NULL. */
|
||||
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CATEGORIES = [
|
||||
"transfers",
|
||||
"income",
|
||||
"investment",
|
||||
"loan_interest",
|
||||
"personal_care",
|
||||
"pets",
|
||||
"gifts",
|
||||
|
||||
@@ -17,6 +17,8 @@ export const CATEGORY_COLORS: Record<string, string> = {
|
||||
transfers: "#6b7280",
|
||||
income: "#34d399",
|
||||
investment: "#818cf8",
|
||||
loan_interest: "#9f1239",
|
||||
fees: "#f87171",
|
||||
personal_care: "#fb7185",
|
||||
pets: "#86efac",
|
||||
gifts: "#fcd34d",
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface TransactionRow {
|
||||
category: string;
|
||||
row_index: number;
|
||||
created_at: string;
|
||||
// loan repayment split — set only when the lender itemises it (migration 0014)
|
||||
principal_amount: number | null;
|
||||
interest_amount: number | null;
|
||||
// override fields
|
||||
category_override: string | null;
|
||||
merchant_override: string | null;
|
||||
@@ -64,6 +67,12 @@ export interface StatementRow {
|
||||
credit_limit: number | null;
|
||||
currency: string;
|
||||
statement_type: string | null;
|
||||
// Loan statements only (see migration 0014)
|
||||
interest_rate: number | null;
|
||||
scheduled_repayment: number | null;
|
||||
repayment_frequency: string | null;
|
||||
redraw_available: number | null;
|
||||
loan_term_months: number | null;
|
||||
tier_used: string | null;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
|
||||
Reference in New Issue
Block a user