init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user