Import the longest bank-permitted history and surface real provider errors

Manual history imports failed opaquely once a bank capped lookback on an
established consent (N26 rejects date_from beyond ~90 days with
WRONG_TRANSACTIONS_PERIOD). Backfill now requests the documented longest
fetching strategy, reports the coverage the bank actually provided, and
non-2xx responses surface allowlisted documented error codes instead of a
generic fallback. Dead-session codes map to reconnection. Failed syncs
retry hourly so a stale sync banner no longer persists for a day.
This commit is contained in:
Lars Nolden
2026-09-10 22:58:03 +02:00
parent 4324660888
commit a8722d58c3
7 changed files with 213 additions and 36 deletions
+79 -3
View File
@@ -55,7 +55,7 @@ type Provider interface {
Exchange(context.Context, string) (Session, error)
Status(context.Context, string) (SessionStatus, error)
Balances(context.Context, string) ([]Balance, error)
Transactions(context.Context, domain.Account, string, string) ([]domain.Facts, error)
Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, error)
}
type EnableBanking struct {
HTTPClient *http.Client
@@ -206,6 +206,75 @@ 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 {
@@ -320,7 +389,7 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Enable Banking returned HTTP %d", response.StatusCode)
return apiError(response)
}
const limit = 16 << 20
b, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
@@ -546,7 +615,11 @@ type transactionDTO struct {
DebtorAccount accountIdentificationDTO `json:"debtor_account"`
}
func (p *EnableBanking) Transactions(ctx context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
// 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")
}
@@ -567,6 +640,9 @@ func (p *EnableBanking) Transactions(ctx context.Context, account domain.Account
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 {