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:
parent
994b5c1e17
commit
42abb12497
13
.env.example
13
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in New Issue