BillTracker/routes/snowball.js

122 lines
3.9 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 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%'
)
)
)`;
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const db = getDb();
const bills = db.prepare(`
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
`).all(req.user.id);
res.json(bills);
});
// 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 — payoff timeline using the snowball math service
router.get('/projection', (req, res) => {
const db = getDb();
const bills = db.prepare(`
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
`).all(req.user.id);
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
const extraPayment = user?.snowball_extra_payment ?? 0;
const now = new Date();
const snowball = calculateSnowball(bills, extraPayment, now);
const avalanche = calculateAvalanche(bills, extraPayment, now);
res.json({ snowball, avalanche });
});
// 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;