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 { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
const {
decorateDataSource,
ensureManualDataSource,
@ -26,10 +26,19 @@ function cleanFilter(value) {
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 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 ──────────────────────────────────────
@ -37,7 +46,6 @@ function safeError(err, fallback) {
// Used by the bank tracking account picker.
router.get('/accounts/all', (req: Req, res: Res) => {
try {
const db = getDb();
const accounts = db
.prepare(
@ -61,9 +69,6 @@ router.get('/accounts/all', (req: Req, res: Res) => {
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 ────────────────────────────────────────────────────
@ -76,21 +81,9 @@ router.get('/', (req: Req, res: Res) => {
const status = cleanFilter(req.query.status);
if (type && !VALID_TYPES.has(type))
return res
.status(400)
.json(
standardizeError(
'type must be manual, file_import, or provider_sync',
'VALIDATION_ERROR',
'type',
),
);
throw ValidationError('type must be manual, file_import, or provider_sync', 'type');
if (status && !VALID_STATUSES.has(status))
return res
.status(400)
.json(
standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'),
);
throw ValidationError('status must be active, inactive, or error', 'status');
let query = `
SELECT
@ -159,17 +152,11 @@ router.get('/simplefin/status', (req: Req, res: Res) => {
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : '';
if (!setupToken) {
return res
.status(400)
.json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken'));
throw ValidationError('setupToken is required', 'setupToken');
}
try {
@ -177,8 +164,7 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
const result = await connectSimplefin(db, req.user.id, setupToken);
res.status(201).json(result);
} catch (err) {
const { msg, status } = safeError(err, 'Failed to connect SimpleFIN');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Failed to connect SimpleFIN');
}
});
@ -187,19 +173,15 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
router.get('/:sourceId/accounts', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
throw ValidationError('Invalid data source id', 'sourceId');
}
try {
const db = getDb();
const source = db
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
.get(sourceId, req.user.id);
if (!source)
return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
if (!source) throw NotFoundError('Data source not found');
const accounts = db
.prepare(
@ -236,9 +218,6 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
}));
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 ─────────────────────
@ -252,15 +231,12 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
!Number.isInteger(accountId) ||
accountId < 1
) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
throw ValidationError('Invalid id');
}
if (typeof req.body?.monitored !== 'boolean') {
return res
.status(400)
.json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
throw ValidationError('monitored must be a boolean', 'monitored');
}
try {
const db = getDb();
const result = db
.prepare(
@ -272,32 +248,22 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
)
.run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id);
if (result.changes === 0)
return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
if (result.changes === 0) throw NotFoundError('Account not found');
const account = db
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
.get(accountId);
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 ─────────────────────────────────────────
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid data source id', 'id');
}
try {
@ -305,8 +271,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
const result = await syncDataSource(db, req.user.id, id);
res.json(result);
} catch (err) {
const { msg, status } = safeError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Sync failed');
}
});
@ -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.
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
try {
const db = getDb();
@ -329,7 +290,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
.all(req.user.id);
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;
@ -364,25 +325,18 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
errors,
});
} catch (err) {
const { msg, status } = safeError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Sync failed');
}
});
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid data source id', 'id');
}
try {
@ -390,33 +344,25 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
const result = await backfillDataSource(db, req.user.id, id);
res.json(result);
} catch (err) {
const { msg, status } = safeError(err, 'Backfill failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Backfill failed');
}
});
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
router.delete('/:id', (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid data source id', 'id');
}
try {
disconnectDataSource(getDb(), req.user.id, id);
res.json({ ok: true });
} catch (err) {
const { msg, status } = safeError(err, 'Failed to disconnect');
res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR'));
throw toSourceError(err, 'Failed to disconnect', '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).
import type { Req, Res, Next } from '../types/http';
const router = require('express').Router();
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { getDb } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const {
applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy,
@ -17,32 +21,23 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat
const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
if (err.status) {
return res
.status(err.status)
.json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field));
// 4xx service errors keep their message/code on the wire; anything else is
// rethrown raw so the terminal handler masks + logs it as a 5xx.
function toMatchError(err) {
if (err.status && err.status < 500) {
return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field });
}
log.error('[matches] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
return err;
}
// GET /api/matches/suggestions
router.get('/suggestions', (req: Req, res: Res) => {
try {
res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
return sendMatchError(res, err, 'Match suggestions failed');
}
});
// POST /api/matches/:id/reject
router.post('/:id/reject', (req: Req, res: Res) => {
try {
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
@ -50,46 +45,36 @@ router.post('/confirm', (req: Req, res: Res) => {
const txId = parseInt(req.body?.transaction_id, 10);
const billId = parseInt(req.body?.bill_id, 10);
if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
return res
.status(400)
.json(
standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'),
);
throw ValidationError('transaction_id and bill_id are required integers');
}
const db = getDb();
const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id);
if (!tx)
return res
.status(404)
.json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
if (!tx) throw NotFoundError('Transaction not found', 'transaction_id');
if (tx.match_status === 'matched') {
return res
.status(409)
.json(
standardizeError(
throw ConflictError(
'Transaction is already matched to a bill',
'ALREADY_MATCHED',
'transaction_id',
),
'ALREADY_MATCHED',
);
}
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const existing = db
.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL')
.get(txId);
if (existing)
return res
.status(409)
.json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
throw ConflictError(
'A payment is already linked to this transaction',
undefined,
'DUPLICATE_MATCH',
);
const paidDate =
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 {
db.exec('ROLLBACK');
} 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) => {
const txId = parseInt(req.params.transactionId, 10);
if (!Number.isInteger(txId)) {
return res
.status(400)
.json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
throw ValidationError('transactionId must be an integer');
}
const db = getDb();
const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND 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') {
return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED'));
throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED');
}
try {
@ -183,7 +166,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
try {
db.exec('ROLLBACK');
} 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).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
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 { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.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 !== '') {
val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) {
return res
.status(400)
.json(
standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
'extra_payment',
),
);
throw ValidationError('extra_payment must be a non-negative number', 'extra_payment');
}
}
@ -261,9 +252,7 @@ function buildComparison(snowball, minimum_only) {
router.patch('/order', (req: Req, res: Res) => {
const items = req.body;
if (!Array.isArray(items)) {
return res
.status(400)
.json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
throw ValidationError('Request body must be an array');
}
if (items.length === 0) {
return res.json({ success: true, updated: 0 });
@ -276,23 +265,11 @@ router.patch('/order', (req: Req, res: Res) => {
const id = parseInt(row?.id, 10);
const order = parseInt(row?.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) {
return res
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
'VALIDATION_ERROR',
),
);
throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`);
}
if (!Number.isInteger(order) || order < 0) {
return res
.status(400)
.json(
standardizeError(
throw ValidationError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
'VALIDATION_ERROR',
),
);
}
parsed.push({ id, order });
@ -373,7 +350,6 @@ function enrichPlanWithProgress(db, plan) {
// POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req: Req, res: Res) => {
try {
const db = getDb();
const userId = req.user.id;
const { name, method, notes } = req.body;
@ -386,13 +362,10 @@ router.post('/plans', (req: Req, res: Res) => {
const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) {
return res
.status(400)
.json(
standardizeError(
throw ValidationError(
'No debts with a balance found. Add a balance to at least one bill.',
undefined,
'NO_DEBTS',
),
);
}
@ -461,19 +434,12 @@ router.post('/plans', (req: Req, res: Res) => {
)
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ?')
.get(result.lastInsertRowid);
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').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
router.get('/plans', (req: Req, res: Res) => {
try {
const db = getDb();
const plans = db
.prepare(
@ -483,15 +449,10 @@ router.get('/plans', (req: Req, res: Res) => {
)
.all(req.user.id);
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)
router.get('/plans/active', (req: Req, res: Res) => {
try {
const db = getDb();
const plan = db
.prepare(
@ -503,23 +464,17 @@ router.get('/plans/active', (req: Req, res: Res) => {
)
.get(req.user.id);
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
router.patch('/plans/:id', (req: Req, res: Res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0)
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', '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 (!plan) throw NotFoundError('Plan not found');
const { name, notes } = req.body;
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
@ -533,44 +488,32 @@ router.patch('/plans/:id', (req: Req, res: Res) => {
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
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.
// `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, action, past }) {
try {
function transitionPlan(req, res, { allowedFrom, setSql, past }) {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid plan id', '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 (!plan) throw NotFoundError('Plan not found');
if (!allowedFrom.includes(plan.status)) {
return res
.status(400)
.json(
standardizeError(
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);
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'));
}
}
// 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 { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts');
const { ValidationError } = require('../utils/apiError.cts');
const DEFAULT_INCOME_LABEL = 'Salary';
const DEFAULT_PENDING_DAYS = 3;
@ -127,10 +128,10 @@ function parseYearMonth(source) {
const month = parseInt(source.month || now.getMonth() + 1, 10);
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) {
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 };
@ -447,7 +448,6 @@ function buildSummary(db, userId, year, month) {
router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const db = getDb();
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) => {
const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const amount = Number(req.body?.amount);
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 =

View File

@ -13,6 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid}
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/snowball.cts');
function handler(method, routePath) {
@ -34,7 +35,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d });
},
};
// 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) });
}
});
}