Files
finance-duck/internal/banking/enablebanking.go
T
Lars Nolden 3df9bda989 Replace free-text institution entry with a searchable bank picker
GET /api/banking/institutions lists the banks Enable Banking can connect
for a country (personal AIS, connectable consents only), with logos
restricted to https Enable Banking hosts to match the CSP image
allowlist. The connect form offers a filterable dropdown with bank
logos, falling back to the previous free-text input when the list is
unavailable or banking is not configured.
2026-09-11 11:17:03 +02:00

764 lines
28 KiB
Go

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"`
}
// 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(context.Context, string, string, 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
}
// Institution describes a bank available for personal 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.
type Institution struct {
Name string `json:"name"`
Country string `json:"country"`
Logo string `json:"logo,omitempty"`
}
// 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"`
MaximumConsentValidity int64 `json:"maximum_consent_validity"`
}
func (p *EnableBanking) aspsps(ctx context.Context, country string) ([]aspspDTO, error) {
var list struct {
ASPSPs []aspspDTO `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 nil, err
}
return list.ASPSPs, nil
}
// Institutions lists the banks connectable for personal 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 {
if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 {
continue
}
result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo)})
}
slices.SortFunc(result, func(a, b Institution) int { return strings.Compare(a.Name, b.Name) })
return result, 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")
}
aspsps, err := p.aspsps(ctx, country)
if err != nil {
return "", err
}
var validity int64
for _, a := range 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) (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")
}