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" "net/http" "net/url" "slices" "strconv" "strings" "time" "finance-duck/internal/domain" "finance-duck/internal/ratelimit" ) // 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"` // Unlinkable counts accounts the bank shared that cannot be represented // as a journal account (no IBAN or stable identification, or an // unsupported currency). They are excluded, never silently merged. Unlinkable int `json:"unlinkable,omitempty"` } // SessionStatus contains only the current consent expiry and external account // membership. Full account metadata is captured once by Exchange. type SessionStatus struct { ValidUntil string AccountIDs []string } 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(ctx context.Context, institution, country, psuType, state string) (string, error) Exchange(context.Context, string) (Session, error) Status(context.Context, string) (SessionStatus, error) Balances(context.Context, string) ([]Balance, error) Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, error) Institutions(ctx context.Context, country string) ([]Institution, error) } type EnableBanking struct { HTTPClient *http.Client BaseURL string appID string key *rsa.PrivateKey redirectURL string requests ratelimit.Controller // Accessed only while requests is acquired. Keys exclude pagination/query data. backgroundQuotas map[string]time.Time } var _ Provider = (*EnableBanking)(nil) // MaxPrivateKeyPEM bounds uploaded and environment-loaded private keys. const MaxPrivateKeyPEM = 32 * 1024 func NewEnableBanking(appID string, keyPEM []byte, redirectURL string) (*EnableBanking, error) { if len(appID) == 0 || len(appID) > 256 { return nil, errors.New("invalid Enable Banking application ID") } for _, c := range appID { if c < 33 || c > 126 { return nil, errors.New("invalid Enable Banking application ID") } } redirect, err := url.Parse(redirectURL) if err != nil || redirect.Hostname() == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil || redirect.Opaque != "" || redirect.Path != "/api/banking/callback" || redirect.RawPath != "" || redirect.RawQuery != "" || redirect.ForceQuery || redirect.Fragment != "" || strings.Contains(redirectURL, "#") { return nil, errors.New("invalid Enable Banking redirect URL") } if len(keyPEM) > MaxPrivateKeyPEM { return nil, errors.New("Enable Banking private key exceeds size limit") } content := bytes.TrimSpace(keyPEM) block, rest := pem.Decode(content) if block == nil || !bytes.HasPrefix(content, []byte("-----BEGIN "+block.Type+"-----")) || len(bytes.TrimSpace(rest)) != 0 || len(block.Headers) != 0 { return nil, errors.New("Enable Banking key must be a single PEM private key") } 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, requests: ratelimit.Controller{MinimumInterval: time.Second, InitialBackoff: 30 * time.Second}}, 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 } // PSU contains only the documented, nonsecret browser metadata for a person // actively requesting account data. It must never be stored on a shared client. type PSU struct { IPAddress string UserAgent string Accept string AcceptCharset string AcceptEncoding string AcceptLanguage string } type psuContextKey struct{} // WithPSU marks this request context as user initiated. Call only at a manual // HTTP boundary, never for scheduled retrieval. Unknown metadata stays empty. func WithPSU(ctx context.Context, psu PSU) context.Context { return context.WithValue(ctx, psuContextKey{}, psu) } func (psu PSU) setHeaders(header http.Header) { if ip := net.ParseIP(psu.IPAddress); ip != nil { header.Set("Psu-Ip-Address", ip.String()) } for _, field := range []struct{ name, value string }{ {"Psu-User-Agent", psu.UserAgent}, {"Psu-Accept", psu.Accept}, {"Psu-Accept-Charset", psu.AcceptCharset}, {"Psu-Accept-Encoding", psu.AcceptEncoding}, {"Psu-Accept-Language", psu.AcceptLanguage}, } { if field.value != "" { header.Set(field.name, field.value) } } } // accountDataPath admits only the internally generated account GET endpoints. // The escaped account segment is retained; dates and continuation keys are not. func accountDataPath(method, path string) string { if method != http.MethodGet { return "" } path, _, _ = strings.Cut(path, "?") parts := strings.Split(path, "/") if len(parts) != 4 || parts[0] != "" || parts[1] != "accounts" || (parts[3] != "balances" && parts[3] != "transactions") { return "" } account, err := url.PathUnescape(parts[2]) if err != nil || account == "" || account == "." || account == ".." || strings.ContainsAny(account, "/\\") || url.PathEscape(account) != parts[2] { return "" } return path } // BackgroundQuotaError identifies the bank's background-only account quota. // It preserves RateLimitError identity and exposes no provider response text. type BackgroundQuotaError struct { *ratelimit.RateLimitError } func (e *BackgroundQuotaError) Error() string { return "background bank retrieval quota (normally six hours): " + e.RateLimitError.Error() } func (e *BackgroundQuotaError) Unwrap() error { return e.RateLimitError } // APIError reports a failed Enable Banking call. Its message is built only // from the HTTP status and, when the response envelope's error code exactly // matches the documented enumeration, that code with a locally written hint. // Provider response text is never included. type APIError struct { Status int Code string // documented Enable Banking error code, or empty } func (e *APIError) Error() string { if hint, known := apiErrorHints[e.Code]; known { return fmt.Sprintf("Enable Banking returned HTTP %d (%s: %s)", e.Status, e.Code, hint) } return fmt.Sprintf("Enable Banking returned HTTP %d", e.Status) } // Unwrap exposes inactive-consent codes as ErrReconnect so callers offer // reconnection instead of a dead-end provider failure. func (e *APIError) Unwrap() error { switch e.Code { case "CLOSED_SESSION", "EXPIRED_SESSION", "REVOKED_SESSION", "SESSION_DOES_NOT_EXIST": return ErrReconnect } return nil } // apiErrorHints holds locally written descriptions for the documented error // codes relevant to account information. Only exact matches are ever exposed. var apiErrorHints = map[string]string{ "ACCESS_DENIED": "access to this resource is denied for the application", "ACCOUNT_DOES_NOT_EXIST": "no account matches the stored identifier", "ASPSP_ACCOUNT_NOT_ACCESSIBLE": "the bank did not grant access to the requested account", "ASPSP_ERROR": "the bank reported an error", "ASPSP_PSU_ACTION_REQUIRED": "the bank requires action in your banking app or online banking", "ASPSP_TIMEOUT": "the bank did not respond in time", "CLOSED_SESSION": "the bank session is closed", "DATE_FROM_IN_FUTURE": "the requested start date is in the future", "EXPIRED_SESSION": "the bank session has expired", "NO_ACCOUNTS_ADDED": "no allowed accounts are added to the application", "PSU_HEADER_INVALID": "the forwarded browser metadata was rejected", "PSU_HEADER_NOT_PROVIDED": "this bank requires a user-initiated request", "REVOKED_SESSION": "the bank session was revoked", "SESSION_DOES_NOT_EXIST": "the bank session no longer exists", "UNAUTHORIZED_ACCESS": "the application is not authorized for this request", "UNAUTHORIZED_IP": "this network address is not authorized for the request", "WRONG_CONTINUATION_KEY": "the pagination key was rejected", "WRONG_DATE_INTERVAL": "the start date must not be after the end date", "WRONG_REQUEST_PARAMETERS": "the request parameters were rejected", "WRONG_SESSION_STATUS": "the bank session is in the wrong state for this request", "WRONG_TRANSACTIONS_PERIOD": "the bank does not provide transactions for the requested period; banks commonly limit history to about 90 days after the initial connection", } // apiError classifies a non-success response by its documented error code // without retaining or exposing any other provider response content. func apiError(response *http.Response) *APIError { failure := &APIError{Status: response.StatusCode} const envelopeLimit = 16 << 10 body, err := io.ReadAll(io.LimitReader(response.Body, envelopeLimit+1)) var envelope struct { Error string `json:"error"` } if err == nil && len(body) <= envelopeLimit && json.Unmarshal(body, &envelope) == nil { if _, known := apiErrorHints[envelope.Error]; known { failure.Code = envelope.Error } } return failure } // backgroundRetryAt follows the bank's six-hour guidance, never shortening a // longer provider deadline. Zero retains the limiter's unbounded-delay meaning. func backgroundRetryAt(header string, now time.Time) time.Time { next := now.Add(6 * time.Hour) header = strings.TrimSpace(header) if header == "" { return next } if strings.Trim(header, "0123456789") == "" { seconds, err := strconv.ParseUint(header, 10, 64) if err != nil || seconds > uint64((1<<63-1)/int64(time.Second)) { return time.Time{} } if delay := time.Duration(seconds) * time.Second; delay > 6*time.Hour { return now.Add(delay) } } else if date, err := http.ParseTime(header); err == nil && date.After(next) { return date } return next } func (p *EnableBanking) request(ctx context.Context, method, path string, input, output any) error { if err := p.requests.Acquire(ctx); err != nil { return fmt.Errorf("Enable Banking: %w", err) } defer p.requests.Release() scope := accountDataPath(method, path) psu, foreground := ctx.Value(psuContextKey{}).(PSU) now := time.Now() for key, next := range p.backgroundQuotas { if !next.IsZero() && !next.After(now) { delete(p.backgroundQuotas, key) } } if scope != "" && !foreground { if next, limited := p.backgroundQuotas[scope]; limited { return fmt.Errorf("Enable Banking: %w", &BackgroundQuotaError{ratelimit.NewError(next)}) } } 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 scope != "" && foreground { psu.setHeaders(req.Header) } if input != nil { req.Header.Set("Content-Type", "application/json") } client := http.Client{Timeout: 30 * time.Second} if p.HTTPClient != nil { client = *p.HTTPClient } // Cap each attempt, including response reads, without timing out retry waits. if client.Timeout <= 0 || client.Timeout > 30*time.Second { client.Timeout = 30 * time.Second } // Never forward signed credentials or financial requests through redirects. client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } response, err := p.requests.Do(ctx, func(ctx context.Context) (*http.Response, error) { response, err := client.Do(req.Clone(ctx)) if err != nil { if errors.Is(err, context.Canceled) { return nil, context.Canceled } if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("request timed out: %w", context.DeadlineExceeded) } return nil, errors.New("connection failed") } if response.StatusCode == http.StatusTooManyRequests && scope != "" && !foreground { // Inspect only a small structured error envelope, never exposing its // detail/message or retaining response bytes. Other 429s remain the // common controller's responsibility (including body closure). const errorLimit = 16 << 10 body, readErr := io.ReadAll(io.LimitReader(response.Body, errorLimit+1)) var envelope struct { Error string `json:"error"` } if readErr == nil && len(body) <= errorLimit && json.Unmarshal(body, &envelope) == nil && envelope.Error == "ASPSP_RATE_LIMIT_EXCEEDED" { next := backgroundRetryAt(response.Header.Get("Retry-After"), time.Now()) if p.backgroundQuotas == nil { p.backgroundQuotas = make(map[string]time.Time) } p.backgroundQuotas[scope] = next response.Body.Close() return nil, &BackgroundQuotaError{ratelimit.NewError(next)} } } return response, nil }, method == http.MethodGet) if err != nil { return fmt.Errorf("Enable Banking: %w", err) } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return apiError(response) } const limit = 16 << 20 b, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) if err != nil { if errors.Is(err, context.Canceled) { return context.Canceled } if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("Enable Banking response timed out: %w", context.DeadlineExceeded) } 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 } // PSU types are the documented account-holder kinds. Enable Banking warns that // a psu_type mismatch can authorize a consent that shares no accounts, so the // caller must choose one the bank actually supports. const ( PSUPersonal = "personal" PSUBusiness = "business" ) // ValidPSUType reports whether s is a documented Enable Banking PSU type. func ValidPSUType(s string) bool { return s == PSUPersonal || s == PSUBusiness } // Institution describes a bank available for account information. Logo is // retained only when it is an https Enable Banking URL, matching the // Content-Security-Policy image allowlist under which the UI displays it. // PSUTypes lists the account-holder kinds this bank supports; a business-only // bank authorized as personal shares no accounts. type Institution struct { Name string `json:"name"` Country string `json:"country"` Logo string `json:"logo,omitempty"` PSUTypes []string `json:"psu_types"` } // safeLogoURL admits only https Enable Banking brand URLs. Any other // provider-supplied location renders no logo rather than a third-party fetch. func safeLogoURL(logo string) string { u, err := url.Parse(logo) if err != nil || u.Scheme != "https" || u.User != nil { return "" } host := strings.ToLower(u.Hostname()) if host != "enablebanking.com" && !strings.HasSuffix(host, ".enablebanking.com") { return "" } return logo } type aspspDTO struct { institutionDTO Logo string `json:"logo"` PSUTypes []string `json:"psu_types"` MaximumConsentValidity int64 `json:"maximum_consent_validity"` } // psuTypes keeps only documented values, preserving personal before business // so callers can offer a stable default. func (a aspspDTO) psuTypes() []string { out := make([]string, 0, 2) for _, kind := range []string{PSUPersonal, PSUBusiness} { if slices.Contains(a.PSUTypes, kind) { out = append(out, kind) } } return out } // aspsps lists account-information banks for a country without filtering by // PSU type: each entry reports the types it supports. func (p *EnableBanking) aspsps(ctx context.Context, country string) ([]aspspDTO, error) { var list struct { ASPSPs []aspspDTO `json:"aspsps"` } query := url.Values{"country": {country}, "service": {"AIS"}} if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil { return nil, err } return list.ASPSPs, nil } // Institutions lists the banks connectable for account information in a // country, excluding entries the Authorize flow would reject anyway. func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Institution, error) { country = strings.ToUpper(strings.TrimSpace(country)) if len(country) != 2 { return nil, fmt.Errorf("country must be a two-letter code") } aspsps, err := p.aspsps(ctx, country) if err != nil { return nil, err } result := make([]Institution, 0, len(aspsps)) for _, a := range aspsps { kinds := a.psuTypes() if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 || len(kinds) == 0 { continue } result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo), PSUTypes: kinds}) } slices.SortFunc(result, func(a, b Institution) int { return strings.Compare(a.Name, b.Name) }) return result, nil } // ConsentError explains why a consent cannot be started. Its message is built // only from the caller's own request and the documented ASPSP listing fields, // never from provider response text, so callers may show it to the user. type ConsentError struct{ message string } func (e *ConsentError) Error() string { return e.message } // Authorize starts a consent for the given account-holder kind. psuType must // be one the bank supports: Enable Banking documents that a mismatch can // authorize a consent that then shares no accounts. func (p *EnableBanking) Authorize(ctx context.Context, institution, country, psuType, 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") } if !ValidPSUType(psuType) { return "", fmt.Errorf("account type must be personal or business") } aspsps, err := p.aspsps(ctx, country) if err != nil { return "", err } var validity int64 var supported []string for _, a := range aspsps { if a.Name == institution && a.Country == country { validity = a.MaximumConsentValidity supported = a.psuTypes() break } } if validity <= 0 { return "", &ConsentError{institution + " does not offer account information through Enable Banking, or advertises no valid consent duration"} } if !slices.Contains(supported, psuType) { return "", &ConsentError{fmt.Sprintf("%s does not offer account information for %s accounts; it supports: %s", institution, psuType, strings.Join(supported, ", "))} } // 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: psuType} 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{}} // One account the journal cannot represent (securities or card entries // commonly lack an IBAN, stable identification or a currency) must not // discard the whole consent: link the usable accounts and count the rest. for _, a := range response.Accounts { account, err := a.account(response.ASPSP.Name) if err != nil { result.Unlinkable++ continue } result.Accounts = append(result.Accounts, account) } return result, nil } func (p *EnableBanking) Status(ctx context.Context, sessionID string) (SessionStatus, error) { if sessionID == "" { return SessionStatus{}, fmt.Errorf("session ID is required") } var response struct { Status string `json:"status"` Accounts []string `json:"accounts"` Access accessDTO `json:"access"` } if err := p.request(ctx, http.MethodGet, "/sessions/"+url.PathEscape(sessionID), nil, &response); err != nil { return SessionStatus{}, err } if response.Status != "AUTHORIZED" { return SessionStatus{}, fmt.Errorf("Enable Banking session is not authorized: %w", ErrReconnect) } expires, err := time.Parse(time.RFC3339, response.Access.ValidUntil) if err != nil { return SessionStatus{}, fmt.Errorf("Enable Banking returned invalid session expiry") } if !expires.After(time.Now()) { return SessionStatus{}, fmt.Errorf("Enable Banking session expired: %w", ErrReconnect) } for _, id := range response.Accounts { if id == "" { return SessionStatus{}, fmt.Errorf("Enable Banking returned empty account identifier") } } return SessionStatus{ValidUntil: response.Access.ValidUntil, AccountIDs: response.Accounts}, 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"` } // Transactions retrieves booked transactions in the requested window. With // longest, the documented "longest" fetching strategy asks the provider for // the maximum period the bank permits instead of rejecting an out-of-range // start date; rows outside the requested window are still filtered out here. func (p *EnableBanking) Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]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) } if longest { query.Set("strategy", "longest") } 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") }