const { roundMoney, sumMoney } = require('../utils/money'); /** * APR / amortization mathematics. * All functions are pure — no DB access, no side effects. */ const MAX_MONTHS = 600; // 50-year simulation cap // ── Primitives ──────────────────────────────────────────────────────────────── /** * One month of interest accrued on a balance at an annual rate. */ function monthlyInterest(balance, annualRatePct) { return Math.max(0, Number(balance) || 0) * (Math.max(0, Number(annualRatePct) || 0) / 100 / 12); } /** * Months to pay off a single balance with a fixed monthly payment. * Returns null if the payment never covers the interest (debt grows forever) * or if the balance/payment are invalid. */ function monthsToPayoff(balance, annualRatePct, monthlyPayment) { const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12; let bal = Number(balance); const pmt = Number(monthlyPayment); if (!Number.isFinite(bal) || bal <= 0) return 0; if (!Number.isFinite(pmt) || pmt <= 0) return null; if (rate > 0 && pmt <= bal * rate) return null; // payment can't overcome interest let months = 0; while (bal > 0.005 && months < MAX_MONTHS) { months++; bal += bal * rate; bal = Math.max(0, bal - pmt); } return months >= MAX_MONTHS ? null : months; } /** * Total interest paid over the life of a single debt at a fixed monthly payment. * Returns null if the payment never overcomes interest. */ function totalInterestPaid(balance, annualRatePct, monthlyPayment) { const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12; let bal = Number(balance); const pmt = Number(monthlyPayment); if (!Number.isFinite(bal) || bal <= 0) return 0; if (!Number.isFinite(pmt) || pmt <= 0) return null; if (rate > 0 && pmt <= bal * rate) return null; let totalInterest = 0; let months = 0; while (bal > 0.005 && months < MAX_MONTHS) { months++; const interest = bal * rate; totalInterest += interest; bal = Math.max(0, bal + interest - pmt); } return months >= MAX_MONTHS ? null : round2(totalInterest); } /** * Full month-by-month amortization schedule for a single debt. * Each row: { month, payment, principal, interest, balance } * * @param {number} balance * @param {number} annualRatePct * @param {number} monthlyPayment * @param {number} [maxMonths=360] Hard cap (prevents huge payloads) */ function amortizationSchedule(balance, annualRatePct, monthlyPayment, maxMonths = 360) { const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12; let bal = Number(balance); const pmt = Number(monthlyPayment); const cap = Math.min(maxMonths, MAX_MONTHS); if (!Number.isFinite(bal) || bal <= 0 || !Number.isFinite(pmt) || pmt <= 0) return []; if (rate > 0 && pmt <= bal * rate) return []; const schedule = []; let month = 0; while (bal > 0.005 && month < cap) { month++; const interest = round2(bal * rate); const rawPmt = Math.min(bal + interest, pmt); const payment = round2(rawPmt); const principal = round2(Math.max(0, payment - interest)); bal = round2(Math.max(0, bal - principal)); schedule.push({ month, payment, principal, interest, balance: bal }); } return schedule; } // ── Minimum-only projection ─────────────────────────────────────────────────── /** * Projects payoff with ONLY minimum payments — no snowball rolling, no extra money. * Each debt runs independently at its minimum payment. * This is the "do nothing extra" baseline for comparing against the snowball method. * * Returns the same shape as calculateSnowball so callers can compare directly. */ function calculateMinimumOnly(debts, startDate = new Date()) { const active = []; const skipped = []; for (const d of debts) { const bal = Number(d.current_balance); const rate = Math.max(0, Number(d.interest_rate) || 0) / 100 / 12; const min = Math.max(0, Number(d.minimum_payment) || 0); if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) { skipped.push({ id: d.id, name: d.name, reason: 'no_balance' }); continue; } // Minimum payment too low to ever pay off — flag it if (rate > 0 && min > 0 && min <= bal * rate) { skipped.push({ id: d.id, name: d.name, reason: 'payment_below_interest' }); continue; } if (min <= 0) { skipped.push({ id: d.id, name: d.name, reason: 'no_minimum_payment' }); continue; } active.push({ id: d.id, name: d.name, balance: bal, minPayment: min, monthlyRate: rate, 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: 0, capped: false, }; } // Each debt is independent — no payment rolls when one clears let month = 0; while (active.some(d => d.balance > 0) && month < MAX_MONTHS) { month++; for (const d of active) { if (d.balance <= 0) continue; const interest = d.balance * d.monthlyRate; d.balance += interest; d.totalInterest += interest; const payment = Math.min(d.balance, d.minPayment); d.balance = Math.max(0, d.balance - payment); if (d.balance < 0.005) d.balance = 0; } for (const d of active) { if (d.balance === 0 && d.payoffMonth === null) d.payoffMonth = month; } } 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) { return new Date(baseYear, baseMo + m, 1) .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 = sumMoney(active, d => d.totalInterest); 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: 0, capped: month >= MAX_MONTHS, }; } // ── Per-debt APR snapshot ───────────────────────────────────────────────────── /** * Computes the current APR breakdown for a single debt based on its present balance. * Returns the metrics needed to understand how expensive the debt is right now. * * @param {object} bill Requires: current_balance, interest_rate, minimum_payment */ function debtAprSnapshot(bill) { const balance = Number(bill.current_balance); const apr = Number(bill.interest_rate) || 0; const min = Number(bill.minimum_payment) || 0; if (!Number.isFinite(balance) || balance <= 0) return null; const monthly_interest = round2(monthlyInterest(balance, apr)); const principal_per_min_pmt = round2(Math.max(0, min - monthly_interest)); const interest_pct_of_min = min > 0 ? round2((monthly_interest / min) * 100) : null; const annual_interest_estimate = round2(monthly_interest * 12); return { monthly_interest, // dollars of interest accruing this month principal_per_min_pmt, // dollars of principal reduction if paying minimum interest_pct_of_min, // % of minimum payment that goes to interest annual_interest_estimate, // rough yearly cost at current balance }; } // ── Helpers ─────────────────────────────────────────────────────────────────── function round2(n) { return roundMoney(n); } module.exports = { monthlyInterest, monthsToPayoff, totalInterestPaid, amortizationSchedule, calculateMinimumOnly, debtAprSnapshot, };