Files
finance-duck/internal/domain/model.go
T
Lars Nolden 762ad3fae5 Import Trade Republic exports, whose conventions invert Scalable's
A second broker export is recognized locally, by its full column set, and read
through the same pipeline: detection and parsing now dispatch on the format, so
the upload path, the review dialog, deduplication, the journal and the Wealth
report are unchanged. Its nine row types cover cash transfers, interest,
dividends, tax settlements and trades in funds, shares and crypto; none of them
moves a position without moving cash, so the cash-neutral class that Scalable's
corporate actions belong to does not arise here.

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

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

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

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

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

Verified end to end against a real export: 26 rows import to a cash balance of
32187.02 matching the figure computed by hand from the source rows, all four
positions close at exactly zero, and every trade satisfies its own arithmetic.
2026-09-11 23:04:22 +02:00

182 lines
6.8 KiB
Go

package domain
// Money is an exact decimal string bounded to signed 64-bit ten-thousandths.
type Money string
// Quantity is an exact decimal string bounded to signed 64-bit
// hundred-millionths. It carries both share counts and unit prices, because
// 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
// Account kinds. An empty kind is a cash account: the field was added after the
// journal format, and absent means the original behaviour.
const (
AccountCash = "cash"
AccountInvestment = "investment"
)
type Account struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Institution string `json:"institution"`
Currency string `json:"currency"`
// Kind is "cash" or "investment". An investment account also holds
// positions, and its facts never reach the sign-based classification
// fallback.
Kind string `json:"kind,omitempty"`
ExternalAccountID string `json:"external_account_id,omitempty"`
IBAN string `json:"iban,omitempty"`
// ReferenceIBAN is the counterpart this account settles cash against: a
// broker exports no counterparty column, so deposits and withdrawals carry
// this IBAN instead and pair with the funding account like any transfer.
ReferenceIBAN string `json:"reference_iban,omitempty"`
Active bool `json:"active"`
}
func (a Account) Investing() bool { return a.Kind == AccountInvestment }
// Investment events. Cash events move money only; buy, sell and reinvest move
// both money and position; corporate actions and position transfers move
// position only and must never touch cash.
const (
EventDeposit = "deposit"
EventWithdrawal = "withdrawal"
EventFee = "fee"
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"
EventBuy = "buy"
EventSell = "sell"
EventReinvest = "reinvest"
EventCorporateAction = "corporate_action"
EventPositionTransfer = "position_transfer"
)
// Investment is the broker-native leg of an imported fact. Cash movement always
// stays in Facts.Amount, so a position-only event has a zero amount; Gross,
// Fee and Tax record the broker's own figures the amount was derived from.
//
// Quantity is signed: positive adds to the holding, negative removes it. The
// export signs corporate actions and position transfers in its share column but
// leaves buys and sells unsigned, so the sign is resolved at import, once.
type Investment struct {
Event string `json:"event"`
InstrumentID string `json:"instrument_id,omitempty"`
Quantity Quantity `json:"quantity,omitempty"`
Price Quantity `json:"price,omitempty"`
Gross Money `json:"gross,omitempty"`
Fee Money `json:"fee,omitempty"`
Tax Money `json:"tax,omitempty"`
}
// CashOnly reports an event that moves money without moving a position.
func (i Investment) CashOnly() bool {
switch i.Event {
case EventDeposit, EventWithdrawal, EventFee, EventInterest, EventTaxSettlement, EventDistribution:
return true
}
return false
}
// PositionOnly reports an event that moves a position without moving money.
func (i Investment) PositionOnly() bool {
return i.Event == EventCorporateAction || i.Event == EventPositionTransfer
}
// Settling reports an event that moves money and position together.
func (i Investment) Settling() bool {
switch i.Event {
case EventBuy, EventSell, EventReinvest:
return true
}
return false
}
// Instrument is a security held in an investment account, identified by ISIN.
// The broker's description for one ISIN changes over time, so Name is editable
// display text and never an identity.
type Instrument struct {
ID string `json:"id"`
ISIN string `json:"isin"`
Name string `json:"name"`
Currency string `json:"currency"`
}
type Facts struct {
ID string `json:"id"`
Source string `json:"source"`
AccountID string `json:"account_id"`
BookingDate string `json:"booking_date"`
ValueDate string `json:"value_date,omitempty"`
Amount Money `json:"amount"`
Currency string `json:"currency"`
RawDescription string `json:"raw_description"`
ExternalID string `json:"external_id,omitempty"`
Fingerprint string `json:"fingerprint"`
Counterparty string `json:"counterparty,omitempty"`
CounterpartyIBAN string `json:"counterparty_iban,omitempty"`
// Investment is present exactly on facts imported from an investment
// account. It is bank fact data and therefore immutable.
Investment *Investment `json:"investment,omitempty"`
}
type Provenance struct {
Source string `json:"source"`
Model string `json:"model,omitempty"`
Confidence string `json:"confidence,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Error string `json:"error,omitempty"`
}
type Enrichment struct {
Kind string `json:"kind"`
MerchantID string `json:"merchant_id,omitempty"`
CategoryID string `json:"category_id,omitempty"`
TagIDs []string `json:"tag_ids"`
TransferPeerID string `json:"transfer_peer_id,omitempty"`
Classification Provenance `json:"classification"`
}
type Transaction struct {
Facts Facts `json:"facts"`
Enrichment Enrichment `json:"enrichment"`
}
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
Kind string `json:"kind"`
Hint string `json:"hint,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Hint string `json:"hint,omitempty"`
}
type Merchant struct {
ID string `json:"id"`
Name string `json:"name"`
Aliases []string `json:"aliases"`
DefaultCategoryID string `json:"default_category_id,omitempty"`
DefaultTagIDs []string `json:"default_tag_ids"`
UseDefaults bool `json:"use_defaults"`
}
type Dataset struct {
Accounts []Account `json:"accounts"`
Categories []Category `json:"categories"`
Tags []Tag `json:"tags"`
Merchants []Merchant `json:"merchants"`
Instruments []Instrument `json:"instruments"`
Transactions []Transaction `json:"transactions"`
}
const ExpenseFallback = "cat_expenses_unclassified"
const IncomeFallback = "cat_income_unclassified"
// KindInvestment is the enrichment kind for broker facts. Like a transfer it
// carries no category or merchant and never reaches spending analytics: money
// moving between your own cash and your own positions is not income or
// spending, and the AI must never see it.
const KindInvestment = "investment"