Import Trade Republic exports, whose conventions invert Scalable's

A second broker export is recognized locally, by its full column set, and read
through the same pipeline: detection and parsing now dispatch on the format, so
the upload path, the review dialog, deduplication, the journal and the Wealth
report are unchanged. Its nine row types cover cash transfers, interest,
dividends, tax settlements and trades in funds, shares and crypto; none of them
moves a position without moving cash, so the cash-neutral class that Scalable's
corporate actions belong to does not arise here.

Three of its conventions are the opposite of the export already supported, and
reading any of them the other way round moves money. Fee and tax are the signed
adjustments it made to the cash rather than deductions from a gross, so a one
euro order fee arrives as -1.00 and is negated at import; the journal keeps one
convention and the domain never learns that two exist. A cash row's amount is
the gross, not the net, so interest of 16.46 with -4.33 of tax credits 12.13 -
where the other export states its cash already net and its tax is recorded and
never applied. Whether a cash row carries a gross now decides which of those it
was, which also makes the first kind's settlement checkable and stops the Wealth
report from claiming a figure was left unapplied when it was not. And a
TAX_OPTIMIZATION row puts zero in the amount column and its money in the tax
column, signed both ways: read as cash, all six in a real export move nothing.

Two more rows lie about their own columns. A dividend fills the share column
with the holding the dividend was paid on, not with a position change, so adding
it would double the holding. Crypto carries a bare ticker in the symbol column
and its ISIN-shaped identifier only in the description, so the identifier is
taken from the symbol when that is an ISIN and otherwise from the one the
description names; a position row resolving to neither is refused rather than
attached to a guess.

The shares-times-price check now holds a gross to the precision the export
stated it at rather than to four places. This export prints the notional rounded
to cents, and 29 of 59 real trades do not land on a whole cent: demanding
exactness rejected half a portfolio. One unit of the stated precision is still
four orders of magnitude tighter than the misplaced separator the check exists
to catch, and where an export prints the full product the check stays exact.

A unit price moves from money to the eight-place quantity type, because a crypto
price is quoted to six and rounding it would break the check the amount is
verified against. Trailing zeros are dropped before any precision test: this
export pads a six-place price to ten, and the padding would otherwise exhaust
the precision the value needs.

A transfer's counterparty comes from the export's own IBAN column when it has
one, from the IBAN the description names in parentheses when it does not, and
from the account's configured settlement IBAN when neither names anything. Free
text contributes only a value shaped like an IBAN. Without this, 108 transfers
stay unpaired and their bank-side counterparts read as spending and income.

Verified end to end against a real export: 26 rows import to a cash balance of
32187.02 matching the figure computed by hand from the source rows, all four
positions close at exactly zero, and every trade satisfies its own arithmetic.
This commit is contained in:
Lars Nolden
2026-09-11 23:04:22 +02:00
parent 87f052a3ea
commit 762ad3fae5
12 changed files with 1059 additions and 288 deletions
+14 -6
View File
@@ -2,7 +2,7 @@
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.
Imported bank facts are separate from editable merchant, category, and tag classifications. N26, ING, Kontist, Scalable Capital, and Trade Republic 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.
@@ -174,7 +174,7 @@ Reconnecting renews bank consent, not your application registration. Correct cer
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**.
**N26**, **ING** (Umsatzanzeige, including its metadata preamble and Windows-1252 encoding), **Kontist**, **Scalable Capital**, and **Trade Republic** 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.
@@ -182,9 +182,9 @@ Reimporting the same statement adds nothing: the review dialog reports the overl
## 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.
Set an account's **kind** to **Investment** in **Accounts**, then import a **Scalable Capital** or **Trade Republic** 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:
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. In a Scalable export the same `amount` column means three different things:
| Row | `amount` is | Settles |
| --- | --- | --- |
@@ -194,9 +194,17 @@ A broker export is not a list of interchangeable statement lines, so it is read
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.
**Trade Republic inverts three of those conventions**, which is why it gets its own parser rather than a second mapping:
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.
- `fee` and `tax` are the **signed adjustments it made to your cash**, not deductions — a one euro order fee is written `-1.00`. Both are negated at import so the journal keeps one convention.
- a cash row's `amount` is the **gross**: interest of `16.46` with `-4.33` of tax credits **12.13**.
- a `TAX_OPTIMIZATION` row puts `0.00` in `amount` and its money in the **`tax`** column, signed both ways. Read as cash, every one of them moves nothing.
Two more traps there: a `DIVIDEND` row fills the share column with **the holding the dividend was paid on**, so adding it would double the position; and crypto carries a bare ticker like `DOGE` in `symbol`, with its real identifier only in the description. Both are handled, and a position row that resolves to neither is refused.
Only `Executed` rows import from Scalable: 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 **to the precision the broker stated the amount at** — exactly, where the export prints the full product; to within a cent, where it prints the notional rounded. An unknown row type, a mismatched classifying column, a foreign settlement currency, an unresolvable security, 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 **Instruments**. The ISIN is the identity; the name is editable display text, because one ISIN appears under several broker names over the years. Crypto is held under the ISIN-shaped identifier the broker issues for it. Set the account's **settlement IBAN** for an export that names no counterparty of its own, so deposits from your bank pair with the funding account instead of staying 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.