apr/snowball 0.27.04
This commit is contained in:
parent
d720931894
commit
576163e85b
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"MD024": { "siblings_only": true }
|
||||
}
|
||||
|
|
@ -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}`),
|
||||
|
|
|
|||
|
|
@ -152,9 +152,18 @@ export default function LoginPage() {
|
|||
<Button
|
||||
type="button"
|
||||
variant={localEnabled ? 'outline' : 'default'}
|
||||
className="w-full"
|
||||
className="w-full gap-2"
|
||||
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}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 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({
|
||||
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 date = new Date(now.getFullYear(), now.getMonth() + first.payoffMonth, 1);
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||
const baseYear = now.getFullYear();
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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) */}
|
||||
<div className="lg:sticky lg:top-24 lg:self-start">
|
||||
<ProjectionPanel
|
||||
projection={projection}
|
||||
projectionLoading={projectionLoading}
|
||||
projection={displayProjection}
|
||||
projectionLoading={projectionLoading && !liveSnowball}
|
||||
billCount={bills.length}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.27.02",
|
||||
"version": "0.27.04",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,10 +19,7 @@ const DEBT_LIKE_CLAUSES = `(
|
|||
)
|
||||
)`;
|
||||
|
||||
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const bills = db.prepare(`
|
||||
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
|
||||
|
|
@ -33,9 +31,12 @@ router.get('/', (req, res) => {
|
|||
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);
|
||||
// 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
|
||||
|
|
@ -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 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 snowball = calculateSnowball(bills, extraPayment, now);
|
||||
const avalanche = calculateAvalanche(bills, extraPayment, now);
|
||||
const snowball = enrich(calculateSnowball(bills, extra, 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
|
||||
router.patch('/order', (req, res) => {
|
||||
const items = req.body;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in New Issue