BillTracker/routes/dataSources.cts

369 lines
12 KiB
TypeScript
Raw Normal View History

import type { Req, Res, Next } from '../types/http';
('use strict');
2026-05-16 20:26:09 -05:00
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']);
2026-05-16 20:26:09 -05:00
const VALID_STATUSES = new Set(['active', 'inactive', 'error']);
function cleanFilter(value: any) {
2026-05-16 20:26:09 -05:00
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: any, fallback: string, 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: any) => ({
...a,
monitored: a.monitored === 1,
balance_dollars: a.balance !== null ? a.balance / 100 : null,
})),
);
});
// ─── GET /api/data-sources ────────────────────────────────────────────────────
router.get('/', (req: Req, res: Res) => {
2026-05-16 20:26:09 -05:00
const db = getDb();
ensureManualDataSource(db, req.user.id);
const type = cleanFilter(req.query.type);
2026-05-16 20:26:09 -05:00
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');
2026-05-16 20:26:09 -05:00
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
2026-05-16 20:26:09 -05:00
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
2026-05-16 20:26:09 -05:00
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);
}
2026-05-16 20:26:09 -05:00
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),
);
2026-05-16 20:26:09 -05:00
});
// ─── 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: any) => ({
...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: any) {
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');
}
});
2026-05-16 20:26:09 -05:00
module.exports = router;