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:
parent
2d81b498d9
commit
57a808c054
665
src/server.js
Normal file
665
src/server.js
Normal 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.');
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user