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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user