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).
|
// @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,15 +32,11 @@ 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)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'Transaction-linked payments must be changed through transaction match controls',
|
'Transaction-linked payments must be changed through transaction match controls',
|
||||||
'TRANSACTION_PAYMENT_LOCKED',
|
|
||||||
'transaction_id',
|
'transaction_id',
|
||||||
),
|
'TRANSACTION_PAYMENT_LOCKED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,22 +44,10 @@ 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,20 +188,18 @@ 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) {
|
||||||
|
|
@ -267,10 +207,7 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
|
||||||
.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(
|
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
|
||||||
0,
|
|
||||||
Number(bill.current_balance) - Number(payment.balance_delta),
|
|
||||||
);
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE bills
|
UPDATE bills
|
||||||
|
|
@ -287,10 +224,6 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
|
||||||
markUnmatched(db, req.user.id, payment.transaction_id);
|
markUnmatched(db, req.user.id, payment.transaction_id);
|
||||||
})();
|
})();
|
||||||
res.json({ success: true });
|
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'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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,26 +804,19 @@ 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(
|
||||||
`
|
`
|
||||||
|
|
@ -953,17 +827,14 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
|
||||||
)
|
)
|
||||||
.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)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'Only bank-synced payments can be reclassified to a different month',
|
'Only bank-synced payments can be reclassified to a different month',
|
||||||
|
undefined,
|
||||||
'RECLASSIFY_ONLY_SYNC',
|
'RECLASSIFY_ONLY_SYNC',
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -972,21 +843,17 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
|
||||||
const origYM = orig.getFullYear() * 12 + orig.getMonth();
|
const origYM = orig.getFullYear() * 12 + orig.getMonth();
|
||||||
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
|
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
|
||||||
if (newYM !== origYM - 1) {
|
if (newYM !== origYM - 1) {
|
||||||
return res
|
throw ValidationError(
|
||||||
.status(400)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'The new paid_date must be in the month immediately before the original payment date',
|
'The new paid_date must be in the month immediately before the original payment date',
|
||||||
'VALIDATION_ERROR',
|
|
||||||
'paid_date',
|
'paid_date',
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare(
|
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||||
"UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?",
|
paid_date,
|
||||||
).run(paid_date, paymentId);
|
paymentId,
|
||||||
|
);
|
||||||
|
|
||||||
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')
|
||||||
|
|
@ -1003,10 +870,6 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
|
||||||
.get(paymentId, req.user.id),
|
.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'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -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,30 +41,16 @@ 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();
|
const db = getDb();
|
||||||
declineRecommendation(db, req.user.id, decline_key);
|
declineRecommendation(db, req.user.id, decline_key);
|
||||||
recordSubscriptionFeedback(db, req.user.id, {
|
recordSubscriptionFeedback(db, req.user.id, {
|
||||||
|
|
@ -70,11 +61,6 @@ router.post('/recommendations/decline', (req: Req, res: Res) => {
|
||||||
metadata: { decline_key },
|
metadata: { decline_key },
|
||||||
});
|
});
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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,14 +179,13 @@ 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.message || 'Could not create subscription',
|
|
||||||
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
|
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
|
||||||
err.field || null,
|
err.message || 'Could not create subscription',
|
||||||
),
|
err.status || 400,
|
||||||
|
{ field: err.field || null },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -228,7 +194,6 @@ 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(
|
||||||
`
|
`
|
||||||
|
|
@ -263,9 +228,7 @@ router.get('/catalog', (req: Req, res: Res) => {
|
||||||
let userDescriptors = [];
|
let userDescriptors = [];
|
||||||
try {
|
try {
|
||||||
userDescriptors = db
|
userDescriptors = db
|
||||||
.prepare(
|
.prepare('SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?')
|
||||||
'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?',
|
|
||||||
)
|
|
||||||
.all(req.user.id);
|
.all(req.user.id);
|
||||||
} catch {
|
} catch {
|
||||||
/* pre-v0.96 */
|
/* pre-v0.96 */
|
||||||
|
|
@ -299,11 +262,6 @@ router.get('/catalog', (req: Req, res: Res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ catalog });
|
res.json({ catalog });
|
||||||
} catch (err) {
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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,34 +321,22 @@ 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(
|
||||||
|
|
@ -409,14 +344,10 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
|
||||||
)
|
)
|
||||||
.get(req.user.id, catalogId, descriptor);
|
.get(req.user.id, catalogId, descriptor);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return res
|
throw ConflictError(
|
||||||
.status(409)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'Descriptor already exists for this service',
|
'Descriptor already exists for this service',
|
||||||
'DUPLICATE_ERROR',
|
|
||||||
'descriptor',
|
'descriptor',
|
||||||
),
|
'DUPLICATE_ERROR',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,11 +364,6 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
|
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'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a user-added catalog descriptor
|
// Delete a user-added catalog descriptor
|
||||||
|
|
@ -445,10 +371,9 @@ 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 = ?',
|
||||||
|
|
@ -458,7 +383,7 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
|
||||||
.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) {
|
||||||
return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND'));
|
throw NotFoundError('Descriptor not found');
|
||||||
}
|
}
|
||||||
if (existing) {
|
if (existing) {
|
||||||
recordSubscriptionFeedback(db, req.user.id, {
|
recordSubscriptionFeedback(db, req.user.id, {
|
||||||
|
|
@ -469,29 +394,19 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json(
|
|
||||||
standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// 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);
|
h(req, res);
|
||||||
|
} catch (err) {
|
||||||
|
resolve({ status: err.status || 500, data: formatError(err) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function balanceOf(billId) {
|
function balanceOf(billId) {
|
||||||
|
|
|
||||||
|
|
@ -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,8 +132,14 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
|
||||||
try {
|
try {
|
||||||
handler(req, res);
|
handler(req, res);
|
||||||
} catch (err) {
|
} catch (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);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,8 +171,14 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {},
|
||||||
try {
|
try {
|
||||||
handler(req, res);
|
handler(req, res);
|
||||||
} catch (err) {
|
} catch (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);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue