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');
|
|
|
|
|
|
|
|
|
|
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
|
|
|
)`;
|
|
|
|
|
|
|
|
|
|
// 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;
|