From 576163e85ba789d224c85e095a5ff8c4bf9b62ee Mon Sep 17 00:00:00 2001 From: null Date: Fri, 15 May 2026 00:03:32 -0500 Subject: [PATCH] apr/snowball 0.27.04 --- .markdownlint.json | 3 + client/api.js | 7 + client/pages/LoginPage.jsx | 11 +- client/pages/SnowballPage.jsx | 94 ++++++++++--- client/public/img/auth.svg | 11 ++ package.json | 2 +- routes/aboutAdmin.js | 10 -- routes/bills.js | 53 +++++++ routes/snowball.js | 113 +++++++++------ routes/version.js | 5 +- services/aprService.js | 250 ++++++++++++++++++++++++++++++++++ 11 files changed, 486 insertions(+), 73 deletions(-) create mode 100644 .markdownlint.json create mode 100644 client/public/img/auth.svg create mode 100644 services/aprService.js 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, +};