BillTracker/services/snowballService.cts

191 lines
7.1 KiB
TypeScript

import type { Dollars } from '../utils/money.mts';
const { roundMoney, sumMoney } = require('../utils/money.mts') as typeof import('../utils/money.mts');
/**
* Debt payoff calculators — Snowball and Avalanche methods.
*
* Snowball (Dave Ramsey): smallest balance first — fast psychological wins.
* Avalanche (math-optimal): highest interest rate first — minimises total interest.
*
* Both share the same month-by-month simulation loop; only the initial order differs.
*/
type Row = Record<string, any>;
interface ActiveDebt {
id: any;
name: any;
balance: number;
minPayment: number;
monthlyRate: number;
payoffMonth: number | null;
totalInterest: number;
}
interface SkippedDebt { id: any; name: any; reason: string }
interface DebtProjection {
months_to_freedom: number | null;
total_interest_paid: number;
payoff_date: string | null;
payoff_display: string | null;
debts: any[];
skipped: SkippedDebt[];
extra_payment: number;
capped: boolean;
}
// ── Private simulation engine ─────────────────────────────────────────────────
function _simulate(orderedDebts: Row[], extraPayment: number, startDate: Date): DebtProjection {
const extra = Math.max(0, Number(extraPayment) || 0);
const active: ActiveDebt[] = [];
const skipped: SkippedDebt[] = [];
for (const d of orderedDebts) {
const bal = Number(d.current_balance);
if (d.current_balance == null || !Number.isFinite(bal)) {
skipped.push({ id: d.id, name: d.name, reason: 'no_balance' });
} else if (bal <= 0) {
skipped.push({ id: d.id, name: d.name, reason: 'zero_balance' });
} else {
active.push({
id: d.id,
name: d.name,
balance: bal,
minPayment: Math.max(0, Number(d.minimum_payment) || 0),
monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
payoffMonth: null,
totalInterest: 0,
});
}
}
if (active.length === 0) {
return {
months_to_freedom: null,
total_interest_paid: 0,
payoff_date: null,
payoff_display: null,
debts: [],
skipped,
extra_payment: extra,
capped: false,
};
}
// ── Month-by-month loop ───────────────────────────────────────────────────
const MAX_MONTHS = 600; // 50-year safety cap
let rollingExtra = extra;
let month = 0;
while (active.some(d => d.balance > 0) && month < MAX_MONTHS) {
month++;
// Attack target = first debt in the ordered list that still has a balance
const targetIdx = active.findIndex(d => d.balance > 0);
for (let i = 0; i < active.length; i++) {
const debt = active[i];
if (debt.balance <= 0) continue;
// Accrue monthly interest
const interest = debt.balance * debt.monthlyRate;
debt.balance += interest;
debt.totalInterest += interest;
// Attack target gets minimums + full snowball; others get minimums only
const payment = Math.min(
debt.balance,
i === targetIdx ? debt.minPayment + rollingExtra : debt.minPayment,
);
debt.balance = Math.max(0, debt.balance - payment);
if (debt.balance < 0.005) debt.balance = 0; // eliminate floating-point dust
}
// Mark any debt that just reached zero (attack target OR paid off naturally by minimums)
// and roll its freed minimum into the snowball for next month.
for (let i = 0; i < active.length; i++) {
const debt = active[i];
if (debt.balance === 0 && debt.payoffMonth === null) {
debt.payoffMonth = month;
rollingExtra += debt.minPayment;
}
}
}
// ── Format results ────────────────────────────────────────────────────────
const baseYear = startDate.getFullYear();
const baseMo = startDate.getMonth();
function monthLabel(m: number): string {
const d = new Date(baseYear, baseMo + m, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function monthDisplay(m: number): string {
const d = new Date(baseYear, baseMo + m, 1);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
}
const debtResults = active.map(d => ({
id: d.id,
name: d.name,
payoff_month: d.payoffMonth,
payoff_date: d.payoffMonth ? monthLabel(d.payoffMonth) : null,
payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null,
total_interest: round2(d.totalInterest),
months: d.payoffMonth,
never_paid: d.payoffMonth === null, // minimum never overcomes interest
}));
// A payoff date only exists if EVERY active debt actually reaches $0. If a
// debt's minimum never overcomes its interest (and the snowball can't cover
// it), it never pays off — reporting the other debts' last month would be a
// misleadingly optimistic "freedom" date, so return null and mark it capped.
const allPaid = active.every(d => d.payoffMonth !== null);
const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0));
const totalInterest = sumMoney(active, d => d.totalInterest);
return {
months_to_freedom: allPaid && maxMonth ? maxMonth : null,
total_interest_paid: round2(totalInterest),
payoff_date: allPaid && maxMonth ? monthLabel(maxMonth) : null,
payoff_display: allPaid && maxMonth ? monthDisplay(maxMonth) : null,
debts: debtResults,
skipped,
extra_payment: extra,
capped: month >= MAX_MONTHS || !allPaid,
};
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Snowball: attack the smallest balance first (fast wins, motivational).
* Debts must already be in snowball order (sorted by current_balance ASC by the caller).
*/
function calculateSnowball(debts: Row[], extraPayment = 0, startDate: Date = new Date()): DebtProjection {
return _simulate(debts, extraPayment, startDate);
}
/**
* Avalanche: attack the highest interest rate first (minimises total interest paid).
* Re-sorts debts internally — caller does not need to pre-sort.
*/
function calculateAvalanche(debts: Row[], extraPayment = 0, startDate: Date = new Date()): DebtProjection {
const sorted = [...debts].sort((a, b) => {
const ra = Number(a.interest_rate) || 0;
const rb = Number(b.interest_rate) || 0;
if (rb !== ra) return rb - ra; // highest rate first
// Tiebreak: smallest balance (clears fastest, rolling the payment sooner)
return (Number(a.current_balance) || 0) - (Number(b.current_balance) || 0);
});
return _simulate(sorted, extraPayment, startDate);
}
function round2(n: number): Dollars {
return roundMoney(n);
}
module.exports = { calculateSnowball, calculateAvalanche };