2026-06-10 20:14:13 -05:00
|
|
|
|
|
|
|
|
const { roundMoney, sumMoney } = require('../utils/money');
|
2026-05-15 00:03:32 -05:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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));
|
2026-06-10 20:14:13 -05:00
|
|
|
const totalInterest = sumMoney(active, d => d.totalInterest);
|
2026-05-15 00:03:32 -05:00
|
|
|
|
|
|
|
|
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) {
|
2026-06-10 20:14:13 -05:00
|
|
|
return roundMoney(n);
|
2026-05-15 00:03:32 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
monthlyInterest,
|
|
|
|
|
monthsToPayoff,
|
|
|
|
|
amortizationSchedule,
|
|
|
|
|
calculateMinimumOnly,
|
|
|
|
|
debtAprSnapshot,
|
|
|
|
|
};
|