diff --git a/routes/payments.cts b/routes/payments.cts index c209c95..df45285 100644 --- a/routes/payments.cts +++ b/routes/payments.cts @@ -1,8 +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 express = require('express'); -const { log } = require('../utils/logger.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { + ApiError, + ValidationError, + NotFoundError, + ConflictError, +} = require('../utils/apiError.cts'); const router = require('express').Router(); const { getDb } = require('../db/database.cts'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts'); @@ -27,38 +32,22 @@ function isTransactionLinkedPayment(payment) { return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null; } -function rejectTransactionLinkedPayment(res) { - return res - .status(409) - .json( - standardizeError( - 'Transaction-linked payments must be changed through transaction match controls', - 'TRANSACTION_PAYMENT_LOCKED', - 'transaction_id', - ), - ); +function rejectTransactionLinkedPayment() { + throw ConflictError( + 'Transaction-linked payments must be changed through transaction match controls', + 'transaction_id', + 'TRANSACTION_PAYMENT_LOCKED', + ); } function parseYearMonth(body) { const year = parseInt(body.year, 10); const month = parseInt(body.month, 10); if (!Number.isInteger(year) || year < 2000 || year > 2100) { - return { - error: 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 (!Number.isInteger(month) || month < 1 || month > 12) { - return { - error: 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'); } return { year, month }; } @@ -73,17 +62,9 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) { `, ) .get(billId, userId); - if (!bill) - return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 }; + if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') { - return { - error: standardizeError( - 'Bill is not eligible for autopay suggestions', - 'VALIDATION_ERROR', - 'bill_id', - ), - status: 400, - }; + throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id'); } const state = db @@ -96,26 +77,12 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) { ) .get(bill.id, year, month); if (state?.is_skipped) { - return { - error: standardizeError( - 'Skipped bills cannot be suggested for payment', - 'VALIDATION_ERROR', - 'bill_id', - ), - status: 400, - }; + throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id'); } const dueDate = resolveDueDate(bill, year, month); if (!dueDate) { - return { - error: standardizeError( - 'Bill does not occur in the selected month', - 'VALIDATION_ERROR', - 'month', - ), - status: 400, - }; + throw ValidationError('Bill does not occur in the selected month', 'month'); } const amount = fromCents(state?.actual_amount ?? bill.expected_amount); return { bill, dueDate, amount }; @@ -128,15 +95,7 @@ router.get('/', (req: Req, res: Res) => { // Validate year/month when provided if ((year || month) && !(year && month)) { - return res - .status(400) - .json( - standardizeError( - 'Both year and month are required when filtering by date', - 'VALIDATION_ERROR', - 'year', - ), - ); + throw ValidationError('Both year and month are required when filtering by date', 'year'); } let y, m; @@ -144,26 +103,10 @@ router.get('/', (req: Req, res: Res) => { y = parseInt(year, 10); m = parseInt(month, 10); if (!Number.isInteger(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 (!Number.isInteger(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'); } } @@ -228,8 +171,7 @@ router.get('/:id', (req: Req, res: Res) => { `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, ) .get(req.params.id, req.user.id); - if (!payment) - return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); + if (!payment) throw NotFoundError('Payment not found', 'id'); res.json(serializePayment(payment)); }); @@ -246,51 +188,42 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => { ) .get(req.params.id, req.user.id); - if (!payment) - return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); + if (!payment) throw NotFoundError('Payment not found', 'id'); if (payment.payment_source !== 'provider_sync') { - return res - .status(409) - .json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH')); + throw ConflictError( + 'Only provider_sync payments can be undone here', + undefined, + 'NOT_AUTO_MATCH', + ); } if (!payment.transaction_id) { - return res - .status(409) - .json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION')); + throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION'); } - try { - db.transaction(() => { - // Restore balance (same logic as DELETE /:id) - if (!payment.accounting_excluded && payment.balance_delta != null) { - const bill = db - .prepare('SELECT current_balance FROM bills WHERE id = ?') - .get(payment.bill_id); - if (bill?.current_balance != null) { - const restored = Math.max( - 0, - Number(bill.current_balance) - Number(payment.balance_delta), - ); - db.prepare( - ` + db.transaction(() => { + // Restore balance (same logic as DELETE /:id) + if (!payment.accounting_excluded && payment.balance_delta != null) { + const bill = db + .prepare('SELECT current_balance FROM bills WHERE id = ?') + .get(payment.bill_id); + if (bill?.current_balance != null) { + const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta)); + db.prepare( + ` UPDATE bills SET current_balance = ?, interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END, updated_at = datetime('now') WHERE id = ? `, - ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); - } + ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); } - reactivatePaymentsOverriddenBy(db, payment.id); - db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); - markUnmatched(db, req.user.id, payment.transaction_id); - })(); - res.json({ success: true }); - } catch (err) { - log.error('[payments] undo-auto error:', err.message); - res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR')); - } + } + reactivatePaymentsOverriddenBy(db, payment.id); + db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); + markUnmatched(db, req.user.id, payment.transaction_id); + })(); + res.json({ success: true }); }); // POST /api/payments — create single payment @@ -313,18 +246,13 @@ router.post('/', (req: Req, res: Res) => { paid_date, payment_source: payment_source ?? 'manual', }); - if (validation.error) { - return res - .status(400) - .json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field)); - } + if (validation.error) throw ValidationError(validation.error, validation.field); const payment = validation.normalized; const bill = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(payment.bill_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'); // The deliberate manual add must not *silently* drop a same-key payment — a user // may legitimately record two identical payments on the same day, and dropping @@ -392,17 +320,12 @@ router.post('/quick', (req: Req, res: Res) => { { bill_id }, { requireAmount: false, requirePaidDate: false }, ); - if (billValidation.error) { - return res - .status(400) - .json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field)); - } + if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field); const bill = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(billValidation.normalized.bill_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 paymentValidation = validatePaymentInput( { @@ -412,11 +335,8 @@ router.post('/quick', (req: Req, res: Res) => { }, { 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 payAmount = paymentValidation.normalized.amount; const payDate = paymentValidation.normalized.paid_date; const paySource = paymentValidation.normalized.payment_source; @@ -468,32 +388,23 @@ router.post('/quick', (req: Req, res: Res) => { router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => { const db = getDb(); const ym = parseYearMonth(req.body); - if (ym.error) return res.status(400).json(ym.error); const billId = parseInt(req.params.billId, 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 context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); - if (context.error) return res.status(context.status).json(context.error); const { bill, dueDate, amount } = context; if (dueDate > todayLocal()) { - return res - .status(400) - .json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date')); + throw ValidationError('Autopay suggestion is not due yet', 'paid_date'); } const paymentValidation = validatePaymentInput( { amount, paid_date: dueDate }, { 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 suggestedPayment = paymentValidation.normalized; const suggestionRange = getCycleRange(ym.year, ym.month, bill); @@ -560,20 +471,17 @@ router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => { router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => { const db = getDb(); const ym = parseYearMonth(req.body); - if (ym.error) return res.status(400).json(ym.error); const billId = parseInt(req.params.billId, 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'); } if ( !db .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'); } db.prepare( @@ -602,23 +510,11 @@ router.post('/bulk', (req: Req, res: Res) => { // Validate request body has payments array if (!payments || !Array.isArray(payments)) - return res - .status(400) - .json( - standardizeError( - 'Request body must contain a `payments` array', - 'VALIDATION_ERROR', - 'payments', - ), - ); + throw ValidationError('Request body must contain a `payments` array', 'payments'); // Validate max items per request (50) if (payments.length > 50) - return res - .status(400) - .json( - standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'), - ); + throw ValidationError('Maximum 50 items allowed per request', 'payments'); // Validate each payment item for (let i = 0; i < payments.length; i++) { @@ -628,15 +524,7 @@ router.post('/bulk', (req: Req, res: Res) => { { fieldPrefix: `payments[${i}].` }, ); if (validation.error) { - return res - .status(400) - .json( - standardizeError( - `Payment at index ${i}: ${validation.error}`, - 'VALIDATION_ERROR', - validation.field, - ), - ); + throw ValidationError(`Payment at index ${i}: ${validation.error}`, validation.field); } } @@ -716,20 +604,15 @@ router.put('/:id', (req: Req, res: Res) => { `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, ) .get(req.params.id, req.user.id); - if (!existing) - return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); - if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res); + if (!existing) throw NotFoundError('Payment not found', 'id'); + if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment(); const { amount, paid_date, method, notes, payment_source } = req.body; const validation = validatePaymentInput( { amount, paid_date, payment_source }, { requireBillId: false, requireAmount: false, requirePaidDate: false }, ); - if (validation.error) { - return res - .status(400) - .json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field)); - } + if (validation.error) throw ValidationError(validation.error, validation.field); const nextAmount = validation.normalized.amount ?? existing.amount; const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date; @@ -824,9 +707,8 @@ router.delete('/:id', (req: Req, res: Res) => { `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, ) .get(req.params.id, req.user.id); - if (!payment) - return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); - if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res); + if (!payment) throw NotFoundError('Payment not found', 'id'); + if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment(); // Atomic: reverse the balance delta and soft-delete the payment together, so a // failure can't leave the balance restored while the payment is still active @@ -868,9 +750,8 @@ router.post('/:id/restore', (req: Req, res: Res) => { 'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL', ) .get(req.params.id, req.user.id); - if (!payment) - return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); - if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res); + if (!payment) throw NotFoundError('Deleted payment not found', 'id'); + if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment(); // Atomic: re-apply the balance delta and un-delete the payment together, so a // failure can't leave the balance re-applied while the payment stays deleted @@ -923,90 +804,72 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => { const db = getDb(); const paymentId = parseInt(req.params.id, 10); if (!Number.isInteger(paymentId) || paymentId < 1) { - return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR')); + throw ValidationError('Invalid payment id'); } const { paid_date } = req.body; if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) { - return res - .status(400) - .json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date')); + throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date'); } // Validate it is a real calendar date const newDate = new Date(paid_date + 'T00:00:00Z'); if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { - return res - .status(400) - .json( - standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'), - ); + throw ValidationError('paid_date is not a valid calendar date', 'paid_date'); } - try { - const payment = db - .prepare( - ` + const payment = db + .prepare( + ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL `, - ) - .get(paymentId, req.user.id); + ) + .get(paymentId, req.user.id); - if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND')); + if (!payment) throw NotFoundError('Payment not found'); - // Only allow date-only reclassification for provider_sync payments - if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') { - return res - .status(409) - .json( - standardizeError( - 'Only bank-synced payments can be reclassified to a different month', - 'RECLASSIFY_ONLY_SYNC', - ), - ); - } - - // Sanity check: new date must be in the month immediately before the original date - const orig = new Date(payment.paid_date + 'T00:00:00'); - const origYM = orig.getFullYear() * 12 + orig.getMonth(); - const newYM = newDate.getFullYear() * 12 + newDate.getMonth(); - if (newYM !== origYM - 1) { - return res - .status(400) - .json( - standardizeError( - 'The new paid_date must be in the month immediately before the original payment date', - 'VALIDATION_ERROR', - 'paid_date', - ), - ); - } - - db.transaction(() => { - db.prepare( - "UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?", - ).run(paid_date, paymentId); - - const bill = db - .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') - .get(payment.bill_id, req.user.id); - if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date }); - })(); - - res.json( - serializePayment( - db - .prepare( - `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, - ) - .get(paymentId, req.user.id), - ), + // Only allow date-only reclassification for provider_sync payments + if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') { + throw ConflictError( + 'Only bank-synced payments can be reclassified to a different month', + undefined, + 'RECLASSIFY_ONLY_SYNC', ); - } catch (err) { - log.error('[payments] attribute-to-month error:', err.message); - res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR')); } + + // Sanity check: new date must be in the month immediately before the original date + const orig = new Date(payment.paid_date + 'T00:00:00'); + const origYM = orig.getFullYear() * 12 + orig.getMonth(); + const newYM = newDate.getFullYear() * 12 + newDate.getMonth(); + if (newYM !== origYM - 1) { + throw ValidationError( + 'The new paid_date must be in the month immediately before the original payment date', + 'paid_date', + ); + } + + db.transaction(() => { + db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?").run( + paid_date, + paymentId, + ); + + const bill = db + .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') + .get(payment.bill_id, req.user.id); + if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date }); + })(); + + res.json( + serializePayment( + db + .prepare( + `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, + ) + .get(paymentId, req.user.id), + ), + ); }); module.exports = router; diff --git a/routes/subscriptions.cts b/routes/subscriptions.cts index 28de331..ec4c491 100644 --- a/routes/subscriptions.cts +++ b/routes/subscriptions.cts @@ -3,7 +3,12 @@ import type { Req, Res, Next } from '../types/http'; const express = require('express'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database.cts'); -const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { + ApiError, + ValidationError, + NotFoundError, + ConflictError, +} = require('../utils/apiError.cts'); const { fromCents } = require('../utils/money.mts'); const { createSubscriptionFromRecommendation, @@ -36,45 +41,26 @@ router.get('/recommendations', (req: Req, res: Res) => { }); router.get('/transaction-matches', (req: Req, res: Res) => { - try { - res.json({ - transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query), - }); - } catch (err) { - res - .status(500) - .json( - standardizeError( - err.message || 'Failed to search subscription transactions', - 'SUBSCRIPTION_SEARCH_ERROR', - ), - ); - } + res.json({ + transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query), + }); }); router.post('/recommendations/decline', (req: Req, res: Res) => { const { decline_key } = req.body || {}; if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) { - return res - .status(400) - .json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key')); - } - try { - const db = getDb(); - declineRecommendation(db, req.user.id, decline_key); - recordSubscriptionFeedback(db, req.user.id, { - action: 'decline', - catalog_id: req.body?.catalog_id, - merchant: req.body?.merchant, - confidence: req.body?.confidence, - metadata: { decline_key }, - }); - res.json({ ok: true }); - } catch (err) { - res - .status(500) - .json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR')); + throw ValidationError('decline_key is required', 'decline_key'); } + const db = getDb(); + declineRecommendation(db, req.user.id, decline_key); + recordSubscriptionFeedback(db, req.user.id, { + action: 'decline', + catalog_id: req.body?.catalog_id, + merchant: req.body?.merchant, + confidence: req.body?.confidence, + metadata: { decline_key }, + }); + res.json({ ok: true }); }); // POST /api/subscriptions/recommendations/match-bill @@ -88,20 +74,10 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => { .slice(0, 50); if (!Number.isInteger(billId) || billId < 1) { - return res - .status(400) - .json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id')); + throw ValidationError('bill_id is required', 'bill_id'); } if (txIds.length === 0) { - return res - .status(400) - .json( - standardizeError( - 'transaction_ids must be a non-empty array', - 'VALIDATION_ERROR', - 'transaction_ids', - ), - ); + throw ValidationError('transaction_ids must be a non-empty array', 'transaction_ids'); } const db = getDb(); @@ -110,8 +86,7 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => { 'SELECT id, name, catalog_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', 'bill_id')); + if (!bill) throw NotFoundError('Bill not found', 'bill_id'); const catalogId = parseInt(req.body?.catalog_id, 10); const catalogEntry = Number.isInteger(catalogId) && catalogId > 0 @@ -187,15 +162,7 @@ router.post('/recommendations/create', (req: Req, res: Res) => { .get(categoryId, req.user.id) : null; if (!category) { - 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'); } } try { @@ -212,15 +179,14 @@ router.post('/recommendations/create', (req: Req, res: Res) => { }); res.status(201).json(created); } catch (err) { - res - .status(err.status || 400) - .json( - standardizeError( - err.message || 'Could not create subscription', - err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', - err.field || null, - ), - ); + // Service validation errors are user-facing at 4xx (parity with the old + // behavior: no-status service throws surfaced at 400, never as masked 5xx). + throw new ApiError( + err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', + err.message || 'Could not create subscription', + err.status || 400, + { field: err.field || null }, + ); } }); @@ -228,24 +194,23 @@ router.post('/recommendations/create', (req: Req, res: Res) => { router.get('/catalog', (req: Req, res: Res) => { const db = getDb(); - try { - const catalogEntries = db - .prepare( - ` + const catalogEntries = db + .prepare( + ` SELECT id, rank, name, category, subcategory, subscription_type, website, starting_monthly_usd, starting_annual_usd FROM subscription_catalog ORDER BY rank ASC `, - ) - .all(); + ) + .all(); - if (!catalogEntries.length) return res.json({ catalog: [] }); + if (!catalogEntries.length) return res.json({ catalog: [] }); - // User's subscription bills that are linked to a catalog entry - const matchedBills = db - .prepare( - ` + // User's subscription bills that are linked to a catalog entry + const matchedBills = db + .prepare( + ` SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id, b.cycle_type, b.billing_cycle FROM bills b @@ -254,56 +219,49 @@ router.get('/catalog', (req: Req, res: Res) => { AND b.deleted_at IS NULL AND b.catalog_id IS NOT NULL `, - ) + ) + .all(req.user.id); + + const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b])); + + // User's custom descriptors + let userDescriptors = []; + try { + userDescriptors = db + .prepare('SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?') .all(req.user.id); - - const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b])); - - // User's custom descriptors - let userDescriptors = []; - try { - userDescriptors = db - .prepare( - 'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?', - ) - .all(req.user.id); - } catch { - /* pre-v0.96 */ - } - - const userDescsByCatalogId = new Map(); - for (const d of userDescriptors) { - if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []); - userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor }); - } - - const catalog = catalogEntries.map((entry) => { - const bill = billByCatalogId.get(entry.id) ?? null; - return { - ...entry, - matched_bill: bill - ? { - id: bill.id, - name: bill.name, - expected_amount: fromCents(bill.expected_amount), - active: !!bill.active, - monthly_equivalent: monthlyEquivalent( - fromCents(bill.expected_amount), - bill.cycle_type, - bill.billing_cycle, - ), - } - : null, - user_descriptors: userDescsByCatalogId.get(entry.id) ?? [], - }; - }); - - res.json({ catalog }); - } catch (err) { - res - .status(500) - .json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR')); + } catch { + /* pre-v0.96 */ } + + const userDescsByCatalogId = new Map(); + for (const d of userDescriptors) { + if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []); + userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor }); + } + + const catalog = catalogEntries.map((entry) => { + const bill = billByCatalogId.get(entry.id) ?? null; + return { + ...entry, + matched_bill: bill + ? { + id: bill.id, + name: bill.name, + expected_amount: fromCents(bill.expected_amount), + active: !!bill.active, + monthly_equivalent: monthlyEquivalent( + fromCents(bill.expected_amount), + bill.cycle_type, + bill.billing_cycle, + ), + } + : null, + user_descriptors: userDescsByCatalogId.get(entry.id) ?? [], + }; + }); + + res.json({ catalog }); }); // Update which catalog entry a bill is linked to (or unlink with null) @@ -311,7 +269,7 @@ router.put('/:id/catalog-link', (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')); + throw ValidationError('Invalid bill ID'); } const bill = db @@ -319,7 +277,7 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => { 'SELECT id, name, catalog_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')); + if (!bill) throw NotFoundError('Bill not found'); const rawCatalogId = req.body?.catalog_id; @@ -338,23 +296,12 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => { const catalogId = parseInt(rawCatalogId, 10); if (!Number.isInteger(catalogId) || catalogId < 1) { - return res - .status(400) - .json( - standardizeError( - 'catalog_id must be a positive integer or null', - 'VALIDATION_ERROR', - 'catalog_id', - ), - ); + throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id'); } const catalogEntry = db .prepare('SELECT id FROM subscription_catalog WHERE id = ?') .get(catalogId); - if (!catalogEntry) - return res - .status(404) - .json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id')); + if (!catalogEntry) throw NotFoundError('Catalog entry not found', 'catalog_id'); db.prepare( "UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?", @@ -374,70 +321,49 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => { const db = getDb(); const catalogId = parseInt(req.params.catalogId, 10); if (!Number.isInteger(catalogId) || catalogId < 1) { - return res.status(400).json(standardizeError('Invalid catalog ID', 'VALIDATION_ERROR')); + throw ValidationError('Invalid catalog ID'); } const catalogEntry = db .prepare('SELECT id FROM subscription_catalog WHERE id = ?') .get(catalogId); - if (!catalogEntry) - return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND')); + if (!catalogEntry) throw NotFoundError('Catalog entry not found'); const descriptor = String(req.body?.descriptor ?? '').trim(); if (!descriptor) { - return res - .status(400) - .json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor')); + throw ValidationError('descriptor is required', 'descriptor'); } if (descriptor.length > 100) { - return res - .status(400) - .json( - standardizeError( - 'descriptor must be 100 characters or less', - 'VALIDATION_ERROR', - 'descriptor', - ), - ); + throw ValidationError('descriptor must be 100 characters or less', 'descriptor'); } - try { - // Check for case-insensitive duplicate - const exists = db - .prepare( - 'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)', - ) - .get(req.user.id, catalogId, descriptor); - if (exists) { - return res - .status(409) - .json( - standardizeError( - 'Descriptor already exists for this service', - 'DUPLICATE_ERROR', - 'descriptor', - ), - ); - } - - const result = db - .prepare( - 'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)', - ) - .run(req.user.id, catalogId, descriptor); - recordSubscriptionFeedback(db, req.user.id, { - action: 'add_descriptor', - catalog_id: catalogId, - merchant: descriptor, - descriptor, - }); - - res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId }); - } catch (err) { - res - .status(500) - .json(standardizeError(err.message || 'Failed to add descriptor', 'DESCRIPTOR_ADD_ERROR')); + // Check for case-insensitive duplicate + const exists = db + .prepare( + 'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)', + ) + .get(req.user.id, catalogId, descriptor); + if (exists) { + throw ConflictError( + 'Descriptor already exists for this service', + 'descriptor', + 'DUPLICATE_ERROR', + ); } + + const result = db + .prepare( + 'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)', + ) + .run(req.user.id, catalogId, descriptor); + recordSubscriptionFeedback(db, req.user.id, { + action: 'add_descriptor', + catalog_id: catalogId, + merchant: descriptor, + descriptor, + }); + + res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId }); }); // Delete a user-added catalog descriptor @@ -445,53 +371,42 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => { const db = getDb(); const descriptorId = parseInt(req.params.id, 10); if (!Number.isInteger(descriptorId) || descriptorId < 1) { - return res.status(400).json(standardizeError('Invalid descriptor ID', 'VALIDATION_ERROR')); + throw ValidationError('Invalid descriptor ID'); } - try { - const existing = db - .prepare( - 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?', - ) - .get(descriptorId, req.user.id); - const result = db - .prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?') - .run(descriptorId, req.user.id); - if (result.changes === 0) { - return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND')); - } - if (existing) { - recordSubscriptionFeedback(db, req.user.id, { - action: 'delete_descriptor', - catalog_id: existing.catalog_id, - merchant: existing.descriptor, - descriptor: existing.descriptor, - }); - } - res.json({ ok: true }); - } catch (err) { - res - .status(500) - .json( - standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'), - ); + const existing = db + .prepare( + 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?', + ) + .get(descriptorId, req.user.id); + const result = db + .prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?') + .run(descriptorId, req.user.id); + if (result.changes === 0) { + throw NotFoundError('Descriptor not found'); } + if (existing) { + recordSubscriptionFeedback(db, req.user.id, { + action: 'delete_descriptor', + catalog_id: existing.catalog_id, + merchant: existing.descriptor, + descriptor: existing.descriptor, + }); + } + res.json({ ok: true }); }); router.patch('/:id', (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 existing = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(billId, 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'); const allowedTypes = new Set([ 'streaming', @@ -530,15 +445,7 @@ router.patch('/:id', (req: Req, res: Res) => { next.reminder_days_before < 0 || next.reminder_days_before > 30 ) { - return res - .status(400) - .json( - standardizeError( - 'reminder_days_before must be between 0 and 30', - 'VALIDATION_ERROR', - 'reminder_days_before', - ), - ); + throw ValidationError('reminder_days_before must be between 0 and 30', 'reminder_days_before'); } db.prepare( diff --git a/tests/paymentsRoute.test.js b/tests/paymentsRoute.test.js index bb243e6..4c1007d 100644 --- a/tests/paymentsRoute.test.js +++ b/tests/paymentsRoute.test.js @@ -15,6 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqli process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database.cts'); +const { formatError } = require('../utils/apiError.cts'); const router = require('../routes/payments.cts'); function handler(method, routePath) { @@ -36,7 +37,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) { resolve({ status: this.statusCode, data: d }); }, }; - h(req, res); + // Routes follow the throw-pattern: mirror the app's terminal error handler + // (server.cts) so thrown ApiErrors resolve to the same wire shape. + try { + h(req, res); + } catch (err) { + resolve({ status: err.status || 500, data: formatError(err) }); + } }); } function balanceOf(billId) { diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index 4842353..810a9b2 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${pro process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database.cts'); +const { formatError } = require('../utils/apiError.cts'); const { ensureManualDataSource } = require('../services/transactionService.cts'); const { getTracker } = require('../services/trackerService.cts'); const { @@ -131,7 +132,13 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) { 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); + } } }); } @@ -164,7 +171,13 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {}, 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); + } } }); }