Compare commits

...

No commits in common. "2487d8a2f95bdc0dd59c5f717a4e901dfdd8d456" and "08feab8d9f024d3c5827d8b6a1680732acdbf8a5" have entirely different histories.

10 changed files with 9 additions and 1294 deletions

4
.gitignore vendored
View File

@ -1,4 +0,0 @@
node_modules/
data/
*.pem
.env

View File

@ -1,20 +0,0 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies first (layer cache)
COPY package.json ./
RUN npm install --omit=dev
# Copy source
COPY src/ ./src/
# Data directory (override with a volume)
RUN mkdir -p /app/data
EXPOSE 3456
ENV NODE_ENV=production
ENV STORE_PATH=/app/data/store.json
CMD ["node", "src/server.js"]

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 jeanGaston
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

225
README.md
View File

@ -1,227 +1,2 @@
# actual-gocardless-proxy
A drop-in [GoCardless Bank Account Data API](https://developer.gocardless.com/bank-account-data/quick-start-guide/) proxy server that lets [Actual Budget](https://actualbudget.org/) sync with French (and other European) bank accounts via [Enable Banking](https://enablebanking.com/) — a **free** PSD2 aggregator.
**Zero modifications to actual-server.** The only change is setting one environment variable:
```
GOCARDLESS_BASE_URL=http://gocardless-proxy:3456
```
## How it works
```
Actual Budget (browser)
│ GoCardless API calls
actual-server
│ GOCARDLESS_BASE_URL → http://gocardless-proxy:3456
gocardless-proxy ◄── this repo
│ Enable Banking PSD2 API
Enable Banking → French banks (BNP, SG, CA, LCL, BP, …)
```
The proxy translates GoCardless API shapes ↔ Enable Banking shapes on the fly and persists all state (tokens, requisitions, account mappings) in `./data/store.json` so Docker restarts don't break existing account links.
---
## Setup
### 1. Sign up for Enable Banking
Go to [enablebanking.com](https://enablebanking.com) and create a **free personal account**. Under your app settings, note your **Application ID**.
### 2. Generate an RSA key pair
Enable Banking uses RS256 JWT auth. You need to provide your **public key** in the Enable Banking dashboard and keep the **private key** on your server.
```bash
# Generate a 2048-bit RSA private key in PKCS8 format
openssl genpkey -algorithm RSA -pkcs8 -out private_key.pem -pkeyopt rsa_keygen_bits:2048
# Extract the public key (upload this to Enable Banking)
openssl pkey -in private_key.pem -pubout -out public_key.pem
```
Upload `public_key.pem` in the Enable Banking dashboard under your application's API settings.
Keep `private_key.pem` somewhere safe — you'll need it in the next step.
### 3. Run the setup script
Clone this repo and install dependencies:
```bash
git clone https://github.com/yourname/actual-gocardless-proxy
cd actual-gocardless-proxy
npm install
```
Run the interactive setup:
```bash
npm run setup
```
The script will ask for:
- Your Enable Banking **Application ID**
- Path to your **private key PEM** file
- The **proxy base URL** (the URL that Enable Banking will redirect to after OAuth — must be reachable from your browser; e.g. `http://localhost:3456` for local dev or `https://yourserver.example.com` for production)
It will generate a random `secret_id` and `secret_key` pair and print them:
```
──────────────────────────────────────────────────────────
Enter these values in Actual Budget → Settings → GoCardless:
Secret ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Secret Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
And set this environment variable on actual-server:
GOCARDLESS_BASE_URL=http://gocardless-proxy:3456
──────────────────────────────────────────────────────────
```
All credentials are stored in `./data/store.json`.
### 4. Add the proxy to your docker-compose.yml
In your existing `actual-server` `docker-compose.yml`, add the proxy service and wire it to actual-server:
```yaml
services:
actual-server:
image: actualbudget/actual-server:latest
# ... your existing config ...
environment:
GOCARDLESS_BASE_URL: http://gocardless-proxy:3456
# ... your other env vars ...
depends_on:
- gocardless-proxy
gocardless-proxy:
build: /path/to/actual-gocardless-proxy
# or: image: ghcr.io/yourname/actual-gocardless-proxy:latest
container_name: gocardless-proxy
restart: unless-stopped
volumes:
- /path/to/actual-gocardless-proxy/data:/app/data
environment:
PORT: 3456
```
> **Note on PROXY_BASE_URL:** The proxy base URL is saved in `data/store.json` during setup. If you need to change it later (e.g. you added a reverse proxy), re-run `npm run setup` or edit `data/store.json` directly and update `config.proxy_base_url`.
### 5. Start the services
```bash
docker compose up -d
```
Verify the proxy is running:
```bash
curl http://localhost:3456/health
# → {"status":"ok","configured":true}
```
### 6. Configure Actual Budget
In Actual Budget, go to **Settings → GoCardless** and enter the `secret_id` and `secret_key` printed in step 3.
You can now go to **Accounts → Link account** and search for your French bank.
---
## OAuth redirect flow
```
1. actual-server POST /api/v2/requisitions/ { redirect: "http://actual:5006/..." }
2. proxy POST https://api.enablebanking.com/auth
{ aspsp: { name, country }, redirect_url: "http://proxy/callback",
state: <requisition_id> }
← { url: "https://tilisy.enablebanking.com/..." }
3. proxy returns requisition with link = EB auth URL
4. User visits the link, authenticates at their bank
5. Enable Banking redirects → http://proxy/callback?code=xxx&state=<req_id>
6. proxy POST https://api.enablebanking.com/sessions { code }
← { session_id, accounts: [uid, ...] }
→ fetches account details, stores GC account IDs
→ marks requisition status "LN" (linked)
→ redirects → http://actual:5006/...?requisition_id=<req_id>
7. actual-server GET /api/v2/requisitions/<req_id>/
← { status: "LN", accounts: ["gc-acc-id-1", ...] }
8. actual-server GET /api/v2/accounts/<id>/details/
GET /api/v2/accounts/<id>/balances/
GET /api/v2/accounts/<id>/transactions/
```
---
## Supported banks
All French banks supported by Enable Banking, including:
| Bank | BIC |
|------|-----|
| BNP Paribas | BNPAFRPP |
| Société Générale | SOGEFRPP |
| Crédit Agricole | AGRIFRPP |
| LCL | CRLYFRPP |
| Banque Populaire | various |
| Caisse d'Épargne | various |
| La Banque Postale | PSSTFRPPPAR |
| Crédit Mutuel | CMCIFRPP |
| CIC | CMCIFRPP |
| Boursorama | BOUSFRPPXXX |
| Fortuneo | FTNOFRP1 |
| Hello bank! | BNPAFRPP |
See [enablebanking.com/docs/markets/fr/](https://enablebanking.com/docs/markets/fr/) for the full list.
---
## Troubleshooting
### "Proxy not configured" error
Run `npm run setup` and restart the container.
### "Unknown institution_id" when creating a requisition
actual-server caches institutions. Go to **Settings → GoCardless → Reset** and re-fetch banks.
### OAuth callback fails with "Enable Banking error"
- Make sure `proxy_base_url` in `data/store.json` is reachable from your browser (not just from Docker).
- Check that the redirect URL is whitelisted in your Enable Banking application settings.
### Transactions not appearing
Enable Banking may return an empty `transactions` array for accounts with no activity in the requested date range. Try widening the date range.
### Private key errors
Make sure the key is **PKCS8** format. To convert from traditional RSA format:
```bash
openssl pkcs8 -topk8 -nocrypt -in old_key.pem -out private_key.pem
```
---
## Data & privacy
All bank credentials and tokens stay on your server inside `./data/store.json`. Nothing is sent to any third party except Enable Banking (which is the PSD2 provider) and your bank.
The `data/` directory is git-ignored and should not be committed.
---
## License
MIT

View File

@ -1,51 +0,0 @@
# docker-compose.yml
#
# Standalone: run just the proxy
# docker compose up
#
# With actual-server: paste the `gocardless-proxy` service block into your
# existing actual-server compose file and add GOCARDLESS_BASE_URL to actual-server.
#
# Run one-time setup BEFORE starting the proxy:
# docker compose run --rm gocardless-proxy node src/setup.js
services:
gocardless-proxy:
build: .
# or use a pre-built image:
# image: ghcr.io/yourname/actual-gocardless-proxy:latest
container_name: gocardless-proxy
restart: unless-stopped
ports:
- "3456:3456"
volumes:
# Persist credentials, tokens, requisitions, and account mappings across restarts
- ./data:/app/data
environment:
PORT: 3456
STORE_PATH: /app/data/store.json
# Set PROXY_BASE_URL to the URL that Enable Banking will redirect to after OAuth.
# Must be reachable by the user's browser.
# Examples:
# http://localhost:3456 (local dev, browser on same machine)
# https://yourserver.example.com (reverse-proxied, production)
# This can also be set during `npm run setup`.
# PROXY_BASE_URL: http://localhost:3456
# ─────────────────────────────────────────────────────────────────────────────
# How to wire this into your existing actual-server compose file:
# ─────────────────────────────────────────────────────────────────────────────
#
# 1. Add the gocardless-proxy service above to your docker-compose.yml
# 2. Add GOCARDLESS_BASE_URL to actual-server's environment:
#
# actual-server:
# image: actualbudget/actual-server:latest
# environment:
# GOCARDLESS_BASE_URL: http://gocardless-proxy:3456
# # ... your other env vars
# depends_on:
# - gocardless-proxy
#
# 3. Make sure both services are on the same Docker network (they will be
# automatically if they're in the same compose file).

View File

@ -1,16 +0,0 @@
{
"name": "actual-gocardless-proxy",
"version": "1.0.0",
"description": "GoCardless Bank Account Data API proxy backed by Enable Banking — lets Actual Budget sync French bank accounts",
"type": "module",
"scripts": {
"start": "node src/server.js",
"setup": "node src/setup.js"
},
"dependencies": {
"express": "^4.18.2",
"jose": "^5.2.3",
"node-fetch": "^3.3.2",
"uuid": "^9.0.1"
}
}

View File

@ -1,125 +0,0 @@
/**
* Enable Banking API client.
*
* Auth: RS256 JWT signed with the app's private key.
* Header: { typ: "JWT", alg: "RS256", kid: app_id }
* Claims: { iss: "enablebanking.com", aud: "api.enablebanking.com", iat, exp }
*
* Base URL: https://api.enablebanking.com
*/
import { SignJWT, importPKCS8 } from 'jose';
import fetch from 'node-fetch';
import { get as getStore } from './store.js';
const EB_BASE = 'https://api.enablebanking.com';
// In-memory JWT cache — no need to persist since it's regenerated on startup anyway
let _cachedJWT = null;
let _cachedJWTExp = 0;
async function getJWT() {
const now = Math.floor(Date.now() / 1000);
// Refresh 60 s before expiry
if (_cachedJWT && now < _cachedJWTExp - 60) return _cachedJWT;
const { config } = getStore();
if (!config?.eb_app_id || !config?.eb_private_key) {
throw new Error('Enable Banking credentials not configured. Run `npm run setup` first.');
}
const key = await importPKCS8(config.eb_private_key, 'RS256');
const exp = now + 3600;
_cachedJWT = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256', kid: config.eb_app_id })
.setIssuer('enablebanking.com')
.setAudience('api.enablebanking.com')
.setIssuedAt(now)
.setExpirationTime(exp)
.sign(key);
_cachedJWTExp = exp;
return _cachedJWT;
}
async function request(method, path, body) {
const jwt = await getJWT();
const url = `${EB_BASE}${path}`;
const opts = {
method,
headers: {
Authorization: `Bearer ${jwt}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { data = text; }
if (!res.ok) {
const msg = data?.message || data?.error || text;
const err = new Error(`EB API ${method} ${path}${res.status}: ${msg}`);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
// ── Institutions ──────────────────────────────────────────────────────────────
export async function getASPSPs(country = 'FR') {
const data = await request('GET', `/aspsps?country=${encodeURIComponent(country)}&psu_type=personal`);
return data.aspsps ?? [];
}
// ── Authorization flow ────────────────────────────────────────────────────────
/**
* Start OAuth flow.
* Returns { url, authorization_id }
*/
export async function startAuth({ aspspName, aspspCountry, state, redirectUrl, validUntil }) {
return request('POST', '/auth', {
access: { valid_until: validUntil },
aspsp: { name: aspspName, country: aspspCountry },
state,
redirect_url: redirectUrl,
psu_type: 'personal',
});
}
/**
* Exchange OAuth code for a session.
* Returns session object: { session_id, accounts: [uid, ...], status, ... }
*/
export async function createSession(code) {
return request('POST', '/sessions', { code });
}
/** Get session status. Returns { session_id, accounts, status, ... } */
export async function getSession(sessionId) {
return request('GET', `/sessions/${encodeURIComponent(sessionId)}`);
}
// ── Account data ──────────────────────────────────────────────────────────────
export async function getAccountDetails(uid) {
return request('GET', `/accounts/${encodeURIComponent(uid)}/details`);
}
export async function getAccountBalances(uid) {
return request('GET', `/accounts/${encodeURIComponent(uid)}/balances`);
}
export async function getAccountTransactions(uid, dateFrom, dateTo) {
const params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
const qs = params.toString() ? `?${params}` : '';
return request('GET', `/accounts/${encodeURIComponent(uid)}/transactions${qs}`);
}

View File

@ -1,665 +0,0 @@
/**
* 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.');
}
});

View File

@ -1,126 +0,0 @@
#!/usr/bin/env node
/**
* One-time setup script.
*
* Usage:
* node src/setup.js
*
* What it does:
* 1. Prompts for Enable Banking app_id and path to RSA private key (.pem)
* 2. Validates the key can be loaded
* 3. Generates a random GC secret_id / secret_key pair
* 4. Writes everything to ./data/store.json
* 5. Prints the secret_id/key to enter in Actual Budget
*/
import { createInterface } from 'readline';
import { readFileSync, mkdirSync, existsSync } from 'fs';
import { importPKCS8 } from 'jose';
import { v4 as uuidv4 } from 'uuid';
import { get as getStore, update, save } from './store.js';
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise((res) => rl.question(q, res));
function randomToken() {
return uuidv4().replace(/-/g, '') + uuidv4().replace(/-/g, '');
}
async function main() {
console.log('\n=== Actual Budget ↔ GoCardless Proxy — Setup ===\n');
const store = getStore();
// ── Enable Banking credentials ──────────────────────────────────────────────
const defaultAppId = store.config?.eb_app_id ?? '';
const ebAppId = (
await ask(`Enable Banking App ID${defaultAppId ? ` [${defaultAppId}]` : ''}: `)
).trim() || defaultAppId;
if (!ebAppId) {
console.error('Error: App ID is required.');
process.exit(1);
}
let privateKeyPem = store.config?.eb_private_key ?? '';
const keyInput = (await ask('Path to RSA private key PEM file (leave blank to keep existing): ')).trim();
if (keyInput) {
if (!existsSync(keyInput)) {
console.error(`Error: File not found: ${keyInput}`);
process.exit(1);
}
privateKeyPem = readFileSync(keyInput, 'utf8');
}
if (!privateKeyPem) {
console.error('Error: Private key is required.');
process.exit(1);
}
// Validate key
try {
await importPKCS8(privateKeyPem, 'RS256');
console.log('✓ Private key loaded and validated.');
} catch (e) {
console.error(`Error: Could not parse private key: ${e.message}`);
console.error('Make sure the PEM file contains a PKCS8-encoded RSA private key.');
console.error('To convert from a traditional RSA key: openssl pkcs8 -topk8 -nocrypt -in key.pem -out key_pkcs8.pem');
process.exit(1);
}
// ── GoCardless credentials (generated) ─────────────────────────────────────
let gcSecretId = store.config?.gc_secret_id;
let gcSecretKey = store.config?.gc_secret_key;
if (gcSecretId && gcSecretKey) {
const regen = (await ask('GoCardless credentials already exist. Regenerate? [y/N]: ')).trim().toLowerCase();
if (regen === 'y') {
gcSecretId = uuidv4();
gcSecretKey = randomToken();
}
} else {
gcSecretId = uuidv4();
gcSecretKey = randomToken();
}
// ── Proxy base URL (for OAuth callbacks) ───────────────────────────────────
const defaultProxyUrl = store.config?.proxy_base_url ?? 'http://gocardless-proxy:3456';
const proxyBaseUrl = (
await ask(`Proxy base URL (used in OAuth redirects) [${defaultProxyUrl}]: `)
).trim() || defaultProxyUrl;
// ── Save ────────────────────────────────────────────────────────────────────
update((s) => {
s.config = {
eb_app_id: ebAppId,
eb_private_key: privateKeyPem,
gc_secret_id: gcSecretId,
gc_secret_key: gcSecretKey,
proxy_base_url: proxyBaseUrl,
};
});
rl.close();
console.log('\n✓ Configuration saved to ./data/store.json\n');
console.log('──────────────────────────────────────────────────────────');
console.log('Enter these values in Actual Budget → Settings → GoCardless:');
console.log('');
console.log(` Secret ID: ${gcSecretId}`);
console.log(` Secret Key: ${gcSecretKey}`);
console.log('');
console.log('And set this environment variable on actual-server:');
console.log('');
console.log(` GOCARDLESS_BASE_URL=${proxyBaseUrl}`);
console.log('──────────────────────────────────────────────────────────\n');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -1,62 +0,0 @@
/**
* Persistent JSON store backed by ./data/store.json (Docker volume mount).
*
* Schema:
* {
* config: { eb_app_id, eb_private_key, gc_secret_id, gc_secret_key } | null,
* gc_tokens: { [token]: { type: "access"|"refresh", expires_at } },
* institutions_cache: [...] | null,
* institutions_cache_at: 0,
* agreements: { [gc_id]: { id, created, institution_id, max_historical_days,
* access_valid_for_days, access_scope } },
* requisitions: { [gc_id]: { id, created, status, institution_id, agreements,
* accounts, reference, redirect, link,
* eb_auth_id, eb_session_id } },
* accounts: { [gc_id]: { gc_id, eb_uid, institution_id, iban, currency,
* owner_name, name, product, created, last_accessed } }
* }
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
const STORE_PATH = process.env.STORE_PATH || './data/store.json';
const EMPTY = () => ({
config: null,
gc_tokens: {},
institutions_cache: null,
institutions_cache_at: 0,
agreements: {},
requisitions: {},
accounts: {},
});
let _store = null;
export function load() {
if (!existsSync(STORE_PATH)) return EMPTY();
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8'));
} catch {
return EMPTY();
}
}
export function get() {
if (!_store) _store = load();
return _store;
}
export function save() {
const dir = dirname(STORE_PATH);
mkdirSync(dir, { recursive: true });
writeFileSync(STORE_PATH, JSON.stringify(_store ?? EMPTY(), null, 2));
}
/** Apply a mutation function and persist immediately. */
export function update(fn) {
const s = get();
fn(s);
save();
}