feat(loans): principal/interest split so repayments stop distorting spend
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:
2026-07-26 00:21:01 +10:00
parent 25ef504574
commit 76db9dddb4
9 changed files with 173 additions and 4 deletions
+6 -4
View File
@@ -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