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
+14
View File
@@ -180,6 +180,20 @@ export default function StatementsPage() {
{s.card_name && (
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
)}
{stmtType === "loan" && (s.interest_rate || s.scheduled_repayment) && (
<div className="text-xs text-zinc-500 truncate max-w-[160px]">
{[
s.interest_rate ? `${Number(s.interest_rate).toFixed(2)}% p.a.` : null,
s.scheduled_repayment
? `${formatAmount(s.scheduled_repayment)}${
s.repayment_frequency ? ` ${s.repayment_frequency}` : ""
}`
: null,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
<Link
href={`/transactions?statement_id=${s.id}`}
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"