diff --git a/routes/bills.cts b/routes/bills.cts index 6595f49..7308399 100644 --- a/routes/bills.cts +++ b/routes/bills.cts @@ -16,7 +16,12 @@ const { applyBalanceDelta, } = require('../services/billsService.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 { addMerchantRule, @@ -100,9 +105,7 @@ router.put('/reorder', (req: Req, res: Res) => { })); if (entries.length === 0) { - return res - .status(400) - .json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder')); + throw ValidationError('At least one bill order is required', 'reorder'); } 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, ); if (invalid) { - return res - .status(400) - .json( - standardizeError( - 'Reorder payload must map bill ids to non-negative integer positions', - 'VALIDATION_ERROR', - 'reorder', - ), - ); + throw ValidationError( + 'Reorder payload must map bill ids to non-negative integer positions', + 'reorder', + ); } const ids = entries.map((item) => item.billId); @@ -133,9 +131,7 @@ router.put('/reorder', (req: Req, res: Res) => { ) .all(req.user.id, ...ids); if (owned.length !== ids.length) { - return res - .status(404) - .json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id')); + throw NotFoundError('One or more bills were not found', 'bill_id'); } const update = db.prepare( @@ -172,19 +168,14 @@ router.get('/audit', (req: Req, res: Res) => { // ── GET /api/bills/drift-report ────────────────────────────────────────────── router.get('/drift-report', (req: Req, res: Res) => { const { getDriftReport } = require('../services/driftService.cts'); - try { - res.json(getDriftReport(req.user.id)); - } catch (err) { - res.status(500).json({ error: 'Failed to compute drift report' }); - } + res.json(getDriftReport(req.user.id)); }); // GET /api/bills/merchant-rules — all rules for this user across all bills router.get('/merchant-rules', (req: Req, res: Res) => { - try { - const rules = getDb() - .prepare( - ` + const rules = getDb() + .prepare( + ` SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at, b.id AS bill_id, b.name AS bill_name FROM bill_merchant_rules bmr @@ -192,13 +183,9 @@ router.get('/merchant-rules', (req: Req, res: Res) => { WHERE bmr.user_id = ? ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC `, - ) - .all(req.user.id); - res.json({ rules }); - } catch (err) { - log.error('[bills/merchant-rules GET]', err.message); - res.status(500).json({ error: 'Failed to load merchant rules' }); - } + ) + .all(req.user.id); + res.json({ rules }); }); // ── 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) => { const db = getDb(); 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 .prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL') .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(); until.setDate(until.getDate() + 30); const untilStr = localDateString(until); @@ -257,36 +244,20 @@ router.post('/templates', (req: Req, res: Res) => { const db = getDb(); const name = String(req.body.name || '').trim(); if (name.length < 2) { - return res - .status(400) - .json( - standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'), - ); + throw ValidationError('Template name must be at least 2 characters', 'name'); } const data = sanitizeTemplateData(req.body.data || {}); if (Object.keys(data).length === 0) { - return res - .status(400) - .json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data')); + throw ValidationError('Template data is required', 'data'); } const validation = validateBillData(data); if (validation.errors.length > 0) { const firstError = validation.errors[0]; - return res - .status(400) - .json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`)); + throw ValidationError(firstError.message, `data.${firstError.field}`); } if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) { - return res - .status(400) - .json( - standardizeError( - 'category_id is invalid for this user', - 'VALIDATION_ERROR', - 'data.category_id', - ), - ); + throw ValidationError('category_id is invalid for this user', 'data.category_id'); } const normalizedData = sanitizeTemplateData(validation.normalized); @@ -323,15 +294,12 @@ router.delete('/templates/:templateId', (req: Req, res: Res) => { const db = getDb(); const templateId = parseInt(req.params.templateId, 10); if (!Number.isInteger(templateId)) { - return res - .status(400) - .json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id')); + throw ValidationError('template_id must be an integer', 'template_id'); } const result = db .prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?') .run(templateId, req.user.id); - if (result.changes === 0) - return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id')); + if (result.changes === 0) throw NotFoundError('Template not found', 'template_id'); res.json({ success: true }); }); @@ -341,15 +309,12 @@ router.post('/:id/duplicate', (req: Req, res: Res) => { const body = req.body || {}; const billId = parseInt(req.params.id, 10); if (!Number.isInteger(billId)) { - return res - .status(400) - .json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id')); + throw ValidationError('bill_id must be an integer', 'bill_id'); } const source = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(billId, req.user.id); - if (!source) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + if (!source) throw NotFoundError('Bill not found', 'bill_id'); const draft = { ...sanitizeTemplateData(serializeBill(source)), @@ -359,17 +324,11 @@ router.post('/:id/duplicate', (req: Req, res: Res) => { const validation = validateBillData(draft); if (validation.errors.length > 0) { const firstError = validation.errors[0]; - return res - .status(400) - .json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field)); + throw ValidationError(firstError.message, firstError.field); } const { normalized } = validation; if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { - return res - .status(400) - .json( - standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'), - ); + throw ValidationError('category_id is invalid for this user', 'category_id'); } 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') .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 month = parseInt(req.query.month, 10); if (isNaN(year) || year < 2000 || year > 2100) - return res - .status(400) - .json( - standardizeError( - 'year must be a 4-digit integer between 2000 and 2100', - 'VALIDATION_ERROR', - 'year', - ), - ); + throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); if (isNaN(month) || month < 1 || month > 12) - return res - .status(400) - .json( - standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'), - ); + throw ValidationError('month must be an integer between 1 and 12', 'month'); const mbs = db .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') .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 y = parseInt(year, 10); const m = parseInt(month, 10); if (isNaN(y) || y < 2000 || y > 2100) - return res - .status(400) - .json( - standardizeError( - 'year must be a 4-digit integer between 2000 and 2100', - 'VALIDATION_ERROR', - 'year', - ), - ); + throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); if (isNaN(m) || m < 1 || m > 12) - return res - .status(400) - .json( - standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'), - ); + throw ValidationError('month must be an integer between 1 and 12', 'month'); if (actual_amount !== undefined && actual_amount !== null) { const amt = parseFloat(actual_amount); if (isNaN(amt) || amt < 0) - return res - .status(400) - .json( - standardizeError( - 'actual_amount must be a non-negative number or null', - 'VALIDATION_ERROR', - 'actual_amount', - ), - ); + throw ValidationError('actual_amount must be a non-negative number or null', 'actual_amount'); } if (snoozed_until !== undefined && snoozed_until !== null) { if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until)) - return res - .status(400) - .json( - standardizeError( - 'snoozed_until must be an ISO date string (YYYY-MM-DD) or null', - 'VALIDATION_ERROR', - 'snoozed_until', - ), - ); + throw ValidationError( + 'snoozed_until must be an ISO date string (YYYY-MM-DD) or null', + 'snoozed_until', + ); } // 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); - if (!bill) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + if (!bill) throw NotFoundError('Bill not found', 'bill_id'); let autopay_stats = null; if (bill.autopay_enabled) { @@ -584,25 +505,16 @@ router.post('/:id/verify-autopay', (req: Req, res: Res) => { const db = getDb(); const id = parseInt(req.params.id, 10); 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 .prepare( 'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', ) .get(id, 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'); if (!bill.autopay_enabled) { - return res - .status(400) - .json( - standardizeError( - 'Bill does not have autopay enabled', - 'VALIDATION_ERROR', - 'autopay_enabled', - ), - ); + throw ValidationError('Bill does not have autopay enabled', 'autopay_enabled'); } const now = new Date().toISOString(); db.prepare( @@ -624,23 +536,12 @@ router.post('/', (req: Req, res: Res) => { ) { const sourceBillId = parseInt(body.source_bill_id, 10); if (!Number.isInteger(sourceBillId)) { - return res - .status(400) - .json( - standardizeError( - 'source_bill_id must be an integer', - 'VALIDATION_ERROR', - 'source_bill_id', - ), - ); + throw ValidationError('source_bill_id must be an integer', 'source_bill_id'); } const source = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(sourceBillId, req.user.id); - if (!source) - return res - .status(404) - .json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id')); + if (!source) throw NotFoundError('Source bill not found', 'source_bill_id'); payload = { ...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(body), @@ -652,20 +553,14 @@ router.post('/', (req: Req, res: Res) => { const validation = validateBillData(payload); if (validation.errors.length > 0) { const firstError = validation.errors[0]; - return res - .status(400) - .json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field)); + throw ValidationError(firstError.message, firstError.field); } const { normalized } = validation; // Validate category_id exists for this user if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { - return res - .status(400) - .json( - standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'), - ); + throw ValidationError('category_id is invalid for this user', 'category_id'); } 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 .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(req.params.id, req.user.id); - if (!existing) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + if (!existing) throw NotFoundError('Bill not found', 'bill_id'); // Validate and normalize bill data const validation = validateBillData(req.body, existing); if (validation.errors.length > 0) { const firstError = validation.errors[0]; - return res - .status(400) - .json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field)); + throw ValidationError(firstError.message, firstError.field); } const { normalized } = validation; // Validate category_id exists for this user if changed if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { - return res - .status(400) - .json( - standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'), - ); + throw ValidationError('category_id is invalid for this user', 'category_id'); } const inactiveReason = @@ -772,13 +660,12 @@ router.put('/:id/archived', (req: Req, res: Res) => { const db = getDb(); const id = parseInt(req.params.id, 10); 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 .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(id, 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 archived = !!req.body?.archived; db.prepare( @@ -797,8 +684,7 @@ router.delete('/:id', (req: Req, res: Res) => { const bill = db .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(req.params.id, 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'); db.prepare( "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 .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL') .get(req.params.id, req.user.id); - if (!bill) - return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id')); + if (!bill) throw NotFoundError('Deleted bill not found', 'bill_id'); db.prepare( "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. router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => { const billId = parseInt(req.params.id, 10); - if (!Number.isInteger(billId)) - return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); + if (!Number.isInteger(billId)) throw ValidationError('Invalid bill id'); const db = getDb(); const bill = db .prepare('SELECT id 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')); - try { - const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId); - res.json(result); - } catch (err) { - res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR')); - } + if (!bill) throw NotFoundError('Bill not found'); + const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId); + res.json(result); }); // ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── @@ -860,8 +740,7 @@ router.get('/:id/payments', (req: Req, res: Res) => { const bill = db .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(req.params.id, 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 limit = Math.min(parseInt(req.query.limit || '20', 10), 100); 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 billId = parseInt(req.params.id, 10); if (!Number.isInteger(billId)) { - return res - .status(400) - .json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id')); + throw ValidationError('bill_id must be an integer', 'bill_id'); } const bill = db .prepare('SELECT id, name 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 rows = db .prepare( @@ -971,40 +847,19 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => { ) .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'); // Scope to year/month if provided const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null; const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null; if ((year === null) !== (month === null)) { - return res - .status(400) - .json( - standardizeError( - 'year and month must both be provided or both omitted', - 'VALIDATION_ERROR', - 'year', - ), - ); + throw ValidationError('year and month must both be provided or both omitted', 'year'); } if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) { - return res - .status(400) - .json( - standardizeError( - 'year must be a 4-digit integer between 2000 and 2100', - 'VALIDATION_ERROR', - 'year', - ), - ); + throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); } if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) { - return res - .status(400) - .json( - standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'), - ); + throw ValidationError('month must be an integer between 1 and 12', 'month'); } 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' }, { requireBillId: false }, ); - if (paymentValidation.error) { - return res - .status(400) - .json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field)); - } + if (paymentValidation.error) + throw ValidationError(paymentValidation.error, paymentValidation.field); const payment = paymentValidation.normalized; // 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') .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 .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') .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 sy = parseInt(start_year, 10); const sm = parseInt(start_month, 10); if (isNaN(sy) || sy < 2000 || sy > 2100) - return res - .status(400) - .json( - standardizeError( - 'start_year must be between 2000 and 2100', - 'VALIDATION_ERROR', - 'start_year', - ), - ); + throw ValidationError('start_year must be between 2000 and 2100', 'start_year'); if (isNaN(sm) || sm < 1 || sm > 12) - return res - .status(400) - .json( - standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'), - ); + throw ValidationError('start_month must be between 1 and 12', 'start_month'); let ey = null, em = null; if (end_year != null) { ey = parseInt(end_year, 10); if (isNaN(ey) || ey < 2000 || ey > 2100) - return res - .status(400) - .json( - standardizeError( - 'end_year must be between 2000 and 2100', - 'VALIDATION_ERROR', - 'end_year', - ), - ); + throw ValidationError('end_year must be between 2000 and 2100', 'end_year'); } if (end_month != null) { em = parseInt(end_month, 10); if (isNaN(em) || em < 1 || em > 12) - return res - .status(400) - .json( - standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'), - ); + throw ValidationError('end_month must be between 1 and 12', 'end_month'); } if ((ey == null) !== (em == null)) { - return res - .status(400) - .json( - standardizeError( - 'end_year and end_month must both be provided or both omitted', - 'VALIDATION_ERROR', - 'end_year', - ), - ); + throw ValidationError( + 'end_year and end_month must both be provided or both omitted', + 'end_year', + ); } if (ey != null) { const startVal = sy * 12 + sm; const endVal = ey * 12 + em; if (endVal < startVal) - return res - .status(400) - .json( - standardizeError( - 'end date must be on or after start date', - 'VALIDATION_ERROR', - 'end_year', - ), - ); + throw ValidationError('end date must be on or after start date', 'end_year'); } 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') .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 .prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .get(req.params.rangeId, req.params.id); - if (!range) - return res - .status(404) - .json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId')); + if (!range) throw NotFoundError('History range not found', 'rangeId'); 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 sm = start_month != null ? parseInt(start_month, 10) : range.start_month; if (isNaN(sy) || sy < 2000 || sy > 2100) - return res - .status(400) - .json( - standardizeError( - 'start_year must be between 2000 and 2100', - 'VALIDATION_ERROR', - 'start_year', - ), - ); + throw ValidationError('start_year must be between 2000 and 2100', 'start_year'); if (isNaN(sm) || sm < 1 || sm > 12) - return res - .status(400) - .json( - standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'), - ); + throw ValidationError('start_month must be between 1 and 12', 'start_month'); let ey = range.end_year; 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 (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100)) - return res - .status(400) - .json( - standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'), - ); + throw ValidationError('end_year must be between 2000 and 2100', 'end_year'); if (em != null && (isNaN(em) || em < 1 || em > 12)) - return res - .status(400) - .json( - standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'), - ); + throw ValidationError('end_month must be between 1 and 12', 'end_month'); if ((ey == null) !== (em == null)) - return res - .status(400) - .json( - standardizeError( - 'end_year and end_month must both be provided or both omitted', - 'VALIDATION_ERROR', - 'end_year', - ), - ); + throw ValidationError( + 'end_year and end_month must both be provided or both omitted', + 'end_year', + ); if (ey != null && ey * 12 + em < sy * 12 + sm) - return res - .status(400) - .json( - standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'), - ); + throw ValidationError('end date must be on or after start date', 'end_year'); 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') .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 .prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .get(req.params.rangeId, req.params.id); - if (!range) - return res - .status(404) - .json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId')); + if (!range) throw NotFoundError('History range not found', 'rangeId'); db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run( req.params.rangeId, @@ -1356,8 +1136,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => { 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 balance = fromCents(Number(bill.current_balance)); const apr = Number(bill.interest_rate) || 0; @@ -1368,9 +1147,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => { if (req.query.payment !== undefined) { const qp = parseFloat(req.query.payment); if (!Number.isFinite(qp) || qp <= 0) { - return res - .status(400) - .json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment')); + throw ValidationError('payment must be a positive number', 'payment'); } 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') .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 = 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 = ?'); vals.push(exempt); } - if (parts.length === 0) - return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR')); + if (parts.length === 0) throw ValidationError('Nothing to update'); parts.push("updated_at = datetime('now')"); db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run( ...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') .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; @@ -1468,15 +1244,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => { if (raw !== null && raw !== '' && raw !== undefined) { val = parseFloat(raw); if (!Number.isFinite(val) || val < 0) { - return res - .status(400) - .json( - standardizeError( - 'current_balance must be a non-negative number', - 'VALIDATION_ERROR', - 'current_balance', - ), - ); + throw ValidationError('current_balance must be a non-negative number', 'current_balance'); } val = roundMoney(val); } @@ -1538,10 +1306,8 @@ function findConflicts(db, userId, billId, normalized) { router.get('/:id/merchant-rules', (req: Req, res: Res) => { const db = getDb(); const billId = parseInt(req.params.id, 10); - if (!Number.isInteger(billId) || billId < 1) - return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); - if (!requireBill(db, billId, req.user.id)) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); + if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); + if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); const rules = db .prepare( @@ -1591,10 +1357,8 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => { router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => { const db = getDb(); const billId = parseInt(req.params.id, 10); - if (!Number.isInteger(billId) || billId < 1) - return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); - if (!requireBill(db, billId, req.user.id)) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); + if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); + if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); const raw = String(req.query.merchant || '').trim(); 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) => { const db = getDb(); const billId = parseInt(req.params.id, 10); - if (!Number.isInteger(billId) || billId < 1) - return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); - if (!requireBill(db, billId, req.user.id)) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); + if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); + if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); const raw = String(req.body?.merchant || '').trim(); const normalized = normalizeMerchant(raw); if (!normalized || normalized.length < 2) - return res - .status(400) - .json( - standardizeError( - 'merchant must be at least 2 characters after normalisation', - 'VALIDATION_ERROR', - 'merchant', - ), - ); + throw ValidationError('merchant must be at least 2 characters after normalisation', 'merchant'); const conflicts = findConflicts(db, req.user.id, billId, normalized); - try { - db.prepare( - ` + db.prepare( + ` INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) VALUES (?, ?, ?) ON CONFLICT(user_id, bill_id, merchant) DO NOTHING `, - ).run(req.user.id, billId, normalized); - } catch (err) { - return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR')); - } + ).run(req.user.id, billId, normalized); // Retroactively apply the new rule to existing unmatched transactions 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) => { const db = getDb(); const billId = parseInt(req.params.id, 10); - if (!Number.isInteger(billId) || billId < 1) - return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); + if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill 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 .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) => { const db = getDb(); const billId = parseInt(req.params.id, 10); - if (!Number.isInteger(billId) || billId < 1) - return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); + if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill 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; if (!Array.isArray(ids) || ids.length === 0) - return res - .status(400) - .json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR')); + throw ValidationError('transaction_ids must be a non-empty array'); const validIds = ids.filter((id) => Number.isInteger(id) && id > 0); - if (validIds.length === 0) - return res - .status(400) - .json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR')); + if (validIds.length === 0) throw ValidationError('No valid transaction ids provided'); const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL'); const getTx = db.prepare( @@ -1837,7 +1580,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => { })(); } catch (err) { 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 }); @@ -1850,9 +1593,8 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res) const billId = parseInt(req.params.id, 10); const ruleId = parseInt(req.params.ruleId, 10); if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) - return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); - if (!requireBill(db, billId, req.user.id)) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); + throw ValidationError('Invalid id'); + if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); const enabled = req.body?.enabled ? 1 : 0; 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; - 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 }); }); @@ -1870,15 +1612,14 @@ router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => { const billId = parseInt(req.params.id, 10); const ruleId = parseInt(req.params.ruleId, 10); if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) - return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); - if (!requireBill(db, billId, req.user.id)) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); + throw ValidationError('Invalid id'); + if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); const changes = db .prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?') .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 }); }); diff --git a/tests/billReorder.test.js b/tests/billReorder.test.js index 6b3fd96..f7376be 100644 --- a/tests/billReorder.test.js +++ b/tests/billReorder.test.js @@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${process.pid}. process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database.cts'); +const { formatError } = require('../utils/apiError.cts'); const { getTracker } = require('../services/trackerService.cts'); function createUser(db, suffix) { @@ -60,7 +61,13 @@ function callBillsRoute(routePath, method, { userId, params = {}, query = {}, bo try { handler(req, res); } 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); + } } }); }