Files
finance-duck/README.md
T

434 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. N26, ING, Kontist, and Scalable Capital CSV imports and bank synchronization work without AI. An investment account tracks positions by ISIN alongside its cash, and reconciles both against your broker's own figures. Optional OpenRouter enrichment sends the transaction date, signed amount, currency, merchant/counterparty text, and a complete registry of editable classification choices through restrictive private routing.
> **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, ING, or Kontist 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. **Settings → Enable Banking credentials** shows the exact callback URL to register, including scheme, hostname, port and path. On the deployed server it is `https://nixos.taile9e6d9.ts.net:8444/api/banking/callback`.
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.
**Settings** shows a copyable callback URL for the current browser origin and saves it with the application credentials. **Accounts** displays the configured callback URL, or suggests the current origin before configuration.
### 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.
- Upload **`secrets/enablebanking.key`** through Finance Duck's Settings, and back up your original key securely. Never upload it to Enable Banking in place of the public certificate.
- 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 a single unencrypted PEM RSA key in PKCS#1 or PKCS#8 format, at least 2048 bits and no larger than 32 KiB. 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 in Settings
Open **Settings → Enable Banking credentials**:
1. Enter the application ID from Enable Banking.
2. Select the matching private-key `.pem` or `.key` file.
3. Register the displayed callback URL in Enable Banking if you have not already.
4. Click **Save configuration**, then proceed to **Accounts** to authorize your bank.
Saving validates the local key and callback format and applies the provider immediately; it does not contact Enable Banking, register an application, or grant bank consent. **Configured** means locally configured, not externally verified. No SSH, environment file, Nix rebuild, or restart is required.
For the same application ID, leave the upload empty to keep the saved key, or upload a replacement to rotate it. Existing bank sessions and sync cursors are preserved. Changing the application ID requires a new upload and invalidates old local bank connections, so reconnect afterward. **Remove configuration** disables banking and invalidates local connections after confirmation; accounts and transaction history stay intact. It does not revoke consent at your bank. Successful configuration changes also cancel pending authorization flows.
The private key and application settings are stored atomically in `state/enablebanking.json` beneath the data directory, with permissions `0600`. The key is never returned by the API or saved in browser storage. Banking sessions are bound to that configuration's application generation so old sessions cannot be reused after switching applications or removal, even across restart. Protect backups of the entire data directory.
#### Optional environment-managed setup
For headless configuration, all three variables below remain a startup fallback **only when no saved banking configuration exists**. A UI-saved configuration, including explicit removal, overrides them. Environment changes require a restart; malformed saved credentials fail closed rather than reverting to the environment.
```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).
### 5. Authorize from the UI
**Before continuing:** link every account you want to use in Finance Duck through the Enable Banking control panel. The **Connect your bank** flow cannot connect an account until it has been linked to the Enable Banking application there.
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. Set **History to import (months)**: **12** by default, or another whole number from **1 to 120**.
4. Click **Authorize bank**.
5. 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 the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Automatic synchronization then runs **twice a day**, every **12 hours** after the last successful run, overlapping each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization at any time. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV.
**HTTP 429 is a provider rate limit, not evidence that bank consent has expired.** Bank reads honor `Retry-After` and use bounded exponential retries. A longer or exhausted limit pauses further requests until the reported retry time; failed accounts keep their previous sync cursors and imported data. Session checks use the saved account metadata rather than fetching every account's details again. A failed session is reported once instead of also marking each of its accounts unavailable. After the cooldown, **Sync now** can retry; the warning clears after a successful sync. One-time authorization and code-exchange requests are never automatically replayed.
**A rate-limited sync is a wait, not a fault.** While every failing bank has supplied a retry time, the dashboard reports that synchronization retries by itself after that moment, the account card shows a rate-limit badge instead of a connection error, and the background scheduler sleeps until the deadline rather than retrying hourly into a refusal it already knows about. **Sync now** still tries immediately. Any failure without a supplied deadline keeps the hourly retry, and its cause is named where Finance Duck can determine it locally: an expired consent, an HTTP status, an unreachable provider, or a response it cannot use, such as a booked transaction without a booking date. Provider response text is never displayed.
Manual **Sync now**, **Import older history**, and balance requests forward the requesting user's IP, browser User-Agent, and available Accept headers to the bank as PSU metadata. Scheduled syncs never claim a user is present. This distinction matters: [Enable Banking documents background limits of roughly four fetches per day at many banks](https://enablebanking.com/docs/faq/#why-am-i-getting-429-response-code-are-there-rate-limits-for-the-api). A confirmed background `ASPSP_RATE_LIMIT_EXCEEDED` defers that account's affected endpoint for at least six hours, preserving longer provider hints; it does not block an eligible user-initiated fetch. General provider limits still apply to both. Bank requests are spaced by at least one second, with longer learned spacing after throttling.
### Import older history for a connected account
Open **Accounts**, find the account, and click **Import older history**. Choose **Months back** (default **12**, whole numbers from **1 to 120**) and confirm. This requests that account's booked transactions from the selected number of calendar months ago through today; the bank may provide less history.
The dialog reports how many new transactions were imported, including zero when nothing new was found. Repeated or overlapping ranges skip existing transactions. New records use the normal import and classification process. The account's regular sync cursor, last-sync time, and saved initial-history choice stay unchanged; other accounts are not fetched. Inactive connected accounts can also use this explicit action. If authorization has expired, reconnect first.
### 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. Correct certificate registration in the Enable Banking control panel and update the matching private key in **Settings** (or your optional environment-managed setup).
## Import a CSV statement
Open **Accounts → Import a statement**, choose the account, select the export, and click **Review statement**. Uploading imports nothing: it parses the file and opens a review dialog showing the detected export, the column mapping, how many records are new or already imported, and a sample of the parsed transactions with their dates, descriptions, counterparties, and signed amounts. **Import N transactions** commits exactly those records; **Cancel**, a reload, or a journal change in between commits nothing.
**N26**, **ING** (Umsatzanzeige, including its metadata preamble and Windows-1252 encoding), **Kontist**, and **Scalable Capital** exports are recognized on your own machine, with no AI involved. Comma, semicolon, and tab separators, UTF-8 with or without BOM, CRLF, quoted multiline descriptions, ISO and German dates, and both decimal separators are accepted. Use the bank's original export rather than a spreadsheet-reformatted copy — a spreadsheet round-trip is what drops a decimal comma. Uploads are limited to **2 MiB**, and a prepared statement expires after **one hour**.
Any other layout needs a saved OpenRouter key and model, which maps the **columns** rather than reading the transactions: the request carries the delimiter, the column names, and up to four sample rows in which every letter is replaced by `x` and every digit by `0`. Descriptions, counterparties, references, IBANs, and amounts are never sent. The proposal must name existing columns, choose exactly one money convention (one signed amount column, or a debit and credit pair), and use a supported date and decimal format; anything else is rejected instead of guessed. Because a proposed mapping can still be wrong, check the sample's dates, signs, and currency before confirming.
Reimporting the same statement adds nothing: the review dialog reports the overlap as already imported. A statement whose currency conflicts with the account, or whose records cannot be parsed, is rejected whole rather than imported in part.
## Track investments
Set an account's **kind** to **Investment** in **Accounts**, then import a **Scalable Capital** transaction export into it. The account then holds both a cash balance and positions, and **Wealth** reports them.
A broker export is not a list of interchangeable statement lines, so it is read by its own parser rather than by a column mapping. The same `amount` column means three different things:
| Row | `amount` is | Settles |
| --- | --- | --- |
| `Deposit`, `Withdrawal`, `Fee`, `Interest`, `Distribution` | the money that moved, **already net of tax** | cash only |
| `Buy`, `Sell`, `Reinvestment_Distribution` | a gross, pinned to shares × price | `amount fee tax`, plus the position |
| `Corporate action`, `Security transfer` | a **position valuation** | **no cash at all** |
Because a cash row's amount already includes the tax the broker withheld or refunded, that tax is recorded on the record and never subtracted again; the review dialog lists every such figure before you confirm. Corporate actions and depot transfers move a position without moving money — treating their amount as cash would invent or destroy it, and a depot switch does that once per instrument.
Only `Executed` rows import: a cancelled retry is all zeros, so it passes every arithmetic check and would otherwise become a phantom trade. Every security row is verified against shares × price at full precision. An unknown row type, an unknown status, a mismatched currency, a missing ISIN, or a failed check rejects the **whole file** with the record number, because each of those can move money that never moved.
Securities are registered by **ISIN** in **Wealth → Instruments**. The ISIN is the identity; the name is editable display text, because one ISIN appears under several broker descriptions over the years. Set the account's **settlement IBAN** so deposits from your bank pair with the funding account: a broker export has no counterparty column, and without it those rows stay unpaired. They never become income either way — a broker record is excluded from spending and income analytics, from bulk reclassification, and from the AI entirely.
**Verify it yourself.** **Wealth** shows each account's cash balance, its positions as exact share counts, and named checks — row arithmetic, cash never negative, holdings never negative. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened.
Deliberately **not** included: market prices, market value, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis.
## Deployment options
| Option | Best fit | Included support |
| ------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------- |
| **Native NixOS service** | Deployment on your own NixOS machine | Pinned flake package and one systemd service, with persistent state and runtime credentials. |
| **Docker Compose** | A private server using containers | Dockerfile and Compose configuration package the binary and frontend together. |
| **Manual native run** | Development or troubleshooting | The quick-start command runs in your terminal. |
**Run one application instance per canonical finance directory.** React is compiled and embedded in the Go binary: production needs neither Node.js nor a separate frontend service.
### Native NixOS service (recommended on NixOS)
Add this input to your host flake after committing and pushing the deployment files:
```nix
inputs.finance-duck.url = "git+https://git.larsnolden.com/lars/finance-duck.git";
```
Include `finance-duck` in your flake's `outputs` arguments, then add these entries to your existing `nixosSystem.modules` list:
```nix
finance-duck.nixosModules.default
{
services.finance-duck = {
enable = true;
publicURL = "http://localhost:8080";
# Optional, once you have installed the runtime credential file:
# environmentFile = "/var/lib/finance-duck-secrets/environment";
};
}
```
Keep your existing host modules, including OpenClaw, unchanged. **Do not make this input's `nixpkgs` follow the host's NixOS 24.11 input:** Finance Duck needs Go 1.24 or newer. The package uses its own pinned toolchain; the service module works on the older host.
Deploy and inspect:
```sh
sudo nixos-rebuild switch --flake /etc/nixos#nixos
systemctl status finance-duck
journalctl -u finance-duck -f
```
The service runs as `finance-duck`, listens on **127.0.0.1:8080**, and stores all canonical data, settings, provider state, and the disposable DuckDB cache in **/var/lib/finance-duck**. It creates that directory with mode `0700`; rebuilds do not replace it. No firewall ports, VM, container, reverse proxy, or Tailscale configuration are created.
For access from another machine, an SSH tunnel is sufficient: `ssh -L 8080:127.0.0.1:8080 your-server`, then open `http://localhost:8080`. For your existing Tailscale setup, instead set `publicURL` to the host's **actual full MagicDNS HTTPS origin**, for example `https://nixos.YOUR-TAILNET.ts.net:8444`, rebuild, and publish the loopback listener:
```sh
sudo tailscale serve --bg --yes --https=8444 http://127.0.0.1:8080
```
Choose an unused port; `8444` leaves the existing OpenClaw `8443` endpoint alone. Keep access restricted to trusted tailnet users. This application has no login; do not enable Funnel or expose it publicly. The Enable Banking callback must use that exact HTTPS origin plus `/api/banking/callback`.
**Optional environment-managed credentials:** both Enable Banking and OpenRouter can be configured directly in **Settings**, without these steps. If you prefer a banking startup fallback, create a private runtime directory and install the key after the first rebuild creates the service account:
```sh
sudo install -d -o root -g finance-duck -m 0750 /var/lib/finance-duck-secrets
sudo install -o root -g finance-duck -m 0640 secrets/enablebanking.key \
/var/lib/finance-duck-secrets/enablebanking.key
sudo touch /var/lib/finance-duck-secrets/environment
sudo chmod 0600 /var/lib/finance-duck-secrets/environment
sudoedit /var/lib/finance-duck-secrets/environment
```
Put the three `ENABLEBANKING_*` variables from [bank setup](#optional-environment-managed-setup) in that file together, using `ENABLEBANKING_KEY_FILE=/var/lib/finance-duck-secrets/enablebanking.key`. Enable `environmentFile` in your Nix configuration and rebuild. Later environment-file changes require `sudo systemctl restart finance-duck`. An optional `OPENROUTER_API_KEY` provides the equivalent AI startup fallback. For each provider, saved Settings configuration or explicit removal takes precedence over its environment fallback. Never place private keys or credential values in Nix configuration or the Nix store.
**Existing local data:** this service does not automatically adopt `./finance`. Stop the previous instance, back it up, stop `finance-duck.service`, and copy the entire data directory into `/var/lib/finance-duck`, owned by `finance-duck:finance-duck`. Never run two writers against the same directory.
**Updates:** update the host's Finance Duck input with `nix flake update finance-duck` in `/etc/nixos`, then run `nixos-rebuild switch` again. Normal NixOS rollback restores the binary, not financial data; keep separate data backups. To build without installing a service, run `nix build` in this repository (the new flake and `nix/` files must be tracked by Git).
#### Deployed server
The server at `bender@100.87.224.3` serves Finance Duck at **https://nixos.taile9e6d9.ts.net:8444**, accessible only through Tailscale. Port 443's existing Funnel and OpenClaw on port 8443 are separate and unchanged.
```sh
ssh -t bender@100.87.224.3 'sudo finance-duck-update'
# Deploy edits already in the server checkout without pulling:
ssh -t bender@100.87.224.3 'sudo finance-duck-update --no-pull'
```
The command pulls `~/projects/finance-duck` with `git pull --ff-only`, snapshots its Git-tracked working files to `/var/lib/finance-duck-source`, updates only the host's `finance-duck` flake input, rebuilds NixOS, and checks backend health. New files must be tracked with Git to enter the snapshot. Ignored data, untracked secrets, and `.git` are excluded; never track credentials. The host's Nixpkgs and NixVirt pins are not updated.
Host configuration lives in `/etc/nixos/flake.nix` and `/etc/nixos/modules/finance-duck.nix`; the latter defines the service settings, tailnet proxy, and updater. Application data lives separately in `/var/lib/finance-duck`, owned by `finance-duck` with mode `0700`. The initial deployment has no bank/AI credentials and does not import workstation data.
Application and deployment source are tracked in the project repository. Commit and push changes before running the normal updater; `--no-pull` can deploy tracked local edits. Git refuses an update that would overwrite conflicting local edits. The updater never stashes, resets, commits, or pushes automatically. See `OPERATIONS.txt` for service inspection, backup, and rollback commands.
### Docker Compose
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.
- Provider credentials configured through **Settings**, or optional 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, use the native flake package and service module above to retain its runtime closure—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method.
### 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.** The included NixOS module runs the native package; an OCI deployment requires your own container configuration.
### 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`.
- Register the callback shown in **Settings → Enable Banking credentials**: the browser origin plus `/api/banking/callback`. For optional environment-managed setup, set `ENABLEBANKING_REDIRECT_URL` to that same URL.
- 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
Open **Settings → OpenRouter credentials**, paste your API key, and click **Save key**. Then choose an exact OpenRouter `provider/model` identifier under **Classification preferences** and save those preferences. No SSH, Nix configuration changes, or service restart is needed.
Use the complete identifier, for example **`deepseek/deepseek-v4.1-flash`**, not just `deepseek-v4.1-flash`. Verify identifiers in OpenRouter's model catalog rather than relying on a model's display name.
Use **Replace key** to rotate the credential or **Remove key** to disable AI. Changes apply to future classifications immediately and survive restart; an already-running classification keeps the key it started with. “Configured” means a key is present, not that OpenRouter has accepted it. A successful **AI classification → Analyse** request checks the key, model, and private routing together.
The key is stored separately from preferences and journals in `state/openrouter.json` beneath your data directory (`/var/lib/finance-duck/state/openrouter.json` on the deployed server), as a private `0600` plaintext file. The API never returns it, and the UI clears the password field after saving rather than storing it in browser storage. Treat data-directory backups as containing credentials. Anyone able to access this no-login application can replace or remove the key: keep access restricted to trusted tailnet users.
For administrator-managed startup configuration, `OPENROUTER_API_KEY` in the server environment or Compose `.env` remains an optional fallback **only while no saved credential file exists**. A UI-saved key takes precedence; **Remove key** persists an explicit disable, so a restart cannot silently restore the environment key. Environment-only changes require a restart/recreate.
Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not** require this key; only mapping an unrecognized CSV layout does. Without AI, explicit merchant-default rules still work; unresolved transactions remain unclassified and editable.
**Classify newly imported transactions with AI** under **Classification preferences** controls whether importing contacts the provider at all. It covers CSV imports and bank synchronization, is on by default, and is stored as `classify_on_import` in `config.toml`. With it off, no import makes a provider request: enabled merchant rules still classify, and everything else arrives unclassified and editable without a failure that would suggest the provider was unreachable. **AI classification → Analyse** still works on demand, so you can review a batch deliberately instead of on every import.
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers, payment references, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence; low-confidence results retain the merchant and tags but use the kind-appropriate unclassified category and appear in **Transactions → Needs review**.
From **Categories**, **Propose taxonomy** samples up to 300 redacted transactions, grouped so recurring counterparties are represented without sending raw identifiers. The proposal can suggest categories, tags, and merchants with hints and evidence. Approve each item individually; applying it also creates any approved category parents required by the hierarchy. Existing registry entries and transaction facts are never overwritten.
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, date, and currency are always included; identifier-only redaction removes account and transaction identifiers, payment references, and configured private names but does not remove merchant or counterparty text. Keep OpenRouter account prompt logging disabled as well.
Classification spaces request starts by at least **three seconds**, including successful requests, rather than sending a burst between 429s. This is a conservative application policy, not a published quota for every model. On HTTP 429, backoff starts at **15 seconds** and increases across consecutive failures; `Retry-After` seconds or HTTP dates can extend the wait. Successful retries retain the learned spacing (up to **30 seconds**) instead of immediately bursting again. Each operation makes at most **four attempts**, with at most **two minutes of automatic retry waiting**, preserving the same model, sanitized prompt, and privacy controls. Imports and previews share this pacing and cooldown. Long or exhausted limits leave records unclassified with a retry-time error; local merchant rules still work. After the cooldown, run **AI classification → Analyse** again for previously failed records—repeating a bank import does not reclassify existing transactions.
## Data, backups, and recovery
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. 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.