apr/snowball 0.27.04

This commit is contained in:
null 2026-05-15 00:03:32 -05:00
parent d720931894
commit 576163e85b
11 changed files with 486 additions and 73 deletions

3
.markdownlint.json Normal file
View File

@ -0,0 +1,3 @@
{
"MD024": { "siblings_only": true }
}

View File

@ -144,6 +144,13 @@ export const api = {
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data), updateBill: (id, data) => put(`/bills/${id}`, data),
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
billAmortization: (id, opts = {}) => {
const params = new URLSearchParams();
if (opts.payment) params.set('payment', String(opts.payment));
if (opts.max_months) params.set('max_months', String(opts.max_months));
const qs = params.toString();
return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`);
},
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`), deleteBill: (id) => del(`/bills/${id}`),

View File

@ -152,9 +152,18 @@ export default function LoginPage() {
<Button <Button
type="button" type="button"
variant={localEnabled ? 'outline' : 'default'} variant={localEnabled ? 'outline' : 'default'}
className="w-full" className="w-full gap-2"
onClick={() => { window.location.href = authMode.oidc_login_url; }} onClick={() => { window.location.href = authMode.oidc_login_url; }}
> >
{providerName.toLowerCase().includes('authentik') && (
<img
src="/img/auth.svg"
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain shrink-0"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
Continue with {providerName} Continue with {providerName}
</Button> </Button>
)} )}

View File

@ -32,21 +32,31 @@ function ordinal(n) {
} }
// Client-side snowball simulation (mirrors server snowballService) // Client-side snowball simulation (mirrors server snowballService)
// Runs in the browser so the payoff date updates instantly as the user types. // Returns the full projection shape so the panel and attack card both update
function computeLiveAttackPayoff(bills, extraPayment) { // instantly as the user types the extra payment no network round-trip needed.
function computeLiveProjection(bills, extraPayment) {
const extra = Math.max(0, Number(extraPayment) || 0); const extra = Math.max(0, Number(extraPayment) || 0);
const active = []; const active = [];
const skipped = [];
for (const d of bills) { for (const d of bills) {
const bal = Number(d.current_balance); const bal = Number(d.current_balance);
if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) continue; if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) {
skipped.push({ id: d.id, name: d.name, reason: 'no_balance' });
continue;
}
active.push({ active.push({
id: d.id,
name: d.name,
balance: bal, balance: bal,
minPayment: Math.max(0, Number(d.minimum_payment) || 0), minPayment: Math.max(0, Number(d.minimum_payment) || 0),
monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12, monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
payoffMonth: null, payoffMonth: null,
totalInterest: 0,
}); });
} }
if (active.length === 0) return null; if (active.length === 0) return null;
let rollingExtra = extra; let rollingExtra = extra;
@ -58,7 +68,9 @@ function computeLiveAttackPayoff(bills, extraPayment) {
for (let i = 0; i < active.length; i++) { for (let i = 0; i < active.length; i++) {
const d = active[i]; const d = active[i];
if (d.balance <= 0) continue; if (d.balance <= 0) continue;
d.balance += d.balance * d.monthlyRate; const interest = d.balance * d.monthlyRate;
d.balance += interest;
d.totalInterest += interest;
const payment = Math.min(d.balance, i === targetIdx ? d.minPayment + rollingExtra : d.minPayment); const payment = Math.min(d.balance, i === targetIdx ? d.minPayment + rollingExtra : d.minPayment);
d.balance = Math.max(0, d.balance - payment); d.balance = Math.max(0, d.balance - payment);
if (d.balance < 0.005) d.balance = 0; if (d.balance < 0.005) d.balance = 0;
@ -71,12 +83,42 @@ function computeLiveAttackPayoff(bills, extraPayment) {
} }
} }
const first = active[0];
if (!first?.payoffMonth) return null;
const now = new Date(); const now = new Date();
const date = new Date(now.getFullYear(), now.getMonth() + first.payoffMonth, 1); const baseYear = now.getFullYear();
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); const baseMo = now.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 debts = 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: Math.round(d.totalInterest * 100) / 100,
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: Math.round(totalInterest * 100) / 100,
payoff_date: maxMonth ? monthLabel(maxMonth) : null,
payoff_display: maxMonth ? monthDisplay(maxMonth) : null,
debts,
skipped,
extra_payment: extra,
capped: month >= 600,
};
} }
// StatCard // StatCard
@ -413,12 +455,22 @@ export default function SnowballPage() {
} }
}; };
// live payoff preview (updates as user types extra amount) // live projection (updates as user types extra amount)
const liveAttackPayoff = useMemo( // Full simulation runs client-side so both the attack card and the
() => computeLiveAttackPayoff(bills, extraPayment), // projection panel update instantly no network round-trip.
const liveSnowball = useMemo(
() => computeLiveProjection(bills, extraPayment),
[bills, extraPayment], [bills, extraPayment],
); );
// Attack card uses the first debt's payoff date from the live simulation
const liveAttackPayoff = liveSnowball?.debts?.[0]?.payoff_display ?? null;
// Panel merges live snowball with server avalanche (avalanche doesn't need to be live)
const displayProjection = liveSnowball
? { snowball: liveSnowball, avalanche: projection?.avalanche }
: projection;
// stats // stats
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
@ -526,9 +578,9 @@ export default function SnowballPage() {
const isDragging = draggingIdx !== null; const isDragging = draggingIdx !== null;
const isTarget = draggingIdx === index; const isTarget = draggingIdx === index;
// Pull this debt's payoff info from the snowball projection (attack card only) // Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack const attackProjection = isAttack
? projection?.snowball?.debts?.[0] ? displayProjection?.snowball?.debts?.[0]
: null; : null;
return ( return (
@ -696,8 +748,8 @@ export default function SnowballPage() {
{/* Projection (sticky sidebar on large screens) */} {/* Projection (sticky sidebar on large screens) */}
<div className="lg:sticky lg:top-24 lg:self-start"> <div className="lg:sticky lg:top-24 lg:self-start">
<ProjectionPanel <ProjectionPanel
projection={projection} projection={displayProjection}
projectionLoading={projectionLoading} projectionLoading={projectionLoading && !liveSnowball}
billCount={bills.length} billCount={bills.length}
/> />
</div> </div>

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<!-- Authentik brand mark — orange geometric shield/key icon -->
<path
fill="#FD4B2D"
d="M12 2L4 5.5v6c0 4.25 3.4 8.2 8 9.5 4.6-1.3 8-5.25 8-9.5v-6L12 2z"
/>
<path
fill="#fff"
d="M12 7a2.5 2.5 0 0 1 2.5 2.5c0 1-.6 1.88-1.5 2.3V14h-2v-2.2A2.5 2.5 0 0 1 9.5 9.5 2.5 2.5 0 0 1 12 7zm0 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-.75 5.75h1.5v1.5h-1.5v-1.5z"
/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.27.02", "version": "0.27.04",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -457,16 +457,6 @@ router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
const { checkForUpdates } = require('../services/updateCheckService'); const { checkForUpdates } = require('../services/updateCheckService');
// GET /api/about-admin/update-status — returns cached update check (no force-refresh)
router.get('/update-status', requireAuth, requireAdmin, async (req, res) => {
try {
const result = await checkForUpdates(false);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message || 'Update check failed' });
}
});
// POST /api/about-admin/check-updates — force a fresh update check, bypassing cache // POST /api/about-admin/check-updates — force a fresh update check, bypassing cache
router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => { router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
try { try {

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService'); const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
@ -499,6 +500,58 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// ── GET /api/bills/:id/amortization — full month-by-month schedule for a debt bill ──
router.get('/:id/amortization', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balance = Number(bill.current_balance);
const apr = Number(bill.interest_rate) || 0;
const minPmt = Number(bill.minimum_payment) || 0;
// Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt;
if (req.query.payment !== undefined) {
const qp = parseFloat(req.query.payment);
if (!Number.isFinite(qp) || qp <= 0) {
return res.status(400).json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
}
payment = qp;
}
// Optional ?max_months=N (default 360, hard cap 600)
let maxMonths = 360;
if (req.query.max_months !== undefined) {
const qm = parseInt(req.query.max_months, 10);
if (Number.isInteger(qm) && qm > 0) maxMonths = Math.min(qm, 600);
}
if (!Number.isFinite(balance) || balance <= 0) {
return res.json({ bill_id: billId, schedule: [], apr_snapshot: null, error: 'No current balance set' });
}
const schedule = amortizationSchedule(balance, apr, payment, maxMonths);
const apr_snapshot = debtAprSnapshot(bill);
const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
res.json({
bill_id: billId,
balance,
apr,
payment,
schedule,
summary: {
months: schedule.length,
total_interest: Math.round(total_interest * 100) / 100,
total_paid: Math.round((schedule.reduce((s, r) => s + r.payment, 0)) * 100) / 100,
capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0,
},
apr_snapshot,
});
});
// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ── // ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ──
router.patch('/:id/snowball', (req, res) => { router.patch('/:id/snowball', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -3,6 +3,7 @@ const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
const DEBT_LIKE_CLAUSES = `( const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1 b.snowball_include = 1
@ -18,10 +19,7 @@ const DEBT_LIKE_CLAUSES = `(
) )
)`; )`;
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order const DEBT_QUERY = `
router.get('/', (req, res) => {
const db = getDb();
const bills = db.prepare(`
SELECT b.*, c.name AS category_name SELECT b.*, c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
@ -33,9 +31,12 @@ router.get('/', (req, res) => {
b.snowball_order ASC, b.snowball_order ASC,
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC, CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC b.current_balance ASC
`).all(req.user.id); `;
res.json(bills); // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const db = getDb();
res.json(db.prepare(DEBT_QUERY).all(req.user.id));
}); });
// GET /api/snowball/settings — extra monthly payment for this user // GET /api/snowball/settings — extra monthly payment for this user
@ -56,7 +57,7 @@ router.patch('/settings', (req, res) => {
return res.status(400).json(standardizeError( return res.status(400).json(standardizeError(
'extra_payment must be a non-negative number', 'extra_payment must be a non-negative number',
'VALIDATION_ERROR', 'VALIDATION_ERROR',
'extra_payment' 'extra_payment',
)); ));
} }
} }
@ -66,34 +67,70 @@ router.patch('/settings', (req, res) => {
res.json({ extra_payment: val }); res.json({ extra_payment: val });
}); });
// GET /api/snowball/projection — payoff timeline using the snowball math service // GET /api/snowball/projection — snowball, avalanche, minimum-only projections
// Each debt result is enriched with current APR metrics (monthly interest, etc.)
router.get('/projection', (req, res) => { router.get('/projection', (req, res) => {
const db = getDb(); const db = getDb();
const bills = db.prepare(` const bills = db.prepare(DEBT_QUERY).all(req.user.id);
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
WHERE b.user_id = ?
AND b.active = 1
AND ${DEBT_LIKE_CLAUSES}
ORDER BY
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
b.snowball_order ASC,
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC
`).all(req.user.id);
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
const extraPayment = user?.snowball_extra_payment ?? 0; const extra = user?.snowball_extra_payment ?? 0;
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
const aprByBill = {};
for (const b of bills) {
const snap = debtAprSnapshot(b);
if (snap) aprByBill[b.id] = snap;
}
// Enrich each debt result with its APR snapshot
function enrich(projection) {
return {
...projection,
debts: projection.debts.map(d => ({
...d,
apr_snapshot: aprByBill[d.id] ?? null,
})),
};
}
const now = new Date(); const now = new Date();
const snowball = calculateSnowball(bills, extraPayment, now); const snowball = enrich(calculateSnowball(bills, extra, now));
const avalanche = calculateAvalanche(bills, extraPayment, now); const avalanche = enrich(calculateAvalanche(bills, extra, now));
const minimum_only = enrich(calculateMinimumOnly(bills, now));
res.json({ snowball, avalanche }); // Comparison: what does the snowball save vs just paying minimums?
const comparison = buildComparison(snowball, minimum_only);
res.json({ snowball, avalanche, minimum_only, comparison });
}); });
// Build a summary comparing snowball to the minimum-only baseline
function buildComparison(snowball, minimum_only) {
const sbMonths = snowball.months_to_freedom;
const moMonths = minimum_only.months_to_freedom;
const sbInterest = snowball.total_interest_paid;
const moInterest = minimum_only.total_interest_paid;
if (!sbMonths || !moMonths) return null;
const months_saved = moMonths - sbMonths;
const interest_saved = Math.round((moInterest - sbInterest) * 100) / 100;
const years_saved = +(months_saved / 12).toFixed(1);
return {
months_saved,
years_saved,
interest_saved,
minimum_only_months: moMonths,
minimum_only_interest: moInterest,
minimum_only_payoff: minimum_only.payoff_display,
snowball_months: sbMonths,
snowball_interest: sbInterest,
snowball_payoff: snowball.payoff_display,
};
}
// PATCH /api/snowball/order — batch-save snowball_order positions // PATCH /api/snowball/order — batch-save snowball_order positions
router.patch('/order', (req, res) => { router.patch('/order', (req, res) => {
const items = req.body; const items = req.body;

View File

@ -41,9 +41,10 @@ function parseHistory() {
return { version, notes }; return { version, notes };
} }
// GET /api/version // GET /api/version — version always comes from package.json; notes from HISTORY.md
router.get('/', (req, res) => { router.get('/', (req, res) => {
res.json(parseHistory()); const { notes } = parseHistory();
res.json({ version: pkg.version, notes });
}); });
// GET /api/version/history // GET /api/version/history

250
services/aprService.js Normal file
View File

@ -0,0 +1,250 @@
/**
* 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 = 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: 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 Math.round(n * 100) / 100;
}
module.exports = {
monthlyInterest,
monthsToPayoff,
totalInterestPaid,
amortizationSchedule,
calculateMinimumOnly,
debtAprSnapshot,
};