BillTracker/services/snowballService.cts

204 lines
7.0 KiB
TypeScript
Raw Normal View History

import type { Dollars } from '../utils/money.mts';
const { roundMoney, sumMoney } =
require('../utils/money.mts') as typeof import('../utils/money.mts');
2026-05-14 02:11:54 -05:00
/**
* 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;
}
2026-05-14 02:11:54 -05:00
// ── Private simulation engine ─────────────────────────────────────────────────
function _simulate(orderedDebts: Row[], extraPayment: number, startDate: Date): DebtProjection {
2026-05-14 02:11:54 -05:00
const extra = Math.max(0, Number(extraPayment) || 0);
const active: ActiveDebt[] = [];
const skipped: SkippedDebt[] = [];
2026-05-14 02:11:54 -05:00
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,
2026-05-14 02:11:54 -05:00
totalInterest: 0,
});
}
}
if (active.length === 0) {
return {
months_to_freedom: null,
2026-05-14 02:11:54 -05:00
total_interest_paid: 0,
payoff_date: null,
payoff_display: null,
debts: [],
2026-05-14 02:11:54 -05:00
skipped,
extra_payment: extra,
capped: false,
2026-05-14 02:11:54 -05:00
};
}
// ── 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) {
2026-05-14 02:11:54 -05:00
month++;
// Attack target = first debt in the ordered list that still has a balance
const targetIdx = active.findIndex((d) => d.balance > 0);
2026-05-14 02:11:54 -05:00
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;
2026-05-14 02:11:54 -05:00
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();
2026-05-14 02:11:54 -05:00
function monthLabel(m: number): string {
2026-05-14 02:11:54 -05:00
const d = new Date(baseYear, baseMo + m, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function monthDisplay(m: number): string {
2026-05-14 02:11:54 -05:00
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,
2026-05-14 02:11:54 -05:00
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
2026-05-14 02:11:54 -05:00
}));
// 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);
2026-05-14 02:11:54 -05:00
return {
months_to_freedom: allPaid && maxMonth ? maxMonth : null,
2026-05-14 02:11:54 -05:00
total_interest_paid: round2(totalInterest),
payoff_date: allPaid && maxMonth ? monthLabel(maxMonth) : null,
payoff_display: allPaid && maxMonth ? monthDisplay(maxMonth) : null,
debts: debtResults,
2026-05-14 02:11:54 -05:00
skipped,
extra_payment: extra,
capped: month >= MAX_MONTHS || !allPaid,
2026-05-14 02:11:54 -05:00
};
}
// ── 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 {
2026-05-14 02:11:54 -05:00
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 {
2026-05-14 02:11:54 -05:00
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);
2026-05-14 02:11:54 -05:00
}
module.exports = { calculateSnowball, calculateAvalanche };