BillTracker/services/snowballService.js

159 lines
5.8 KiB
JavaScript

/**
* 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.
*/
// ── Private simulation engine ─────────────────────────────────────────────────
function _simulate(orderedDebts, extraPayment, startDate) {
const extra = Math.max(0, Number(extraPayment) || 0);
const active = [];
const skipped = [];
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) {
const d = new Date(baseYear, baseMo + m, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function monthDisplay(m) {
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,
}));
const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0));
const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0);
return {
months_to_freedom: maxMonth || null,
total_interest_paid: round2(totalInterest),
payoff_date: maxMonth ? monthLabel(maxMonth) : null,
payoff_display: maxMonth ? monthDisplay(maxMonth) : null,
debts: debtResults,
skipped,
extra_payment: extra,
capped: month >= MAX_MONTHS,
};
}
// ── 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, extraPayment = 0, startDate = new Date()) {
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, extraPayment = 0, startDate = new Date()) {
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) {
return Math.round(n * 100) / 100;
}
module.exports = { calculateSnowball, calculateAvalanche };