'use strict'; const router = require('express').Router(); const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService'); const { connectSimplefin, syncDataSource, disconnectDataSource } = require('../services/bankSyncService'); const { sanitizeErrorMessage } = require('../services/simplefinService'); const { getBankSyncConfig } = require('../services/bankSyncConfigService'); 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() : ''; } function safeError(err, fallback) { const msg = sanitizeErrorMessage(err?.message || fallback); const status = typeof err?.status === 'number' ? err.status : 500; return { msg, status }; } // ─── GET /api/data-sources ──────────────────────────────────────────────────── router.get('/', (req, 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)) return res.status(400).json(standardizeError('type must be manual, file_import, or provider_sync', 'VALIDATION_ERROR', 'type')); if (status && !VALID_STATUSES.has(status)) return res.status(400).json(standardizeError('status must be active, inactive, or error', 'VALIDATION_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, res) => { const { enabled, sync_days } = getBankSyncConfig(); res.json({ enabled, sync_days }); }); // ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── router.post('/simplefin/connect', async (req, res) => { if (!getBankSyncConfig().enabled) { return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); } 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')); } try { const db = getDb(); 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')); } }); // ─── GET /api/data-sources/:sourceId/accounts ──────────────────────────────── router.get('/:sourceId/accounts', (req, 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')); } 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')); 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 id, posted_date, transacted_at, amount, currency, payee, description, memo, match_status, ignored FROM transactions WHERE account_id = ? AND user_id = ? ORDER BY COALESCE(posted_date, substr(transacted_at, 1, 10), created_at) DESC, id DESC LIMIT 50 `); const result = accounts.map(acc => ({ ...acc, monitored: acc.monitored === 1, transactions: txStmt.all(acc.id, req.user.id), })); 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 ───────────────────── router.put('/:sourceId/accounts/:accountId', (req, 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) { return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); } if (typeof req.body?.monitored !== 'boolean') { return res.status(400).json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored')); } try { 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) return res.status(404).json(standardizeError('Account not found', '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', async (req, res) => { if (!getBankSyncConfig().enabled) { return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); } 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')); } try { const db = getDb(); 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')); } }); // ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── router.delete('/:id', (req, res) => { if (!getBankSyncConfig().enabled) { return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); } 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')); } 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')); } }); module.exports = router;