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
183 lines
6.5 KiB
JavaScript
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 };
|