refactor(routes): throw-pattern for summary, matches, snowball, dataSources

Standards-unification batch 1 (CODE_STANDARDS 'Error responses', exemplar
categories.cts): inline res.status().json(standardizeError(...)) bodies and
try/catch-500 wrappers replaced with thrown ApiError factories; the terminal
handler formats 4xx and masks+logs 5xx. Wire shapes preserved, including
client-visible codes (ALREADY_MATCHED, DUPLICATE_MATCH, NOT_MATCHED,
NO_DEBTS, INVALID_PLAN_STATE, BANK_SYNC_DISABLED).

Also kills three more err.message-on-500 leaks in dataSources.cts (DB_ERROR
wrappers) that the QA-B13-02 grep missed; SimpleFIN errors keep their
deliberate sanitized pass-through via ApiError wraps (tokens/URLs stripped).

snowballPlanRoute.test.js harness now mirrors the terminal handler for
thrown errors (same wire shape as production). Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 16:44:19 -05:00
parent a8f8446743
commit c4219b6020
5 changed files with 246 additions and 368 deletions

View File

@ -4,7 +4,7 @@ import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
const { const {
decorateDataSource, decorateDataSource,
ensureManualDataSource, ensureManualDataSource,
@ -26,10 +26,19 @@ function cleanFilter(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function safeError(err, fallback) { // SimpleFIN/service errors are deliberately surfaced to the user — but only
// after sanitizeErrorMessage strips tokens/URLs. Wrapping in ApiError keeps
// the sanitized message through the terminal handler (raw 5xx would be masked).
function toSourceError(err, fallback, code = 'SIMPLEFIN_ERROR') {
const msg = sanitizeErrorMessage(err?.message || fallback); const msg = sanitizeErrorMessage(err?.message || fallback);
const status = typeof err?.status === 'number' ? err.status : 500; const status = typeof err?.status === 'number' ? err.status : 500;
return { msg, status }; return new ApiError(err?.code || code, msg, status);
}
function requireBankSyncEnabled() {
if (!getBankSyncConfig().enabled) {
throw new ApiError('BANK_SYNC_DISABLED', 'Bank sync is not enabled on this server', 503);
}
} }
// ─── GET /api/data-sources/accounts/all ────────────────────────────────────── // ─── GET /api/data-sources/accounts/all ──────────────────────────────────────
@ -37,11 +46,10 @@ function safeError(err, fallback) {
// Used by the bank tracking account picker. // Used by the bank tracking account picker.
router.get('/accounts/all', (req: Req, res: Res) => { router.get('/accounts/all', (req: Req, res: Res) => {
try { const db = getDb();
const db = getDb(); const accounts = db
const accounts = db .prepare(
.prepare( `
`
SELECT SELECT
fa.id, fa.name, fa.org_name, fa.account_type, fa.id, fa.name, fa.org_name, fa.account_type,
fa.balance, fa.available_balance, fa.currency, fa.balance, fa.available_balance, fa.currency,
@ -51,19 +59,16 @@ router.get('/accounts/all', (req: Req, res: Res) => {
WHERE fa.user_id = ? WHERE fa.user_id = ?
ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json( res.json(
accounts.map((a) => ({ accounts.map((a) => ({
...a, ...a,
monitored: a.monitored === 1, monitored: a.monitored === 1,
balance_dollars: a.balance !== null ? a.balance / 100 : null, balance_dollars: a.balance !== null ? a.balance / 100 : null,
})), })),
); );
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
}); });
// ─── GET /api/data-sources ──────────────────────────────────────────────────── // ─── GET /api/data-sources ────────────────────────────────────────────────────
@ -76,21 +81,9 @@ router.get('/', (req: Req, res: Res) => {
const status = cleanFilter(req.query.status); const status = cleanFilter(req.query.status);
if (type && !VALID_TYPES.has(type)) if (type && !VALID_TYPES.has(type))
return res throw ValidationError('type must be manual, file_import, or provider_sync', 'type');
.status(400)
.json(
standardizeError(
'type must be manual, file_import, or provider_sync',
'VALIDATION_ERROR',
'type',
),
);
if (status && !VALID_STATUSES.has(status)) if (status && !VALID_STATUSES.has(status))
return res throw ValidationError('status must be active, inactive, or error', 'status');
.status(400)
.json(
standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'),
);
let query = ` let query = `
SELECT SELECT
@ -159,17 +152,11 @@ router.get('/simplefin/status', (req: Req, res: Res) => {
// ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── // ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req: Req, res: Res) => { router.post('/simplefin/connect', async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : ''; const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : '';
if (!setupToken) { if (!setupToken) {
return res throw ValidationError('setupToken is required', 'setupToken');
.status(400)
.json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken'));
} }
try { try {
@ -177,8 +164,7 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
const result = await connectSimplefin(db, req.user.id, setupToken); const result = await connectSimplefin(db, req.user.id, setupToken);
res.status(201).json(result); res.status(201).json(result);
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Failed to connect SimpleFIN'); throw toSourceError(err, 'Failed to connect SimpleFIN');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
@ -187,23 +173,19 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
router.get('/:sourceId/accounts', (req: Req, res: Res) => { router.get('/:sourceId/accounts', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10); const sourceId = parseInt(req.params.sourceId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1) { if (!Number.isInteger(sourceId) || sourceId < 1) {
return res throw ValidationError('Invalid data source id', 'sourceId');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
} }
try { const db = getDb();
const db = getDb();
const source = db const source = db
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?') .prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
.get(sourceId, req.user.id); .get(sourceId, req.user.id);
if (!source) if (!source) throw NotFoundError('Data source not found');
return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
const accounts = db const accounts = db
.prepare( .prepare(
` `
SELECT SELECT
fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type, fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type,
fa.balance, fa.available_balance, fa.currency, fa.monitored, fa.balance, fa.available_balance, fa.currency, fa.monitored,
@ -215,10 +197,10 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
GROUP BY fa.id GROUP BY fa.id
ORDER BY fa.name COLLATE NOCASE ASC ORDER BY fa.name COLLATE NOCASE ASC
`, `,
) )
.all(sourceId, req.user.id); .all(sourceId, req.user.id);
const txStmt = db.prepare(` const txStmt = db.prepare(`
SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency, SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency,
t.payee, t.description, t.memo, t.match_status, t.ignored, t.payee, t.description, t.memo, t.match_status, t.ignored,
t.matched_bill_id, b.name AS matched_bill_name t.matched_bill_id, b.name AS matched_bill_name
@ -229,16 +211,13 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
LIMIT 50 LIMIT 50
`); `);
const result = accounts.map((acc) => ({ const result = accounts.map((acc) => ({
...acc, ...acc,
monitored: acc.monitored === 1, monitored: acc.monitored === 1,
transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [], transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
})); }));
res.json(result); res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
}); });
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ───────────────────── // ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
@ -252,52 +231,39 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
!Number.isInteger(accountId) || !Number.isInteger(accountId) ||
accountId < 1 accountId < 1
) { ) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); throw ValidationError('Invalid id');
} }
if (typeof req.body?.monitored !== 'boolean') { if (typeof req.body?.monitored !== 'boolean') {
return res throw ValidationError('monitored must be a boolean', 'monitored');
.status(400)
.json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
} }
try { const db = getDb();
const db = getDb(); const result = db
const result = db .prepare(
.prepare( `
`
UPDATE financial_accounts UPDATE financial_accounts
SET monitored = ?, updated_at = datetime('now') SET monitored = ?, updated_at = datetime('now')
WHERE id = ? AND data_source_id = ? AND user_id = ? WHERE id = ? AND data_source_id = ? AND user_id = ?
`, `,
) )
.run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); .run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id);
if (result.changes === 0) if (result.changes === 0) throw NotFoundError('Account not found');
return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
const account = db const account = db
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?') .prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
.get(accountId); .get(accountId);
res.json({ ...account, monitored: account.monitored === 1 }); res.json({ ...account, monitored: account.monitored === 1 });
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR'));
}
}); });
// ─── POST /api/data-sources/:id/sync ───────────────────────────────────────── // ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
return res throw ValidationError('Invalid data source id', 'id');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
@ -305,8 +271,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
const result = await syncDataSource(db, req.user.id, id); const result = await syncDataSource(db, req.user.id, id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Sync failed'); throw toSourceError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
@ -314,11 +279,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
// Syncs every SimpleFIN source for the current user. Returns aggregated stats. // Syncs every SimpleFIN source for the current user. Returns aggregated stats.
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
try { try {
const db = getDb(); const db = getDb();
@ -329,7 +290,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
if (sources.length === 0) { if (sources.length === 0) {
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND')); throw NotFoundError('No SimpleFIN connections found');
} }
let accountsUpserted = 0; let accountsUpserted = 0;
@ -364,25 +325,18 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
errors, errors,
}); });
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Sync failed'); throw toSourceError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
} }
}); });
// ─── POST /api/data-sources/:id/backfill ───────────────────────────────────── // ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => { router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
return res throw ValidationError('Invalid data source id', 'id');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
@ -390,33 +344,25 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
const result = await backfillDataSource(db, req.user.id, id); const result = await backfillDataSource(db, req.user.id, id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Backfill failed'); throw toSourceError(err, 'Backfill failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
// ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── // ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
router.delete('/:id', (req: Req, res: Res) => { router.delete('/:id', (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
return res throw ValidationError('Invalid data source id', 'id');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
disconnectDataSource(getDb(), req.user.id, id); disconnectDataSource(getDb(), req.user.id, id);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Failed to disconnect'); throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR');
res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR'));
} }
}); });

View File

@ -1,9 +1,13 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { const {
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
@ -17,32 +21,23 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat
const { serializePayment } = require('../services/paymentValidation.cts'); const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { // 4xx service errors keep their message/code on the wire; anything else is
if (err.status) { // rethrown raw so the terminal handler masks + logs it as a 5xx.
return res function toMatchError(err) {
.status(err.status) if (err.status && err.status < 500) {
.json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field)); return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field });
} }
log.error('[matches] service error:', err.stack || err.message); return err;
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
} }
// GET /api/matches/suggestions // GET /api/matches/suggestions
router.get('/suggestions', (req: Req, res: Res) => { router.get('/suggestions', (req: Req, res: Res) => {
try { res.json(listMatchSuggestions(req.user.id, req.query));
res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
return sendMatchError(res, err, 'Match suggestions failed');
}
}); });
// POST /api/matches/:id/reject // POST /api/matches/:id/reject
router.post('/:id/reject', (req: Req, res: Res) => { router.post('/:id/reject', (req: Req, res: Res) => {
try { res.json(rejectMatchSuggestion(req.user.id, req.params.id));
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
} catch (err) {
return sendMatchError(res, err, 'Rejecting match suggestion failed');
}
}); });
// POST /api/matches/confirm — link a transaction to a bill and record a payment // POST /api/matches/confirm — link a transaction to a bill and record a payment
@ -50,46 +45,36 @@ router.post('/confirm', (req: Req, res: Res) => {
const txId = parseInt(req.body?.transaction_id, 10); const txId = parseInt(req.body?.transaction_id, 10);
const billId = parseInt(req.body?.bill_id, 10); const billId = parseInt(req.body?.bill_id, 10);
if (!Number.isInteger(txId) || !Number.isInteger(billId)) { if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
return res throw ValidationError('transaction_id and bill_id are required integers');
.status(400)
.json(
standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'),
);
} }
const db = getDb(); const db = getDb();
const tx = db const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id); .get(txId, req.user.id);
if (!tx) if (!tx) throw NotFoundError('Transaction not found', 'transaction_id');
return res
.status(404)
.json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
if (tx.match_status === 'matched') { if (tx.match_status === 'matched') {
return res throw ConflictError(
.status(409) 'Transaction is already matched to a bill',
.json( 'transaction_id',
standardizeError( 'ALREADY_MATCHED',
'Transaction is already matched to a bill', );
'ALREADY_MATCHED',
'transaction_id',
),
);
} }
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const existing = db const existing = db
.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL')
.get(txId); .get(txId);
if (existing) if (existing)
return res throw ConflictError(
.status(409) 'A payment is already linked to this transaction',
.json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); undefined,
'DUPLICATE_MATCH',
);
const paidDate = const paidDate =
tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal());
@ -134,7 +119,7 @@ router.post('/confirm', (req: Req, res: Res) => {
try { try {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
} catch {} } catch {}
return sendMatchError(res, err, 'Failed to confirm match'); throw toMatchError(err);
} }
}); });
@ -142,18 +127,16 @@ router.post('/confirm', (req: Req, res: Res) => {
router.post('/:transactionId/unmatch', (req: Req, res: Res) => { router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
const txId = parseInt(req.params.transactionId, 10); const txId = parseInt(req.params.transactionId, 10);
if (!Number.isInteger(txId)) { if (!Number.isInteger(txId)) {
return res throw ValidationError('transactionId must be an integer');
.status(400)
.json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
} }
const db = getDb(); const db = getDb();
const tx = db const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id); .get(txId, req.user.id);
if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND')); if (!tx) throw NotFoundError('Transaction not found');
if (tx.match_status !== 'matched') { if (tx.match_status !== 'matched') {
return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED')); throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED');
} }
try { try {
@ -183,7 +166,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
try { try {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
} catch {} } catch {}
return sendMatchError(res, err, 'Failed to unmatch transaction'); throw toMatchError(err);
} }
}); });

View File

@ -1,10 +1,9 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { ValidationError, NotFoundError } = require('../utils/apiError.cts');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts');
const { serializeBill } = require('../services/billsService.cts'); const { serializeBill } = require('../services/billsService.cts');
@ -122,15 +121,7 @@ router.patch('/settings', (req: Req, res: Res) => {
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') { if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
val = parseFloat(extra_payment); val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
return res throw ValidationError('extra_payment must be a non-negative number', 'extra_payment');
.status(400)
.json(
standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
'extra_payment',
),
);
} }
} }
@ -261,9 +252,7 @@ function buildComparison(snowball, minimum_only) {
router.patch('/order', (req: Req, res: Res) => { router.patch('/order', (req: Req, res: Res) => {
const items = req.body; const items = req.body;
if (!Array.isArray(items)) { if (!Array.isArray(items)) {
return res throw ValidationError('Request body must be an array');
.status(400)
.json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
} }
if (items.length === 0) { if (items.length === 0) {
return res.json({ success: true, updated: 0 }); return res.json({ success: true, updated: 0 });
@ -276,24 +265,12 @@ router.patch('/order', (req: Req, res: Res) => {
const id = parseInt(row?.id, 10); const id = parseInt(row?.id, 10);
const order = parseInt(row?.snowball_order, 10); const order = parseInt(row?.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`);
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
'VALIDATION_ERROR',
),
);
} }
if (!Number.isInteger(order) || order < 0) { if (!Number.isInteger(order) || order < 0) {
return res throw ValidationError(
.status(400) `Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
.json( );
standardizeError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
'VALIDATION_ERROR',
),
);
} }
parsed.push({ id, order }); parsed.push({ id, order });
} }
@ -373,204 +350,170 @@ function enrichPlanWithProgress(db, plan) {
// POST /api/snowball/plans — start a new snowball plan // POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req: Req, res: Res) => { router.post('/plans', (req: Req, res: Res) => {
try { const db = getDb();
const db = getDb(); const userId = req.user.id;
const userId = req.user.id; const { name, method, notes } = req.body;
const { name, method, notes } = req.body;
const planName = const planName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan'; typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan';
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball'; const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
const ramseyMode = isRamseyMode(userId); const ramseyMode = isRamseyMode(userId);
const debts = getDebtBills(userId, ramseyMode); const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0); const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) { if (activeDebts.length === 0) {
return res throw ValidationError(
.status(400) 'No debts with a balance found. Add a balance to at least one bill.',
.json( undefined,
standardizeError( 'NO_DEBTS',
'No debts with a balance found. Add a balance to at least one bill.',
'NO_DEBTS',
),
);
}
// Money fields on `debts` are stored as integer cents; the snowball/APR
// math and plan_snapshot are dollar-denominated, so convert before computing.
const debtsForMath = debts.map((b) => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
const snowball =
planMethod === 'avalanche'
? calculateAvalanche(debtsForMath, extra, now)
: calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(
0,
Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) /
100,
); );
}
const debtSnaps = debtsForMath.map((b, i) => { // Money fields on `debts` are stored as integer cents; the snowball/APR
const proj = snowball.debts?.find((d) => d.id === b.id); // math and plan_snapshot are dollar-denominated, so convert before computing.
return { const debtsForMath = debts.map((b) => ({
bill_id: b.id, ...b,
name: b.name, current_balance: fromCents(b.current_balance),
starting_balance: b.current_balance ?? 0, minimum_payment: fromCents(b.minimum_payment),
minimum_payment: b.minimum_payment ?? 0, }));
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
const planSnapshot = JSON.stringify({ const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
projected_payoff_date: snowball.payoff_date ?? null, const extraCents = user?.snowball_extra_payment ?? 0;
projected_months: snowball.months_to_freedom ?? null, const extra = fromCents(extraCents);
projected_total_interest: snowball.total_interest_paid ?? null, const now = new Date();
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
// Abandon any existing active/paused plan first const snowball =
db.prepare( planMethod === 'avalanche'
` ? calculateAvalanche(debtsForMath, extra, now)
: calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(
0,
Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) /
100,
);
const debtSnaps = debtsForMath.map((b, i) => {
const proj = snowball.debts?.find((d) => d.id === b.id);
return {
bill_id: b.id,
name: b.name,
starting_balance: b.current_balance ?? 0,
minimum_payment: b.minimum_payment ?? 0,
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
const planSnapshot = JSON.stringify({
projected_payoff_date: snowball.payoff_date ?? null,
projected_months: snowball.months_to_freedom ?? null,
projected_total_interest: snowball.total_interest_paid ?? null,
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
// Abandon any existing active/paused plan first
db.prepare(
`
UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now')
WHERE user_id = ? AND status IN ('active', 'paused') WHERE user_id = ? AND status IN ('active', 'paused')
`, `,
).run(userId); ).run(userId);
const result = db const result = db
.prepare( .prepare(
` `
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
`, `,
) )
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
const plan = db const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
.prepare('SELECT * FROM snowball_plans WHERE id = ?') res.status(201).json(enrichPlanWithProgress(db, plan));
.get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan));
} catch (err) {
log.error('[snowball plans] POST error:', err.message);
res.status(500).json(standardizeError('Failed to start plan', 'PLAN_CREATE_ERROR'));
}
}); });
// GET /api/snowball/plans — list all plans for user // GET /api/snowball/plans — list all plans for user
router.get('/plans', (req: Req, res: Res) => { router.get('/plans', (req: Req, res: Res) => {
try { const db = getDb();
const db = getDb(); const plans = db
const plans = db .prepare(
.prepare( `
`
SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) }); res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) });
} catch (err) {
log.error('[snowball plans] GET /plans error:', err.message);
res.status(500).json(standardizeError('Failed to load plans', 'PLAN_LIST_ERROR'));
}
}); });
// GET /api/snowball/plans/active — return the active or paused plan (or null) // GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req: Req, res: Res) => { router.get('/plans/active', (req: Req, res: Res) => {
try { const db = getDb();
const db = getDb(); const plan = db
const plan = db .prepare(
.prepare( `
`
SELECT * FROM snowball_plans SELECT * FROM snowball_plans
WHERE user_id = ? AND status IN ('active', 'paused') WHERE user_id = ? AND status IN ('active', 'paused')
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
`, `,
) )
.get(req.user.id); .get(req.user.id);
res.json(plan ? enrichPlanWithProgress(db, plan) : null); res.json(plan ? enrichPlanWithProgress(db, plan) : null);
} catch (err) {
log.error('[snowball plans] GET /plans/active error:', err.message);
res.status(500).json(standardizeError('Failed to load active plan', 'PLAN_ACTIVE_ERROR'));
}
}); });
// PATCH /api/snowball/plans/:id — update name or notes // PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req: Req, res: Res) => { router.patch('/plans/:id', (req: Req, res: Res) => {
try { const db = getDb();
const db = getDb(); const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id');
if (!Number.isInteger(id) || id <= 0) const plan = db
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id')); .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
const plan = db .get(id, req.user.id);
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') if (!plan) throw NotFoundError('Plan not found');
.get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
const { name, notes } = req.body; const { name, notes } = req.body;
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name; const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
const newNotes = notes !== undefined ? notes || null : plan.notes; const newNotes = notes !== undefined ? notes || null : plan.notes;
db.prepare( db.prepare(
` `
UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ? UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`, `,
).run(newName, newNotes, id); ).run(newName, newNotes, id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated)); res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
log.error('[snowball plans] PATCH error:', err.message);
res.status(500).json(standardizeError('Failed to update plan', 'PLAN_UPDATE_ERROR'));
}
}); });
// Shared status-transition handler for pause / resume / complete / abandon. // Shared status-transition handler for pause / resume / complete / abandon.
// `setSql` is a fixed constant per route (never user input). // `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, action, past }) { function transitionPlan(req, res, { allowedFrom, setSql, past }) {
try { const db = getDb();
const db = getDb(); const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id <= 0) {
if (!Number.isInteger(id) || id <= 0) { throw ValidationError('Invalid plan id', 'id');
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
}
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
if (!allowedFrom.includes(plan.status)) {
return res
.status(400)
.json(
standardizeError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
'INVALID_PLAN_STATE',
),
);
}
db.prepare(
`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`,
).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
log.error(`[snowball plans] ${action} error:`, err.message);
res.status(500).json(standardizeError(`Failed to ${action} plan`, 'PLAN_TRANSITION_ERROR'));
} }
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) throw NotFoundError('Plan not found');
if (!allowedFrom.includes(plan.status)) {
throw ValidationError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
undefined,
'INVALID_PLAN_STATE',
);
}
db.prepare(`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`).run(
id,
);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} }
// POST /api/snowball/plans/:id/{pause,resume,complete,abandon} // POST /api/snowball/plans/:id/{pause,resume,complete,abandon}

View File

@ -8,6 +8,7 @@ const { getCycleRange, resolveDueDate } = require('../services/statusService.cts
const { getUserSettings } = require('../services/userSettings.cts'); const { getUserSettings } = require('../services/userSettings.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts');
const { ValidationError } = require('../utils/apiError.cts');
const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_INCOME_LABEL = 'Salary';
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
@ -127,10 +128,10 @@ function parseYearMonth(source) {
const month = parseInt(source.month || now.getMonth() + 1, 10); const month = parseInt(source.month || now.getMonth() + 1, 10);
if (Number.isNaN(year) || year < 2000 || year > 2100) { if (Number.isNaN(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100' }; throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
} }
if (Number.isNaN(month) || month < 1 || month > 12) { if (Number.isNaN(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12' }; throw ValidationError('month must be an integer between 1 and 12', 'month');
} }
return { year, month }; return { year, month };
@ -447,7 +448,6 @@ function buildSummary(db, userId, year, month) {
router.get('/', (req: Req, res: Res) => { router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query); const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const db = getDb(); const db = getDb();
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month)); res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
@ -455,11 +455,10 @@ router.get('/', (req: Req, res: Res) => {
router.put('/income', (req: Req, res: Res) => { router.put('/income', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.body || {}); const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const amount = Number(req.body?.amount); const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) { if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' }); throw ValidationError('amount must be a number between 0 and 1000000000', 'amount');
} }
const label = const label =

View File

@ -13,6 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid}
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/snowball.cts'); const router = require('../routes/snowball.cts');
function handler(method, routePath) { function handler(method, routePath) {
@ -34,7 +35,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d }); resolve({ status: this.statusCode, data: d });
}, },
}; };
h(req, res); // Routes follow the throw-pattern: mirror the app's terminal error handler
// (server.cts) so thrown ApiErrors resolve to the same wire shape.
try {
h(req, res);
} catch (err) {
resolve({ status: err.status || 500, data: formatError(err) });
}
}); });
} }