Compare commits

..
2 Commits
Author SHA1 Message Date
Lars Nolden cc5912ece2 Rebuild the overview around where the money actually went
The page reported three totals, a net-per-month bar chart and six ranked lists.
That answers how much moved, never where it went, and the one chart carrying a
shape printed its full formatted amount above every 43px column: eleven values
collided into a single line of text, the dates read as 2025-11, and negative
months were grey while positive ones were green, so the sign of a month was the
one thing the colour did not say. The dashboard now answers four questions in
the order a person asks them - am I ahead, where did it go, what changed, and
what is committed - and every panel is a click into the transactions behind it.

Monthly cash flow becomes a measured SVG chart: income drawn above the zero
line in the brand green, spending below it in the danger red, and net as a line
whose dot takes the colour of its sign. Exact figures move into a hover tooltip
that names the month, both directions, the net and the transaction count, so the
plot area carries a y-axis of about three gridlines a side instead of eleven
overlapping labels, and the month axis prints Nov with the year only where the
year changes. The chart measures its own content box through a ResizeObserver
and draws at real pixel size rather than scaling a viewBox, because scaled axis
text is the wrong weight at every width except one. A month with no activity is
filled in as an explicit zero: it is a real answer, not a gap to close.

A six-month window is the default view, long enough to show a trend and a
seasonal bill and short enough that the current month still matters. The window
starts on the first of a month so the buckets are whole, leaves its upper bound
open so it always reaches today, and lives in the shared filter bar next to
1M/3M/12M/YTD/All, so the transactions page inherits the same framing. Reset
returns to six months rather than to all of history.

Where the money went is a Sankey, because the question is literally a flow: the
income categories a user named, through one trunk, into the categories that
consumed it. Both columns balance by construction - a surplus is a node called
Left over on the right, a deficit is one called Drawn from reserves feeding the
trunk from the left - so an overspend is visible as money entering from outside
the period rather than as a total that silently fails to add up. Ancestor
rollups already include their descendants, so a root's unexplained remainder
becomes its own slice and the columns stay honest. Beside it, a donut ranks the
same spending by share, and the category tree keeps the drill-down it had.

What changed compares spending per leaf category against the preceding interval,
which required the analytics index to return that interval's categories as well:
categoryGroups is now a constant run over both filters, so the two sides of a
delta cannot disagree about how a parent rolls up. Monthly stops being a list of
Group rows carrying only a net and becomes MonthlyPoint, with income and
spending as separate positive magnitudes and net as the only signed figure,
which is what a two-sided chart needs and what a single SUM could not give.

Biggest payments keeps one row per payee. Ranking outflows by amount returned
the same rent six times, which explains nothing; the window now picks each
merchant's single largest payment before the per-currency ranking, and a fact
with no merchant competes as itself. Both windows order by the DECIMAL column
rather than by its VARCHAR rendering, which would sort -0.0009 ahead of
-900719925474.0991. The per-currency partition stays: one busy currency must not
crowd another out of its own list.

Two figures are withheld rather than printed wrong. A savings rate is net over
income, and a part-month carrying only an interest credit read -10268% kept;
below -100% the outflow was more than twice the income and that sentence is the
answer, so the ratio is replaced by it. Period-over-period change truncates
instead of rounding, because a 99.6% fall rendered as -100% claims the figure
went to zero.

Charts are per currency by their nature, and four copies of every panel is not a
dashboard, so the busiest currency leads and a chip row switches between them.
That is a view choice and not a filter: it never narrows the data the totals or
the ranked lists were computed from.

Verified against a running instance on a generated twelve-month, three-account,
two-currency journal. The June tooltip reports in 5,701.80, out 2,611.95, net
3,089.85 over 26 transactions, matching /api/dashboard exactly. A single-month
window with 16.99 of income against 1,761.59 of spending shows the net in red
below the axis, withholds the savings rate, and puts 1.7k of Drawn from reserves
into the trunk against 1.6k of Housing. Clicking the Housing node lands on the
transactions page filtered to Expenses / Housing with eighteen rows and the
period intact, and the whole page stacks and stays legible at 430px.
2026-09-11 23:35:46 +02:00
Lars Nolden 762ad3fae5 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.
2026-09-11 23:04:22 +02:00
20 changed files with 3093 additions and 668 deletions
+115 -31
View File
@@ -357,8 +357,8 @@ mandate reference must never become a transaction identity. ING facts likewise
carry no reference. Review the previewed dates, amount signs and currency before carry no reference. Review the previewed dates, amount signs and currency before
confirming; a wrong mapping is visible there, not after import. confirming; a wrong mapping is visible there, not after import.
Import sources: n26_csv, ing_csv, kontist_csv, scalable_csv, csv (AI-mapped), Import sources: n26_csv, ing_csv, kontist_csv, scalable_csv, traderepublic_csv,
enablebanking. csv (AI-mapped), enablebanking.
Stable provider entry references are scoped by account, source and debit/credit Stable provider entry references are scoped by account, source and debit/credit
direction: a debit and credit can share a reference without being collapsed. direction: a debit and credit can share a reference without being collapsed.
@@ -392,11 +392,17 @@ Investment accounts and broker imports
-------------------------------------- --------------------------------------
An account has a kind, "cash" (the default, and what an absent kind means) or An account has a kind, "cash" (the default, and what an absent kind means) or
"investment". An investment account holds a cash balance and positions. It also "investment". An investment account holds a cash balance and positions. It also
carries a settlement IBAN (reference_iban): a broker export has no counterparty carries a settlement IBAN (reference_iban), used when an export names no
column, so deposits and withdrawals are stamped with that IBAN and pair with the counterparty of its own, so deposits and withdrawals pair with the funding
funding account through ordinary transfer matching. Leave it empty and those account through ordinary transfer matching. Leave it empty and those rows simply
rows simply stay unpaired, which costs accuracy in spending analysis but never stay unpaired, which costs accuracy in spending analysis but never invents
invents income. income.
Two broker exports are recognized locally, by their complete column set. A
layout is matched whole because a row's meaning depends on a combination of its
classifying columns, so a partial match is a different file wearing the same
names. Everything below about events, instruments, precision and the checks
applies to both; the per-export differences are listed under each.
Scalable Capital exports (scalable_csv) are recognized locally by their full Scalable Capital exports (scalable_csv) are recognized locally by their full
column set: date, time, status, reference, description, assetType, type, isin, column set: date, time, status, reference, description, assetType, type, isin,
@@ -437,35 +443,50 @@ The share column is signed only for corporate actions and depot transfers. Buys
and sells are unsigned and take their direction from the type. Both conventions and sells are unsigned and take their direction from the type. Both conventions
are resolved at import, once. are resolved at import, once.
Every security row is checked against shares times price at full precision. Every security row is checked against shares times price, to the precision the
This is the only check that catches a lost decimal separator, and it cannot export stated the amount at and no further. One export prints the exact product
catch one that was lost uniformly across a row: 1 x 25,795 and 1 x 25795 both to nine places, and the check is then exact. Another prints the notional rounded
satisfy it. A price cross-check against an outside provider is the only remedy to cents, where demanding exactness rejects every trade whose product does not
and is deliberately not implemented. land on a whole cent - measured on a real export, 29 of 59 of them. One unit of
the stated precision is still four orders of magnitude tighter than the
misplaced decimal separator this check exists to catch.
Rejected whole, with the record number: an unknown status, an unknown type, an It cannot catch a separator lost uniformly across a row: 1 x 25,795 and
assetType that disagrees with its type, a currency other than the account's, a 1 x 25795 both satisfy it. A price cross-check against an outside provider is
security row without an ISIN, an invalid ISIN, a signed buy or sell, a corporate the only remedy and is deliberately not implemented.
action or depot transfer carrying a fee or tax, and any failed arithmetic check.
A zero amount is accepted; it corrupts nothing, and a free share allocation is
legitimately priced at zero.
Money holds four decimal places and share counts hold eight. An amount is the Rejected whole, with the record number: an unknown status, an unknown type, a
row's share count times its price, so it carries as many decimal places as the classifying column that disagrees with its type, an account type other than the
two together need: a reinvested distribution in a real export reaches nine, one the import targets, a currency other than the account's, a security row
past both. Amounts are therefore read at arbitrary precision, rounded to four without a resolvable identifier, an invalid ISIN, a signed buy or sell where the
places half away from zero, and the exact discarded residue is summed and export leaves them unsigned, a corporate action or depot transfer carrying a fee
reported in the import review rather than hidden. A share count or a price or tax, and any failed arithmetic check. A zero amount is accepted; it corrupts
beyond its own precision is refused instead of truncated: rounding a share nothing, and a free share allocation is legitimately priced at zero.
count misstates a holding, and rounding a price would break the shares-times-
price check that the amount is verified against. Money holds four decimal places; share counts and unit prices hold eight. An
amount is the row's share count times its price, so it carries as many decimal
places as the two together need: a reinvested distribution in a real export
reaches nine, past both. Amounts are therefore read at arbitrary precision,
rounded to four places half away from zero, and the exact discarded residue is
summed and reported in the import review rather than hidden. Trailing zeros are
padding, not precision: an export that writes a six-place price to ten places is
read at six. A share count or a price beyond eight places is refused instead of
truncated: rounding a share count misstates a holding, and rounding a price
would break the check the amount is verified against.
Fee and tax are always stored as deductions from a gross, so a refunded tax is
a negative deduction, and an export that writes its fee as the negative
adjustment it made to the cash is normalized once, at import. Whether a cash
row's amount is already net of its tax, or a gross the deductions still apply
to, is a fact about the source and is decided there too.
Instruments are registered from the export, keyed by ISIN, with an ID derived Instruments are registered from the export, keyed by ISIN, with an ID derived
from the ISIN so re-importing never creates a second entry for one security. One from the ISIN so re-importing never creates a second entry for one security. One
ISIN appears under several descriptions over the years and sometimes under the ISIN appears under several names over the years and sometimes under the ISIN
ISIN itself; the most recent real description names it, and an import never itself; the most recent real name wins, and an import never renames an
renames an instrument that already exists. The name is editable display text; instrument that already exists. The name is editable display text; the ISIN is
the ISIN is identity and cannot be changed. identity and cannot be changed. Crypto is held under the ISIN-shaped identifier
the broker issues for it, so it needs no separate identity scheme.
A broker reuses one reference across every leg of an economic event: the cash A broker reuses one reference across every leg of an economic event: the cash
and position sides of a corporate action arrive with the same reference byte for and position sides of a corporate action arrive with the same reference byte for
@@ -474,6 +495,69 @@ Transaction identity therefore includes the event and its instrument. The
reference itself also embeds an account-level identifier that repeats across reference itself also embeds an account-level identifier that repeats across
unrelated events, so it is evidence of an event, never of a transaction. unrelated events, so it is evidence of an event, never of a transaction.
Trade Republic exports and their differences
-------------------------------------------
Trade Republic exports (traderepublic_csv) are recognized by their full column
set: datetime, date, account_type, category, type, asset_class, name, symbol,
shares, price, amount, fee, tax, currency, original_amount, original_currency,
fx_rate, description, transaction_id, counterparty_name, counterparty_iban,
payment_reference, mcc_code.
Nine row types, classified by category and type:
category type cash position
CASH TRANSFER_INBOUND amount -
CASH TRANSFER_INSTANT_INBOUND amount -
CASH TRANSFER_OUTBOUND amount -
CASH TRANSFER_INSTANT_OUTBOUND amount -
CASH INTEREST_PAYMENT amount -
CASH DIVIDEND amount -
CASH TAX_OPTIMIZATION amount -
TRADING BUY amount +shares
TRADING SELL amount -shares
where cash is in every case amount minus the fee and tax deducted from it.
No row type moves a position without moving cash, so the cash-neutral class
that Scalable's corporate actions and depot transfers belong to does not arise.
Three conventions are the opposite of Scalable's, and each one moves money if
read the other way round:
- fee and tax are signed adjustments to cash, not deductions. A one euro
order fee is written -1.00 and withheld tax -4.33, so both are negated at
import and the journal keeps its single convention.
- a cash row's amount is the gross, not the net. Interest of 16.46 with -4.33
of tax credits 12.13.
- a TAX_OPTIMIZATION row carries zero in the amount column and its money in
the tax column, signed both ways. Read as cash, all of them move nothing;
read correctly, they are the loss-offset pot settling, in either direction.
A DIVIDEND row populates the share column with the holding the dividend was
paid on, not with a position change. Adding it would double the holding, so it
is read as the attribution it is and otherwise discarded.
The security identifier is the symbol column when that is an ISIN, and
otherwise the one ISIN the description names: crypto carries a bare ticker in
the column and its identifier only in the text. A row that moves a position
and resolves to neither is refused.
The counterparty of a transfer is the counterparty_iban column when populated,
else the IBAN the description carries in parentheses, else the account's
configured settlement IBAN. Free text contributes only a value shaped like an
IBAN, so a description naming no account contributes nothing.
The booking date is the date column exactly as printed. The datetime column is
UTC while the date column is local, so they disagree for rows booked late in
the evening; deriving the date from the timestamp moves those rows a day back.
Only account_type DEFAULT imports. One export covers one account, and a second
account type in the same file would merge two cash balances into one.
original_amount, original_currency and fx_rate are informational: settlement is
in the currency column, which must match the account's. payment_reference and
mcc_code are unused - no card rows appear in this export type, and if they ever
do they are spending with a merchant, not broker activity.
Broker facts carry enrichment kind "investment". Like a transfer it has no Broker facts carry enrichment kind "investment". Like a transfer it has no
category and no merchant, it is excluded from spending and income analytics and category and no merchant, it is excluded from spending and income analytics and
from bulk reclassification, and the AI never sees it. Crucially, a broker fact from bulk reclassification, and the AI never sees it. Crucially, a broker fact
+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. 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. > **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. 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. 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 ## 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 | | 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. 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. **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.
+64 -10
View File
@@ -7,11 +7,21 @@ import (
"time" "time"
) )
// filteredPrefix opens the common table expression every group query reads
// from; the closing parenthesis is supplied with the WHERE clause.
const filteredPrefix = "WITH filtered AS (SELECT t.* FROM transactions t WHERE "
// categoryGroups runs twice, once per compared interval, so a period-over-period
// delta sees exactly the same ancestor rollup on both sides.
const categoryGroups = `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id
JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency`
func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) { func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
empty := Dashboard{ empty := Dashboard{
Totals: []Total{}, Previous: []Total{}, Monthly: []Group{}, Totals: []Total{}, Previous: []Total{}, Monthly: []MonthlyPoint{},
Categories: []Group{}, Tags: []Group{}, Merchants: []Group{}, Categories: []Group{}, PreviousCategories: []Group{}, Tags: []Group{},
Accounts: []Group{}, Recurring: []Group{}, Merchants: []Group{}, Accounts: []Group{}, Recurring: []Group{}, Largest: []Group{},
} }
if err := filter.validate(); err != nil { if err := filter.validate(); err != nil {
return empty, err return empty, err
@@ -33,19 +43,21 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
if result.Previous, err = queryTotals(ctx, tx, previous); err != nil { if result.Previous, err = queryTotals(ctx, tx, previous); err != nil {
return empty, err return empty, err
} }
where, args := previous.where()
if result.PreviousCategories, err = queryGroups(ctx, tx, filteredPrefix+where+") "+categoryGroups, args); err != nil {
return empty, fmt.Errorf("query previous categories: %w", err)
}
} }
where, args := filter.where() where, args := filter.where()
prefix := "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + where + ") " prefix := filteredPrefix + where + ") "
if result.Monthly, err = queryMonthly(ctx, tx, prefix, args); err != nil {
return empty, err
}
queries := []struct { queries := []struct {
output *[]Group output *[]Group
query string query string
}{ }{
{&result.Monthly, `SELECT strftime(booking_date, '%Y-%m'), strftime(booking_date, '%Y-%m'), currency, {&result.Categories, categoryGroups},
strftime(booking_date, '%Y-%m'), CAST(SUM(amount) AS VARCHAR), COUNT(*)
FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY 4, 3`},
{&result.Categories, `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id
JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency`},
{&result.Tags, `SELECT tag.id, tag.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) {&result.Tags, `SELECT tag.id, tag.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
FROM filtered t JOIN transaction_tags tt ON tt.transaction_id = t.id FROM filtered t JOIN transaction_tags tt ON tt.transaction_id = t.id
JOIN tags tag ON tag.id = tt.tag_id GROUP BY tag.id, tag.name, t.currency ORDER BY tag.id, t.currency`}, JOIN tags tag ON tag.id = tt.tag_id GROUP BY tag.id, tag.name, t.currency ORDER BY tag.id, t.currency`},
@@ -71,6 +83,25 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
m.name, c.currency, c.cadence, CAST(c.total AS VARCHAR), c.occurrences m.name, c.currency, c.cadence, CAST(c.total AS VARCHAR), c.occurrences
FROM candidates c JOIN merchants m ON m.id = c.merchant_id FROM candidates c JOIN merchants m ON m.id = c.merchant_id
WHERE c.cadence <> '' ORDER BY 1, 3`}, WHERE c.cadence <> '' ORDER BY 1, 3`},
// One row per payee: a rent paid on time every month is six identical
// rows that explain nothing, so only a merchant's single biggest payment
// competes. Ranked per currency rather than by a plain LIMIT, so one
// busy currency cannot crowd another out of its own list. Both windows
// order by the decimal column, never by its VARCHAR rendering.
{&result.Largest, `, payments AS (
SELECT t.id, CASE WHEN COALESCE(m.name, '') <> '' THEN m.name ELSE t.raw_description END AS label,
t.currency, CAST(t.booking_date AS VARCHAR) AS day, t.amount AS value,
ROW_NUMBER() OVER (PARTITION BY t.currency,
CASE WHEN t.merchant_id <> '' THEN 'm:' || t.merchant_id ELSE 'x:' || t.id END
ORDER BY t.amount, t.id) AS repeats
FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id WHERE t.amount < 0
), ranked AS (
SELECT id, label, currency, day, value,
ROW_NUMBER() OVER (PARTITION BY currency ORDER BY value, id) AS position
FROM payments WHERE repeats = 1
)
SELECT id, label, currency, day, CAST(value AS VARCHAR), CAST(1 AS BIGINT) FROM ranked
WHERE position <= 8 ORDER BY currency, position`},
} }
for _, item := range queries { for _, item := range queries {
groups, err := queryGroups(ctx, tx, prefix+item.query, args) groups, err := queryGroups(ctx, tx, prefix+item.query, args)
@@ -85,6 +116,29 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
return result, nil return result, nil
} }
// queryMonthly returns one row per month and currency. Months with no activity
// are absent: the caller knows the requested window and fills the gaps.
func queryMonthly(ctx context.Context, tx *sql.Tx, prefix string, args []any) ([]MonthlyPoint, error) {
rows, err := tx.QueryContext(ctx, prefix+`SELECT strftime(booking_date, '%Y-%m'), currency,
CAST(SUM(CASE WHEN amount > 0 THEN amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
CAST(SUM(CASE WHEN amount < 0 THEN -amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
CAST(SUM(amount) AS VARCHAR), COUNT(*)
FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY currency, 1`, args...)
if err != nil {
return nil, fmt.Errorf("query analytics months: %w", err)
}
defer rows.Close()
result := []MonthlyPoint{}
for rows.Next() {
var point MonthlyPoint
if err := rows.Scan(&point.Period, &point.Currency, &point.Income, &point.Expenses, &point.Net, &point.Count); err != nil {
return nil, err
}
result = append(result, point)
}
return result, rows.Err()
}
func queryTotals(ctx context.Context, tx *sql.Tx, filter Filter) ([]Total, error) { func queryTotals(ctx context.Context, tx *sql.Tx, filter Filter) ([]Total, error) {
where, args := filter.where() where, args := filter.where()
rows, err := tx.QueryContext(ctx, `SELECT t.currency, rows, err := tx.QueryContext(ctx, `SELECT t.currency,
+20 -1
View File
@@ -42,15 +42,34 @@ type Group struct {
Count int64 `json:"count"` Count int64 `json:"count"`
} }
// MonthlyPoint is one calendar month of one currency. Income and Expenses are
// both positive magnitudes so a chart can draw them on either side of zero;
// Net is their signed difference and the only figure that may be negative.
type MonthlyPoint struct {
Period string `json:"period"`
Currency string `json:"currency"`
Income string `json:"income"`
Expenses string `json:"expenses"`
Net string `json:"net"`
Count int64 `json:"count"`
}
type Dashboard struct { type Dashboard struct {
Totals []Total `json:"totals"` Totals []Total `json:"totals"`
Previous []Total `json:"previous"` Previous []Total `json:"previous"`
Monthly []Group `json:"monthly"` Monthly []MonthlyPoint `json:"monthly"`
// Categories and PreviousCategories share a shape so the two periods can be
// subtracted category by category; PreviousCategories is empty whenever the
// filter has no comparable preceding interval.
Categories []Group `json:"categories"` Categories []Group `json:"categories"`
PreviousCategories []Group `json:"previous_categories"`
Tags []Group `json:"tags"` Tags []Group `json:"tags"`
Merchants []Group `json:"merchants"` Merchants []Group `json:"merchants"`
Accounts []Group `json:"accounts"` Accounts []Group `json:"accounts"`
Recurring []Group `json:"recurring"` Recurring []Group `json:"recurring"`
// Largest is the biggest single outflows of the period, one row per
// transaction: Period carries its booking date and Count is always one.
Largest []Group `json:"largest"`
} }
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
+35 -1
View File
@@ -97,6 +97,39 @@ func TestExactTotalsCurrenciesAndTransferExclusion(t *testing.T) {
} }
} }
func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
s := openFixture(t, fixture())
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"})
months := []MonthlyPoint{
{Period: "2026-02", Currency: "EUR", Income: "100.1235", Expenses: "900719925474.1000", Net: "-900719925373.9765", Count: 4},
{Period: "2026-02", Currency: "USD", Income: "0.0000", Expenses: "4.2500", Net: "-4.2500", Count: 1},
}
if !reflect.DeepEqual(got.Monthly, months) {
t.Fatalf("monthly: got %#v, want %#v", got.Monthly, months)
}
// A repeat payee contributes only its biggest payment, and one currency's
// outflows never crowd another currency out of the list.
largest := []Group{
{ID: "tx_large", Name: "Shop", Currency: "EUR", Period: "2026-02-10", Amount: "-900719925474.0991", Count: 1},
{ID: "tx_usd", Name: "Shop", Currency: "USD", Period: "2026-02-10", Amount: "-4.2500", Count: 1},
}
if !reflect.DeepEqual(got.Largest, largest) {
t.Fatalf("largest: got %#v, want %#v", got.Largest, largest)
}
// The comparison period rolls up through the same ancestors as the current one.
previous := []Group{
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-25.0000", Count: 1},
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-25.0000", Count: 1},
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-25.0000", Count: 1},
}
if !reflect.DeepEqual(got.PreviousCategories, previous) {
t.Fatalf("previous categories: got %#v, want %#v", got.PreviousCategories, previous)
}
if all := queryFixture(t, s, Filter{}); len(all.PreviousCategories) != 0 {
t.Fatalf("all-time query must have no comparison period: %#v", all.PreviousCategories)
}
}
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) { func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
s := openFixture(t, fixture()) s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"} filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"}
@@ -269,7 +302,8 @@ func TestRecurringRequiresStableCadenceAndSeparatesCurrencies(t *testing.T) {
func TestEmptyIndexAndInvalidDates(t *testing.T) { func TestEmptyIndexAndInvalidDates(t *testing.T) {
s := openFixture(t, domain.NewDataset()) s := openFixture(t, domain.NewDataset())
got := queryFixture(t, s, Filter{}) got := queryFixture(t, s, Filter{})
if got.Totals == nil || got.Previous == nil || got.Monthly == nil || got.Categories == nil || got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil { if got.Totals == nil || got.Previous == nil || got.Monthly == nil || got.Categories == nil || got.PreviousCategories == nil ||
got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil || got.Largest == nil {
t.Fatal("empty collections must encode as arrays") t.Fatal("empty collections must encode as arrays")
} }
for _, filter := range []Filter{{From: "2026-02-30"}, {From: "2026-03-01", To: "2026-02-01"}} { for _, filter := range []Filter{{From: "2026-02-30"}, {From: "2026-03-01", To: "2026-02-01"}} {
+18 -7
View File
@@ -132,7 +132,7 @@ type CSVImport struct {
// than by a column mapping, and the review needs to show what that parser // than by a column mapping, and the review needs to show what that parser
// decided: which securities it would register, which rows it skipped, and // decided: which securities it would register, which rows it skipped, and
// which figures it deliberately did not apply. // which figures it deliberately did not apply.
Broker *banking.ScalableImport `json:"broker,omitempty"` Broker *banking.BrokerImport `json:"broker,omitempty"`
facts []domain.Facts facts []domain.Facts
instruments []domain.Instrument instruments []domain.Instrument
@@ -175,8 +175,8 @@ func (a *App) PrepareCSVImport(ctx context.Context, rev, accountID string, r io.
// cash on one row, a gross to be netted on another, and a position // cash on one row, a gross to be netted on another, and a position
// valuation that must not touch cash on a third, so a column mapping cannot // valuation that must not touch cash on a third, so a column mapping cannot
// describe it. // describe it.
if header, broker := banking.DetectScalableCSV(file); broker { if source, label, header, broker := banking.DetectBrokerCSV(file); broker {
read, e := banking.ParseScalableCSV(file, account, s.Data.Instruments) read, e := banking.ParseBrokerCSV(file, account, s.Data.Instruments)
if e != nil { if e != nil {
return CSVImport{}, e return CSVImport{}, e
} }
@@ -184,9 +184,9 @@ func (a *App) PrepareCSVImport(ctx context.Context, rev, accountID string, r io.
if e != nil { if e != nil {
return CSVImport{}, e return CSVImport{}, e
} }
prepared.Source, prepared.SourceLabel = banking.SourceScalable, "Scalable Capital" prepared.Source, prepared.SourceLabel = source, label
prepared.Mapping = banking.CSVMapping{HeaderRow: header, DateFormat: "yyyy-mm-dd", DecimalFormat: "comma"} prepared.Mapping = banking.CSVMapping{HeaderRow: header}
prepared.Columns = brokerColumns() prepared.Columns = brokerColumns(source)
prepared.Records, prepared.New, prepared.Duplicates = len(read.Facts), len(added), len(read.Facts)-len(added) prepared.Records, prepared.New, prepared.Duplicates = len(read.Facts), len(added), len(read.Facts)-len(added)
prepared.Samples, prepared.facts, prepared.instruments = csvSamples(read.Facts), read.Facts, read.Instruments prepared.Samples, prepared.facts, prepared.instruments = csvSamples(read.Facts), read.Facts, read.Instruments
prepared.Broker = &read prepared.Broker = &read
@@ -262,7 +262,18 @@ func (a *App) retain(prepared CSVImport) (CSVImport, error) {
// brokerColumns describes what the broker parser decided, in the same // brokerColumns describes what the broker parser decided, in the same
// reviewable shape as a column mapping. The dispatch is the part that can be // reviewable shape as a column mapping. The dispatch is the part that can be
// wrong in a way that moves money, so it is the part shown. // wrong in a way that moves money, so it is the part shown.
func brokerColumns() []CSVColumn { func brokerColumns(source string) []CSVColumn {
if source == banking.SourceTradeRepublic {
return []CSVColumn{
{Field: "Booking date", Column: "date, exactly as printed; the datetime column is UTC and disagrees with it late in the evening"},
{Field: "Cash movement", Column: "amount fee tax, where the export writes fee and tax as the signed adjustments it made and the amount is the gross"},
{Field: "Position change", Column: "shares, already signed; a dividend's shares are the holding it was paid on and move nothing"},
{Field: "Instrument", Column: "symbol when it is an ISIN, else the one ISIN the description names; crypto carries a ticker in the column"},
{Field: "Counterparty", Column: "counterparty_iban, else the IBAN the description names in parentheses, else this account's settlement IBAN"},
{Field: "Reference", Column: "transaction_id"},
{Field: "Decimals", Column: "plain decimal point; trailing zeros are padding, not precision"},
}
}
return []CSVColumn{ return []CSVColumn{
{Field: "Booking date", Column: "date, exactly as printed; the time column is local and crosses midnight, so it is ignored"}, {Field: "Booking date", Column: "date, exactly as printed; the time column is local and crosses midnight, so it is ignored"},
{Field: "Imported rows", Column: `status "Executed" only; cancelled retries are all zeros and would import as phantom trades`}, {Field: "Imported rows", Column: `status "Executed" only; cancelled retries are all zeros and would import as phantom trades`},
+5 -1
View File
@@ -149,9 +149,13 @@ func WealthOf(data domain.Dataset) Wealth {
st.broken = append(st.broken, fmt.Sprintf("%s %s: %v", f.BookingDate, f.ID, err)) st.broken = append(st.broken, fmt.Sprintf("%s %s: %v", f.BookingDate, f.ID, err))
} }
if inv.CashOnly() { if inv.CashOnly() {
// A cash row carrying a gross had its fee and tax applied to reach
// that amount, and its settlement is already verified above. Only a
// row whose amount arrived net has figures that were recorded and
// deliberately never subtracted.
fee, _ := inv.Fee.Minor() fee, _ := inv.Fee.Minor()
tax, _ := inv.Tax.Minor() tax, _ := inv.Tax.Minor()
if fee != 0 || tax != 0 { if inv.Gross == "" && (fee != 0 || tax != 0) {
st.unappliedRows++ st.unappliedRows++
st.unappliedFee += fee st.unappliedFee += fee
st.unappliedTax += tax st.unappliedTax += tax
+298
View File
@@ -0,0 +1,298 @@
package banking
import (
"errors"
"fmt"
"math/big"
"strings"
"finance-duck/internal/domain"
)
// BrokerNote records a figure an export carried that the import deliberately
// did not apply, so it can be reviewed before confirming and recognized later
// if a balance disagrees.
type BrokerNote struct {
Record int `json:"record"`
Date string `json:"date"`
Description string `json:"description"`
Fee domain.Money `json:"fee,omitempty"`
Tax domain.Money `json:"tax,omitempty"`
}
// BrokerImport is a read broker export awaiting review.
type BrokerImport struct {
Facts []domain.Facts `json:"-"`
// Instruments are securities the export named that the registry does not
// hold yet. An import never renames an existing instrument: the name is
// editable display text, and an export's own description for one ISIN
// changes over time.
Instruments []domain.Instrument `json:"instruments"`
// Cancelled counts rows the broker did not execute. Their money and share
// columns are all zeros, so they satisfy every arithmetic check and would
// otherwise import as phantom trades.
Cancelled int `json:"cancelled"`
// Rounded counts rows whose money carried more than four decimal places,
// and Rounding is the exact total adjustment that rounding applied, at
// whatever precision the export used.
Rounded int `json:"rounded"`
Rounding string `json:"rounding"`
// Unapplied lists cash rows carrying a fee or tax that was recorded but
// not subtracted, because the export had already applied it to the amount.
Unapplied []BrokerNote `json:"unapplied"`
}
func newBrokerImport() BrokerImport {
// Empty rather than nil: these are arrays in the reviewed JSON, and a null
// where a caller expects a list is a bug waiting on a different machine.
return BrokerImport{Instruments: []domain.Instrument{}, Unapplied: []BrokerNote{}}
}
// DetectBrokerCSV recognizes a broker export by its complete column set and
// reports the 1-based record holding its header. A layout is matched in full
// rather than column by column: a row's meaning depends on a combination of its
// classifying columns, so a partial match is a different file wearing the same
// names.
func DetectBrokerCSV(f CSVFile) (source, label string, header int, ok bool) {
for _, format := range []struct {
source, label string
columns []string
}{
{SourceScalable, "Scalable Capital", scalableColumns},
{SourceTradeRepublic, "Trade Republic", tradeRepublicColumns},
} {
if header, found := matchColumns(f, format.columns); found {
return format.source, format.label, header, true
}
}
return "", "", 0, false
}
// ParseBrokerCSV reads whichever recognized broker export the document is.
func ParseBrokerCSV(f CSVFile, account domain.Account, registry []domain.Instrument) (BrokerImport, error) {
source, _, _, ok := DetectBrokerCSV(f)
switch {
case !ok:
return newBrokerImport(), errors.New("not a recognized broker export")
case source == SourceScalable:
return ParseScalableCSV(f, account, registry)
default:
return ParseTradeRepublicCSV(f, account, registry)
}
}
func matchColumns(f CSVFile, want []string) (header int, ok bool) {
for i, row := range f.rows {
if i >= maxCSVPreambleRows {
break
}
columns, usable := csvColumnIndex(row)
if !usable || len(columns) != len(want) {
continue
}
matched := true
for _, name := range want {
if _, exists := columns[name]; !exists {
matched = false
break
}
}
if matched {
return i + 1, true
}
}
return 0, false
}
// investmentTarget checks that an export can be imported into this account at all.
func investmentTarget(account domain.Account) error {
if account.ID == "" {
return errors.New("broker import requires a selected account")
}
if !account.Investing() {
return fmt.Errorf("account %q must be an investment account to hold a broker export", account.DisplayName)
}
return nil
}
// brokerColumnIndex resolves a matched header row to column positions.
func brokerColumnIndex(f CSVFile, header int) (headers []string, at func([]string, string) string) {
headers = f.rows[header-1]
index := make(map[string]int, len(headers))
for i, raw := range headers {
index[headerName(raw)] = i
}
return headers, func(row []string, name string) string { return strings.TrimSpace(row[index[name]]) }
}
// nonzeroMoney reports a figure that could change a balance. An export leaves a
// column blank where it does not apply and writes an explicit zero where it
// applies but is nil; only the second kind is worth putting in front of someone
// before they confirm an import.
func nonzeroMoney(m domain.Money) bool {
minor, err := m.Minor()
return err == nil && minor != 0
}
// negated flips a signed adjustment into a deduction. One broker states a fee
// as the negative amount it took off the cash; the journal stores fees and
// taxes as deductions from a gross, so that convention is normalized once, at
// import, rather than being carried into the domain.
func negated(m domain.Money) (domain.Money, error) {
if m == "" {
return "", nil
}
minor, err := m.Minor()
if err != nil {
return "", err
}
return domain.FormatMoney(-minor), nil
}
// residueScale is the precision a discarded remainder is accumulated at. A
// broker amount is its share count times its unit price, so it carries as many
// decimal places as the two together need: a real export reinvests to nine.
// Eighteen is far past anything a settlement can produce and still exact.
const residueScale = 18
// Decimal conventions a broker export can use. German exports write a comma
// decimal and group thousands with a dot; the rest write a plain decimal point.
const (
decimalGerman = true
decimalPlain = false
)
// brokerMoney reads one money cell, rounds it to money's four decimal places
// half away from zero, and returns the exact remainder that rounding discarded,
// in units of 1e-18. The remainder is reported rather than hidden, and never
// guessed at: it is the only honest account of why a computed balance can
// differ from the broker's by a fraction of a cent.
//
// An empty cell is empty money, not zero: blank marks a column that does not
// apply to the row.
func brokerMoney(value string, german bool) (domain.Money, *big.Int, error) {
plain, ok, err := brokerPlain(value, german)
if !ok || err != nil {
return "", new(big.Int), err
}
magnitude, negative, err := brokerDigits(plain)
if err != nil {
return "", new(big.Int), err
}
// One money place is 1e14 residue units. Rounding compares twice the
// remainder against that, so a tie rounds away from zero.
place := new(big.Int).Exp(big.NewInt(10), big.NewInt(residueScale-4), nil)
rounded, remainder := new(big.Int).QuoRem(magnitude, place, new(big.Int))
if new(big.Int).Lsh(remainder, 1).Cmp(place) >= 0 {
rounded.Add(rounded, big.NewInt(1))
}
if !rounded.IsInt64() {
return "", new(big.Int), errors.New("value is out of range for money")
}
residue := new(big.Int).Sub(magnitude, new(big.Int).Mul(rounded, place))
minor := rounded.Int64()
if negative {
minor, residue = -minor, residue.Neg(residue)
}
return domain.FormatMoney(minor), residue, nil
}
// brokerQuantity reads one share count or unit price. Nothing is rounded: a
// holding is verified against the broker's own figure, and a rounded price
// would break the shares-times-price check the amount is verified against, so
// a value beyond eight decimal places is refused instead of truncated.
func brokerQuantity(value string, german bool) (domain.Quantity, error) {
plain, ok, err := brokerPlain(value, german)
if !ok || err != nil {
return "", err
}
return domain.ParseQuantity(plain)
}
// brokerPlain normalizes one numeric cell to a plain decimal string, or reports
// that the cell was blank. Insignificant trailing zeros are dropped: exporters
// pad a column to a fixed width, so a six-place price arrives written to ten,
// and the padding would otherwise exhaust the precision the value needs.
func brokerPlain(value string, german bool) (string, bool, error) {
value = strings.NewReplacer("\u00a0", "", "\u202f", "", "'", "").Replace(strings.TrimSpace(value))
if value == "" {
return "", false, nil
}
plain := strings.TrimPrefix(value, "+")
if german {
converted, err := germanDecimal(plain)
if err != nil {
return "", false, err
}
plain = converted
}
if whole, fraction, found := strings.Cut(plain, "."); found {
if trimmed := strings.TrimRight(fraction, "0"); trimmed == "" {
plain = whole
} else {
plain = whole + "." + trimmed
}
}
return plain, true, nil
}
// brokerDigits splits a plain decimal string into its exact magnitude in
// residue units and its sign.
func brokerDigits(plain string) (magnitude *big.Int, negative bool, err error) {
digits := plain
if rest, cut := strings.CutPrefix(digits, "-"); cut {
negative, digits = true, rest
}
whole, decimals, _ := strings.Cut(digits, ".")
if whole == "" {
return nil, false, errors.New("decimal needs a digit before the separator")
}
if len(decimals) > residueScale {
return nil, false, fmt.Errorf("more than %d fractional digits", residueScale)
}
scaled, ok := new(big.Int).SetString(whole+decimals+strings.Repeat("0", residueScale-len(decimals)), 10)
if !ok {
return nil, false, errors.New("not a decimal number")
}
return scaled, negative, nil
}
// decimalString renders exact units at a scale without trailing zeros, so an
// adjustment of 1e-9 is reported as such rather than padded to eighteen places.
func decimalString(units *big.Int, scale int) string {
sign := ""
magnitude := new(big.Int).Abs(units)
if units.Sign() < 0 {
sign = "-"
}
digits := magnitude.String()
if len(digits) <= scale {
digits = strings.Repeat("0", scale+1-len(digits)) + digits
}
whole, fraction := digits[:len(digits)-scale], strings.TrimRight(digits[len(digits)-scale:], "0")
if fraction == "" {
return sign + whole
}
return sign + whole + "." + fraction
}
// brokerSettlement is gross minus fee minus tax: the cash a row moved. Fee and
// tax are stored as deductions, so a refunded tax is a negative deduction and
// adds to the cash.
func brokerSettlement(gross, fee, tax domain.Money) (domain.Money, error) {
total := int64(0)
for _, deduction := range []struct {
sign int64
money domain.Money
}{{1, gross}, {-1, fee}, {-1, tax}} {
if deduction.money == "" {
continue
}
minor, err := deduction.money.Minor()
if err != nil {
return "", err
}
total += deduction.sign * minor
}
return domain.FormatMoney(total), nil
}
+15 -216
View File
@@ -39,64 +39,9 @@ var scalableEvents = map[string]string{
"security transfer": domain.EventPositionTransfer, "security transfer": domain.EventPositionTransfer,
} }
// ScalableNote records a figure the export carried that the import deliberately // DetectScalableCSV reports whether a document is a Scalable Capital export
// did not apply, so it can be reviewed before confirming and recognized later // and which 1-based record holds its header.
// if a balance disagrees. func DetectScalableCSV(f CSVFile) (header int, ok bool) { return matchColumns(f, scalableColumns) }
type ScalableNote struct {
Record int `json:"record"`
Date string `json:"date"`
Description string `json:"description"`
Fee domain.Money `json:"fee,omitempty"`
Tax domain.Money `json:"tax,omitempty"`
}
// ScalableImport is a read broker export awaiting review.
type ScalableImport struct {
Facts []domain.Facts `json:"-"`
// Instruments are securities the export named that the registry does not
// hold yet. An import never renames an existing instrument: the name is
// editable display text, and the export's own description for one ISIN
// changes over time.
Instruments []domain.Instrument `json:"instruments"`
// Cancelled counts rows the broker did not execute. Their money and share
// columns are all zeros, so they satisfy every arithmetic check and would
// otherwise import as phantom trades.
Cancelled int `json:"cancelled"`
// Rounded counts rows whose money carried more than four decimal places,
// and Rounding is the exact total adjustment that rounding applied, at
// whatever precision the export used.
Rounded int `json:"rounded"`
Rounding string `json:"rounding"`
// Unapplied lists cash rows carrying a fee or tax. A broker cash amount is
// already net of them, so subtracting them again would double-count; they
// are recorded on the fact and reported here.
Unapplied []ScalableNote `json:"unapplied"`
}
// DetectScalableCSV reports whether a document is a Scalable Capital export and
// which 1-based record holds its header.
func DetectScalableCSV(f CSVFile) (header int, ok bool) {
for i, row := range f.rows {
if i >= maxCSVPreambleRows {
break
}
columns, usable := csvColumnIndex(row)
if !usable || len(columns) != len(scalableColumns) {
continue
}
matched := true
for _, name := range scalableColumns {
if _, exists := columns[name]; !exists {
matched = false
break
}
}
if matched {
return i + 1, true
}
}
return 0, false
}
// ParseScalableCSV converts a broker export into bank facts carrying position // ParseScalableCSV converts a broker export into bank facts carrying position
// legs. // legs.
@@ -125,26 +70,16 @@ func DetectScalableCSV(f CSVFile) (header int, ok bool) {
// check, rejects the whole file. Every one of those cases can move money, and a // check, rejects the whole file. Every one of those cases can move money, and a
// partially imported broker history cannot be told from a truncated export // partially imported broker history cannot be told from a truncated export
// afterwards. // afterwards.
func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instrument) (ScalableImport, error) { func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instrument) (BrokerImport, error) {
// Empty rather than nil: these are arrays in the reviewed JSON, and a null result := newBrokerImport()
// where a caller expects a list is a bug waiting on a different machine. if err := investmentTarget(account); err != nil {
result := ScalableImport{Instruments: []domain.Instrument{}, Unapplied: []ScalableNote{}} return result, err
if account.ID == "" {
return result, errors.New("broker import requires a selected account")
}
if !account.Investing() {
return result, fmt.Errorf("account %q must be an investment account to hold a broker export", account.DisplayName)
} }
header, ok := DetectScalableCSV(f) header, ok := DetectScalableCSV(f)
if !ok { if !ok {
return result, errors.New("not a Scalable Capital export") return result, errors.New("not a Scalable Capital export")
} }
headers := f.rows[header-1] headers, cell := brokerColumnIndex(f, header)
index := map[string]int{}
for i, raw := range headers {
index[headerName(raw)] = i
}
cell := func(row []string, name string) string { return strings.TrimSpace(row[index[name]]) }
instruments := map[string]domain.Instrument{} instruments := map[string]domain.Instrument{}
byISIN := map[string]domain.Instrument{} byISIN := map[string]domain.Instrument{}
@@ -216,15 +151,15 @@ func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instr
result.Instruments[slot].Name = description result.Instruments[slot].Name = description
} }
} }
amount, amountDrift, err := scalableMoney(cell(row, "amount")) amount, amountDrift, err := brokerMoney(cell(row, "amount"), decimalGerman)
if err != nil { if err != nil {
return result, fmt.Errorf("broker record %d has an invalid amount %q: %w", record, cell(row, "amount"), err) return result, fmt.Errorf("broker record %d has an invalid amount %q: %w", record, cell(row, "amount"), err)
} }
fee, feeDrift, err := scalableMoney(cell(row, "fee")) fee, feeDrift, err := brokerMoney(cell(row, "fee"), decimalGerman)
if err != nil { if err != nil {
return result, fmt.Errorf("broker record %d has an invalid fee %q: %w", record, cell(row, "fee"), err) return result, fmt.Errorf("broker record %d has an invalid fee %q: %w", record, cell(row, "fee"), err)
} }
tax, taxDrift, err := scalableMoney(cell(row, "tax")) tax, taxDrift, err := brokerMoney(cell(row, "tax"), decimalGerman)
if err != nil { if err != nil {
return result, fmt.Errorf("broker record %d has an invalid tax %q: %w", record, cell(row, "tax"), err) return result, fmt.Errorf("broker record %d has an invalid tax %q: %w", record, cell(row, "tax"), err)
} }
@@ -235,24 +170,21 @@ func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instr
cash := amount cash := amount
if investment.CashOnly() { if investment.CashOnly() {
if nonzeroMoney(fee) || nonzeroMoney(tax) { if nonzeroMoney(fee) || nonzeroMoney(tax) {
result.Unapplied = append(result.Unapplied, ScalableNote{Record: record, Date: booking, Description: description, Fee: fee, Tax: tax}) result.Unapplied = append(result.Unapplied, BrokerNote{Record: record, Date: booking, Description: description, Fee: fee, Tax: tax})
} }
investment.Fee, investment.Tax = fee, tax investment.Fee, investment.Tax = fee, tax
} else { } else {
if isin == "" { if isin == "" {
return result, fmt.Errorf("broker record %d moves a position without an ISIN", record) return result, fmt.Errorf("broker record %d moves a position without an ISIN", record)
} }
shares, err := scalableQuantity(cell(row, "shares")) shares, err := brokerQuantity(cell(row, "shares"), decimalGerman)
if err != nil { if err != nil {
return result, fmt.Errorf("broker record %d has an invalid share count %q: %w", record, cell(row, "shares"), err) return result, fmt.Errorf("broker record %d has an invalid share count %q: %w", record, cell(row, "shares"), err)
} }
price, priceDrift, err := scalableMoney(cell(row, "price")) price, err := brokerQuantity(cell(row, "price"), decimalGerman)
if err != nil { if err != nil {
return result, fmt.Errorf("broker record %d has an invalid price %q: %w", record, cell(row, "price"), err) return result, fmt.Errorf("broker record %d has an invalid price %q: %w", record, cell(row, "price"), err)
} }
if priceDrift.Sign() != 0 {
return result, fmt.Errorf("broker record %d has a price %q beyond four decimal places", record, cell(row, "price"))
}
signed, err := scalableSignedShares(event, shares) signed, err := scalableSignedShares(event, shares)
if err != nil { if err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err) return result, fmt.Errorf("broker record %d: %w", record, err)
@@ -265,7 +197,7 @@ func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instr
cash = "0.00" cash = "0.00"
} else { } else {
investment.Fee, investment.Tax = fee, tax investment.Fee, investment.Tax = fee, tax
if cash, err = scalableSettlement(amount, fee, tax); err != nil { if cash, err = brokerSettlement(amount, fee, tax); err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err) return result, fmt.Errorf("broker record %d: %w", record, err)
} }
} }
@@ -294,118 +226,6 @@ func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instr
return result, nil return result, nil
} }
// nonzeroMoney reports a figure that could change a balance. The export leaves
// a column blank where it does not apply and writes an explicit zero where it
// applies but is nil; only the second kind is worth putting in front of
// someone before they confirm an import.
func nonzeroMoney(m domain.Money) bool {
minor, err := m.Minor()
return err == nil && minor != 0
}
// residueScale is the precision the discarded remainder is accumulated at.
// A broker amount is its share count times its price, so it carries as many
// decimal places as the two together need: a real export reinvests to nine.
// Eighteen is far past anything a settlement can produce and still exact.
const residueScale = 18
// scalableMoney reads one German-formatted money cell, rounds it to money's
// four decimal places half away from zero, and returns the exact remainder
// that rounding discarded, in units of 1e-18. The remainder is reported rather
// than hidden, and never guessed at: it is the only honest account of why a
// computed balance can differ from the broker's by a fraction of a cent.
//
// An empty cell is empty money, not zero: blank marks a column that does not
// apply to the row.
func scalableMoney(value string) (domain.Money, *big.Int, error) {
plain, ok, err := scalablePlain(value)
if !ok || err != nil {
return "", new(big.Int), err
}
magnitude, negative, err := scalableDigits(plain)
if err != nil {
return "", new(big.Int), err
}
// One money place is 1e14 residue units. Rounding compares twice the
// remainder against that, so a tie rounds away from zero.
place := new(big.Int).Exp(big.NewInt(10), big.NewInt(residueScale-4), nil)
rounded, remainder := new(big.Int).QuoRem(magnitude, place, new(big.Int))
if new(big.Int).Lsh(remainder, 1).Cmp(place) >= 0 {
rounded.Add(rounded, big.NewInt(1))
}
if !rounded.IsInt64() {
return "", new(big.Int), fmt.Errorf("value is out of range for money")
}
residue := new(big.Int).Sub(magnitude, new(big.Int).Mul(rounded, place))
minor := rounded.Int64()
if negative {
minor, residue = -minor, residue.Neg(residue)
}
return domain.FormatMoney(minor), residue, nil
}
// scalableDigits splits a plain decimal string into its exact magnitude in
// residue units and its sign.
func scalableDigits(plain string) (magnitude *big.Int, negative bool, err error) {
digits := plain
if rest, cut := strings.CutPrefix(digits, "-"); cut {
negative, digits = true, rest
}
whole, decimals, _ := strings.Cut(digits, ".")
if whole == "" {
return nil, false, fmt.Errorf("decimal needs a digit before the separator")
}
if len(decimals) > residueScale {
return nil, false, fmt.Errorf("more than %d fractional digits", residueScale)
}
scaled, ok := new(big.Int).SetString(whole+decimals+strings.Repeat("0", residueScale-len(decimals)), 10)
if !ok {
return nil, false, fmt.Errorf("not a decimal number")
}
return scaled, negative, nil
}
// decimalString renders exact units at a scale without trailing zeros, so an
// adjustment of 1e-9 is reported as such rather than padded to eighteen places.
func decimalString(units *big.Int, scale int) string {
sign := ""
magnitude := new(big.Int).Abs(units)
if units.Sign() < 0 {
sign = "-"
}
digits := magnitude.String()
if len(digits) <= scale {
digits = strings.Repeat("0", scale+1-len(digits)) + digits
}
whole, fraction := digits[:len(digits)-scale], strings.TrimRight(digits[len(digits)-scale:], "0")
if fraction == "" {
return sign + whole
}
return sign + whole + "." + fraction
}
// scalableQuantity reads one German-formatted share count. Nothing is rounded:
// a holding is verified against the broker's own figure, so a count beyond
// eight decimal places is refused instead of silently truncated.
func scalableQuantity(value string) (domain.Quantity, error) {
plain, ok, err := scalablePlain(value)
if !ok || err != nil {
return "", err
}
return domain.ParseQuantity(plain)
}
// scalablePlain normalizes one numeric cell to a plain decimal string, or
// reports that the cell was blank.
func scalablePlain(value string) (string, bool, error) {
value = strings.NewReplacer("\u00a0", "", "\u202f", "", "'", "").Replace(strings.TrimSpace(value))
if value == "" {
return "", false, nil
}
plain, err := germanDecimal(value)
return plain, err == nil, err
}
// scalableSignedShares resolves the export's two sign conventions. A buy, sell // scalableSignedShares resolves the export's two sign conventions. A buy, sell
// or reinvestment carries an unsigned count and takes its direction from the // or reinvestment carries an unsigned count and takes its direction from the
// type; a corporate action or depot transfer is already signed. // type; a corporate action or depot transfer is already signed.
@@ -428,24 +248,3 @@ func scalableSignedShares(event string, shares domain.Quantity) (domain.Quantity
} }
return domain.FormatQuantity(units), nil return domain.FormatQuantity(units), nil
} }
// scalableSettlement is gross minus fee minus tax: the cash a trade moved. The
// broker states fee and tax as positive deductions whichever way the trade
// went, so both are subtracted from a signed gross.
func scalableSettlement(gross, fee, tax domain.Money) (domain.Money, error) {
total := int64(0)
for _, deduction := range []struct {
sign int64
money domain.Money
}{{1, gross}, {-1, fee}, {-1, tax}} {
if deduction.money == "" {
continue
}
minor, err := deduction.money.Minor()
if err != nil {
return "", err
}
total += deduction.sign * minor
}
return domain.FormatMoney(total), nil
}
+1 -1
View File
@@ -42,7 +42,7 @@ func brokerAccount() domain.Account {
} }
} }
func readBroker(t *testing.T, rows ...string) ScalableImport { func readBroker(t *testing.T, rows ...string) BrokerImport {
t.Helper() t.Helper()
file, err := ReadCSV(strings.NewReader(scalableHeader + strings.Join(rows, "\n") + "\n")) file, err := ReadCSV(strings.NewReader(scalableHeader + strings.Join(rows, "\n") + "\n"))
if err != nil { if err != nil {
+277
View File
@@ -0,0 +1,277 @@
package banking
import (
"errors"
"fmt"
"math/big"
"regexp"
"strings"
"finance-duck/internal/domain"
)
// SourceTradeRepublic identifies facts imported from a Trade Republic export.
const SourceTradeRepublic = "traderepublic_csv"
// tradeRepublicColumns are the exact normalized headers of a Trade Republic
// transaction export.
var tradeRepublicColumns = []string{
"datetime", "date", "account_type", "category", "type", "asset_class",
"name", "symbol", "shares", "price", "amount", "fee", "tax", "currency",
"original_amount", "original_currency", "fx_rate", "description",
"transaction_id", "counterparty_name", "counterparty_iban", "payment_reference", "mcc_code",
}
// tradeRepublicEvents maps the export's complete type vocabulary to journal
// events. The set is closed on purpose: an unrecognized type could move cash in
// either direction, or none, and defaulting it risks a silent balance error.
var tradeRepublicEvents = map[string]string{
"TRANSFER_INBOUND": domain.EventDeposit,
"TRANSFER_INSTANT_INBOUND": domain.EventDeposit,
"TRANSFER_OUTBOUND": domain.EventWithdrawal,
"TRANSFER_INSTANT_OUTBOUND": domain.EventWithdrawal,
"INTEREST_PAYMENT": domain.EventInterest,
"DIVIDEND": domain.EventDistribution,
"TAX_OPTIMIZATION": domain.EventTaxSettlement,
"BUY": domain.EventBuy,
"SELL": domain.EventSell,
}
// isinInText finds the security identifier a row names in its free text. Trade
// Republic puts an ISIN in the symbol column for funds and shares, but a bare
// ticker for crypto, whose ISIN-shaped identifier appears only in the
// description: "Sell trade XF000DOGE012 Dogecoin".
var isinInText = regexp.MustCompile(`\b[A-Z]{2}[A-Z0-9]{9}[0-9]\b`)
// ibanInText finds the counterparty a transfer names in its free text. Older
// rows leave the counterparty_iban column empty and write the IBAN in
// parentheses instead: "Outgoing transfer for LARS NOLDEN (DE04...)".
var ibanInText = regexp.MustCompile(`\(([A-Z]{2}[0-9]{2}[A-Z0-9]{10,30})\)`)
// ParseTradeRepublicCSV converts a Trade Republic export into bank facts
// carrying position legs.
//
// Three conventions differ from every other export this program reads, and each
// one moves money if it is read the other way round:
//
// - fee and tax are signed adjustments to cash, not deductions from a gross.
// The export writes a one euro order fee as -1.00 and withheld tax as
// -4.33, so both are negated at import and the journal keeps one
// convention: cash is gross minus fee minus tax.
// - a cash row's amount is the gross, not the net. Interest of 16.46 with
// -4.33 of tax credits 12.13. This is the opposite of an export that
// states its cash already net, where the tax is recorded and never
// applied.
// - a TAX_OPTIMIZATION row carries zero in the amount column and the money
// in the tax column, signed both ways. Read as cash, all six of them move
// nothing; read correctly, they are the loss-offset pot settling.
//
// A dividend row populates the share column with the holding the dividend was
// paid on, not with a position change. Adding it would double the holding, so
// it is read as the attribution it is and discarded.
//
// The amount on a trade is the notional rounded to cents, not the exact
// product, so the shares-times-price check is satisfied to the precision the
// broker stated rather than exactly. Of 59 trades in a real export, 30 are
// exact at four places and all 59 are within a cent.
//
// The booking date is the date column exactly as printed. The datetime column
// is UTC while the date column is local, so they disagree for rows booked late
// in the evening and deriving the date from the timestamp would move them to
// the previous day.
func ParseTradeRepublicCSV(f CSVFile, account domain.Account, registry []domain.Instrument) (BrokerImport, error) {
result := newBrokerImport()
if err := investmentTarget(account); err != nil {
return result, err
}
header, ok := DetectTradeRepublicCSV(f)
if !ok {
return result, errors.New("not a Trade Republic export")
}
headers, cell := brokerColumnIndex(f, header)
instruments := map[string]domain.Instrument{}
byISIN := map[string]domain.Instrument{}
for _, v := range registry {
instruments[v.ID] = v
byISIN[v.ISIN] = v
}
created := map[string]int{}
named := map[string]string{}
drift := new(big.Int)
for offset, row := range f.rows[header:] {
record := header + offset + 1
if blankCSVRow(row) {
continue
}
if len(row) != len(headers) {
return result, fmt.Errorf("broker record %d has %d columns, expected %d", record, len(row), len(headers))
}
// One export covers one account. A second account type in the same file
// would silently merge two cash balances into one.
if kind := cell(row, "account_type"); !strings.EqualFold(kind, "DEFAULT") {
return result, fmt.Errorf("broker record %d belongs to account type %q, and only DEFAULT can be imported into one account", record, kind)
}
rawType, category := cell(row, "type"), cell(row, "category")
event, known := tradeRepublicEvents[strings.ToUpper(strings.TrimSpace(rawType))]
if !known {
return result, fmt.Errorf("broker record %d has unknown type %q: it may or may not move cash, so nothing was imported", record, rawType)
}
investment := domain.Investment{Event: event}
wanted := "TRADING"
if investment.CashOnly() {
wanted = "CASH"
}
if !strings.EqualFold(category, wanted) {
return result, fmt.Errorf("broker record %d pairs type %q with category %q, expected %q", record, rawType, category, wanted)
}
currency := strings.ToUpper(cell(row, "currency"))
if currency != strings.ToUpper(account.Currency) {
return result, fmt.Errorf("broker record %d settles in %q but account %q holds %s: currency conversion is not supported", record, currency, account.DisplayName, account.Currency)
}
booking, err := parseMappedCSVDate(cell(row, "date"), "yyyy-mm-dd")
if err != nil {
return result, fmt.Errorf("broker record %d has an invalid date %q", record, cell(row, "date"))
}
description := cell(row, "description")
isin, err := tradeRepublicISIN(cell(row, "symbol"), description, !investment.CashOnly())
if err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err)
}
if isin != "" {
held, exists := byISIN[isin]
if !exists {
name := cell(row, "name")
if name == "" {
name = isin
}
held = domain.Instrument{ID: domain.InstrumentID(isin), ISIN: isin, Name: name, Currency: currency}
byISIN[isin] = held
instruments[held.ID] = held
created[isin] = len(result.Instruments)
result.Instruments = append(result.Instruments, held)
}
investment.InstrumentID = held.ID
slot, mine := created[isin]
if name := cell(row, "name"); mine && name != "" && name != isin && booking >= named[isin] {
named[isin] = booking
result.Instruments[slot].Name = name
}
}
gross, grossDrift, err := brokerMoney(cell(row, "amount"), decimalPlain)
if err != nil {
return result, fmt.Errorf("broker record %d has an invalid amount %q: %w", record, cell(row, "amount"), err)
}
fee, feeDrift, err := brokerMoney(cell(row, "fee"), decimalPlain)
if err != nil {
return result, fmt.Errorf("broker record %d has an invalid fee %q: %w", record, cell(row, "fee"), err)
}
tax, taxDrift, err := brokerMoney(cell(row, "tax"), decimalPlain)
if err != nil {
return result, fmt.Errorf("broker record %d has an invalid tax %q: %w", record, cell(row, "tax"), err)
}
if grossDrift.Sign() != 0 || feeDrift.Sign() != 0 || taxDrift.Sign() != 0 {
result.Rounded++
drift.Add(drift, grossDrift).Add(drift, feeDrift).Add(drift, taxDrift)
}
// The export states what it took off the cash; the journal stores what
// was deducted from the gross.
if fee, err = negated(fee); err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err)
}
if tax, err = negated(tax); err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err)
}
if investment.CashOnly() {
// The share column on a dividend is the holding it was paid on.
investment.Gross, investment.Fee, investment.Tax = gross, fee, tax
} else {
if isin == "" {
return result, fmt.Errorf("broker record %d moves a position without a security identifier", record)
}
shares, err := brokerQuantity(cell(row, "shares"), decimalPlain)
if err != nil {
return result, fmt.Errorf("broker record %d has an invalid share count %q: %w", record, cell(row, "shares"), err)
}
price, err := brokerQuantity(cell(row, "price"), decimalPlain)
if err != nil {
return result, fmt.Errorf("broker record %d has an invalid price %q: %w", record, cell(row, "price"), err)
}
investment.Quantity, investment.Price, investment.Gross = shares, price, gross
investment.Fee, investment.Tax = fee, tax
}
cash, err := brokerSettlement(gross, fee, tax)
if err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err)
}
facts := domain.Facts{
Source: SourceTradeRepublic, AccountID: account.ID, BookingDate: booking,
Amount: cash, Currency: currency, RawDescription: description,
ExternalID: cell(row, "transaction_id"), Counterparty: cell(row, "counterparty_name"),
Investment: &investment,
}
if investment.Event == domain.EventDeposit || investment.Event == domain.EventWithdrawal {
facts.CounterpartyIBAN = tradeRepublicIBAN(cell(row, "counterparty_iban"), description, account.ReferenceIBAN)
}
if err := domain.ValidateInvestment(facts, account, instruments); err != nil {
return result, fmt.Errorf("broker record %d: %w", record, err)
}
result.Facts = append(result.Facts, facts)
}
if len(result.Facts) == 0 {
return result, errors.New("broker export contains no records")
}
result.Rounding = decimalString(drift, residueScale)
return result, nil
}
// DetectTradeRepublicCSV reports whether a document is a Trade Republic export
// and which 1-based record holds its header.
func DetectTradeRepublicCSV(f CSVFile) (header int, ok bool) {
return matchColumns(f, tradeRepublicColumns)
}
// tradeRepublicISIN resolves the security a row names. The symbol column holds
// an ISIN for funds and shares and a bare ticker for crypto, whose ISIN-shaped
// identifier appears only in the description. Exactly one identifier must be
// findable, or the row is refused rather than attached to a guess.
func tradeRepublicISIN(symbol, description string, required bool) (string, error) {
candidate := strings.ToUpper(strings.Join(strings.Fields(symbol), ""))
if domain.ValidISIN(candidate) {
return candidate, nil
}
found := isinInText.FindAllString(description, -1)
unique := map[string]bool{}
for _, match := range found {
if domain.ValidISIN(match) {
unique[match] = true
}
}
if len(unique) == 1 {
for match := range unique {
return match, nil
}
}
if !required {
return "", nil
}
if candidate == "" {
return "", errors.New("row moves a position but names no security")
}
return "", fmt.Errorf("symbol %q is not an ISIN and its description does not name exactly one", symbol)
}
// tradeRepublicIBAN resolves the account a transfer settles against: the
// export's own column when it has one, else the IBAN the description carries in
// parentheses, else the account's configured settlement IBAN. Free text only
// contributes a value that is shaped like an IBAN, so a description that names
// no account contributes nothing.
func tradeRepublicIBAN(column, description, fallback string) string {
if iban := normalizeIBAN(column); iban != "" {
return iban
}
if match := ibanInText.FindStringSubmatch(strings.ToUpper(description)); match != nil {
return normalizeIBAN(match[1])
}
return normalizeIBAN(fallback)
}
+239
View File
@@ -0,0 +1,239 @@
package banking
import (
"strings"
"testing"
"finance-duck/internal/domain"
)
const tradeRepublicHeader = "datetime;date;account_type;category;type;asset_class;name;symbol;shares;price;amount;fee;tax;currency;original_amount;original_currency;fx_rate;description;transaction_id;counterparty_name;counterparty_iban;payment_reference;mcc_code\n"
// Real Trade Republic export lines. Between them they cover every one of the
// nine row types, both sign conventions for a transfer, a trade whose notional
// does not land on a whole cent, a crypto trade whose identifier is only in the
// description, a tax settlement that carries its money in the tax column, a
// dividend whose share column is the holding rather than a position change, and
// numbers padded with insignificant zeros.
var tradeRepublicRows = []string{
`2025-01-10T13:17:25.211420Z;2025-01-10;DEFAULT;CASH;TRANSFER_INBOUND;;;;;;34337.000000;;;EUR;;;;Incoming transfer from LARS NOLDEN;cccf7fb9-f35a-462a-8d2c-162664479274;;;;`,
`2025-01-16T13:59:44.872Z;2025-01-16;DEFAULT;TRADING;BUY;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;0.9493860000;64.410000;-61.15;;;EUR;;;;Buy trade IE00B8FHGS14 iShares VI plc, quantity: 0.949386;ebbc70c1-a260-4e59-b499-14dcec7e6f04;;;;`,
`2025-01-16T13:59:45.293Z;2025-01-16;DEFAULT;TRADING;BUY;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;485.0000000000;64.410000;-31238.85;-1.00;;EUR;;;;Buy trade IE00B8FHGS14 iShares VI plc, quantity: 485;93aaf560-5d26-4fd3-95ab-3cff4e5f1b12;;;;`,
`2025-01-18T00:27:04.446Z;2025-01-18;DEFAULT;TRADING;BUY;CRYPTO;Dogecoin;DOGE;865.7000000000;0.415787;-359.95;-1.00;;EUR;;;;Ausfuehrung Kauf/Verkauf XF000DOGE012;f305e14c-b9a5-43eb-adbc-b8b00f579c80;;;;`,
`2025-02-01T12:24:38.795049Z;2025-02-01;DEFAULT;CASH;INTEREST_PAYMENT;;;;;;16.460000;;-4.33;EUR;;;;Interest payment Booking;94ad7cae-6b55-4d11-83ef-668c397e9391;;;;`,
`2025-02-10T13:29:45.670Z;2025-02-10;DEFAULT;TRADING;SELL;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;-20.0000000000;67.210000;1344.20;-1.00;-10.14;EUR;;;;Sell trade IE00B8FHGS14 iShares VI plc, quantity: 20;7b647416-c8e8-45bf-beea-2aea65e3950a;;;;`,
`2025-03-07T02:29:08.390291Z;2025-03-07;DEFAULT;CASH;TAX_OPTIMIZATION;;;;;;0.000000;;14.95;EUR;;;;Tax Optimisation;b9a02670-b419-42d4-a8d7-d0336d9ae9cb;;;;`,
`2025-09-30T12:49:02.644Z;2025-09-30;DEFAULT;TRADING;BUY;STOCK;DroneShield;AU000000DRO2;167.0000000000;2.9800000000;-497.66;-1.00;;EUR;;;;Buy trade AU000000DRO2 DRONESHIELD LTD, quantity: 167.0;9b08e71c-5d85-49c4-bcf3-31bc7671a278;;;;`,
`2025-10-06T09:02:07.835Z;2025-10-06;DEFAULT;TRADING;SELL;FUND;Edge MSCI World Min Volatility USD (Acc);IE00B8FHGS14;-0.4265810000;63.0600000000;26.90;-1.00;;EUR;;;;Sell trade IE00B8FHGS14 iShares VI plc, quantity: 0.426581;dcd1df8b-3324-4f71-be38-4a2e6cde326d;;;;`,
`2025-12-23T12:44:47.627337Z;2025-12-23;DEFAULT;CASH;TRANSFER_INSTANT_OUTBOUND;;;;;;-5700.000000;;;EUR;;;;Outgoing transfer for Lars Nolden (DE04100110012623927730);019b4b3d-9c8b-7e5a-b17f-9c884edc0ae8;;;;`,
`2026-01-27T08:44:12.845140Z;2026-01-27;DEFAULT;CASH;TAX_OPTIMIZATION;;;;;;0.000000;;-30.44;EUR;;;;Tax Optimisation;019bfe9f-eead-7321-ac84-d1aac355b444;;;;`,
`2026-04-09T09:08:43.203685Z;2026-04-09;DEFAULT;CASH;DIVIDEND;STOCK;TSMC (ADR);US8740391003;24.9110320000;;15.790000;;-3.17;EUR;18.48;USD;0.854263;Cash Dividend for ISIN US8740391003;019d7180-3e43-7de4-bcd1-7a61a118944a;;;;`,
`2026-05-11T16:08:03.023362Z;2026-05-11;DEFAULT;CASH;TRANSFER_INSTANT_OUTBOUND;;;;;;-1481.000000;;;EUR;;;;Outgoing transfer for LARS NOLDEN (DE41110101002098897347);019e17cb-a6cf-70f7-b602-886ffa8fdffe;LARS NOLDEN;DE41110101002098897347;;`,
`2026-05-26T13:18:23.430Z;2026-05-26;DEFAULT;TRADING;SELL;CRYPTO;Dogecoin;DOGE;-865.7000000000;0.0879270000;76.12;-1.00;;EUR;;;;Sell trade XF000DOGE012 Dogecoin, quantity: 865.7;9a82e774-08a8-49bc-a706-6a65691b71c7;;;;`,
}
func readTradeRepublic(t *testing.T, rows ...string) BrokerImport {
t.Helper()
file, err := ReadCSV(strings.NewReader(tradeRepublicHeader + strings.Join(rows, "\n") + "\n"))
if err != nil {
t.Fatal(err)
}
result, err := ParseTradeRepublicCSV(file, brokerAccount(), nil)
if err != nil {
t.Fatal(err)
}
return result
}
func TestTradeRepublicSettlesGrossLessItsSignedAdjustments(t *testing.T) {
result := readTradeRepublic(t, tradeRepublicRows...)
if len(result.Facts) != len(tradeRepublicRows) {
t.Fatalf("read %d of %d rows", len(result.Facts), len(tradeRepublicRows))
}
// The export writes fee and tax as the signed adjustments it made, and the
// amount as the gross. Cash is what is left, and a tax settlement's money
// lives entirely in the tax column.
wantCash := map[string]string{
"cccf7fb9-f35a-462a-8d2c-162664479274": "34337.00",
"ebbc70c1-a260-4e59-b499-14dcec7e6f04": "-61.15",
"93aaf560-5d26-4fd3-95ab-3cff4e5f1b12": "-31239.85",
"f305e14c-b9a5-43eb-adbc-b8b00f579c80": "-360.95",
"94ad7cae-6b55-4d11-83ef-668c397e9391": "12.13",
"7b647416-c8e8-45bf-beea-2aea65e3950a": "1333.06",
"b9a02670-b419-42d4-a8d7-d0336d9ae9cb": "14.95",
"9b08e71c-5d85-49c4-bcf3-31bc7671a278": "-498.66",
"dcd1df8b-3324-4f71-be38-4a2e6cde326d": "25.90",
"019b4b3d-9c8b-7e5a-b17f-9c884edc0ae8": "-5700.00",
"019bfe9f-eead-7321-ac84-d1aac355b444": "-30.44",
"019d7180-3e43-7de4-bcd1-7a61a118944a": "12.62",
"019e17cb-a6cf-70f7-b602-886ffa8fdffe": "-1481.00",
"9a82e774-08a8-49bc-a706-6a65691b71c7": "75.12",
}
total := int64(0)
for _, f := range result.Facts {
minor, err := f.Amount.Minor()
if err != nil {
t.Fatal(err)
}
total += minor
if want, ok := wantCash[f.ExternalID]; !ok {
t.Errorf("unexpected record %s", f.ExternalID)
} else if string(f.Amount) != want {
t.Errorf("%s settled %s, want %s", f.ExternalID, f.Amount, want)
}
}
if got := string(domain.FormatMoney(total)); got != "-3561.27" {
t.Errorf("cash balance %s, want -3561.27", got)
}
holdings := map[string]int64{}
for _, f := range result.Facts {
if f.Investment.Quantity == "" {
continue
}
units, err := f.Investment.Quantity.Units()
if err != nil {
t.Fatal(err)
}
holdings[f.Investment.InstrumentID] += units
}
for isin, want := range map[string]int64{
"IE00B8FHGS14": 46552280500, // 0.949386 + 485 20 0.426581
"XF000DOGE012": 0, // bought and sold whole
"AU000000DRO2": 16700000000,
"US8740391003": 0, // a dividend attributes to a security without moving it
} {
if got := holdings[domain.InstrumentID(isin)]; got != want {
t.Errorf("%s holds %d hundred-millionths, want %d", isin, got, want)
}
}
// Crypto carries a bare ticker in the symbol column, so its identifier
// comes from the description, and the security is registered like any other.
names := map[string]string{}
for _, v := range result.Instruments {
names[v.ISIN] = v.Name
}
for isin, want := range map[string]string{
"XF000DOGE012": "Dogecoin",
"IE00B8FHGS14": "Edge MSCI World Min Volatility USD (Acc)",
"US8740391003": "TSMC (ADR)",
"AU000000DRO2": "DroneShield",
} {
if names[isin] != want {
t.Errorf("%s named %q, want %q", isin, names[isin], want)
}
}
// Padding is not precision: a value written to six or ten places with
// trailing zeros needs no rounding at all.
if result.Rounded != 0 || result.Rounding != "0" {
t.Errorf("rounding reported as %d row(s) and %s, want none", result.Rounded, result.Rounding)
}
// The export applies its own fee and tax, so nothing is recorded unapplied.
if len(result.Unapplied) != 0 {
t.Errorf("unapplied notes on an export that nets its own cash: %+v", result.Unapplied)
}
}
// A dividend populates the share column with the holding the dividend was paid
// on. Adding it as a position change would double the holding.
func TestTradeRepublicDividendDoesNotMoveThePosition(t *testing.T) {
result := readTradeRepublic(t, tradeRepublicRows[11])
dividend := result.Facts[0].Investment
if dividend.Quantity != "" || dividend.Price != "" {
t.Fatalf("dividend moved a position: %+v", dividend)
}
if dividend.Event != domain.EventDistribution || dividend.InstrumentID == "" {
t.Fatalf("dividend lost its attribution: %+v", dividend)
}
// 18.48 USD at 0.854263 is 15.79 EUR gross, less 3.17 withheld.
if result.Facts[0].Amount != "12.62" || dividend.Gross != "15.79" || dividend.Tax != "3.17" {
t.Fatalf("dividend settled %s from gross %s less tax %s", result.Facts[0].Amount, dividend.Gross, dividend.Tax)
}
}
// The counterparty comes from the column when the export has one, from the
// IBAN the description names when it does not, and from the account's
// configured settlement IBAN when neither names anything. Without it a broker
// transfer cannot pair with the bank debit that funded it.
func TestTradeRepublicResolvesTransferCounterparties(t *testing.T) {
result := readTradeRepublic(t, tradeRepublicRows[0], tradeRepublicRows[9], tradeRepublicRows[12])
want := []string{
"DE89370400440532013000", // neither column nor description: the account's own settlement IBAN
"DE04100110012623927730", // named in the description only
"DE41110101002098897347", // the column
}
for i, f := range result.Facts {
if f.CounterpartyIBAN != want[i] {
t.Errorf("record %d settled against %q, want %q", i+1, f.CounterpartyIBAN, want[i])
}
}
if result.Facts[2].Counterparty != "LARS NOLDEN" {
t.Errorf("counterparty name lost: %q", result.Facts[2].Counterparty)
}
}
// A notional that does not land on a whole cent is the normal case here, not an
// error: the export states cash to the cent while the product runs longer.
func TestTradeRepublicChecksGrossToTheStatedPrecision(t *testing.T) {
result := readTradeRepublic(t, tradeRepublicRows[8])
inv := result.Facts[0].Investment
// 0.426581 x 63.06 = 26.90019786, stated as 26.90.
if inv.Gross != "26.90" || inv.Quantity != "-0.426581" || inv.Price != "63.06" {
t.Fatalf("trade read as %+v", inv)
}
// A factor of ten is still caught: the tolerance is one cent, not one order.
broken := strings.Replace(tradeRepublicRows[8], ";26.90;", ";269.00;", 1)
file, err := ReadCSV(strings.NewReader(tradeRepublicHeader + broken + "\n"))
if err != nil {
t.Fatal(err)
}
if _, err := ParseTradeRepublicCSV(file, brokerAccount(), nil); err == nil {
t.Fatal("accepted a gross ten times its own shares times price")
}
}
func TestTradeRepublicRejectsRowsItCannotAccountFor(t *testing.T) {
const base = `2026-01-05T10:00:00Z;2026-01-05;DEFAULT;CASH;TRANSFER_INBOUND;;;;;;12.00;;;EUR;;;;Incoming transfer;R1;;;;`
for name, row := range map[string]string{
"unknown type": strings.Replace(base, "TRANSFER_INBOUND", "VORABPAUSCHALE", 1),
"category mismatch": strings.Replace(base, "CASH;TRANSFER_INBOUND", "TRADING;TRANSFER_INBOUND", 1),
"foreign currency": strings.Replace(base, ";EUR;", ";USD;", 1),
"other account type": strings.Replace(base, ";DEFAULT;", ";SAVINGS;", 1),
"trade without a security": `2026-01-05T10:00:00Z;2026-01-05;DEFAULT;TRADING;BUY;STOCK;Mystery;;1.0;2.00;-2.00;;;EUR;;;;Buy trade of something;R2;;;;`,
"unresolvable ticker": `2026-01-05T10:00:00Z;2026-01-05;DEFAULT;TRADING;BUY;CRYPTO;Bitcoin;BTC;1.0;2.00;-2.00;;;EUR;;;;Kauf Bitcoin;R3;;;;`,
} {
file, err := ReadCSV(strings.NewReader(tradeRepublicHeader + row + "\n"))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if _, err := ParseTradeRepublicCSV(file, brokerAccount(), nil); err == nil {
t.Errorf("%s: accepted a row that can move money it should not", name)
}
}
}
// Both formats are recognized from the same upload path, and neither is
// mistaken for the other.
func TestBrokerDetectionDistinguishesTheTwoExports(t *testing.T) {
for _, format := range []struct {
name, header, row, want string
}{
{"trade republic", tradeRepublicHeader, tradeRepublicRows[0], SourceTradeRepublic},
{"scalable", scalableHeader, scalableRows[0], SourceScalable},
} {
file, err := ReadCSV(strings.NewReader(format.header + format.row + "\n"))
if err != nil {
t.Fatalf("%s: %v", format.name, err)
}
source, label, header, ok := DetectBrokerCSV(file)
if !ok || source != format.want || header != 1 || label == "" {
t.Fatalf("%s detected as %q/%q at row %d (ok=%v)", format.name, source, label, header, ok)
}
if _, err := ParseBrokerCSV(file, brokerAccount(), nil); err != nil {
t.Errorf("%s: %v", format.name, err)
}
}
}
+61 -16
View File
@@ -246,6 +246,9 @@ func validText(values ...string) bool {
} }
return true return true
} }
func validHint(s string) bool {
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 200
}
// ValidISIN reports a syntactically valid ISIN: two country letters, nine // ValidISIN reports a syntactically valid ISIN: two country letters, nine
// alphanumerics and a check digit. // alphanumerics and a check digit.
@@ -289,8 +292,8 @@ func Validate(d Dataset) error {
if err := register(c.ID, "category"); err != nil { if err := register(c.ID, "category"); err != nil {
return err return err
} }
if !nonblank(c.Name) || (c.Kind != "expense" && c.Kind != "income") { if !nonblank(c.Name) || !validHint(c.Hint) || (c.Kind != "expense" && c.Kind != "income") {
return fmt.Errorf("category %q: invalid name or kind", c.ID) return fmt.Errorf("category %q: invalid name, hint or kind", c.ID)
} }
categories[c.ID] = c categories[c.ID] = c
if c.ParentID != "" { if c.ParentID != "" {
@@ -327,8 +330,8 @@ func Validate(d Dataset) error {
if err := register(t.ID, "tag"); err != nil { if err := register(t.ID, "tag"); err != nil {
return err return err
} }
if !nonblank(t.Name) { if !nonblank(t.Name) || !validHint(t.Hint) {
return fmt.Errorf("tag %q: name required", t.ID) return fmt.Errorf("tag %q: name or hint invalid", t.ID)
} }
tags[t.ID] = true tags[t.ID] = true
} }
@@ -463,9 +466,12 @@ func (index enrichmentIndex) validate(f Facts, e Enrichment) error {
if e.MerchantID != "" && !index.merchants[e.MerchantID] { if e.MerchantID != "" && !index.merchants[e.MerchantID] {
return fmt.Errorf("unknown merchant %q", e.MerchantID) return fmt.Errorf("unknown merchant %q", e.MerchantID)
} }
if !validText(e.Classification.Source, e.Classification.Model, e.Classification.Error) { if !validText(e.Classification.Source, e.Classification.Model, e.Classification.Confidence, e.Classification.Error) {
return fmt.Errorf("classification metadata must be valid UTF-8") return fmt.Errorf("classification metadata must be valid UTF-8")
} }
if e.Classification.Confidence != "" && e.Classification.Confidence != "high" && e.Classification.Confidence != "medium" && e.Classification.Confidence != "low" {
return fmt.Errorf("invalid classification confidence")
}
if e.Classification.Timestamp != "" { if e.Classification.Timestamp != "" {
if _, err := time.Parse(time.RFC3339Nano, e.Classification.Timestamp); err != nil { if _, err := time.Parse(time.RFC3339Nano, e.Classification.Timestamp); err != nil {
return fmt.Errorf("invalid classification timestamp") return fmt.Errorf("invalid classification timestamp")
@@ -538,18 +544,18 @@ func optionalQuantity(q Quantity) (int64, error) {
return q.Units() return q.Units()
} }
// RoundedProduct multiplies an exact share count by an exact price and rounds // RoundedProduct multiplies an exact share count by an exact unit price and
// to money's four places, half away from zero. Quantity is 1e-8 units and // rounds to money's four places, half away from zero. Both operands are 1e-8
// price is 1e-4 units, so the product is 1e-12 and needs 128-bit width. // units, so the product is 1e-16 and needs 128-bit width.
func RoundedProduct(quantity, price int64) (int64, bool) { func RoundedProduct(quantity, price int64) (int64, bool) {
product := new(big.Int).Mul(big.NewInt(quantity), big.NewInt(price)) product := new(big.Int).Mul(big.NewInt(quantity), big.NewInt(price))
half := big.NewInt(50_000_000) half := big.NewInt(500_000_000_000)
if product.Sign() < 0 { if product.Sign() < 0 {
product.Sub(product, half) product.Sub(product, half)
} else { } else {
product.Add(product, half) product.Add(product, half)
} }
rounded := product.Quo(product, big.NewInt(100_000_000)) rounded := product.Quo(product, big.NewInt(1_000_000_000_000))
if !rounded.IsInt64() { if !rounded.IsInt64() {
return 0, false return 0, false
} }
@@ -592,7 +598,7 @@ func validateInvestment(f Facts, a Account, instruments map[string]Instrument) e
if err != nil { if err != nil {
return err return err
} }
price, err := optionalMoney(inv.Price) price, err := optionalQuantity(inv.Price)
if err != nil { if err != nil {
return err return err
} }
@@ -613,11 +619,19 @@ func validateInvestment(f Facts, a Account, instruments map[string]Instrument) e
return err return err
} }
if inv.CashOnly() { if inv.CashOnly() {
if quantity != 0 || inv.Price != "" || inv.Gross != "" { if quantity != 0 || inv.Price != "" {
return fmt.Errorf("%s moves cash only: it carries no quantity, price or gross", inv.Event) return fmt.Errorf("%s moves cash only: it carries no quantity or price", inv.Event)
} }
// The gross is optional here. One broker states a cash row already net
// of the tax it withheld, and then only the net is knowable, so the
// tax is recorded and never applied. Another states the gross and the
// deductions separately, and then the settlement is checkable like any
// trade's. Which one is a fact about the source, decided at import.
if inv.Gross == "" {
return nil return nil
} }
return settles(inv, gross, fee, tax, amount)
}
if inv.InstrumentID == "" { if inv.InstrumentID == "" {
return fmt.Errorf("%s requires an instrument", inv.Event) return fmt.Errorf("%s requires an instrument", inv.Event)
} }
@@ -633,8 +647,19 @@ func validateInvestment(f Facts, a Account, instruments map[string]Instrument) e
if inv.Settling() { if inv.Settling() {
expected = -expected expected = -expected
} }
if gross != expected { // The gross is checked to the precision the broker stated it at, and no
return fmt.Errorf("%s gross %s does not equal quantity %s times price %s", inv.Event, Money(formatScaled(gross, moneyScale, 2)), inv.Quantity.String(), inv.Price.String()) // further. One broker prints the exact product to nine places, and the
// check is then exact. Another prints the notional rounded to cents, where
// demanding exactness rejects every trade whose product does not land on a
// whole cent - measured on a real export, 29 of 59 of them. One unit of
// the stated precision is still four orders of magnitude tighter than the
// misplaced decimal separator this check exists to catch.
difference := expected - gross
if difference < 0 {
difference = -difference
}
if difference >= statedUnit(inv.Gross) {
return fmt.Errorf("%s gross %s does not equal quantity %s times price %s, which is %s", inv.Event, inv.Gross.String(), inv.Quantity.String(), inv.Price.String(), Money(formatScaled(expected, moneyScale, 2)))
} }
if inv.PositionOnly() { if inv.PositionOnly() {
if amount != 0 { if amount != 0 {
@@ -648,10 +673,30 @@ func validateInvestment(f Facts, a Account, instruments map[string]Instrument) e
if (inv.Event == EventSell) != (quantity < 0) { if (inv.Event == EventSell) != (quantity < 0) {
return fmt.Errorf("%s must %s the position", inv.Event, map[bool]string{true: "reduce", false: "increase"}[inv.Event == EventSell]) return fmt.Errorf("%s must %s the position", inv.Event, map[bool]string{true: "reduce", false: "increase"}[inv.Event == EventSell])
} }
return settles(inv, gross, fee, tax, amount)
}
// settles enforces that the cash a fact moved is its gross less the fee and
// the tax deducted from it. Fee and tax are stored as deductions whichever sign
// the source printed, so a refunded tax is a negative deduction and a broker
// that writes its fee as a negative adjustment is normalized at import.
func settles(inv *Investment, gross, fee, tax, amount int64) error {
settled := new(big.Int).Sub(big.NewInt(gross), big.NewInt(fee)) settled := new(big.Int).Sub(big.NewInt(gross), big.NewInt(fee))
settled.Sub(settled, big.NewInt(tax)) settled.Sub(settled, big.NewInt(tax))
if !settled.IsInt64() || settled.Int64() != amount { if !settled.IsInt64() || settled.Int64() != amount {
return fmt.Errorf("%s cash %s does not equal gross %s minus fee %s minus tax %s", inv.Event, f.Amount.String(), inv.Gross.String(), inv.Fee.String(), inv.Tax.String()) return fmt.Errorf("%s cash %s does not equal gross %s minus fee %s minus tax %s", inv.Event, Money(formatScaled(amount, moneyScale, 2)), inv.Gross.String(), inv.Fee.String(), inv.Tax.String())
} }
return nil return nil
} }
// statedUnit is one unit of the last decimal place a money figure was written
// with, in exact ten-thousandths. Money always renders at least two places, so
// a whole-euro figure counts as stated to the cent.
func statedUnit(m Money) int64 {
_, fraction, _ := strings.Cut(string(m), ".")
unit := int64(1)
for range moneyScale - len(strings.TrimRight(fraction, "0")) {
unit *= 10
}
return unit
}
+10 -5
View File
@@ -3,9 +3,10 @@ package domain
// Money is an exact decimal string bounded to signed 64-bit ten-thousandths. // Money is an exact decimal string bounded to signed 64-bit ten-thousandths.
type Money string type Money string
// Quantity is an exact decimal string bounded to signed 64-bit hundred-millionths. // Quantity is an exact decimal string bounded to signed 64-bit
// Broker share counts are fractional: savings plans and reinvested distributions // hundred-millionths. It carries both share counts and unit prices, because
// settle in eight decimal places, which Money cannot represent. // both exceed money's four places: a reinvested distribution settles a fraction
// of a share, and a crypto unit price is quoted to six.
type Quantity string type Quantity string
// Account kinds. An empty kind is a cash account: the field was added after the // Account kinds. An empty kind is a cash account: the field was added after the
@@ -43,6 +44,10 @@ const (
EventWithdrawal = "withdrawal" EventWithdrawal = "withdrawal"
EventFee = "fee" EventFee = "fee"
EventInterest = "interest" EventInterest = "interest"
// EventTaxSettlement is a broker settling withheld tax in cash, in either
// direction: a loss-offset pot returning tax already paid, or a
// recalculation charging more.
EventTaxSettlement = "tax_settlement"
EventDistribution = "distribution" EventDistribution = "distribution"
EventBuy = "buy" EventBuy = "buy"
EventSell = "sell" EventSell = "sell"
@@ -62,7 +67,7 @@ type Investment struct {
Event string `json:"event"` Event string `json:"event"`
InstrumentID string `json:"instrument_id,omitempty"` InstrumentID string `json:"instrument_id,omitempty"`
Quantity Quantity `json:"quantity,omitempty"` Quantity Quantity `json:"quantity,omitempty"`
Price Money `json:"price,omitempty"` Price Quantity `json:"price,omitempty"`
Gross Money `json:"gross,omitempty"` Gross Money `json:"gross,omitempty"`
Fee Money `json:"fee,omitempty"` Fee Money `json:"fee,omitempty"`
Tax Money `json:"tax,omitempty"` Tax Money `json:"tax,omitempty"`
@@ -71,7 +76,7 @@ type Investment struct {
// CashOnly reports an event that moves money without moving a position. // CashOnly reports an event that moves money without moving a position.
func (i Investment) CashOnly() bool { func (i Investment) CashOnly() bool {
switch i.Event { switch i.Event {
case EventDeposit, EventWithdrawal, EventFee, EventInterest, EventDistribution: case EventDeposit, EventWithdrawal, EventFee, EventInterest, EventTaxSettlement, EventDistribution:
return true return true
} }
return false return false
+1309 -139
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -33,6 +33,7 @@ const EVENTS: Record<string, string> = {
withdrawal: "Withdrawal", withdrawal: "Withdrawal",
fee: "Fee", fee: "Fee",
interest: "Interest", interest: "Interest",
tax_settlement: "Tax settlement",
distribution: "Distribution", distribution: "Distribution",
buy: "Buy", buy: "Buy",
sell: "Sell", sell: "Sell",
+53 -1
View File
@@ -155,15 +155,27 @@ export interface Group {
amount: string; amount: string;
count: number; count: number;
} }
// MonthlyPoint mirrors the analytics row: income and expenses are both positive
// magnitudes, net is the only signed figure.
export interface MonthlyPoint {
period: string;
currency: string;
income: string;
expenses: string;
net: string;
count: number;
}
export interface Dashboard { export interface Dashboard {
totals: Total[]; totals: Total[];
previous: Total[]; previous: Total[];
monthly: Group[]; monthly: MonthlyPoint[];
categories: Group[]; categories: Group[];
previous_categories: Group[];
tags: Group[]; tags: Group[];
merchants: Group[]; merchants: Group[];
accounts: Group[]; accounts: Group[];
recurring: Group[]; recurring: Group[];
largest: Group[];
} }
export interface Filter { export interface Filter {
from: string; from: string;
@@ -418,6 +430,29 @@ export function money(value: string, currency: string): string {
const decimals = (match[3] || "").replace(/0+$/, "").padEnd(2, "0"); const decimals = (match[3] || "").replace(/0+$/, "").padEnd(2, "0");
return `${match[1] === "-" ? "" : ""}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${decimals} ${currency}`; return `${match[1] === "-" ? "" : ""}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${decimals} ${currency}`;
} }
// compactMoney is for chart axes and ticks, where an exact figure would not
// fit: it rounds to at most one fractional digit and abbreviates thousands.
// Every figure a user might act on is still rendered by money().
export function compactMoney(value: string, currency = ""): string {
const n = Number(value);
if (!Number.isFinite(n)) return value;
const sign = n < 0 ? "" : "";
const abs = Math.abs(n);
const [scaled, unit]: [number, string] =
abs >= 1e9
? [abs / 1e9, "b"]
: abs >= 1e6
? [abs / 1e6, "m"]
: abs >= 1000
? [abs / 1000, "k"]
: [abs, ""];
const digits = unit ? (scaled < 10 ? 1 : 0) : abs > 0 && abs < 10 ? 2 : 0;
const text = scaled.toLocaleString("en-US", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${sign}${text}${unit}${currency ? ` ${currency}` : ""}`;
}
export function categoryPath(data: Dataset, id?: string): string { export function categoryPath(data: Dataset, id?: string): string {
if (!id) return "No category"; if (!id) return "No category";
const names: string[] = []; const names: string[] = [];
@@ -439,3 +474,20 @@ export const emptyFilter: Filter = {
tag_id: "", tag_id: "",
merchant_id: "", merchant_id: "",
}; };
// A six-month window is the default view: long enough to show a trend and a
// seasonal bill, short enough that the current month still matters. The window
// starts on the first day of the month, so month buckets are whole.
export const DEFAULT_MONTHS = 6;
export function monthStart(monthsBack: number): string {
const now = new Date();
const day = new Date(
Date.UTC(now.getFullYear(), now.getMonth() - monthsBack, 1),
);
return day.toISOString().slice(0, 10);
}
export function yearStart(): string {
return `${new Date().getFullYear()}-01-01`;
}
export function defaultFilter(): Filter {
return { ...emptyFilter, from: monthStart(DEFAULT_MONTHS - 1) };
}
+2 -2
View File
@@ -21,7 +21,7 @@ import {
import type { State } from "./api"; import type { State } from "./api";
import { import {
APIError, APIError,
emptyFilter, defaultFilter,
localInstant, localInstant,
normalizeState, normalizeState,
request, request,
@@ -64,7 +64,7 @@ function App() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [mobileNav, setMobileNav] = useState(false); const [mobileNav, setMobileNav] = useState(false);
const [filter, setFilter] = useState({ ...emptyFilter }); const [filter, setFilter] = useState(defaultFilter);
const acceptState = useCallback((value: State, message?: string) => { const acceptState = useCallback((value: State, message?: string) => {
setState(normalizeState(value)); setState(normalizeState(value));
setConflict(false); setConflict(false);
+356 -74
View File
@@ -474,7 +474,7 @@ main {
} }
.stat-grid { .stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(178px, 1fr));
gap: 20px; gap: 20px;
} }
.stat { .stat {
@@ -528,15 +528,18 @@ main {
.dashboard-grid.thirds { .dashboard-grid.thirds {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.dashboard-grid.flipped {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
}
.dashboard-grid.even {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-panel {
overflow: hidden;
}
.dashboard-grid .panel { .dashboard-grid .panel {
height: calc(100% - 24px); height: calc(100% - 24px);
} }
.monthly-charts {
padding: 0 24px 25px;
}
.monthly-charts > div + div {
margin-top: 28px;
}
.eyebrow { .eyebrow {
display: block; display: block;
font-size: 10px; font-size: 10px;
@@ -545,60 +548,6 @@ main {
font-weight: 650; font-weight: 650;
color: #819387; color: #819387;
} }
.bar-chart {
display: flex;
gap: 13px;
height: 242px;
overflow-x: auto;
margin-top: 12px;
padding: 28px 5px 0;
border-bottom: 1px solid #e9eef1;
background: repeating-linear-gradient(
to top,
transparent 0,
transparent 51px,
#f0f3f6 52px,
#f0f3f6 53px
);
}
.bar-column {
min-width: 43px;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.bar-track {
height: 170px;
width: 100%;
max-width: 48px;
display: flex;
align-items: flex-end;
}
.bar {
background: #63bca0;
border-radius: 4px 4px 0 0;
min-height: 2px;
width: 100%;
transition: height 0.3s;
}
.bar.negative {
background: #afbecd;
}
.bar-value {
font-size: 9px;
position: absolute;
top: -23px;
white-space: nowrap;
color: #748496;
}
.bar-label {
font-size: 9px;
color: #8e99a7;
margin-top: 13px;
white-space: nowrap;
}
.group-list { .group-list {
padding: 0 24px 16px; padding: 0 24px 16px;
} }
@@ -1537,6 +1486,10 @@ footer span:first-child {
grid-template-columns: 1.2fr 1fr; grid-template-columns: 1.2fr 1fr;
gap: 18px; gap: 18px;
} }
.dashboard-grid.flipped,
.dashboard-grid.even {
gap: 18px;
}
.dashboard-grid.thirds { .dashboard-grid.thirds {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
@@ -1553,9 +1506,6 @@ footer span:first-child {
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
} }
.bar-value {
font-size: 8px;
}
.description { .description {
max-width: 220px; max-width: 220px;
} }
@@ -1614,7 +1564,9 @@ footer span:first-child {
.stat small { .stat small {
font-size: 9px; font-size: 9px;
} }
.dashboard-grid { .dashboard-grid,
.dashboard-grid.flipped,
.dashboard-grid.even {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-grid.thirds { .dashboard-grid.thirds {
@@ -1787,15 +1739,6 @@ footer span:first-child {
border-radius: 8px; border-radius: 8px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.monthly-charts {
padding: 0 17px 20px;
}
.bar-chart {
gap: 12px;
}
.bar-track {
max-width: 40px;
}
.group-list { .group-list {
padding: 0 18px 15px; padding: 0 18px 15px;
} }
@@ -2258,3 +2201,342 @@ footer span:first-child {
.category-node .button.subtle { .category-node .button.subtle {
font-size: 10px; font-size: 10px;
} }
.negative {
color: var(--danger);
}
.link {
border: 0;
background: transparent;
padding: 0;
color: #2b6f8a;
font: inherit;
text-align: left;
border-radius: 3px;
}
.link:hover {
color: var(--emerald-dark);
text-decoration: underline;
}
.filter-bar {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 9px;
margin-bottom: 24px;
box-shadow: 0 1px 2px #1c314705;
}
.range-row {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 18px 0;
}
.range-row .chips {
margin-top: 0;
gap: 5px;
}
.range-row .filter-reset {
margin-left: auto;
}
.filter-bar .filters {
border: 0;
box-shadow: none;
border-radius: 0;
margin-bottom: 0;
padding-top: 13px;
background: transparent;
}
.chip {
border: 1px solid #dde4ea;
background: #fcfdfe;
color: #61717f;
border-radius: 20px;
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
}
.chip:hover:not(.active) {
border-color: #b9cfc6;
color: #2c6d57;
}
.chip.active {
background: var(--emerald);
border-color: var(--emerald);
color: #fff;
}
.currency-switch {
display: flex;
gap: 6px;
margin-bottom: 18px;
}
.stat-icon.rate {
background: #f3f0fa;
color: #8a7fb0;
}
.stat-trend {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: #8d98a7;
}
.stat-trend strong {
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.stat-trend.better {
color: #2e8064;
}
.stat-trend.worse {
color: #a9554f;
}
/* Charts are drawn at measured pixel width, so the body only needs to be a
positioning context for the hover tooltip and to clip a stale wide SVG. */
.chart-body {
position: relative;
padding: 4px 20px 22px;
overflow: hidden;
}
.chart-body svg {
display: block;
overflow: visible;
}
.chart-axis {
font-size: 10px;
fill: #8e99a7;
font-variant-numeric: tabular-nums;
}
.chart-axis.strong {
font-size: 11px;
font-weight: 600;
fill: #56667b;
}
.chart-tip {
position: absolute;
top: 4px;
transform: translateX(-50%);
background: #16283c;
color: #eef3f7;
border-radius: 7px;
padding: 9px 11px;
font-size: 11px;
min-width: 178px;
pointer-events: none;
box-shadow: 0 6px 18px #10223426;
z-index: 2;
}
.chart-tip strong {
display: block;
font-size: 11px;
font-weight: 650;
margin-bottom: 6px;
}
.chart-tip span {
display: flex;
align-items: center;
gap: 7px;
color: #b9c6d2;
line-height: 1.85;
}
.chart-tip span b {
margin-left: auto;
color: #fff;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.chart-tip i {
width: 8px;
height: 8px;
border-radius: 2px;
flex: none;
}
.chart-tip em {
display: block;
margin-top: 5px;
font-style: normal;
color: #8ea0b1;
font-size: 10px;
}
.flow-node.drill {
cursor: pointer;
}
.flow-node.drill:hover rect {
opacity: 0.75;
}
.flow-node.drill:hover .flow-name {
fill: var(--emerald-dark);
}
.flow-name {
font-size: 11px;
font-weight: 600;
fill: #37495d;
}
.flow-value {
font-size: 10px;
fill: #8b96a4;
font-variant-numeric: tabular-nums;
}
.flow-trunk {
font-size: 11px;
font-weight: 650;
fill: #46586c;
font-variant-numeric: tabular-nums;
}
.share-body {
display: flex;
align-items: center;
gap: 26px;
padding: 6px 24px 24px;
flex-wrap: wrap;
}
.share-body svg {
flex: none;
}
.donut-total {
font-size: 17px;
font-weight: 700;
fill: var(--navy);
font-variant-numeric: tabular-nums;
}
.donut-caption {
font-size: 10px;
letter-spacing: 1.2px;
text-transform: uppercase;
fill: #94a0ad;
}
.share-legend {
flex: 1;
min-width: 190px;
}
.share-row {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 6px 4px;
border-radius: 4px;
font-size: 11px;
color: #50606f;
}
.share-row:hover:not(:disabled) {
background: #f7faf9;
}
.share-row i {
width: 9px;
height: 9px;
border-radius: 2px;
flex: none;
}
.share-row span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-row b {
font-weight: 650;
color: var(--navy);
font-variant-numeric: tabular-nums;
}
.share-row em {
font-style: normal;
color: #8b96a4;
font-size: 10px;
min-width: 84px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.mover-list {
padding: 0 24px 16px;
}
.mover-row {
border: 0;
background: transparent;
width: 100%;
text-align: left;
padding: 9px 0 11px;
display: block;
border-radius: 4px;
}
.mover-row:hover {
background: #f7faf9;
}
.mover-row small {
color: #93a0ad;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.mover-head {
display: flex;
justify-content: space-between;
gap: 14px;
font-size: 11px;
margin-bottom: 8px;
color: #46586c;
}
.mover-head > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 550;
}
.mover-head strong {
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.mover-track {
height: 5px;
background: #eef2f5;
border-radius: 10px;
overflow: hidden;
margin-bottom: 6px;
}
.mover-track span {
display: block;
height: 100%;
border-radius: 10px;
}
.mover-track span.up {
background: #d09090;
}
.mover-track span.down {
background: #7cc0a8;
}
.group-track span.out {
background: #d09090;
}
.stat-notes {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
@media (max-width: 680px) {
.range-row {
flex-wrap: wrap;
padding: 13px 13px 0;
gap: 9px;
}
.range-row .filter-reset {
margin-left: 0;
}
.chart-body {
padding: 4px 12px 18px;
}
.share-body {
padding: 6px 16px 20px;
gap: 16px;
justify-content: center;
}
.mover-list {
padding: 0 18px 15px;
}
.chart-tip {
min-width: 150px;
font-size: 10px;
}
}
+50 -7
View File
@@ -9,7 +9,13 @@ import {
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import type { Dataset, Filter } from "./api"; import type { Dataset, Filter } from "./api";
import { categoryPath, emptyFilter } from "./api"; import {
categoryPath,
DEFAULT_MONTHS,
defaultFilter,
monthStart,
yearStart,
} from "./api";
export function Modal({ export function Modal({
title, title,
children, children,
@@ -403,7 +409,49 @@ export function Filters({
...data.transactions.map((t) => t.facts.currency), ...data.transactions.map((t) => t.facts.currency),
]), ]),
).sort(); ).sort();
// Presets leave `to` open so the window always reaches today; the explicit
// date fields below stay authoritative for anything narrower.
const ranges = [
...[1, 3, DEFAULT_MONTHS, 12].map((months) => ({
label: `${months}M`,
title: months === 1 ? "This month" : `Last ${months} months`,
from: monthStart(months - 1),
to: "",
})),
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
{ label: "All", title: "All time", from: "", to: "" },
];
return ( return (
<div className="filter-bar">
<div className="range-row">
<span className="eyebrow">Period</span>
<div className="chips">
{ranges.map((range) => {
const active = value.from === range.from && value.to === range.to;
return (
<button
key={range.label}
type="button"
className={`chip ${active ? "active" : ""}`}
aria-pressed={active}
title={range.title}
aria-label={range.title}
onClick={() =>
onChange({ ...value, from: range.from, to: range.to })
}
>
{range.label}
</button>
);
})}
</div>
<button
className="button subtle filter-reset"
onClick={() => onChange(defaultFilter())}
>
Reset
</button>
</div>
<div className="filters"> <div className="filters">
<DateField <DateField
label="From" label="From"
@@ -478,12 +526,7 @@ export function Filters({
))} ))}
</select> </select>
</Field> </Field>
<button </div>
className="button subtle filter-reset"
onClick={() => onChange({ ...emptyFilter })}
>
Reset
</button>
</div> </div>
); );
} }