From c481a5bdbf108f820d654c5006b2cf61d2a9a88a Mon Sep 17 00:00:00 2001 From: null Date: Fri, 10 Jul 2026 17:18:28 -0500 Subject: [PATCH] refactor(routes): throw-pattern for profile, transactions, admin, aboutAdmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards-unification batch 4b (completes the route conversion program — all 29 route files now emit errors through the canonical path or a documented exception): - profile.cts: full conversion; try/catch-500 wrappers removed - transactions.cts: boundary adapter throwStandardized() rethrows the internal {error} parse/normalize results as ApiError (internal result convention unchanged); sendTransactionServiceError -> throwing toTransactionServiceError (4xx keep message/code, 5xx masked) - admin.cts: user-management + bank-sync-config + rollback converted; coded rollback errors (NOT_APPLIED 404 / ROLLBACK_NOT_SUPPORTED 422) preserved as intentional messages - aboutAdmin.cts: loose 400 converted; static no-path-disclosure 500s kept Documented exceptions (intentional, commented inline): admin sendError (backup download can fail mid-stream after headers sent), import.cts sendImportError pipeline (error-id envelope), transactions bulk-unmatch 500 (carries per-item results), payments DUPLICATE_SUSPECTED 409 (carries existing payment), calendarFeed text/plain 404 (ICS clients), authOidc static 501/502 (rewritten wholesale in the openid-client v6 batch). Server suite 252/252. Co-Authored-By: Claude Fable 5 --- routes/aboutAdmin.cts | 3 +- routes/admin.cts | 177 +++++++++++++++++++--------------------- routes/profile.cts | 113 +++++++++++++------------ routes/transactions.cts | 88 +++++++++++--------- 4 files changed, 188 insertions(+), 193 deletions(-) diff --git a/routes/aboutAdmin.cts b/routes/aboutAdmin.cts index f503f46..33cbf48 100644 --- a/routes/aboutAdmin.cts +++ b/routes/aboutAdmin.cts @@ -5,6 +5,7 @@ const { log } = require('../utils/logger.cts'); const fs = require('fs'); const path = require('path'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts'); +const { ValidationError } = require('../utils/apiError.cts'); const router = express.Router(); @@ -594,7 +595,7 @@ router.get('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: R router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => { const { enabled } = req.body || {}; if (typeof enabled !== 'boolean') { - return res.status(400).json({ error: 'enabled must be a boolean' }); + throw ValidationError('enabled must be a boolean', 'enabled'); } setSetting('update_check_enabled', enabled ? 'true' : 'false'); res.json({ enabled }); diff --git a/routes/admin.cts b/routes/admin.cts index 12e394b..26a5c4d 100644 --- a/routes/admin.cts +++ b/routes/admin.cts @@ -4,6 +4,12 @@ const express = require('express'); const { log } = require('../utils/logger.cts'); const router = express.Router(); const { getDb, rollbackMigration } = require('../db/database.cts'); +const { + ApiError, + ValidationError, + NotFoundError, + ConflictError, +} = require('../utils/apiError.cts'); const { getBankSyncConfig, setBankSyncEnabled, @@ -43,6 +49,9 @@ function parseUserId(params) { return Number.isInteger(n) && n > 0 ? n : null; } +// Backup-ops error path. Kept res-based (not throw-pattern) deliberately: the +// download route can fail mid-stream after headers are sent, where a throw +// can't produce a response. Masks 500s; 4xx service messages pass through. function sendError(res, err) { const status = err.status || 500; res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message }); @@ -170,80 +179,70 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => { router.post('/users', async (req: Req, res: Res) => { const { username, password } = req.body; if (!username || username.length < 3) - return res.status(400).json({ error: 'Username must be at least 3 characters' }); + throw ValidationError('Username must be at least 3 characters'); if (!password || password.length < 8) - return res.status(400).json({ error: 'Password must be at least 8 characters' }); + throw ValidationError('Password must be at least 8 characters'); const db = getDb(); if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) - return res.status(409).json({ error: 'Username already taken' }); + throw ConflictError('Username already taken', 'username'); - try { - const hash = await hashPassword(password); - const result = db - .prepare( - "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)", - ) - .run(username, hash); + const hash = await hashPassword(password); + const result = db + .prepare( + "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)", + ) + .run(username, hash); - const created = db - .prepare( - 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?', - ) - .get(result.lastInsertRowid); + const created = db + .prepare( + 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?', + ) + .get(result.lastInsertRowid); - logAudit({ - user_id: req.user.id, - action: 'admin.user.create', - entity_type: 'user', - entity_id: created.id, - details: { created_username: username }, - ip_address: req.ip, - user_agent: req.get('user-agent'), - }); + logAudit({ + user_id: req.user.id, + action: 'admin.user.create', + entity_type: 'user', + entity_id: created.id, + details: { created_username: username }, + ip_address: req.ip, + user_agent: req.get('user-agent'), + }); - res.status(201).json(created); - } catch (err) { - log.error('[admin] create-user error:', err.message); - res.status(500).json({ error: 'Failed to create user' }); - } + res.status(201).json(created); }); // PUT /api/admin/users/:id/password router.put('/users/:id/password', async (req: Req, res: Res) => { const targetId = parseUserId(req.params); - if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); + if (!targetId) throw ValidationError('Invalid user ID'); const { password } = req.body; if (!password || password.length < 8) - return res.status(400).json({ error: 'Password must be at least 8 characters' }); + throw ValidationError('Password must be at least 8 characters'); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); - try { - const hash = await hashPassword(password); - db.prepare( - "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", - ).run(hash, targetId); - db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); + const hash = await hashPassword(password); + db.prepare( + "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", + ).run(hash, targetId); + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); - logAudit({ - user_id: req.user.id, - action: 'admin.password.reset', - entity_type: 'user', - entity_id: targetId, - details: { target_username: user.username }, - ip_address: req.ip, - user_agent: req.get('user-agent'), - }); + logAudit({ + user_id: req.user.id, + action: 'admin.password.reset', + entity_type: 'user', + entity_id: targetId, + details: { target_username: user.username }, + ip_address: req.ip, + user_agent: req.get('user-agent'), + }); - res.json({ success: true }); - } catch (err) { - log.error('[admin] reset-password error:', err.message); - res.status(500).json({ error: 'Failed to reset password' }); - } + res.json({ success: true }); }); // PUT /api/admin/users/:id/role @@ -251,24 +250,24 @@ router.put('/users/:id/password', async (req: Req, res: Res) => { // changing your own role mid-session. router.put('/users/:id/role', (req: Req, res: Res) => { const targetId = parseUserId(req.params); - if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); + if (!targetId) throw ValidationError('Invalid user ID'); const { role } = req.body; if (!['admin', 'user'].includes(role)) { - return res.status(400).json({ error: 'role must be "admin" or "user"' }); + throw ValidationError('role must be "admin" or "user"'); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); if (req.user?.id === targetId) { - return res.status(400).json({ error: 'You cannot change your own admin role.' }); + throw ValidationError('You cannot change your own admin role.'); } if (user.role === 'admin' && role === 'user') { const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n; if (adminCount <= 1) { - return res.status(400).json({ error: 'Cannot remove the last admin account.' }); + throw ValidationError('Cannot remove the last admin account.'); } } @@ -305,14 +304,14 @@ router.put('/users/:id/role', (req: Req, res: Res) => { // PUT /api/admin/users/:id/active router.put('/users/:id/active', (req: Req, res: Res) => { const targetId = parseUserId(req.params); - if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); + if (!targetId) throw ValidationError('Invalid user ID'); const active = req.body?.active ? 1 : 0; const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); if (req.user?.id === targetId) { - return res.status(400).json({ error: 'You cannot deactivate your own account.' }); + throw ValidationError('You cannot deactivate your own account.'); } db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run( @@ -334,27 +333,27 @@ router.put('/users/:id/active', (req: Req, res: Res) => { router.put('/users/:id/username', (req: Req, res: Res) => { const { username } = req.body; if (!username || typeof username !== 'string') { - return res.status(400).json({ error: 'username is required' }); + throw ValidationError('username is required'); } const trimmed = username.trim(); if (trimmed.length < 3) { - return res.status(400).json({ error: 'Username must be at least 3 characters' }); + throw ValidationError('Username must be at least 3 characters'); } if (trimmed.length > 50) { - return res.status(400).json({ error: 'Username must be 50 characters or fewer' }); + throw ValidationError('Username must be 50 characters or fewer'); } const targetId = parseUserId(req.params); - if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); + if (!targetId) throw ValidationError('Invalid user ID'); const db = getDb(); const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); const taken = db .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .get(trimmed, targetId); - if (taken) return res.status(409).json({ error: 'Username already taken' }); + if (taken) throw ConflictError('Username already taken', 'username'); const previousUsername = user.username; db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( @@ -384,13 +383,12 @@ router.put('/users/:id/username', (req: Req, res: Res) => { // DELETE /api/admin/users/:id router.delete('/users/:id', (req: Req, res: Res) => { const targetId = parseUserId(req.params); - if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); + if (!targetId) throw ValidationError('Invalid user ID'); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); - if (!user) return res.status(404).json({ error: 'User not found' }); - if (req.user?.id === user.id) - return res.status(400).json({ error: 'You cannot delete your own account.' }); + if (!user) throw NotFoundError('User not found'); + if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.'); const deleteUser = db.transaction(() => { // These three tables have no FK/CASCADE to users — must delete explicitly. @@ -502,28 +500,19 @@ router.get('/bank-sync-config', (req: Req, res: Res) => { // PUT /api/admin/bank-sync-config router.put('/bank-sync-config', (req: Req, res: Res) => { const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {}; - try { - let config = getBankSyncConfig(); - if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); - if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); - if (sync_days !== undefined) config = setSyncDays(sync_days); - if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging); - res.json(config); - } catch (err) { - // 4xx setter validation messages are user-facing; 5xx internals are not. - if (err.status && err.status < 500) { - return res.status(err.status).json({ error: err.message }); - } - log.error('[admin/bank-sync-config]', err); - res.status(500).json({ error: 'Failed to update bank sync config' }); - } + let config = getBankSyncConfig(); + if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); + if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); + if (sync_days !== undefined) config = setSyncDays(sync_days); + if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging); + res.json(config); }); // ── Migration Rollback ──────────────────────────────────────────────────────── router.post('/migrations/rollback', async (req: Req, res: Res) => { const { version } = req.body; if (!version) { - return res.status(400).json({ error: 'Version is required' }); + throw ValidationError('Version is required'); } try { @@ -549,15 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => { user_agent: req.get('user-agent'), }); - if (err.code === 'NOT_APPLIED') { - return res.status(404).json({ error: err.message }); - } - if (err.code === 'ROLLBACK_NOT_SUPPORTED') { - return res.status(422).json({ error: err.message }); - } - // Unknown failure: the audit log (above) keeps err.message; the response must not. - log.error('[admin/migrations/rollback]', err); - res.status(500).json({ error: 'Rollback failed' }); + // Coded rollback errors are intentional user-facing messages; anything + // else propagates raw and the terminal handler masks + logs it as a 5xx + // (the audit log above keeps err.message either way). + if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message); + if (err.code === 'ROLLBACK_NOT_SUPPORTED') + throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422); + throw err; } }); diff --git a/routes/profile.cts b/routes/profile.cts index aca4ab0..eb68242 100644 --- a/routes/profile.cts +++ b/routes/profile.cts @@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http'; ('use strict'); const express = require('express'); -const { log } = require('../utils/logger.cts'); + const router = express.Router(); const bcrypt = require('bcryptjs'); const { passwordLimiter } = require('../middleware/rateLimiter.cts'); @@ -18,7 +18,14 @@ const { } = require('../services/authService.cts'); const { getImportHistory } = require('../services/spreadsheetImportService.cts'); const { logAudit } = require('../services/auditService.cts'); -const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { + ApiError, + ValidationError, + AuthError, + ForbiddenError, + NotFoundError, + ConflictError, +} = require('../utils/apiError.cts'); const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts'); // All profile routes require authentication — enforced in server.js. @@ -30,9 +37,7 @@ function dataImportEnabled() { function requireDataImportEnabled(req, res, next) { if (!dataImportEnabled()) { - return res - .status(403) - .json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN')); + throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false'); } next(); } @@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => { ) .get(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); res.json({ ...profileResponse(user), @@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => { if (username !== undefined) { if (typeof username !== 'string') { - return res.status(400).json({ error: 'username must be a string' }); + throw ValidationError('username must be a string', 'username'); } const trimmedUsername = username.trim(); if (trimmedUsername.length < 3) { - return res.status(400).json({ error: 'username must be at least 3 characters' }); + throw ValidationError('username must be at least 3 characters', 'username'); } if (trimmedUsername.length > 50) { - return res.status(400).json({ error: 'username must be 50 characters or fewer' }); + throw ValidationError('username must be 50 characters or fewer', 'username'); } const taken = db .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .get(trimmedUsername, req.user.id); if (taken) { - return res.status(409).json({ error: 'Username already taken' }); + throw ConflictError('Username already taken', 'username'); } db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( trimmedUsername, @@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => { if (displayNameInput !== undefined) { if (typeof displayNameInput !== 'string') { - return res.status(400).json({ error: 'display_name must be a string' }); + throw ValidationError('display_name must be a string', 'display_name'); } const trimmed = displayNameInput.trim(); if (trimmed.length > 100) { - return res.status(400).json({ error: 'display_name must be 100 characters or fewer' }); + throw ValidationError('display_name must be 100 characters or fewer', 'display_name'); } db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run( @@ -188,7 +193,7 @@ router.get('/settings', (req: Req, res: Res) => { ) .get(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); // Decrypt push secrets — return masked indicator for token (never the raw value) const pushUrlDecrypted = user.push_url @@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => { if (nextEmail !== undefined && nextEmail !== null) { if (typeof nextEmail !== 'string') { - return res.status(400).json({ error: 'notification_email must be a string' }); + throw ValidationError('notification_email must be a string', 'notification_email'); } if (nextEmail.trim().length > 255) { - return res.status(400).json({ error: 'notification_email is too long' }); + throw ValidationError('notification_email is too long', 'notification_email'); } } @@ -350,67 +355,64 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => { const { current_password, new_password, confirm_new_password } = req.body; if (!current_password) { - return res.status(400).json({ error: 'current_password is required' }); + throw ValidationError('current_password is required', 'current_password'); } if (!new_password) { - return res.status(400).json({ error: 'new_password is required' }); + throw ValidationError('new_password is required', 'new_password'); } if (!confirm_new_password) { - return res.status(400).json({ error: 'confirm_new_password is required' }); + throw ValidationError('confirm_new_password is required', 'confirm_new_password'); } if (new_password !== confirm_new_password) { - return res.status(400).json({ error: 'new passwords do not match' }); + throw ValidationError('new passwords do not match', 'confirm_new_password'); } if (new_password.length < 8) { - return res.status(400).json({ error: 'new password must be at least 8 characters' }); + throw ValidationError('new password must be at least 8 characters', 'new_password'); } const db = getDb(); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) throw NotFoundError('User not found'); - try { - const valid = await bcrypt.compare(current_password, user.password_hash); - if (!valid) { - return res.status(401).json({ error: 'current password is incorrect' }); - } + const valid = await bcrypt.compare(current_password, user.password_hash); + if (!valid) { + throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, { + field: 'current_password', + }); + } - const hash = await hashPassword(new_password); + const hash = await hashPassword(new_password); - db.prepare( - ` + db.prepare( + ` UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ? `, - ).run(hash, req.user.id); + ).run(hash, req.user.id); - // Invalidate all other sessions for this user - const currentSessionId = req.cookies?.[COOKIE_NAME]; - if (currentSessionId) { - invalidateOtherSessions(req.user.id, currentSessionId); + // Invalidate all other sessions for this user + const currentSessionId = req.cookies?.[COOKIE_NAME]; + if (currentSessionId) { + invalidateOtherSessions(req.user.id, currentSessionId); - // Rotate the current session ID for security - const newSessionId = rotateSessionId(currentSessionId, req.user.id); - if (newSessionId) { - res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req)); - } + // Rotate the current session ID for security + const newSessionId = rotateSessionId(currentSessionId, req.user.id); + if (newSessionId) { + res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req)); } - - logAudit({ - user_id: req.user.id, - action: 'password.change', - ip_address: req.ip, - user_agent: req.get('user-agent'), - }); - - res.json({ success: true }); - } catch (err) { - log.error('[profile] change-password error:', err.message); - res.status(500).json({ error: 'Password change failed' }); } + + logAudit({ + user_id: req.user.id, + action: 'password.change', + ip_address: req.ip, + user_agent: req.get('user-agent'), + }); + + res.json({ success: true }); }); // ── GET /api/profile/exports ────────────────────────────────────────────────── @@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => { // Returns the signed-in user's import history. // Delegates to the same service as GET /api/import/history. router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => { - try { - const history = getImportHistory(req.user.id); - res.json({ history }); - } catch (err) { - log.error('[profile] import-history error:', err.message); - res.status(500).json({ error: 'Failed to load import history' }); - } + const history = getImportHistory(req.user.id); + res.json({ history }); }); module.exports = router; diff --git a/routes/transactions.cts b/routes/transactions.cts index 1f5e929..c0e3ca2 100644 --- a/routes/transactions.cts +++ b/routes/transactions.cts @@ -4,6 +4,16 @@ const router = require('express').Router(); const { log } = require('../utils/logger.cts'); const { getDb } = require('../db/database.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts'); + +// Boundary adapter: the internal parse/normalize helpers return +// { error: } result objects; at the HTTP boundary we +// rethrow them as ApiError so the terminal handler emits the same wire shape. +function throwStandardized(errBody, status = 400) { + throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, { + field: errBody.field, + }); +} const { decorateTransaction, ensureManualDataSource, @@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) { ); } -function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') { - if (err.status) { - return res - .status(err.status) - .json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field)); +// 4xx service errors keep their message/code on the wire; anything else is +// rethrown raw so the terminal handler masks + logs it as a 5xx. +function toTransactionServiceError(err) { + if (err.status && err.status < 500) { + return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, { + field: err.field, + }); } - log.error('[transactions] service error:', err.stack || err.message); - return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR')); + return err; } function emptyBankLedger(enabled, hasConnections = false) { @@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => { ensureManualDataSource(db, req.user.id); const page = parseLimitOffset(req.query); - if (page.error) return res.status(400).json(page.error); + if (page.error) throwStandardized(page.error); const SORT_COLUMNS = { date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', @@ -534,7 +545,7 @@ router.get('/', (req: Req, res: Res) => { for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) { if (req.query[field] !== undefined) { const parsed = parseInteger(req.query[field], field); - if (parsed.error) return res.status(400).json(parsed.error); + if (parsed.error) throwStandardized(parsed.error); where.push(`t.${field} = ?`); params.push(parsed.value); } @@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => { if (req.query.start_date) { const parsed = parseDate(req.query.start_date, 'start_date'); - if (parsed.error) return res.status(400).json(parsed.error); + if (parsed.error) throwStandardized(parsed.error); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?'); params.push(parsed.value); } if (req.query.end_date) { const parsed = parseDate(req.query.end_date, 'end_date'); - if (parsed.error) return res.status(400).json(parsed.error); + if (parsed.error) throwStandardized(parsed.error); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?'); params.push(parsed.value); } @@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => { router.get('/bank-ledger', (req: Req, res: Res) => { const config = getBankSyncConfig(); const page = parseLimitOffset(req.query); - if (page.error) return res.status(400).json(page.error); + if (page.error) throwStandardized(page.error); const db = getDb(); const sources = db @@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => { .all(req.user.id); const filtered = bankLedgerWhere(req.query, req.user.id); - if (filtered.error) return res.status(400).json(filtered.error); + if (filtered.error) throwStandardized(filtered.error); const SORT_COLUMNS = { date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', @@ -758,14 +769,14 @@ router.get('/bank-ledger', (req: Req, res: Res) => { router.post('/manual', (req: Req, res: Res) => { const db = getDb(); const directMatchState = rejectDirectMatchState(req.body); - if (directMatchState) return res.status(400).json(directMatchState); + if (directMatchState) throwStandardized(directMatchState); const validation = normalizeTransactionFields(db, req.user.id, req.body); - if (validation.error) return res.status(validation.status || 400).json(validation.error); + if (validation.error) throwStandardized(validation.error, validation.status || 400); const tx = validation.normalized; const source = ensureManualDataSource(db, req.user.id); const state = resolveTransactionState(tx); - if (state.error) return res.status(400).json(state.error); + if (state.error) throwStandardized(state.error); Object.assign(tx, state); const result = db @@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => { router.put('/:id', (req: Req, res: Res) => { const db = getDb(); const directMatchState = rejectDirectMatchState(req.body); - if (directMatchState) return res.status(400).json(directMatchState); + if (directMatchState) throwStandardized(directMatchState); const id = parseInteger(req.params.id, 'id'); - if (id.error) return res.status(400).json(id.error); + if (id.error) throwStandardized(id.error); const existing = getTransactionForUser(db, req.user.id, id.value); - if (!existing) - return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); + if (!existing) throw NotFoundError('Transaction not found', 'id'); const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true }); - if (validation.error) return res.status(validation.status || 400).json(validation.error); + if (validation.error) throwStandardized(validation.error, validation.status || 400); const tx = validation.normalized; const state = resolveTransactionState(tx, existing); - if (state.error) return res.status(400).json(state.error); + if (state.error) throwStandardized(state.error); Object.assign(tx, state); const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date; @@ -880,11 +890,10 @@ router.put('/:id', (req: Req, res: Res) => { router.delete('/:id', (req: Req, res: Res) => { const db = getDb(); const id = parseInteger(req.params.id, 'id'); - if (id.error) return res.status(400).json(id.error); + if (id.error) throwStandardized(id.error); const existing = getTransactionForUser(db, req.user.id, id.value); - if (!existing) - return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); + if (!existing) throw NotFoundError('Transaction not found', 'id'); db.transaction(() => { unmatchTransaction(req.user.id, id.value); @@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => { ); res.json(result); } catch (err) { - return sendTransactionServiceError(res, err, 'Transaction match failed'); + throw toTransactionServiceError(err); } }); @@ -915,7 +924,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => { const result = unmatchTransaction(req.user.id, req.params.id); res.json(result); } catch (err) { - return sendTransactionServiceError(res, err, 'Transaction unmatch failed'); + throw toTransactionServiceError(err); } }); @@ -926,7 +935,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => { router.post('/unmatch-bulk', (req: Req, res: Res) => { const matches = req.body?.matches; if (!Array.isArray(matches) || matches.length === 0) { - return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR')); + throw ValidationError('matches array required'); } if (matches.length > 50) { return res @@ -1006,6 +1015,9 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => { const failed = results.filter((r) => !r.ok); if (failed.length > 0 && failed.length === results.length) { + // Deliberate hand-rolled body: carries the per-item `results` payload the + // client renders; formatError has no extras channel (same as payments' + // DUPLICATE_SUSPECTED exception). return res.status(500).json({ error: 'All unmatches failed', results }); } res.json({ results, unmatched: results.filter((r) => r.ok).length }); @@ -1017,7 +1029,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => { const result = ignoreTransaction(req.user.id, req.params.id); res.json(result.transaction); } catch (err) { - return sendTransactionServiceError(res, err, 'Transaction ignore failed'); + throw toTransactionServiceError(err); } }); @@ -1027,7 +1039,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => { const result = unignoreTransaction(req.user.id, req.params.id); res.json(result.transaction); } catch (err) { - return sendTransactionServiceError(res, err, 'Transaction unignore failed'); + throw toTransactionServiceError(err); } }); @@ -1036,11 +1048,10 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => { try { const db = getDb(); const id = parseInteger(req.params.id, 'id'); - if (id.error) return res.status(400).json(id.error); + if (id.error) throwStandardized(id.error); const existing = getTransactionForUser(db, req.user.id, id.value); - if (!existing) - return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); + if (!existing) throw NotFoundError('Transaction not found', 'id'); const match = findMerchantMatch( db, @@ -1048,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => { ); res.json({ match }); } catch (err) { - return sendTransactionServiceError(res, err, 'Failed to look up merchant match'); + throw toTransactionServiceError(err); } }); @@ -1057,11 +1068,10 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => { try { const db = getDb(); const id = parseInteger(req.params.id, 'id'); - if (id.error) return res.status(400).json(id.error); + if (id.error) throwStandardized(id.error); const existing = getTransactionForUser(db, req.user.id, id.value); - if (!existing) - return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); + if (!existing) throw NotFoundError('Transaction not found', 'id'); const match = findMerchantMatch( db, @@ -1078,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => { display_name: match.display_name, }); } catch (err) { - return sendTransactionServiceError(res, err, 'Failed to apply merchant match'); + throw toTransactionServiceError(err); } }); @@ -1091,7 +1101,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => { }); res.json(result); } catch (err) { - return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions'); + throw toTransactionServiceError(err); } });