2026-07-06 11:26:50 -05:00
|
|
|
import type { Req, Res, Next } from '../types/http';
|
2026-07-06 14:23:53 -05:00
|
|
|
('use strict');
|
2026-06-04 04:31:25 -05:00
|
|
|
|
|
|
|
|
const express = require('express');
|
2026-07-06 14:23:53 -05:00
|
|
|
const router = express.Router();
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb } = require('../db/database.cts');
|
2026-07-10 16:36:27 -05:00
|
|
|
const { ValidationError } = require('../utils/apiError.cts');
|
2026-06-04 04:31:25 -05:00
|
|
|
const {
|
2026-07-06 14:23:53 -05:00
|
|
|
getSpendingSummary,
|
|
|
|
|
getSpendingTransactions,
|
|
|
|
|
categorizeTransaction,
|
|
|
|
|
getSpendingBudgets,
|
|
|
|
|
setSpendingBudget,
|
|
|
|
|
getSpendingCategoryRules,
|
|
|
|
|
addSpendingCategoryRule,
|
|
|
|
|
deleteSpendingCategoryRule,
|
2026-06-04 19:53:38 -05:00
|
|
|
getIncomeTransactions,
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
} = require('../services/spendingService.cts');
|
2026-06-04 04:31:25 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
// 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).
|
2026-07-11 06:53:27 -05:00
|
|
|
function parseYM(source: any) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const now = new Date();
|
|
|
|
|
const year = parseInt(source.year || now.getFullYear(), 10);
|
2026-06-04 04:31:25 -05:00
|
|
|
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
2026-07-10 16:36:27 -05:00
|
|
|
if (isNaN(year) || year < 2000 || year > 2100) throw ValidationError('Invalid year', 'year');
|
|
|
|
|
if (isNaN(month) || month < 1 || month > 12) throw ValidationError('Invalid month', 'month');
|
2026-06-04 04:31:25 -05:00
|
|
|
return { year, month };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/summary?year=&month=
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/summary', (req: Req, res: Res) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
const ym = parseYM(req.query);
|
2026-07-10 16:36:27 -05:00
|
|
|
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/transactions', (req: Req, res: Res) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
const ym = parseYM(req.query);
|
|
|
|
|
|
|
|
|
|
const { category_id, page, limit } = req.query;
|
2026-07-06 14:23:53 -05:00
|
|
|
const categoryId =
|
|
|
|
|
category_id === 'null'
|
|
|
|
|
? null
|
|
|
|
|
: category_id !== undefined
|
|
|
|
|
? parseInt(category_id, 10)
|
|
|
|
|
: undefined;
|
2026-06-04 04:31:25 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
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),
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /api/spending/transactions/:id/category
|
2026-07-06 11:26:50 -05:00
|
|
|
router.patch('/transactions/:id/category', (req: Req, res: Res) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
const txId = parseInt(req.params.id, 10);
|
2026-07-10 16:36:27 -05:00
|
|
|
if (isNaN(txId)) throw ValidationError('Invalid transaction ID', 'id');
|
2026-06-04 04:31:25 -05:00
|
|
|
|
|
|
|
|
const { category_id, save_rule } = req.body || {};
|
2026-07-06 14:23:53 -05:00
|
|
|
const categoryId =
|
|
|
|
|
category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
|
2026-06-04 04:31:25 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
|
|
|
|
|
res.json({ ok: true });
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/budgets?year=&month=
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/budgets', (req: Req, res: Res) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
const ym = parseYM(req.query);
|
2026-07-10 16:36:27 -05:00
|
|
|
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
|
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
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/budgets/copy', (req: Req, res: Res) => {
|
2026-06-04 21:57:42 -05:00
|
|
|
const ym = parseYM(req.body || {});
|
|
|
|
|
|
|
|
|
|
// Previous month
|
2026-07-06 14:23:53 -05:00
|
|
|
let prevYear = ym.year,
|
|
|
|
|
prevMonth = ym.month - 1;
|
|
|
|
|
if (prevMonth < 1) {
|
|
|
|
|
prevMonth = 12;
|
|
|
|
|
prevYear--;
|
|
|
|
|
}
|
2026-06-04 21:57:42 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const prev = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-04 21:57:42 -05:00
|
|
|
SELECT category_id, amount FROM spending_budgets
|
|
|
|
|
WHERE user_id = ? AND year = ? AND month = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
2026-07-10 16:36:27 -05:00
|
|
|
)
|
|
|
|
|
.all(req.user.id, prevYear, prevMonth);
|
2026-06-04 21:57:42 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
if (prev.length === 0) {
|
|
|
|
|
return res.json({
|
|
|
|
|
copied: 0,
|
|
|
|
|
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-04 21:57:42 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
const upsert = db.prepare(`
|
2026-06-04 21:57:42 -05:00
|
|
|
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')
|
|
|
|
|
`);
|
|
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
db.transaction(() => {
|
|
|
|
|
for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
|
|
|
|
|
})();
|
2026-06-04 21:57:42 -05:00
|
|
|
|
2026-07-10 16:36:27 -05:00
|
|
|
res.json({
|
|
|
|
|
copied: prev.length,
|
|
|
|
|
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
|
|
|
|
|
});
|
2026-06-04 21:57:42 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
// PUT /api/spending/budgets — { category_id, year, month, amount }
|
2026-07-06 11:26:50 -05:00
|
|
|
router.put('/budgets', (req: Req, res: Res) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
const { category_id, year, month, amount } = req.body || {};
|
2026-07-10 16:36:27 -05:00
|
|
|
if (!category_id) throw ValidationError('category_id required', 'category_id');
|
2026-06-04 04:31:25 -05:00
|
|
|
const ym = parseYM({ year, month });
|
2026-07-10 16:36:27 -05:00
|
|
|
|
|
|
|
|
setSpendingBudget(
|
|
|
|
|
getDb(),
|
|
|
|
|
req.user.id,
|
|
|
|
|
parseInt(category_id, 10),
|
|
|
|
|
ym.year,
|
|
|
|
|
ym.month,
|
|
|
|
|
amount ?? null,
|
|
|
|
|
);
|
|
|
|
|
res.json({ ok: true });
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/category-rules
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/category-rules', (req: Req, res: Res) => {
|
2026-07-10 16:36:27 -05:00
|
|
|
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/spending/category-rules — { category_id, merchant }
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/category-rules', (req: Req, res: Res) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
const { category_id, merchant } = req.body || {};
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!category_id || !merchant)
|
2026-07-10 16:36:27 -05:00
|
|
|
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 });
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /api/spending/category-rules/:id
|
2026-07-06 11:26:50 -05:00
|
|
|
router.delete('/category-rules/:id', (req: Req, res: Res) => {
|
2026-07-10 16:36:27 -05:00
|
|
|
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
|
|
|
|
|
res.json({ ok: true });
|
2026-06-04 19:53:38 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/spending/income?year=&month=&page=
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/income', (req: Req, res: Res) => {
|
2026-06-04 19:53:38 -05:00
|
|
|
const ym = parseYM(req.query);
|
2026-07-10 16:36:27 -05:00
|
|
|
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',
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-06-04 04:31:25 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|