BillTracker/routes/snowball.js

159 lines
5.2 KiB
JavaScript

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 '%mortgage%'
OR LOWER(c.name) LIKE '%housing%'
OR LOWER(c.name) LIKE '%debt%'
)
)
)`;
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
router.get('/', (req, res) => {
const db = getDb();
res.json(db.prepare(DEBT_QUERY).all(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 });
});
// 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',
'extra_payment',
));
}
}
const db = getDb();
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
res.json({ extra_payment: val });
});
// 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 = 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;
// 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;