Files
finance-duck/README.md
T
2026-09-10 12:30:42 +02:00

282 lines
16 KiB
Markdown

# Finance Duck
A self-hosted personal finance dashboard with a **Go backend**, **React frontend**, and **DuckDB analytics**. Human-readable `.finance` journals are the source of truth; DuckDB is a disposable index.
Imported bank facts are separate from editable merchant, category, and tag classifications. Imports work without AI. Optional OpenRouter enrichment uses restrictive provider routing and omits amounts by default.
> **There is no application login.** Keep Finance Duck behind your VPN. The default service and Docker Compose port bindings are loopback-only. Setting a hostname does not provide authentication or firewall protection.
## Quick start on NixOS
From the repository root:
```sh
nix-shell
npm --prefix web ci
npm --prefix web run build
go build -o bin/finance-duck ./cmd/finance-duck
./bin/finance-duck -data ./finance
```
Open **http://localhost:8080**. For CSV imports, create a local account in **Accounts**, then import an N26 statement. For automatic bank imports, follow the setup below; authorization discovers the bank's accounts for you.
Build React before building Go: its production assets are embedded in the binary. `shell.nix` supplies Go, Node.js, and the CGO toolchain needed by DuckDB. Node.js is not needed at runtime.
## Connect your bank
Bank connections use Enable Banking. There are two separate credentials:
- **Application ID + RSA private key:** authenticate Finance Duck to Enable Banking.
- **Bank session:** grants access to the accounts you approve. Finance Duck obtains and stores this automatically after bank login.
The callback's one-time authorization code is **not** a reusable API key or the application private key.
### 1. Choose the callback URL
Use the address you open Finance Duck at through your VPN, followed by `/api/banking/callback`. For example:
```text
https://finance.example.internal/api/banking/callback
```
Replace `finance.example.internal` throughout these examples with your actual hostname. Register the exact URL with Enable Banking and configure the same value on the server, including scheme, hostname, port if applicable, and path.
For local use, the corresponding URL is:
```text
http://localhost:8080/api/banking/callback
```
`localhost` works only when the browser completing bank login can reach Finance Duck on that computer. For a remote server, use its VPN-accessible hostname instead.
**This is a browser redirect, not a webhook.** Your browser must be connected to the VPN and able to reach the callback. Finance Duck does not need to be publicly exposed. The server needs outbound HTTPS access to Enable Banking.
The **Accounts** screen displays a copyable callback URL. Before configuration it suggests the current browser origin; afterward it displays `ENABLEBANKING_REDIRECT_URL`.
### 2. Generate an application key and certificate
If you already have the private key for your registered Enable Banking application, use it instead of generating a replacement.
Otherwise, on NixOS:
```sh
umask 077
mkdir -p secrets
chmod 700 secrets
nix-shell -p openssl --run '
openssl genrsa -out secrets/enablebanking.key 4096 &&
openssl req -new -x509 -days 365 \
-key secrets/enablebanking.key \
-out secrets/enablebanking.crt \
-subj "/CN=Finance Duck"
'
```
- Upload **`secrets/enablebanking.crt`**, the public certificate, to Enable Banking.
- Keep **`secrets/enablebanking.key`** private on your server and back it up securely.
- Do not rerun the generation command over a key already in use. A different private key will not authenticate against the existing registered certificate.
The application accepts PEM RSA keys in PKCS#1 or PKCS#8 format, at least 2048 bits. It creates signed application JWTs itself; you do not need to generate daily tokens.
### 3. Register and activate the Enable Banking application
Open the [Enable Banking application control panel](https://enablebanking.com/cp/applications):
1. Register a **Production** application for real bank data, rather than a Sandbox application.
2. Upload your public certificate and register the callback URL.
3. Copy the resulting **application ID**.
4. Use **Activate by linking accounts** to link your own accounts.
Enable Banking documents restricted production access for individual, non-commercial use. In that mode, only accounts linked to the application can be accessed. Follow its current registration requirements and terms: [whitelisting your own accounts](https://enablebanking.com/docs/api/linked-accounts/).
**Linking accounts in their control panel activates your application; it does not create Finance Duck's bank session.** You must also authorize from Finance Duck in step 5, even for the same account.
### 4. Configure Finance Duck and restart it
For a native process, set all three banking variables in the environment of the process that starts Finance Duck:
```sh
export ENABLEBANKING_APP_ID="your-application-id"
export ENABLEBANKING_KEY_FILE="$PWD/secrets/enablebanking.key"
export ENABLEBANKING_REDIRECT_URL="https://finance.example.internal/api/banking/callback"
./bin/finance-duck \
-data ./finance \
-listen 127.0.0.1:8080 \
-public-url https://finance.example.internal
```
This example assumes a VPN-accessible reverse proxy forwards to `127.0.0.1:8080`. For local browser use without a proxy, set the redirect to `http://localhost:8080/api/banking/callback` and omit `-public-url`.
Stop the old process before restarting; only one process may write a finance directory. Native runs do **not** automatically load `.env` files. Shell exports also do not configure an already-running process or a systemd service—configure that service's environment separately.
For Docker, use the container-side key path and the configuration in [Docker Compose](#docker-compose-recommended).
### 5. Authorize from the UI
Open **Accounts → Connect your bank**:
1. Enter the bank's exact Enable Banking institution name.
2. Set the country to **DE** for a German bank.
3. Click **Authorize bank**.
4. Log in on your bank's page and approve account access.
Finance Duck verifies the callback state, exchanges the returned code for a `session_id`, stores the session locally with restrictive permissions, and wakes the synchronization worker immediately. You do not need to copy the code or session ID manually.
Initial synchronization requests **90 days of booked transactions per account**. Subsequent daily synchronization overlaps each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization; older history can be imported with CSV.
### Reauthorize expired consent
When consent expires or is revoked, the dashboard shows a warning such as **“ING needs reconnection.”** Open **Accounts** and click **Reconnect ING**. The bank and country are already selected.
```text
Reconnect → bank login and approval → callback code → new session
replace old consent and resume sync
```
Renewal preserves local account identities and existing transaction history. It does not create a second copy of the account or its transactions. Transient provider failures are displayed separately from expired consent.
Reconnecting renews bank consent, not your application registration. Problems with the registered certificate/private key must be corrected in the server configuration and Enable Banking control panel.
## Deployment options
| Option | Best fit | Tradeoff / current support |
| --- | --- | --- |
| **Docker Compose** | Straightforward unattended deployment on a private server or VM | Included Dockerfile and Compose configuration; packages the Go runtime dependencies and frontend together. |
| **Native binary + systemd** | A small installation without a container daemon | Binary build is supported; configure your own service user, persistent data directory, credentials, and systemd unit. |
| **NixOS declarative OCI container** | Managing the application alongside other services in your NixOS configuration | Reuse the Docker image through `virtualisation.oci-containers`; a ready-made NixOS module is not included. |
| **Manual native run** | Development, evaluation, or troubleshooting | The quick-start command works, but it is not an unattended service and stops when the process is terminated. |
A VM or VPS can host either of the first two options; it does not require a separate application architecture. Kubernetes and multiple replicas add little here: **run one application instance per canonical finance directory**.
### Docker Compose (recommended)
For an initial local deployment without bank credentials:
```sh
docker compose up --build -d
docker compose logs -f finance
```
Open **http://localhost:8080**. The supplied configuration:
- Publishes `127.0.0.1:8080`, not a public host interface.
- Persists financial data in a named volume mounted at `/data`.
- Runs as UID/GID `10001:10001` with a read-only root filesystem.
- Drops Linux capabilities and enables `no-new-privileges`.
- Restarts the service unless you explicitly stop it.
To enable banking, create a private `.env` file in the repository root:
```dotenv
FINANCE_PUBLIC_URL=https://finance.example.internal
ENABLEBANKING_APP_ID=your-application-id
ENABLEBANKING_KEY_FILE=/run/secrets/enablebanking.key
ENABLEBANKING_REDIRECT_URL=https://finance.example.internal/api/banking/callback
```
Protect it with `chmod 600 .env`. `.env*` and `secrets/` are excluded from Git and the Docker build context. Compose reads `.env` for variable substitution; an already-exported shell variable takes precedence, so remove conflicting exports when switching from a native run.
Uncomment the example key mount in `compose.yaml`, under `services.finance.volumes`:
```yaml
- ./secrets/enablebanking.key:/run/secrets/enablebanking.key:ro
```
The private key must be readable by the container service user, while remaining inaccessible to unrelated users. A host-owned key with mode `0600` is not automatically readable by container UID `10001`. Set ownership or a restrictive ACL for the service's mapped host UID; rootless Docker and user-namespace remapping may use a different host UID. Do not solve this by making the private key world-readable.
Then recreate the service with its new environment and mount:
```sh
docker compose up --build -d
```
**Existing native data:** Compose's named volume does not automatically use `./finance`. If migrating an existing installation, stop the native service, back it up, and either copy the full data directory into the named volume or replace `finance-data:/data` with `./finance:/data`. A bind-mounted data directory must be writable by the container's mapped service UID. Do not point two running instances at it.
Useful commands:
```sh
docker compose logs -f finance
docker compose stop
# Start again or rebuild after updating the source:
docker compose up --build -d
```
Do not use `docker compose down -v` unless you intend to delete the named data volume.
### Native binary + systemd
Build the frontend and binary as in the quick start, then install the binary on a compatible Linux host. The React assets are embedded, and DuckDB is in-process: no separate frontend server or database service is needed.
Configure a systemd service with:
- A dedicated unprivileged user and a writable persistent data directory, such as `/var/lib/finance-duck`.
- An absolute binary path and `-data /var/lib/finance-duck`.
- `-listen 127.0.0.1:8080` behind your VPN-only reverse proxy.
- `-public-url https://finance.example.internal` matching the browser origin.
- Banking environment variables and a private key readable by that service user.
- Automatic startup and a restart policy, such as `Restart=on-failure`.
The binary uses CGO/native DuckDB dependencies; it is not a universally portable static Go executable. Build for your target OS/architecture and provide the required native runtime libraries. On NixOS, deploy it with its runtime closure retained—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method. The repository currently provides `shell.nix`, not a production Nix package or service module.
### NixOS declarative OCI container
If you prefer `nixos-rebuild` to manage the service, use the included Docker image with `virtualisation.oci-containers` and Docker or Podman. Mirror the Compose configuration: one instance, loopback port mapping, persistent `/data`, a read-only private-key mount, runtime environment files, and the explicit `-public-url` command argument.
Build the image from the repository:
```sh
docker build -t finance-duck:local .
```
Make it available to the daemon used by the NixOS service, or publish it to a private registry and pin a version/digest. A rootless Docker image is not automatically available to the system Docker daemon or Podman.
Keep private keys and API keys in protected runtime files or a secrets manager. **Do not embed secret values in Nix expressions or copy them into the world-readable Nix store.** This is an available deployment approach, not an included ready-to-enable Finance Duck NixOS module.
### VPN and reverse-proxy requirements
For any deployment:
- Keep the application and proxy private to your VPN/firewall. A public DNS name or `-public-url` is not an access-control mechanism.
- Point your chosen hostname at the VPN-accessible proxy and configure trusted HTTPS there. Finance Duck itself serves HTTP.
- Forward to the application and preserve the public `Host` header. The application checks Host and mutation origins; forwarding only `X-Forwarded-Host` is insufficient.
- For native runs, configure `-public-url`; for the supplied Compose configuration, set `FINANCE_PUBLIC_URL`.
- Set `ENABLEBANKING_REDIRECT_URL` to that same origin plus `/api/banking/callback`, and register it exactly with Enable Banking.
- Allow outbound HTTPS to Enable Banking, and optionally OpenRouter. No public inbound bank webhook is required.
- Allow sufficiently long proxy requests for sequential bulk AI previews.
A direct bind to a VPN interface is also supported with `-listen <VPN-IP>:8080` and a matching `-public-url`, provided the network is private and your registered callback uses that same browser address. HTTPS through your existing private reverse proxy is the preferred setup.
## Optional OpenRouter setup
Set `OPENROUTER_API_KEY` in the server environment, or add it to the Compose `.env`, then restart/recreate the service and choose a supported model in **Settings**.
Bank imports do **not** require this key. Without AI, explicit merchant-default rules still work; unresolved transactions remain unclassified and editable.
Every AI classification requests `provider.data_collection = "deny"`, `provider.zdr = true`, and `provider.require_parameters = true`. Unsupported private routing fails rather than falling back to a less restrictive provider. Amount sharing is off by default. Keep OpenRouter account prompt logging disabled as well. Automatic redaction minimizes data; it is not a guarantee that arbitrary transaction prose is anonymous.
## Data, backups, and recovery
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus the separately stored secrets. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
Use **Settings → Rebuild index** while the app runs. For an offline rebuild, stop the service first and run:
```sh
./bin/finance-duck -data ./finance -rebuild
```
See [OPERATIONS.txt](OPERATIONS.txt) for the journal grammar, CSV formats and identity limitations, transfer matching, recovery, privacy boundaries, and reclassification behavior.
## Development checks
After building the frontend:
```sh
nix-shell --run 'go test ./... && go vet ./...'
nix-shell --run 'npm --prefix web run build'
```
Tests cover deterministic financial invariants and mocked provider HTTP behavior. Real bank consent and real OpenRouter access require your own application registration and credentials.