dataflow/api/lib/simplefin.js
Paul Trowbridge 3613037ab5 Add SimpleFIN Bridge sync as an alternative to CSV import
Sources with a `simplefin` block in their config can pull transactions
straight from the bridge instead of taking a CSV upload. Only the fetch
differs — dedupe, logging, and transformation reuse the import path.

The access URL is the whole credential, so it lives in .env rather than
the database that manage.py offers to reset. Claiming a setup token is
exposed as an endpoint because the token is single-use and easy to burn.

The bridge answers 200 with a populated `errors` array when a bank is
failing, which would otherwise read as a successful empty pull — those
errors ride along in the sync response and show on the Import page.

Pending transactions are skipped by default: they get a new id once they
post, which would import the same charge twice under two keys. Sources
should use ['id'] as constraint_fields — the transaction id makes
overlapping pulls free while keeping genuinely repeated charges distinct.

Verified against a stubbed bridge response, not a live account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-01 12:47:04 -04:00

183 lines
6.5 KiB
JavaScript

/**
* SimpleFIN Bridge client
*
* Read-only access to linked bank accounts. Auth is a single "access URL" with
* credentials embedded (https://user:pass@bridge.simplefin.org/simplefin),
* obtained once by claiming a setup token. No certificates, no refresh flow —
* the URL is the credential, so it lives in .env and never in the database.
*
* Protocol: https://www.simplefin.org/protocol.html
*/
const REQUEST_TIMEOUT = 30000;
// The bridge refreshes from banks roughly daily and transactions can land a few
// days late, so pulls overlap by design and lean on constraint_key for dedupe.
const DEFAULT_DAYS = 10;
class SimpleFinError extends Error {
constructor(message, status) {
super(message);
this.name = 'SimpleFinError';
this.status = status;
}
}
// Resolve an access URL from the environment. Sources name their own variable
// via config.simplefin.access_url_env so several bridges can coexist.
function getAccessUrl(urlEnv) {
const name = urlEnv || 'SIMPLEFIN_ACCESS_URL';
const value = process.env[name];
if (!value) {
throw new SimpleFinError(`SimpleFIN access URL not found — set ${name} in .env`, 500);
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new SimpleFinError(`${name} is not a valid URL`, 500);
}
if (!parsed.username) {
throw new SimpleFinError(`${name} has no credentials — expected https://user:pass@host/path`, 500);
}
return parsed;
}
async function get(accessUrl, path, params) {
// Credentials travel in the Authorization header, not the request line.
const target = new URL(accessUrl.pathname + path, accessUrl.origin);
for (const [k, v] of Object.entries(params || {})) {
if (v !== undefined && v !== null) target.searchParams.append(k, String(v));
}
const auth = Buffer.from(`${decodeURIComponent(accessUrl.username)}:${decodeURIComponent(accessUrl.password)}`).toString('base64');
let res;
try {
res = await fetch(target, {
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
});
} catch (err) {
throw new SimpleFinError(`SimpleFIN request failed: ${err.message}`, 502);
}
if (res.status === 401 || res.status === 403) {
throw new SimpleFinError(
'SimpleFIN rejected the access URL — it may have been revoked; claim a new setup token', res.status);
}
if (!res.ok) {
throw new SimpleFinError(`SimpleFIN returned ${res.status}: ${(await res.text()).slice(0, 200)}`, res.status);
}
try {
return await res.json();
} catch {
throw new SimpleFinError('SimpleFIN returned a non-JSON response', 502);
}
}
/**
* Exchange a setup token for a permanent access URL. Run once per bridge; the
* returned URL goes in .env. The token is base64 of a one-shot claim URL and is
* consumed by this call.
*/
async function claimSetupToken(setupToken) {
let claimUrl;
try {
claimUrl = Buffer.from(String(setupToken).trim(), 'base64').toString('utf8');
new URL(claimUrl);
} catch {
throw new SimpleFinError('Setup token is not valid base64 of a claim URL', 400);
}
let res;
try {
res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(REQUEST_TIMEOUT) });
} catch (err) {
throw new SimpleFinError(`Claim request failed: ${err.message}`, 502);
}
if (!res.ok) {
throw new SimpleFinError(
`Claim returned ${res.status} — setup tokens can only be claimed once`, res.status);
}
return (await res.text()).trim();
}
// Epoch seconds → YYYY-MM-DD, so dates sort and compare as plain text the way
// CSV-imported dates already do.
function toDate(epochSeconds) {
if (epochSeconds === undefined || epochSeconds === null) return null;
return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
}
// Flatten a SimpleFIN transaction into the shallow string map the rule engine
// expects. Account context is folded in so records stay self-describing.
function flatten(txn, account) {
return {
id: txn.id,
date: toDate(txn.posted),
transacted_at: toDate(txn.transacted_at),
description: txn.description,
payee: txn.payee,
memo: txn.memo,
amount: txn.amount,
pending: txn.pending ? 'true' : 'false',
account_id: account.id,
account_name: account.name,
organization: account.org?.name || account.org?.domain,
};
}
async function listAccounts(urlEnv) {
const data = await get(getAccessUrl(urlEnv), '/accounts', { 'balances-only': 1 });
return {
errors: data.errors || [],
accounts: (data.accounts || []).map(a => ({
id: a.id,
name: a.name,
organization: a.org?.name || a.org?.domain,
currency: a.currency,
balance: a.balance,
available_balance: a['available-balance'],
balance_date: toDate(a['balance-date']),
})),
};
}
/**
* Fetch transactions for one account.
*
* days — how far back to ask for; 0 lets the bridge return its default window
* includePending — pending transactions get a new id once they post, so they are excluded by default
*/
async function fetchTransactions({ accountId, accessUrlEnv, days, includePending = false }) {
days = Number.isFinite(days) ? days : DEFAULT_DAYS;
const params = { account: accountId };
if (days > 0) params['start-date'] = Math.floor(Date.now() / 1000) - days * 86400;
if (includePending) params.pending = 1;
const data = await get(getAccessUrl(accessUrlEnv), '/accounts', params);
// The bridge reports per-institution problems here and still returns 200 —
// one bank being down must not look like a successful empty pull.
const errors = data.errors || [];
const account = (data.accounts || []).find(a => a.id === accountId);
if (!account) {
const detail = errors.length ? ` — bridge reported: ${errors.join('; ')}` : '';
throw new SimpleFinError(`Account ${accountId} not returned by SimpleFIN${detail}`, 502);
}
const txns = account.transactions || [];
const kept = includePending ? txns : txns.filter(t => !t.pending);
return {
fetched: txns.length,
errors,
records: kept.map(t => flatten(t, account)),
};
}
module.exports = { listAccounts, fetchTransactions, claimSetupToken, SimpleFinError, DEFAULT_DAYS };