package banking import ( "errors" "fmt" "math/big" "strings" "finance-duck/internal/domain" ) // SourceScalable identifies facts imported from a Scalable Capital broker // export. const SourceScalable = "scalable_csv" // scalableColumns are the exact normalized headers of a Scalable Capital // transaction export. The layout is matched in full rather than column by // column: a row's meaning depends on the combination of status, assetType and // type, so a partial match would be a different file wearing the same names. var scalableColumns = []string{ "date", "time", "status", "reference", "description", "assettype", "type", "isin", "shares", "price", "amount", "fee", "tax", "currency", } // scalableEvents maps the export's complete type vocabulary to journal events. // The set is closed on purpose: two of the ten types move a position without // moving money, so an unrecognized type cannot be defaulted either way without // risking a silent balance error. Keys are lowercased with collapsed spaces. var scalableEvents = map[string]string{ "deposit": domain.EventDeposit, "withdrawal": domain.EventWithdrawal, "fee": domain.EventFee, "interest": domain.EventInterest, "distribution": domain.EventDistribution, "buy": domain.EventBuy, "sell": domain.EventSell, "reinvestment_distribution": domain.EventReinvest, "corporate action": domain.EventCorporateAction, "security transfer": domain.EventPositionTransfer, } // 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) { return matchColumns(f, scalableColumns) } // ParseScalableCSV converts a broker export into bank facts carrying position // legs. // // The amount column means a different thing per row class, and reading it // wrongly moves money that never moved: // // - a cash row's amount is the money that actually settled, already net of // the tax the broker withheld or refunded, so its tax is recorded and not // applied; // - a buy, sell or reinvestment quotes gross shares times price and settles // gross minus fee minus tax; // - a corporate action or depot transfer quotes a position valuation and // settles no cash at all. // // The share column is signed only for those last two types; buys and sells are // unsigned and take their direction from the type. Both conventions are // resolved here, once. // // The booking date is the date column exactly as printed. Batch rows are // stamped midnight UTC rendered in local time, so the time column crosses // midnight for part of the year and reading date and time together would move // those rows to the previous day. // // A single unrecognized status, type or assetType, or one failed arithmetic // 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 // afterwards. func ParseScalableCSV(f CSVFile, account domain.Account, registry []domain.Instrument) (BrokerImport, error) { result := newBrokerImport() if err := investmentTarget(account); err != nil { return result, err } header, ok := DetectScalableCSV(f) if !ok { return result, errors.New("not a Scalable Capital 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)) } switch status := cell(row, "status"); { case strings.EqualFold(status, "executed"): case strings.EqualFold(status, "cancelled"), strings.EqualFold(status, "canceled"): result.Cancelled++ continue default: return result, fmt.Errorf("broker record %d has unknown status %q: only executed and cancelled rows are understood", record, status) } rawType := cell(row, "type") event, known := scalableEvents[strings.ToLower(strings.Join(strings.Fields(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} asset, wanted := cell(row, "assettype"), "Security" if investment.CashOnly() { wanted = "Cash" } if !strings.EqualFold(asset, wanted) { return result, fmt.Errorf("broker record %d pairs type %q with assetType %q, expected %q", record, rawType, asset, 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 := strings.ToUpper(strings.Join(strings.Fields(cell(row, "isin")), "")) if isin != "" && !domain.ValidISIN(isin) { return result, fmt.Errorf("broker record %d has an invalid ISIN %q", record, isin) } if isin != "" { held, exists := byISIN[isin] if !exists { held = domain.Instrument{ID: domain.InstrumentID(isin), ISIN: isin, Name: isin, Currency: currency} byISIN[isin] = held instruments[held.ID] = held created[isin] = len(result.Instruments) result.Instruments = append(result.Instruments, held) } investment.InstrumentID = held.ID // One ISIN appears under several descriptions over the years, and // once under the ISIN itself. The most recent real description // names it, and only when this import is the one creating it. slot, mine := created[isin] if mine && description != "" && description != isin && booking >= named[isin] { named[isin] = booking result.Instruments[slot].Name = description } } amount, amountDrift, err := brokerMoney(cell(row, "amount"), decimalGerman) 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"), decimalGerman) 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"), decimalGerman) if err != nil { return result, fmt.Errorf("broker record %d has an invalid tax %q: %w", record, cell(row, "tax"), err) } if amountDrift.Sign() != 0 || feeDrift.Sign() != 0 || taxDrift.Sign() != 0 { result.Rounded++ drift.Add(drift, amountDrift).Add(drift, feeDrift).Add(drift, taxDrift) } cash := amount if investment.CashOnly() { if nonzeroMoney(fee) || nonzeroMoney(tax) { result.Unapplied = append(result.Unapplied, BrokerNote{Record: record, Date: booking, Description: description, Fee: fee, Tax: tax}) } investment.Fee, investment.Tax = fee, tax } else { if isin == "" { return result, fmt.Errorf("broker record %d moves a position without an ISIN", record) } shares, err := brokerQuantity(cell(row, "shares"), decimalGerman) 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"), decimalGerman) if err != nil { return result, fmt.Errorf("broker record %d has an invalid price %q: %w", record, cell(row, "price"), err) } signed, err := scalableSignedShares(event, shares) if err != nil { return result, fmt.Errorf("broker record %d: %w", record, err) } investment.Quantity, investment.Price, investment.Gross = signed, price, amount if investment.PositionOnly() { if fee != "" || tax != "" { return result, fmt.Errorf("broker record %d is a %s carrying fee %q and tax %q, which have no settled cash to apply to", record, rawType, fee, tax) } cash = "0.00" } else { investment.Fee, investment.Tax = fee, tax if cash, err = brokerSettlement(amount, fee, tax); err != nil { return result, fmt.Errorf("broker record %d: %w", record, err) } } } facts := domain.Facts{ Source: SourceScalable, AccountID: account.ID, BookingDate: booking, Amount: cash, Currency: currency, RawDescription: description, ExternalID: cell(row, "reference"), Investment: &investment, } // A broker export has no counterparty column, so a deposit or // withdrawal takes the account's configured settlement IBAN. That is // what lets the ordinary transfer matcher pair it with the funding // account instead of leaving it to look like income. if investment.Event == domain.EventDeposit || investment.Event == domain.EventWithdrawal { facts.CounterpartyIBAN = normalizeIBAN(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 executed records") } result.Rounding = decimalString(drift, residueScale) return result, nil } // scalableSignedShares resolves the export's two sign conventions. A buy, sell // or reinvestment carries an unsigned count and takes its direction from the // type; a corporate action or depot transfer is already signed. func scalableSignedShares(event string, shares domain.Quantity) (domain.Quantity, error) { units, err := shares.Units() if err != nil { return "", err } if units == 0 { return "", fmt.Errorf("%s requires a nonzero share count", event) } switch event { case domain.EventBuy, domain.EventReinvest, domain.EventSell: if units < 0 { return "", fmt.Errorf("%s carries a signed share count %s; only corporate actions and depot transfers are signed", event, shares) } if event == domain.EventSell { units = -units } } return domain.FormatQuantity(units), nil }