BillTracker/services/aprService.js

228 lines
7.9 KiB
JavaScript
Raw Permalink Normal View History

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));
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) {
return roundMoney(n);
2026-05-15 00:03:32 -05:00
}
module.exports = {
monthlyInterest,
monthsToPayoff,
amortizationSchedule,
calculateMinimumOnly,
debtAprSnapshot,
};