BillTracker/routes/snowball.js

518 lines
20 KiB
JavaScript
Raw Permalink Normal View History

2026-05-14 02:11:54 -05:00
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
2026-05-15 00:03:32 -05:00
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
const { serializeBill } = require('../services/billsService');
const { toCents, fromCents } = require('../utils/money');
2026-05-14 02:11:54 -05:00
const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1
2026-05-14 03:00:01 -05:00
OR (
COALESCE(b.snowball_exempt, 0) = 0
AND (
LOWER(c.name) LIKE '%credit%'
OR LOWER(c.name) LIKE '%loan%'
OR LOWER(c.name) LIKE '%debt%'
OR LOWER(c.name) LIKE '%mortgage%'
OR LOWER(c.name) LIKE '%housing%'
2026-05-14 03:00:01 -05:00
)
)
2026-05-14 02:11:54 -05:00
)`;
2026-05-16 10:17:24 -05:00
function isRamseyMode(userId) {
const db = getDb();
const row = db.prepare(`
SELECT value
FROM user_settings
WHERE user_id = ? AND key = 'snowball_ramsey_mode'
`).get(userId);
return row ? row.value !== 'false' && row.value !== '0' : true;
}
function getUserBoolSetting(userId, key, fallback = false) {
const db = getDb();
const row = db.prepare(`
SELECT value
FROM user_settings
WHERE user_id = ? AND key = ?
`).get(userId, key);
if (!row) return fallback;
return row.value === 'true' || row.value === '1';
}
function upsertUserSetting(db, userId, key, value) {
db.prepare(`
INSERT INTO user_settings (user_id, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')
`).run(userId, key, String(value));
}
function getDebtQuery(ramseyMode) {
const orderBy = ramseyMode
? `
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC,
LOWER(b.name) ASC,
b.id ASC`
: `
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`;
return `
2026-05-15 00:03:32 -05:00
SELECT b.*, c.name AS category_name
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id AND c.deleted_at IS NULL
2026-05-15 00:03:32 -05:00
WHERE b.user_id = ?
AND b.active = 1
2026-05-16 10:34:32 -05:00
AND b.deleted_at IS NULL
2026-05-15 00:03:32 -05:00
AND ${DEBT_LIKE_CLAUSES}
2026-05-16 10:17:24 -05:00
ORDER BY${orderBy}
`;
}
function getDebtBills(userId, ramseyMode) {
2026-05-16 10:17:24 -05:00
const db = getDb();
const mode = ramseyMode !== undefined ? ramseyMode : isRamseyMode(userId);
return db.prepare(getDebtQuery(mode)).all(userId);
2026-05-16 10:17:24 -05:00
}
2026-05-15 00:03:32 -05:00
2026-05-14 02:11:54 -05:00
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const ramseyMode = isRamseyMode(req.user.id);
res.json(getDebtBills(req.user.id, ramseyMode).map(serializeBill));
2026-05-14 02:11:54 -05:00
});
// GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
2026-05-16 10:17:24 -05:00
res.json({
extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
2026-05-16 10:17:24 -05:00
ramsey_mode: isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
});
2026-05-14 02:11:54 -05:00
});
// PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req, res) => {
2026-05-16 10:17:24 -05:00
const { extra_payment, ramsey_mode, ready_current_on_bills, ready_emergency_fund } = req.body;
2026-05-14 02:11:54 -05:00
let val = 0;
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) {
return res.status(400).json(standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
2026-05-15 00:03:32 -05:00
'extra_payment',
2026-05-14 02:11:54 -05:00
));
}
}
const db = getDb();
2026-05-16 10:17:24 -05:00
const save = db.transaction(() => {
if (extra_payment !== undefined) {
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(toCents(val), req.user.id);
2026-05-16 10:17:24 -05:00
}
if (ramsey_mode !== undefined) {
upsertUserSetting(db, req.user.id, 'snowball_ramsey_mode', ramsey_mode ? 'true' : 'false');
}
if (ready_current_on_bills !== undefined) {
upsertUserSetting(db, req.user.id, 'snowball_ready_current_on_bills', ready_current_on_bills ? 'true' : 'false');
}
if (ready_emergency_fund !== undefined) {
upsertUserSetting(db, req.user.id, 'snowball_ready_emergency_fund', ready_emergency_fund ? 'true' : 'false');
}
});
save();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({
extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
// Use body value when ramsey_mode was just saved; fall back to DB read if not in request
ramsey_mode: ramsey_mode !== undefined ? !!ramsey_mode : isRamseyMode(req.user.id),
2026-05-16 10:17:24 -05:00
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
});
2026-05-14 02:11:54 -05:00
});
2026-05-15 00:03:32 -05:00
// GET /api/snowball/projection — snowball, avalanche, minimum-only projections
// Each debt result is enriched with current APR metrics (monthly interest, etc.)
2026-05-14 02:11:54 -05:00
router.get('/projection', (req, res) => {
const db = getDb();
const ramseyMode = isRamseyMode(req.user.id);
const bills = getDebtBills(req.user.id, ramseyMode);
2026-05-15 00:03:32 -05:00
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
// Money fields on `bills` are stored as integer cents; the snowball/APR math
// and the API response are dollar-denominated, so convert before computing.
const billsForMath = bills.map(b => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
// Allow an optional ?extra=N override so the client can preview an unsaved
// extra payment without a round-trip save. Falls back to the stored value.
const queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN;
const extra = Number.isFinite(queryExtra) && queryExtra >= 0
? queryExtra
: fromCents(user?.snowball_extra_payment ?? 0);
2026-05-14 02:11:54 -05:00
2026-05-15 00:03:32 -05:00
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
const aprByBill = {};
for (const b of billsForMath) {
2026-05-15 00:03:32 -05:00
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 = enrich(calculateSnowball(billsForMath, extra, now));
const avalanche = enrich(calculateAvalanche(billsForMath, extra, now));
const minimum_only = enrich(calculateMinimumOnly(billsForMath, now));
2026-05-14 02:11:54 -05:00
2026-05-15 00:03:32 -05:00
// Comparison: what does the snowball save vs just paying minimums?
const comparison = buildComparison(snowball, minimum_only);
2026-05-14 02:11:54 -05:00
2026-05-15 00:03:32 -05:00
res.json({ snowball, avalanche, minimum_only, comparison });
2026-05-14 02:11:54 -05:00
});
2026-05-15 00:03:32 -05:00
// 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,
};
}
2026-05-14 02:11:54 -05:00
// PATCH /api/snowball/order — batch-save snowball_order positions
router.patch('/order', (req, res) => {
const items = req.body;
if (!Array.isArray(items)) {
return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
}
if (items.length === 0) {
return res.json({ success: true, updated: 0 });
}
// Validate every row before touching the DB — no silent skips
const parsed = [];
for (let i = 0; i < items.length; i++) {
const row = items[i];
const id = parseInt(row?.id, 10);
const order = parseInt(row?.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError(
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
'VALIDATION_ERROR',
));
}
if (!Number.isInteger(order) || order < 0) {
return res.status(400).json(standardizeError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
'VALIDATION_ERROR',
));
}
parsed.push({ id, order });
}
2026-05-14 02:11:54 -05:00
2026-05-15 00:03:32 -05:00
const db = getDb();
2026-05-14 02:11:54 -05:00
const userId = req.user.id;
const update = db.prepare(
'UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
);
db.transaction(() => {
for (const { id, order } of parsed) {
2026-05-14 02:11:54 -05:00
update.run(order, id, userId);
}
})();
2026-05-14 02:11:54 -05:00
res.json({ success: true, updated: parsed.length });
2026-05-14 02:11:54 -05:00
});
2026-05-30 17:27:15 -05:00
// ── Snowball Plan helpers ─────────────────────────────────────────────────────
function enrichPlanWithProgress(db, plan) {
let snapshot;
try { snapshot = JSON.parse(plan.plan_snapshot); } catch { snapshot = null; }
const currentDebts = (snapshot?.debts ?? []).map(d => {
const bill = db.prepare('SELECT current_balance, name, deleted_at FROM bills WHERE id = ?').get(d.bill_id);
const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null;
2026-05-30 17:27:15 -05:00
const startingBalance = d.starting_balance ?? 0;
const progressPct = startingBalance > 0 && currentBalance !== null
? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100)))
: null;
return { bill_id: d.bill_id, name: d.name, current_balance: currentBalance, starting_balance: startingBalance, progress_pct: progressPct, deleted: !!(bill?.deleted_at) };
});
const startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now();
const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30));
return { ...plan, extra_payment: fromCents(plan.extra_payment), plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
2026-05-30 17:27:15 -05:00
}
// POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const { name, method, notes } = req.body;
const planName = (typeof name === 'string' && name.trim()) ? name.trim().slice(0, 100) : 'Snowball Plan';
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
const ramseyMode = isRamseyMode(userId);
const debts = getDebtBills(userId, ramseyMode);
2026-05-30 17:27:15 -05:00
const activeDebts = debts.filter(b => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) {
return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' });
}
// Money fields on `debts` are stored as integer cents; the snowball/APR
// math and plan_snapshot are dollar-denominated, so convert before computing.
const debtsForMath = debts.map(b => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debtsForMath, extra, now) : calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
2026-05-30 17:27:15 -05:00
const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100);
const debtSnaps = debtsForMath.map((b, i) => {
2026-05-30 17:27:15 -05:00
const proj = snowball.debts?.find(d => d.id === b.id);
return {
bill_id: b.id,
name: b.name,
starting_balance: b.current_balance ?? 0,
minimum_payment: b.minimum_payment ?? 0,
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
const planSnapshot = JSON.stringify({
projected_payoff_date: snowball.payoff_date ?? null,
projected_months: snowball.months_to_freedom ?? null,
projected_total_interest: snowball.total_interest_paid ?? null,
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
// Abandon any existing active/paused plan first
db.prepare(`
UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now')
WHERE user_id = ? AND status IN ('active', 'paused')
`).run(userId);
const result = db.prepare(`
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
`).run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
2026-05-30 17:27:15 -05:00
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan));
} catch (err) {
console.error('[snowball plans] POST error:', err.message);
res.status(500).json({ error: 'Failed to start plan' });
}
});
// GET /api/snowball/plans — list all plans for user
router.get('/plans', (req, res) => {
try {
const db = getDb();
const plans = db.prepare(`
SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json({ plans: plans.map(p => enrichPlanWithProgress(db, p)) });
} catch (err) {
console.error('[snowball plans] GET /plans error:', err.message);
res.status(500).json({ error: 'Failed to load plans' });
}
});
// GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req, res) => {
try {
const db = getDb();
const plan = db.prepare(`
SELECT * FROM snowball_plans
WHERE user_id = ? AND status IN ('active', 'paused')
ORDER BY created_at DESC LIMIT 1
`).get(req.user.id);
res.json(plan ? enrichPlanWithProgress(db, plan) : null);
} catch (err) {
console.error('[snowball plans] GET /plans/active error:', err.message);
res.status(500).json({ error: 'Failed to load active plan' });
}
});
// PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req, res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!plan) return res.status(404).json({ error: 'Plan not found' });
const { name, notes } = req.body;
const newName = (typeof name === 'string' && name.trim()) ? name.trim().slice(0, 100) : plan.name;
const newNotes = notes !== undefined ? (notes || null) : plan.notes;
db.prepare(`
UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`).run(newName, newNotes, id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
console.error('[snowball plans] PATCH error:', err.message);
res.status(500).json({ error: 'Failed to update plan' });
}
});
// POST /api/snowball/plans/:id/pause
router.post('/plans/:id/pause', (req, res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!plan) return res.status(404).json({ error: 'Plan not found' });
if (plan.status !== 'active') return res.status(400).json({ error: 'Only active plans can be paused' });
db.prepare(`
UPDATE snowball_plans SET status = 'paused', paused_at = datetime('now'), updated_at = datetime('now') WHERE id = ?
`).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
console.error('[snowball plans] pause error:', err.message);
res.status(500).json({ error: 'Failed to pause plan' });
}
});
// POST /api/snowball/plans/:id/resume
router.post('/plans/:id/resume', (req, res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!plan) return res.status(404).json({ error: 'Plan not found' });
if (plan.status !== 'paused') return res.status(400).json({ error: 'Only paused plans can be resumed' });
db.prepare(`
UPDATE snowball_plans SET status = 'active', paused_at = NULL, updated_at = datetime('now') WHERE id = ?
`).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
console.error('[snowball plans] resume error:', err.message);
res.status(500).json({ error: 'Failed to resume plan' });
}
});
// POST /api/snowball/plans/:id/complete
router.post('/plans/:id/complete', (req, res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!plan) return res.status(404).json({ error: 'Plan not found' });
if (!['active', 'paused'].includes(plan.status)) return res.status(400).json({ error: 'Only active or paused plans can be completed' });
db.prepare(`
UPDATE snowball_plans SET status = 'completed', completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?
`).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
console.error('[snowball plans] complete error:', err.message);
res.status(500).json({ error: 'Failed to complete plan' });
}
});
// POST /api/snowball/plans/:id/abandon
router.post('/plans/:id/abandon', (req, res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!plan) return res.status(404).json({ error: 'Plan not found' });
if (!['active', 'paused'].includes(plan.status)) return res.status(400).json({ error: 'Only active or paused plans can be abandoned' });
db.prepare(`
UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') WHERE id = ?
`).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
console.error('[snowball plans] abandon error:', err.message);
res.status(500).json({ error: 'Failed to abandon plan' });
}
});
2026-05-14 02:11:54 -05:00
module.exports = router;