494 lines
18 KiB
Go
494 lines
18 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/http"
|
|
"net/url"
|
|
"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)
|
|
|
|
// 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}, 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")
|
|
}
|