2026-06-04 04:31:25 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const express = require('express');
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
const { getDb } = require('../db/database');
|
|
|
|
|
const {
|
|
|
|
|
getSpendingSummary, getSpendingTransactions, categorizeTransaction,
|
|
|
|
|
getSpendingBudgets, setSpendingBudget,
|
|
|
|
|
getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule,
|
2026-06-04 19:53:38 -05:00
|
|
|
getIncomeTransactions,
|
2026-06-04 04:31:25 -05:00
|
|
|
} = require('../services/spendingService');
|
|
|
|
|
|
|
|
|
|
function parseYM(source) {
|
|
|
|
|
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) return { error: 'Invalid year' };
|
|
|
|
|
if (isNaN(month) || month < 1 || month > 12) return { error: 'Invalid month' };
|
|
|
|
|
return { year, month };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/summary?year=&month=
|
|
|
|
|
router.get('/summary', (req, res) => {
|
|
|
|
|
const ym = parseYM(req.query);
|
|
|
|
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
|
|
|
|
try {
|
|
|
|
|
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/summary]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to load spending summary' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
|
|
|
|
|
router.get('/transactions', (req, res) => {
|
|
|
|
|
const ym = parseYM(req.query);
|
|
|
|
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
|
|
|
|
|
|
|
|
|
const { category_id, page, limit } = req.query;
|
|
|
|
|
const categoryId = category_id === 'null' ? null
|
|
|
|
|
: category_id !== undefined ? parseInt(category_id, 10)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
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),
|
|
|
|
|
}));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/transactions]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to load transactions' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /api/spending/transactions/:id/category
|
|
|
|
|
router.patch('/transactions/:id/category', (req, res) => {
|
|
|
|
|
const txId = parseInt(req.params.id, 10);
|
|
|
|
|
if (isNaN(txId)) return res.status(400).json({ error: 'Invalid transaction ID' });
|
|
|
|
|
|
|
|
|
|
const { category_id, save_rule } = req.body || {};
|
|
|
|
|
const categoryId = category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(err.status || 500).json({ error: err.message || 'Failed to categorize transaction' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/budgets?year=&month=
|
|
|
|
|
router.get('/budgets', (req, res) => {
|
|
|
|
|
const ym = parseYM(req.query);
|
|
|
|
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
2026-06-04 19:53:38 -05:00
|
|
|
try {
|
|
|
|
|
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/budgets GET]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to load budgets' });
|
|
|
|
|
}
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-04 21:57:42 -05:00
|
|
|
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month
|
|
|
|
|
router.post('/budgets/copy', (req, res) => {
|
|
|
|
|
const ym = parseYM(req.body || {});
|
|
|
|
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
|
|
|
|
|
|
|
|
|
// Previous month
|
|
|
|
|
let prevYear = ym.year, prevMonth = ym.month - 1;
|
|
|
|
|
if (prevMonth < 1) { prevMonth = 12; prevYear--; }
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
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) });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/budgets/copy]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to copy budgets' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
// PUT /api/spending/budgets — { category_id, year, month, amount }
|
|
|
|
|
router.put('/budgets', (req, res) => {
|
|
|
|
|
const { category_id, year, month, amount } = req.body || {};
|
|
|
|
|
if (!category_id) return res.status(400).json({ error: 'category_id required' });
|
|
|
|
|
const ym = parseYM({ year, month });
|
|
|
|
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setSpendingBudget(getDb(), req.user.id, parseInt(category_id, 10), ym.year, ym.month, amount ?? null);
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
} catch (err) {
|
2026-06-04 19:53:38 -05:00
|
|
|
console.error('[spending/budgets PUT]', err.message);
|
2026-06-04 04:31:25 -05:00
|
|
|
res.status(500).json({ error: 'Failed to save budget' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/category-rules
|
|
|
|
|
router.get('/category-rules', (req, res) => {
|
2026-06-04 19:53:38 -05:00
|
|
|
try {
|
|
|
|
|
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/category-rules GET]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to load rules' });
|
|
|
|
|
}
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/spending/category-rules — { category_id, merchant }
|
|
|
|
|
router.post('/category-rules', (req, res) => {
|
|
|
|
|
const { category_id, merchant } = req.body || {};
|
|
|
|
|
if (!category_id || !merchant) return res.status(400).json({ error: 'category_id and merchant required' });
|
|
|
|
|
try {
|
|
|
|
|
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
} catch (err) {
|
2026-06-04 19:53:38 -05:00
|
|
|
console.error('[spending/category-rules POST]', err.message);
|
2026-06-04 04:31:25 -05:00
|
|
|
res.status(err.status || 500).json({ error: err.message || 'Failed to save rule' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /api/spending/category-rules/:id
|
|
|
|
|
router.delete('/category-rules/:id', (req, res) => {
|
2026-06-04 19:53:38 -05:00
|
|
|
try {
|
|
|
|
|
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/category-rules DELETE]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to delete rule' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/income?year=&month=&page=
|
|
|
|
|
router.get('/income', (req, res) => {
|
|
|
|
|
const ym = parseYM(req.query);
|
|
|
|
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
|
|
|
|
try {
|
|
|
|
|
res.json(getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
2026-06-04 21:19:25 -05:00
|
|
|
page: parseInt(req.query.page || '1', 10),
|
|
|
|
|
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
|
|
|
|
|
includeIgnored: req.query.include_ignored === 'true',
|
2026-06-04 19:53:38 -05:00
|
|
|
}));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[spending/income]', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to load income transactions' });
|
|
|
|
|
}
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|