- Table of contents with anchor links - Ansible deploy.yml: clones repo, copies private key, bootstraps store.json via Ansible Vault variables, builds image, runs container; uses creates: guard to preserve live requisitions on re-deploy - AI-generated disclaimer (prominent, at the bottom) - Full MIT licence text (not just the word "MIT") - Improved prose and formatting throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
14 KiB
Markdown
413 lines
14 KiB
Markdown
# 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.
|