diff --git a/.markdownlint.json b/.markdownlint.json
new file mode 100644
index 0000000..8ab4145
--- /dev/null
+++ b/.markdownlint.json
@@ -0,0 +1,3 @@
+{
+ "MD024": { "siblings_only": true }
+}
diff --git a/client/api.js b/client/api.js
index 0f9914c..a4bface 100644
--- a/client/api.js
+++ b/client/api.js
@@ -144,6 +144,13 @@ export const api = {
createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data),
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),
deleteBill: (id) => del(`/bills/${id}`),
diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx
index 06ed368..71638f2 100644
--- a/client/pages/LoginPage.jsx
+++ b/client/pages/LoginPage.jsx
@@ -152,9 +152,18 @@ export default function LoginPage() {
)}
diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx
index e50e7ef..40792c1 100644
--- a/client/pages/SnowballPage.jsx
+++ b/client/pages/SnowballPage.jsx
@@ -32,21 +32,31 @@ function ordinal(n) {
}
// ── Client-side snowball simulation (mirrors server snowballService) ───────────
-// Runs in the browser so the payoff date updates instantly as the user types.
-function computeLiveAttackPayoff(bills, extraPayment) {
+// Returns the full projection shape so the panel and attack card both update
+// 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 active = [];
+ const active = [];
+ const skipped = [];
+
for (const d of bills) {
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({
- balance: bal,
- minPayment: Math.max(0, Number(d.minimum_payment) || 0),
- monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
- payoffMonth: null,
+ 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 null;
let rollingExtra = extra;
@@ -58,7 +68,9 @@ function computeLiveAttackPayoff(bills, extraPayment) {
for (let i = 0; i < active.length; i++) {
const d = active[i];
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);
d.balance = Math.max(0, d.balance - payment);
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 baseYear = now.getFullYear();
+ const baseMo = now.getMonth();
- const now = new Date();
- const date = new Date(now.getFullYear(), now.getMonth() + first.payoffMonth, 1);
- return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
+ 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 ──────────────────────────────────────────────────────────────────
@@ -413,12 +455,22 @@ export default function SnowballPage() {
}
};
- // ── live payoff preview (updates as user types extra amount) ─────────────
- const liveAttackPayoff = useMemo(
- () => computeLiveAttackPayoff(bills, extraPayment),
+ // ── live projection (updates as user types extra amount) ─────────────────
+ // Full simulation runs client-side so both the attack card and the
+ // projection panel update instantly — no network round-trip.
+ const liveSnowball = useMemo(
+ () => computeLiveProjection(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 ─────────────────────────────────────────────────────────────────
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 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 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
- ? projection?.snowball?.debts?.[0]
+ ? displayProjection?.snowball?.debts?.[0]
: null;
return (
@@ -696,8 +748,8 @@ export default function SnowballPage() {
{/* Projection (sticky sidebar on large screens) */}
diff --git a/client/public/img/auth.svg b/client/public/img/auth.svg
new file mode 100644
index 0000000..6fd48b0
--- /dev/null
+++ b/client/public/img/auth.svg
@@ -0,0 +1,11 @@
+
diff --git a/package.json b/package.json
index c3045dc..67e0752 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.27.02",
+ "version": "0.27.04",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/aboutAdmin.js b/routes/aboutAdmin.js
index 8dee651..1721384 100644
--- a/routes/aboutAdmin.js
+++ b/routes/aboutAdmin.js
@@ -457,16 +457,6 @@ router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
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
router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
try {
diff --git a/routes/bills.js b/routes/bills.js
index 1ce1878..a401a77 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
+const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter');
// ── GET /api/bills ────────────────────────────────────────────────────────────
@@ -499,6 +500,58 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
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 ──
router.patch('/:id/snowball', (req, res) => {
const db = getDb();
diff --git a/routes/snowball.js b/routes/snowball.js
index b288b8e..3cfdf39 100644
--- a/routes/snowball.js
+++ b/routes/snowball.js
@@ -3,6 +3,7 @@ const router = express.Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
+const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1
@@ -18,24 +19,24 @@ const DEBT_LIKE_CLAUSES = `(
)
)`;
+const DEBT_QUERY = `
+ 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
+`;
+
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const db = getDb();
- const bills = db.prepare(`
- 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);
-
- res.json(bills);
+ res.json(db.prepare(DEBT_QUERY).all(req.user.id));
});
// 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(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
- 'extra_payment'
+ 'extra_payment',
));
}
}
@@ -66,34 +67,70 @@ router.patch('/settings', (req, res) => {
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) => {
const db = getDb();
- const bills = db.prepare(`
- 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 bills = db.prepare(DEBT_QUERY).all(req.user.id);
+ const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
+ const extra = user?.snowball_extra_payment ?? 0;
- const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
- const extraPayment = 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;
+ }
- const now = new Date();
- const snowball = calculateSnowball(bills, extraPayment, now);
- const avalanche = calculateAvalanche(bills, extraPayment, now);
+ // 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,
+ })),
+ };
+ }
- res.json({ snowball, avalanche });
+ const now = new Date();
+ const snowball = enrich(calculateSnowball(bills, extra, now));
+ const avalanche = enrich(calculateAvalanche(bills, extra, now));
+ const minimum_only = enrich(calculateMinimumOnly(bills, now));
+
+ // 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
router.patch('/order', (req, res) => {
const items = req.body;
@@ -101,13 +138,13 @@ router.patch('/order', (req, res) => {
return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
}
- const db = getDb();
+ const db = getDb();
const userId = req.user.id;
const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?');
db.transaction((rows) => {
for (const row of rows) {
- const id = parseInt(row.id, 10);
+ const id = parseInt(row.id, 10);
const order = parseInt(row.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) continue;
if (!Number.isInteger(order) || order < 0) continue;
diff --git a/routes/version.js b/routes/version.js
index 7c1d137..82f1bc5 100644
--- a/routes/version.js
+++ b/routes/version.js
@@ -41,9 +41,10 @@ function parseHistory() {
return { version, notes };
}
-// GET /api/version
+// GET /api/version — version always comes from package.json; notes from HISTORY.md
router.get('/', (req, res) => {
- res.json(parseHistory());
+ const { notes } = parseHistory();
+ res.json({ version: pkg.version, notes });
});
// GET /api/version/history
diff --git a/services/aprService.js b/services/aprService.js
new file mode 100644
index 0000000..29ae5ba
--- /dev/null
+++ b/services/aprService.js
@@ -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,
+};