/** * 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 };