refactor(routes): throw-pattern for payments + subscriptions

Standards-unification batch 2. All inline 4xx bodies and try/catch-500
wrappers converted to thrown ApiError factories; validation helpers
(parseYearMonth, getAutopaySuggestionContext, rejectTransactionLinkedPayment)
now throw. Client-visible codes preserved (TRANSACTION_PAYMENT_LOCKED,
NOT_AUTO_MATCH, NO_TRANSACTION, RECLASSIFY_ONLY_SYNC, DUPLICATE_ERROR,
NO_DEBTS). Kills five more err.message-on-500 leaks in subscriptions.cts
(search/decline/catalog/descriptor wrappers).

Two deliberate exceptions, documented inline:
- payments POST / keeps the hand-rolled DUPLICATE_SUSPECTED 409 (carries the
  'existing' payment payload the client's add-anyway flow reads)
- subscriptions /recommendations/create keeps 400-parity pass-through of
  service validation messages via an ApiError wrap

Test harnesses (paymentsRoute, transactionMatchService) mirror the terminal
handler for thrown errors. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 16:53:20 -05:00
parent c4219b6020
commit 543e94288e
4 changed files with 279 additions and 489 deletions

View File

@ -1,8 +1,13 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.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; return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
} }
function rejectTransactionLinkedPayment(res) { function rejectTransactionLinkedPayment() {
return res throw ConflictError(
.status(409) 'Transaction-linked payments must be changed through transaction match controls',
.json( 'transaction_id',
standardizeError( 'TRANSACTION_PAYMENT_LOCKED',
'Transaction-linked payments must be changed through transaction match controls', );
'TRANSACTION_PAYMENT_LOCKED',
'transaction_id',
),
);
} }
function parseYearMonth(body) { function parseYearMonth(body) {
const year = parseInt(body.year, 10); const year = parseInt(body.year, 10);
const month = parseInt(body.month, 10); const month = parseInt(body.month, 10);
if (!Number.isInteger(year) || year < 2000 || year > 2100) { if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
error: standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
};
} }
if (!Number.isInteger(month) || month < 1 || month > 12) { if (!Number.isInteger(month) || month < 1 || month > 12) {
return { throw ValidationError('month must be an integer between 1 and 12', 'month');
error: standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
};
} }
return { year, month }; return { year, month };
} }
@ -73,17 +62,9 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
`, `,
) )
.get(billId, userId); .get(billId, userId);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') { if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
return { throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id');
error: standardizeError(
'Bill is not eligible for autopay suggestions',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
} }
const state = db const state = db
@ -96,26 +77,12 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
) )
.get(bill.id, year, month); .get(bill.id, year, month);
if (state?.is_skipped) { if (state?.is_skipped) {
return { throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id');
error: standardizeError(
'Skipped bills cannot be suggested for payment',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
} }
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) { if (!dueDate) {
return { throw ValidationError('Bill does not occur in the selected month', 'month');
error: standardizeError(
'Bill does not occur in the selected month',
'VALIDATION_ERROR',
'month',
),
status: 400,
};
} }
const amount = fromCents(state?.actual_amount ?? bill.expected_amount); const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
return { bill, dueDate, amount }; return { bill, dueDate, amount };
@ -128,15 +95,7 @@ router.get('/', (req: Req, res: Res) => {
// Validate year/month when provided // Validate year/month when provided
if ((year || month) && !(year && month)) { if ((year || month) && !(year && month)) {
return res throw ValidationError('Both year and month are required when filtering by date', 'year');
.status(400)
.json(
standardizeError(
'Both year and month are required when filtering by date',
'VALIDATION_ERROR',
'year',
),
);
} }
let y, m; let y, m;
@ -144,26 +103,10 @@ router.get('/', (req: Req, res: Res) => {
y = parseInt(year, 10); y = parseInt(year, 10);
m = parseInt(month, 10); m = parseInt(month, 10);
if (!Number.isInteger(y) || y < 2000 || y > 2100) { if (!Number.isInteger(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 (!Number.isInteger(m) || m < 1 || m > 12) { if (!Number.isInteger(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',
),
);
} }
} }
@ -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`, `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); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(serializePayment(payment)); res.json(serializePayment(payment));
}); });
@ -246,51 +188,42 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (payment.payment_source !== 'provider_sync') { if (payment.payment_source !== 'provider_sync') {
return res throw ConflictError(
.status(409) 'Only provider_sync payments can be undone here',
.json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH')); undefined,
'NOT_AUTO_MATCH',
);
} }
if (!payment.transaction_id) { if (!payment.transaction_id) {
return res throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION');
.status(409)
.json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
} }
try { db.transaction(() => {
db.transaction(() => { // Restore balance (same logic as DELETE /:id)
// Restore balance (same logic as DELETE /:id) if (!payment.accounting_excluded && payment.balance_delta != null) {
if (!payment.accounting_excluded && payment.balance_delta != null) { const bill = db
const bill = db .prepare('SELECT current_balance FROM bills WHERE id = ?')
.prepare('SELECT current_balance FROM bills WHERE id = ?') .get(payment.bill_id);
.get(payment.bill_id); if (bill?.current_balance != null) {
if (bill?.current_balance != null) { const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
const restored = Math.max( db.prepare(
0, `
Number(bill.current_balance) - Number(payment.balance_delta),
);
db.prepare(
`
UPDATE bills UPDATE bills
SET current_balance = ?, SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END, interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? 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); reactivatePaymentsOverriddenBy(db, payment.id);
markUnmatched(db, req.user.id, payment.transaction_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) { res.json({ success: true });
log.error('[payments] undo-auto error:', err.message);
res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR'));
}
}); });
// POST /api/payments — create single payment // POST /api/payments — create single payment
@ -313,18 +246,13 @@ router.post('/', (req: Req, res: Res) => {
paid_date, paid_date,
payment_source: payment_source ?? 'manual', payment_source: payment_source ?? 'manual',
}); });
if (validation.error) { if (validation.error) throw ValidationError(validation.error, validation.field);
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const payment = validation.normalized; const payment = validation.normalized;
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(payment.bill_id, req.user.id); .get(payment.bill_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'));
// The deliberate manual add must not *silently* drop a same-key payment — a user // 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 // 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 }, { bill_id },
{ requireAmount: false, requirePaidDate: false }, { requireAmount: false, requirePaidDate: false },
); );
if (billValidation.error) { if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field);
return res
.status(400)
.json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
}
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(billValidation.normalized.bill_id, req.user.id); .get(billValidation.normalized.bill_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 paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ {
@ -412,11 +335,8 @@ router.post('/quick', (req: Req, res: Res) => {
}, },
{ 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 payAmount = paymentValidation.normalized.amount; const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date; const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source; 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) => { router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const ym = parseYearMonth(req.body); const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10); const billId = parseInt(req.params.billId, 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 context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); 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; const { bill, dueDate, amount } = context;
if (dueDate > todayLocal()) { if (dueDate > todayLocal()) {
return res throw ValidationError('Autopay suggestion is not due yet', 'paid_date');
.status(400)
.json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
} }
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ amount, paid_date: dueDate }, { amount, paid_date: dueDate },
{ 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 suggestedPayment = paymentValidation.normalized; const suggestedPayment = paymentValidation.normalized;
const suggestionRange = getCycleRange(ym.year, ym.month, bill); 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) => { router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const ym = parseYearMonth(req.body); const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10); const billId = parseInt(req.params.billId, 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'));
} }
if ( if (
!db !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)
) { ) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
} }
db.prepare( db.prepare(
@ -602,23 +510,11 @@ router.post('/bulk', (req: Req, res: Res) => {
// Validate request body has payments array // Validate request body has payments array
if (!payments || !Array.isArray(payments)) if (!payments || !Array.isArray(payments))
return res throw ValidationError('Request body must contain a `payments` array', 'payments');
.status(400)
.json(
standardizeError(
'Request body must contain a `payments` array',
'VALIDATION_ERROR',
'payments',
),
);
// Validate max items per request (50) // Validate max items per request (50)
if (payments.length > 50) if (payments.length > 50)
return res throw ValidationError('Maximum 50 items allowed per request', 'payments');
.status(400)
.json(
standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'),
);
// Validate each payment item // Validate each payment item
for (let i = 0; i < payments.length; i++) { for (let i = 0; i < payments.length; i++) {
@ -628,15 +524,7 @@ router.post('/bulk', (req: Req, res: Res) => {
{ fieldPrefix: `payments[${i}].` }, { fieldPrefix: `payments[${i}].` },
); );
if (validation.error) { if (validation.error) {
return res throw ValidationError(`Payment at index ${i}: ${validation.error}`, validation.field);
.status(400)
.json(
standardizeError(
`Payment at index ${i}: ${validation.error}`,
'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`, `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); .get(req.params.id, req.user.id);
if (!existing) if (!existing) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment();
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
const { amount, paid_date, method, notes, payment_source } = req.body; const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput( const validation = validatePaymentInput(
{ amount, paid_date, payment_source }, { amount, paid_date, payment_source },
{ requireBillId: false, requireAmount: false, requirePaidDate: false }, { requireBillId: false, requireAmount: false, requirePaidDate: false },
); );
if (validation.error) { if (validation.error) throw ValidationError(validation.error, validation.field);
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const nextAmount = validation.normalized.amount ?? existing.amount; const nextAmount = validation.normalized.amount ?? existing.amount;
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date; 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`, `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); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: reverse the balance delta and soft-delete the payment together, so a // 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 // 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', '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); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Deleted payment not found', 'id');
return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: re-apply the balance delta and un-delete the payment together, so a // 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 // 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 db = getDb();
const paymentId = parseInt(req.params.id, 10); const paymentId = parseInt(req.params.id, 10);
if (!Number.isInteger(paymentId) || paymentId < 1) { 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; const { paid_date } = req.body;
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) { if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
return res throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date');
.status(400)
.json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
} }
// Validate it is a real calendar date // Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00Z'); const newDate = new Date(paid_date + 'T00:00:00Z');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
return res throw ValidationError('paid_date is not a valid calendar date', 'paid_date');
.status(400)
.json(
standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'),
);
} }
try { const payment = db
const payment = db .prepare(
.prepare( `
`
SELECT p.* FROM payments p SELECT p.* FROM payments p
JOIN bills b ON b.id = p.bill_id 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 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 // Only allow date-only reclassification for provider_sync payments
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') { if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
return res throw ConflictError(
.status(409) 'Only bank-synced payments can be reclassified to a different month',
.json( undefined,
standardizeError( 'RECLASSIFY_ONLY_SYNC',
'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),
),
); );
} 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; module.exports = router;

View File

@ -3,7 +3,12 @@ import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts'); 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 { fromCents } = require('../utils/money.mts');
const { const {
createSubscriptionFromRecommendation, createSubscriptionFromRecommendation,
@ -36,45 +41,26 @@ router.get('/recommendations', (req: Req, res: Res) => {
}); });
router.get('/transaction-matches', (req: Req, res: Res) => { router.get('/transaction-matches', (req: Req, res: Res) => {
try { res.json({
res.json({ transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
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',
),
);
}
}); });
router.post('/recommendations/decline', (req: Req, res: Res) => { router.post('/recommendations/decline', (req: Req, res: Res) => {
const { decline_key } = req.body || {}; const { decline_key } = req.body || {};
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) { if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
return res throw ValidationError('decline_key is required', 'decline_key');
.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'));
} }
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 // POST /api/subscriptions/recommendations/match-bill
@ -88,20 +74,10 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
.slice(0, 50); .slice(0, 50);
if (!Number.isInteger(billId) || billId < 1) { if (!Number.isInteger(billId) || billId < 1) {
return res throw ValidationError('bill_id is required', 'bill_id');
.status(400)
.json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
} }
if (txIds.length === 0) { if (txIds.length === 0) {
return res throw ValidationError('transaction_ids must be a non-empty array', 'transaction_ids');
.status(400)
.json(
standardizeError(
'transaction_ids must be a non-empty array',
'VALIDATION_ERROR',
'transaction_ids',
),
);
} }
const db = getDb(); 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', 'SELECT id, name, catalog_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) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const catalogId = parseInt(req.body?.catalog_id, 10); const catalogId = parseInt(req.body?.catalog_id, 10);
const catalogEntry = const catalogEntry =
Number.isInteger(catalogId) && catalogId > 0 Number.isInteger(catalogId) && catalogId > 0
@ -187,15 +162,7 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
.get(categoryId, req.user.id) .get(categoryId, req.user.id)
: null; : null;
if (!category) { if (!category) {
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',
),
);
} }
} }
try { try {
@ -212,15 +179,14 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
}); });
res.status(201).json(created); res.status(201).json(created);
} catch (err) { } catch (err) {
res // Service validation errors are user-facing at 4xx (parity with the old
.status(err.status || 400) // behavior: no-status service throws surfaced at 400, never as masked 5xx).
.json( throw new ApiError(
standardizeError( err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
err.message || 'Could not create subscription', err.message || 'Could not create subscription',
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.status || 400,
err.field || null, { field: err.field || null },
), );
);
} }
}); });
@ -228,24 +194,23 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
router.get('/catalog', (req: Req, res: Res) => { router.get('/catalog', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
try { const catalogEntries = db
const catalogEntries = db .prepare(
.prepare( `
`
SELECT id, rank, name, category, subcategory, subscription_type, SELECT id, rank, name, category, subcategory, subscription_type,
website, starting_monthly_usd, starting_annual_usd website, starting_monthly_usd, starting_annual_usd
FROM subscription_catalog FROM subscription_catalog
ORDER BY rank ASC 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 // User's subscription bills that are linked to a catalog entry
const matchedBills = db const matchedBills = db
.prepare( .prepare(
` `
SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id, SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id,
b.cycle_type, b.billing_cycle b.cycle_type, b.billing_cycle
FROM bills b FROM bills b
@ -254,56 +219,49 @@ router.get('/catalog', (req: Req, res: Res) => {
AND b.deleted_at IS NULL AND b.deleted_at IS NULL
AND b.catalog_id IS NOT 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); .all(req.user.id);
} catch {
const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b])); /* pre-v0.96 */
// 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'));
} }
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) // 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 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) {
return res.status(400).json(standardizeError('Invalid bill ID', 'VALIDATION_ERROR')); throw ValidationError('Invalid bill ID');
} }
const bill = db 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', 'SELECT id, name, catalog_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');
const rawCatalogId = req.body?.catalog_id; const rawCatalogId = req.body?.catalog_id;
@ -338,23 +296,12 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
const catalogId = parseInt(rawCatalogId, 10); const catalogId = parseInt(rawCatalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) { if (!Number.isInteger(catalogId) || catalogId < 1) {
return res throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id');
.status(400)
.json(
standardizeError(
'catalog_id must be a positive integer or null',
'VALIDATION_ERROR',
'catalog_id',
),
);
} }
const catalogEntry = db const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?') .prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId); .get(catalogId);
if (!catalogEntry) if (!catalogEntry) throw NotFoundError('Catalog entry not found', 'catalog_id');
return res
.status(404)
.json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id'));
db.prepare( db.prepare(
"UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "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 db = getDb();
const catalogId = parseInt(req.params.catalogId, 10); const catalogId = parseInt(req.params.catalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) { 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 const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?') .prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId); .get(catalogId);
if (!catalogEntry) if (!catalogEntry) throw NotFoundError('Catalog entry not found');
return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND'));
const descriptor = String(req.body?.descriptor ?? '').trim(); const descriptor = String(req.body?.descriptor ?? '').trim();
if (!descriptor) { if (!descriptor) {
return res throw ValidationError('descriptor is required', 'descriptor');
.status(400)
.json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor'));
} }
if (descriptor.length > 100) { if (descriptor.length > 100) {
return res throw ValidationError('descriptor must be 100 characters or less', 'descriptor');
.status(400)
.json(
standardizeError(
'descriptor must be 100 characters or less',
'VALIDATION_ERROR',
'descriptor',
),
);
} }
try { // Check for case-insensitive duplicate
// Check for case-insensitive duplicate const exists = db
const exists = db .prepare(
.prepare( 'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)',
'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)', )
) .get(req.user.id, catalogId, descriptor);
.get(req.user.id, catalogId, descriptor); if (exists) {
if (exists) { throw ConflictError(
return res 'Descriptor already exists for this service',
.status(409) 'descriptor',
.json( 'DUPLICATE_ERROR',
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'));
} }
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 // Delete a user-added catalog descriptor
@ -445,53 +371,42 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const descriptorId = parseInt(req.params.id, 10); const descriptorId = parseInt(req.params.id, 10);
if (!Number.isInteger(descriptorId) || descriptorId < 1) { 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
const existing = db .prepare(
.prepare( 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?', )
) .get(descriptorId, req.user.id);
.get(descriptorId, req.user.id); const result = db
const result = db .prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?') .run(descriptorId, req.user.id);
.run(descriptorId, req.user.id); if (result.changes === 0) {
if (result.changes === 0) { throw NotFoundError('Descriptor not found');
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'),
);
} }
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) => { router.patch('/:id', (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 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(billId, req.user.id); .get(billId, 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'));
const allowedTypes = new Set([ const allowedTypes = new Set([
'streaming', 'streaming',
@ -530,15 +445,7 @@ router.patch('/:id', (req: Req, res: Res) => {
next.reminder_days_before < 0 || next.reminder_days_before < 0 ||
next.reminder_days_before > 30 next.reminder_days_before > 30
) { ) {
return res throw ValidationError('reminder_days_before must be between 0 and 30', 'reminder_days_before');
.status(400)
.json(
standardizeError(
'reminder_days_before must be between 0 and 30',
'VALIDATION_ERROR',
'reminder_days_before',
),
);
} }
db.prepare( db.prepare(

View File

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

View File

@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${pro
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 { ensureManualDataSource } = require('../services/transactionService.cts'); const { ensureManualDataSource } = require('../services/transactionService.cts');
const { getTracker } = require('../services/trackerService.cts'); const { getTracker } = require('../services/trackerService.cts');
const { const {
@ -131,7 +132,13 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
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);
}
} }
}); });
} }
@ -164,7 +171,13 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {},
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);
}
} }
}); });
} }