From c4219b6020856c55ef0d632751074cfac9b75db3 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 10 Jul 2026 16:44:19 -0500 Subject: [PATCH] refactor(routes): throw-pattern for summary, matches, snowball, dataSources Standards-unification batch 1 (CODE_STANDARDS 'Error responses', exemplar categories.cts): inline res.status().json(standardizeError(...)) bodies and try/catch-500 wrappers replaced with thrown ApiError factories; the terminal handler formats 4xx and masks+logs 5xx. Wire shapes preserved, including client-visible codes (ALREADY_MATCHED, DUPLICATE_MATCH, NOT_MATCHED, NO_DEBTS, INVALID_PLAN_STATE, BANK_SYNC_DISABLED). Also kills three more err.message-on-500 leaks in dataSources.cts (DB_ERROR wrappers) that the QA-B13-02 grep missed; SimpleFIN errors keep their deliberate sanitized pass-through via ApiError wraps (tokens/URLs stripped). snowballPlanRoute.test.js harness now mirrors the terminal handler for thrown errors (same wire shape as production). Server suite 252/252. Co-Authored-By: Claude Fable 5 --- routes/dataSources.cts | 200 ++++++++------------ routes/matches.cts | 81 ++++---- routes/snowball.cts | 315 +++++++++++++------------------- routes/summary.cts | 9 +- tests/snowballPlanRoute.test.js | 9 +- 5 files changed, 246 insertions(+), 368 deletions(-) diff --git a/routes/dataSources.cts b/routes/dataSources.cts index 7bb52b5..b2a46b6 100644 --- a/routes/dataSources.cts +++ b/routes/dataSources.cts @@ -4,7 +4,7 @@ import type { Req, Res, Next } from '../types/http'; const router = require('express').Router(); const { getDb } = require('../db/database.cts'); -const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts'); const { decorateDataSource, ensureManualDataSource, @@ -26,10 +26,19 @@ function cleanFilter(value) { return typeof value === 'string' ? value.trim() : ''; } -function safeError(err, fallback) { +// SimpleFIN/service errors are deliberately surfaced to the user — but only +// after sanitizeErrorMessage strips tokens/URLs. Wrapping in ApiError keeps +// the sanitized message through the terminal handler (raw 5xx would be masked). +function toSourceError(err, fallback, code = 'SIMPLEFIN_ERROR') { const msg = sanitizeErrorMessage(err?.message || fallback); const status = typeof err?.status === 'number' ? err.status : 500; - return { msg, status }; + return new ApiError(err?.code || code, msg, status); +} + +function requireBankSyncEnabled() { + if (!getBankSyncConfig().enabled) { + throw new ApiError('BANK_SYNC_DISABLED', 'Bank sync is not enabled on this server', 503); + } } // ─── GET /api/data-sources/accounts/all ────────────────────────────────────── @@ -37,11 +46,10 @@ function safeError(err, fallback) { // Used by the bank tracking account picker. router.get('/accounts/all', (req: Req, res: Res) => { - try { - const db = getDb(); - const accounts = db - .prepare( - ` + const db = getDb(); + const accounts = db + .prepare( + ` SELECT fa.id, fa.name, fa.org_name, fa.account_type, fa.balance, fa.available_balance, fa.currency, @@ -51,19 +59,16 @@ router.get('/accounts/all', (req: Req, res: Res) => { WHERE fa.user_id = ? ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC `, - ) - .all(req.user.id); + ) + .all(req.user.id); - res.json( - accounts.map((a) => ({ - ...a, - monitored: a.monitored === 1, - balance_dollars: a.balance !== null ? a.balance / 100 : null, - })), - ); - } catch (err) { - res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR')); - } + res.json( + accounts.map((a) => ({ + ...a, + monitored: a.monitored === 1, + balance_dollars: a.balance !== null ? a.balance / 100 : null, + })), + ); }); // ─── GET /api/data-sources ──────────────────────────────────────────────────── @@ -76,21 +81,9 @@ router.get('/', (req: Req, res: Res) => { const status = cleanFilter(req.query.status); if (type && !VALID_TYPES.has(type)) - return res - .status(400) - .json( - standardizeError( - 'type must be manual, file_import, or provider_sync', - 'VALIDATION_ERROR', - 'type', - ), - ); + throw ValidationError('type must be manual, file_import, or provider_sync', 'type'); if (status && !VALID_STATUSES.has(status)) - return res - .status(400) - .json( - standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'), - ); + throw ValidationError('status must be active, inactive, or error', 'status'); let query = ` SELECT @@ -159,17 +152,11 @@ router.get('/simplefin/status', (req: Req, res: Res) => { // ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── router.post('/simplefin/connect', async (req: Req, res: Res) => { - if (!getBankSyncConfig().enabled) { - return res - .status(503) - .json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); - } + requireBankSyncEnabled(); const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : ''; if (!setupToken) { - return res - .status(400) - .json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken')); + throw ValidationError('setupToken is required', 'setupToken'); } try { @@ -177,8 +164,7 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => { const result = await connectSimplefin(db, req.user.id, setupToken); res.status(201).json(result); } catch (err) { - const { msg, status } = safeError(err, 'Failed to connect SimpleFIN'); - res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR')); + throw toSourceError(err, 'Failed to connect SimpleFIN'); } }); @@ -187,23 +173,19 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => { router.get('/:sourceId/accounts', (req: Req, res: Res) => { const sourceId = parseInt(req.params.sourceId, 10); if (!Number.isInteger(sourceId) || sourceId < 1) { - return res - .status(400) - .json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId')); + throw ValidationError('Invalid data source id', 'sourceId'); } - try { - const db = getDb(); + const db = getDb(); - const source = db - .prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?') - .get(sourceId, req.user.id); - if (!source) - return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND')); + const source = db + .prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?') + .get(sourceId, req.user.id); + if (!source) throw NotFoundError('Data source not found'); - const accounts = db - .prepare( - ` + const accounts = db + .prepare( + ` SELECT fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type, fa.balance, fa.available_balance, fa.currency, fa.monitored, @@ -215,10 +197,10 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => { GROUP BY fa.id ORDER BY fa.name COLLATE NOCASE ASC `, - ) - .all(sourceId, req.user.id); + ) + .all(sourceId, req.user.id); - const txStmt = db.prepare(` + const txStmt = db.prepare(` SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency, t.payee, t.description, t.memo, t.match_status, t.ignored, t.matched_bill_id, b.name AS matched_bill_name @@ -229,16 +211,13 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => { LIMIT 50 `); - const result = accounts.map((acc) => ({ - ...acc, - monitored: acc.monitored === 1, - transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [], - })); + const result = accounts.map((acc) => ({ + ...acc, + monitored: acc.monitored === 1, + transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [], + })); - res.json(result); - } catch (err) { - res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR')); - } + res.json(result); }); // ─── PUT /api/data-sources/:sourceId/accounts/:accountId ───────────────────── @@ -252,52 +231,39 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => { !Number.isInteger(accountId) || accountId < 1 ) { - return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); + throw ValidationError('Invalid id'); } if (typeof req.body?.monitored !== 'boolean') { - return res - .status(400) - .json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored')); + throw ValidationError('monitored must be a boolean', 'monitored'); } - try { - const db = getDb(); - const result = db - .prepare( - ` + const db = getDb(); + const result = db + .prepare( + ` UPDATE financial_accounts SET monitored = ?, updated_at = datetime('now') WHERE id = ? AND data_source_id = ? AND user_id = ? `, - ) - .run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); + ) + .run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); - if (result.changes === 0) - return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND')); + if (result.changes === 0) throw NotFoundError('Account not found'); - const account = db - .prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?') - .get(accountId); - res.json({ ...account, monitored: account.monitored === 1 }); - } catch (err) { - res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR')); - } + const account = db + .prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?') + .get(accountId); + res.json({ ...account, monitored: account.monitored === 1 }); }); // ─── POST /api/data-sources/:id/sync ───────────────────────────────────────── router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { - if (!getBankSyncConfig().enabled) { - return res - .status(503) - .json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); - } + requireBankSyncEnabled(); const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id < 1) { - return res - .status(400) - .json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id')); + throw ValidationError('Invalid data source id', 'id'); } try { @@ -305,8 +271,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { const result = await syncDataSource(db, req.user.id, id); res.json(result); } catch (err) { - const { msg, status } = safeError(err, 'Sync failed'); - res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR')); + throw toSourceError(err, 'Sync failed'); } }); @@ -314,11 +279,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { // Syncs every SimpleFIN source for the current user. Returns aggregated stats. router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { - if (!getBankSyncConfig().enabled) { - return res - .status(503) - .json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); - } + requireBankSyncEnabled(); try { const db = getDb(); @@ -329,7 +290,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { .all(req.user.id); if (sources.length === 0) { - return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND')); + throw NotFoundError('No SimpleFIN connections found'); } let accountsUpserted = 0; @@ -364,25 +325,18 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { errors, }); } catch (err) { - const { msg, status } = safeError(err, 'Sync failed'); - res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR')); + throw toSourceError(err, 'Sync failed'); } }); // ─── POST /api/data-sources/:id/backfill ───────────────────────────────────── router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => { - if (!getBankSyncConfig().enabled) { - return res - .status(503) - .json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); - } + requireBankSyncEnabled(); const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id < 1) { - return res - .status(400) - .json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id')); + throw ValidationError('Invalid data source id', 'id'); } try { @@ -390,33 +344,25 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => { const result = await backfillDataSource(db, req.user.id, id); res.json(result); } catch (err) { - const { msg, status } = safeError(err, 'Backfill failed'); - res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR')); + throw toSourceError(err, 'Backfill failed'); } }); // ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── router.delete('/:id', (req: Req, res: Res) => { - if (!getBankSyncConfig().enabled) { - return res - .status(503) - .json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); - } + requireBankSyncEnabled(); const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id < 1) { - return res - .status(400) - .json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id')); + throw ValidationError('Invalid data source id', 'id'); } try { disconnectDataSource(getDb(), req.user.id, id); res.json({ ok: true }); } catch (err) { - const { msg, status } = safeError(err, 'Failed to disconnect'); - res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR')); + throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR'); } }); diff --git a/routes/matches.cts b/routes/matches.cts index c7c4ce3..b92c42a 100644 --- a/routes/matches.cts +++ b/routes/matches.cts @@ -1,9 +1,13 @@ // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). import type { Req, Res, Next } from '../types/http'; const router = require('express').Router(); -const { log } = require('../utils/logger.cts'); -const { standardizeError } = require('../middleware/errorFormatter.cts'); const { getDb } = require('../db/database.cts'); +const { + ApiError, + ValidationError, + NotFoundError, + ConflictError, +} = require('../utils/apiError.cts'); const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy, @@ -17,32 +21,23 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat const { serializePayment } = require('../services/paymentValidation.cts'); const { todayLocal } = require('../utils/dates.mts'); -function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { - if (err.status) { - return res - .status(err.status) - .json(standardizeError(err.message, err.code || 'MATCH_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 toMatchError(err) { + if (err.status && err.status < 500) { + return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field }); } - log.error('[matches] service error:', err.stack || err.message); - return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR')); + return err; } // GET /api/matches/suggestions router.get('/suggestions', (req: Req, res: Res) => { - try { - res.json(listMatchSuggestions(req.user.id, req.query)); - } catch (err) { - return sendMatchError(res, err, 'Match suggestions failed'); - } + res.json(listMatchSuggestions(req.user.id, req.query)); }); // POST /api/matches/:id/reject router.post('/:id/reject', (req: Req, res: Res) => { - try { - res.json(rejectMatchSuggestion(req.user.id, req.params.id)); - } catch (err) { - return sendMatchError(res, err, 'Rejecting match suggestion failed'); - } + res.json(rejectMatchSuggestion(req.user.id, req.params.id)); }); // POST /api/matches/confirm — link a transaction to a bill and record a payment @@ -50,46 +45,36 @@ router.post('/confirm', (req: Req, res: Res) => { const txId = parseInt(req.body?.transaction_id, 10); const billId = parseInt(req.body?.bill_id, 10); if (!Number.isInteger(txId) || !Number.isInteger(billId)) { - return res - .status(400) - .json( - standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'), - ); + throw ValidationError('transaction_id and bill_id are required integers'); } const db = getDb(); const tx = db .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .get(txId, req.user.id); - if (!tx) - return res - .status(404) - .json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id')); + if (!tx) throw NotFoundError('Transaction not found', 'transaction_id'); if (tx.match_status === 'matched') { - return res - .status(409) - .json( - standardizeError( - 'Transaction is already matched to a bill', - 'ALREADY_MATCHED', - 'transaction_id', - ), - ); + throw ConflictError( + 'Transaction is already matched to a bill', + 'transaction_id', + 'ALREADY_MATCHED', + ); } const bill = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(billId, req.user.id); - if (!bill) - return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + if (!bill) throw NotFoundError('Bill not found', 'bill_id'); const existing = db .prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL') .get(txId); if (existing) - return res - .status(409) - .json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); + throw ConflictError( + 'A payment is already linked to this transaction', + undefined, + 'DUPLICATE_MATCH', + ); const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); @@ -134,7 +119,7 @@ router.post('/confirm', (req: Req, res: Res) => { try { db.exec('ROLLBACK'); } catch {} - return sendMatchError(res, err, 'Failed to confirm match'); + throw toMatchError(err); } }); @@ -142,18 +127,16 @@ router.post('/confirm', (req: Req, res: Res) => { router.post('/:transactionId/unmatch', (req: Req, res: Res) => { const txId = parseInt(req.params.transactionId, 10); if (!Number.isInteger(txId)) { - return res - .status(400) - .json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR')); + throw ValidationError('transactionId must be an integer'); } const db = getDb(); const tx = db .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .get(txId, req.user.id); - if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND')); + if (!tx) throw NotFoundError('Transaction not found'); if (tx.match_status !== 'matched') { - return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED')); + throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED'); } try { @@ -183,7 +166,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => { try { db.exec('ROLLBACK'); } catch {} - return sendMatchError(res, err, 'Failed to unmatch transaction'); + throw toMatchError(err); } }); diff --git a/routes/snowball.cts b/routes/snowball.cts index d477c6b..2704a74 100644 --- a/routes/snowball.cts +++ b/routes/snowball.cts @@ -1,10 +1,9 @@ // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). import type { Req, Res, Next } from '../types/http'; const express = require('express'); -const { log } = require('../utils/logger.cts'); const router = express.Router(); const { getDb } = require('../db/database.cts'); -const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { ValidationError, NotFoundError } = require('../utils/apiError.cts'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts'); const { serializeBill } = require('../services/billsService.cts'); @@ -122,15 +121,7 @@ router.patch('/settings', (req: Req, res: Res) => { if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') { val = parseFloat(extra_payment); if (!Number.isFinite(val) || val < 0) { - return res - .status(400) - .json( - standardizeError( - 'extra_payment must be a non-negative number', - 'VALIDATION_ERROR', - 'extra_payment', - ), - ); + throw ValidationError('extra_payment must be a non-negative number', 'extra_payment'); } } @@ -261,9 +252,7 @@ function buildComparison(snowball, minimum_only) { router.patch('/order', (req: Req, res: Res) => { const items = req.body; if (!Array.isArray(items)) { - return res - .status(400) - .json(standardizeError('Request body must be an array', 'VALIDATION_ERROR')); + throw ValidationError('Request body must be an array'); } if (items.length === 0) { return res.json({ success: true, updated: 0 }); @@ -276,24 +265,12 @@ router.patch('/order', (req: Req, res: Res) => { const id = parseInt(row?.id, 10); const order = parseInt(row?.snowball_order, 10); if (!Number.isInteger(id) || id <= 0) { - return res - .status(400) - .json( - standardizeError( - `Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`, - 'VALIDATION_ERROR', - ), - ); + throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`); } if (!Number.isInteger(order) || order < 0) { - return res - .status(400) - .json( - standardizeError( - `Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`, - 'VALIDATION_ERROR', - ), - ); + throw ValidationError( + `Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`, + ); } parsed.push({ id, order }); } @@ -373,204 +350,170 @@ function enrichPlanWithProgress(db, plan) { // POST /api/snowball/plans — start a new snowball plan router.post('/plans', (req: Req, res: Res) => { - try { - const db = getDb(); - const userId = req.user.id; - const { name, method, notes } = req.body; + const db = getDb(); + const userId = req.user.id; + const { name, method, notes } = req.body; - const planName = - typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan'; - const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball'; + const planName = + typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan'; + const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball'; - const ramseyMode = isRamseyMode(userId); - const debts = getDebtBills(userId, ramseyMode); - const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0); - if (activeDebts.length === 0) { - return res - .status(400) - .json( - standardizeError( - 'No debts with a balance found. Add a balance to at least one bill.', - 'NO_DEBTS', - ), - ); - } - - // Money fields on `debts` are stored as integer cents; the snowball/APR - // math and plan_snapshot are dollar-denominated, so convert before computing. - const debtsForMath = debts.map((b) => ({ - ...b, - current_balance: fromCents(b.current_balance), - minimum_payment: fromCents(b.minimum_payment), - })); - - const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); - const extraCents = user?.snowball_extra_payment ?? 0; - const extra = fromCents(extraCents); - const now = new Date(); - - const snowball = - planMethod === 'avalanche' - ? calculateAvalanche(debtsForMath, extra, now) - : calculateSnowball(debtsForMath, extra, now); - const minOnly = calculateMinimumOnly(debtsForMath, now); - const interestSaved = Math.max( - 0, - Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / - 100, + const ramseyMode = isRamseyMode(userId); + const debts = getDebtBills(userId, ramseyMode); + const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0); + if (activeDebts.length === 0) { + throw ValidationError( + 'No debts with a balance found. Add a balance to at least one bill.', + undefined, + 'NO_DEBTS', ); + } - const debtSnaps = debtsForMath.map((b, i) => { - const proj = snowball.debts?.find((d) => d.id === b.id); - return { - bill_id: b.id, - name: b.name, - starting_balance: b.current_balance ?? 0, - minimum_payment: b.minimum_payment ?? 0, - interest_rate: b.interest_rate ?? 0, - projected_payoff_month: proj?.payoff_month ?? null, - projected_payoff_date: proj?.payoff_date ?? null, - projected_total_interest: proj?.total_interest ?? null, - order: i, - }; - }); + // Money fields on `debts` are stored as integer cents; the snowball/APR + // math and plan_snapshot are dollar-denominated, so convert before computing. + const debtsForMath = debts.map((b) => ({ + ...b, + current_balance: fromCents(b.current_balance), + minimum_payment: fromCents(b.minimum_payment), + })); - const planSnapshot = JSON.stringify({ - projected_payoff_date: snowball.payoff_date ?? null, - projected_months: snowball.months_to_freedom ?? null, - projected_total_interest: snowball.total_interest_paid ?? null, - minimum_only_months: minOnly.months_to_freedom ?? null, - interest_saved: interestSaved, - debts: debtSnaps, - }); + const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); + const extraCents = user?.snowball_extra_payment ?? 0; + const extra = fromCents(extraCents); + const now = new Date(); - // Abandon any existing active/paused plan first - db.prepare( - ` + const snowball = + planMethod === 'avalanche' + ? calculateAvalanche(debtsForMath, extra, now) + : calculateSnowball(debtsForMath, extra, now); + const minOnly = calculateMinimumOnly(debtsForMath, now); + const interestSaved = Math.max( + 0, + Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / + 100, + ); + + const debtSnaps = debtsForMath.map((b, i) => { + const proj = snowball.debts?.find((d) => d.id === b.id); + return { + bill_id: b.id, + name: b.name, + starting_balance: b.current_balance ?? 0, + minimum_payment: b.minimum_payment ?? 0, + interest_rate: b.interest_rate ?? 0, + projected_payoff_month: proj?.payoff_month ?? null, + projected_payoff_date: proj?.payoff_date ?? null, + projected_total_interest: proj?.total_interest ?? null, + order: i, + }; + }); + + const planSnapshot = JSON.stringify({ + projected_payoff_date: snowball.payoff_date ?? null, + projected_months: snowball.months_to_freedom ?? null, + projected_total_interest: snowball.total_interest_paid ?? null, + minimum_only_months: minOnly.months_to_freedom ?? null, + interest_saved: interestSaved, + debts: debtSnaps, + }); + + // Abandon any existing active/paused plan first + db.prepare( + ` UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') WHERE user_id = ? AND status IN ('active', 'paused') `, - ).run(userId); + ).run(userId); - const result = db - .prepare( - ` + const result = db + .prepare( + ` INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) `, - ) - .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); + ) + .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); - const plan = db - .prepare('SELECT * FROM snowball_plans WHERE id = ?') - .get(result.lastInsertRowid); - res.status(201).json(enrichPlanWithProgress(db, plan)); - } catch (err) { - log.error('[snowball plans] POST error:', err.message); - res.status(500).json(standardizeError('Failed to start plan', 'PLAN_CREATE_ERROR')); - } + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(enrichPlanWithProgress(db, plan)); }); // GET /api/snowball/plans — list all plans for user router.get('/plans', (req: Req, res: Res) => { - try { - const db = getDb(); - const plans = db - .prepare( - ` + const db = getDb(); + const plans = db + .prepare( + ` SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC `, - ) - .all(req.user.id); - res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) }); - } catch (err) { - log.error('[snowball plans] GET /plans error:', err.message); - res.status(500).json(standardizeError('Failed to load plans', 'PLAN_LIST_ERROR')); - } + ) + .all(req.user.id); + res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) }); }); // GET /api/snowball/plans/active — return the active or paused plan (or null) router.get('/plans/active', (req: Req, res: Res) => { - try { - const db = getDb(); - const plan = db - .prepare( - ` + const db = getDb(); + const plan = db + .prepare( + ` SELECT * FROM snowball_plans WHERE user_id = ? AND status IN ('active', 'paused') ORDER BY created_at DESC LIMIT 1 `, - ) - .get(req.user.id); - res.json(plan ? enrichPlanWithProgress(db, plan) : null); - } catch (err) { - log.error('[snowball plans] GET /plans/active error:', err.message); - res.status(500).json(standardizeError('Failed to load active plan', 'PLAN_ACTIVE_ERROR')); - } + ) + .get(req.user.id); + res.json(plan ? enrichPlanWithProgress(db, plan) : null); }); // PATCH /api/snowball/plans/:id — update name or notes router.patch('/plans/:id', (req: Req, res: Res) => { - try { - const db = getDb(); - const id = parseInt(req.params.id, 10); - if (!Number.isInteger(id) || id <= 0) - return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id')); - const plan = db - .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') - .get(id, req.user.id); - if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND')); + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id'); + const plan = db + .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') + .get(id, req.user.id); + if (!plan) throw NotFoundError('Plan not found'); - const { name, notes } = req.body; - const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name; - const newNotes = notes !== undefined ? notes || null : plan.notes; + const { name, notes } = req.body; + const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name; + const newNotes = notes !== undefined ? notes || null : plan.notes; - db.prepare( - ` + db.prepare( + ` UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ? `, - ).run(newName, newNotes, id); + ).run(newName, newNotes, id); - const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); - res.json(enrichPlanWithProgress(db, updated)); - } catch (err) { - log.error('[snowball plans] PATCH error:', err.message); - res.status(500).json(standardizeError('Failed to update plan', 'PLAN_UPDATE_ERROR')); - } + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); }); // Shared status-transition handler for pause / resume / complete / abandon. // `setSql` is a fixed constant per route (never user input). -function transitionPlan(req, res, { allowedFrom, setSql, action, past }) { - try { - const db = getDb(); - const id = parseInt(req.params.id, 10); - if (!Number.isInteger(id) || id <= 0) { - return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id')); - } - const plan = db - .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') - .get(id, req.user.id); - if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND')); - if (!allowedFrom.includes(plan.status)) { - return res - .status(400) - .json( - standardizeError( - `Only ${allowedFrom.join(' or ')} plans can be ${past}`, - 'INVALID_PLAN_STATE', - ), - ); - } - db.prepare( - `UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`, - ).run(id); - const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); - res.json(enrichPlanWithProgress(db, updated)); - } catch (err) { - log.error(`[snowball plans] ${action} error:`, err.message); - res.status(500).json(standardizeError(`Failed to ${action} plan`, 'PLAN_TRANSITION_ERROR')); +function transitionPlan(req, res, { allowedFrom, setSql, past }) { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) { + throw ValidationError('Invalid plan id', 'id'); } + const plan = db + .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') + .get(id, req.user.id); + if (!plan) throw NotFoundError('Plan not found'); + if (!allowedFrom.includes(plan.status)) { + throw ValidationError( + `Only ${allowedFrom.join(' or ')} plans can be ${past}`, + undefined, + 'INVALID_PLAN_STATE', + ); + } + db.prepare(`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`).run( + id, + ); + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); } // POST /api/snowball/plans/:id/{pause,resume,complete,abandon} diff --git a/routes/summary.cts b/routes/summary.cts index e2e9b8b..392f5de 100644 --- a/routes/summary.cts +++ b/routes/summary.cts @@ -8,6 +8,7 @@ const { getCycleRange, resolveDueDate } = require('../services/statusService.cts const { getUserSettings } = require('../services/userSettings.cts'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { toCents, fromCents } = require('../utils/money.mts'); +const { ValidationError } = require('../utils/apiError.cts'); const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_PENDING_DAYS = 3; @@ -127,10 +128,10 @@ function parseYearMonth(source) { const month = parseInt(source.month || now.getMonth() + 1, 10); if (Number.isNaN(year) || year < 2000 || year > 2100) { - return { error: 'year must be a 4-digit integer between 2000 and 2100' }; + throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); } if (Number.isNaN(month) || month < 1 || month > 12) { - return { error: 'month must be an integer between 1 and 12' }; + throw ValidationError('month must be an integer between 1 and 12', 'month'); } return { year, month }; @@ -447,7 +448,6 @@ function buildSummary(db, userId, year, month) { router.get('/', (req: Req, res: Res) => { const parsed = parseYearMonth(req.query); - if (parsed.error) return res.status(400).json({ error: parsed.error }); const db = getDb(); res.json(buildSummary(db, req.user.id, parsed.year, parsed.month)); @@ -455,11 +455,10 @@ router.get('/', (req: Req, res: Res) => { router.put('/income', (req: Req, res: Res) => { const parsed = parseYearMonth(req.body || {}); - if (parsed.error) return res.status(400).json({ error: parsed.error }); const amount = Number(req.body?.amount); if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) { - return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' }); + throw ValidationError('amount must be a number between 0 and 1000000000', 'amount'); } const label = diff --git a/tests/snowballPlanRoute.test.js b/tests/snowballPlanRoute.test.js index 04acacb..e8bdc4c 100644 --- a/tests/snowballPlanRoute.test.js +++ b/tests/snowballPlanRoute.test.js @@ -13,6 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid} process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database.cts'); +const { formatError } = require('../utils/apiError.cts'); const router = require('../routes/snowball.cts'); function handler(method, routePath) { @@ -34,7 +35,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) { resolve({ status: this.statusCode, data: d }); }, }; - h(req, res); + // Routes follow the throw-pattern: mirror the app's terminal error handler + // (server.cts) so thrown ApiErrors resolve to the same wire shape. + try { + h(req, res); + } catch (err) { + resolve({ status: err.status || 500, data: formatError(err) }); + } }); }