159 lines
5.8 KiB
JavaScript
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 };
|