330 lines
13 KiB
JavaScript
330 lines
13 KiB
JavaScript
'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, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService');
|
|
const { sanitizeErrorMessage } = require('../services/simplefinService');
|
|
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
|
const { syncLimiter } = require('../middleware/rateLimiter');
|
|
|
|
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/accounts/all ──────────────────────────────────────
|
|
// Returns all financial accounts for the user across all sources.
|
|
// Used by the bank tracking account picker.
|
|
|
|
router.get('/accounts/all', (req, res) => {
|
|
try {
|
|
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,
|
|
})));
|
|
} catch (err) {
|
|
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
|
|
}
|
|
});
|
|
|
|
// ─── 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();
|
|
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);
|
|
|
|
res.json({ enabled, sync_days, has_connections: hasConnections, has_merchant_rules: hasMerchantRules });
|
|
});
|
|
|
|
// ─── 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 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);
|
|
} 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', syncLimiter, 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'));
|
|
}
|
|
});
|
|
|
|
// ─── POST /api/data-sources/sync-all ─────────────────────────────────────────
|
|
// Syncs every SimpleFIN source for the current user. Returns aggregated stats.
|
|
|
|
router.post('/sync-all', syncLimiter, async (req, res) => {
|
|
if (!getBankSyncConfig().enabled) {
|
|
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
|
}
|
|
|
|
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) {
|
|
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_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) {
|
|
const { msg, status } = safeError(err, 'Sync failed');
|
|
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
|
|
}
|
|
});
|
|
|
|
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
|
|
|
|
router.post('/:id/backfill', syncLimiter, 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 backfillDataSource(db, req.user.id, id);
|
|
res.json(result);
|
|
} catch (err) {
|
|
const { msg, status } = safeError(err, 'Backfill 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;
|