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:
parent
197fdd1733
commit
e0bc82b229
|
|
@ -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';
|
||||
const router = require('express').Router();
|
||||
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
|
||||
// { 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) {
|
||||
function throwStandardized(errBody: any, status = 400): never {
|
||||
throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, {
|
||||
field: errBody.field,
|
||||
});
|
||||
|
|
@ -54,7 +53,7 @@ function todayStr() {
|
|||
return todayLocal();
|
||||
}
|
||||
|
||||
function cleanText(value, maxLength) {
|
||||
function cleanText(value: any, maxLength: number) {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
const text = String(value).trim();
|
||||
|
|
@ -62,7 +61,15 @@ function cleanText(value, 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 === undefined) return { value: undefined };
|
||||
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 };
|
||||
}
|
||||
|
||||
function parseBooleanInt(value, field) {
|
||||
function parseBooleanInt(value: any, field: string) {
|
||||
if (value === undefined) return { value: undefined };
|
||||
if (value === true || value === 'true' || value === '1' || value === 1) return { value: 1 };
|
||||
if (value === false || value === 'false' || value === '0' || value === 0) return { value: 0 };
|
||||
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 === undefined) return { value: undefined };
|
||||
const text = String(value).trim();
|
||||
|
|
@ -111,7 +118,7 @@ function parseDate(value, field, { allowNull = false } = {}) {
|
|||
return { value: text };
|
||||
}
|
||||
|
||||
function parseOptionalDateTime(value, field) {
|
||||
function parseOptionalDateTime(value: any, field: string) {
|
||||
if (value === undefined) return { value: undefined };
|
||||
if (value === null) return { value: null };
|
||||
const text = String(value).trim();
|
||||
|
|
@ -137,26 +144,31 @@ function parseOptionalDateTime(value, field) {
|
|||
return { value: text };
|
||||
}
|
||||
|
||||
function hasOwn(obj, field) {
|
||||
function hasOwn(obj: any, field: string) {
|
||||
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;
|
||||
return db
|
||||
.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ? AND monitored = 1')
|
||||
.get(accountId, userId);
|
||||
}
|
||||
|
||||
function getOwnedBill(db, userId, billId) {
|
||||
function getOwnedBill(db: any, userId: any, billId: any) {
|
||||
if (billId == null) return null;
|
||||
return db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, userId);
|
||||
}
|
||||
|
||||
function normalizeTransactionFields(db, userId, body = {}, { partial = false } = {}) {
|
||||
const normalized = {};
|
||||
function normalizeTransactionFields(
|
||||
db: any,
|
||||
userId: any,
|
||||
body: any = {},
|
||||
{ partial = false } = {},
|
||||
) {
|
||||
const normalized: Record<string, any> = {};
|
||||
|
||||
if (!partial || body.amount !== undefined) {
|
||||
const parsed = parseInteger(body.amount, 'amount');
|
||||
|
|
@ -228,7 +240,7 @@ function normalizeTransactionFields(db, userId, body = {}, { partial = false } =
|
|||
|
||||
if (body.match_status !== undefined) {
|
||||
const matchStatus = cleanText(body.match_status, 32);
|
||||
if (!MATCH_STATUSES.has(matchStatus)) {
|
||||
if (!MATCH_STATUSES.has(matchStatus as string)) {
|
||||
return {
|
||||
error: standardizeError(
|
||||
'match_status must be unmatched, matched, or ignored',
|
||||
|
|
@ -249,7 +261,7 @@ function normalizeTransactionFields(db, userId, body = {}, { partial = false } =
|
|||
return { normalized };
|
||||
}
|
||||
|
||||
function resolveTransactionState(next, existing = {}) {
|
||||
function resolveTransactionState(next: any, existing: any = {}) {
|
||||
const hasStatus = hasOwn(next, 'match_status');
|
||||
const hasIgnored = hasOwn(next, 'ignored');
|
||||
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 });
|
||||
if (limit.error) return { error: limit.error };
|
||||
const offset = parseInteger(query.offset ?? 0, 'offset', { min: 0 });
|
||||
|
|
@ -352,7 +364,7 @@ function parseLimitOffset(query) {
|
|||
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));
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +380,7 @@ function rejectDirectMatchState(body = {}) {
|
|||
|
||||
// 4xx service errors keep their message/code on the wire; anything else is
|
||||
// rethrown raw so the terminal handler masks + logs it as a 5xx.
|
||||
function toTransactionServiceError(err) {
|
||||
function toTransactionServiceError(err: any) {
|
||||
if (err.status && err.status < 500) {
|
||||
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
|
||||
field: err.field,
|
||||
|
|
@ -377,7 +389,7 @@ function toTransactionServiceError(err) {
|
|||
return err;
|
||||
}
|
||||
|
||||
function emptyBankLedger(enabled, hasConnections = false) {
|
||||
function emptyBankLedger(enabled: any, hasConnections = false) {
|
||||
return {
|
||||
enabled: Boolean(enabled),
|
||||
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 = [
|
||||
't.user_id = ?',
|
||||
"ds.type = 'provider_sync'",
|
||||
|
|
@ -473,7 +485,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
const page = parseLimitOffset(req.query);
|
||||
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)',
|
||||
amount: 't.amount',
|
||||
};
|
||||
|
|
@ -602,7 +614,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
.all(...params, page.limit, page.offset);
|
||||
|
||||
res.json({
|
||||
transactions: rows.map((row) => {
|
||||
transactions: rows.map((row: any) => {
|
||||
const decorated = decorateTransaction(row);
|
||||
const title = row.payee || row.description || row.memo || '';
|
||||
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);
|
||||
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)',
|
||||
amount: 't.amount',
|
||||
merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
|
||||
|
|
@ -701,7 +713,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
WHERE ${filtered.whereClause}
|
||||
`,
|
||||
)
|
||||
.get(...filtered.params);
|
||||
.get(...(filtered.params as any[]));
|
||||
|
||||
summary.category_breakdown = db
|
||||
.prepare(
|
||||
|
|
@ -717,7 +729,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
LIMIT 6
|
||||
`,
|
||||
)
|
||||
.all(...filtered.params);
|
||||
.all(...(filtered.params as any[]));
|
||||
|
||||
const total = summary.total;
|
||||
const rows = db
|
||||
|
|
@ -741,7 +753,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
)
|
||||
.all(...filtered.params, page.limit, page.offset);
|
||||
.all(...(filtered.params as any[]), page.limit, page.offset);
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
|
|
@ -749,7 +761,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
sources,
|
||||
accounts,
|
||||
summary,
|
||||
transactions: rows.map((row) => {
|
||||
transactions: rows.map((row: any) => {
|
||||
const decorated = decorateTransaction(row);
|
||||
if (!row.spending_category_id) {
|
||||
decorated.suggested_match = findMerchantMatch(
|
||||
|
|
@ -773,7 +785,7 @@ router.post('/manual', (req: Req, res: Res) => {
|
|||
|
||||
const validation = normalizeTransactionFields(db, req.user.id, req.body);
|
||||
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 state = resolveTransactionState(tx);
|
||||
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 });
|
||||
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
||||
const tx = validation.normalized;
|
||||
const tx: any = validation.normalized;
|
||||
const state = resolveTransactionState(tx, existing);
|
||||
if (state.error) throwStandardized(state.error);
|
||||
Object.assign(tx, state);
|
||||
|
|
@ -913,7 +925,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
|
|||
{ learnMerchant: true },
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -923,7 +935,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
|
|||
try {
|
||||
const result = unmatchTransaction(req.user.id, req.params.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -945,7 +957,7 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
|||
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
const results = [];
|
||||
const results: any[] = [];
|
||||
|
||||
db.transaction(() => {
|
||||
for (const m of matches) {
|
||||
|
|
@ -1004,7 +1016,7 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
|||
unmatchTransaction(userId, String(txId));
|
||||
results.push({ transaction_id: txId, ok: true });
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
// Per-item feedback: service 4xx messages are user-facing; anything
|
||||
// else is masked — 500-class internals must not leak into the results.
|
||||
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) {
|
||||
// Deliberate hand-rolled body: carries the per-item `results` payload the
|
||||
// client renders; formatError has no extras channel (same as payments'
|
||||
// DUPLICATE_SUSPECTED exception).
|
||||
return res.status(500).json({ error: 'All unmatches failed', results });
|
||||
}
|
||||
res.json({ results, unmatched: results.filter((r) => r.ok).length });
|
||||
res.json({ results, unmatched: results.filter((r: any) => r.ok).length });
|
||||
});
|
||||
|
||||
// POST /api/transactions/:id/ignore
|
||||
|
|
@ -1028,7 +1040,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => {
|
|||
try {
|
||||
const result = ignoreTransaction(req.user.id, req.params.id);
|
||||
res.json(result.transaction);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -1038,7 +1050,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => {
|
|||
try {
|
||||
const result = unignoreTransaction(req.user.id, req.params.id);
|
||||
res.json(result.transaction);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -1058,7 +1070,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
|||
existing.payee || existing.description || existing.memo || '',
|
||||
);
|
||||
res.json({ match });
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -1087,7 +1099,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
|||
category: { id: categoryId, name: match.category },
|
||||
display_name: match.display_name,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -1100,7 +1112,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => {
|
|||
dryRun: Boolean(req.body?.dry_run),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue