Compare commits
8 Commits
08feab8d9f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e739f97056 | |||
| e3891f4dc1 | |||
| 2487d8a2f9 | |||
| fa3463a6e3 | |||
| b4f174dcff | |||
| 57a808c054 | |||
| 2d81b498d9 | |||
| ccb8efd72a |
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.pem
|
||||
.env
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
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"]
|
||||
@@ -1,9 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 jeanGaston
|
||||
Copyright (c) 2026
|
||||
|
||||
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:
|
||||
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 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.
|
||||
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.
|
||||
|
||||
@@ -1,2 +1,412 @@
|
||||
# actual-gocardless-proxy
|
||||
|
||||
> **AI-generated code — see disclaimer below.**
|
||||
|
||||
A drop-in [GoCardless Bank Account Data API](https://developer.gocardless.com/bank-account-data/quick-start-guide/) proxy 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 required is one environment variable:
|
||||
|
||||
```
|
||||
GOCARDLESS_BASE_URL=http://gocardless-proxy:3456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [How it works](#how-it-works)
|
||||
2. [Setup](#setup)
|
||||
- [1. Sign up for Enable Banking](#1-sign-up-for-enable-banking)
|
||||
- [2. Generate an RSA key pair](#2-generate-an-rsa-key-pair)
|
||||
- [3. Run the setup script](#3-run-the-setup-script)
|
||||
- [4. Add the proxy to docker-compose](#4-add-the-proxy-to-docker-compose)
|
||||
- [5. Start the services](#5-start-the-services)
|
||||
- [6. Configure Actual Budget](#6-configure-actual-budget)
|
||||
3. [Ansible deployment](#ansible-deployment)
|
||||
4. [OAuth redirect flow](#oauth-redirect-flow)
|
||||
5. [Supported banks](#supported-banks)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
7. [Data & privacy](#data--privacy)
|
||||
8. [Disclaimer](#disclaimer)
|
||||
9. [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## 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 (RS256 JWT auth)
|
||||
▼
|
||||
Enable Banking → French banks (BNP, SG, CA, LCL, BP, …)
|
||||
```
|
||||
|
||||
The proxy translates GoCardless API shapes ↔ Enable Banking shapes on the fly. All state (tokens, requisitions, account mappings) is persisted in `./data/store.json` so Docker restarts do not break existing Actual Budget account links.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Sign up for Enable Banking
|
||||
|
||||
Go to [enablebanking.com](https://enablebanking.com) and create a **free personal account**. Under your application settings, note your **Application ID**.
|
||||
|
||||
### 2. Generate an RSA key pair
|
||||
|
||||
Enable Banking authenticates API calls with an RS256 JWT signed by your application's private key. You upload the public key to Enable Banking; the private key stays on your server.
|
||||
|
||||
```bash
|
||||
# Generate a PKCS8 RSA private key
|
||||
openssl genpkey -algorithm RSA -pkcs8 -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
|
||||
# Extract the public key — upload this to the Enable Banking dashboard
|
||||
openssl pkey -in private_key.pem -pubout -out public_key.pem
|
||||
```
|
||||
|
||||
Store `private_key.pem` securely. You will reference it during setup.
|
||||
|
||||
### 3. Run the setup script
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourname/actual-gocardless-proxy
|
||||
cd actual-gocardless-proxy
|
||||
npm install
|
||||
npm run setup
|
||||
```
|
||||
|
||||
The script prompts for:
|
||||
|
||||
| Prompt | Example |
|
||||
|--------|---------|
|
||||
| Enable Banking Application ID | `abc123-...` |
|
||||
| Path to private key PEM | `/home/user/private_key.pem` |
|
||||
| Proxy base URL | `https://yourserver.example.com` or `http://localhost:3456` |
|
||||
|
||||
The proxy base URL is the address Enable Banking redirects to after OAuth — it **must be reachable from the user's browser**, not just from within Docker.
|
||||
|
||||
On completion the script prints:
|
||||
|
||||
```
|
||||
──────────────────────────────────────────────────────────
|
||||
Enter these values in Actual Budget → Settings → GoCardless:
|
||||
|
||||
Secret ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
Secret Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
And set this environment variable on actual-server:
|
||||
|
||||
GOCARDLESS_BASE_URL=http://gocardless-proxy:3456
|
||||
──────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
All credentials are written to `./data/store.json`.
|
||||
|
||||
### 4. Add the proxy to docker-compose
|
||||
|
||||
Paste the `gocardless-proxy` service block into your existing `actual-server` `docker-compose.yml` and add `GOCARDLESS_BASE_URL` to actual-server:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
actual-server:
|
||||
image: actualbudget/actual-server:latest
|
||||
environment:
|
||||
GOCARDLESS_BASE_URL: http://gocardless-proxy:3456
|
||||
# ... rest of your actual-server env vars
|
||||
depends_on:
|
||||
- gocardless-proxy
|
||||
|
||||
gocardless-proxy:
|
||||
build: /path/to/actual-gocardless-proxy
|
||||
# or use a pre-built image:
|
||||
# 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
|
||||
```
|
||||
|
||||
> **Changing the proxy base URL later:** Re-run `npm run setup`, or edit `config.proxy_base_url` directly in `data/store.json` and restart the container.
|
||||
|
||||
### 5. Start the services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify the proxy is up:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3456/health
|
||||
# {"status":"ok","configured":true}
|
||||
```
|
||||
|
||||
### 6. Configure Actual Budget
|
||||
|
||||
Go to **Settings → GoCardless** in Actual Budget and enter the `Secret ID` and `Secret Key` printed in step 3. Then link your bank via **Accounts → Link account**.
|
||||
|
||||
---
|
||||
|
||||
## Ansible deployment
|
||||
|
||||
The playbook below deploys the proxy to a remote host that already runs `actual-server` via Docker Compose. Adjust variables at the top to match your environment.
|
||||
|
||||
```yaml
|
||||
# deploy.yml
|
||||
---
|
||||
- name: Deploy actual-gocardless-proxy
|
||||
hosts: actual_host
|
||||
become: true
|
||||
|
||||
vars:
|
||||
proxy_dir: /opt/actual-gocardless-proxy
|
||||
data_dir: /opt/actual-gocardless-proxy/data
|
||||
proxy_port: 3456
|
||||
# Set proxy_base_url to the URL reachable from the user's browser
|
||||
proxy_base_url: "https://actual.example.com"
|
||||
eb_app_id: "{{ vault_eb_app_id }}"
|
||||
eb_private_key_src: "{{ playbook_dir }}/files/private_key.pem"
|
||||
gc_secret_id: "{{ vault_gc_secret_id }}"
|
||||
gc_secret_key: "{{ vault_gc_secret_key }}"
|
||||
|
||||
tasks:
|
||||
|
||||
- name: Install git and Docker dependencies
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- git
|
||||
- python3-docker
|
||||
state: present
|
||||
|
||||
- name: Clone / update repository
|
||||
ansible.builtin.git:
|
||||
repo: https://github.com/yourname/actual-gocardless-proxy.git
|
||||
dest: "{{ proxy_dir }}"
|
||||
version: main
|
||||
force: true
|
||||
|
||||
- name: Create data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ data_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Copy RSA private key
|
||||
ansible.builtin.copy:
|
||||
src: "{{ eb_private_key_src }}"
|
||||
dest: "{{ data_dir }}/private_key.pem"
|
||||
mode: "0600"
|
||||
|
||||
- name: Write store.json (credentials + proxy config)
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ data_dir }}/store.json"
|
||||
mode: "0600"
|
||||
content: |
|
||||
{
|
||||
"config": {
|
||||
"eb_app_id": "{{ eb_app_id }}",
|
||||
"eb_private_key": {{ lookup('file', eb_private_key_src) | to_json }},
|
||||
"gc_secret_id": "{{ gc_secret_id }}",
|
||||
"gc_secret_key": "{{ gc_secret_key }}",
|
||||
"proxy_base_url": "{{ proxy_base_url }}"
|
||||
},
|
||||
"gc_tokens": {},
|
||||
"institutions_cache": null,
|
||||
"institutions_cache_at": 0,
|
||||
"agreements": {},
|
||||
"requisitions": {},
|
||||
"accounts": {}
|
||||
}
|
||||
# Skip this task if store.json already exists (preserves live state:
|
||||
# tokens, requisitions, linked accounts). Remove the condition if you
|
||||
# want to reset credentials on every deploy.
|
||||
args:
|
||||
creates: "{{ data_dir }}/store.json"
|
||||
|
||||
- name: Build proxy Docker image
|
||||
community.docker.docker_image:
|
||||
name: actual-gocardless-proxy
|
||||
tag: local
|
||||
build:
|
||||
path: "{{ proxy_dir }}"
|
||||
source: build
|
||||
force_source: true
|
||||
|
||||
- name: Run gocardless-proxy container
|
||||
community.docker.docker_container:
|
||||
name: gocardless-proxy
|
||||
image: actual-gocardless-proxy:local
|
||||
restart_policy: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:{{ proxy_port }}:{{ proxy_port }}"
|
||||
volumes:
|
||||
- "{{ data_dir }}:/app/data"
|
||||
env:
|
||||
PORT: "{{ proxy_port | string }}"
|
||||
STORE_PATH: /app/data/store.json
|
||||
|
||||
- name: Ensure actual-server has GOCARDLESS_BASE_URL set
|
||||
ansible.builtin.debug:
|
||||
msg: >
|
||||
Remember to add
|
||||
GOCARDLESS_BASE_URL=http://gocardless-proxy:{{ proxy_port }}
|
||||
to your actual-server environment and restart it.
|
||||
```
|
||||
|
||||
**Sensitive variables** (`eb_app_id`, `gc_secret_id`, `gc_secret_key`) should be stored in an Ansible Vault file:
|
||||
|
||||
```bash
|
||||
# Create vault file
|
||||
ansible-vault create group_vars/actual_host/vault.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# group_vars/actual_host/vault.yml
|
||||
vault_eb_app_id: "your-enable-banking-app-id"
|
||||
vault_gc_secret_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
vault_gc_secret_key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
Run the playbook:
|
||||
|
||||
```bash
|
||||
ansible-playbook deploy.yml --ask-vault-pass
|
||||
```
|
||||
|
||||
> **First deploy only:** `store.json` is written with `creates:` so it is not overwritten on subsequent runs — this preserves live requisitions and linked accounts. On a brand-new host the credentials are bootstrapped automatically; no need to run `npm run setup` manually.
|
||||
|
||||
---
|
||||
|
||||
## OAuth redirect flow
|
||||
|
||||
```
|
||||
1. actual-server POST /api/v2/requisitions/
|
||||
body: { redirect: "http://actual:5006/...", institution_id }
|
||||
↓
|
||||
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 { id, status: "CR", link: <EB auth URL> }
|
||||
↓
|
||||
4. User visits the link, authenticates at their bank
|
||||
↓
|
||||
5. Enable Banking → http://proxy/callback?code=xxx&state=<requisition_id>
|
||||
↓
|
||||
6. proxy POST https://api.enablebanking.com/sessions { code }
|
||||
← { session_id, accounts: [uid, ...] }
|
||||
→ fetches account details per UID
|
||||
→ stores gc_id ↔ eb_uid mapping in store.json
|
||||
→ sets requisition status "LN" (linked)
|
||||
→ 302 → http://actual:5006/...?requisition_id=<req_id>
|
||||
↓
|
||||
7. actual-server GET /api/v2/requisitions/<req_id>/
|
||||
← { status: "LN", accounts: ["gc-uuid-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 complete list.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Proxy not configured" error**
|
||||
Run `npm run setup` and restart the container.
|
||||
|
||||
**"Unknown institution_id" when linking an account**
|
||||
actual-server caches the institutions list. Go to **Settings → GoCardless → Reset** and re-fetch.
|
||||
|
||||
**OAuth callback fails with "Enable Banking error"**
|
||||
- `proxy_base_url` in `data/store.json` must be reachable from your browser, not just from inside Docker.
|
||||
- Make sure the callback URL (`{proxy_base_url}/callback`) is whitelisted in your Enable Banking application settings.
|
||||
|
||||
**Transactions not appearing**
|
||||
Enable Banking may return an empty array if no transactions fall in the requested date range. Try widening the range in Actual Budget.
|
||||
|
||||
**Private key parse error**
|
||||
The key must be in **PKCS8** format. To convert from a traditional RSA key:
|
||||
```bash
|
||||
openssl pkcs8 -topk8 -nocrypt -in old_key.pem -out private_key.pem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data & privacy
|
||||
|
||||
All credentials and session data are stored locally in `./data/store.json`. No data is sent to any third party other than Enable Banking (the licensed PSD2 provider) and your bank.
|
||||
|
||||
The `data/` directory is git-ignored and must not be committed.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This project was entirely generated by an AI assistant (Claude, by Anthropic) without manual code authorship.**
|
||||
|
||||
It has not been independently audited, penetration-tested, or validated against production bank APIs. Use it at your own risk. In particular:
|
||||
|
||||
- The Enable Banking integration is based on publicly available documentation and may require adjustments as the API evolves.
|
||||
- No warranty is given regarding correctness, security, or fitness for any purpose.
|
||||
- Your bank credentials and PSD2 access tokens are handled by this software — review the code before deploying in a sensitive environment.
|
||||
|
||||
Contributions, corrections, and reviews from human developers are very welcome.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
+665
@@ -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.');
|
||||
}
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user