refactor(types): type-check batch 3 — 4 large routes (bills/snowball/aboutAdmin/summary)

~141 errors, mostly param/callback annotations. Two real correctness
touch-ups the checker forced:
- bills.cts: the end-date guard was `if (ey != null)` but used `em` in
  arithmetic; tightened to `ey != null && em != null` (they're validated
  as a both-or-neither pair, so behavior-identical — but the guard now
  says what it means)
- aboutAdmin.cts: `dateB - dateA` on two Dates -> `.getTime()` (coerced
  fine at runtime; correct form now)

@ts-nocheck server count: 14 -> 10. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 07:03:23 -05:00
parent 6c915a62c8
commit 197fdd1733
4 changed files with 91 additions and 91 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 express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
@ -9,7 +8,7 @@ const { ValidationError } = require('../utils/apiError.cts');
const router = express.Router(); const router = express.Router();
let pkg; let pkg: any;
try { try {
pkg = require('../package.json'); pkg = require('../package.json');
} catch { } catch {
@ -23,7 +22,7 @@ const ALLOWED_FILES = {
}; };
// Priority emoji to label mapping // Priority emoji to label mapping
const PRIORITY_MAP = { const PRIORITY_MAP: Record<string, string> = {
'🔴': 'CRITICAL', '🔴': 'CRITICAL',
'🟠': 'HIGH', '🟠': 'HIGH',
'🟡': 'MEDIUM', '🟡': 'MEDIUM',
@ -34,7 +33,7 @@ const PRIORITY_MAP = {
/** /**
* Generate a slug from a title: lowercase, hyphens, strip emojis * Generate a slug from a title: lowercase, hyphens, strip emojis
*/ */
function slugify(title) { function slugify(title: any) {
return title return title
.replace( .replace(
/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FAFF}]/gu, /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FAFF}]/gu,
@ -49,7 +48,7 @@ function slugify(title) {
* Extract effort estimate from implementation notes text. * Extract effort estimate from implementation notes text.
* Matches patterns like "Estimated effort: 3-4 hours", "Estimated effort: 8 hours" * Matches patterns like "Estimated effort: 3-4 hours", "Estimated effort: 8 hours"
*/ */
function extractEffort(text) { function extractEffort(text: any) {
if (!text) return null; if (!text) return null;
const match = text.match(/Estimated effort:\s*(\d+(?:\s*-\s*\d+)?\s*hours?)/i); const match = text.match(/Estimated effort:\s*(\d+(?:\s*-\s*\d+)?\s*hours?)/i);
if (!match) return null; if (!match) return null;
@ -61,10 +60,10 @@ function extractEffort(text) {
* Parse FUTURE.md into structured roadmap items. * Parse FUTURE.md into structured roadmap items.
* Filters out completed/strikethrough items and template/meta sections. * Filters out completed/strikethrough items and template/meta sections.
*/ */
function parseFutureMd(content) { function parseFutureMd(content: any) {
if (!content) return { items: [], counts: {} }; if (!content) return { items: [], counts: {} };
const items = []; const items: any[] = [];
const counts = { critical: 0, high: 0, medium: 0, low: 0, niceToHave: 0 }; const counts = { critical: 0, high: 0, medium: 0, low: 0, niceToHave: 0 };
const lines = content.split('\n'); const lines = content.split('\n');
@ -279,7 +278,7 @@ function parseFutureMd(content) {
/** /**
* Add a parsed item to the items array and update counts. * Add a parsed item to the items array and update counts.
*/ */
function _addItem(items, counts, emoji, label, title, bodyLines) { function _addItem(items: any, counts: any, emoji: any, label: any, title: any, bodyLines: any) {
const body = bodyLines.join('\n'); const body = bodyLines.join('\n');
const description = _extractField(body, 'Description'); const description = _extractField(body, 'Description');
const rationale = _extractField(body, 'Rationale'); const rationale = _extractField(body, 'Rationale');
@ -297,6 +296,7 @@ function _addItem(items, counts, emoji, label, title, bodyLines) {
// Map priority label to count key // Map priority label to count key
const countKey = const countKey =
(
{ {
CRITICAL: 'critical', CRITICAL: 'critical',
HIGH: 'high', HIGH: 'high',
@ -305,7 +305,8 @@ function _addItem(items, counts, emoji, label, title, bodyLines) {
'NICE TO HAVE': 'niceToHave', 'NICE TO HAVE': 'niceToHave',
NICE_TO_HAVE: 'niceToHave', NICE_TO_HAVE: 'niceToHave',
MEH: 'niceToHave', MEH: 'niceToHave',
}[label] || 'medium'; } as Record<string, string>
)[label] || 'medium';
counts[countKey]++; counts[countKey]++;
items.push({ items.push({
@ -327,7 +328,7 @@ function _addItem(items, counts, emoji, label, title, bodyLines) {
* Extract a named field from markdown body text. * Extract a named field from markdown body text.
* Looks for **Field Name:** and captures everything until the next ** field or ### heading or end. * Looks for **Field Name:** and captures everything until the next ** field or ### heading or end.
*/ */
function _extractField(body, fieldName) { function _extractField(body: any, fieldName: any) {
// Match **FieldName:** followed by content until next ** or ### heading // Match **FieldName:** followed by content until next ** or ### heading
const regex = new RegExp( const regex = new RegExp(
`\\*\\*${fieldName}:\\*\\*\\s*\n([\\s\\S]*?)(?=\\n\\*\\*[^*]|\\n###|$)`, `\\*\\*${fieldName}:\\*\\*\\s*\n([\\s\\S]*?)(?=\\n\\*\\*[^*]|\\n###|$)`,
@ -342,7 +343,7 @@ function _extractField(body, fieldName) {
* Parse DEVELOPMENT_LOG.md into structured log entries. * Parse DEVELOPMENT_LOG.md into structured log entries.
* Returns entries sorted by date descending. * Returns entries sorted by date descending.
*/ */
function parseDevLogMd(content) { function parseDevLogMd(content: any) {
if (!content) return []; if (!content) return [];
const entries = []; const entries = [];
@ -367,10 +368,10 @@ function parseDevLogMd(content) {
} }
// Sort by date descending // Sort by date descending
entries.sort((a, b) => { entries.sort((a: any, b: any) => {
const dateA = a.date ? new Date(a.date) : new Date(0); const dateA = a.date ? new Date(a.date) : new Date(0);
const dateB = b.date ? new Date(b.date) : new Date(0); const dateB = b.date ? new Date(b.date) : new Date(0);
return dateB - dateA; return dateB.getTime() - dateA.getTime();
}); });
return entries; return entries;
@ -379,7 +380,7 @@ function parseDevLogMd(content) {
/** /**
* Parse a single dev log entry block. * Parse a single dev log entry block.
*/ */
function _parseDevLogEntry(block, version, title) { function _parseDevLogEntry(block: any, version: any, title: any) {
// Status // Status
const statusMatch = block.match(/\*\*Status:\*\*\s*(.+)/); const statusMatch = block.match(/\*\*Status:\*\*\s*(.+)/);
const status = statusMatch ? statusMatch[1].trim() : null; const status = statusMatch ? statusMatch[1].trim() : null;
@ -423,7 +424,7 @@ function _parseDevLogEntry(block, version, title) {
const filesModified = filesMatch const filesModified = filesMatch
? filesMatch[1] ? filesMatch[1]
.split(',') .split(',')
.map((f) => f.trim().replace(/^`|`$/g, '')) .map((f: any) => f.trim().replace(/^`|`$/g, ''))
.filter(Boolean) .filter(Boolean)
: []; : [];
@ -433,7 +434,7 @@ function _parseDevLogEntry(block, version, title) {
if (workMatch) { if (workMatch) {
const items = workMatch[1].match(/- \[[ x]\] .+/g); const items = workMatch[1].match(/- \[[ x]\] .+/g);
if (items) { if (items) {
workCompleted.push(...items.map((item) => item.replace(/^- \[[ x]\]\s*/, '').trim())); workCompleted.push(...items.map((item: any) => item.replace(/^- \[[ x]\]\s*/, '').trim()));
} }
} }
@ -454,7 +455,7 @@ function _parseDevLogEntry(block, version, title) {
* @param {string} content - The content to redact * @param {string} content - The content to redact
* @returns {string} - The redacted content * @returns {string} - The redacted content
*/ */
function redactSensitiveContent(content) { function redactSensitiveContent(content: any) {
if (!content) return content; if (!content) return content;
return ( return (
@ -493,7 +494,7 @@ function redactSensitiveContent(content) {
// ── Forgejo issues cache ────────────────────────────────────────────────────── // ── Forgejo issues cache ──────────────────────────────────────────────────────
const FORGEJO_BASE = 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker'; const FORGEJO_BASE = 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker';
let _forgejoCache = null; let _forgejoCache: any = null;
let _forgejoCacheTs = 0; let _forgejoCacheTs = 0;
const FORGEJO_TTL_MS = 5 * 60 * 1000; const FORGEJO_TTL_MS = 5 * 60 * 1000;
@ -524,7 +525,7 @@ router.get('/', requireAuth, requireAdmin, (req: Req, res: Res) => {
future: sanitizedFutureContent, future: sanitizedFutureContent,
developmentLog: sanitizedDevLogContent, developmentLog: sanitizedDevLogContent,
}); });
} catch (err) { } catch (err: any) {
// Generic error message to prevent path disclosure // Generic error message to prevent path disclosure
log.error('[aboutAdmin] Error reading files'); log.error('[aboutAdmin] Error reading files');
res.status(500).json({ res.status(500).json({
@ -546,7 +547,7 @@ router.get('/roadmap', requireAuth, requireAdmin, async (req: Req, res: Res) =>
_forgejoCache = { issues, fetchedAt: new Date().toISOString() }; _forgejoCache = { issues, fetchedAt: new Date().toISOString() };
_forgejoCacheTs = now; _forgejoCacheTs = now;
res.json(_forgejoCache); res.json(_forgejoCache);
} catch (err) { } catch (err: any) {
log.error('[aboutAdmin] Forgejo issues error:', err.message); log.error('[aboutAdmin] Forgejo issues error:', err.message);
if (_forgejoCache) return res.json({ ..._forgejoCache, stale: true }); if (_forgejoCache) return res.json({ ..._forgejoCache, stale: true });
res res
@ -562,7 +563,7 @@ router.get('/dev-log', requireAuth, requireAdmin, (req: Req, res: Res) => {
const sanitized = redactSensitiveContent(devLogContent); const sanitized = redactSensitiveContent(devLogContent);
const entries = parseDevLogMd(sanitized); const entries = parseDevLogMd(sanitized);
res.json({ entries, version: pkg.version }); res.json({ entries, version: pkg.version });
} catch (err) { } catch (err: any) {
log.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log'); log.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log');
res.status(500).json({ res.status(500).json({
error: 'Failed to read dev log data', error: 'Failed to read dev log data',
@ -578,7 +579,7 @@ router.post('/check-updates', requireAuth, requireAdmin, async (req: Req, res: R
try { try {
const result = await checkForUpdates(true); const result = await checkForUpdates(true);
res.json(result); res.json(result);
} catch (err) { } catch (err: any) {
res.status(500).json({ error: 'Update check failed' }); res.status(500).json({ error: 'Update check failed' });
} }
}); });

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 express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
@ -87,7 +86,7 @@ router.get('/deleted', (req: Req, res: Res) => {
) )
.all(req.user.id); .all(req.user.id);
res.json( res.json(
rows.map((row) => ({ rows.map((row: any) => ({
...serializeBill(row), ...serializeBill(row),
category_name: row.category_name, category_name: row.category_name,
deleted_at: row.deleted_at, deleted_at: row.deleted_at,
@ -137,7 +136,7 @@ router.put('/reorder', (req: Req, res: Res) => {
const update = db.prepare( const update = db.prepare(
"UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
); );
const applyOrder = db.transaction((items) => { const applyOrder = db.transaction((items: any) => {
for (const item of items) update.run(item.sortOrder, item.billId, req.user.id); for (const item of items) update.run(item.sortOrder, item.billId, req.user.id);
}); });
applyOrder(entries); applyOrder(entries);
@ -208,7 +207,7 @@ router.post('/:id/snooze-drift', (req: Req, res: Res) => {
// Bill templates store money fields (expected_amount, current_balance, minimum_payment) // Bill templates store money fields (expected_amount, current_balance, minimum_payment)
// in integer cents, matching validateBillData's normalized output. Convert back to // in integer cents, matching validateBillData's normalized output. Convert back to
// dollars for API responses, mirroring serializeBill. // dollars for API responses, mirroring serializeBill.
function serializeTemplateData(data) { function serializeTemplateData(data: any) {
if (!data) return data; if (!data) return data;
const out = { ...data }; const out = { ...data };
if (out.expected_amount != null) out.expected_amount = fromCents(out.expected_amount); if (out.expected_amount != null) out.expected_amount = fromCents(out.expected_amount);
@ -232,7 +231,7 @@ router.get('/templates', (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
res.json( res.json(
rows.map((row) => ({ rows.map((row: any) => ({
...row, ...row,
data: serializeTemplateData(parseTemplateData(row.data)), data: serializeTemplateData(parseTemplateData(row.data)),
})), })),
@ -812,7 +811,7 @@ router.get('/:id/transactions', (req: Req, res: Res) => {
) )
.all(billId, req.user.id, billId); .all(billId, req.user.id, billId);
const transactions = rows.map((row) => const transactions = rows.map((row: any) =>
decorateTransaction({ decorateTransaction({
...row, ...row,
linked_payment: row.linked_payment_id linked_payment: row.linked_payment_id
@ -1020,7 +1019,7 @@ router.post('/:id/history-ranges', (req: Req, res: Res) => {
'end_year', 'end_year',
); );
} }
if (ey != null) { if (ey != null && em != null) {
const startVal = sy * 12 + sm; const startVal = sy * 12 + sm;
const endVal = ey * 12 + em; const endVal = ey * 12 + em;
if (endVal < startVal) if (endVal < startVal)
@ -1174,7 +1173,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
current_balance: balance, current_balance: balance,
minimum_payment: minPmt, minimum_payment: minPmt,
}); });
const total_interest = schedule.reduce((s, r) => s + r.interest, 0); const total_interest = schedule.reduce((s: number, r: any) => s + r.interest, 0);
res.json({ res.json({
bill_id: billId, bill_id: billId,
@ -1185,7 +1184,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
summary: { summary: {
months: schedule.length, months: schedule.length,
total_interest: roundMoney(total_interest), total_interest: roundMoney(total_interest),
total_paid: sumMoney(schedule, (r) => r.payment), total_paid: sumMoney(schedule, (r: any) => r.payment),
capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0, capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0,
}, },
apr_snapshot, apr_snapshot,
@ -1258,14 +1257,14 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
// ── Merchant rule helpers ───────────────────────────────────────────────────── // ── Merchant rule helpers ─────────────────────────────────────────────────────
function requireBill(db, billId, userId) { function requireBill(db: any, billId: any, userId: any) {
return db return db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, userId); .get(billId, userId);
} }
// Count unmatched transactions that would match a normalized merchant string. // Count unmatched transactions that would match a normalized merchant string.
function previewMatchCount(db, userId, normalized) { function previewMatchCount(db: any, userId: any, normalized: any) {
if (!normalized || normalized.length < 2) return 0; if (!normalized || normalized.length < 2) return 0;
const txRows = db const txRows = db
.prepare( .prepare(
@ -1281,14 +1280,14 @@ function previewMatchCount(db, userId, normalized) {
`, `,
) )
.all(userId); .all(userId);
return txRows.filter((tx) => { return txRows.filter((tx: any) => {
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
return txMerchant && merchantMatches(txMerchant, normalized); return txMerchant && merchantMatches(txMerchant, normalized);
}).length; }).length;
} }
// Find bills (other than this one) that already claim this merchant. // Find bills (other than this one) that already claim this merchant.
function findConflicts(db, userId, billId, normalized) { function findConflicts(db: any, userId: any, billId: any, normalized: any) {
return db return db
.prepare( .prepare(
` `
@ -1336,7 +1335,7 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
`, `,
) )
.all(req.user.id) .all(req.user.id)
.map((tx) => { .map((tx: any) => {
const raw = tx.payee || tx.description || tx.memo || ''; const raw = tx.payee || tx.description || tx.memo || '';
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
return { return {
@ -1347,7 +1346,7 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
date: tx.posted_date || String(tx.transacted_at || '').slice(0, 10), date: tx.posted_date || String(tx.transacted_at || '').slice(0, 10),
}; };
}) })
.filter((s) => s.normalized.length >= 2); .filter((s: any) => s.normalized.length >= 2);
res.json({ rules, suggestions }); res.json({ rules, suggestions });
}); });
@ -1422,7 +1421,7 @@ router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
const rules = db const rules = db
.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?') .prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?')
.all(req.user.id, billId) .all(req.user.id, billId)
.map((r) => r.merchant); .map((r: any) => r.merchant);
if (rules.length === 0) return res.json({ candidates: [] }); if (rules.length === 0) return res.json({ candidates: [] });
@ -1455,14 +1454,14 @@ router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
'SELECT transaction_id FROM payments WHERE bill_id = ? AND transaction_id IS NOT NULL AND deleted_at IS NULL', 'SELECT transaction_id FROM payments WHERE bill_id = ? AND transaction_id IS NOT NULL AND deleted_at IS NULL',
) )
.all(billId) .all(billId)
.map((r) => r.transaction_id), .map((r: any) => r.transaction_id),
); );
const candidates = []; const candidates = [];
for (const tx of txRows) { for (const tx of txRows) {
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
if (!txMerchant) continue; if (!txMerchant) continue;
const matches = rules.some((r) => merchantMatches(txMerchant, r)); const matches = rules.some((r: any) => merchantMatches(txMerchant, r));
if (!matches) continue; if (!matches) continue;
const paidDate = const paidDate =
@ -1523,7 +1522,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
`); `);
let imported = 0; let imported = 0;
const lateAttributions = []; const lateAttributions: any[] = [];
try { try {
db.transaction(() => { db.transaction(() => {
@ -1578,7 +1577,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
} }
} }
})(); })();
} catch (err) { } catch (err: any) {
log.error('[import-historical] Transaction failed:', err.message); log.error('[import-historical] Transaction failed:', err.message);
throw new ApiError('DB_ERROR', 'Import failed', 500); throw new ApiError('DB_ERROR', 'Import failed', 500);
} }

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 express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
@ -23,7 +22,7 @@ const DEBT_LIKE_CLAUSES = `(
) )
)`; )`;
function isRamseyMode(userId) { function isRamseyMode(userId: any) {
const db = getDb(); const db = getDb();
const row = db const row = db
.prepare( .prepare(
@ -37,7 +36,7 @@ function isRamseyMode(userId) {
return row ? row.value !== 'false' && row.value !== '0' : true; return row ? row.value !== 'false' && row.value !== '0' : true;
} }
function getUserBoolSetting(userId, key, fallback = false) { function getUserBoolSetting(userId: any, key: string, fallback = false) {
const db = getDb(); const db = getDb();
const row = db const row = db
.prepare( .prepare(
@ -52,7 +51,7 @@ function getUserBoolSetting(userId, key, fallback = false) {
return row.value === 'true' || row.value === '1'; return row.value === 'true' || row.value === '1';
} }
function upsertUserSetting(db, userId, key, value) { function upsertUserSetting(db: any, userId: any, key: string, value: any) {
db.prepare( db.prepare(
` `
INSERT INTO user_settings (user_id, key, value, updated_at) INSERT INTO user_settings (user_id, key, value, updated_at)
@ -64,7 +63,7 @@ function upsertUserSetting(db, userId, key, value) {
).run(userId, key, String(value)); ).run(userId, key, String(value));
} }
function getDebtQuery(ramseyMode) { function getDebtQuery(ramseyMode: any) {
const orderBy = ramseyMode const orderBy = ramseyMode
? ` ? `
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC, CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
@ -89,7 +88,7 @@ function getDebtQuery(ramseyMode) {
`; `;
} }
function getDebtBills(userId, ramseyMode) { function getDebtBills(userId: any, ramseyMode?: any) {
const db = getDb(); const db = getDb();
const mode = ramseyMode !== undefined ? ramseyMode : isRamseyMode(userId); const mode = ramseyMode !== undefined ? ramseyMode : isRamseyMode(userId);
return db.prepare(getDebtQuery(mode)).all(userId); return db.prepare(getDebtQuery(mode)).all(userId);
@ -179,7 +178,7 @@ router.get('/projection', (req: Req, res: Res) => {
// Money fields on `bills` are stored as integer cents; the snowball/APR math // Money fields on `bills` are stored as integer cents; the snowball/APR math
// and the API response are dollar-denominated, so convert before computing. // and the API response are dollar-denominated, so convert before computing.
const billsForMath = bills.map((b) => ({ const billsForMath = bills.map((b: any) => ({
...b, ...b,
current_balance: fromCents(b.current_balance), current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment), minimum_payment: fromCents(b.minimum_payment),
@ -194,17 +193,17 @@ router.get('/projection', (req: Req, res: Res) => {
: fromCents(user?.snowball_extra_payment ?? 0); : fromCents(user?.snowball_extra_payment ?? 0);
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances) // Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
const aprByBill = {}; const aprByBill: Record<string, any> = {};
for (const b of billsForMath) { for (const b of billsForMath) {
const snap = debtAprSnapshot(b); const snap = debtAprSnapshot(b);
if (snap) aprByBill[b.id] = snap; if (snap) aprByBill[b.id] = snap;
} }
// Enrich each debt result with its APR snapshot // Enrich each debt result with its APR snapshot
function enrich(projection) { function enrich(projection: any) {
return { return {
...projection, ...projection,
debts: projection.debts.map((d) => ({ debts: projection.debts.map((d: any) => ({
...d, ...d,
apr_snapshot: aprByBill[d.id] ?? null, apr_snapshot: aprByBill[d.id] ?? null,
})), })),
@ -223,7 +222,7 @@ router.get('/projection', (req: Req, res: Res) => {
}); });
// Build a summary comparing snowball to the minimum-only baseline // Build a summary comparing snowball to the minimum-only baseline
function buildComparison(snowball, minimum_only) { function buildComparison(snowball: any, minimum_only: any) {
const sbMonths = snowball.months_to_freedom; const sbMonths = snowball.months_to_freedom;
const moMonths = minimum_only.months_to_freedom; const moMonths = minimum_only.months_to_freedom;
const sbInterest = snowball.total_interest_paid; const sbInterest = snowball.total_interest_paid;
@ -259,7 +258,7 @@ router.patch('/order', (req: Req, res: Res) => {
} }
// Validate every row before touching the DB — no silent skips // Validate every row before touching the DB — no silent skips
const parsed = []; const parsed: any[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const row = items[i]; const row = items[i];
const id = parseInt(row?.id, 10); const id = parseInt(row?.id, 10);
@ -292,7 +291,7 @@ router.patch('/order', (req: Req, res: Res) => {
// ── Snowball Plan helpers ───────────────────────────────────────────────────── // ── Snowball Plan helpers ─────────────────────────────────────────────────────
function enrichPlanWithProgress(db, plan) { function enrichPlanWithProgress(db: any, plan: any) {
let snapshot; let snapshot;
try { try {
snapshot = JSON.parse(plan.plan_snapshot); snapshot = JSON.parse(plan.plan_snapshot);
@ -302,8 +301,8 @@ function enrichPlanWithProgress(db, plan) {
// Fetch every snapshot bill in one query (user-scoped), not one-per-debt. // Fetch every snapshot bill in one query (user-scoped), not one-per-debt.
const snapDebts = snapshot?.debts ?? []; const snapDebts = snapshot?.debts ?? [];
const billIds = [...new Set(snapDebts.map((d) => d.bill_id).filter(Boolean))]; const billIds = [...new Set(snapDebts.map((d: any) => d.bill_id).filter(Boolean))];
const billsById = {}; const billsById: Record<string, any> = {};
if (billIds.length) { if (billIds.length) {
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
for (const b of db for (const b of db
@ -315,7 +314,7 @@ function enrichPlanWithProgress(db, plan) {
} }
} }
const currentDebts = snapDebts.map((d) => { const currentDebts = snapDebts.map((d: any) => {
const bill = billsById[d.bill_id]; const bill = billsById[d.bill_id];
const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null; const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null;
const startingBalance = d.starting_balance ?? 0; const startingBalance = d.starting_balance ?? 0;
@ -360,7 +359,7 @@ router.post('/plans', (req: Req, res: Res) => {
const ramseyMode = isRamseyMode(userId); const ramseyMode = isRamseyMode(userId);
const debts = getDebtBills(userId, ramseyMode); const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0); const activeDebts = debts.filter((b: any) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) { if (activeDebts.length === 0) {
throw ValidationError( throw ValidationError(
'No debts with a balance found. Add a balance to at least one bill.', 'No debts with a balance found. Add a balance to at least one bill.',
@ -371,7 +370,7 @@ router.post('/plans', (req: Req, res: Res) => {
// Money fields on `debts` are stored as integer cents; the snowball/APR // Money fields on `debts` are stored as integer cents; the snowball/APR
// math and plan_snapshot are dollar-denominated, so convert before computing. // math and plan_snapshot are dollar-denominated, so convert before computing.
const debtsForMath = debts.map((b) => ({ const debtsForMath = debts.map((b: any) => ({
...b, ...b,
current_balance: fromCents(b.current_balance), current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment), minimum_payment: fromCents(b.minimum_payment),
@ -393,8 +392,8 @@ router.post('/plans', (req: Req, res: Res) => {
100, 100,
); );
const debtSnaps = debtsForMath.map((b, i) => { const debtSnaps = debtsForMath.map((b: any, i: number) => {
const proj = snowball.debts?.find((d) => d.id === b.id); const proj = snowball.debts?.find((d: any) => d.id === b.id);
return { return {
bill_id: b.id, bill_id: b.id,
name: b.name, name: b.name,
@ -451,7 +450,7 @@ router.get('/plans', (req: Req, res: Res) => {
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) }); res.json({ plans: plans.map((p: any) => enrichPlanWithProgress(db, p)) });
}); });
// GET /api/snowball/plans/active — return the active or paused plan (or null) // GET /api/snowball/plans/active — return the active or paused plan (or null)
@ -495,7 +494,7 @@ router.patch('/plans/:id', (req: Req, res: Res) => {
// Shared status-transition handler for pause / resume / complete / abandon. // Shared status-transition handler for pause / resume / complete / abandon.
// `setSql` is a fixed constant per route (never user input). // `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, past }) { function transitionPlan(req: Req, res: Res, { allowedFrom, setSql, past }: any) {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {

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 express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
@ -15,7 +14,7 @@ const DEFAULT_PENDING_DAYS = 3;
// Build bank tracking summary when the user has enabled SimpleFIN bank tracking. // Build bank tracking summary when the user has enabled SimpleFIN bank tracking.
// Returns null when disabled, the account isn't found, or bank sync isn't set up. // Returns null when disabled, the account isn't found, or bank sync isn't set up.
function buildBankTrackingSummary(db, userId, year, month) { function buildBankTrackingSummary(db: any, userId: any, year: any, month: any) {
try { try {
const settings = getUserSettings(userId); const settings = getUserSettings(userId);
if (settings.bank_tracking_enabled !== 'true') return null; if (settings.bank_tracking_enabled !== 'true') return null;
@ -92,8 +91,8 @@ function buildBankTrackingSummary(db, userId, year, month) {
) )
.all(year, month, start, end, userId); .all(year, month, start, end, userId);
const unpaidTotalCents = unpaidCandidates const unpaidTotalCents = unpaidCandidates
.filter((row) => resolveDueDate(row, year, month)) .filter((row: any) => resolveDueDate(row, year, month))
.reduce((sum, row) => sum + (row.amount_cents || 0), 0); .reduce((sum: number, row: any) => sum + (row.amount_cents || 0), 0);
const balanceDollars = money(account.balance / 100); const balanceDollars = money(account.balance / 100);
const pendingDollars = fromCents(pendingRow.pending_total); const pendingDollars = fromCents(pendingRow.pending_total);
@ -116,13 +115,13 @@ function buildBankTrackingSummary(db, userId, year, month) {
unpaid_this_month: unpaidDollars, unpaid_this_month: unpaidDollars,
remaining: money(effectiveDollars - unpaidDollars), remaining: money(effectiveDollars - unpaidDollars),
}; };
} catch (err) { } catch (err: any) {
log.error('[buildBankTrackingSummary] Error:', err.message); log.error('[buildBankTrackingSummary] Error:', err.message);
return null; return null;
} }
} }
function parseYearMonth(source) { function parseYearMonth(source: any) {
const now = new Date(); const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10); const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10); const month = parseInt(source.month || now.getMonth() + 1, 10);
@ -137,12 +136,12 @@ function parseYearMonth(source) {
return { year, month }; return { year, month };
} }
function money(value) { function money(value: any) {
const n = Number(value); const n = Number(value);
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0; return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
} }
function getStartingAmounts(db, userId, year, month) { function getStartingAmounts(db: any, userId: any, year: any, month: any) {
const row = db const row = db
.prepare( .prepare(
` `
@ -160,7 +159,7 @@ function getStartingAmounts(db, userId, year, month) {
}; };
} }
function calculatePaidDeductions(db, userId, year, month) { function calculatePaidDeductions(db: any, userId: any, year: any, month: any) {
const { start, end } = getCycleRange(year, month); const { start, end } = getCycleRange(year, month);
// Paid from first bucket: bills with due_day 1-14 // Paid from first bucket: bills with due_day 1-14
@ -237,7 +236,7 @@ function calculatePaidDeductions(db, userId, year, month) {
}; };
} }
function buildStartingAmountsSummary(db, userId, year, month) { function buildStartingAmountsSummary(db: any, userId: any, year: any, month: any) {
const amounts = getStartingAmounts(db, userId, year, month); const amounts = getStartingAmounts(db, userId, year, month);
const paid = calculatePaidDeductions(db, userId, year, month); const paid = calculatePaidDeductions(db, userId, year, month);
@ -264,7 +263,7 @@ function buildStartingAmountsSummary(db, userId, year, month) {
}; };
} }
function getIncome(db, userId, year, month) { function getIncome(db: any, userId: any, year: any, month: any) {
const row = db const row = db
.prepare( .prepare(
` `
@ -282,7 +281,7 @@ function getIncome(db, userId, year, month) {
}; };
} }
function buildSummary(db, userId, year, month) { function buildSummary(db: any, userId: any, year: any, month: any) {
const income = getIncome(db, userId, year, month); const income = getIncome(db, userId, year, month);
const { start, end } = getCycleRange(year, month); const { start, end } = getCycleRange(year, month);
@ -321,9 +320,9 @@ function buildSummary(db, userId, year, month) {
// QA-B5-01: only bills that actually occur in this month (matches the Tracker's // QA-B5-01: only bills that actually occur in this month (matches the Tracker's
// resolveDueDate gating) — otherwise annual / off-month quarterly bills inflate // resolveDueDate gating) — otherwise annual / off-month quarterly bills inflate
// the monthly expense list and total, disagreeing with the Tracker. // the monthly expense list and total, disagreeing with the Tracker.
.filter((row) => resolveDueDate(row, year, month)); .filter((row: any) => resolveDueDate(row, year, month));
const billIds = billRows.map((row) => row.bill_id); const billIds = billRows.map((row: any) => row.bill_id);
const paymentMap = new Map(); const paymentMap = new Map();
if (billIds.length > 0) { if (billIds.length > 0) {
@ -353,7 +352,7 @@ function buildSummary(db, userId, year, month) {
} }
} }
const expenses = billRows.map((row) => { const expenses = billRows.map((row: any) => {
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 }; const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined; const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
const displayAmount = fromCents(hasActual ? row.actual_amount : row.expected_amount); const displayAmount = fromCents(hasActual ? row.actual_amount : row.expected_amount);
@ -378,13 +377,15 @@ function buildSummary(db, userId, year, month) {
}; };
}); });
const countedExpenses = expenses.filter((expense) => !expense.is_skipped); const countedExpenses = expenses.filter((expense: any) => !expense.is_skipped);
const incomeTotal = money(income.amount); const incomeTotal = money(income.amount);
const expenseTotal = money( const expenseTotal = money(
countedExpenses.reduce((sum, expense) => sum + expense.display_amount, 0), countedExpenses.reduce((sum: number, expense: any) => sum + expense.display_amount, 0),
); );
const paidTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.paid_amount, 0)); const paidTotal = money(
const paidExpenseCount = countedExpenses.filter((expense) => expense.is_paid).length; countedExpenses.reduce((sum: number, expense: any) => sum + expense.paid_amount, 0),
);
const paidExpenseCount = countedExpenses.filter((expense: any) => expense.is_paid).length;
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month); const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
const bank_tracking = buildBankTrackingSummary(db, userId, year, month); const bank_tracking = buildBankTrackingSummary(db, userId, year, month);
// When bank tracking is on, drive the "plan base" from the effective bank balance // When bank tracking is on, drive the "plan base" from the effective bank balance