This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
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)
}