From 57a808c05472d42a079683f553236a3422cbca7b Mon Sep 17 00:00:00 2001 From: jeanGaston Date: Mon, 13 Apr 2026 10:34:12 +0200 Subject: [PATCH] feat: GoCardless proxy server (all /api/v2/* routes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full GoCardless Bank Account Data API surface that actual-server expects: POST /api/v2/token/new/ fake GC tokens backed by stored secret_id/key POST /api/v2/token/refresh/ GET /api/v2/institutions/ proxies EB /aspsps, 1 h cache POST /api/v2/agreements/enduser/ stored locally GET /api/v2/agreements/enduser/:id/ POST /api/v2/requisitions/ calls EB /auth, returns OAuth link GET /api/v2/requisitions/:id/ status CR→LN after callback DELETE /api/v2/requisitions/:id/ GET /api/v2/accounts/:id/ metadata from store GET /api/v2/accounts/:id/details/ proxies EB /accounts/:uid/details GET /api/v2/accounts/:id/balances/ proxies EB /accounts/:uid/balances GET /api/v2/accounts/:id/transactions/ proxies EB transactions, splits booked/pending GET /callback OAuth callback: exchanges code, links requisition, redirects back to actual-server Balance type mapping: EB ISO 20022 (CLBD/ITBD/ITAV/FWAV…) → GC camelCase. Transaction mapping: EB flat array with status field → GC {booked, pending}. Co-Authored-By: Claude Sonnet 4.6 --- src/server.js | 665 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 src/server.js diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..172dcb2 --- /dev/null +++ b/src/server.js @@ -0,0 +1,665 @@ +/** + * GoCardless Bank Account Data API proxy server. + * + * Implements the GoCardless /api/v2/* surface that actual-server expects, + * backed by Enable Banking (free PSD2 aggregator). + * + * actual-server needs only one change: + * GOCARDLESS_BASE_URL=http://gocardless-proxy:3456 + */ + +import express from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { get as getStore, update } from './store.js'; +import * as eb from './eb-client.js'; + +const PORT = process.env.PORT || 3456; +const app = express(); +app.use(express.json()); + +// ── Logging ─────────────────────────────────────────────────────────────────── + +app.use((req, _res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); + next(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function now() { + return Math.floor(Date.now() / 1000); +} + +function isoNow() { + return new Date().toISOString(); +} + +/** Date N days from now in ISO format */ +function isoFuture(days) { + const d = new Date(); + d.setDate(d.getDate() + days); + return d.toISOString(); +} + +/** + * Derive a stable GoCardless-style institution ID from an EB ASPSP name + country. + * Format: EB_{COUNTRY}_{SLUG} + */ +function makeInstitutionId(name, country) { + const slug = name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, ''); + return `EB_${country.toUpperCase()}_${slug}`; +} + +/** + * Reverse the institution ID to get { name, country } from the institutions cache. + */ +function resolveInstitution(gcId) { + const store = getStore(); + if (!store.institutions_cache) return null; + return store.institutions_cache.find((i) => i.gc_id === gcId) ?? null; +} + +// ── Balance type mapping ────────────────────────────────────────────────────── + +// EB ISO 20022 4-letter codes → GoCardless camelCase +const EB_BALANCE_TYPE_MAP = { + CLBD: 'closingBooked', + CLAV: 'closingAvailable', + ITBD: 'interimBooked', + ITAV: 'interimAvailable', + FWAV: 'forwardAvailable', + OPBD: 'openingBooked', + PRCD: 'previouslyClosedBooked', + XPCD: 'expected', +}; + +function mapBalanceType(ebType) { + if (!ebType) return 'interimBooked'; + return EB_BALANCE_TYPE_MAP[ebType.toUpperCase()] ?? ebType; +} + +// ── Transaction mapping ──────────────────────────────────────────────────────── + +function mapTransaction(ebTx) { + return { + transactionId: ebTx.entry_reference || uuidv4(), + bookingDate: ebTx.booking_date ?? null, + valueDate: ebTx.value_date ?? null, + transactionAmount: { + amount: ebTx.transaction_amount?.amount ?? '0', + currency: ebTx.transaction_amount?.currency ?? 'EUR', + }, + creditorName: ebTx.creditor?.name ?? null, + debtorName: ebTx.debtor?.name ?? null, + remittanceInformationUnstructured: Array.isArray(ebTx.remittance_information) + ? ebTx.remittance_information.join(' / ') + : (ebTx.remittance_information ?? null), + internalTransactionId: ebTx.entry_reference ?? null, + }; +} + +// ── Auth middleware ─────────────────────────────────────────────────────────── + +/** + * Validate the Bearer token sent by actual-server. + * We issued these tokens ourselves in POST /api/v2/token/new/, so just check + * they exist in our store with a valid expiry. + */ +function requireAuth(req, res, next) { + const auth = req.headers.authorization ?? ''; + if (!auth.startsWith('Bearer ')) { + return res.status(401).json({ detail: 'Authentication credentials were not provided.' }); + } + const token = auth.slice(7); + const store = getStore(); + const record = store.gc_tokens?.[token]; + if (!record || record.type !== 'access' || record.expires_at < now()) { + return res.status(401).json({ detail: 'Token is invalid or expired.' }); + } + next(); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TOKEN ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * POST /api/v2/token/new/ + * Body: { secret_id, secret_key } + * Returns: { access, access_expires, refresh, refresh_expires } + */ +app.post('/api/v2/token/new/', (req, res) => { + const { secret_id, secret_key } = req.body ?? {}; + const store = getStore(); + + if (!store.config) { + return res.status(503).json({ detail: 'Proxy not configured. Run npm run setup first.' }); + } + + if (secret_id !== store.config.gc_secret_id || secret_key !== store.config.gc_secret_key) { + return res.status(401).json({ detail: 'Invalid credentials.' }); + } + + const accessToken = uuidv4(); + const refreshToken = uuidv4(); + const accessExpires = now() + 86400; // 24 h + const refreshExpires = now() + 2592000; // 30 days + + update((s) => { + s.gc_tokens[accessToken] = { type: 'access', expires_at: accessExpires }; + s.gc_tokens[refreshToken] = { type: 'refresh', expires_at: refreshExpires }; + }); + + res.json({ + access: accessToken, + access_expires: accessExpires, + refresh: refreshToken, + refresh_expires: refreshExpires, + }); +}); + +/** + * POST /api/v2/token/refresh/ + * Body: { refresh } + * Returns: { access, access_expires, refresh, refresh_expires } + */ +app.post('/api/v2/token/refresh/', (req, res) => { + const { refresh } = req.body ?? {}; + const store = getStore(); + const record = store.gc_tokens?.[refresh]; + + if (!record || record.type !== 'refresh' || record.expires_at < now()) { + return res.status(401).json({ detail: 'Token is invalid or expired.' }); + } + + const accessToken = uuidv4(); + const accessExpires = now() + 86400; + + update((s) => { + s.gc_tokens[accessToken] = { type: 'access', expires_at: accessExpires }; + }); + + res.json({ + access: accessToken, + access_expires: accessExpires, + refresh, + refresh_expires: record.expires_at, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// INSTITUTIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * GET /api/v2/institutions/?country=FR + * Returns array of Institution objects. + * Caches the EB ASPSP list for 1 hour. + */ +app.get('/api/v2/institutions/', requireAuth, async (req, res) => { + const country = (req.query.country ?? 'FR').toUpperCase(); + const store = getStore(); + + // Serve from cache if fresh (1 hour) + const cacheAge = now() - (store.institutions_cache_at ?? 0); + if (store.institutions_cache && cacheAge < 3600) { + const filtered = store.institutions_cache.filter((i) => + i.countries.includes(country), + ); + return res.json(filtered); + } + + try { + const aspsps = await eb.getASPSPs(country); + + const institutions = aspsps.map((a) => ({ + gc_id: makeInstitutionId(a.name, a.country), // internal field for lookup + id: makeInstitutionId(a.name, a.country), + name: a.name, + bic: a.bic ?? '', + transaction_total_days: a.maximum_consent_validity + ? String(Math.floor(a.maximum_consent_validity / 86400)) + : '365', + countries: [a.country], + logo: a.logo ?? '', + // preserve EB fields for reverse-lookup + _eb_name: a.name, + _eb_country: a.country, + })); + + update((s) => { + s.institutions_cache = institutions; + s.institutions_cache_at = now(); + }); + + const filtered = institutions.filter((i) => i.countries.includes(country)); + // Strip internal fields from response + res.json(filtered.map(publicInstitution)); + } catch (e) { + console.error('Failed to fetch institutions from Enable Banking:', e.message); + res.status(502).json({ detail: `Upstream error: ${e.message}` }); + } +}); + +function publicInstitution(i) { + const { gc_id, _eb_name, _eb_country, ...pub } = i; + return pub; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// AGREEMENTS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * POST /api/v2/agreements/enduser/ + * Body: { institution_id, max_historical_days, access_valid_for_days, access_scope } + * Returns agreement object. + * + * GoCardless uses agreements to define access scope; we store them locally + * and reference them from requisitions. + */ +app.post('/api/v2/agreements/enduser/', requireAuth, (req, res) => { + const { + institution_id, + max_historical_days = 90, + access_valid_for_days = 90, + access_scope = ['balances', 'details', 'transactions'], + } = req.body ?? {}; + + if (!institution_id) { + return res.status(400).json({ detail: 'institution_id is required.' }); + } + + const id = uuidv4(); + const agreement = { + id, + created: isoNow(), + institution_id, + max_historical_days: Number(max_historical_days), + access_valid_for_days: Number(access_valid_for_days), + access_scope, + accepted: null, + }; + + update((s) => { + s.agreements[id] = agreement; + }); + + res.status(201).json(agreement); +}); + +/** + * GET /api/v2/agreements/enduser/:id/ + */ +app.get('/api/v2/agreements/enduser/:id/', requireAuth, (req, res) => { + const agreement = getStore().agreements[req.params.id]; + if (!agreement) return res.status(404).json({ detail: 'Not found.' }); + res.json(agreement); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// REQUISITIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * POST /api/v2/requisitions/ + * Body: { redirect, institution_id, reference, agreement, user_language, account_selection } + * Returns requisition with `link` pointing to Enable Banking's OAuth flow. + * + * Flow: + * 1. Look up EB ASPSP for institution_id + * 2. Call EB POST /auth → get auth URL + * 3. Store requisition with status "CR" + * 4. Return requisition with link = EB auth URL + */ +app.post('/api/v2/requisitions/', requireAuth, async (req, res) => { + const { + redirect: redirectUrl, + institution_id, + reference = uuidv4(), + agreement, + user_language, + account_selection = false, + } = req.body ?? {}; + + if (!institution_id) { + return res.status(400).json({ detail: 'institution_id is required.' }); + } + if (!redirectUrl) { + return res.status(400).json({ detail: 'redirect is required.' }); + } + + // Look up the EB ASPSP + const store = getStore(); + const cached = store.institutions_cache?.find((i) => i.gc_id === institution_id); + if (!cached) { + return res.status(400).json({ + detail: `Unknown institution_id: ${institution_id}. Fetch /api/v2/institutions/?country=FR first.`, + }); + } + + const gcId = uuidv4(); + const proxyBase = store.config?.proxy_base_url ?? `http://localhost:${PORT}`; + const callbackUrl = `${proxyBase}/callback`; + + // Calculate access validity from agreement if present + let validDays = 90; + if (agreement && store.agreements[agreement]) { + validDays = store.agreements[agreement].access_valid_for_days ?? 90; + } + const validUntil = isoFuture(validDays); + + let authResult; + try { + authResult = await eb.startAuth({ + aspspName: cached._eb_name, + aspspCountry: cached._eb_country, + state: gcId, // we use the GC requisition ID as state — echoed back in callback + redirectUrl: callbackUrl, + validUntil, + }); + } catch (e) { + console.error('EB /auth failed:', e.message); + return res.status(502).json({ detail: `Enable Banking error: ${e.message}` }); + } + + const requisition = { + id: gcId, + created: isoNow(), + status: 'CR', + institution_id, + agreements: agreement ? [agreement] : [], + accounts: [], + reference, + redirect: redirectUrl, + link: authResult.url, + account_selection, + eb_auth_id: authResult.authorization_id, + eb_session_id: null, + }; + + update((s) => { + s.requisitions[gcId] = requisition; + }); + + res.status(201).json(publicRequisition(requisition)); +}); + +/** + * GET /api/v2/requisitions/:id/ + * Returns requisition with current status and linked account IDs. + */ +app.get('/api/v2/requisitions/:id/', requireAuth, (req, res) => { + const req_ = getStore().requisitions[req.params.id]; + if (!req_) return res.status(404).json({ detail: 'Not found.' }); + res.json(publicRequisition(req_)); +}); + +/** + * DELETE /api/v2/requisitions/:id/ + * actual-server calls this via remove-account. + */ +app.delete('/api/v2/requisitions/:id/', requireAuth, (req, res) => { + const store = getStore(); + const req_ = store.requisitions[req.params.id]; + if (!req_) return res.status(404).json({ detail: 'Not found.' }); + + update((s) => { + // Remove linked accounts from store + (req_.accounts ?? []).forEach((accId) => delete s.accounts[accId]); + delete s.requisitions[req.params.id]; + }); + + res.json({ detail: 'Requisition deleted.' }); +}); + +function publicRequisition(r) { + const { eb_auth_id, eb_session_id, ...pub } = r; + return pub; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// OAUTH CALLBACK +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * GET /callback?code=...&state= + * + * Enable Banking redirects here after the user authenticates at their bank. + * We exchange the code for an EB session, populate accounts, then redirect + * back to actual-server's original redirect URL. + */ +app.get('/callback', async (req, res) => { + const { code, state: gcReqId, error, error_description } = req.query; + + if (error) { + console.error(`OAuth error: ${error} — ${error_description}`); + return res.status(400).send( + `

Authentication failed

${error}: ${error_description ?? ''}

`, + ); + } + + if (!code || !gcReqId) { + return res.status(400).send('

Bad request

Missing code or state parameter.

'); + } + + const store = getStore(); + const requisition = store.requisitions[gcReqId]; + if (!requisition) { + return res.status(404).send(`

Not found

Unknown requisition: ${gcReqId}

`); + } + + try { + // Exchange code for EB session + const session = await eb.createSession(code); + const sessionId = session.session_id ?? session.id; + const ebAccountUids = session.accounts ?? []; + + // Generate GC account IDs and cache account info + const gcAccountIds = []; + for (const uid of ebAccountUids) { + // Try to fetch account details; skip on error + let details = null; + try { + details = await eb.getAccountDetails(uid); + } catch (e) { + console.warn(`Could not fetch details for EB account ${uid}: ${e.message}`); + } + + const gcAccId = uuidv4(); + gcAccountIds.push(gcAccId); + + update((s) => { + s.accounts[gcAccId] = { + gc_id: gcAccId, + eb_uid: uid, + institution_id: requisition.institution_id, + iban: details?.account_id?.iban ?? null, + currency: details?.currency ?? 'EUR', + owner_name: details?.name ?? null, + product: details?.product ?? null, + display_name: details?.name ?? null, + created: isoNow(), + last_accessed: isoNow(), + status: 'READY', + }; + }); + } + + // Mark requisition as linked + update((s) => { + s.requisitions[gcReqId].status = 'LN'; + s.requisitions[gcReqId].accounts = gcAccountIds; + s.requisitions[gcReqId].eb_session_id = sessionId; + }); + + console.log( + `Requisition ${gcReqId} linked — ${gcAccountIds.length} account(s) from EB session ${sessionId}`, + ); + + // Redirect back to actual-server + const redirectTarget = new URL(requisition.redirect); + redirectTarget.searchParams.set('requisition_id', gcReqId); + res.redirect(302, redirectTarget.toString()); + } catch (e) { + console.error('Failed to exchange EB code:', e.message, e.body); + res.status(502).send(`

Enable Banking error

${e.message}
`); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ACCOUNTS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * GET /api/v2/accounts/:id/ + * Returns account metadata. + */ +app.get('/api/v2/accounts/:id/', requireAuth, (req, res) => { + const acc = getStore().accounts[req.params.id]; + if (!acc) return res.status(404).json({ detail: 'Not found.' }); + + update((s) => { + s.accounts[req.params.id].last_accessed = isoNow(); + }); + + res.json({ + id: acc.gc_id, + created: acc.created, + last_accessed: acc.last_accessed, + iban: acc.iban ?? '', + institution_id: acc.institution_id, + status: acc.status ?? 'READY', + owner_name: acc.owner_name ?? '', + }); +}); + +/** + * GET /api/v2/accounts/:id/details/ + * Returns detailed account info (IBAN, owner, currency, product). + */ +app.get('/api/v2/accounts/:id/details/', requireAuth, async (req, res) => { + const acc = getStore().accounts[req.params.id]; + if (!acc) return res.status(404).json({ detail: 'Not found.' }); + + try { + const details = await eb.getAccountDetails(acc.eb_uid); + + // Cache updated details + update((s) => { + const a = s.accounts[req.params.id]; + a.iban = details.account_id?.iban ?? a.iban; + a.currency = details.currency ?? a.currency; + a.owner_name = details.name ?? a.owner_name; + a.product = details.product ?? a.product; + a.last_accessed = isoNow(); + }); + + res.json({ + account: { + iban: details.account_id?.iban ?? acc.iban ?? '', + currency: details.currency ?? acc.currency ?? 'EUR', + ownerName: details.name ?? acc.owner_name ?? '', + displayName: details.name ?? acc.display_name ?? '', + product: details.product ?? acc.product ?? '', + }, + }); + } catch (e) { + console.error(`getAccountDetails failed for ${acc.eb_uid}:`, e.message); + // Fall back to cached values + res.json({ + account: { + iban: acc.iban ?? '', + currency: acc.currency ?? 'EUR', + ownerName: acc.owner_name ?? '', + displayName: acc.display_name ?? '', + product: acc.product ?? '', + }, + }); + } +}); + +/** + * GET /api/v2/accounts/:id/balances/ + * Returns balances in GoCardless format. + */ +app.get('/api/v2/accounts/:id/balances/', requireAuth, async (req, res) => { + const acc = getStore().accounts[req.params.id]; + if (!acc) return res.status(404).json({ detail: 'Not found.' }); + + try { + const data = await eb.getAccountBalances(acc.eb_uid); + const balances = (data.balances ?? []).map((b) => ({ + balanceAmount: { + amount: b.balance_amount?.amount ?? '0', + currency: b.balance_amount?.currency ?? acc.currency ?? 'EUR', + }, + balanceType: mapBalanceType(b.balance_type), + referenceDate: b.reference_date ?? null, + lastChangeDateTime: b.last_change_date_time ?? null, + })); + + res.json({ balances }); + } catch (e) { + console.error(`getAccountBalances failed for ${acc.eb_uid}:`, e.message); + res.status(502).json({ detail: `Enable Banking error: ${e.message}` }); + } +}); + +/** + * GET /api/v2/accounts/:id/transactions/?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD + * Returns transactions split into booked and pending arrays. + */ +app.get('/api/v2/accounts/:id/transactions/', requireAuth, async (req, res) => { + const acc = getStore().accounts[req.params.id]; + if (!acc) return res.status(404).json({ detail: 'Not found.' }); + + const { date_from, date_to } = req.query; + + try { + const data = await eb.getAccountTransactions(acc.eb_uid, date_from, date_to); + const all = data.transactions ?? []; + + const booked = []; + const pending = []; + + for (const tx of all) { + const mapped = mapTransaction(tx); + if (tx.status === 'BOOK') { + booked.push(mapped); + } else { + pending.push(mapped); + } + } + + res.json({ transactions: { booked, pending } }); + } catch (e) { + console.error(`getAccountTransactions failed for ${acc.eb_uid}:`, e.message); + res.status(502).json({ detail: `Enable Banking error: ${e.message}` }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// HEALTH / MISC +// ═══════════════════════════════════════════════════════════════════════════════ + +app.get('/health', (_req, res) => { + const configured = !!getStore().config; + res.json({ status: 'ok', configured }); +}); + +// Catch-all for unimplemented GC routes — helps debug actual-server calls +app.all('/api/v2/*', (req, res) => { + console.warn(`Unhandled route: ${req.method} ${req.path}`); + res.status(404).json({ detail: `Route not implemented: ${req.method} ${req.path}` }); +}); + +// ── Start ───────────────────────────────────────────────────────────────────── + +app.listen(PORT, () => { + console.log(`GoCardless proxy listening on port ${PORT}`); + const configured = !!getStore().config; + if (!configured) { + console.warn('WARNING: Not configured. Run `npm run setup` and restart.'); + } +});