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) }