BillTracker/routes/spending.cts

179 lines
5.6 KiB
TypeScript
Raw Normal View History

import type { Req, Res, Next } from '../types/http';
('use strict');
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database.cts');
const { ValidationError } = require('../utils/apiError.cts');
const {
getSpendingSummary,
getSpendingTransactions,
categorizeTransaction,
getSpendingBudgets,
setSpendingBudget,
getSpendingCategoryRules,
addSpendingCategoryRule,
deleteSpendingCategoryRule,
getIncomeTransactions,
} = require('../services/spendingService.cts');
// Throws ValidationError on bad input; service errors propagate to the
// terminal handler (4xx with .status pass their message through, 5xx are
// masked + logged there — no per-route try/catch, per CODE_STANDARDS.md).
function parseYM(source: any) {
const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100) throw ValidationError('Invalid year', 'year');
if (isNaN(month) || month < 1 || month > 12) throw ValidationError('Invalid month', 'month');
return { year, month };
}
// GET /api/spending/summary?year=&month=
router.get('/summary', (req: Req, res: Res) => {
const ym = parseYM(req.query);
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
});
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
router.get('/transactions', (req: Req, res: Res) => {
const ym = parseYM(req.query);
const { category_id, page, limit } = req.query;
const categoryId =
category_id === 'null'
? null
: category_id !== undefined
? parseInt(category_id, 10)
: undefined;
res.json(
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
categoryId,
uncategorizedOnly: category_id === 'null',
page: parseInt(page || '1', 10),
limit: Math.min(parseInt(limit || '50', 10), 200),
}),
);
});
// PATCH /api/spending/transactions/:id/category
router.patch('/transactions/:id/category', (req: Req, res: Res) => {
const txId = parseInt(req.params.id, 10);
if (isNaN(txId)) throw ValidationError('Invalid transaction ID', 'id');
const { category_id, save_rule } = req.body || {};
const categoryId =
category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
res.json({ ok: true });
});
// GET /api/spending/budgets?year=&month=
router.get('/budgets', (req: Req, res: Res) => {
const ym = parseYM(req.query);
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
});
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month
router.post('/budgets/copy', (req: Req, res: Res) => {
const ym = parseYM(req.body || {});
// Previous month
let prevYear = ym.year,
prevMonth = ym.month - 1;
if (prevMonth < 1) {
prevMonth = 12;
prevYear--;
}
const db = getDb();
const prev = db
.prepare(
`
SELECT category_id, amount FROM spending_budgets
WHERE user_id = ? AND year = ? AND month = ?
`,
)
.all(req.user.id, prevYear, prevMonth);
if (prev.length === 0) {
return res.json({
copied: 0,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
});
}
const upsert = db.prepare(`
INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
amount = excluded.amount,
updated_at = datetime('now')
`);
db.transaction(() => {
for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
})();
res.json({
copied: prev.length,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
});
});
// PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req: Req, res: Res) => {
const { category_id, year, month, amount } = req.body || {};
if (!category_id) throw ValidationError('category_id required', 'category_id');
const ym = parseYM({ year, month });
setSpendingBudget(
getDb(),
req.user.id,
parseInt(category_id, 10),
ym.year,
ym.month,
amount ?? null,
);
res.json({ ok: true });
});
// GET /api/spending/category-rules
router.get('/category-rules', (req: Req, res: Res) => {
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
});
// POST /api/spending/category-rules — { category_id, merchant }
router.post('/category-rules', (req: Req, res: Res) => {
const { category_id, merchant } = req.body || {};
if (!category_id || !merchant)
throw ValidationError(
'category_id and merchant required',
!category_id ? 'category_id' : 'merchant',
);
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
res.json({ ok: true });
});
// DELETE /api/spending/category-rules/:id
router.delete('/category-rules/:id', (req: Req, res: Res) => {
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true });
});
// GET /api/spending/income?year=&month=&page=
router.get('/income', (req: Req, res: Res) => {
const ym = parseYM(req.query);
res.json(
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
page: parseInt(req.query.page || '1', 10),
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
includeIgnored: req.query.include_ignored === 'true',
}),
);
});
module.exports = router;