feat: SimpleFin bank sync with encrypted token storage

New services:
  services/encryptionService.js — AES-256-GCM with SHA-256 derived key
  services/simplefinService.js — protocol layer: claim token, fetch accounts/transactions, normalize to DB shapes
  services/bankSyncService.js — orchestration: connect, sync, disconnect with encrypted access URL storage

Modified:
  routes/dataSources.js — status, connect, sync, disconnect endpoints (gate on BANK_SYNC_ENABLED=true)
  client/api.js — simplefinStatus, connectSimplefin, syncDataSource, deleteDataSource, dataSources
  client/pages/SettingsPage.jsx — BankSyncSection with connected account info, sync/disconnect actions, setup token input
  .env.example — BANK_SYNC_ENABLED, TOKEN_ENCRYPTION_KEY, SIMPLEFIN_APP_NAME
This commit is contained in:
null 2026-05-28 21:30:20 -05:00
parent 994b5c1e17
commit 42abb12497
7 changed files with 769 additions and 24 deletions

View File

@ -37,6 +37,19 @@ NODE_ENV=production
# DB_PATH=/opt/bill-tracker/data/db/bills.db
# BACKUP_PATH=/opt/bill-tracker/data/backups
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Optional. Disabled by default. Requires a SimpleFIN Bridge account.
# Users connect their own SimpleFIN Bridge — BillTracker never stores bank credentials.
#
# BANK_SYNC_ENABLED=false
#
# Required when BANK_SYNC_ENABLED=true. Must be at least 32 characters.
# Used to encrypt the SimpleFIN Access URL at rest.
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-secret-at-least-32-chars
#
# How many days back to fetch transactions on first sync (default: 90).
# SIMPLEFIN_SYNC_DAYS=90
# ── First-run admin account ────────────────────────────────────────────────────
# Set BOTH on first start to create the admin account automatically.
# Remove or comment out after the server has started once — they are not

View File

@ -302,6 +302,13 @@ export const api = {
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`),
// Data sources & SimpleFIN bank sync
dataSources: (params = {}) => get(`/data-sources${queryString(params)}`),
simplefinStatus: () => get('/data-sources/simplefin/status'),
connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }),
syncDataSource: (id) => post(`/data-sources/${id}/sync`),
deleteDataSource: (id) => del(`/data-sources/${id}`),
// User SQLite import
previewUserDbImport: async (file) => {
const res = await fetch('/api/import/user-db/preview', {

View File

@ -1,11 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Sun, Moon, Users, AlertCircle, RefreshCw } from 'lucide-react';
import { AlertCircle, Building2, Link2Off, Loader2, Moon, RefreshCw, Sun, Users } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
@ -208,6 +212,239 @@ function SettingsSkeleton() {
);
}
// Bank Sync Section
function BankSyncSection() {
const [enabled, setEnabled] = useState(null); // null = loading
const [connections, setConnections] = useState([]);
const [loadError, setLoadError] = useState('');
const [setupToken, setSetupToken] = useState('');
const [connecting, setConnecting] = useState(false);
const [syncing, setSyncing] = useState(null); // id being synced
const [disconnectTarget, setDisconnectTarget] = useState(null);
const [disconnecting, setDisconnecting] = useState(false);
const load = useCallback(async () => {
setLoadError('');
try {
const [status, sources] = await Promise.all([
api.simplefinStatus(),
api.dataSources({ type: 'provider_sync' }),
]);
setEnabled(status.enabled);
setConnections(Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []);
} catch (err) {
setEnabled(false);
setLoadError(err.message || 'Failed to load bank sync status');
}
}, []);
useEffect(() => { load(); }, [load]);
const handleConnect = async () => {
const token = setupToken.trim();
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
setConnecting(true);
try {
const result = await api.connectSimplefin(token);
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
setSetupToken('');
await load();
} catch (err) {
toast.error(err.message || 'Failed to connect SimpleFIN');
} finally {
setConnecting(false);
}
};
const handleSync = async (id) => {
setSyncing(id);
try {
const result = await api.syncDataSource(id);
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
await load();
} catch (err) {
toast.error(err.message || 'Sync failed');
await load();
} finally {
setSyncing(null);
}
};
const handleDisconnect = async () => {
if (!disconnectTarget) return;
setDisconnecting(true);
try {
await api.deleteDataSource(disconnectTarget.id);
toast.success('SimpleFIN disconnected.');
setDisconnectTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to disconnect');
} finally {
setDisconnecting(false);
}
};
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
if (enabled === null) {
return (
<SectionCard title="Bank Sync">
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading
</div>
</SectionCard>
);
}
return (
<>
<SectionCard title="Bank Sync">
{!enabled ? (
<div className="px-6 py-5 text-sm text-muted-foreground">
Bank sync is not enabled on this server.
Set <code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">BANK_SYNC_ENABLED=true</code> and
a <code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">TOKEN_ENCRYPTION_KEY</code> in your
environment to enable SimpleFIN.
</div>
) : (
<>
{loadError && (
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
)}
{/* Connected accounts */}
{connections.length > 0 && connections.map(conn => (
<div key={conn.id} className="px-6 py-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{conn.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm" variant="outline"
onClick={() => handleSync(conn.id)}
disabled={syncing === conn.id}
className="h-8 text-xs gap-1.5"
>
{syncing === conn.id
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
</Button>
<Button
size="sm" variant="ghost"
onClick={() => setDisconnectTarget(conn)}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
>
<Link2Off className="h-3.5 w-3.5" />
Disconnect
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs">
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">Last sync</p>
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
</div>
<div className={cn(
'rounded-lg border px-3 py-2',
conn.last_error ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
)}>
<p className="text-muted-foreground">Status</p>
<p className={cn('font-medium mt-0.5', conn.last_error ? 'text-destructive' : '')}>
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
</p>
</div>
</div>
</div>
))}
{/* Connect form */}
{connections.length === 0 && (
<div className="px-6 py-5 space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL no bank credentials are saved.</p>
</div>
<div className="flex gap-2">
<Input
value={setupToken}
onChange={e => setSetupToken(e.target.value)}
placeholder="Paste SimpleFIN setup token…"
className="flex-1 font-mono text-xs"
/>
<Button
onClick={handleConnect}
disabled={connecting || !setupToken.trim()}
className="shrink-0"
>
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting</> : 'Connect'}
</Button>
</div>
</div>
)}
{/* Add another connection — only show if already connected */}
{connections.length > 0 && (
<div className="px-6 py-4 border-t border-border/50 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
<div className="flex gap-2">
<Input
value={setupToken}
onChange={e => setSetupToken(e.target.value)}
placeholder="Paste SimpleFIN setup token…"
className="flex-1 font-mono text-xs"
/>
<Button
size="sm"
onClick={handleConnect}
disabled={connecting || !setupToken.trim()}
className="shrink-0"
>
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting</> : 'Connect'}
</Button>
</div>
</div>
)}
</>
)}
</SectionCard>
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disconnect SimpleFIN?</AlertDialogTitle>
<AlertDialogDescription>
This removes the connection and deletes synced accounts. Previously synced transactions
are kept but will no longer be associated with a data source.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={disconnecting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// SettingsPage
export default function SettingsPage() {
@ -339,6 +576,9 @@ export default function SettingsPage() {
</SettingRow>
</SectionCard>
{/* Bank Sync */}
<BankSyncSection />
{/* Save button — right-aligned below all cards */}
<div className="flex justify-end mt-6">
<Button size="sm" onClick={handleSave} disabled={saving}>

View File

@ -1,51 +1,54 @@
const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
'use strict';
const VALID_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
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 BANK_SYNC_ENABLED = process.env.BANK_SYNC_ENABLED === 'true';
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() : '';
}
// GET /api/data-sources?type=&status=
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 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'));
}
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
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
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);
}
if (type) { query += ' AND ds.type = ?'; params.push(type); }
if (status) { query += ' AND ds.status = ?'; params.push(status); }
query += `
GROUP BY ds.id
@ -57,4 +60,75 @@ router.get('/', (req, res) => {
res.json(db.prepare(query).all(...params).map(decorateDataSource));
});
// ─── GET /api/data-sources/simplefin/status ──────────────────────────────────
router.get('/simplefin/status', (req, res) => {
res.json({ enabled: BANK_SYNC_ENABLED });
});
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req, res) => {
if (!BANK_SYNC_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'));
}
});
// ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', async (req, res) => {
if (!BANK_SYNC_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 (!BANK_SYNC_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;

195
services/bankSyncService.js Normal file
View File

@ -0,0 +1,195 @@
'use strict';
const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService');
const {
claimSetupToken,
fetchAccountsAndTransactions,
normalizeAccount,
normalizeTransaction,
sanitizeErrorMessage,
} = require('./simplefinService');
const { decorateDataSource } = require('./transactionService');
const SYNC_DAYS_DEFAULT = 90;
function syncDaysBack() {
const n = parseInt(process.env.SIMPLEFIN_SYNC_DAYS, 10);
return Number.isFinite(n) && n > 0 ? n : SYNC_DAYS_DEFAULT;
}
function sinceEpoch(dataSource) {
if (dataSource.last_sync_at) {
// Overlap by 2 days to catch late-posted transactions
const ts = new Date(dataSource.last_sync_at).getTime();
if (Number.isFinite(ts)) return Math.floor((ts - 2 * 86400 * 1000) / 1000);
}
return Math.floor((Date.now() - syncDaysBack() * 86400 * 1000) / 1000);
}
function safeErrorMessage(err) {
return sanitizeErrorMessage(err?.message || String(err || 'Sync failed'));
}
// Upsert a single financial account, return the local row.
function upsertAccount(db, accountRow) {
const existing = db.prepare(`
SELECT id FROM financial_accounts
WHERE data_source_id = ? AND provider_account_id = ? AND user_id = ?
`).get(accountRow.data_source_id, accountRow.provider_account_id, accountRow.user_id);
if (existing) {
db.prepare(`
UPDATE financial_accounts
SET name = ?, org_name = ?, currency = ?, balance = ?, available_balance = ?,
raw_data = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
accountRow.name, accountRow.org_name, accountRow.currency,
accountRow.balance, accountRow.available_balance, accountRow.raw_data,
existing.id,
);
return existing.id;
}
const result = db.prepare(`
INSERT INTO financial_accounts
(user_id, data_source_id, provider_account_id, name, org_name, account_type,
currency, balance, available_balance, raw_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
accountRow.user_id, accountRow.data_source_id, accountRow.provider_account_id,
accountRow.name, accountRow.org_name, accountRow.account_type,
accountRow.currency, accountRow.balance, accountRow.available_balance, accountRow.raw_data,
);
return result.lastInsertRowid;
}
// Insert a transaction, ignoring duplicates (unique index on data_source_id + provider_transaction_id).
function insertTransactionIfNew(db, txRow) {
try {
db.prepare(`
INSERT INTO transactions
(user_id, data_source_id, account_id, provider_transaction_id,
source_type, posted_date, transacted_at, amount, currency,
description, payee, memo, match_status, ignored, raw_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
txRow.user_id, txRow.data_source_id, txRow.account_id, txRow.provider_transaction_id,
txRow.source_type, txRow.posted_date, txRow.transacted_at, txRow.amount, txRow.currency,
txRow.description, txRow.payee, txRow.memo, txRow.match_status, txRow.ignored, txRow.raw_data,
);
return 'inserted';
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message || '').includes('UNIQUE')) {
return 'skipped';
}
throw err;
}
}
async function runSync(db, userId, dataSource) {
const accessUrl = decryptSecret(dataSource.encrypted_secret);
const since = sinceEpoch(dataSource);
const raw = await fetchAccountsAndTransactions(accessUrl, since);
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
let accountsUpserted = 0;
let transactionsNew = 0;
let transactionsSkip = 0;
for (const rawAccount of accounts) {
const accountRow = normalizeAccount(rawAccount, dataSource.id, userId);
const localAccId = upsertAccount(db, accountRow);
accountsUpserted += 1;
for (const rawTx of (rawAccount.transactions || [])) {
const txRow = normalizeTransaction(
rawTx, localAccId, dataSource.id, userId, dataSource.id, rawAccount.id,
);
const outcome = insertTransactionIfNew(db, txRow);
if (outcome === 'inserted') transactionsNew += 1;
else transactionsSkip += 1;
}
}
db.prepare(`
UPDATE data_sources
SET last_sync_at = datetime('now'), last_error = NULL, status = 'active', updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(dataSource.id, userId);
return { accountsUpserted, transactionsNew, transactionsSkip };
}
// ─── Public API ───────────────────────────────────────────────────────────────
async function connectSimplefin(db, userId, setupToken) {
assertEncryptionReady();
const accessUrl = await claimSetupToken(setupToken);
const encrypted = encryptSecret(accessUrl);
const result = db.prepare(`
INSERT INTO data_sources (user_id, type, provider, name, status, encrypted_secret)
VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active', ?)
`).run(userId, encrypted);
const dataSourceId = result.lastInsertRowid;
const dataSource = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
let syncResult = { accountsUpserted: 0, transactionsNew: 0, transactionsSkip: 0 };
try {
syncResult = await runSync(db, userId, dataSource);
} catch (err) {
const msg = safeErrorMessage(err);
db.prepare(`
UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now')
WHERE id = ?
`).run(msg, dataSourceId);
}
const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
return { dataSource: decorateDataSource(fresh), ...syncResult };
}
async function syncDataSource(db, userId, dataSourceId) {
assertEncryptionReady();
const dataSource = db.prepare(`
SELECT * FROM data_sources
WHERE id = ? AND user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
`).get(dataSourceId, userId);
if (!dataSource) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 });
if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection');
let syncResult;
try {
syncResult = await runSync(db, userId, dataSource);
} catch (err) {
const msg = safeErrorMessage(err);
db.prepare(`
UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now')
WHERE id = ?
`).run(msg, dataSourceId);
// Re-throw so the route can surface a meaningful error
throw err;
}
const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
return { dataSource: decorateDataSource(fresh), ...syncResult };
}
function disconnectDataSource(db, userId, dataSourceId) {
const row = db.prepare(`
SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin'
`).get(dataSourceId, userId);
if (!row) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 });
// Financial accounts cascade-delete. Transactions get data_source_id = NULL (SET NULL FK).
db.prepare('DELETE FROM data_sources WHERE id = ? AND user_id = ?').run(dataSourceId, userId);
}
module.exports = { connectSimplefin, syncDataSource, disconnectDataSource };

View File

@ -0,0 +1,43 @@
'use strict';
const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const TAG_BYTES = 16;
function getKey() {
const raw = process.env.TOKEN_ENCRYPTION_KEY || '';
if (!raw) throw new Error('TOKEN_ENCRYPTION_KEY is not set');
const buf = Buffer.from(raw, 'utf8');
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
return crypto.createHash('sha256').update(buf).digest();
}
function assertEncryptionReady() {
getKey();
}
function encryptSecret(plaintext) {
const key = getKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
}
function decryptSecret(stored) {
const parts = stored.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
const [ivHex, tagHex, ctHex] = parts;
const key = getKey();
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ct = Buffer.from(ctHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
module.exports = { assertEncryptionReady, encryptSecret, decryptSecret };

View File

@ -0,0 +1,173 @@
'use strict';
// SimpleFIN consumer client.
//
// This module handles the protocol-level work: claiming tokens, fetching
// accounts/transactions, and normalizing the raw data into DB row shapes.
//
// Security rules enforced here:
// - Only HTTPS claim URLs are accepted.
// - Only HTTPS Access URLs are accepted.
// - Tokens and Access URLs are never logged.
// - Error messages are sanitized before being returned to callers.
function sanitizeErrorMessage(msg) {
if (typeof msg !== 'string') return 'Provider error';
// Strip embedded HTTP Basic Auth credentials (https://user:pass@host)
return msg.replace(/https?:\/\/[^@\s]+:[^@\s]+@/gi, 'https://[credentials]@');
}
function sanitizeError(err) {
const msg = sanitizeErrorMessage(err?.message || String(err || 'Unknown error'));
const e = new Error(msg);
e.code = err?.code || 'SIMPLEFIN_ERROR';
e.status = err?.status;
return e;
}
function sanitizeRawData(obj) {
if (!obj || typeof obj !== 'object') return null;
const safe = JSON.parse(JSON.stringify(obj));
// Remove org sfin-url which may embed auth
if (safe.org) delete safe.org['sfin-url'];
try {
return JSON.stringify(safe).slice(0, 4096);
} catch {
return null;
}
}
async function claimSetupToken(setupToken) {
if (!setupToken || typeof setupToken !== 'string') {
throw new Error('setupToken is required');
}
const token = setupToken.trim();
// Base64-decode to get the claim URL; handle both raw base64 and data-URLs
let claimUrl;
try {
const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
claimUrl = decoded.startsWith('http') ? decoded : token;
} catch {
claimUrl = token;
}
// Also accept a raw URL pasted directly
if (!claimUrl.startsWith('http')) {
throw new Error('Could not decode setup token into a claim URL');
}
if (!claimUrl.startsWith('https://')) {
throw new Error('Setup token must use a secure HTTPS URL');
}
let accessUrl;
try {
const res = await fetch(claimUrl, { method: 'POST' });
if (res.status === 403) {
throw new Error('This setup token has already been claimed or is invalid');
}
if (!res.ok) {
throw new Error(`Token claim failed (HTTP ${res.status})`);
}
accessUrl = (await res.text()).trim();
} catch (err) {
throw sanitizeError(err);
}
if (!accessUrl.startsWith('https://')) {
throw new Error('Provider returned an insecure Access URL — only HTTPS is supported');
}
return accessUrl;
}
async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) {
let url;
try {
url = new URL(accessUrl);
} catch {
throw new Error('Invalid Access URL');
}
const basicAuth = Buffer.from(`${url.username}:${url.password}`).toString('base64');
const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/?$/, '')}`;
const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}`;
let data;
try {
const res = await fetch(endpoint, {
headers: { 'Authorization': `Basic ${basicAuth}` },
});
if (res.status === 403) {
throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), { code: 'SIMPLEFIN_REVOKED' });
}
if (!res.ok) {
throw new Error(`SimpleFIN fetch failed (HTTP ${res.status})`);
}
data = await res.json();
} catch (err) {
throw sanitizeError(err);
}
return data;
}
function normalizeAccount(rawAccount, dataSourceId, userId) {
const balance = rawAccount.balance != null ? Math.round(parseFloat(rawAccount.balance) * 100) : null;
const availableBalance = rawAccount['available-balance'] != null ? Math.round(parseFloat(rawAccount['available-balance']) * 100) : null;
return {
user_id: userId,
data_source_id: dataSourceId,
provider_account_id: String(rawAccount.id),
name: String(rawAccount.name || 'Account').slice(0, 255),
org_name: rawAccount.org?.name ? String(rawAccount.org.name).slice(0, 255) : null,
account_type: null,
currency: rawAccount.currency ? String(rawAccount.currency).slice(0, 10) : 'USD',
balance: Number.isFinite(balance) ? balance : null,
available_balance: Number.isFinite(availableBalance) ? availableBalance : null,
raw_data: sanitizeRawData(rawAccount),
};
}
function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connId, accountId) {
const amount = Math.round(parseFloat(rawTx.amount) * 100);
const postedDate = rawTx.posted
? new Date(rawTx.posted * 1000).toISOString().slice(0, 10)
: null;
const transactedAt = rawTx['transacted_at']
? new Date(rawTx['transacted_at'] * 1000).toISOString()
: null;
// Format: simplefin:{data_source_id}:{simplefin_account_id}:{tx.id}
const providerTxId = `simplefin:${connId}:${accountId}:${rawTx.id}`;
return {
user_id: userId,
data_source_id: dataSourceId,
account_id: localAccountId,
provider_transaction_id: providerTxId,
source_type: 'provider_sync',
posted_date: postedDate,
transacted_at: transactedAt,
amount: Number.isFinite(amount) ? amount : 0,
currency: 'USD',
description: rawTx.description ? String(rawTx.description).slice(0, 500) : null,
payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null,
memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null,
match_status: 'unmatched',
ignored: 0,
raw_data: sanitizeRawData(rawTx),
};
}
module.exports = {
claimSetupToken,
fetchAccountsAndTransactions,
normalizeAccount,
normalizeTransaction,
sanitizeError,
sanitizeErrorMessage,
};