Files
finance-duck/internal/banking/enablebanking.go
T
Lars Nolden e77969b8c5 Report failed bank connections and stop resurrecting deleted accounts
Three defects made new connections silently vanish while removed
accounts returned:

- A single shared account the journal cannot represent (securities or
  card entries without IBAN, stable identification or currency) aborted
  the entire consent. Usable accounts are now linked and the rest
  counted and reported.
- A consent that linked nothing was stored, redirected as success and
  later reaped by session recovery. It now fails with the reason.
- Callback failures rendered a bare JSON error page and were never
  logged. They now log and redirect into the app with the reason shown.
- Deleting an account left its session binding, so the next connect or
  sync recovered the binding and re-added the account. Account deletion
  now releases bindings, consents and cursors before committing.
2026-09-11 11:46:59 +02:00

772 lines
29 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"`
// 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(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{}}
// 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")
}