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');
|
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 '%mortgage%'
|
|
|
|
|
OR LOWER(c.name) LIKE '%housing%'
|
|
|
|
|
OR LOWER(c.name) LIKE '%debt%'
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-14 02:11:54 -05:00
|
|
|
)`;
|
|
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
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
|
|
|
|
|
`;
|
|
|
|
|
|
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 db = getDb();
|
2026-05-15 00:03:32 -05:00
|
|
|
res.json(db.prepare(DEBT_QUERY).all(req.user.id));
|
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);
|
|
|
|
|
res.json({ extra_payment: user?.snowball_extra_payment ?? 0 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /api/snowball/settings — save extra monthly payment
|
|
|
|
|
router.patch('/settings', (req, res) => {
|
|
|
|
|
const { extra_payment } = 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',
|
2026-05-15 00:03:32 -05:00
|
|
|
'extra_payment',
|
2026-05-14 02:11:54 -05:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = getDb();
|
|
|
|
|
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
|
|
|
|
|
res.json({ extra_payment: val });
|
|
|
|
|
});
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
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 extra = 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 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));
|
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'));
|
|
|
|
|
}
|
|
|
|
|
|
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 = ?');
|
|
|
|
|
|
|
|
|
|
db.transaction((rows) => {
|
|
|
|
|
for (const row of rows) {
|
2026-05-15 00:03:32 -05:00
|
|
|
const id = parseInt(row.id, 10);
|
2026-05-14 02:11:54 -05:00
|
|
|
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;
|