package banking import ( "encoding/csv" "errors" "fmt" "io" "strconv" "strings" "time" "unicode" "unicode/utf8" "finance-duck/internal/domain" ) // maxCSVBytes bounds an uploaded statement. Statements are held in memory so a // mapping can be proposed, previewed and confirmed without re-uploading. const maxCSVBytes = 2 << 20 const maxCSVColumns = 128 const maxCSVPreambleRows = 50 const maxCSVSampleRows = 4 // CSVFile is a parsed CSV document: its detected delimiter and every record, // including the bank preamble records that precede the column header. Blank // lines are not records, so indexes count parsed records, not file lines. type CSVFile struct { delimiter rune rows [][]string } // CSVMapping assigns source columns, by their exact header text, to bank facts. // Money comes either from one signed AmountColumn or from separate // DebitColumn/CreditColumn pairs, never from both. FixedCurrency records a // currency carried by a header such as "Amount (EUR)" rather than a column. type CSVMapping struct { // HeaderRow is the 1-based parsed record holding the column names. HeaderRow int `json:"header_row"` BookingDateColumn string `json:"booking_date_column"` ValueDateColumn string `json:"value_date_column,omitempty"` AmountColumn string `json:"amount_column,omitempty"` DebitColumn string `json:"debit_column,omitempty"` CreditColumn string `json:"credit_column,omitempty"` CurrencyColumn string `json:"currency_column,omitempty"` DescriptionColumn string `json:"description_column"` FallbackDescriptionColumn string `json:"fallback_description_column,omitempty"` CounterpartyColumn string `json:"counterparty_column,omitempty"` CounterpartyIBANColumn string `json:"counterparty_iban_column,omitempty"` ExternalIDColumn string `json:"external_id_column,omitempty"` DateFormat string `json:"date_format"` DecimalFormat string `json:"decimal_format"` FixedCurrency string `json:"fixed_currency,omitempty"` } // CSVSample describes a statement's shape for column mapping. Cell values are // replaced by their character shape: no account text, name, reference or amount // digit is retained. type CSVSample struct { Delimiter string `json:"delimiter"` HeaderRow int `json:"header_row"` Headers []string `json:"headers"` ShapedRows [][]string `json:"shaped_rows"` RecordCount int `json:"record_count"` } // ReadCSV decodes an uploaded statement. UTF-8 and Windows-1252 (still emitted // by some ING exports) are accepted, along with a BOM, CRLF, comma/semicolon/tab // delimiters and RFC4180 quoted multiline fields. func ReadCSV(input io.Reader) (CSVFile, error) { raw, err := io.ReadAll(io.LimitReader(input, maxCSVBytes+1)) if err != nil { return CSVFile{}, fmt.Errorf("read CSV: %w", err) } if len(raw) > maxCSVBytes { return CSVFile{}, fmt.Errorf("CSV statement exceeds %d MiB", maxCSVBytes>>20) } text, err := decodeCSVText(raw) if err != nil { return CSVFile{}, err } if strings.TrimSpace(text) == "" { return CSVFile{}, errors.New("CSV statement is empty") } // Choose the delimiter that yields the widest consistently parsed records: // descriptions routinely contain the delimiters used by other banks. delimiter, best := ' ', -1 for _, candidate := range []rune{',', ';', '\t'} { if _, score := parseCSVRecords(text, candidate); score > best { delimiter, best = candidate, score } } if best <= 0 { return CSVFile{}, errors.New("unreadable CSV statement: no delimiter produced multi-column records") } rows, _ := parseCSVRecords(text, delimiter) for _, row := range rows { if len(row) > maxCSVColumns { return CSVFile{}, fmt.Errorf("CSV statement has more than %d columns", maxCSVColumns) } } return CSVFile{delimiter: delimiter, rows: rows}, nil } func decodeCSVText(raw []byte) (string, error) { text := strings.TrimPrefix(string(raw), "\ufeff") if strings.IndexByte(text, 0) >= 0 { return "", errors.New("CSV statement contains a NUL byte") } if utf8.ValidString(text) { return text, nil } // Windows-1252 is decoded locally: undecodable account text must never be // forwarded to a model or stored as invalid UTF-8 bank facts. windows1252 := [...]rune{'€', 0, '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', 0, 'Ž', 0, 0, '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', 0, 'ž', 'Ÿ'} var out strings.Builder out.Grow(len(raw)) for _, b := range []byte(text) { switch { case b < 0x80 || b >= 0xa0: out.WriteRune(rune(b)) default: r := windows1252[int(b)-0x80] if r == 0 { return "", errors.New("CSV statement is neither valid UTF-8 nor Windows-1252") } out.WriteRune(r) } } return out.String(), nil } // parseCSVRecords returns every record plus a preference score. Malformed // quoting scores below zero so a delimiter is never silently accepted for a // document it cannot represent. func parseCSVRecords(text string, delimiter rune) ([][]string, int) { parser := csv.NewReader(strings.NewReader(text)) parser.Comma = delimiter parser.FieldsPerRecord = -1 parser.ReuseRecord = false rows := make([][]string, 0, 64) widest, populated := 0, 0 for { row, err := parser.Read() if err == io.EOF { break } if err != nil { return nil, -1 } rows = append(rows, row) if len(row) > 1 && !blankCSVRow(row) { populated++ if len(row) > widest { widest = len(row) } } } if widest == 0 { return rows, 0 } return rows, widest*1_000_000 + populated } func blankCSVRow(row []string) bool { for _, value := range row { if strings.TrimSpace(value) != "" { return false } } return true } // Sample locates the column header and describes the statement's shape. The // header is the first widest record, which skips the metadata preamble that // banks such as ING place above their columns. func (f CSVFile) Sample() (CSVSample, error) { header, width := 0, 0 for i, row := range f.rows { if i >= maxCSVPreambleRows { break } if len(row) > width && !blankCSVRow(row) { header, width = i+1, len(row) } } if width < 2 { return CSVSample{}, errors.New("CSV statement has no column header row") } headers, err := csvHeaders(f, header) if err != nil { return CSVSample{}, err } sample := CSVSample{Delimiter: string(f.delimiter), HeaderRow: header, Headers: headers, ShapedRows: [][]string{}} for _, row := range f.rows[header:] { if blankCSVRow(row) { continue } if len(row) != width { return CSVSample{}, fmt.Errorf("CSV statement has ragged records: expected %d columns", width) } sample.RecordCount++ if len(sample.ShapedRows) < maxCSVSampleRows { shaped := make([]string, len(row)) for i, value := range row { shaped[i] = shapeCSVValue(value) } sample.ShapedRows = append(sample.ShapedRows, shaped) } } if sample.RecordCount == 0 { return CSVSample{}, errors.New("CSV statement contains no transaction records") } return sample, nil } func csvHeaders(f CSVFile, header int) ([]string, error) { if header < 1 || header > len(f.rows) { return nil, errors.New("CSV mapping has an invalid header row") } headers := make([]string, 0, len(f.rows[header-1])) seen := map[string]bool{} for _, raw := range f.rows[header-1] { name := strings.TrimSpace(raw) key := headerName(name) if key == "" { return nil, errors.New("CSV column headers must all be named") } if seen[key] { return nil, fmt.Errorf("duplicate CSV column %q", name) } seen[key] = true headers = append(headers, name) } return headers, nil } // shapeCSVValue keeps only structure: letters become x, digits 0. This is what a // column mapping needs, and it keeps descriptions, names, references, IBANs and // amounts out of any request that leaves this machine. func shapeCSVValue(value string) string { var out strings.Builder for i, r := range strings.TrimSpace(value) { if i >= 64 { out.WriteRune('…') break } switch { case unicode.IsLetter(r): out.WriteRune('x') case unicode.IsDigit(r): out.WriteRune('0') case unicode.IsSpace(r): out.WriteRune(' ') default: out.WriteRune(r) } } return out.String() } // DetectCSVMapping recognizes known bank exports without any model. The returned // source identifies imported facts; label names the export for the operator. func DetectCSVMapping(f CSVFile) (mapping CSVMapping, source, label string, ok bool) { for i, row := range f.rows { if i >= maxCSVPreambleRows { break } columns, usable := csvColumnIndex(row) if !usable { continue } if mapping, ok := n26Mapping(i+1, columns); ok { return mapping, "n26_csv", "N26", true } if mapping, ok := ingMapping(i+1, columns); ok { return mapping, "ing_csv", "ING", true } if mapping, ok := kontistMapping(f, i+1, columns); ok { return mapping, "kontist_csv", "Kontist", true } } return CSVMapping{}, "", "", false } // csvColumnIndex maps normalized column names to their exact header text. func csvColumnIndex(row []string) (map[string]string, bool) { columns := make(map[string]string, len(row)) for _, raw := range row { actual := strings.TrimSpace(raw) name := headerName(actual) if name == "" { return nil, false } if _, exists := columns[name]; exists { return nil, false } columns[name] = actual } return columns, len(columns) > 1 } func csvColumn(columns map[string]string, names ...string) string { for _, name := range names { if actual, ok := columns[name]; ok { return actual } } return "" } // n26Mapping accepts N26's English and German account-activity exports, // including their older Date/Datum and newer Booking Date/Buchungsdatum // schemas. Foreign original amounts, exchange rates and categories are // deliberately never used as account money. func n26Mapping(header int, columns map[string]string) (CSVMapping, bool) { amount, currency := "", "" for name, actual := range columns { if name == "amount" || name == "betrag" { amount = actual continue } for _, prefix := range []string{"amount (", "betrag ("} { if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ")") { code := strings.ToUpper(strings.TrimSuffix(strings.TrimPrefix(name, prefix), ")")) if validCurrency(code) { amount, currency = actual, code } } } } mapping := CSVMapping{ HeaderRow: header, BookingDateColumn: csvColumn(columns, "date", "datum", "booking date", "buchungsdatum"), ValueDateColumn: csvColumn(columns, "value date", "wertstellung", "wertstellungsdatum", "valutadatum"), AmountColumn: amount, CurrencyColumn: csvColumn(columns, "currency", "währung"), DescriptionColumn: csvColumn(columns, "payment reference", "verwendungszweck", "reference", "beschreibung"), FallbackDescriptionColumn: csvColumn(columns, "payment type", "transaktionstyp", "zahlungstyp", "type", "typ"), CounterpartyColumn: csvColumn(columns, "payee", "partner name", "zahlungsempfänger", "zahlungsempfänger name", "empfänger", "empfänger/auftraggeber", "partnername", "name zahlungspartner"), CounterpartyIBANColumn: csvColumn(columns, "account number", "partner iban", "kontonummer", "iban", "konto"), ExternalIDColumn: csvColumn(columns, "transaction id", "transaktions-id", "transaktions id"), DateFormat: "iso-or-german", DecimalFormat: "dot-or-comma", FixedCurrency: currency, } // An N26 export always carries a payment reference and a typed transaction. marker := mapping.FallbackDescriptionColumn != "" || csvColumn(columns, "original amount", "betrag (fremdwährung)", "account name", "partner name", "payee") != "" if mapping.BookingDateColumn == "" || mapping.AmountColumn == "" || mapping.DescriptionColumn == "" || !marker { return CSVMapping{}, false } return mapping, true } // ingMapping accepts ING's Umsatzanzeige export, whose columns sit below a // metadata preamble. Gläubiger-ID, Mandatsreferenz and Kundenreferenz are SEPA // mandate references that repeat across bookings, so they are never used as a // transaction identity. func ingMapping(header int, columns map[string]string) (CSVMapping, bool) { mapping := CSVMapping{ HeaderRow: header, BookingDateColumn: csvColumn(columns, "buchung"), ValueDateColumn: csvColumn(columns, "wertstellungsdatum", "valuta"), AmountColumn: csvColumn(columns, "betrag"), CurrencyColumn: csvColumn(columns, "währung", "waehrung"), DescriptionColumn: csvColumn(columns, "verwendungszweck"), FallbackDescriptionColumn: csvColumn(columns, "buchungstext"), CounterpartyColumn: csvColumn(columns, "auftraggeber/empfänger", "auftraggeber/empfaenger"), DateFormat: "dd.mm.yyyy", DecimalFormat: "comma", } if mapping.BookingDateColumn == "" || mapping.AmountColumn == "" || mapping.DescriptionColumn == "" || mapping.CounterpartyColumn == "" { return CSVMapping{}, false } return mapping, true } // kontistMapping accepts Kontist's documented transaction vocabulary: a payment // date, an amount, a purpose and a counterparty name. Date and decimal // conventions are inferred from the file's own first populated values, and the // mapping is always reviewed before anything is imported. func kontistMapping(f CSVFile, header int, columns map[string]string) (CSVMapping, bool) { mapping := CSVMapping{ HeaderRow: header, BookingDateColumn: csvColumn(columns, "payment date", "booking date", "buchungsdatum", "zahlungsdatum"), ValueDateColumn: csvColumn(columns, "value date", "wertstellungsdatum", "valuta"), AmountColumn: csvColumn(columns, "amount", "betrag"), CurrencyColumn: csvColumn(columns, "currency", "währung", "waehrung"), DescriptionColumn: csvColumn(columns, "purpose", "verwendungszweck", "payment reference"), CounterpartyColumn: csvColumn(columns, "name", "counterparty", "zahlungspartner"), CounterpartyIBANColumn: csvColumn(columns, "iban", "counterparty iban"), ExternalIDColumn: csvColumn(columns, "transaction id", "transaction_id", "transaktions-id"), } if mapping.BookingDateColumn == "" || mapping.AmountColumn == "" || mapping.DescriptionColumn == "" || mapping.CounterpartyColumn == "" { return CSVMapping{}, false } mapping.DateFormat = inferCSVDateFormat(csvFirstValue(f, header, mapping.BookingDateColumn)) mapping.DecimalFormat = inferCSVDecimalFormat(csvFirstValue(f, header, mapping.AmountColumn)) return mapping, true } func csvFirstValue(f CSVFile, header int, column string) string { headers := f.rows[header-1] index := -1 for i, name := range headers { if strings.TrimSpace(name) == column { index = i break } } if index < 0 { return "" } for _, row := range f.rows[header:] { if len(row) == len(headers) && strings.TrimSpace(row[index]) != "" { return strings.TrimSpace(row[index]) } } return "" } func inferCSVDateFormat(value string) string { switch { case len(value) >= 11 && value[4] == '-' && (value[10] == 'T' || value[10] == ' '): return "iso-date-time" case len(value) == 10 && value[4] == '-' && value[7] == '-': return "yyyy-mm-dd" case strings.Count(value, ".") == 2: return "dd.mm.yyyy" case strings.Count(value, "/") == 2: // Kontist documents month/day/year for interchange. A first component // above twelve can only be a day; the preview shows the parsed result. if first, err := strconv.Atoi(strings.SplitN(value, "/", 2)[0]); err == nil && first > 12 { return "dd/mm/yyyy" } return "mm/dd/yyyy" default: return "yyyy-mm-dd" } } func inferCSVDecimalFormat(value string) string { if strings.Contains(value, ",") { return "comma" } return "dot" } // ParseMappedCSV converts every record into bank facts. A single malformed // record fails the whole statement: a partially imported statement cannot be // distinguished from a truncated export later. func ParseMappedCSV(f CSVFile, account domain.Account, mapping CSVMapping, source string) ([]domain.Facts, error) { if account.ID == "" { return nil, errors.New("CSV requires a selected account") } if source == "" { return nil, errors.New("CSV import source is required") } columns, err := validateCSVMapping(f, mapping) if err != nil { return nil, err } headers := f.rows[mapping.HeaderRow-1] get := func(row []string, column string) string { if column == "" { return "" } return strings.TrimSpace(row[columns[column]]) } facts := make([]domain.Facts, 0, len(f.rows)-mapping.HeaderRow) for offset, row := range f.rows[mapping.HeaderRow:] { record := mapping.HeaderRow + offset + 1 if blankCSVRow(row) { continue } if len(row) != len(headers) { return nil, fmt.Errorf("CSV record %d has %d columns, expected %d", record, len(row), len(headers)) } booking, err := parseMappedCSVDate(get(row, mapping.BookingDateColumn), mapping.DateFormat) if err != nil { return nil, fmt.Errorf("invalid booking date in CSV record %d", record) } value := get(row, mapping.ValueDateColumn) if value != "" { if value, err = parseMappedCSVDate(value, mapping.DateFormat); err != nil { return nil, fmt.Errorf("invalid value date in CSV record %d", record) } } amount, err := mappedCSVAmount(row, get, mapping) if err != nil { return nil, fmt.Errorf("invalid account amount in CSV record %d", record) } currency := strings.ToUpper(get(row, mapping.CurrencyColumn)) if currency == "€" { currency = "EUR" } if currency == "" { currency = strings.ToUpper(mapping.FixedCurrency) } if currency == "" { currency = strings.ToUpper(account.Currency) } fixed := strings.ToUpper(mapping.FixedCurrency) if !validCurrency(currency) || (fixed != "" && currency != fixed) || (account.Currency != "" && currency != strings.ToUpper(account.Currency)) { return nil, fmt.Errorf("invalid or conflicting account currency in CSV record %d", record) } description := get(row, mapping.DescriptionColumn) if description == "" { description = get(row, mapping.FallbackDescriptionColumn) } facts = append(facts, domain.Facts{ Source: source, AccountID: account.ID, BookingDate: booking, ValueDate: value, Amount: amount, Currency: currency, RawDescription: description, ExternalID: get(row, mapping.ExternalIDColumn), Counterparty: get(row, mapping.CounterpartyColumn), CounterpartyIBAN: normalizeIBAN(get(row, mapping.CounterpartyIBANColumn)), }) } if len(facts) == 0 { return nil, errors.New("CSV statement contains no transaction records") } return facts, nil } // validateCSVMapping resolves a mapping against the document and returns each // mapped column's index. Every referenced column must exist exactly, no column // may serve two fields, and money must come from exactly one strategy. func validateCSVMapping(f CSVFile, mapping CSVMapping) (map[string]int, error) { if mapping.HeaderRow < 1 || mapping.HeaderRow > len(f.rows) || mapping.HeaderRow > maxCSVPreambleRows { return nil, errors.New("CSV mapping has an invalid header row") } if _, err := csvHeaders(f, mapping.HeaderRow); err != nil { return nil, err } indexes := make(map[string]int, len(f.rows[mapping.HeaderRow-1])) for i, raw := range f.rows[mapping.HeaderRow-1] { indexes[strings.TrimSpace(raw)] = i } if mapping.BookingDateColumn == "" { return nil, errors.New("CSV mapping requires a booking date column") } if mapping.DescriptionColumn == "" { return nil, errors.New("CSV mapping requires a description column") } signed, split := mapping.AmountColumn != "", mapping.DebitColumn != "" || mapping.CreditColumn != "" if signed == split { return nil, errors.New("CSV mapping requires either one signed amount column or separate debit and credit columns") } if split && (mapping.DebitColumn == "" || mapping.CreditColumn == "") { return nil, errors.New("CSV mapping requires both a debit and a credit column") } if !validCSVDateFormat(mapping.DateFormat) { return nil, fmt.Errorf("unsupported CSV date format %q", mapping.DateFormat) } if !validCSVDecimalFormat(mapping.DecimalFormat) { return nil, fmt.Errorf("unsupported CSV decimal format %q", mapping.DecimalFormat) } if mapping.FixedCurrency != "" && !validCurrency(strings.ToUpper(mapping.FixedCurrency)) { return nil, errors.New("CSV mapping has an invalid fixed currency") } assigned := map[string]string{} for _, field := range []struct{ name, column string }{ {"booking date", mapping.BookingDateColumn}, {"value date", mapping.ValueDateColumn}, {"amount", mapping.AmountColumn}, {"debit", mapping.DebitColumn}, {"credit", mapping.CreditColumn}, {"currency", mapping.CurrencyColumn}, {"description", mapping.DescriptionColumn}, {"secondary description", mapping.FallbackDescriptionColumn}, {"counterparty", mapping.CounterpartyColumn}, {"counterparty IBAN", mapping.CounterpartyIBANColumn}, {"transaction reference", mapping.ExternalIDColumn}, } { if field.column == "" { continue } if _, ok := indexes[field.column]; !ok { return nil, fmt.Errorf("CSV mapping references unknown %s column %q", field.name, field.column) } if previous, ok := assigned[field.column]; ok { return nil, fmt.Errorf("CSV column %q is mapped to both %s and %s", field.column, previous, field.name) } assigned[field.column] = field.name } return indexes, nil } // CSVDateFormats and CSVDecimalFormats are the exact accepted conventions. A // proposed mapping outside them is rejected rather than guessed. func CSVDateFormats() []string { return []string{"yyyy-mm-dd", "dd.mm.yyyy", "mm/dd/yyyy", "dd/mm/yyyy", "iso-date-time", "iso-or-german"} } func CSVDecimalFormats() []string { return []string{"dot", "comma", "dot-or-comma"} } func validCSVDateFormat(format string) bool { for _, valid := range CSVDateFormats() { if format == valid { return true } } return false } func validCSVDecimalFormat(format string) bool { for _, valid := range CSVDecimalFormats() { if format == valid { return true } } return false } func parseMappedCSVDate(value, format string) (string, error) { layouts := map[string][]string{ "yyyy-mm-dd": {"2006-01-02"}, "dd.mm.yyyy": {"02.01.2006", "2.1.2006"}, "mm/dd/yyyy": {"01/02/2006", "1/2/2006"}, "dd/mm/yyyy": {"02/01/2006", "2/1/2006"}, "iso-date-time": {time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"}, "iso-or-german": {"2006-01-02", "02.01.2006", "2.1.2006"}, } for _, layout := range layouts[format] { if parsed, err := time.Parse(layout, value); err == nil { return parsed.Format("2006-01-02"), nil } } return "", fmt.Errorf("invalid date %q", value) } // mappedCSVAmount returns signed account money. With split columns a debit is // negative however the bank wrote its sign, a credit must not be negative, and // the unused column may be empty or an explicit zero but never carry money. func mappedCSVAmount(row []string, get func([]string, string) string, mapping CSVMapping) (domain.Money, error) { if mapping.AmountColumn != "" { return parseMappedCSVDecimal(get(row, mapping.AmountColumn), mapping.DecimalFormat) } debit, credit := get(row, mapping.DebitColumn), get(row, mapping.CreditColumn) if debit == "" && credit == "" { return "", errors.New("debit and credit are both empty") } parse := func(value string) (domain.Money, int64, error) { if value == "" { return "0.00", 0, nil } money, err := parseMappedCSVDecimal(value, mapping.DecimalFormat) if err != nil { return "", 0, err } minor, err := money.Minor() return money, minor, err } debitMoney, debited, err := parse(debit) if err != nil { return "", err } creditMoney, credited, err := parse(credit) if err != nil { return "", err } if debited != 0 && credited != 0 { return "", errors.New("debit and credit both carry money") } if credited < 0 { return "", errors.New("a credit column must not hold negative money") } if credited != 0 { return creditMoney, nil } if debited != 0 { return domain.ParseMoney("-" + strings.TrimPrefix(debitMoney.String(), "-")) } return domain.ParseMoney("0") } func parseMappedCSVDecimal(value, format string) (domain.Money, error) { value = strings.NewReplacer("\u00a0", "", "\u202f", "", "'", "").Replace(strings.TrimSpace(value)) switch format { case "dot-or-comma": return parseCSVAmount(value) case "comma": plain, err := germanDecimal(value) if err != nil { return "", err } return domain.ParseMoney(plain) case "dot": value = strings.TrimPrefix(value, "+") if strings.Contains(value, ",") { digits, ok := ungroup(value, ",") if !ok { return "", errors.New("invalid grouping") } value = digits } return domain.ParseMoney(value) default: return "", fmt.Errorf("unsupported CSV decimal format %q", format) } } // germanDecimal rewrites a German-formatted number as a plain decimal string // without parsing it, so a caller can choose its own precision. A dot is only // grouping when every group is exactly three digits: "1.014" is 1014 while // "1.14" stays 1.14. Broker exports carry both shapes in one share column. func germanDecimal(value string) (string, error) { value = strings.TrimPrefix(strings.TrimSpace(value), "+") if strings.Contains(value, ",") { if strings.Count(value, ",") != 1 { return "", errors.New("invalid decimal separator") } whole, fraction, _ := strings.Cut(value, ",") if strings.Contains(whole, ".") { digits, ok := ungroup(whole, ".") if !ok { return "", errors.New("invalid grouping") } whole = digits } return whole + "." + fraction, nil } if strings.Contains(value, ".") { if digits, ok := ungroup(value, "."); ok { return digits, nil } } return value, nil } // ungroup removes thousands separators, and only when every group is exactly // three digits: "1.234" is 1234, while "1.23" stays a decimal value. func ungroup(value, separator string) (string, bool) { sign := "" if rest, found := strings.CutPrefix(value, "-"); found { sign, value = "-", rest } groups := strings.Split(value, separator) if len(groups) < 2 || len(groups[0]) < 1 || len(groups[0]) > 3 { return "", false } for _, group := range groups[1:] { if len(group) != 3 { return "", false } } return sign + strings.Join(groups, ""), true } func headerName(s string) string { return strings.ToLower(strings.Join(strings.Fields(strings.TrimPrefix(s, "\ufeff")), " ")) } func normalizeIBAN(s string) string { return strings.ToUpper(strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return -1 } return r }, s)) } func validCurrency(s string) bool { if len(s) != 3 { return false } for _, c := range s { if c < 'A' || c > 'Z' { return false } } return true } func parseDate(s string) (string, error) { for _, layout := range []string{"2006-01-02", "02.01.2006", "2.1.2006"} { if d, e := time.Parse(layout, s); e == nil { return d.Format("2006-01-02"), nil } } return "", fmt.Errorf("invalid date") } func parseCSVAmount(s string) (domain.Money, error) { s = strings.TrimPrefix(strings.TrimSpace(s), "+") // German grouping is only accepted when every group is exactly three digits. if strings.Contains(s, ",") { if strings.Count(s, ",") != 1 { return "", fmt.Errorf("invalid decimal separator") } pair := strings.SplitN(s, ",", 2) if strings.Contains(pair[0], ".") { digits, ok := ungroup(pair[0], ".") if !ok { return "", fmt.Errorf("invalid grouping") } pair[0] = digits } s = pair[0] + "." + pair[1] } return domain.ParseMoney(s) }