Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5912ece2 | ||
|
|
762ad3fae5 |
+115
-31
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 []Group `json:"categories"`
|
// Categories and PreviousCategories share a shape so the two periods can be
|
||||||
Tags []Group `json:"tags"`
|
// subtracted category by category; PreviousCategories is empty whenever the
|
||||||
Merchants []Group `json:"merchants"`
|
// filter has no comparable preceding interval.
|
||||||
Accounts []Group `json:"accounts"`
|
Categories []Group `json:"categories"`
|
||||||
Recurring []Group `json:"recurring"`
|
PreviousCategories []Group `json:"previous_categories"`
|
||||||
|
Tags []Group `json:"tags"`
|
||||||
|
Merchants []Group `json:"merchants"`
|
||||||
|
Accounts []Group `json:"accounts"`
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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
@@ -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`},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-17
@@ -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,10 +619,18 @@ 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)
|
||||||
}
|
}
|
||||||
return nil
|
// 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 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,10 +40,14 @@ func (a Account) Investing() bool { return a.Kind == AccountInvestment }
|
|||||||
// both money and position; corporate actions and position transfers move
|
// both money and position; corporate actions and position transfers move
|
||||||
// position only and must never touch cash.
|
// position only and must never touch cash.
|
||||||
const (
|
const (
|
||||||
EventDeposit = "deposit"
|
EventDeposit = "deposit"
|
||||||
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
|
||||||
|
|||||||
+1374
-204
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+123
-80
@@ -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,87 +409,124 @@ 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="filters">
|
<div className="filter-bar">
|
||||||
<DateField
|
<div className="range-row">
|
||||||
label="From"
|
<span className="eyebrow">Period</span>
|
||||||
clearable
|
<div className="chips">
|
||||||
value={value.from}
|
{ranges.map((range) => {
|
||||||
max={value.to || undefined}
|
const active = value.from === range.from && value.to === range.to;
|
||||||
onChange={(day) => update("from", day)}
|
return (
|
||||||
/>
|
<button
|
||||||
<DateField
|
key={range.label}
|
||||||
label="To"
|
type="button"
|
||||||
clearable
|
className={`chip ${active ? "active" : ""}`}
|
||||||
value={value.to}
|
aria-pressed={active}
|
||||||
min={value.from || undefined}
|
title={range.title}
|
||||||
onChange={(day) => update("to", day)}
|
aria-label={range.title}
|
||||||
/>
|
onClick={() =>
|
||||||
<Field label="Currency">
|
onChange({ ...value, from: range.from, to: range.to })
|
||||||
<select
|
}
|
||||||
value={value.currency}
|
>
|
||||||
onChange={(e) => update("currency", e.target.value)}
|
{range.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button subtle filter-reset"
|
||||||
|
onClick={() => onChange(defaultFilter())}
|
||||||
>
|
>
|
||||||
<option value="">All currencies</option>
|
Reset
|
||||||
{currencies.map((c) => (
|
</button>
|
||||||
<option key={c}>{c}</option>
|
</div>
|
||||||
))}
|
<div className="filters">
|
||||||
</select>
|
<DateField
|
||||||
</Field>
|
label="From"
|
||||||
<Field label="Account">
|
clearable
|
||||||
<select
|
value={value.from}
|
||||||
value={value.account_id}
|
max={value.to || undefined}
|
||||||
onChange={(e) => update("account_id", e.target.value)}
|
onChange={(day) => update("from", day)}
|
||||||
>
|
/>
|
||||||
<option value="">All accounts</option>
|
<DateField
|
||||||
{data.accounts.map((a) => (
|
label="To"
|
||||||
<option value={a.id} key={a.id}>
|
clearable
|
||||||
{a.display_name}
|
value={value.to}
|
||||||
</option>
|
min={value.from || undefined}
|
||||||
))}
|
onChange={(day) => update("to", day)}
|
||||||
</select>
|
/>
|
||||||
</Field>
|
<Field label="Currency">
|
||||||
<Field label="Category">
|
<select
|
||||||
<select
|
value={value.currency}
|
||||||
value={value.category_id}
|
onChange={(e) => update("currency", e.target.value)}
|
||||||
onChange={(e) => update("category_id", e.target.value)}
|
>
|
||||||
>
|
<option value="">All currencies</option>
|
||||||
<option value="">All categories</option>
|
{currencies.map((c) => (
|
||||||
<CategoryOptions data={data} />
|
<option key={c}>{c}</option>
|
||||||
</select>
|
))}
|
||||||
</Field>
|
</select>
|
||||||
<Field label="Tag">
|
</Field>
|
||||||
<select
|
<Field label="Account">
|
||||||
value={value.tag_id}
|
<select
|
||||||
onChange={(e) => update("tag_id", e.target.value)}
|
value={value.account_id}
|
||||||
>
|
onChange={(e) => update("account_id", e.target.value)}
|
||||||
<option value="">All tags</option>
|
>
|
||||||
{data.tags.map((t) => (
|
<option value="">All accounts</option>
|
||||||
<option value={t.id} key={t.id}>
|
{data.accounts.map((a) => (
|
||||||
{t.name}
|
<option value={a.id} key={a.id}>
|
||||||
</option>
|
{a.display_name}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</Field>
|
</select>
|
||||||
<Field label="Merchant">
|
</Field>
|
||||||
<select
|
<Field label="Category">
|
||||||
value={value.merchant_id}
|
<select
|
||||||
onChange={(e) => update("merchant_id", e.target.value)}
|
value={value.category_id}
|
||||||
>
|
onChange={(e) => update("category_id", e.target.value)}
|
||||||
<option value="">All merchants</option>
|
>
|
||||||
{data.merchants.map((m) => (
|
<option value="">All categories</option>
|
||||||
<option value={m.id} key={m.id}>
|
<CategoryOptions data={data} />
|
||||||
{m.name}
|
</select>
|
||||||
</option>
|
</Field>
|
||||||
))}
|
<Field label="Tag">
|
||||||
</select>
|
<select
|
||||||
</Field>
|
value={value.tag_id}
|
||||||
<button
|
onChange={(e) => update("tag_id", e.target.value)}
|
||||||
className="button subtle filter-reset"
|
>
|
||||||
onClick={() => onChange({ ...emptyFilter })}
|
<option value="">All tags</option>
|
||||||
>
|
{data.tags.map((t) => (
|
||||||
Reset
|
<option value={t.id} key={t.id}>
|
||||||
</button>
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Merchant">
|
||||||
|
<select
|
||||||
|
value={value.merchant_id}
|
||||||
|
onChange={(e) => update("merchant_id", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All merchants</option>
|
||||||
|
{data.merchants.map((m) => (
|
||||||
|
<option value={m.id} key={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user