FINANCE DUCK — GO + REACT Local build on NixOS ------------------- 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. Create an account, then import its N26 CSV from Accounts. The application starts empty except for expense/income fallback categories. Create your category tree, tags, and merchants in the UI. Enable a merchant's default rule explicitly only when its category/tags are reliable; leave it disabled for ambiguous merchants such as Amazon. Tests: go test ./... The Go build embeds web/dist, so build React first. CGO and a C++ linker are required by the native DuckDB driver. shell.nix supplies the toolchain. React and CSS are served locally; there are no CDN requests or tracking scripts. Private deployment ------------------ There is NO application login. Default bind: 127.0.0.1:8080. For a VPN/reverse-proxy hostname, use an exact browser origin: ./bin/finance-duck -data /srv/finance -listen 127.0.0.1:8080 \ -public-url https://finance.example.internal Preserve the public Host header at the reverse proxy. The application rejects other Hosts and cross-origin writes. Configure a long proxy request timeout for bulk AI previews. Do not expose the reverse proxy to the public Internet. A VPN-address bind can be used instead, with the corresponding -public-url. Docker: docker compose up --build -d Compose publishes only 127.0.0.1:8080. Set FINANCE_PUBLIC_URL when proxying it. The container is unprivileged with a read-only root and a persistent named volume at /data. A host bind mount must be writable by UID/GID 10001. Never change the port mapping to public 0.0.0.0 without VPN/firewall isolation. OpenRouter ---------- Set OPENROUTER_API_KEY in the process environment, then choose a model in Settings. The model and chosen endpoint must support strict structured outputs and the configured privacy routing. Every classification request sets: provider.data_collection = deny provider.zdr = true provider.require_parameters = true No retry relaxes these requirements. OpenRouter must also have prompt logging disabled in your account settings. The underlying provider processes prompts; this is not local AI and cannot promise that a remote provider honors policy. Amounts and currency are omitted by default. Include Amount in Settings is explicit opt-in. Local account/provider IDs, known counterparty names, banking identifiers and recognizable references are stripped; candidate identifiers are per-request opaque tokens. Categories/tags and candidate merchant names are deliberately sent as classification context. Free-form text can contain unknown personal names, so automatic sanitization is not an anonymity guarantee. Conservative redaction can reduce recognition quality. Inspect your descriptions and do not configure an API key if no financial text may leave the server. Classification failures do not discard imports: facts are committed first and failed enrichment stays unclassified with an error visible in Transactions. Classification requests use one transaction at a time, not batches. Known merchant defaults can classify without any configured AI key. Enable Banking -------------- Register your application and public certificate with Enable Banking. For personal production access follow its linked-own-accounts registration rules: https://enablebanking.com/docs/api/linked-accounts/ Configure the allowed redirect URL to the exact externally reachable URL: https://finance.example.internal/api/banking/callback The browser must be able to reach this callback through your VPN. Environment variables (all three required when enabling banking): ENABLEBANKING_APP_ID= ENABLEBANKING_KEY_FILE=/run/secrets/enablebanking.key ENABLEBANKING_REDIRECT_URL=https://finance.example.internal/api/banking/callback Use a PEM RSA private key (PKCS#1 or PKCS#8, at least 2048 bits). Mount it read-only with permissions allowing the service user to read it. Never commit it to Git. The Compose file contains a commented example key mount; set the container-side KEY_FILE path when enabling that mount. Accounts shows a copyable callback URL. Register it exactly with Enable Banking and set ENABLEBANKING_REDIRECT_URL to the same value. The redirect carries a one-time code and state, not a reusable API key. Go verifies state, exchanges the code for a session_id, and persists session details locally with mode 0600. Accounts -> Connect: enter the exact Enable Banking institution name and country code (DE for Germany), then authorize through the bank. A successful callback immediately wakes the synchronization worker; Sync now is also available. The dashboard shows, for example, \"ING needs reconnection\" when consent expires or is revoked. Reconnect ING starts the same approval flow with that bank and country already selected. The new consent replaces the old account bindings without duplicating local accounts or their financial history. Transient provider errors are displayed separately from expired consent. Only booked transactions are persisted. Daily sync deliberately overlaps each account's last successful sync by 14 days; each new account first requests 90 days. Per-account cursors prevent newly connected or reactivated accounts losing history because another account synced recently. Older records can be imported using CSV. A failed provider call retains local data and is retried by the daily scheduler; Sync now can retry sooner. Balances are fetched on demand, with exact amount/currency/type values, rather than inferred from an incomplete historical journal. CSV and identity ---------------- The initial real CSV adapter is N26, not generic ING/Kontist CSV autodetection. It accepts comma/semicolon separators, German/English headers, UTF-8 BOM, quoted multiline descriptions, ISO/German dates and decimal point/comma. Required columns: Date / Datum / Booking Date / Buchungsdatum and Amount (EUR) / Betrag (EUR) (or Amount/Betrag with a currency column/account). Optional: Payee / Partner Name / Zahlungsempfaenger [with German umlaut], Payment reference / Verwendungszweck, Account number / IBAN, Value Date / Wertstellung, Transaction ID / Transaktions-ID. Use the original export, not spreadsheet-reformatted dates/numbers. Foreign original amounts/exchange-rate columns are not mistaken for account amounts. The initial application preserves currency but never converts or sums currencies. Stable provider entry references are preferred. Enable Banking transaction_id is NOT guaranteed stable and is not used as the primary identity. Fallback fingerprints retain identical-record occurrence counts: two identical rows remain two transactions, and repeat imports do not add two more. Without stable IDs, identical records from separately truncated exports are intrinsically ambiguous. Import consistent overlapping/full exports. Uncertain cross-source collisions are rejected rather than silently double counted; retain the error and reconcile the input locally before retrying. Facts are never silently replaced when upstream descriptions or amounts change for an existing identity. Transfers use reciprocal records from different owned accounts, equal/opposite exact amounts and matching currency, with own-IBAN evidence and unambiguous matching. Ambiguous pairs are not guessed. The linked records remain separate immutable facts; analytical double-entry postings balance and transfers do not count as income/spending. Populate local account IBANs to support recognition. Canonical files and recovery ---------------------------- finance/ config.toml model/amount opt-in only, no API keys accounts.finance categories.finance tags.finance merchants.finance journal/YYYY/YYYY-MM.finance state/sync-state.json sensitive local consent/session metadata cache/finance.duckdb disposable analytical projection The custom grammar is deliberately small: category { id: "cat_example" name: "Groceries" parent_id: "cat_expenses" kind: "expense" } A transaction block has facts: {...} and enrichment: {...} JSON-valued fields. Financial amounts are quoted decimal strings, never binary floating point. Up to four fractional digits are supported; arithmetic uses exact ten-thousandths with explicit overflow checks. DuckDB stores DECIMAL(24,4). Each block starts with account/category/tag/merchant/transaction and '{' on its own line; fields use name: JSON. Strings use JSON escaping (including \n for multiline descriptions). JSON values may span lines. Blank lines and full-line # or // comments are accepted between fields/blocks. Unknown fields, duplicate keys, malformed records, invalid references and taxonomy cycles are rejected. The grammar is version-one strict: extension/split fields are not accepted yet. Future format extensions require an explicit parser migration. Stable category IDs survive renaming and moving; assigned categories must remain leaves. Built-in roots and fallback leaves are protected. Move assigned records to another leaf before adding children to their former category. Category merges migrate referenced transactions/defaults; tag merges deduplicate links; tag deletion removes all affected links after UI confirmation. Merchant merging migrates transactions and retains source names/aliases on the target merchant. Unchanged blocks and comments retain their text. UI enrichment edits do not rewrite imported facts. File hashes detect external edits; invalid files stop loading/indexing with a file/line error, not a partially refreshed dashboard. A process lock prevents multiple app writers; use one instance per finance dir. Revision conflicts require refreshing/re-previewing, not blind overwriting. Multi-file writes are recoverable and logically atomic within the application. Do not run external writers during a commit; external tools do not participate in the app's process lock. Stop the service for manual bulk edits or backups. If a write is interrupted, keep all state files and restart for recovery before editing the journal manually. Keep backups of the entire canonical directory, including hidden/state recovery files, plus the separately stored secrets. The cache can be excluded. Avoid exposing any financial directory through a static file server, Git public remote, or unencrypted shared backup. Rebuild from text: ./bin/finance-duck -data ./finance -rebuild Stop the running app before using that command (single writer lock), or use Settings -> Rebuild index while it runs. A broken/deleted DuckDB file can be removed while stopped and regenerated; it never contains the only copy of financial records. When an index rebuild fails, UI mutations still preserve canonical data and the index error is surfaced rather than serving stale totals. Reclassification ---------------- AI / Classification: choose dates, model and independent Merchant/Category/Tags fields. Analyse produces a read-only preview. Apply all/selected writes all selected changes in one canonical commit; financial facts never change. A manual edit, external journal change or taxonomy change invalidates old previews. Previews are kept in memory for up to one hour and disappear on restart. Cancel writes nothing. Transfers are skipped, and unselected fields are preserved. Failed rows remain unchanged and are listed separately from proposed changes. Boundaries and verification --------------------------- There are no splits, budgets, investments, tax/invoice/receipt processing, login/multi-user support, arbitrary SQL or natural-language query execution. Natural-language query DSL and Sankey exploration remain explicitly later work. There is no browser-to-bank credential handling or payment initiation. Automated tests exercise deterministic financial invariants and mock remote provider HTTP behavior. They do not replace testing real consent renewals and real booked transaction samples for your banks. No real user credentials or financial history are included in the repository. Test fixtures are synthetic. API documentation sources: https://enablebanking.com/docs/api/reference/ https://openrouter.ai/docs/guides/features/structured-outputs https://openrouter.ai/docs/guides/features/zdr https://openrouter.ai/docs/guides/routing/provider-selection