Files
accounted/extensions/general/enable-banking/lib/api-client.ts
T
5eac2a492c fix(enable-banking): stop reading every ASPSP_ERROR as a too-wide window (#2202) (#2247)
* fix(enable-banking): stop reading every ASPSP_ERROR as a too-wide window (#2202)

ASPSP_ERROR is Enable Banking's generic wrapper for any upstream bank
failure, so "history window beyond the PSD2 limit" and "the bank is
refusing right now" arrived as the same string, and every rejection
walked the whole 90/60/30 narrowing ladder: one user click cost up to
five upstream calls against a bank that was already saying no, the
failure surfaced with "förnya anslutningen" advice that fixes nothing,
and a sync that did narrow was reported as complete.

What the account has accepted before is the signal that tells the two
apart. sync.ts now records the widest window (days before date_to) each
account's bank has answered, on accounts_data as accepted_history_days
(no migration; persisted by the same write-back as dedup_scope). On a
rejected window: no wider than that = the bank is unavailable, stop after
one call; wider = one retry straight at the accepted width, then stop.
Without a record (first sync, legacy rows) the ladder runs as before, but
its exhaustion is now AspspUnavailableError too. The web sync route maps
that to 503 BANK_UNAVAILABLE with copy that says the connection does not
need renewing and leaves the row alone; the agent path keeps the contract
code BANK_SYNC_FAILED but no longer persists renewal advice.

getAllTransactionsWithRaw returns the requested and the effective
date_from plus a narrowed flag; the sync result and the /sync response
carry them (history_from), and the settings toast says from which date
the history is complete when the bank cut the window.

Not done: a per-account backoff for the user-triggered route (the agent
path already has the 15-minute lease from #2165), and using the
envelope's `detail` field (one sample, identical to a width rejection).

Closes #2202

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

* docs(decisions): carry the batch's decision lines (#2237, #2203, #2214) here

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:43:01 +02:00

1313 lines
46 KiB
TypeScript

/**
* Enable Banking API integration for PSD2 bank connections
*
* Documentation: https://enablebanking.com/docs/api/reference/
*
* Flow:
* 1. POST /auth → { url, authorization_id }
* 2. User redirects to URL, authenticates with bank
* 3. Callback receives ?code=XXX&state=YYY
* 4. POST /sessions { code } → { session_id, accounts }
* 5. GET /accounts/{uid}/balances
* 6. GET /accounts/{uid}/transactions
*/
import { getAuthorizationHeader } from './jwt'
import { deriveTransactionLabel } from './transaction-label'
import { FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams'
import { dateFromDaysBefore, historyWindowDays } from './history-window'
// Prefer _PRODUCTION variant; sandbox uses api.tilisy.com, production uses api.enablebanking.com
const ENABLE_BANKING_API_URL =
process.env.ENABLE_BANKING_API_URL_PRODUCTION ||
process.env.ENABLE_BANKING_API_URL ||
'https://api.enablebanking.com'
// Types
export interface ASPSP {
name: string
country: string
logo?: string
bic?: string
beta?: boolean
max_consent_validity?: number
// Enable Banking returns this field as `auth_methods` on the ASPSP object.
auth_methods?: AuthMethod[]
}
export interface AuthMethod {
name: string
title?: string
// How the SCA is performed. Mobile BankID at several Swedish banks is a
// DECOUPLED method; the visible default is often a REDIRECT method.
approach?: 'REDIRECT' | 'DECOUPLED' | 'EMBEDDED'
// When true, Enable Banking only uses this method if it is requested
// explicitly via auth_method (it is not the implicit default).
hidden_method?: boolean
psu_types?: ('personal' | 'business')[]
}
export interface AuthResponse {
url: string
authorization_id: string
}
export interface SessionResponse {
session_id: string
access: {
valid_until: string
}
accounts: AccountInfo[]
aspsp: {
name: string
country: string
}
psu_type: string
/**
* Lifecycle state of the PSD2 session as Enable Banking sees it. Present on
* GET /sessions/{id}; used by probeSessionHealth to detect a consent that
* died bank-side without us attempting a transaction fetch.
*/
status?: string
}
export interface AccountInfo {
uid: string
account_id?: {
iban?: string
bban?: string
other?: string
}
name?: string
product?: string
currency: string
identification_hash?: string
}
export interface Balance {
balance_amount: {
amount: string
currency: string
}
balance_type: string
reference_date?: string
last_change_date_time?: string
}
export interface BalanceResponse {
balances: Balance[]
}
export interface Transaction {
entry_reference?: string
transaction_id?: string
booking_date?: string
value_date?: string
transaction_amount: {
amount: string
currency: string
}
credit_debit_indicator?: 'CRDT' | 'DBIT' // CRDT = credit (income), DBIT = debit (expense)
creditor_name?: string
creditor_account?: {
iban?: string
bban?: string
}
creditor?: {
name?: string
}
debtor_name?: string
debtor_account?: {
iban?: string
bban?: string
}
debtor?: {
name?: string
}
remittance_information?: string[]
merchant_category_code?: string
bank_transaction_code?: string
proprietary_bank_transaction_code?: string
}
export interface TransactionsResponse {
transactions: Transaction[]
continuation_key?: string
}
/**
* Strategy for how Enable Banking fetches transactions from the upstream ASPSP.
* - 'default': fast path, may return only the most recent window even if date_from is older
* - 'longest': fetch the longest available history (up to PSD2 90-day max), slower
*
* When omitted, Enable Banking applies its default strategy.
*/
export type TransactionsFetchStrategy = 'default' | 'longest'
export interface BankTransaction {
id: string
date: string
booking_date: string
amount: number
currency: string
description: string
counterparty_name?: string
counterparty_account?: string
reference?: string
merchant_category_code?: string
// ISO 20022 / proprietary transaction codes: carried through so the
// description fallback can derive a meaningful Swedish label when remittance
// text and a counterparty name are both absent. See deriveTransactionLabel.
bank_transaction_code?: string
proprietary_bank_transaction_code?: string
}
// Constants
const FETCH_TIMEOUT_MS = 15_000
const MAX_RETRIES = 2
const RETRY_DELAY_MS = 1000
const MAX_PAGINATION_PAGES = 100
const DEFAULT_PAGE_SIZE = 500
/**
* Thrown by getAccountTransactions on a non-OK response. Carries the HTTP
* status and raw body so the pagination caller (getAllTransactions) can run the
* same first-page strategy/window fallbacks as getAllTransactionsWithRaw. The
* message is identical to the previous plain Error for back-compat.
*/
class TransactionsFetchError extends Error {
constructor(
readonly status: number,
readonly body: string
) {
super(`Failed to get transactions (${status}): ${body}`)
this.name = 'TransactionsFetchError'
}
}
/**
* Normalized signatures (uppercase, non-alphanumerics stripped) of the
* responses Enable Banking (or the upstream ASPSP via Enable Banking's
* envelope) returns when the PSD2 session can no longer be used: the consent
* was closed, expired, or invalidated bank-side. Spelling and casing vary by
* bank (CLOSED_SESSION, EXPIRED_SESSION, SESSION_EXPIRED / session_expired,
* INVALID_SESSION, SESSION_NOT_FOUND, WRONG_SESSION_STATUS, and the plain
* "Session is closed" message), so we match the whole family. A dead session
* is unrecoverable by retrying: the user must re-authorize.
*/
const SESSION_DEAD_NEEDLES = [
'CLOSEDSESSION', // CLOSED_SESSION
'SESSIONCLOSED', // "session closed"
'SESSIONISCLOSED', // "Session is closed"
'SESSIONEXPIRED', // SESSION_EXPIRED / session_expired
'EXPIREDSESSION', // EXPIRED_SESSION
'INVALIDSESSION', // INVALID_SESSION
'SESSIONNOTFOUND', // SESSION_NOT_FOUND
'WRONGSESSIONSTATUS', // WRONG_SESSION_STATUS
] as const
/**
* Whether a failed transactions response signals a dead PSD2 session (vs. a
* transient error or a config-level auth failure). Only 401/403 with a
* session-expiry signal in the body counts: a bare 401 "Unauthorized" is an
* app-credential problem, not a closed consent, and must NOT be misread as
* "reconnect the bank". The match is deterministic: normalize the body and
* test for any known session-dead needle.
*/
export function isSessionExpiredResponse(status: number, body: string): boolean {
if (status !== 401 && status !== 403) return false
const normalized = body.toUpperCase().replace(/[^A-Z0-9]/g, '')
return SESSION_DEAD_NEEDLES.some(needle => normalized.includes(needle))
}
/**
* User-facing (Swedish) messages persisted to bank_connections.error_message
* and returned to the settings UI. error_message is a literal string in the
* DB, not an i18n key, matching the extension's other user-facing strings.
* Raw Enable Banking error bodies are English JSON envelopes and must never
* land here: they belong in server logs only.
*/
export const REAUTH_REQUIRED_MESSAGE =
'Bankanslutningen har löpt ut. Förnya anslutningen för att fortsätta synka.'
export const SYNC_FAILED_MESSAGE =
'Banksynkningen misslyckades. Försök igen, eller förnya anslutningen om felet kvarstår.'
/**
* The bank is refusing right now and narrowing the window cannot help. Says
* explicitly that the connection does NOT need renewing: a transient
* ASPSP_ERROR used to surface as SYNC_FAILED_MESSAGE, whose "förnya
* anslutningen" advice costs a BankID round trip and fixes nothing (#2202).
*/
export const BANK_UNAVAILABLE_MESSAGE =
'Banken svarade inte just nu (tillfälligt fel hos banken). Försök igen om en stund. Anslutningen behöver inte förnyas.'
/**
* Thrown when a transactions fetch fails because the PSD2 session is dead
* (closed/expired/invalid). Distinct from TransactionsFetchError so the sync
* handler can flip the connection to 'expired' and prompt re-authorization
* instead of surfacing a raw error the user can't act on. Carries the status
* and raw body for logging. See isSessionExpiredResponse for the codes covered.
*/
export class SessionExpiredError extends Error {
constructor(
readonly status: number,
readonly body: string
) {
super(`Bank session expired (${status}): ${body}`)
this.name = 'SessionExpiredError'
}
}
/**
* Thrown when the ASPSP rejected the transactions request and narrowing the
* history window is not the answer (issue #2202):
*
* - 'window-already-accepted': the rejected window is no wider than one this
* account has fetched successfully before (StoredAccount.accepted_history_days),
* so width is not the problem; the bank is refusing for its own reasons.
* - 'ladder-exhausted': every narrower window was refused too.
*
* Either way the sync should say "try again later" and must not flip the
* connection to expired/error or prompt a consent renewal. The message keeps
* the `Failed to get transactions (status)` prefix so existing log matching
* still works; carries the raw body for the server log only.
*/
export class AspspUnavailableError extends Error {
constructor(
readonly status: number,
readonly body: string,
readonly reason: 'window-already-accepted' | 'ladder-exhausted',
readonly dateFrom: string | undefined
) {
super(`Failed to get transactions (${status}), bank unavailable [${reason}]: ${body}`)
this.name = 'AspspUnavailableError'
}
}
// API Helper
async function authenticatedFetch(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
// Connector mode: a self-host with a connector key and no own Enable Banking
// credentials routes every upstream call through the hosted bank proxy. The
// proxy holds the real EB credentials and mints the JWT on its side, so the
// instance sends the connector key as a Bearer token and NEVER calls
// getAuthorizationHeader() (there is no private key to sign with here). When
// this instance has its own EB credentials, or on hosted, bankConnectorMode()
// returns null and the direct path below is byte-identical to before.
const connector = bankConnectorMode()
const url = connector ? `${connector.baseUrl}${endpoint}` : `${ENABLE_BANKING_API_URL}${endpoint}`
const authorization = connector ? `Bearer ${connector.key}` : getAuthorizationHeader()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Authorization': authorization,
'Content-Type': 'application/json',
...options.headers,
},
})
return response
} finally {
clearTimeout(timeout)
}
}
/**
* Retry wrapper for idempotent read operations.
* Retries on 429, 502, 503, 504, and AbortError (timeout).
*/
async function authenticatedFetchWithRetry(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await authenticatedFetch(endpoint, options)
if (attempt < MAX_RETRIES && [429, 502, 503, 504].includes(response.status)) {
// A 429 caused by a DAILY quota cannot clear within the retry window:
// PSD2 unattended consents allow only a handful of balance calls per
// day (observed body: "Consent daily limit 4 is exceeded"), so
// retrying just burns time and duplicates the failure in logs. Read
// the body from a clone so the returned response stays consumable.
if (response.status === 429) {
const body = await response.clone().text().catch(() => '')
if (/daily limit/i.test(body)) {
console.warn(`[enable-banking] 429 daily quota exhausted for ${endpoint}: not retrying`, {
status: response.status,
body,
})
return response
}
}
console.warn(`[enable-banking] Retrying ${endpoint} (attempt ${attempt + 1}/${MAX_RETRIES})`, {
status: response.status,
statusText: response.statusText,
})
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)))
continue
}
return response
} catch (error: unknown) {
const isAbort = error instanceof Error && error.name === 'AbortError'
if (attempt < MAX_RETRIES && isAbort) {
console.warn(`[enable-banking] Request timeout, retrying ${endpoint} (attempt ${attempt + 1}/${MAX_RETRIES})`)
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)))
continue
}
console.error(`[enable-banking] Request failed for ${endpoint}`, {
attempt,
error: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : undefined,
isTimeout: isAbort,
})
throw error
}
}
// Unreachable, but satisfies TypeScript
throw new Error('Max retries exceeded')
}
// API Functions
/**
* Get list of supported banks (ASPSPs) for a country
*/
export async function getASPSPs(country: string = 'SE', psuType?: 'personal' | 'business'): Promise<ASPSP[]> {
const resolvedPsuType = psuType || process.env.ENABLE_BANKING_PSU_TYPE || 'business'
const isSandbox = ENABLE_BANKING_API_URL.includes('tilisy')
const params = new URLSearchParams({
country,
sandbox: String(isSandbox),
psu_type: resolvedPsuType,
})
const response = await authenticatedFetchWithRetry(`/aspsps?${params.toString()}`)
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] getASPSPs failed', {
status: response.status,
statusText: response.statusText,
body,
country,
psuType: resolvedPsuType,
sandbox: isSandbox,
apiUrl: ENABLE_BANKING_API_URL,
})
throw new Error(`Failed to fetch banks (${response.status})`)
}
const data = await response.json()
return data.aspsps || []
}
/**
* Pick the DECOUPLED auth method worth pinning explicitly, or undefined to let
* Enable Banking run the ASPSP's default flow.
*
* A method is only pinned when pinning is both NECESSARY and APPLICABLE:
*
* - hidden_method === true: a hidden method is never used unless requested
* explicitly via auth_method, so pinning is the only way to reach it. That
* is the Handelsbanken case: its Mobile BankID (DECOUPLED) is hidden, and
* without pinning it corporate PSUs fail right after approving in the
* BankID app ("fel efter BankID"). A VISIBLE decoupled method is already
* part of the bank's own default flow; force-pinning it overrides a working
* default. That regression (from PR #854) broke Lunar-class banks: the user
* typed their personnummer on Enable Banking's page, was told to approve in
* the bank's app, and no approval request ever arrived.
* - psu_types, when present and non-empty, must include the PSU type we are
* authorizing as: a method scoped to 'personal' must never be pinned for a
* 'business' consent (and vice versa). A missing or empty psu_types means
* the method applies to all PSU types.
*/
export function selectPreferredAuthMethod(
authMethods: AuthMethod[] | undefined,
psuType: 'personal' | 'business'
): AuthMethod | undefined {
return authMethods?.find(
(m) =>
m.approach === 'DECOUPLED' &&
m.hidden_method === true &&
(!m.psu_types || m.psu_types.length === 0 || m.psu_types.includes(psuType))
)
}
/**
* Resolve the auth method to pin for a given bank, with full metadata so the
* caller can log approach/hidden_method/psu_types, or undefined to let Enable
* Banking use the ASPSP's default flow. Selection rules live in
* selectPreferredAuthMethod. Returns undefined on lookup failure so banks
* that already work are untouched.
*/
export async function getPreferredAuthMethodDetails(
aspspName: string,
country: string,
psuType: 'personal' | 'business'
): Promise<AuthMethod | undefined> {
try {
const aspsps = await getASPSPs(country, psuType)
const aspsp = aspsps.find((a) => a.name === aspspName)
return selectPreferredAuthMethod(aspsp?.auth_methods, psuType)
} catch (error) {
console.error('[enable-banking] getPreferredAuthMethod failed; using ASPSP default', {
aspspName,
country,
psuType,
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}
/**
* Name-only convenience wrapper around getPreferredAuthMethodDetails: the
* value to send as auth_method on POST /auth, or undefined for the ASPSP
* default.
*/
export async function getPreferredAuthMethod(
aspspName: string,
country: string,
psuType: 'personal' | 'business'
): Promise<string | undefined> {
const method = await getPreferredAuthMethodDetails(aspspName, country, psuType)
return method?.name
}
/**
* Start bank authorization flow
*
* @param aspspName - The name of the ASPSP (bank) exactly as returned from /aspsps
* @param aspspCountry - The country code (e.g., 'SE')
* @param redirectUrl - URL to redirect user after bank authorization
* @param state - State parameter returned in callback (e.g., user ID)
* @param psuType - Type of user: 'personal' or 'business'
* @param authMethod - Optional Enable Banking auth_method name. When omitted,
* Enable Banking uses the ASPSP's visible default method. See
* getPreferredAuthMethod for why we pin Mobile BankID at some banks.
*/
export async function startAuthorization(
aspspName: string,
aspspCountry: string,
redirectUrl: string,
state: string,
psuType: 'personal' | 'business' = 'personal',
authMethod?: string,
companyId?: string
): Promise<AuthResponse> {
// Calculate consent validity (90 days)
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 90)
const requestBody: {
access: { valid_until: string }
aspsp: { name: string; country: string }
state: string
redirect_url: string
psu_type: 'personal' | 'business'
auth_method?: string
} = {
access: {
valid_until: validUntil.toISOString()
},
aspsp: {
name: aspspName,
country: aspspCountry
},
state,
redirect_url: redirectUrl,
psu_type: psuType
}
if (authMethod) {
requestBody.auth_method = authMethod
}
// In connector mode the hosted bank proxy meters the per-company connection
// quota, so it needs to know which company this authorization is for. This is
// gated on bankConnectorMode(), NOT merely on companyId: on hosted and on
// own-credentials self-hosts companyId is always set, and sending an internal
// company UUID to the real Enable Banking API is both a needless behavior
// change and an identifier leak to a third-party processor. Off the connector
// path the direct request stays byte-identical.
const authHeaders =
companyId && bankConnectorMode() ? { [CONNECTOR_COMPANY_HEADER]: companyId } : undefined
const response = await authenticatedFetch('/auth', {
method: 'POST',
body: JSON.stringify(requestBody),
...(authHeaders ? { headers: authHeaders } : {}),
})
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] startAuthorization failed', {
status: response.status,
statusText: response.statusText,
body,
aspspName,
aspspCountry,
psuType,
redirectUrl,
apiUrl: ENABLE_BANKING_API_URL,
requestBody: JSON.stringify(requestBody),
})
throw new Error(`Failed to start bank connection (${response.status}): ${body}`)
}
return response.json()
}
/**
* Create a session after user completes bank authorization
*
* @param code - The authorization code from callback
* @param connectorState - The signed connector state echoed back through the
* hosted callback in connector mode. The bank proxy binds the /sessions
* exchange to the pending row it signed at /auth time (single-use, race-safe),
* so it is required in connector mode and absent on the direct path.
*/
export async function createSession(code: string, connectorState?: string): Promise<SessionResponse> {
const response = await authenticatedFetch('/sessions', {
method: 'POST',
body: JSON.stringify(connectorState ? { code, connector_state: connectorState } : { code })
})
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] createSession failed', {
status: response.status,
statusText: response.statusText,
body,
hasCode: !!code,
codeLength: code?.length,
apiUrl: ENABLE_BANKING_API_URL,
})
throw new Error(`Failed to create bank session (${response.status}): ${body}`)
}
return response.json()
}
/**
* Get session details
*
* @param sessionId - The session ID
*/
export async function getSession(sessionId: string): Promise<SessionResponse> {
const response = await authenticatedFetchWithRetry(`/sessions/${sessionId}`)
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] getSession failed', {
status: response.status,
statusText: response.statusText,
body,
sessionId,
})
throw new Error(`Failed to get session (${response.status}): ${body}`)
}
return response.json()
}
/**
* Session lifecycle values that mean the consent can no longer be used. Kept
* deliberately narrow: an unrecognized status resolves to 'unknown' and leaves
* the stored connection state untouched, because wrongly flipping a live
* connection to 'expired' costs the user a full BankID re-authorization.
*/
const SESSION_DEAD_STATUSES = new Set([
'CANCELLED',
'CLOSED',
'EXPIRED',
'INVALID',
'REJECTED',
'REVOKED',
])
/**
* Session lifecycle values that mean the consent is usable. Anything outside
* both sets (including a response carrying no status at all) is 'unknown':
* claiming 'alive' for a value we do not recognize would be asserting more
* than the probe actually established.
*/
const SESSION_ALIVE_STATUSES = new Set(['AUTHORIZED', 'VALID', 'ACTIVE'])
export type SessionHealth = 'alive' | 'dead' | 'unknown'
/**
* Ask Enable Banking whether a PSD2 session is still usable, without fetching
* any account data.
*
* Until this existed, a connection only ever learned its session was dead by
* TRYING to sync: a bank that invalidates a consent server-side (several
* ASPSPs drop the previous session when the same PSU authorizes again) left
* the row sitting at status 'active' with a stale last_synced_at, so the UI
* kept presenting old balances as current. Never throws: probing is a
* best-effort health signal, and 'unknown' is always a safe answer.
*/
export async function probeSessionHealth(sessionId: string): Promise<SessionHealth> {
try {
const response = await authenticatedFetchWithRetry(`/sessions/${sessionId}`)
const body = await response.text()
if (!response.ok) {
if (isSessionExpiredResponse(response.status, body)) return 'dead'
// The session record itself is gone: nothing left to sync with.
if (response.status === 404) return 'dead'
return 'unknown'
}
try {
const parsed = JSON.parse(body) as SessionResponse
const status = typeof parsed.status === 'string' ? parsed.status.toUpperCase() : null
if (status && SESSION_DEAD_STATUSES.has(status)) return 'dead'
if (status && SESSION_ALIVE_STATUSES.has(status)) return 'alive'
} catch {
// Unparseable body on a 200: treat as inconclusive, not as dead.
return 'unknown'
}
return 'unknown'
} catch (error) {
console.warn('[enable-banking] probeSessionHealth failed', {
sessionId,
error: error instanceof Error ? error.message : String(error),
})
return 'unknown'
}
}
/**
* Delete/revoke a session
*
* @param sessionId - The session ID to revoke
*/
export async function deleteSession(sessionId: string): Promise<void> {
const response = await authenticatedFetch(`/sessions/${sessionId}`, {
method: 'DELETE'
})
if (!response.ok) {
const body = await response.text()
// A session Enable Banking has already dropped (404, or a 401/403 naming
// an expired/closed session) is the expected answer when we revoke a
// connection whose PSD2 consent ran out: every caller catches and carries
// on, so it does not belong in the error panel.
const alreadyGone = response.status === 404 || isSessionExpiredResponse(response.status, body)
const logLine = {
status: response.status,
statusText: response.statusText,
body,
sessionId,
}
if (alreadyGone) {
console.warn('[enable-banking] deleteSession: session already gone at Enable Banking', logLine)
} else {
console.error('[enable-banking] deleteSession failed', logLine)
}
throw new Error(`Failed to revoke session (${response.status}): ${body}`)
}
}
/**
* Get account balances
*
* @param accountUid - The account UID (from session.accounts[].uid)
*/
export async function getAccountBalances(accountUid: string): Promise<Balance[]> {
const response = await authenticatedFetchWithRetry(`/accounts/${accountUid}/balances`)
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] getAccountBalances failed', {
status: response.status,
statusText: response.statusText,
body,
accountUid,
})
throw new Error(`Failed to get account balances (${response.status}): ${body}`)
}
const data: BalanceResponse = await response.json()
return data.balances || []
}
// Balance-type preference orders. ASPSPs report types either as camelCase
// names or ISO 20022 codes; both spellings of each type are accepted,
// case-insensitively. Booked answers "what has the bank settled", available
// answers "what can be spent right now" (the covering-decision number).
// closingBooked (settled, definitive) wins over expected, which wins over
// interimBooked (intraday booked): fall through to less-final booked types
// only when the stabler one is absent, and to the generic first-entry
// fallback only when no booked type exists at all.
const BOOKED_BALANCE_TYPES = ['closingbooked', 'clbd', 'expected', 'xpcd', 'interimbooked', 'itbd']
const AVAILABLE_BALANCE_TYPES = [
'interimavailable',
'itav',
'closingavailable',
'clav',
'forwardavailable',
'fwav',
]
function pickBalanceByType(balances: Balance[], preference: string[]): Balance | undefined {
for (const type of preference) {
const match = balances.find(b => b.balance_type?.toLowerCase() === type)
if (match) return match
}
return undefined
}
/**
* Get account balance from one BALANCES call: the booked amount (falling back
* to the first reported balance, as before) plus the available amount when the
* ASPSP reports one. One call: both figures come from the same quota-limited
* response, so exposing `available` costs nothing extra.
*
* Returns null when the ASPSP reports NO balances at all. The old behavior
* fabricated `amount: 0` here; once balances became user-facing ("how much
* money is in the bank", covering decisions before payment runs) a fabricated
* zero with a fresh timestamp is dangerous, so the caller keeps its previous
* stored value instead.
*/
export async function getAccountBalance(
accountUid: string
): Promise<{ amount: number; date: string; available: number | null } | null> {
const balances = await getAccountBalances(accountUid)
// Prefer closingBooked, then expected, then first available
const balance = pickBalanceByType(balances, BOOKED_BALANCE_TYPES) || balances[0]
if (!balance) {
return null
}
const availableBalance = pickBalanceByType(balances, AVAILABLE_BALANCE_TYPES)
const available = availableBalance
? parseFloat(availableBalance.balance_amount.amount)
: null
return {
amount: parseFloat(balance.balance_amount.amount),
date: balance.reference_date || new Date().toISOString().split('T')[0],
available: available != null && Number.isFinite(available) ? available : null,
}
}
/**
* Get account transactions
*
* @param accountUid - The account UID
* @param dateFrom - Start date (YYYY-MM-DD)
* @param dateTo - End date (YYYY-MM-DD)
* @param continuationKey - Pagination key from previous response
*/
export async function getAccountTransactions(
accountUid: string,
dateFrom?: string,
dateTo?: string,
continuationKey?: string,
strategy?: TransactionsFetchStrategy
): Promise<TransactionsResponse> {
const params = new URLSearchParams()
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (continuationKey) params.set('continuation_key', continuationKey)
if (strategy) params.set('strategy', strategy)
params.set('limit', String(DEFAULT_PAGE_SIZE))
const queryString = params.toString()
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
const response = await authenticatedFetchWithRetry(endpoint)
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] getAccountTransactions failed', {
status: response.status,
statusText: response.statusText,
body,
accountUid,
dateFrom,
dateTo,
strategy,
hasContinuationKey: !!continuationKey,
})
if (isSessionExpiredResponse(response.status, body)) {
throw new SessionExpiredError(response.status, body)
}
throw new TransactionsFetchError(response.status, body)
}
return response.json()
}
/**
* Get all transactions with pagination
*/
export async function getAllTransactions(
accountUid: string,
dateFrom?: string,
dateTo?: string,
strategy?: TransactionsFetchStrategy,
options?: HistoryWindowOptions
): Promise<Transaction[]> {
const allTransactions: Transaction[] = []
let continuationKey: string | undefined
let page = 0
let activeStrategy = strategy
// date_from is narrowed in place when the ASPSP rejects the window (below).
let activeDateFrom = dateFrom
while (true) {
let response: TransactionsResponse
try {
response = await getAccountTransactions(
accountUid,
activeDateFrom,
dateTo,
continuationKey,
activeStrategy
)
} catch (err) {
// Apply the same first-page recovery as getAllTransactionsWithRaw. Only
// TransactionsFetchError carries the status/body needed to decide;
// network errors and the like propagate untouched.
if (err instanceof TransactionsFetchError) {
const recovery = planFirstPageRecovery({
status: err.status,
body: err.body,
page,
hasContinuationKey: !!continuationKey,
activeStrategy,
activeDateFrom,
dateTo,
acceptedHistoryDays: options?.acceptedHistoryDays,
})
if (recovery.type === 'drop-strategy') {
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
accountUid,
strategy: activeStrategy,
body: err.body,
})
activeStrategy = undefined
continue
}
if (recovery.type === 'narrow') {
console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', {
accountUid,
previousDateFrom: activeDateFrom,
nextDateFrom: recovery.dateFrom,
dateTo,
body: err.body,
})
activeDateFrom = recovery.dateFrom
continue
}
if (recovery.type === 'aspsp-unavailable') {
throw bankUnavailable(accountUid, err.status, err.body, recovery.reason, activeDateFrom, dateTo)
}
}
throw err
}
allTransactions.push(...response.transactions)
continuationKey = response.continuation_key
page++
if (page >= MAX_PAGINATION_PAGES) {
console.warn(`[enable-banking] Pagination cap reached (${MAX_PAGINATION_PAGES} pages) for account ${accountUid}`)
break
}
if (!continuationKey) break
}
return allTransactions
}
/**
* Lookback windows (days before date_to) we step through when an ASPSP rejects
* the requested transaction history. Descending so each fallback yields a
* strictly narrower window. PSD2 obliges banks to ~90 days without fresh SCA;
* the smaller rungs cover banks that cap below that.
*/
const ASPSP_HISTORY_FALLBACK_DAYS = [90, 60, 30] as const
/**
* Per-account knowledge the caller can hand the pagination loops (#2202).
*/
export interface HistoryWindowOptions {
/**
* Widest window (whole days before date_to) this account's bank has
* accepted before: StoredAccount.accepted_history_days. When set, a
* rejected window that is no wider than this is reported as the bank being
* unavailable (AspspUnavailableError) instead of narrowed, and a wider
* rejected window jumps straight to this width instead of walking the
* 90/60/30 ladder. Unset (first sync, legacy rows): the ladder runs as
* before.
*/
acceptedHistoryDays?: number
}
/**
* Enable Banking wraps upstream-bank failures in a generic envelope, e.g.
* {"code":400,"message":"Error interacting with ASPSP","error":"ASPSP_ERROR"}.
* The envelope is the SAME for a history window beyond the bank's PSD2 limit
* and for a bank that is refusing right now (maintenance, throttling, an
* upstream error): the one sample of a transient failure on record carried
* "detail":"Unknown error", exactly like a window rejection does. So the
* string alone cannot tell the two apart; planFirstPageRecovery uses what the
* account has accepted before to decide (issue #2202).
*/
function isAspspError(body: string): boolean {
return body.includes('ASPSP_ERROR') || body.includes('interacting with ASPSP')
}
/**
* Given the date_from we just tried, return the next strictly-narrower
* date_from from ASPSP_HISTORY_FALLBACK_DAYS, anchored to date_to. Returns
* undefined when no narrower window remains (or date_to is missing), which
* ends the retry loop. The "strictly narrower" guard keeps the loop monotonic
* and terminating even if the original window was already short.
*/
function nextNarrowerDateFrom(
currentDateFrom: string | undefined,
dateTo: string | undefined
): string | undefined {
if (!dateTo) return undefined
const anchor = new Date(`${dateTo}T00:00:00Z`)
if (!Number.isFinite(anchor.getTime())) return undefined
for (const days of ASPSP_HISTORY_FALLBACK_DAYS) {
const candidate = new Date(anchor.getTime() - days * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
if (!currentDateFrom || candidate > currentDateFrom) {
return candidate
}
}
return undefined
}
/**
* First-page recovery policy shared by getAllTransactions and
* getAllTransactionsWithRaw, so the two pagination loops can't drift. Fallbacks
* apply only to the very first request (page 0, no continuation_key): a
* continuation_key is scoped to the window/strategy that produced it, so the
* query is never rewritten mid-pagination.
*
* - 'drop-strategy' : an unsupported strategy enum: retry the same window.
* - 'narrow' : the ASPSP rejected the history window: retry with a
* narrower date_from (the bank caps history below the
* ask). With a known accepted width the retry jumps
* straight to it; without one it steps down the
* 90/60/30 ladder.
* - 'aspsp-unavailable' : the ASPSP rejected a window it has accepted before,
* or every narrower window too: width is not the
* problem, the bank is refusing right now. The caller
* throws AspspUnavailableError (#2202).
* - 'give-up' : nothing left to try; the caller should rethrow.
*/
type FirstPageRecovery =
| { type: 'drop-strategy' }
| { type: 'narrow'; dateFrom: string }
| { type: 'aspsp-unavailable'; reason: AspspUnavailableError['reason'] }
| { type: 'give-up' }
function planFirstPageRecovery(args: {
status: number
body: string
page: number
hasContinuationKey: boolean
activeStrategy: TransactionsFetchStrategy | undefined
activeDateFrom: string | undefined
dateTo: string | undefined
acceptedHistoryDays: number | undefined
}): FirstPageRecovery {
const {
status,
body,
page,
hasContinuationKey,
activeStrategy,
activeDateFrom,
dateTo,
acceptedHistoryDays,
} = args
if (status !== 400 || page !== 0 || hasContinuationKey) return { type: 'give-up' }
// Drop an unsupported strategy first: preserves the full requested window.
if (activeStrategy) return { type: 'drop-strategy' }
if (!isAspspError(body)) return { type: 'give-up' }
// A window this account has fetched before is the strongest signal on hand
// that width is not the problem: stop after this one call. Wider than that:
// retry once at the known-good width, which is the cheapest test with the
// best odds, instead of spending three rungs on a bank that is saying no.
const requestedDays = historyWindowDays(activeDateFrom, dateTo)
if (acceptedHistoryDays !== undefined && requestedDays !== undefined) {
if (requestedDays <= acceptedHistoryDays) {
return { type: 'aspsp-unavailable', reason: 'window-already-accepted' }
}
const dateFrom = dateFromDaysBefore(dateTo, acceptedHistoryDays)
if (dateFrom) return { type: 'narrow', dateFrom }
}
// No accepted width on record (first sync, legacy rows): step date_from
// toward date_to (e.g. Danske past ~90 days) so a partial sync survives.
const dateFrom = nextNarrowerDateFrom(activeDateFrom, dateTo)
if (dateFrom) return { type: 'narrow', dateFrom }
return { type: 'aspsp-unavailable', reason: 'ladder-exhausted' }
}
/** Log and build the error both pagination loops throw on 'aspsp-unavailable'. */
function bankUnavailable(
accountUid: string,
status: number,
body: string,
reason: AspspUnavailableError['reason'],
activeDateFrom: string | undefined,
dateTo: string | undefined
): AspspUnavailableError {
console.warn('[enable-banking] ASPSP refused a request that narrowing cannot fix; treating the bank as unavailable', {
accountUid,
reason,
dateFrom: activeDateFrom,
dateTo,
status,
body,
})
return new AspspUnavailableError(status, body, reason, activeDateFrom)
}
/**
* Get all transactions with raw JSON responses for archival.
* Returns both parsed transactions and the raw response strings.
*
* If `strategy` is provided and the API rejects it with a 400 on the first
* request, retry once without `strategy` so unknown enum values can't break
* the sync. Logs a warning when the fallback fires.
*
* If the ASPSP then still rejects the first page with an ASPSP_ERROR (typically
* a history window beyond the bank's PSD2 limit, e.g. Danske past ~90 days),
* progressively narrow date_from toward date_to (90→60→30 days, or straight to
* options.acceptedHistoryDays when the account has one) so a partial sync of
* the recent window survives instead of failing outright. Logs a warning on
* each narrowing. A rejection that narrowing cannot fix throws
* AspspUnavailableError (see planFirstPageRecovery).
*
* The result names the date_from the bank finally ANSWERED (effectiveDateFrom)
* next to the one that was asked for, so a truncated window is visible to the
* caller instead of looking like a complete sync (#2202).
*/
export interface TransactionsWithRaw {
transactions: Transaction[]
rawPages: string[]
/** The date_from the caller asked for. */
requestedDateFrom: string | undefined
/** The date_from the bank answered: equal to requestedDateFrom unless narrowed. */
effectiveDateFrom: string | undefined
/** True when the bank refused the requested window and a narrower one was used. */
narrowed: boolean
}
export async function getAllTransactionsWithRaw(
accountUid: string,
dateFrom?: string,
dateTo?: string,
strategy?: TransactionsFetchStrategy,
options?: HistoryWindowOptions
): Promise<TransactionsWithRaw> {
const allTransactions: Transaction[] = []
const rawPages: string[] = []
let continuationKey: string | undefined
let page = 0
let activeStrategy = strategy
// date_from is narrowed in place when the ASPSP rejects the window (below).
let activeDateFrom = dateFrom
while (true) {
const params = new URLSearchParams()
if (activeDateFrom) params.set('date_from', activeDateFrom)
if (dateTo) params.set('date_to', dateTo)
if (continuationKey) params.set('continuation_key', continuationKey)
if (activeStrategy) params.set('strategy', activeStrategy)
params.set('limit', String(DEFAULT_PAGE_SIZE))
const queryString = params.toString()
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
const response = await authenticatedFetchWithRetry(endpoint)
if (!response.ok) {
const body = await response.text()
const recovery = planFirstPageRecovery({
status: response.status,
body,
page,
hasContinuationKey: !!continuationKey,
activeStrategy,
activeDateFrom,
dateTo,
acceptedHistoryDays: options?.acceptedHistoryDays,
})
if (recovery.type === 'drop-strategy') {
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
accountUid,
strategy: activeStrategy,
body,
})
activeStrategy = undefined
continue
}
if (recovery.type === 'narrow') {
console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', {
accountUid,
previousDateFrom: activeDateFrom,
nextDateFrom: recovery.dateFrom,
dateTo,
body,
})
activeDateFrom = recovery.dateFrom
continue
}
if (recovery.type === 'aspsp-unavailable') {
throw bankUnavailable(accountUid, response.status, body, recovery.reason, activeDateFrom, dateTo)
}
// An expired PSD2 session is an expected end of life for a consent, not
// a failure: SessionExpiredError below flips the connection to 'expired'
// and asks the user to re-authorize. Log it at warn so only the genuine
// ASPSP/upstream failures reach the error panel.
const sessionExpired = isSessionExpiredResponse(response.status, body)
const logLine = {
status: response.status,
statusText: response.statusText,
body,
accountUid,
dateFrom: activeDateFrom,
dateTo,
strategy: activeStrategy,
page,
hasContinuationKey: !!continuationKey,
}
if (sessionExpired) {
console.warn('[enable-banking] getAllTransactionsWithRaw: bank session expired', logLine)
throw new SessionExpiredError(response.status, body)
}
console.error('[enable-banking] getAllTransactionsWithRaw failed', logLine)
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
}
const rawText = await response.text()
rawPages.push(rawText)
const data: TransactionsResponse = JSON.parse(rawText)
allTransactions.push(...data.transactions)
continuationKey = data.continuation_key
page++
if (page >= MAX_PAGINATION_PAGES) {
console.warn(`[enable-banking] Pagination cap reached (${MAX_PAGINATION_PAGES} pages) for account ${accountUid}`)
break
}
if (!continuationKey) break
}
return {
transactions: allTransactions,
rawPages,
requestedDateFrom: dateFrom,
effectiveDateFrom: activeDateFrom,
narrowed: activeDateFrom !== dateFrom,
}
}
/**
* Convert Enable Banking transaction to legacy format
*/
export function convertTransaction(tx: Transaction, accountCurrency: string): BankTransaction {
const rawAmount = parseFloat(tx.transaction_amount.amount)
// Use credit_debit_indicator to determine sign
// CRDT = credit (money in) = positive
// DBIT = debit (money out) = negative
const isCredit = tx.credit_debit_indicator === 'CRDT'
const amount = isCredit ? Math.abs(rawAmount) : -Math.abs(rawAmount)
// Get counterparty name from creditor/debtor objects or direct fields
const creditorName = tx.creditor?.name || tx.creditor_name
const debtorName = tx.debtor?.name || tx.debtor_name
return {
id: tx.entry_reference || tx.transaction_id || `${tx.booking_date}_${rawAmount}`,
date: tx.value_date || tx.booking_date || new Date().toISOString().split('T')[0],
booking_date: tx.booking_date || tx.value_date || new Date().toISOString().split('T')[0],
amount,
currency: tx.transaction_amount.currency || accountCurrency,
// Fallback chain: bank's payment message → counterparty name → a Swedish
// label derived from the ISO 20022 / MCC codes the bank DID send (card
// purchases, ATM, fees, interest) → 'Okänd transaktion'. The final fallback
// is also normalized at the ingest boundary, so any leftover lands as the
// same Swedish neutral.
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
(isCredit ? debtorName : creditorName) ||
deriveTransactionLabel({
bankTransactionCode: tx.bank_transaction_code,
proprietaryBankTransactionCode: tx.proprietary_bank_transaction_code,
mcc: tx.merchant_category_code,
isCredit,
}) ||
FALLBACK_DESCRIPTION,
counterparty_name: isCredit ? debtorName : creditorName,
counterparty_account: isCredit
? tx.debtor_account?.iban || tx.debtor_account?.bban
: tx.creditor_account?.iban || tx.creditor_account?.bban,
merchant_category_code: tx.merchant_category_code,
bank_transaction_code: tx.bank_transaction_code,
proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code,
}
}
/**
* Whether the current configuration targets the sandbox API
*/
export function isSandboxMode(): boolean {
return ENABLE_BANKING_API_URL.includes('tilisy')
}
// Utility functions
/**
* Check if consent is expiring soon (within 7 days)
*/
export function isConsentExpiringSoon(expiresAt: string | null): boolean {
if (!expiresAt) return false
const expiryDate = new Date(expiresAt)
const warningDate = new Date()
warningDate.setDate(warningDate.getDate() + 7)
return expiryDate <= warningDate
}
/**
* Get days until consent expires
*/
export function getDaysUntilExpiry(expiresAt: string | null): number | null {
if (!expiresAt) return null
const expiryDate = new Date(expiresAt)
const now = new Date()
const diffTime = expiryDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
}