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;