package banking import ( "bufio" "encoding/csv" "fmt" "io" "strings" "time" "unicode" "finance-duck/internal/domain" ) // ParseCSV accepts N26 English and German account-activity exports, including // their older Date/Datum and newer Booking Date/Buchungsdatum schemas. Supported // columns: Date/Datum/Booking Date/Buchungsdatum, Value Date/Wertstellung/ // Wertstellungsdatum, Payee/Partner Name/Zahlungsempfänger/Empfänger/Auftraggeber, // Account number/Kontonummer/IBAN, Payment reference/Verwendungszweck, // Payment type/Transaktionstyp, Amount (EUR)/Betrag (EUR), and optional // Currency/Währung and Transaction ID/Transaktions-ID. Foreign-original-amount, // exchange-rate and category columns are deliberately not used for account money. // Comma and semicolon delimiters, UTF-8 BOM, CRLF, RFC4180 quoted multiline // descriptions, ISO and German dates, decimal comma and decimal point are accepted. // Missing required booking-date or account-amount columns fail the entire import. func ParseCSV(input io.Reader, account domain.Account) ([]domain.Facts, error) { if account.ID == "" { return nil, fmt.Errorf("CSV requires a selected account") } reader := bufio.NewReader(input) first, err := reader.ReadString('\n') if err != nil && err != io.EOF { return nil, fmt.Errorf("read CSV header: %w", err) } first = strings.TrimPrefix(first, "\ufeff") delimiter := ',' // Count separators outside quotes; descriptions may contain either delimiter. quoted := false commas, semicolons := 0, 0 for _, r := range first { if r == '"' { quoted = !quoted } if !quoted { if r == ',' { commas++ } if r == ';' { semicolons++ } } } if semicolons > commas { delimiter = ';' } parser := csv.NewReader(io.MultiReader(strings.NewReader(first), reader)) parser.Comma = delimiter headers, err := parser.Read() if err != nil { return nil, fmt.Errorf("invalid N26 CSV header") } columns := make(map[string]int) amountCurrency := "" for i, h := range headers { name := headerName(h) key := "" switch name { case "date", "datum", "booking date", "buchungsdatum": key = "date" case "value date", "wertstellung", "wertstellungsdatum", "valutadatum": key = "value" case "payee", "partner name", "zahlungsempfänger", "zahlungsempfänger name", "empfänger", "empfänger/auftraggeber", "partnername", "name zahlungspartner": key = "party" case "account number", "partner iban", "kontonummer", "iban", "konto": key = "iban" case "payment reference", "verwendungszweck", "reference", "beschreibung": key = "description" case "payment type", "transaktionstyp", "zahlungstyp", "type", "typ": key = "type" case "currency", "währung": key = "currency" case "transaction id", "transaktions-id", "transaktions id": key = "external" case "amount", "betrag": key = "amount" default: for _, prefix := range []string{"amount (", "betrag ("} { if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ")") { candidate := strings.ToUpper(strings.TrimSuffix(strings.TrimPrefix(name, prefix), ")")) if validCurrency(candidate) { key = "amount" amountCurrency = candidate } } } } if key != "" { if _, exists := columns[key]; exists { return nil, fmt.Errorf("duplicate N26 CSV column %s", key) } columns[key] = i } } if _, ok := columns["date"]; !ok { return nil, fmt.Errorf("N26 CSV requires Date/Datum or Booking Date/Buchungsdatum") } if _, ok := columns["amount"]; !ok { return nil, fmt.Errorf("N26 CSV requires Amount (currency)/Betrag (currency)") } get := func(row []string, key string) string { if i, ok := columns[key]; ok { return strings.TrimSpace(row[i]) } return "" } result := make([]domain.Facts, 0) for rowNumber := 2; ; rowNumber++ { row, err := parser.Read() if err == io.EOF { break } if err != nil { return nil, fmt.Errorf("invalid N26 CSV record %d", rowNumber) } date, err := parseDate(get(row, "date")) if err != nil { return nil, fmt.Errorf("invalid booking date in CSV record %d", rowNumber) } value := get(row, "value") if value != "" { value, err = parseDate(value) if err != nil { return nil, fmt.Errorf("invalid value date in CSV record %d", rowNumber) } } amount, err := parseCSVAmount(get(row, "amount")) if err != nil { return nil, fmt.Errorf("invalid account amount in CSV record %d", rowNumber) } currency := strings.ToUpper(get(row, "currency")) if currency == "" { currency = amountCurrency } if currency == "" { currency = strings.ToUpper(account.Currency) } if !validCurrency(currency) || (amountCurrency != "" && currency != amountCurrency) || (account.Currency != "" && currency != strings.ToUpper(account.Currency)) { return nil, fmt.Errorf("invalid or conflicting account currency in CSV record %d", rowNumber) } description := get(row, "description") if description == "" { description = get(row, "type") } result = append(result, domain.Facts{Source: "n26_csv", AccountID: account.ID, BookingDate: date, ValueDate: value, Amount: amount, Currency: currency, RawDescription: description, ExternalID: get(row, "external"), Counterparty: get(row, "party"), CounterpartyIBAN: normalizeIBAN(get(row, "iban"))}) } return result, nil } 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], ".") { groups := strings.Split(strings.TrimLeft(pair[0], "+-"), ".") if len(groups[0]) < 1 || len(groups[0]) > 3 { return "", fmt.Errorf("invalid grouping") } for _, g := range groups[1:] { if len(g) != 3 { return "", fmt.Errorf("invalid grouping") } } pair[0] = strings.ReplaceAll(pair[0], ".", "") } s = pair[0] + "." + pair[1] } return domain.ParseMoney(s) }