refactor(types): type-check batch 4 — transactions.cts (the 116-error monster)

The worst file, but the errors collapsed to a few roots:
- typed throwStandardized(): never — it always throws, and saying so lets
  TS follow the error-guard, which cleared all 16 'possibly undefined'
  tx.* accesses (validation returns {error} xor {normalized}; the error
  path throws, so normalized is always present past the guard — proven,
  not suppressed)
- typed the normalizeTransactionFields `normalized` accumulator + the
  dynamic body/next/existing params + SORT_COLUMNS maps
- parseInteger's {min,max} options default inferred as null; gave it a
  real {min?: number|null; ...} type
- the rest: callback/helper param annotations, filtered.params spread casts

@ts-nocheck server count: 10 -> 9 (all 20 routes now checked; the 9
remaining are the intentional dynamic services/db files, next commit).
Server 252/252, transaction/spending suites green, probe 33/33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 07:08:36 -05:00
parent 197fdd1733
commit e0bc82b229
1 changed files with 51 additions and 39 deletions

View File

@ -1,4 +1,3 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
@ -9,7 +8,7 @@ const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.
// Boundary adapter: the internal parse/normalize helpers return // Boundary adapter: the internal parse/normalize helpers return
// { error: <standardized body> } result objects; at the HTTP boundary we // { error: <standardized body> } result objects; at the HTTP boundary we
// rethrow them as ApiError so the terminal handler emits the same wire shape. // rethrow them as ApiError so the terminal handler emits the same wire shape.
function throwStandardized(errBody, status = 400) { function throwStandardized(errBody: any, status = 400): never {
throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, { throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, {
field: errBody.field, field: errBody.field,
}); });
@ -54,7 +53,7 @@ function todayStr() {
return todayLocal(); return todayLocal();
} }
function cleanText(value, maxLength) { function cleanText(value: any, maxLength: number) {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (value === null) return null; if (value === null) return null;
const text = String(value).trim(); const text = String(value).trim();
@ -62,7 +61,15 @@ function cleanText(value, maxLength) {
return text.slice(0, maxLength); return text.slice(0, maxLength);
} }
function parseInteger(value, field, { allowNull = false, min = null, max = null } = {}) { function parseInteger(
value: any,
field: string,
{
allowNull = false,
min = null,
max = null,
}: { allowNull?: boolean; min?: number | null; max?: number | null } = {},
) {
if (value === null && allowNull) return { value: null }; if (value === null && allowNull) return { value: null };
if (value === undefined) return { value: undefined }; if (value === undefined) return { value: undefined };
const n = typeof value === 'number' ? value : Number(value); const n = typeof value === 'number' ? value : Number(value);
@ -82,14 +89,14 @@ function parseInteger(value, field, { allowNull = false, min = null, max = null
return { value: n }; return { value: n };
} }
function parseBooleanInt(value, field) { function parseBooleanInt(value: any, field: string) {
if (value === undefined) return { value: undefined }; if (value === undefined) return { value: undefined };
if (value === true || value === 'true' || value === '1' || value === 1) return { value: 1 }; if (value === true || value === 'true' || value === '1' || value === 1) return { value: 1 };
if (value === false || value === 'false' || value === '0' || value === 0) return { value: 0 }; if (value === false || value === 'false' || value === '0' || value === 0) return { value: 0 };
return { error: standardizeError(`${field} must be true or false`, 'VALIDATION_ERROR', field) }; return { error: standardizeError(`${field} must be true or false`, 'VALIDATION_ERROR', field) };
} }
function parseDate(value, field, { allowNull = false } = {}) { function parseDate(value: any, field: string, { allowNull = false } = {}) {
if (value === null && allowNull) return { value: null }; if (value === null && allowNull) return { value: null };
if (value === undefined) return { value: undefined }; if (value === undefined) return { value: undefined };
const text = String(value).trim(); const text = String(value).trim();
@ -111,7 +118,7 @@ function parseDate(value, field, { allowNull = false } = {}) {
return { value: text }; return { value: text };
} }
function parseOptionalDateTime(value, field) { function parseOptionalDateTime(value: any, field: string) {
if (value === undefined) return { value: undefined }; if (value === undefined) return { value: undefined };
if (value === null) return { value: null }; if (value === null) return { value: null };
const text = String(value).trim(); const text = String(value).trim();
@ -137,26 +144,31 @@ function parseOptionalDateTime(value, field) {
return { value: text }; return { value: text };
} }
function hasOwn(obj, field) { function hasOwn(obj: any, field: string) {
return Object.prototype.hasOwnProperty.call(obj, field); return Object.prototype.hasOwnProperty.call(obj, field);
} }
function getOwnedAccount(db, userId, accountId) { function getOwnedAccount(db: any, userId: any, accountId: any) {
if (accountId == null) return null; if (accountId == null) return null;
return db return db
.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ? AND monitored = 1') .prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ? AND monitored = 1')
.get(accountId, userId); .get(accountId, userId);
} }
function getOwnedBill(db, userId, billId) { function getOwnedBill(db: any, userId: any, billId: any) {
if (billId == null) return null; if (billId == null) return null;
return db return db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, userId); .get(billId, userId);
} }
function normalizeTransactionFields(db, userId, body = {}, { partial = false } = {}) { function normalizeTransactionFields(
const normalized = {}; db: any,
userId: any,
body: any = {},
{ partial = false } = {},
) {
const normalized: Record<string, any> = {};
if (!partial || body.amount !== undefined) { if (!partial || body.amount !== undefined) {
const parsed = parseInteger(body.amount, 'amount'); const parsed = parseInteger(body.amount, 'amount');
@ -228,7 +240,7 @@ function normalizeTransactionFields(db, userId, body = {}, { partial = false } =
if (body.match_status !== undefined) { if (body.match_status !== undefined) {
const matchStatus = cleanText(body.match_status, 32); const matchStatus = cleanText(body.match_status, 32);
if (!MATCH_STATUSES.has(matchStatus)) { if (!MATCH_STATUSES.has(matchStatus as string)) {
return { return {
error: standardizeError( error: standardizeError(
'match_status must be unmatched, matched, or ignored', 'match_status must be unmatched, matched, or ignored',
@ -249,7 +261,7 @@ function normalizeTransactionFields(db, userId, body = {}, { partial = false } =
return { normalized }; return { normalized };
} }
function resolveTransactionState(next, existing = {}) { function resolveTransactionState(next: any, existing: any = {}) {
const hasStatus = hasOwn(next, 'match_status'); const hasStatus = hasOwn(next, 'match_status');
const hasIgnored = hasOwn(next, 'ignored'); const hasIgnored = hasOwn(next, 'ignored');
const hasMatchedBill = hasOwn(next, 'matched_bill_id'); const hasMatchedBill = hasOwn(next, 'matched_bill_id');
@ -344,7 +356,7 @@ function resolveTransactionState(next, existing = {}) {
}; };
} }
function parseLimitOffset(query) { function parseLimitOffset(query: any) {
const limit = parseInteger(query.limit ?? 50, 'limit', { min: 1, max: 200 }); const limit = parseInteger(query.limit ?? 50, 'limit', { min: 1, max: 200 });
if (limit.error) return { error: limit.error }; if (limit.error) return { error: limit.error };
const offset = parseInteger(query.offset ?? 0, 'offset', { min: 0 }); const offset = parseInteger(query.offset ?? 0, 'offset', { min: 0 });
@ -352,7 +364,7 @@ function parseLimitOffset(query) {
return { limit: limit.value, offset: offset.value }; return { limit: limit.value, offset: offset.value };
} }
function selectedTransaction(db, userId, id) { function selectedTransaction(db: any, userId: any, id: any) {
return decorateTransaction(getTransactionForUser(db, userId, id)); return decorateTransaction(getTransactionForUser(db, userId, id));
} }
@ -368,7 +380,7 @@ function rejectDirectMatchState(body = {}) {
// 4xx service errors keep their message/code on the wire; anything else is // 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. // rethrown raw so the terminal handler masks + logs it as a 5xx.
function toTransactionServiceError(err) { function toTransactionServiceError(err: any) {
if (err.status && err.status < 500) { if (err.status && err.status < 500) {
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, { return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
field: err.field, field: err.field,
@ -377,7 +389,7 @@ function toTransactionServiceError(err) {
return err; return err;
} }
function emptyBankLedger(enabled, hasConnections = false) { function emptyBankLedger(enabled: any, hasConnections = false) {
return { return {
enabled: Boolean(enabled), enabled: Boolean(enabled),
has_connections: Boolean(hasConnections), has_connections: Boolean(hasConnections),
@ -401,7 +413,7 @@ function emptyBankLedger(enabled, hasConnections = false) {
}; };
} }
function bankLedgerWhere(query, userId) { function bankLedgerWhere(query: any, userId: any) {
const where = [ const where = [
't.user_id = ?', 't.user_id = ?',
"ds.type = 'provider_sync'", "ds.type = 'provider_sync'",
@ -473,7 +485,7 @@ router.get('/', (req: Req, res: Res) => {
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) throwStandardized(page.error); if (page.error) throwStandardized(page.error);
const SORT_COLUMNS = { const SORT_COLUMNS: Record<string, string> = {
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)',
amount: 't.amount', amount: 't.amount',
}; };
@ -602,7 +614,7 @@ router.get('/', (req: Req, res: Res) => {
.all(...params, page.limit, page.offset); .all(...params, page.limit, page.offset);
res.json({ res.json({
transactions: rows.map((row) => { transactions: rows.map((row: any) => {
const decorated = decorateTransaction(row); const decorated = decorateTransaction(row);
const title = row.payee || row.description || row.memo || ''; const title = row.payee || row.description || row.memo || '';
decorated.advisory_filter = advisoryCheck(title); decorated.advisory_filter = advisoryCheck(title);
@ -666,7 +678,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
const filtered = bankLedgerWhere(req.query, req.user.id); const filtered = bankLedgerWhere(req.query, req.user.id);
if (filtered.error) throwStandardized(filtered.error); if (filtered.error) throwStandardized(filtered.error);
const SORT_COLUMNS = { const SORT_COLUMNS: Record<string, string> = {
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)',
amount: 't.amount', amount: 't.amount',
merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))", merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
@ -701,7 +713,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
WHERE ${filtered.whereClause} WHERE ${filtered.whereClause}
`, `,
) )
.get(...filtered.params); .get(...(filtered.params as any[]));
summary.category_breakdown = db summary.category_breakdown = db
.prepare( .prepare(
@ -717,7 +729,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
LIMIT 6 LIMIT 6
`, `,
) )
.all(...filtered.params); .all(...(filtered.params as any[]));
const total = summary.total; const total = summary.total;
const rows = db const rows = db
@ -741,7 +753,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`, `,
) )
.all(...filtered.params, page.limit, page.offset); .all(...(filtered.params as any[]), page.limit, page.offset);
res.json({ res.json({
enabled: true, enabled: true,
@ -749,7 +761,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
sources, sources,
accounts, accounts,
summary, summary,
transactions: rows.map((row) => { transactions: rows.map((row: any) => {
const decorated = decorateTransaction(row); const decorated = decorateTransaction(row);
if (!row.spending_category_id) { if (!row.spending_category_id) {
decorated.suggested_match = findMerchantMatch( decorated.suggested_match = findMerchantMatch(
@ -773,7 +785,7 @@ router.post('/manual', (req: Req, res: Res) => {
const validation = normalizeTransactionFields(db, req.user.id, req.body); const validation = normalizeTransactionFields(db, req.user.id, req.body);
if (validation.error) throwStandardized(validation.error, validation.status || 400); if (validation.error) throwStandardized(validation.error, validation.status || 400);
const tx = validation.normalized; const tx: any = 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) throwStandardized(state.error); if (state.error) throwStandardized(state.error);
@ -824,7 +836,7 @@ router.put('/:id', (req: Req, res: Res) => {
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) throwStandardized(validation.error, validation.status || 400); if (validation.error) throwStandardized(validation.error, validation.status || 400);
const tx = validation.normalized; const tx: any = validation.normalized;
const state = resolveTransactionState(tx, existing); const state = resolveTransactionState(tx, existing);
if (state.error) throwStandardized(state.error); if (state.error) throwStandardized(state.error);
Object.assign(tx, state); Object.assign(tx, state);
@ -913,7 +925,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
{ learnMerchant: true }, { learnMerchant: true },
); );
res.json(result); res.json(result);
} catch (err) { } catch (err: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });
@ -923,7 +935,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
try { try {
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: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });
@ -945,7 +957,7 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const userId = req.user.id; const userId = req.user.id;
const results = []; const results: any[] = [];
db.transaction(() => { db.transaction(() => {
for (const m of matches) { for (const m of matches) {
@ -1004,7 +1016,7 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
unmatchTransaction(userId, String(txId)); unmatchTransaction(userId, String(txId));
results.push({ transaction_id: txId, ok: true }); results.push({ transaction_id: txId, ok: true });
} }
} catch (err) { } catch (err: any) {
// Per-item feedback: service 4xx messages are user-facing; anything // Per-item feedback: service 4xx messages are user-facing; anything
// else is masked — 500-class internals must not leak into the results. // else is masked — 500-class internals must not leak into the results.
const msg = err.status && err.status < 500 ? err.message : 'Unmatch failed'; const msg = err.status && err.status < 500 ? err.message : 'Unmatch failed';
@ -1013,14 +1025,14 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
} }
})(); })();
const failed = results.filter((r) => !r.ok); const failed = results.filter((r: any) => !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 // Deliberate hand-rolled body: carries the per-item `results` payload the
// client renders; formatError has no extras channel (same as payments' // client renders; formatError has no extras channel (same as payments'
// DUPLICATE_SUSPECTED exception). // 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: any) => r.ok).length });
}); });
// POST /api/transactions/:id/ignore // POST /api/transactions/:id/ignore
@ -1028,7 +1040,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => {
try { try {
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: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });
@ -1038,7 +1050,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => {
try { try {
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: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });
@ -1058,7 +1070,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
existing.payee || existing.description || existing.memo || '', existing.payee || existing.description || existing.memo || '',
); );
res.json({ match }); res.json({ match });
} catch (err) { } catch (err: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });
@ -1087,7 +1099,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
category: { id: categoryId, name: match.category }, category: { id: categoryId, name: match.category },
display_name: match.display_name, display_name: match.display_name,
}); });
} catch (err) { } catch (err: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });
@ -1100,7 +1112,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => {
dryRun: Boolean(req.body?.dry_run), dryRun: Boolean(req.body?.dry_run),
}); });
res.json(result); res.json(result);
} catch (err) { } catch (err: any) {
throw toTransactionServiceError(err); throw toTransactionServiceError(err);
} }
}); });