/** * 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 };