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 <noreply@anthropic.com>
This commit is contained in:
parent
a8f8446743
commit
c4219b6020
|
|
@ -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,7 +46,6 @@ 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(
|
||||
|
|
@ -61,9 +69,6 @@ router.get('/accounts/all', (req: Req, res: Res) => {
|
|||
balance_dollars: a.balance !== null ? a.balance / 100 : null,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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,19 +173,15 @@ 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 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'));
|
||||
if (!source) throw NotFoundError('Data source not found');
|
||||
|
||||
const accounts = db
|
||||
.prepare(
|
||||
|
|
@ -236,9 +218,6 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
|
|||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
|
||||
|
|
@ -252,15 +231,12 @@ 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(
|
||||
|
|
@ -272,32 +248,22 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
|
|||
)
|
||||
.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'));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
// 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(
|
||||
throw ConflictError(
|
||||
'Transaction is already matched to a bill',
|
||||
'ALREADY_MATCHED',
|
||||
'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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,23 +265,11 @@ 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(
|
||||
throw ValidationError(
|
||||
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
|
||||
'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
parsed.push({ id, order });
|
||||
|
|
@ -373,7 +350,6 @@ 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;
|
||||
|
|
@ -386,13 +362,10 @@ router.post('/plans', (req: Req, res: Res) => {
|
|||
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(
|
||||
throw ValidationError(
|
||||
'No debts with a balance found. Add a balance to at least one bill.',
|
||||
undefined,
|
||||
'NO_DEBTS',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -461,19 +434,12 @@ router.post('/plans', (req: Req, res: Res) => {
|
|||
)
|
||||
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
|
||||
|
||||
const plan = db
|
||||
.prepare('SELECT * FROM snowball_plans WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/snowball/plans — list all plans for user
|
||||
router.get('/plans', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const plans = db
|
||||
.prepare(
|
||||
|
|
@ -483,15 +449,10 @@ router.get('/plans', (req: Req, res: Res) => {
|
|||
)
|
||||
.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'));
|
||||
}
|
||||
});
|
||||
|
||||
// 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(
|
||||
|
|
@ -503,23 +464,17 @@ router.get('/plans/active', (req: Req, res: Res) => {
|
|||
)
|
||||
.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'));
|
||||
}
|
||||
});
|
||||
|
||||
// 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'));
|
||||
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) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
|
||||
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;
|
||||
|
|
@ -533,44 +488,32 @@ router.patch('/plans/:id', (req: Req, res: Res) => {
|
|||
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
||||
// 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 {
|
||||
function transitionPlan(req, res, { allowedFrom, setSql, past }) {
|
||||
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'));
|
||||
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) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
|
||||
if (!plan) throw NotFoundError('Plan not found');
|
||||
if (!allowedFrom.includes(plan.status)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
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);
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/snowball/plans/:id/{pause,resume,complete,abandon}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
// 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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue