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 }