// @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'; ('use strict'); const router = require('express').Router(); const { getDb } = require('../db/database.cts'); const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts'); const { decorateDataSource, ensureManualDataSource, } = require('../services/transactionService.cts'); const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource, } = require('../services/bankSyncService.cts'); const { sanitizeErrorMessage } = require('../services/simplefinService.cts'); const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); const { syncLimiter } = require('../middleware/rateLimiter.cts'); const VALID_TYPES = new Set(['manual', 'file_import', 'provider_sync']); const VALID_STATUSES = new Set(['active', 'inactive', 'error']); function cleanFilter(value) { return typeof value === 'string' ? value.trim() : ''; } // 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 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 ────────────────────────────────────── // Returns all financial accounts for the user across all sources. // Used by the bank tracking account picker. router.get('/accounts/all', (req: Req, res: Res) => { const db = getDb(); const accounts = db .prepare( ` SELECT fa.id, fa.name, fa.org_name, fa.account_type, fa.balance, fa.available_balance, fa.currency, fa.monitored, ds.id AS source_id FROM financial_accounts fa JOIN data_sources ds ON ds.id = fa.data_source_id WHERE fa.user_id = ? ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC `, ) .all(req.user.id); res.json( accounts.map((a) => ({ ...a, monitored: a.monitored === 1, balance_dollars: a.balance !== null ? a.balance / 100 : null, })), ); }); // ─── GET /api/data-sources ──────────────────────────────────────────────────── router.get('/', (req: Req, res: Res) => { const db = getDb(); ensureManualDataSource(db, req.user.id); const type = cleanFilter(req.query.type); const status = cleanFilter(req.query.status); if (type && !VALID_TYPES.has(type)) throw ValidationError('type must be manual, file_import, or provider_sync', 'type'); if (status && !VALID_STATUSES.has(status)) throw ValidationError('status must be active, inactive, or error', 'status'); let query = ` SELECT ds.id, ds.user_id, ds.type, ds.provider, ds.name, ds.status, ds.config_json, ds.last_sync_at, ds.last_error, ds.created_at, ds.updated_at, COUNT(DISTINCT fa.id) AS account_count, COUNT(DISTINCT t.id) AS transaction_count FROM data_sources ds LEFT JOIN financial_accounts fa ON fa.data_source_id = ds.id AND fa.user_id = ds.user_id LEFT JOIN transactions t ON t.data_source_id = ds.id AND t.user_id = ds.user_id WHERE ds.user_id = ? `; const params = [req.user.id]; if (type) { query += ' AND ds.type = ?'; params.push(type); } if (status) { query += ' AND ds.status = ?'; params.push(status); } query += ` GROUP BY ds.id ORDER BY CASE WHEN ds.type = 'manual' THEN 0 ELSE 1 END, ds.name COLLATE NOCASE ASC `; res.json( db .prepare(query) .all(...params) .map(decorateDataSource), ); }); // ─── GET /api/data-sources/simplefin/status ────────────────────────────────── router.get('/simplefin/status', (req: Req, res: Res) => { const { enabled, sync_days, seed_days } = getBankSyncConfig(); const db = getDb(); const hasConnections = !!db .prepare( "SELECT 1 FROM data_sources WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin' LIMIT 1", ) .get(req.user.id); const hasMerchantRules = !!db .prepare('SELECT 1 FROM bill_merchant_rules WHERE user_id = ? LIMIT 1') .get(req.user.id); const timezone = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || null; res.json({ enabled, sync_days, seed_days, has_connections: hasConnections, has_merchant_rules: hasMerchantRules, timezone, }); }); // ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── router.post('/simplefin/connect', async (req: Req, res: Res) => { requireBankSyncEnabled(); const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : ''; if (!setupToken) { throw ValidationError('setupToken is required', 'setupToken'); } try { const db = getDb(); const result = await connectSimplefin(db, req.user.id, setupToken); res.status(201).json(result); } catch (err) { throw toSourceError(err, 'Failed to connect SimpleFIN'); } }); // ─── GET /api/data-sources/:sourceId/accounts ──────────────────────────────── router.get('/:sourceId/accounts', (req: Req, res: Res) => { const sourceId = parseInt(req.params.sourceId, 10); if (!Number.isInteger(sourceId) || sourceId < 1) { throw ValidationError('Invalid data source id', 'sourceId'); } const db = getDb(); const source = db .prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?') .get(sourceId, req.user.id); if (!source) throw NotFoundError('Data source not found'); const accounts = db .prepare( ` SELECT fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type, fa.balance, fa.available_balance, fa.currency, fa.monitored, fa.created_at, fa.updated_at, COUNT(t.id) AS transaction_count FROM financial_accounts fa LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id WHERE fa.data_source_id = ? AND fa.user_id = ? GROUP BY fa.id ORDER BY fa.name COLLATE NOCASE ASC `, ) .all(sourceId, req.user.id); const txStmt = db.prepare(` SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency, t.payee, t.description, t.memo, t.match_status, t.ignored, t.matched_bill_id, b.name AS matched_bill_name FROM transactions t LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL WHERE t.account_id = ? AND t.user_id = ? ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC LIMIT 50 `); const result = accounts.map((acc) => ({ ...acc, monitored: acc.monitored === 1, transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [], })); res.json(result); }); // ─── PUT /api/data-sources/:sourceId/accounts/:accountId ───────────────────── router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => { const sourceId = parseInt(req.params.sourceId, 10); const accountId = parseInt(req.params.accountId, 10); if ( !Number.isInteger(sourceId) || sourceId < 1 || !Number.isInteger(accountId) || accountId < 1 ) { throw ValidationError('Invalid id'); } if (typeof req.body?.monitored !== 'boolean') { throw ValidationError('monitored must be a boolean', 'monitored'); } const db = getDb(); const result = db .prepare( ` UPDATE financial_accounts SET monitored = ?, updated_at = datetime('now') WHERE id = ? AND data_source_id = ? AND user_id = ? `, ) .run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); 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 }); }); // ─── POST /api/data-sources/:id/sync ───────────────────────────────────────── router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { requireBankSyncEnabled(); const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id < 1) { throw ValidationError('Invalid data source id', 'id'); } try { const db = getDb(); const result = await syncDataSource(db, req.user.id, id); res.json(result); } catch (err) { throw toSourceError(err, 'Sync failed'); } }); // ─── POST /api/data-sources/sync-all ───────────────────────────────────────── // Syncs every SimpleFIN source for the current user. Returns aggregated stats. router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { requireBankSyncEnabled(); try { const db = getDb(); const sources = db .prepare( "SELECT id FROM data_sources WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'", ) .all(req.user.id); if (sources.length === 0) { throw NotFoundError('No SimpleFIN connections found'); } let accountsUpserted = 0; let transactionsNew = 0; let transactionsSkip = 0; let autoMatched = 0; const matchedBillSet = new Set(); const lateAttrAll = []; const errors = []; for (const source of sources) { try { const result = await syncDataSource(db, req.user.id, source.id); accountsUpserted += result.accountsUpserted ?? 0; transactionsNew += result.transactionsNew ?? 0; transactionsSkip += result.transactionsSkip ?? 0; autoMatched += result.autoMatched ?? 0; for (const name of result.matched_bills ?? []) matchedBillSet.add(name); for (const attr of result.late_attributions ?? []) lateAttrAll.push(attr); } catch (err) { errors.push(sanitizeErrorMessage(err?.message || 'Sync failed')); } } res.json({ accounts_upserted: accountsUpserted, transactions_new: transactionsNew, transactions_skip: transactionsSkip, auto_matched: autoMatched, matched_bills: [...matchedBillSet], late_attributions: lateAttrAll, errors, }); } catch (err) { throw toSourceError(err, 'Sync failed'); } }); // ─── POST /api/data-sources/:id/backfill ───────────────────────────────────── router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => { requireBankSyncEnabled(); const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id < 1) { throw ValidationError('Invalid data source id', 'id'); } try { const db = getDb(); const result = await backfillDataSource(db, req.user.id, id); res.json(result); } catch (err) { throw toSourceError(err, 'Backfill failed'); } }); // ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── router.delete('/:id', (req: Req, res: Res) => { requireBankSyncEnabled(); const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id < 1) { throw ValidationError('Invalid data source id', 'id'); } try { disconnectDataSource(getDb(), req.user.id, id); res.json({ ok: true }); } catch (err) { throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR'); } }); module.exports = router;