/** * 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; // The bridge hard-caps ranges at 90 days and reports the capping in `errors`; // 89 stays just inside it so a routine backfill doesn't look like data loss. It // also advises staying under 45 days, which it says on anything longer — that // notice is passed through, since it is a real hint about future behaviour. // Omitting start-date entirely is not "everything": it returns only the most // recent handful of transactions, so a start-date is always sent. const MAX_DAYS = 89; 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. Pending transactions carry posted=0 rather than // omitting it, so falsy epochs are "no date", not 1970-01-01. function toDate(epochSeconds) { if (!epochSeconds) return null; return new Date(epochSeconds * 1000).toISOString().slice(0, 10); } // Flatten a SimpleFIN transaction into the shallow map the rule engine expects. // // Every scalar the bridge sends is passed through rather than whitelisted, so // institution-specific fields (mcc, and whatever a given bank adds) show up in // the data and in field discovery without a code change here. Only the epoch // timestamps are reshaped, and account context is folded in so records stay // self-describing. function flatten(txn, account) { const out = {}; for (const [key, value] of Object.entries(txn)) { if (value === null || value === undefined) continue; if (typeof value === 'object') continue; // nested objects handled below out[key] = value; } // `extra` is free-form and institution-specific; hoist its scalars under a // prefix so they can't collide with the documented fields for (const [key, value] of Object.entries(txn.extra || {})) { if (value !== null && value !== undefined && typeof value !== 'object') { out[`extra_${key}`] = value; } } const posted = toDate(txn.posted); const transacted = toDate(txn.transacted_at); delete out.posted; // replaced by the YYYY-MM-DD forms below // A pending transaction has no posted date yet; fall back to when it was // transacted so every record has something usable to sort and filter on out.date = posted || transacted; out.posted_date = posted; out.transacted_at = transacted; out.pending = txn.pending ? 'true' : 'false'; out.account_id = account.id; out.account_name = account.name; out.organization = account.org?.name || account.org?.domain; return out; } 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 or more than MAX_DAYS means the full 90 * 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; if (days <= 0 || days > MAX_DAYS) days = MAX_DAYS; const params = { account: accountId, '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, MAX_DAYS };