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:
parent
c4219b6020
commit
543e94288e
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue