const express = require('express'); 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 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%' ) ) )`; 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) { const db = getDb(); return db.prepare(getDebtQuery(isRamseyMode(userId))).all(userId); } // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order router.get('/', (req, res) => { res.json(getDebtBills(req.user.id)); }); // 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); res.json({ extra_payment: 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, 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) { return res.status(400).json(standardizeError( 'extra_payment must be a non-negative number', 'VALIDATION_ERROR', 'extra_payment', )); } } const db = getDb(); const save = db.transaction(() => { if (extra_payment !== undefined) { db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(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: 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'), }); }); // 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 = getDebtBills(req.user.id); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); const extra = user?.snowball_extra_payment ?? 0; // 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 = enrich(calculateSnowball(bills, extra, now)); const avalanche = enrich(calculateAvalanche(bills, extra, now)); const minimum_only = enrich(calculateMinimumOnly(bills, now)); // Comparison: what does the snowball save vs just paying minimums? const comparison = buildComparison(snowball, minimum_only); res.json({ snowball, avalanche, minimum_only, comparison }); }); // Build a summary comparing snowball to the minimum-only baseline function buildComparison(snowball, minimum_only) { const sbMonths = snowball.months_to_freedom; const moMonths = minimum_only.months_to_freedom; const sbInterest = snowball.total_interest_paid; const moInterest = minimum_only.total_interest_paid; if (!sbMonths || !moMonths) return null; const months_saved = moMonths - sbMonths; const interest_saved = Math.round((moInterest - sbInterest) * 100) / 100; const years_saved = +(months_saved / 12).toFixed(1); return { months_saved, years_saved, interest_saved, minimum_only_months: moMonths, minimum_only_interest: moInterest, minimum_only_payoff: minimum_only.payoff_display, snowball_months: sbMonths, snowball_interest: sbInterest, snowball_payoff: snowball.payoff_display, }; } // PATCH /api/snowball/order — batch-save snowball_order positions router.patch('/order', (req, res) => { const items = req.body; if (!Array.isArray(items)) { return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR')); } const db = getDb(); const userId = req.user.id; const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?'); db.transaction((rows) => { for (const row of rows) { const id = parseInt(row.id, 10); const order = parseInt(row.snowball_order, 10); if (!Number.isInteger(id) || id <= 0) continue; if (!Number.isInteger(order) || order < 0) continue; update.run(order, id, userId); } })(items); res.json({ success: true }); }); module.exports = router;