213 lines
6.7 KiB
Go
213 lines
6.7 KiB
Go
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)
|
|
}
|