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),
|
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}`),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
balance: bal,
|
id: d.id,
|
||||||
minPayment: Math.max(0, Number(d.minimum_payment) || 0),
|
name: d.name,
|
||||||
monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
|
balance: bal,
|
||||||
payoffMonth: null,
|
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;
|
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];
|
const now = new Date();
|
||||||
if (!first?.payoffMonth) return null;
|
const baseYear = now.getFullYear();
|
||||||
|
const baseMo = now.getMonth();
|
||||||
|
|
||||||
const now = new Date();
|
function monthLabel(m) {
|
||||||
const date = new Date(now.getFullYear(), now.getMonth() + first.payoffMonth, 1);
|
const d = new Date(baseYear, baseMo + m, 1);
|
||||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,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
|
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const bills = db.prepare(`
|
res.json(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);
|
|
||||||
|
|
||||||
res.json(bills);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
||||||
FROM bills b
|
const extra = user?.snowball_extra_payment ?? 0;
|
||||||
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);
|
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
|
||||||
const extraPayment = user?.snowball_extra_payment ?? 0;
|
const aprByBill = {};
|
||||||
|
for (const b of bills) {
|
||||||
|
const snap = debtAprSnapshot(b);
|
||||||
|
if (snap) aprByBill[b.id] = snap;
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
// Enrich each debt result with its APR snapshot
|
||||||
const snowball = calculateSnowball(bills, extraPayment, now);
|
function enrich(projection) {
|
||||||
const avalanche = calculateAvalanche(bills, extraPayment, now);
|
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
|
// 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;
|
||||||
|
|
@ -101,13 +138,13 @@ router.patch('/order', (req, res) => {
|
||||||
return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
|
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 userId = req.user.id;
|
||||||
const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?');
|
const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?');
|
||||||
|
|
||||||
db.transaction((rows) => {
|
db.transaction((rows) => {
|
||||||
for (const row of 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);
|
const order = parseInt(row.snowball_order, 10);
|
||||||
if (!Number.isInteger(id) || id <= 0) continue;
|
if (!Number.isInteger(id) || id <= 0) continue;
|
||||||
if (!Number.isInteger(order) || order < 0) continue;
|
if (!Number.isInteger(order) || order < 0) continue;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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