refactor(routes): throw-pattern for bills.cts (largest route, 43+ sites)

Standards-unification batch 3. All inline standardizeError bodies (404s,
year/month/history-range/merchant validation, reorder payload checks) and
the drift/merchant-rule/sync try/catch-500 wrappers converted to thrown
ApiError factories. Two more err.message-on-500 leaks killed (SYNC_ERROR
wrappers). The import-historical transaction keeps its try/catch for the
log line but rethrows a coded ApiError instead of hand-rolling the body.

billReorder harness mirrors the terminal handler. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 17:00:01 -05:00
parent 543e94288e
commit c0c3c2f347
2 changed files with 131 additions and 383 deletions

View File

@ -16,7 +16,12 @@ const {
applyBalanceDelta, applyBalanceDelta,
} = require('../services/billsService.cts'); } = require('../services/billsService.cts');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { const {
addMerchantRule, addMerchantRule,
@ -100,9 +105,7 @@ router.put('/reorder', (req: Req, res: Res) => {
})); }));
if (entries.length === 0) { if (entries.length === 0) {
return res throw ValidationError('At least one bill order is required', 'reorder');
.status(400)
.json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder'));
} }
const invalid = entries.find( const invalid = entries.find(
@ -110,15 +113,10 @@ router.put('/reorder', (req: Req, res: Res) => {
!Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0, !Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0,
); );
if (invalid) { if (invalid) {
return res throw ValidationError(
.status(400) 'Reorder payload must map bill ids to non-negative integer positions',
.json( 'reorder',
standardizeError( );
'Reorder payload must map bill ids to non-negative integer positions',
'VALIDATION_ERROR',
'reorder',
),
);
} }
const ids = entries.map((item) => item.billId); const ids = entries.map((item) => item.billId);
@ -133,9 +131,7 @@ router.put('/reorder', (req: Req, res: Res) => {
) )
.all(req.user.id, ...ids); .all(req.user.id, ...ids);
if (owned.length !== ids.length) { if (owned.length !== ids.length) {
return res throw NotFoundError('One or more bills were not found', 'bill_id');
.status(404)
.json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id'));
} }
const update = db.prepare( const update = db.prepare(
@ -172,19 +168,14 @@ router.get('/audit', (req: Req, res: Res) => {
// ── GET /api/bills/drift-report ────────────────────────────────────────────── // ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req: Req, res: Res) => { router.get('/drift-report', (req: Req, res: Res) => {
const { getDriftReport } = require('../services/driftService.cts'); const { getDriftReport } = require('../services/driftService.cts');
try { res.json(getDriftReport(req.user.id));
res.json(getDriftReport(req.user.id));
} catch (err) {
res.status(500).json({ error: 'Failed to compute drift report' });
}
}); });
// GET /api/bills/merchant-rules — all rules for this user across all bills // GET /api/bills/merchant-rules — all rules for this user across all bills
router.get('/merchant-rules', (req: Req, res: Res) => { router.get('/merchant-rules', (req: Req, res: Res) => {
try { const rules = getDb()
const rules = getDb() .prepare(
.prepare( `
`
SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at, SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at,
b.id AS bill_id, b.name AS bill_name b.id AS bill_id, b.name AS bill_name
FROM bill_merchant_rules bmr FROM bill_merchant_rules bmr
@ -192,13 +183,9 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
WHERE bmr.user_id = ? WHERE bmr.user_id = ?
ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json({ rules }); res.json({ rules });
} catch (err) {
log.error('[bills/merchant-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load merchant rules' });
}
}); });
// ── POST /api/bills/:id/snooze-drift ───────────────────────────────────────── // ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
@ -206,11 +193,11 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
router.post('/:id/snooze-drift', (req: Req, res: Res) => { router.post('/:id/snooze-drift', (req: Req, res: Res) => {
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) return res.status(400).json({ error: 'Invalid id' }); if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid id');
const bill = db const bill = db
.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL') .prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL')
.get(id); .get(id);
if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' }); if (!bill || bill.user_id !== req.user.id) throw NotFoundError('Not found');
const until = new Date(); const until = new Date();
until.setDate(until.getDate() + 30); until.setDate(until.getDate() + 30);
const untilStr = localDateString(until); const untilStr = localDateString(until);
@ -257,36 +244,20 @@ router.post('/templates', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (name.length < 2) { if (name.length < 2) {
return res throw ValidationError('Template name must be at least 2 characters', 'name');
.status(400)
.json(
standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'),
);
} }
const data = sanitizeTemplateData(req.body.data || {}); const data = sanitizeTemplateData(req.body.data || {});
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
return res throw ValidationError('Template data is required', 'data');
.status(400)
.json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
} }
const validation = validateBillData(data); const validation = validateBillData(data);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, `data.${firstError.field}`);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
} }
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'data.category_id');
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'data.category_id',
),
);
} }
const normalizedData = sanitizeTemplateData(validation.normalized); const normalizedData = sanitizeTemplateData(validation.normalized);
@ -323,15 +294,12 @@ router.delete('/templates/:templateId', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const templateId = parseInt(req.params.templateId, 10); const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) { if (!Number.isInteger(templateId)) {
return res throw ValidationError('template_id must be an integer', 'template_id');
.status(400)
.json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
} }
const result = db const result = db
.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?') .prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?')
.run(templateId, req.user.id); .run(templateId, req.user.id);
if (result.changes === 0) if (result.changes === 0) throw NotFoundError('Template not found', 'template_id');
return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
res.json({ success: true }); res.json({ success: true });
}); });
@ -341,15 +309,12 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const body = req.body || {}; const body = req.body || {};
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const source = db const source = 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 (!source) if (!source) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const draft = { const draft = {
...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(serializeBill(source)),
@ -359,17 +324,11 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const validation = validateBillData(draft); const validation = validateBillData(draft);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, firstError.field);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
@ -384,26 +343,14 @@ router.get('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const year = parseInt(req.query.year, 10); const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10); const month = parseInt(req.query.month, 10);
if (isNaN(year) || year < 2000 || year > 2100) if (isNaN(year) || year < 2000 || year > 2100)
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(month) || month < 1 || month > 12) if (isNaN(month) || month < 1 || month > 12)
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
const mbs = db const mbs = db
.prepare( .prepare(
@ -430,54 +377,29 @@ router.put('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body; const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
const y = parseInt(year, 10); const y = parseInt(year, 10);
const m = parseInt(month, 10); const m = parseInt(month, 10);
if (isNaN(y) || y < 2000 || y > 2100) if (isNaN(y) || y < 2000 || y > 2100)
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(m) || m < 1 || m > 12) if (isNaN(m) || m < 1 || m > 12)
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
if (actual_amount !== undefined && actual_amount !== null) { if (actual_amount !== undefined && actual_amount !== null) {
const amt = parseFloat(actual_amount); const amt = parseFloat(actual_amount);
if (isNaN(amt) || amt < 0) if (isNaN(amt) || amt < 0)
return res throw ValidationError('actual_amount must be a non-negative number or null', 'actual_amount');
.status(400)
.json(
standardizeError(
'actual_amount must be a non-negative number or null',
'VALIDATION_ERROR',
'actual_amount',
),
);
} }
if (snoozed_until !== undefined && snoozed_until !== null) { if (snoozed_until !== undefined && snoozed_until !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until)) if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
return res throw ValidationError(
.status(400) 'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
.json( 'snoozed_until',
standardizeError( );
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
'VALIDATION_ERROR',
'snoozed_until',
),
);
} }
// Partial-update semantics: fields omitted from the request keep their // Partial-update semantics: fields omitted from the request keep their
@ -551,8 +473,7 @@ router.get('/:id', (req: Req, res: Res) => {
`, `,
) )
.get(req.params.id, req.user.id); .get(req.params.id, 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'));
let autopay_stats = null; let autopay_stats = null;
if (bill.autopay_enabled) { if (bill.autopay_enabled) {
@ -584,25 +505,16 @@ router.post('/:id/verify-autopay', (req: Req, res: Res) => {
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) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id')); throw ValidationError('Invalid id', 'bill_id');
} }
const bill = db const bill = db
.prepare( .prepare(
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(id, req.user.id); .get(id, 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'));
if (!bill.autopay_enabled) { if (!bill.autopay_enabled) {
return res throw ValidationError('Bill does not have autopay enabled', 'autopay_enabled');
.status(400)
.json(
standardizeError(
'Bill does not have autopay enabled',
'VALIDATION_ERROR',
'autopay_enabled',
),
);
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare( db.prepare(
@ -624,23 +536,12 @@ router.post('/', (req: Req, res: Res) => {
) { ) {
const sourceBillId = parseInt(body.source_bill_id, 10); const sourceBillId = parseInt(body.source_bill_id, 10);
if (!Number.isInteger(sourceBillId)) { if (!Number.isInteger(sourceBillId)) {
return res throw ValidationError('source_bill_id must be an integer', 'source_bill_id');
.status(400)
.json(
standardizeError(
'source_bill_id must be an integer',
'VALIDATION_ERROR',
'source_bill_id',
),
);
} }
const source = db const source = 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(sourceBillId, req.user.id); .get(sourceBillId, req.user.id);
if (!source) if (!source) throw NotFoundError('Source bill not found', 'source_bill_id');
return res
.status(404)
.json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
payload = { payload = {
...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body), ...sanitizeTemplateData(body),
@ -652,20 +553,14 @@ router.post('/', (req: Req, res: Res) => {
const validation = validateBillData(payload); const validation = validateBillData(payload);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, firstError.field);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user // Validate category_id exists for this user
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
@ -677,27 +572,20 @@ router.put('/:id', (req: Req, res: Res) => {
const existing = db const existing = 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(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!existing) if (!existing) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Validate and normalize bill data // Validate and normalize bill data
const validation = validateBillData(req.body, existing); const validation = validateBillData(req.body, existing);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, firstError.field);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user if changed // Validate category_id exists for this user if changed
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
const inactiveReason = const inactiveReason =
@ -772,13 +660,12 @@ router.put('/:id/archived', (req: Req, res: Res) => {
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) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id')); throw ValidationError('Invalid id', 'bill_id');
} }
const bill = db const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(id, req.user.id); .get(id, 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 archived = !!req.body?.archived; const archived = !!req.body?.archived;
db.prepare( db.prepare(
@ -797,8 +684,7 @@ router.delete('/:id', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, 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'));
db.prepare( db.prepare(
"UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -818,8 +704,7 @@ router.post('/:id/restore', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Deleted bill not found', 'bill_id');
return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare( db.prepare(
"UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -839,19 +724,14 @@ router.post('/:id/restore', (req: Req, res: Res) => {
// backfill any missing payments. // backfill any missing payments.
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => { router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) if (!Number.isInteger(billId)) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const bill = db const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
try { const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId); res.json(result);
res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
}
}); });
// ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── // ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
@ -860,8 +740,7 @@ router.get('/:id/payments', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, 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 limit = Math.min(parseInt(req.query.limit || '20', 10), 100); const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
const page = Math.max(parseInt(req.query.page || '1', 10), 1); const page = Math.max(parseInt(req.query.page || '1', 10), 1);
@ -893,16 +772,13 @@ router.get('/:id/transactions', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name 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 rows = db const rows = db
.prepare( .prepare(
@ -971,40 +847,19 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
) )
.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'));
// Scope to year/month if provided // Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null; const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null; const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
if ((year === null) !== (month === null)) { if ((year === null) !== (month === null)) {
return res throw ValidationError('year and month must both be provided or both omitted', 'year');
.status(400)
.json(
standardizeError(
'year and month must both be provided or both omitted',
'VALIDATION_ERROR',
'year',
),
);
} }
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) { if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
} }
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) { if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
} }
let currentPayment; let currentPayment;
@ -1068,11 +923,8 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
{ amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' }, { amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) { if (paymentValidation.error)
return res throw ValidationError(paymentValidation.error, paymentValidation.field);
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payment = paymentValidation.normalized; const payment = paymentValidation.normalized;
// Compute balance delta for debt bills before inserting // Compute balance delta for debt bills before inserting
@ -1112,7 +964,7 @@ router.get('/:id/history-ranges', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const ranges = db const ranges = db
.prepare( .prepare(
@ -1139,77 +991,40 @@ router.post('/:id/history-ranges', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = parseInt(start_year, 10); const sy = parseInt(start_year, 10);
const sm = parseInt(start_month, 10); const sm = parseInt(start_month, 10);
if (isNaN(sy) || sy < 2000 || sy > 2100) if (isNaN(sy) || sy < 2000 || sy > 2100)
return res throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
if (isNaN(sm) || sm < 1 || sm > 12) if (isNaN(sm) || sm < 1 || sm > 12)
return res throw ValidationError('start_month must be between 1 and 12', 'start_month');
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = null, let ey = null,
em = null; em = null;
if (end_year != null) { if (end_year != null) {
ey = parseInt(end_year, 10); ey = parseInt(end_year, 10);
if (isNaN(ey) || ey < 2000 || ey > 2100) if (isNaN(ey) || ey < 2000 || ey > 2100)
return res throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
.status(400)
.json(
standardizeError(
'end_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'end_year',
),
);
} }
if (end_month != null) { if (end_month != null) {
em = parseInt(end_month, 10); em = parseInt(end_month, 10);
if (isNaN(em) || em < 1 || em > 12) if (isNaN(em) || em < 1 || em > 12)
return res throw ValidationError('end_month must be between 1 and 12', 'end_month');
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
} }
if ((ey == null) !== (em == null)) { if ((ey == null) !== (em == null)) {
return res throw ValidationError(
.status(400) 'end_year and end_month must both be provided or both omitted',
.json( 'end_year',
standardizeError( );
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
} }
if (ey != null) { if (ey != null) {
const startVal = sy * 12 + sm; const startVal = sy * 12 + sm;
const endVal = ey * 12 + em; const endVal = ey * 12 + em;
if (endVal < startVal) if (endVal < startVal)
return res throw ValidationError('end date must be on or after start date', 'end_year');
.status(400)
.json(
standardizeError(
'end date must be on or after start date',
'VALIDATION_ERROR',
'end_year',
),
);
} }
const result = db const result = db
@ -1235,36 +1050,21 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const range = db const range = db
.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id); .get(req.params.rangeId, req.params.id);
if (!range) if (!range) throw NotFoundError('History range not found', 'rangeId');
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = start_year != null ? parseInt(start_year, 10) : range.start_year; const sy = start_year != null ? parseInt(start_year, 10) : range.start_year;
const sm = start_month != null ? parseInt(start_month, 10) : range.start_month; const sm = start_month != null ? parseInt(start_month, 10) : range.start_month;
if (isNaN(sy) || sy < 2000 || sy > 2100) if (isNaN(sy) || sy < 2000 || sy > 2100)
return res throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
if (isNaN(sm) || sm < 1 || sm > 12) if (isNaN(sm) || sm < 1 || sm > 12)
return res throw ValidationError('start_month must be between 1 and 12', 'start_month');
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = range.end_year; let ey = range.end_year;
let em = range.end_month; let em = range.end_month;
@ -1272,33 +1072,16 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null; if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null;
if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100)) if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100))
return res throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
.status(400)
.json(
standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'),
);
if (em != null && (isNaN(em) || em < 1 || em > 12)) if (em != null && (isNaN(em) || em < 1 || em > 12))
return res throw ValidationError('end_month must be between 1 and 12', 'end_month');
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
if ((ey == null) !== (em == null)) if ((ey == null) !== (em == null))
return res throw ValidationError(
.status(400) 'end_year and end_month must both be provided or both omitted',
.json( 'end_year',
standardizeError( );
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
if (ey != null && ey * 12 + em < sy * 12 + sm) if (ey != null && ey * 12 + em < sy * 12 + sm)
return res throw ValidationError('end date must be on or after start date', 'end_year');
.status(400)
.json(
standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'),
);
db.prepare( db.prepare(
` `
@ -1331,15 +1114,12 @@ router.delete('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const range = db const range = db
.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id); .get(req.params.rangeId, req.params.id);
if (!range) if (!range) throw NotFoundError('History range not found', 'rangeId');
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run( db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run(
req.params.rangeId, req.params.rangeId,
@ -1356,8 +1136,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
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 balance = fromCents(Number(bill.current_balance)); const balance = fromCents(Number(bill.current_balance));
const apr = Number(bill.interest_rate) || 0; const apr = Number(bill.interest_rate) || 0;
@ -1368,9 +1147,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
if (req.query.payment !== undefined) { if (req.query.payment !== undefined) {
const qp = parseFloat(req.query.payment); const qp = parseFloat(req.query.payment);
if (!Number.isFinite(qp) || qp <= 0) { if (!Number.isFinite(qp) || qp <= 0) {
return res throw ValidationError('payment must be a positive number', 'payment');
.status(400)
.json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
} }
payment = qp; payment = qp;
} }
@ -1424,7 +1201,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
} }
const include = const include =
req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined; req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
@ -1440,8 +1217,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
parts.push('snowball_exempt = ?'); parts.push('snowball_exempt = ?');
vals.push(exempt); vals.push(exempt);
} }
if (parts.length === 0) if (parts.length === 0) throw ValidationError('Nothing to update');
return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
parts.push("updated_at = datetime('now')"); parts.push("updated_at = datetime('now')");
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run( db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(
...vals, ...vals,
@ -1460,7 +1236,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
} }
const raw = req.body.current_balance; const raw = req.body.current_balance;
@ -1468,15 +1244,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
if (raw !== null && raw !== '' && raw !== undefined) { if (raw !== null && raw !== '' && raw !== undefined) {
val = parseFloat(raw); val = parseFloat(raw);
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
return res throw ValidationError('current_balance must be a non-negative number', 'current_balance');
.status(400)
.json(
standardizeError(
'current_balance must be a non-negative number',
'VALIDATION_ERROR',
'current_balance',
),
);
} }
val = roundMoney(val); val = roundMoney(val);
} }
@ -1538,10 +1306,8 @@ function findConflicts(db, userId, billId, normalized) {
router.get('/:id/merchant-rules', (req: Req, res: Res) => { router.get('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db const rules = db
.prepare( .prepare(
@ -1591,10 +1357,8 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => { router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.query.merchant || '').trim(); const raw = String(req.query.merchant || '').trim();
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
@ -1612,37 +1376,23 @@ router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
router.post('/:id/merchant-rules', (req: Req, res: Res) => { router.post('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.body?.merchant || '').trim(); const raw = String(req.body?.merchant || '').trim();
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2) if (!normalized || normalized.length < 2)
return res throw ValidationError('merchant must be at least 2 characters after normalisation', 'merchant');
.status(400)
.json(
standardizeError(
'merchant must be at least 2 characters after normalisation',
'VALIDATION_ERROR',
'merchant',
),
);
const conflicts = findConflicts(db, req.user.id, billId, normalized); const conflicts = findConflicts(db, req.user.id, billId, normalized);
try { db.prepare(
db.prepare( `
`
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
`, `,
).run(req.user.id, billId, normalized); ).run(req.user.id, billId, normalized);
} catch (err) {
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
}
// Retroactively apply the new rule to existing unmatched transactions // Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId); const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
@ -1665,10 +1415,9 @@ router.post('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => { router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id); const bill = requireBill(db, billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
const rules = db const rules = db
.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?') .prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?')
@ -1750,22 +1499,16 @@ router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => { router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id); const bill = requireBill(db, billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
const ids = req.body?.transaction_ids; const ids = req.body?.transaction_ids;
if (!Array.isArray(ids) || ids.length === 0) if (!Array.isArray(ids) || ids.length === 0)
return res throw ValidationError('transaction_ids must be a non-empty array');
.status(400)
.json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
const validIds = ids.filter((id) => Number.isInteger(id) && id > 0); const validIds = ids.filter((id) => Number.isInteger(id) && id > 0);
if (validIds.length === 0) if (validIds.length === 0) throw ValidationError('No valid transaction ids provided');
return res
.status(400)
.json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL'); const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
const getTx = db.prepare( const getTx = db.prepare(
@ -1837,7 +1580,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
})(); })();
} catch (err) { } catch (err) {
log.error('[import-historical] Transaction failed:', err.message); log.error('[import-historical] Transaction failed:', err.message);
return res.status(500).json(standardizeError('Import failed', 'DB_ERROR')); throw new ApiError('DB_ERROR', 'Import failed', 500);
} }
res.json({ imported, late_attributions: lateAttributions }); res.json({ imported, late_attributions: lateAttributions });
@ -1850,9 +1593,8 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10); const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); throw ValidationError('Invalid id');
if (!requireBill(db, billId, req.user.id)) if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const enabled = req.body?.enabled ? 1 : 0; const enabled = req.body?.enabled ? 1 : 0;
const changes = db const changes = db
@ -1861,7 +1603,7 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
) )
.run(enabled, ruleId, req.user.id, billId).changes; .run(enabled, ruleId, req.user.id, billId).changes;
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND')); if (changes === 0) throw NotFoundError('Rule not found');
res.json({ id: ruleId, auto_attribute_late: enabled === 1 }); res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
}); });
@ -1870,15 +1612,14 @@ router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10); const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); throw ValidationError('Invalid id');
if (!requireBill(db, billId, req.user.id)) if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const changes = db const changes = db
.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?') .prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
.run(ruleId, req.user.id, billId).changes; .run(ruleId, req.user.id, billId).changes;
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND')); if (changes === 0) throw NotFoundError('Rule not found');
res.json({ success: true }); res.json({ success: true });
}); });

View File

@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${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 { getTracker } = require('../services/trackerService.cts'); const { getTracker } = require('../services/trackerService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
@ -60,7 +61,13 @@ function callBillsRoute(routePath, method, { userId, params = {}, query = {}, bo
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
reject(err); // Routes follow the throw-pattern: mirror the app's terminal error
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err);
}
} }
}); });
} }