feat: GoCardless proxy server (all /api/v2/* routes)

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 <noreply@anthropic.com>
This commit is contained in:
jeanGaston 2026-04-13 10:34:12 +02:00
parent 2d81b498d9
commit 57a808c054

665
src/server.js Normal file
View File

@ -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=<gc_requisition_id>
*
* 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(
`<h1>Authentication failed</h1><p>${error}: ${error_description ?? ''}</p>`,
);
}
if (!code || !gcReqId) {
return res.status(400).send('<h1>Bad request</h1><p>Missing code or state parameter.</p>');
}
const store = getStore();
const requisition = store.requisitions[gcReqId];
if (!requisition) {
return res.status(404).send(`<h1>Not found</h1><p>Unknown requisition: ${gcReqId}</p>`);
}
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(`<h1>Enable Banking error</h1><pre>${e.message}</pre>`);
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// 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.');
}
});