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)
}
+486
View File
@@ -0,0 +1,486 @@
package banking
// DTOs and authentication follow https://enablebanking.com/docs/api/reference/.
// In particular entry_reference is stable across sessions, transaction_id is NOT;
// GET /sessions returns UID strings, unlike POST /sessions' account objects.
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"finance-duck/internal/domain"
)
// ErrReconnect identifies inactive bank consent, not application authentication
// failures or temporary transport/provider errors.
var ErrReconnect = errors.New("bank consent requires reconnection")
type Session struct {
ID string `json:"session_id"`
ValidUntil string `json:"valid_until"`
Accounts []domain.Account `json:"accounts"`
}
type Balance struct {
Amount domain.Money `json:"amount"`
Currency string `json:"currency"`
Type string `json:"type"`
ReferenceDate string `json:"reference_date,omitempty"`
}
type Provider interface {
Authorize(context.Context, string, string, string) (string, error)
Exchange(context.Context, string) (Session, error)
Status(context.Context, string) (Session, error)
Balances(context.Context, string) ([]Balance, error)
Transactions(context.Context, domain.Account, string, string) ([]domain.Facts, error)
}
type EnableBanking struct {
HTTPClient *http.Client
BaseURL string
appID string
key *rsa.PrivateKey
redirectURL string
}
var _ Provider = (*EnableBanking)(nil)
func NewEnableBanking(appID, keyFile, redirectURL string) (*EnableBanking, error) {
if strings.TrimSpace(appID) == "" {
return nil, fmt.Errorf("Enable Banking application ID is required")
}
redirect, err := url.Parse(redirectURL)
if err != nil || redirect.Host == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil {
return nil, fmt.Errorf("invalid Enable Banking redirect URL")
}
content, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("read Enable Banking RSA private key: %w", err)
}
block, _ := pem.Decode(content)
if block == nil {
return nil, fmt.Errorf("Enable Banking key must be PEM encoded")
}
var key *rsa.PrivateKey
switch block.Type {
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
case "PRIVATE KEY":
var parsed any
parsed, err = x509.ParsePKCS8PrivateKey(block.Bytes)
if err == nil {
var ok bool
key, ok = parsed.(*rsa.PrivateKey)
if !ok {
err = fmt.Errorf("not RSA")
}
}
default:
err = fmt.Errorf("unsupported key type")
}
if err != nil || key == nil {
return nil, fmt.Errorf("invalid Enable Banking RSA private key")
}
if key.N.BitLen() < 2048 {
return nil, fmt.Errorf("Enable Banking RSA key must be at least 2048 bits")
}
if err = key.Validate(); err != nil {
return nil, fmt.Errorf("invalid Enable Banking RSA private key")
}
return &EnableBanking{HTTPClient: &http.Client{Timeout: 30 * time.Second}, BaseURL: "https://api.enablebanking.com", appID: appID, key: key, redirectURL: redirectURL}, nil
}
func (p *EnableBanking) jwt() (string, error) {
if p.key == nil || p.appID == "" {
return "", fmt.Errorf("Enable Banking is not configured")
}
now := time.Now().Unix()
header, _ := json.Marshal(map[string]any{"typ": "JWT", "alg": "RS256", "kid": p.appID})
claims, _ := json.Marshal(map[string]any{"iss": "enablebanking.com", "aud": "api.enablebanking.com", "iat": now, "exp": now + 3600})
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(claims)
hash := sha256.Sum256([]byte(unsigned))
signature, err := rsa.SignPKCS1v15(rand.Reader, p.key, crypto.SHA256, hash[:])
if err != nil {
return "", fmt.Errorf("sign Enable Banking token")
}
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func (p *EnableBanking) request(ctx context.Context, method, path string, input, output any) error {
// Enforce a deadline even when a caller injects a client without Timeout.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
token, err := p.jwt()
if err != nil {
return err
}
var body io.Reader
if input != nil {
b, e := json.Marshal(input)
if e != nil {
return fmt.Errorf("encode Enable Banking request")
}
body = bytes.NewReader(b)
}
base, err := url.Parse(p.BaseURL)
if err != nil || base.Host == "" || base.User != nil || (base.Scheme != "http" && base.Scheme != "https") {
return fmt.Errorf("invalid Enable Banking base URL")
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(p.BaseURL, "/")+path, body)
if err != nil {
return fmt.Errorf("create Enable Banking request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
if input != nil {
req.Header.Set("Content-Type", "application/json")
}
client := http.Client{Timeout: 30 * time.Second}
if p.HTTPClient != nil {
client = *p.HTTPClient
}
// Never forward signed credentials or financial requests through redirects.
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
response, err := client.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return context.Canceled
}
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("Enable Banking request timed out")
}
return fmt.Errorf("Enable Banking connection failed")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Enable Banking returned HTTP %d", response.StatusCode)
}
const limit = 16 << 20
b, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil {
return fmt.Errorf("read Enable Banking response")
}
if len(b) > limit {
return fmt.Errorf("Enable Banking response exceeded size limit")
}
if err = json.Unmarshal(b, output); err != nil {
return fmt.Errorf("invalid Enable Banking response")
}
return nil
}
type accessDTO struct {
ValidUntil string `json:"valid_until"`
}
type institutionDTO struct {
Name string `json:"name"`
Country string `json:"country"`
}
type accountIdentificationDTO struct {
IBAN string `json:"iban"`
}
type accountDTO struct {
UID string `json:"uid"`
IdentificationHash string `json:"identification_hash"`
AccountID accountIdentificationDTO `json:"account_id"`
Name string `json:"name"`
Details string `json:"details"`
Currency string `json:"currency"`
}
func (a accountDTO) account(institution string) (domain.Account, error) {
if !validCurrency(a.Currency) {
return domain.Account{}, fmt.Errorf("Enable Banking account has invalid currency")
}
stable := a.IdentificationHash
if stable == "" {
stable = normalizeIBAN(a.AccountID.IBAN)
}
if stable == "" {
return domain.Account{}, fmt.Errorf("Enable Banking account lacks stable identification")
}
name := a.Details
if name == "" {
name = a.Name
}
if name == "" {
name = institution
}
return domain.Account{ID: "acct_" + digest("enablebanking", stable), DisplayName: name, Institution: institution, Currency: a.Currency, ExternalAccountID: a.UID, IBAN: normalizeIBAN(a.AccountID.IBAN), Active: a.UID != ""}, nil
}
func (p *EnableBanking) Authorize(ctx context.Context, institution, country, state string) (string, error) {
country = strings.ToUpper(strings.TrimSpace(country))
institution = strings.TrimSpace(institution)
if institution == "" || len(country) != 2 || state == "" {
return "", fmt.Errorf("institution, country and authorization state are required")
}
var list struct {
ASPSPs []struct {
institutionDTO
MaximumConsentValidity int64 `json:"maximum_consent_validity"`
} `json:"aspsps"`
}
query := url.Values{"country": {country}, "psu_type": {"personal"}, "service": {"AIS"}}
if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil {
return "", err
}
var validity int64
for _, a := range list.ASPSPs {
if a.Name == institution && a.Country == country {
validity = a.MaximumConsentValidity
break
}
}
if validity <= 0 {
return "", fmt.Errorf("institution is unavailable for personal account information or has no valid consent duration")
}
// Avoid overflow or unexpectedly long access while honoring each bank's limit.
if validity > 180*24*3600 {
validity = 180 * 24 * 3600
}
request := struct {
Access struct {
ValidUntil string `json:"valid_until"`
Balances bool `json:"balances"`
Transactions bool `json:"transactions"`
} `json:"access"`
ASPSP institutionDTO `json:"aspsp"`
State string `json:"state"`
RedirectURL string `json:"redirect_url"`
PSUType string `json:"psu_type"`
}{ASPSP: institutionDTO{institution, country}, State: state, RedirectURL: p.redirectURL, PSUType: "personal"}
request.Access.ValidUntil = time.Now().UTC().Add(time.Duration(validity) * time.Second).Format(time.RFC3339)
request.Access.Balances = true
request.Access.Transactions = true
var response struct {
URL string `json:"url"`
}
if err := p.request(ctx, http.MethodPost, "/auth", request, &response); err != nil {
return "", err
}
parsed, err := url.Parse(response.URL)
if err != nil || parsed.Host == "" || parsed.Scheme != "https" || parsed.User != nil {
return "", fmt.Errorf("Enable Banking returned invalid authorization URL")
}
return response.URL, nil
}
func (p *EnableBanking) Exchange(ctx context.Context, code string) (Session, error) {
if code == "" {
return Session{}, fmt.Errorf("authorization code is required")
}
var response struct {
ID string `json:"session_id"`
Accounts []accountDTO `json:"accounts"`
Access accessDTO `json:"access"`
ASPSP institutionDTO `json:"aspsp"`
}
if err := p.request(ctx, http.MethodPost, "/sessions", map[string]string{"code": code}, &response); err != nil {
return Session{}, err
}
if response.ID == "" {
return Session{}, fmt.Errorf("Enable Banking returned no session ID")
}
if _, err := time.Parse(time.RFC3339, response.Access.ValidUntil); err != nil {
return Session{}, fmt.Errorf("Enable Banking returned invalid session expiry")
}
result := Session{ID: response.ID, ValidUntil: response.Access.ValidUntil, Accounts: []domain.Account{}}
for _, a := range response.Accounts {
account, err := a.account(response.ASPSP.Name)
if err != nil {
return Session{}, err
}
result.Accounts = append(result.Accounts, account)
}
return result, nil
}
func (p *EnableBanking) Status(ctx context.Context, sessionID string) (Session, error) {
if sessionID == "" {
return Session{}, fmt.Errorf("session ID is required")
}
var response struct {
Status string `json:"status"`
Accounts []string `json:"accounts"`
AccountsData []accountDTO `json:"accounts_data"`
Access accessDTO `json:"access"`
ASPSP institutionDTO `json:"aspsp"`
}
if err := p.request(ctx, http.MethodGet, "/sessions/"+url.PathEscape(sessionID), nil, &response); err != nil {
return Session{}, err
}
if response.Status != "AUTHORIZED" {
return Session{}, fmt.Errorf("Enable Banking session is not authorized: %w", ErrReconnect)
}
expires, err := time.Parse(time.RFC3339, response.Access.ValidUntil)
if err != nil {
return Session{}, fmt.Errorf("Enable Banking returned invalid session expiry")
}
if !expires.After(time.Now()) {
return Session{}, fmt.Errorf("Enable Banking session expired: %w", ErrReconnect)
}
result := Session{ID: sessionID, ValidUntil: response.Access.ValidUntil, Accounts: []domain.Account{}}
hashes := map[string]string{}
for _, a := range response.AccountsData {
hashes[a.UID] = a.IdentificationHash
}
for _, id := range response.Accounts {
if id == "" {
return Session{}, fmt.Errorf("Enable Banking returned empty account identifier")
}
var details accountDTO
if err := p.request(ctx, http.MethodGet, "/accounts/"+url.PathEscape(id)+"/details", nil, &details); err != nil {
return Session{}, err
}
details.UID = id
if details.IdentificationHash == "" {
details.IdentificationHash = hashes[id]
}
a, err := details.account(response.ASPSP.Name)
if err != nil {
return Session{}, err
}
result.Accounts = append(result.Accounts, a)
}
return result, nil
}
type amountDTO struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
func (p *EnableBanking) Balances(ctx context.Context, externalAccountID string) ([]Balance, error) {
if externalAccountID == "" {
return nil, fmt.Errorf("account is not connected to Enable Banking")
}
var response struct {
Balances []struct {
Amount amountDTO `json:"balance_amount"`
Type string `json:"balance_type"`
ReferenceDate string `json:"reference_date"`
} `json:"balances"`
}
if err := p.request(ctx, http.MethodGet, "/accounts/"+url.PathEscape(externalAccountID)+"/balances", nil, &response); err != nil {
return nil, err
}
result := make([]Balance, 0, len(response.Balances))
for _, b := range response.Balances {
amount, err := domain.ParseMoney(b.Amount.Amount)
if err != nil || !validCurrency(b.Amount.Currency) {
return nil, fmt.Errorf("Enable Banking returned invalid balance amount")
}
result = append(result, Balance{Amount: amount, Currency: b.Amount.Currency, Type: b.Type, ReferenceDate: b.ReferenceDate})
}
return result, nil
}
type transactionDTO struct {
EntryReference string `json:"entry_reference"`
Amount amountDTO `json:"transaction_amount"`
Indicator string `json:"credit_debit_indicator"`
Status string `json:"status"`
BookingDate string `json:"booking_date"`
ValueDate string `json:"value_date"`
TransactionDate string `json:"transaction_date"`
Remittance []string `json:"remittance_information"`
ReferenceNumber string `json:"reference_number"`
Creditor struct {
Name string `json:"name"`
} `json:"creditor"`
Debtor struct {
Name string `json:"name"`
} `json:"debtor"`
CreditorAccount accountIdentificationDTO `json:"creditor_account"`
DebtorAccount accountIdentificationDTO `json:"debtor_account"`
}
func (p *EnableBanking) Transactions(ctx context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
if account.ID == "" || account.ExternalAccountID == "" {
return nil, fmt.Errorf("account is not connected to Enable Banking")
}
for _, date := range []string{from, to} {
if date != "" {
if _, err := time.Parse("2006-01-02", date); err != nil {
return nil, fmt.Errorf("invalid transaction date range")
}
}
}
if from != "" && to != "" && from > to {
return nil, fmt.Errorf("invalid transaction date range")
}
query := url.Values{"transaction_status": {"BOOK"}}
if from != "" {
query.Set("date_from", from)
}
if to != "" {
query.Set("date_to", to)
}
result := make([]domain.Facts, 0)
seen := map[string]bool{}
for range 1000 {
var response struct {
Transactions []transactionDTO `json:"transactions"`
ContinuationKey string `json:"continuation_key"`
}
if err := p.request(ctx, http.MethodGet, "/accounts/"+url.PathEscape(account.ExternalAccountID)+"/transactions?"+query.Encode(), nil, &response); err != nil {
return nil, err
}
for _, t := range response.Transactions {
if t.Status != "BOOK" {
continue
}
amount, err := domain.ParseMoney(t.Amount.Amount)
if err != nil || strings.HasPrefix(amount.String(), "-") || !validCurrency(t.Amount.Currency) {
return nil, fmt.Errorf("Enable Banking returned invalid transaction amount")
}
party, iban := t.Debtor.Name, t.DebtorAccount.IBAN
switch t.Indicator {
case "DBIT":
amount, err = domain.ParseMoney("-" + amount.String())
if err != nil {
return nil, fmt.Errorf("invalid debit amount")
}
party, iban = t.Creditor.Name, t.CreditorAccount.IBAN
case "CRDT":
default:
return nil, fmt.Errorf("Enable Banking returned invalid credit/debit indicator")
}
// Booked records without a booking date cannot be placed truthfully in the journal.
if _, err := time.Parse("2006-01-02", t.BookingDate); err != nil {
return nil, fmt.Errorf("Enable Banking booked transaction has no valid booking date")
}
if (from != "" && t.BookingDate < from) || (to != "" && t.BookingDate > to) {
continue
}
if t.ValueDate != "" {
if _, err := time.Parse("2006-01-02", t.ValueDate); err != nil {
return nil, fmt.Errorf("Enable Banking returned invalid value date")
}
}
description := strings.Join(t.Remittance, "\n")
if description == "" {
description = t.ReferenceNumber
}
result = append(result, domain.Facts{Source: "enablebanking", AccountID: account.ID, BookingDate: t.BookingDate, ValueDate: t.ValueDate, Amount: amount, Currency: t.Amount.Currency, RawDescription: description, ExternalID: t.EntryReference, Counterparty: party, CounterpartyIBAN: normalizeIBAN(iban)})
}
if response.ContinuationKey == "" {
return result, nil
}
if seen[response.ContinuationKey] {
return nil, fmt.Errorf("Enable Banking repeated a pagination key")
}
seen[response.ContinuationKey] = true
query.Set("continuation_key", response.ContinuationKey)
}
return nil, fmt.Errorf("Enable Banking transaction pagination exceeded limit")
}
+270
View File
@@ -0,0 +1,270 @@
package banking
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "private.pem")
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0600); err != nil {
t.Fatal(err)
}
p, err := NewEnableBanking("test-app", path, "http://localhost:8080/api/banking/callback")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
p.BaseURL = server.URL
p.HTTPClient = server.Client()
return p, key
}
func assertJWT(t *testing.T, r *http.Request, key *rsa.PrivateKey) {
t.Helper()
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
t.Error("missing Bearer authentication")
return
}
parts := strings.Split(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), ".")
if len(parts) != 3 {
t.Error("invalid JWT structure")
return
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Error(err)
return
}
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], signature); err != nil {
t.Errorf("invalid JWT signature: %v", err)
}
var header map[string]string
b, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Error(err)
return
}
if err := json.Unmarshal(b, &header); err != nil {
t.Error(err)
return
}
if header["alg"] != "RS256" || header["kid"] != "test-app" || header["typ"] != "JWT" {
t.Errorf("wrong JWT header: %v", header)
}
var claims struct {
Issuer string `json:"iss"`
Audience string `json:"aud"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
}
b, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Error(err)
return
}
if err := json.Unmarshal(b, &claims); err != nil {
t.Error(err)
return
}
now := time.Now().Unix()
if claims.Issuer != "enablebanking.com" || claims.Audience != "api.enablebanking.com" || claims.Issued > now+1 || claims.Expires <= now || claims.Expires-claims.Issued > 86400 {
t.Errorf("invalid JWT claims: %+v", claims)
}
}
func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
var key *rsa.PrivateKey
expiry := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
pages := 0
handler := func(w http.ResponseWriter, r *http.Request) {
assertJWT(t, r, key)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/aspsps":
if r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" {
t.Error("institution filter missing")
}
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
case "/auth":
if r.Method != "POST" {
t.Error("wrong auth method")
}
var request struct {
Access struct {
ValidUntil string `json:"valid_until"`
Balances bool `json:"balances"`
Transactions bool `json:"transactions"`
} `json:"access"`
State string `json:"state"`
Redirect string `json:"redirect_url"`
PSUType string `json:"psu_type"`
ASPSP institutionDTO `json:"aspsp"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
valid, err := time.Parse(time.RFC3339, request.Access.ValidUntil)
if err != nil || valid.After(time.Now().Add(time.Hour)) || !valid.After(time.Now()) || !request.Access.Balances || !request.Access.Transactions || request.State != "csrf-state" || request.Redirect != "http://localhost:8080/api/banking/callback" || request.PSUType != "personal" || request.ASPSP.Name != "N26" {
t.Errorf("invalid authorization request: %+v", request)
}
fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`)
case "/sessions":
if r.Method != "POST" {
t.Error("wrong exchange method")
}
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request["code"] != "secret-code" {
t.Error("missing exchange code")
}
fmt.Fprintf(w, `{"session_id":"session-1","access":{"valid_until":%q},"aspsp":{"name":"N26","country":"DE"},"accounts":[{"uid":"uid-one","identification_hash":"stable-hash","account_id":{"iban":"DE02120300000000202051"},"details":"Main account","currency":"EUR"}]}`, expiry)
case "/sessions/session-1":
fmt.Fprintf(w, `{"status":"AUTHORIZED","access":{"valid_until":%q},"aspsp":{"name":"N26","country":"DE"},"accounts":["uid-one"],"accounts_data":[{"uid":"uid-one","identification_hash":"stable-hash"}]}`, expiry)
case "/accounts/uid-one/details":
fmt.Fprint(w, `{"account_id":{"iban":"DE02120300000000202051"},"details":"Main account","currency":"EUR"}`)
case "/accounts/uid-one/balances":
fmt.Fprint(w, `{"balances":[{"name":"Booked","balance_amount":{"currency":"EUR","amount":"1234.5678"},"balance_type":"CLBD","reference_date":"2026-09-01"}]}`)
case "/accounts/uid-one/transactions":
pages++
q := r.URL.Query()
if q.Get("transaction_status") != "BOOK" || q.Get("date_from") != "2026-09-01" || q.Get("date_to") != "2026-09-30" {
t.Error("missing booked/date filters")
}
if pages == 1 {
if q.Get("continuation_key") != "" {
t.Error("unexpected initial continuation")
}
fmt.Fprint(w, `{"transactions":[{"entry_reference":"entry-one","transaction_id":"unstable","transaction_amount":{"amount":"12.3456","currency":"EUR"},"credit_debit_indicator":"DBIT","status":"BOOK","booking_date":"2026-09-01","value_date":"2026-09-02","creditor":{"name":"Cafe"},"creditor_account":{"iban":"DE89370400440532013000"},"remittance_information":["first","second"]},{"transaction_amount":{"amount":"99.00","currency":"EUR"},"credit_debit_indicator":"DBIT","status":"PDNG","booking_date":"2026-09-01"}],"continuation_key":"opaque +/=?token"}`)
} else {
if q.Get("continuation_key") != "opaque +/=?token" {
t.Error("pagination key was not encoded correctly")
}
fmt.Fprint(w, `{"transactions":[{"transaction_id":"not-a-stable-id","transaction_amount":{"amount":"20.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-03","debtor":{"name":"Employer"},"debtor_account":{"iban":"DE02120300000000202051"},"remittance_information":["Income"]}],"continuation_key":null}`)
}
default:
t.Errorf("unexpected request %s", r.URL.Path)
http.NotFound(w, r)
}
}
p, k := testProvider(t, handler)
key = k
authorization, err := p.Authorize(context.Background(), "N26", "de", "csrf-state")
if err != nil || authorization != "https://enablebanking.com/auth/consent" {
t.Fatalf("authorize: %s %v", authorization, err)
}
session, err := p.Exchange(context.Background(), "secret-code")
if err != nil {
t.Fatal(err)
}
if session.ID != "session-1" || len(session.Accounts) != 1 || session.Accounts[0].IBAN != "DE02120300000000202051" {
t.Fatalf("incorrect session: %+v", session)
}
status, err := p.Status(context.Background(), session.ID)
if err != nil {
t.Fatal(err)
}
if len(status.Accounts) != 1 || status.Accounts[0].ID != session.Accounts[0].ID || status.Accounts[0].ExternalAccountID != "uid-one" {
t.Fatalf("account identity changed between session DTOs: %+v", status)
}
balances, err := p.Balances(context.Background(), "uid-one")
if err != nil || len(balances) != 1 || balances[0].Amount.String() != "1234.5678" || balances[0].Type != "CLBD" {
t.Fatalf("balance precision lost: %+v %v", balances, err)
}
transactions, err := p.Transactions(context.Background(), session.Accounts[0], "2026-09-01", "2026-09-30")
if err != nil {
t.Fatal(err)
}
if pages != 2 || len(transactions) != 2 {
t.Fatalf("booked pagination: pages=%d rows=%d", pages, len(transactions))
}
if transactions[0].Amount.String() != "-12.3456" || transactions[0].ExternalID != "entry-one" || transactions[0].Counterparty != "Cafe" || transactions[0].RawDescription != "first\nsecond" || transactions[1].Amount.String() != "20.00" || transactions[1].ExternalID != "" || transactions[1].Counterparty != "Employer" {
t.Fatalf("wrong booking facts: %+v", transactions)
}
}
func TestEnableBankingFailsClosed(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "secret-account-IBAN private upstream failure", http.StatusUnauthorized)
})
_, err := p.Balances(context.Background(), "sensitive-account-identifier")
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "sensitive") || !strings.Contains(err.Error(), "401") {
t.Fatalf("unsafe error: %v", err)
}
if _, err := p.Status(context.Background(), "session"); err == nil || errors.Is(err, ErrReconnect) {
t.Fatalf("application HTTP401 conflated with bank consent: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := p.Balances(ctx, "uid"); err == nil {
t.Fatal("ignored cancellation")
}
}
func TestEnableBankingRejectsPaginationCyclesAndPartialResults(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"transactions":[{"transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-01"}],"continuation_key":"same"}`)
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "")
if err == nil || rows != nil {
t.Fatal("pagination cycle returned partial import")
}
}
func TestEnableBankingSessionRevocationAndInvalidBookedDates(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/sessions/") {
fmt.Fprint(w, `{"status":"REVOKED","accounts":[],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`)
return
}
fmt.Fprint(w, `{"transactions":[{"transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","value_date":"2026-09-01"}]}`)
})
if _, err := p.Status(context.Background(), "revoked"); !errors.Is(err, ErrReconnect) {
t.Fatalf("revoked consent must request reconnection: %v", err)
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "")
if err == nil || rows != nil {
t.Fatal("invented booking date for missing bank fact")
}
}
func TestEnableBankingDoesNotFollowRedirects(t *testing.T) {
leaked := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true }))
defer target.Close()
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
})
if _, err := p.Balances(context.Background(), "uid"); err == nil || leaked {
t.Fatalf("followed sensitive banking redirect: leaked=%v error=%v", leaked, err)
}
}
func TestEnableBankingExpiredConsentRequiresReconnect(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"AUTHORIZED","accounts":[],"access":{"valid_until":"2000-01-01T00:00:00Z"}}`)
})
if _, err := p.Status(context.Background(), "expired"); !errors.Is(err, ErrReconnect) {
t.Fatalf("expired consent must request reconnection: %v", err)
}
}
+335
View File
@@ -0,0 +1,335 @@
package banking
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"finance-duck/internal/domain"
)
func digest(parts ...string) string {
b, _ := json.Marshal(parts)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func identity(f domain.Facts) string { return digest(f.AccountID, f.Source, f.ExternalID) }
func fingerprint(f domain.Facts) string {
return digest(f.AccountID, f.BookingDate, f.ValueDate, f.Amount.String(), f.Currency, strings.Join(strings.Fields(f.RawDescription), " "), strings.ToLower(strings.Join(strings.Fields(f.Counterparty), " ")), f.CounterpartyIBAN)
}
func looseFingerprint(f domain.Facts) string {
return digest(f.AccountID, f.BookingDate, f.Amount.String(), f.Currency)
}
func sameBookedMoney(a, b domain.Facts) bool {
return a.AccountID == b.AccountID && a.BookingDate == b.BookingDate && a.Amount == b.Amount && a.Currency == b.Currency
}
// NormalizeAndDedupe returns new records without mutating the input. Stable bank
// entry references take precedence over text. CSV rows without references use
// occurrence counts, not a set: two identical rows remain two transactions and
// importing the same export again creates none. For overlapping partial exports,
// indistinguishable rows cannot prove an additional occurrence; import complete
// overlapping date windows to establish multiplicity.
//
// Cross-source reconciliation only suppresses equal full-fingerprint groups with
// equal multiplicity. Same-day/same-money cross-source discrepancies fail closed
// for user review rather than guessing or silently inflating balances. No alias
// or bank fact is rewritten, so later upstream metadata drift remains visible.
func NormalizeAndDedupe(data domain.Dataset, incoming []domain.Facts) ([]domain.Transaction, error) {
accounts := make(map[string]bool, len(data.Accounts))
for _, a := range data.Accounts {
accounts[a.ID] = true
}
type group struct {
source, fp string
facts []domain.Facts
}
groups := map[string]*group{}
existing := map[string]map[string]int{}
existingAnonymous := map[string]int{}
existingIDs := map[string]domain.Facts{}
loose := map[string]map[string]map[string]bool{}
addLoose := func(f domain.Facts, fp string) {
k := looseFingerprint(f)
if loose[k] == nil {
loose[k] = map[string]map[string]bool{}
}
if loose[k][f.Source] == nil {
loose[k][f.Source] = map[string]bool{}
}
loose[k][f.Source][fp] = true
}
for _, t := range data.Transactions {
f, err := normalizeFacts(t.Facts, accounts)
if err != nil {
return nil, fmt.Errorf("existing transaction %s: %w", t.Facts.ID, err)
}
fp := fingerprint(f)
if existing[fp] == nil {
existing[fp] = map[string]int{}
}
existing[fp][f.Source]++
if f.ExternalID == "" {
existingAnonymous[digest(f.Source, fp)]++
}
if f.ExternalID != "" {
existingIDs[identity(f)] = f
}
addLoose(f, fp)
}
seenIDs := map[string]domain.Facts{}
for index, original := range incoming {
f, err := normalizeFacts(original, accounts)
if err != nil {
return nil, fmt.Errorf("incoming record %d: %w", index+1, err)
}
if f.ExternalID != "" {
key := identity(f)
if old, ok := seenIDs[key]; ok {
if !sameBookedMoney(old, f) {
return nil, fmt.Errorf("conflicting upstream transaction identity in incoming records")
}
continue
}
seenIDs[key] = f
if old, ok := existingIDs[key]; ok {
if !sameBookedMoney(old, f) {
return nil, fmt.Errorf("upstream transaction changed immutable booking facts")
}
// Use stored metadata to keep this matched occurrence in its original group.
f = old
}
}
fp := fingerprint(f)
key := digest(f.Source, fp)
if groups[key] == nil {
groups[key] = &group{source: f.Source, fp: fp}
}
groups[key].facts = append(groups[key].facts, f)
addLoose(f, fp)
}
// Reject ambiguous collisions even when one exact match also exists.
for _, g := range groups {
for _, f := range g.facts {
for source, fps := range loose[looseFingerprint(f)] {
if source != g.source {
for fp := range fps {
if fp != g.fp {
return nil, fmt.Errorf("uncertain cross-source match on account %s at %s; reconcile differing bank/CSV records before importing", f.AccountID, f.BookingDate)
}
}
}
}
}
}
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
result := make([]domain.Transaction, 0)
accepted := map[string]map[string]int{}
for _, key := range keys {
g := groups[key]
crossCount := -1
for source, n := range existing[g.fp] {
if source != g.source {
if crossCount >= 0 && crossCount != n {
return nil, fmt.Errorf("uncertain cross-source occurrence counts")
}
crossCount = n
}
}
for source, n := range accepted[g.fp] {
if source != g.source {
if crossCount >= 0 && crossCount != n {
return nil, fmt.Errorf("uncertain cross-source occurrence counts")
}
crossCount = n
}
}
if crossCount >= 0 {
f := g.facts[0]
if strings.TrimSpace(f.RawDescription) == "" && strings.TrimSpace(f.Counterparty) == "" && f.CounterpartyIBAN == "" {
return nil, fmt.Errorf("uncertain cross-source match lacks descriptive bank evidence")
}
if crossCount != len(g.facts) {
return nil, fmt.Errorf("uncertain cross-source occurrence counts on account %s at %s", g.facts[0].AccountID, g.facts[0].BookingDate)
}
continue
}
// Sorting IDs makes equal-fingerprint upstream records input-order independent.
sort.SliceStable(g.facts, func(i, j int) bool { return g.facts[i].ExternalID < g.facts[j].ExternalID })
// Referenced and anonymous records consume separate occurrence pools. When a
// reference appears/disappears, a spare record in the other pool is ambiguous:
// it may be an existing booking with changed identity metadata, not new money.
baseline := existingAnonymous[key]
anonymousCount, matchedReferences, newReferences := 0, 0, 0
for _, f := range g.facts {
if f.ExternalID == "" {
anonymousCount++
} else if _, ok := existingIDs[identity(f)]; ok {
matchedReferences++
} else {
newReferences++
}
}
unmatchedReferences := existing[g.fp][g.source] - baseline - matchedReferences
if (newReferences > 0 && baseline > anonymousCount) || (anonymousCount > baseline && unmatchedReferences > 0) {
return nil, fmt.Errorf("uncertain transaction identity changed between referenced and anonymous records on account %s at %s", g.facts[0].AccountID, g.facts[0].BookingDate)
}
occurrence := 0
for _, f := range g.facts {
if f.ExternalID != "" {
if _, ok := existingIDs[identity(f)]; ok {
continue
}
} else {
occurrence++
if occurrence <= baseline {
continue
}
}
f.Fingerprint = g.fp
if f.ExternalID != "" {
f.ID = "tx_" + identity(f)
} else {
f.ID = "tx_" + digest(f.Source, g.fp, strconv.Itoa(occurrence))
}
result = append(result, domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)})
}
if accepted[g.fp] == nil {
accepted[g.fp] = map[string]int{}
}
accepted[g.fp][g.source] = len(g.facts)
}
sort.Slice(result, func(i, j int) bool {
a, b := result[i].Facts, result[j].Facts
if a.BookingDate != b.BookingDate {
return a.BookingDate < b.BookingDate
}
return a.ID < b.ID
})
return result, nil
}
func normalizeFacts(f domain.Facts, accounts map[string]bool) (domain.Facts, error) {
if !accounts[f.AccountID] {
return f, fmt.Errorf("unknown account")
}
if f.Source == "" {
return f, fmt.Errorf("missing import source")
}
date, err := parseDate(f.BookingDate)
if err != nil {
return f, fmt.Errorf("invalid booking date")
}
f.BookingDate = date
if f.ValueDate != "" {
f.ValueDate, err = parseDate(f.ValueDate)
if err != nil {
return f, fmt.Errorf("invalid value date")
}
}
f.Amount, err = domain.ParseMoney(string(f.Amount))
if err != nil {
return f, fmt.Errorf("invalid amount")
}
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
if !validCurrency(f.Currency) {
return f, fmt.Errorf("invalid currency")
}
f.CounterpartyIBAN = normalizeIBAN(f.CounterpartyIBAN)
f.ExternalID = strings.TrimSpace(f.ExternalID)
return f, nil
}
// MatchTransfers links only mutually unique candidates, with reciprocal own
// IBANs, inverse exact money in one currency, and booking dates within 3 calendar
// days. Existing manual links are retained. Ambiguous equal payments stay ordinary
// transactions: iteration order must never decide which transfer gets linked.
func MatchTransfers(data *domain.Dataset) {
if data == nil {
return
}
own := map[string]string{}
duplicates := map[string]bool{}
for _, a := range data.Accounts {
iban := normalizeIBAN(a.IBAN)
if iban == "" {
continue
}
if _, ok := own[iban]; ok {
duplicates[iban] = true
}
own[iban] = a.ID
}
byAccount := map[string]string{}
for iban, id := range own {
if !duplicates[iban] {
byAccount[id] = iban
}
}
candidates := make([][]int, len(data.Transactions))
for i := range data.Transactions {
a := data.Transactions[i]
if a.Enrichment.Kind == "transfer" || a.Enrichment.TransferPeerID != "" {
continue
}
ai := byAccount[a.Facts.AccountID]
target := normalizeIBAN(a.Facts.CounterpartyIBAN)
if ai == "" || target == "" || duplicates[target] || own[target] == "" || own[target] == a.Facts.AccountID {
continue
}
am, err := a.Facts.Amount.Minor()
if err != nil || am == 0 {
continue
}
ad, err := time.Parse("2006-01-02", a.Facts.BookingDate)
if err != nil {
continue
}
for j := i + 1; j < len(data.Transactions); j++ {
b := data.Transactions[j]
if b.Enrichment.Kind == "transfer" || b.Enrichment.TransferPeerID != "" || b.Facts.AccountID != own[target] || normalizeIBAN(b.Facts.CounterpartyIBAN) != ai || a.Facts.Currency != b.Facts.Currency {
continue
}
bm, err := b.Facts.Amount.Minor()
if err != nil || (am > 0) == (bm > 0) || am+bm != 0 {
continue
}
bd, err := time.Parse("2006-01-02", b.Facts.BookingDate)
if err != nil {
continue
}
delta := ad.Sub(bd)
if delta < -72*time.Hour || delta > 72*time.Hour {
continue
}
candidates[i] = append(candidates[i], j)
candidates[j] = append(candidates[j], i)
}
}
for i, matches := range candidates {
if len(matches) != 1 {
continue
}
j := matches[0]
if j <= i || len(candidates[j]) != 1 {
continue
}
for _, pair := range [][2]int{{i, j}, {j, i}} {
t := &data.Transactions[pair[0]]
tags := t.Enrichment.TagIDs
if tags == nil {
tags = []string{}
}
t.Enrichment = domain.Enrichment{Kind: "transfer", TagIDs: tags, TransferPeerID: data.Transactions[pair[1]].Facts.ID, Classification: domain.Provenance{Source: "transfer_match"}}
}
}
}
+285
View File
@@ -0,0 +1,285 @@
package banking
import (
"reflect"
"strings"
"testing"
"finance-duck/internal/domain"
)
func fixtureDataset() domain.Dataset {
d := domain.NewDataset()
d.Accounts = []domain.Account{{ID: "account_a", DisplayName: "N26", Currency: "EUR", IBAN: "DE02120300000000202051", Active: true}, {ID: "account_b", DisplayName: "Savings", Currency: "EUR", IBAN: "DE89370400440532013000", Active: true}}
return d
}
func fixtureFacts() domain.Facts {
return domain.Facts{Source: "n26_csv", AccountID: "account_a", BookingDate: "2026-09-01", Amount: "-12.30", Currency: "EUR", RawDescription: "Lunch", Counterparty: "Cafe", Fingerprint: "fixture"}
}
func TestN26SupportedExportSchemas(t *testing.T) {
cases := []struct{ name, csv, amount, description, party, iban, value string }{
{"English legacy quoted multiline", "Date,Payee,Account number,Payment type,Payment reference,Amount (EUR),Amount (Foreign Currency),Type Foreign Currency,Exchange Rate\r\n2026-09-01,\"Cafe, Berlin\",DE02120300000000202051,MasterCard Payment,\"Lunch, first line\nsecond line\",-12.30,-14.50,USD,0.85\r\n", "-12.30", "Lunch, first line\nsecond line", "Cafe, Berlin", "DE02120300000000202051", ""},
{"German decimal comma semicolon BOM", "\ufeffDatum;Zahlungsempfänger;Kontonummer;Transaktionstyp;Verwendungszweck;Betrag (EUR);Betrag (Fremdwährung);Fremdwährung;Wechselkurs\n01.09.2026;Arbeitgeber;DE89 3704 0044 0532 0130 00;Überweisung;Gehalt;\"1.234,56\";;;\n", "1234.56", "Gehalt", "Arbeitgeber", "DE89370400440532013000", ""},
{"English booking and value dates", "Booking Date,Value Date,Partner Name,Partner IBAN,Type,Payment Reference,Account Name,Amount (EUR),Original Amount,Original Currency,Exchange Rate\n2026-09-01,2026-08-31,Cafe,DE02120300000000202051,Card,Lunch,Main,-12.30,-14.50,USD,0.85\n", "-12.30", "Lunch", "Cafe", "DE02120300000000202051", "2026-08-31"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
rows, err := ParseCSV(strings.NewReader(tt.csv), fixtureDataset().Accounts[0])
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("records: %d", len(rows))
}
f := rows[0]
if f.Amount.String() != tt.amount || f.RawDescription != tt.description || f.Counterparty != tt.party || f.CounterpartyIBAN != tt.iban || f.ValueDate != tt.value || f.BookingDate != "2026-09-01" || f.Currency != "EUR" {
t.Fatalf("unexpected parsed facts: %+v", f)
}
})
}
}
func TestCSVRejectsPartialAndMalformedImports(t *testing.T) {
for _, input := range []string{
"Date,Payee,Amount (Foreign Currency)\n2026-09-01,Cafe,-1.00\n",
"Date,Amount (EUR)\n2026-09-01,-1.00\n2026-09-02,nope\n",
"Date,Amount (EUR)\n2026-02-30,-1.00\n",
"Date,Amount (EUR),Currency\n2026-09-01,-1.00,USD\n",
"Date,Amount (EUR)\n2026-09-01,\"unterminated\n",
} {
rows, err := ParseCSV(strings.NewReader(input), fixtureDataset().Accounts[0])
if err == nil || rows != nil {
t.Fatalf("accepted malformed/partial import %q", input)
}
}
}
func TestFallbackOccurrenceMultiplicityAndRepeatImport(t *testing.T) {
d := fixtureDataset()
f := fixtureFacts()
rows, err := NormalizeAndDedupe(d, []domain.Facts{f, f})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].Facts.ID == rows[1].Facts.ID {
t.Fatalf("legitimate duplicate rows lost: %+v", rows)
}
d.Transactions = append(d.Transactions, rows...)
again, err := NormalizeAndDedupe(d, []domain.Facts{f, f})
if err != nil || len(again) != 0 {
t.Fatalf("repeat not idempotent: %v %+v", err, again)
}
added, err := NormalizeAndDedupe(d, []domain.Facts{f, f, f})
if err != nil || len(added) != 1 {
t.Fatalf("new occurrence lost: %v %+v", err, added)
}
d.Transactions = append(d.Transactions, added...)
again, err = NormalizeAndDedupe(d, []domain.Facts{f, f, f})
if err != nil || len(again) != 0 {
t.Fatalf("expanded repeat not idempotent: %v %+v", err, again)
}
if err := domain.Validate(d); err != nil {
t.Fatal(err)
}
}
func TestUpstreamIdentityPreferredAndAccountScoped(t *testing.T) {
d := fixtureDataset()
a := fixtureFacts()
a.Source = "enablebanking"
a.ExternalID = "bank-entry-1"
b := a
b.AccountID = "account_b"
rows, err := NormalizeAndDedupe(d, []domain.Facts{a, a, b})
if err != nil || len(rows) != 2 {
t.Fatalf("account identity lost: %v %+v", err, rows)
}
d.Transactions = rows
a.RawDescription = "Updated upstream display"
a.ValueDate = "2026-09-02"
again, err := NormalizeAndDedupe(d, []domain.Facts{a})
if err != nil || len(again) != 0 {
t.Fatalf("upstream identity not preferred: %v %+v", err, again)
}
a.Amount = "-99.00"
again, err = NormalizeAndDedupe(d, []domain.Facts{a})
if err == nil || again != nil {
t.Fatal("changed immutable upstream money accepted")
}
}
func TestDistinctUpstreamIDsPreserveEqualTransactions(t *testing.T) {
d := fixtureDataset()
a := fixtureFacts()
a.Source = "enablebanking"
a.ExternalID = "one"
b := a
b.ExternalID = "two"
rows, err := NormalizeAndDedupe(d, []domain.Facts{a, b})
if err != nil || len(rows) != 2 {
t.Fatalf("distinct IDs collapsed: %v %+v", err, rows)
}
reverse, err := NormalizeAndDedupe(d, []domain.Facts{b, a})
if err != nil || !reflect.DeepEqual(rows, reverse) {
t.Fatalf("order changed IDs: %v", err)
}
d.Transactions = rows[:1]
added, err := NormalizeAndDedupe(d, []domain.Facts{a, b})
if err != nil || len(added) != 1 {
t.Fatalf("new equal upstream record suppressed: %v %+v", err, added)
}
}
func TestCrossSourceExactMatchAndUncertainty(t *testing.T) {
d := fixtureDataset()
csv := fixtureFacts()
rows, err := NormalizeAndDedupe(d, []domain.Facts{csv})
if err != nil {
t.Fatal(err)
}
d.Transactions = rows
api := csv
api.Source = "enablebanking"
api.ExternalID = "upstream"
matched, err := NormalizeAndDedupe(d, []domain.Facts{api})
if err != nil || len(matched) != 0 {
t.Fatalf("double counted matching cross-source transaction: %v %+v", err, matched)
}
api.RawDescription = "Different bank text"
matched, err = NormalizeAndDedupe(d, []domain.Facts{api})
if err == nil || matched != nil {
t.Fatal("uncertain overlap was silently counted")
}
api.RawDescription = csv.RawDescription
b := api
b.ExternalID = "second"
matched, err = NormalizeAndDedupe(d, []domain.Facts{api, b})
if err == nil || matched != nil {
t.Fatal("unequal cross-source multiplicity was guessed")
}
d.Transactions[0].Facts.RawDescription = ""
d.Transactions[0].Facts.Counterparty = ""
api.RawDescription = ""
api.Counterparty = ""
matched, err = NormalizeAndDedupe(d, []domain.Facts{api})
if err == nil || matched != nil {
t.Fatal("matched cross-source money without descriptive evidence")
}
}
func transferDataset() domain.Dataset {
d := fixtureDataset()
a := fixtureFacts()
a.ID = "tx_a"
a.Amount = "-10.00"
a.CounterpartyIBAN = d.Accounts[1].IBAN
b := a
b.ID = "tx_b"
b.AccountID = "account_b"
b.Amount = "10.00"
b.BookingDate = "2026-09-03"
b.CounterpartyIBAN = d.Accounts[0].IBAN
d.Transactions = []domain.Transaction{{Facts: a, Enrichment: domain.Fallback(a)}, {Facts: b, Enrichment: domain.Fallback(b)}}
return d
}
func TestTransfersRequireUniqueReciprocalOwnBankEvidence(t *testing.T) {
d := transferDataset()
MatchTransfers(&d)
if d.Transactions[0].Enrichment.TransferPeerID != "tx_b" || d.Transactions[1].Enrichment.TransferPeerID != "tx_a" {
t.Fatal("unique own-account transfer not linked")
}
if err := domain.Validate(d); err != nil {
t.Fatal(err)
}
for _, change := range []func(*domain.Dataset){
func(d *domain.Dataset) {
copy := d.Transactions[1]
copy.Facts.ID = "tx_c"
d.Transactions = append(d.Transactions, copy)
},
func(d *domain.Dataset) { d.Transactions[1].Facts.CounterpartyIBAN = "" },
func(d *domain.Dataset) { d.Transactions[1].Facts.Currency = "USD" },
func(d *domain.Dataset) { d.Transactions[1].Facts.Amount = "9.99" },
func(d *domain.Dataset) { d.Transactions[1].Facts.BookingDate = "2026-09-05" },
func(d *domain.Dataset) {
d.Accounts = append(d.Accounts, domain.Account{ID: "ambiguous_account", IBAN: d.Accounts[1].IBAN})
},
} {
d := transferDataset()
change(&d)
before := domain.Clone(d)
MatchTransfers(&d)
if !reflect.DeepEqual(d, before) {
t.Fatalf("ambiguous or unsupported transfer evidence linked: %+v", d.Transactions)
}
}
}
func TestMixedReferencedAndAnonymousMultiplicity(t *testing.T) {
d := fixtureDataset()
anonymous := fixtureFacts()
anonymous.Source = "enablebanking"
referenced := anonymous
referenced.ExternalID = "known-reference"
original, err := NormalizeAndDedupe(d, []domain.Facts{referenced})
if err != nil {
t.Fatal(err)
}
d.Transactions = original
for _, window := range [][]domain.Facts{{referenced, anonymous}, {anonymous, referenced}} {
added, err := NormalizeAndDedupe(d, window)
if err != nil || len(added) != 1 || added[0].Facts.ExternalID != "" {
t.Fatalf("lost additional anonymous booking beside matched reference: %+v %v", added, err)
}
if added[0].Facts.ID == original[0].Facts.ID {
t.Fatal("anonymous booking reused referenced identity")
}
}
added, err := NormalizeAndDedupe(d, []domain.Facts{referenced, anonymous})
if err != nil {
t.Fatal(err)
}
d.Transactions = append(d.Transactions, added...)
repeated, err := NormalizeAndDedupe(d, []domain.Facts{anonymous, referenced})
if err != nil || len(repeated) != 0 {
t.Fatalf("mixed repeat is not idempotent: %+v %v", repeated, err)
}
second, err := NormalizeAndDedupe(d, []domain.Facts{anonymous, referenced, anonymous})
if err != nil || len(second) != 1 || second[0].Facts.ID == added[0].Facts.ID {
t.Fatalf("second anonymous occurrence lost or ID reused: %+v %v", second, err)
}
d.Transactions = append(d.Transactions, second...)
repeated, err = NormalizeAndDedupe(d, []domain.Facts{referenced, anonymous, anonymous})
if err != nil || len(repeated) != 0 {
t.Fatalf("expanded mixed repeat is not idempotent: %+v %v", repeated, err)
}
if err := domain.Validate(d); err != nil {
t.Fatal(err)
}
}
func TestChangingReferenceAvailabilityFailsClosed(t *testing.T) {
anonymous := fixtureFacts()
anonymous.Source = "enablebanking"
referenced := anonymous
referenced.ExternalID = "new-reference"
for _, pair := range [][2]domain.Facts{{anonymous, referenced}, {referenced, anonymous}} {
d := fixtureDataset()
original, err := NormalizeAndDedupe(d, []domain.Facts{pair[0]})
if err != nil {
t.Fatal(err)
}
d.Transactions = original
added, err := NormalizeAndDedupe(d, []domain.Facts{pair[1]})
if err == nil || added != nil {
t.Fatalf("identity availability change silently added/dropped money: %+v %v", added, err)
}
}
// A complete window containing the known anonymous booking separately proves
// that an additional referenced booking increases multiplicity.
d := fixtureDataset()
original, err := NormalizeAndDedupe(d, []domain.Facts{anonymous})
if err != nil {
t.Fatal(err)
}
d.Transactions = original
added, err := NormalizeAndDedupe(d, []domain.Facts{anonymous, referenced})
if err != nil || len(added) != 1 || added[0].Facts.ExternalID != "new-reference" {
t.Fatalf("proven additional referenced booking was lost: %+v %v", added, err)
}
}