BillTracker/routes/snowball.cts

554 lines
18 KiB
TypeScript

// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database.cts');
const { ValidationError, NotFoundError } = require('../utils/apiError.cts');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts');
const { serializeBill } = require('../services/billsService.cts');
const { toCents, fromCents } = require('../utils/money.mts');
const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1
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%'
)
)
)`;
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 `
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 AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.active = 1
AND b.deleted_at IS NULL
AND ${DEBT_LIKE_CLAUSES}
ORDER BY${orderBy}
`;
}
function getDebtBills(userId, ramseyMode) {
const db = getDb();
const mode = ramseyMode !== undefined ? ramseyMode : isRamseyMode(userId);
return db.prepare(getDebtQuery(mode)).all(userId);
}
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req: Req, res: Res) => {
const ramseyMode = isRamseyMode(req.user.id);
res.json(getDebtBills(req.user.id, ramseyMode).map(serializeBill));
});
// GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req: Req, res: Res) => {
const db = getDb();
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),
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'),
});
});
// PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req: Req, res: Res) => {
const { extra_payment, ramsey_mode, ready_current_on_bills, ready_emergency_fund } = req.body;
let val = 0;
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) {
throw ValidationError('extra_payment must be a non-negative number', 'extra_payment');
}
}
const db = getDb();
const save = db.transaction(() => {
if (extra_payment !== undefined) {
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(
toCents(val),
req.user.id,
);
}
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),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
});
});
// 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: Req, res: Res) => {
const db = getDb();
const ramseyMode = isRamseyMode(req.user.id);
const bills = getDebtBills(req.user.id, ramseyMode);
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);
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
const aprByBill = {};
for (const b of billsForMath) {
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));
// 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: Req, res: Res) => {
const items = req.body;
if (!Array.isArray(items)) {
throw ValidationError('Request body must be an array');
}
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) {
throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`);
}
if (!Number.isInteger(order) || order < 0) {
throw ValidationError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
);
}
parsed.push({ id, order });
}
const db = getDb();
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) {
update.run(order, id, userId);
}
})();
res.json({ success: true, updated: parsed.length });
});
// ── Snowball Plan helpers ─────────────────────────────────────────────────────
function enrichPlanWithProgress(db, plan) {
let snapshot;
try {
snapshot = JSON.parse(plan.plan_snapshot);
} catch {
snapshot = null;
}
// Fetch every snapshot bill in one query (user-scoped), not one-per-debt.
const snapDebts = snapshot?.debts ?? [];
const billIds = [...new Set(snapDebts.map((d) => d.bill_id).filter(Boolean))];
const billsById = {};
if (billIds.length) {
const placeholders = billIds.map(() => '?').join(',');
for (const b of db
.prepare(
`SELECT id, current_balance, name, deleted_at FROM bills WHERE id IN (${placeholders}) AND user_id = ?`,
)
.all(...billIds, plan.user_id)) {
billsById[b.id] = b;
}
}
const currentDebts = snapDebts.map((d) => {
const bill = billsById[d.bill_id];
const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null;
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,
};
}
// POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req: Req, res: Res) => {
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);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) {
throw ValidationError(
'No debts with a balance found. Add a balance to at least one bill.',
undefined,
'NO_DEBTS',
);
}
// 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);
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) => {
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);
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan));
});
// GET /api/snowball/plans — list all plans for user
router.get('/plans', (req: Req, res: Res) => {
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)) });
});
// GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req: Req, res: Res) => {
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);
});
// PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id');
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) throw NotFoundError('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));
});
// Shared status-transition handler for pause / resume / complete / abandon.
// `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, past }) {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
throw ValidationError('Invalid plan id', 'id');
}
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) throw NotFoundError('Plan not found');
if (!allowedFrom.includes(plan.status)) {
throw ValidationError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
undefined,
'INVALID_PLAN_STATE',
);
}
db.prepare(`UPDATE snowball_plans SET ${setSql}, 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));
}
// POST /api/snowball/plans/:id/{pause,resume,complete,abandon}
router.post('/plans/:id/pause', (req: Req, res: Res) =>
transitionPlan(req, res, {
allowedFrom: ['active'],
setSql: "status = 'paused', paused_at = datetime('now')",
action: 'pause',
past: 'paused',
}),
);
router.post('/plans/:id/resume', (req: Req, res: Res) =>
transitionPlan(req, res, {
allowedFrom: ['paused'],
setSql: "status = 'active', paused_at = NULL",
action: 'resume',
past: 'resumed',
}),
);
router.post('/plans/:id/complete', (req: Req, res: Res) =>
transitionPlan(req, res, {
allowedFrom: ['active', 'paused'],
setSql: "status = 'completed', completed_at = datetime('now')",
action: 'complete',
past: 'completed',
}),
);
router.post('/plans/:id/abandon', (req: Req, res: Res) =>
transitionPlan(req, res, {
allowedFrom: ['active', 'paused'],
setSql: "status = 'abandoned'",
action: 'abandon',
past: 'abandoned',
}),
);
module.exports = router;