refactor(routes): throw-pattern for profile, transactions, admin, aboutAdmin

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 17:18:28 -05:00
parent d0ab375f9a
commit c481a5bdbf
4 changed files with 188 additions and 193 deletions

View File

@ -5,6 +5,7 @@ const { log } = require('../utils/logger.cts');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
const { ValidationError } = require('../utils/apiError.cts');
const router = express.Router(); 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) => { router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
const { enabled } = req.body || {}; const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') { 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'); setSetting('update_check_enabled', enabled ? 'true' : 'false');
res.json({ enabled }); res.json({ enabled });

View File

@ -4,6 +4,12 @@ const express = require('express');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database.cts'); const { getDb, rollbackMigration } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { const {
getBankSyncConfig, getBankSyncConfig,
setBankSyncEnabled, setBankSyncEnabled,
@ -43,6 +49,9 @@ function parseUserId(params) {
return Number.isInteger(n) && n > 0 ? n : null; 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) { function sendError(res, err) {
const status = err.status || 500; const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message }); res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
@ -170,15 +179,14 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
router.post('/users', async (req: Req, res: Res) => { router.post('/users', async (req: Req, res: Res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || username.length < 3) 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) 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 db = getDb();
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) 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 hash = await hashPassword(password);
const result = db const result = db
.prepare( .prepare(
@ -203,26 +211,21 @@ router.post('/users', async (req: Req, res: Res) => {
}); });
res.status(201).json(created); res.status(201).json(created);
} catch (err) {
log.error('[admin] create-user error:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
}); });
// PUT /api/admin/users/:id/password // PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req: Req, res: Res) => { router.put('/users/:id/password', async (req: Req, res: Res) => {
const targetId = parseUserId(req.params); 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; const { password } = req.body;
if (!password || password.length < 8) 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 db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); 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); const hash = await hashPassword(password);
db.prepare( db.prepare(
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
@ -240,10 +243,6 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[admin] reset-password error:', err.message);
res.status(500).json({ error: 'Failed to reset password' });
}
}); });
// PUT /api/admin/users/:id/role // 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. // changing your own role mid-session.
router.put('/users/:id/role', (req: Req, res: Res) => { router.put('/users/:id/role', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); 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; const { role } = req.body;
if (!['admin', 'user'].includes(role)) { 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 db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); 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) { 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') { if (user.role === 'admin' && role === 'user') {
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n; const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
if (adminCount <= 1) { 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 // PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req: Req, res: Res) => { router.put('/users/:id/active', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); 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 active = req.body?.active ? 1 : 0;
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); 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) { 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( 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) => { router.put('/users/:id/username', (req: Req, res: Res) => {
const { username } = req.body; const { username } = req.body;
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'username is required' }); throw ValidationError('username is required');
} }
const trimmed = username.trim(); const trimmed = username.trim();
if (trimmed.length < 3) { 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) { 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); 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 db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId); 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 const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmed, targetId); .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; const previousUsername = user.username;
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( 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 // DELETE /api/admin/users/:id
router.delete('/users/:id', (req: Req, res: Res) => { router.delete('/users/:id', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); 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 db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); 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 === user.id) if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.');
return res.status(400).json({ error: 'You cannot delete your own account.' });
const deleteUser = db.transaction(() => { const deleteUser = db.transaction(() => {
// These three tables have no FK/CASCADE to users — must delete explicitly. // 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 // PUT /api/admin/bank-sync-config
router.put('/bank-sync-config', (req: Req, res: Res) => { router.put('/bank-sync-config', (req: Req, res: Res) => {
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {}; const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
try {
let config = getBankSyncConfig(); let config = getBankSyncConfig();
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
if (sync_days !== undefined) config = setSyncDays(sync_days); if (sync_days !== undefined) config = setSyncDays(sync_days);
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging); if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
res.json(config); 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' });
}
}); });
// ── Migration Rollback ──────────────────────────────────────────────────────── // ── Migration Rollback ────────────────────────────────────────────────────────
router.post('/migrations/rollback', async (req: Req, res: Res) => { router.post('/migrations/rollback', async (req: Req, res: Res) => {
const { version } = req.body; const { version } = req.body;
if (!version) { if (!version) {
return res.status(400).json({ error: 'Version is required' }); throw ValidationError('Version is required');
} }
try { try {
@ -549,15 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
if (err.code === 'NOT_APPLIED') { // Coded rollback errors are intentional user-facing messages; anything
return res.status(404).json({ error: err.message }); // 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 === 'ROLLBACK_NOT_SUPPORTED') { if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message);
return res.status(422).json({ error: err.message }); if (err.code === 'ROLLBACK_NOT_SUPPORTED')
} throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422);
// Unknown failure: the audit log (above) keeps err.message; the response must not. throw err;
log.error('[admin/migrations/rollback]', err);
res.status(500).json({ error: 'Rollback failed' });
} }
}); });

View File

@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
('use strict'); ('use strict');
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts');
@ -18,7 +18,14 @@ const {
} = require('../services/authService.cts'); } = require('../services/authService.cts');
const { getImportHistory } = require('../services/spreadsheetImportService.cts'); const { getImportHistory } = require('../services/spreadsheetImportService.cts');
const { logAudit } = require('../services/auditService.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'); const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
// All profile routes require authentication — enforced in server.js. // All profile routes require authentication — enforced in server.js.
@ -30,9 +37,7 @@ function dataImportEnabled() {
function requireDataImportEnabled(req, res, next) { function requireDataImportEnabled(req, res, next) {
if (!dataImportEnabled()) { if (!dataImportEnabled()) {
return res throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
.status(403)
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
} }
next(); next();
} }
@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => {
) )
.get(req.user.id); .get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
res.json({ res.json({
...profileResponse(user), ...profileResponse(user),
@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => {
if (username !== undefined) { if (username !== undefined) {
if (typeof username !== 'string') { 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(); const trimmedUsername = username.trim();
if (trimmedUsername.length < 3) { 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) { 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 const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmedUsername, req.user.id); .get(trimmedUsername, req.user.id);
if (taken) { 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( db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
trimmedUsername, trimmedUsername,
@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => {
if (displayNameInput !== undefined) { if (displayNameInput !== undefined) {
if (typeof displayNameInput !== 'string') { 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(); const trimmed = displayNameInput.trim();
if (trimmed.length > 100) { 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( 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); .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) // Decrypt push secrets — return masked indicator for token (never the raw value)
const pushUrlDecrypted = user.push_url const pushUrlDecrypted = user.push_url
@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => {
if (nextEmail !== undefined && nextEmail !== null) { if (nextEmail !== undefined && nextEmail !== null) {
if (typeof nextEmail !== 'string') { 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) { 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,29 +355,30 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
const { current_password, new_password, confirm_new_password } = req.body; const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) { if (!current_password) {
return res.status(400).json({ error: 'current_password is required' }); throw ValidationError('current_password is required', 'current_password');
} }
if (!new_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) { 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) { 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) { 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 db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); 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); const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) { if (!valid) {
return res.status(401).json({ error: 'current password is incorrect' }); 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);
@ -407,10 +413,6 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[profile] change-password error:', err.message);
res.status(500).json({ error: 'Password change failed' });
}
}); });
// ── GET /api/profile/exports ────────────────────────────────────────────────── // ── GET /api/profile/exports ──────────────────────────────────────────────────
@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => {
// Returns the signed-in user's import history. // Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history. // Delegates to the same service as GET /api/import/history.
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => { router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
try {
const history = getImportHistory(req.user.id); const history = getImportHistory(req.user.id);
res.json({ history }); res.json({ history });
} catch (err) {
log.error('[profile] import-history error:', err.message);
res.status(500).json({ error: 'Failed to load import history' });
}
}); });
module.exports = router; module.exports = router;

View File

@ -4,6 +4,16 @@ const router = require('express').Router();
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
// Boundary adapter: the internal parse/normalize helpers return
// { error: <standardized body> } 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 { const {
decorateTransaction, decorateTransaction,
ensureManualDataSource, ensureManualDataSource,
@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) {
); );
} }
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') { // 4xx service errors keep their message/code on the wire; anything else is
if (err.status) { // rethrown raw so the terminal handler masks + logs it as a 5xx.
return res function toTransactionServiceError(err) {
.status(err.status) if (err.status && err.status < 500) {
.json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field)); return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
field: err.field,
});
} }
log.error('[transactions] service error:', err.stack || err.message); return err;
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
} }
function emptyBankLedger(enabled, hasConnections = false) { function emptyBankLedger(enabled, hasConnections = false) {
@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => {
ensureManualDataSource(db, req.user.id); ensureManualDataSource(db, req.user.id);
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error); if (page.error) throwStandardized(page.error);
const SORT_COLUMNS = { const SORT_COLUMNS = {
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', 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']) { for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
if (req.query[field] !== undefined) { if (req.query[field] !== undefined) {
const parsed = parseInteger(req.query[field], field); 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} = ?`); where.push(`t.${field} = ?`);
params.push(parsed.value); params.push(parsed.value);
} }
@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => {
if (req.query.start_date) { if (req.query.start_date) {
const parsed = parseDate(req.query.start_date, '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)) >= ?'); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
params.push(parsed.value); params.push(parsed.value);
} }
if (req.query.end_date) { if (req.query.end_date) {
const parsed = parseDate(req.query.end_date, '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)) <= ?'); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
params.push(parsed.value); params.push(parsed.value);
} }
@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => {
router.get('/bank-ledger', (req: Req, res: Res) => { router.get('/bank-ledger', (req: Req, res: Res) => {
const config = getBankSyncConfig(); const config = getBankSyncConfig();
const page = parseLimitOffset(req.query); 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 db = getDb();
const sources = db const sources = db
@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
const filtered = bankLedgerWhere(req.query, 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 = { const SORT_COLUMNS = {
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', 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) => { router.post('/manual', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body); 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); 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 tx = validation.normalized;
const source = ensureManualDataSource(db, req.user.id); const source = ensureManualDataSource(db, req.user.id);
const state = resolveTransactionState(tx); const state = resolveTransactionState(tx);
if (state.error) return res.status(400).json(state.error); if (state.error) throwStandardized(state.error);
Object.assign(tx, state); Object.assign(tx, state);
const result = db const result = db
@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => {
router.put('/:id', (req: Req, res: Res) => { router.put('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body); const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState); if (directMatchState) throwStandardized(directMatchState);
const id = parseInteger(req.params.id, 'id'); 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); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true }); 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 tx = validation.normalized;
const state = resolveTransactionState(tx, existing); 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); Object.assign(tx, state);
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date; 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) => { router.delete('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); 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); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
db.transaction(() => { db.transaction(() => {
unmatchTransaction(req.user.id, id.value); unmatchTransaction(req.user.id, id.value);
@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
); );
res.json(result); res.json(result);
} catch (err) { } 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); const result = unmatchTransaction(req.user.id, req.params.id);
res.json(result); res.json(result);
} catch (err) { } 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) => { router.post('/unmatch-bulk', (req: Req, res: Res) => {
const matches = req.body?.matches; const matches = req.body?.matches;
if (!Array.isArray(matches) || matches.length === 0) { 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) { if (matches.length > 50) {
return res return res
@ -1006,6 +1015,9 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
const failed = results.filter((r) => !r.ok); const failed = results.filter((r) => !r.ok);
if (failed.length > 0 && failed.length === results.length) { 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 }); return res.status(500).json({ error: 'All unmatches failed', results });
} }
res.json({ results, unmatched: results.filter((r) => r.ok).length }); 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); const result = ignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction); res.json(result.transaction);
} catch (err) { } 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); const result = unignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction); res.json(result.transaction);
} catch (err) { } 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 { try {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); 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); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const match = findMerchantMatch( const match = findMerchantMatch(
db, db,
@ -1048,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
); );
res.json({ match }); res.json({ match });
} catch (err) { } 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 { try {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); 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); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const match = findMerchantMatch( const match = findMerchantMatch(
db, db,
@ -1078,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
display_name: match.display_name, display_name: match.display_name,
}); });
} catch (err) { } 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); res.json(result);
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions'); throw toTransactionServiceError(err);
} }
}); });