Files
accounted/extensions/general/enable-banking/lib/sync.ts
T
MattssonandClaude Fable 5.1 1e1e1d5d17 fix(enable-banking): connector-hop failures no longer park a connection in error (#2296)
* fix(enable-banking): connector-hop failures no longer park a connection in error

On 2026-09-04 the daily cron flipped four canary companies (bank sync routed
through Accounted Connect, PR #2205) to 'error' with "Banksynkningen
misslyckades ... forny anslutningen" because the service answered 200 with a
body that fails bankSyncResponseSchema. The PSD2 sessions were fine; users
re-authorized Danske, SEB and Revolut for nothing, and the bare "unexpected
shape" message left the contract mismatch undiagnosable.

- New ConnectorSyncError (status, code, body, Zod issue paths) thrown for
  every connector-hop failure: transport, timeout, error envelope other than
  a dead session, and wire-contract mismatch. The failing field paths are
  logged at the throw site.
- Cron: AspspUnavailableError and ConnectorSyncError are transient. The row
  is left untouched (no status flip, no renewal advice) and logged at warn
  level; the health probe on the same run still catches a dead session.
- Manual sync (POST /sync) and agent sync (triggerConnectionSync) answer
  retryable with CONNECTOR_UNAVAILABLE_MESSAGE, which says explicitly that
  the connection does not need renewing.

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

* fix(enable-banking): keep the connector response body out of the sync log line

Superagent P2 and CodeRabbit: POST /sync logged up to 500 chars of the raw
connector body next to user and connection ids. A connector response can
carry transaction and personal data, so the log line now carries only code,
status, the Zod issue paths and the body length. The BAD_SHAPE message no
longer falls back to the body either.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 00:28:27 +02:00

480 lines
20 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import {
getAllTransactionsWithRaw,
convertTransaction,
getAccountBalance,
SessionExpiredError,
ConnectorSyncError,
} from './api-client'
import { historyWindowDays } from './history-window'
import { bankSyncResponseSchema, connectorErrorSchema } from '@accounted/connect-contract'
import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest'
import { buildStableExternalIds, FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
import type { RawTransaction, IngestResult, IngestOptions } from '@/types'
import type { StoredAccount, TransactionsFetchStrategy } from '../types'
/** Ingest function signature: matches lib/transactions/ingest */
export type IngestFn = (
supabase: SupabaseClient,
companyId: string,
userId: string,
raw: RawTransaction[],
options?: IngestOptions
) => Promise<IngestResult>
export interface SyncOptions {
/** Skip auto-categorization during ingestion (e.g. SIE overlap) */
skipAutoCategorization?: boolean
/** Only INSERT + dedup, no matching/categorization (viewer imports) */
rawInsertOnly?: boolean
/**
* Fetch strategy passed to Enable Banking. 'longest' instructs the upstream
* to fetch the deepest available history (slower); omit for incremental syncs.
*/
strategy?: TransactionsFetchStrategy
}
/**
* How long a stored account balance stays fresh before a sync refreshes it.
* PSD2 unattended consents allow only 4 balance calls per account per day
* (observed: 429 "Consent daily limit 4 is exceeded"), while transaction
* fetches are budgeted separately. 12h keeps at most 2 balance calls per day
* regardless of how many manual "Synka nu" clicks or cron runs happen.
*/
const BALANCE_MAX_AGE_MS = 12 * 60 * 60 * 1000
export interface SyncResult {
imported: number
duplicates: number
errors: number
/** Earliest booking date the ASPSP returned. Undefined when no transactions came back. */
returnedMinBookingDate?: string
/** Latest booking date the ASPSP returned. Undefined when no transactions came back. */
returnedMaxBookingDate?: string
/** The date_from asked of the bank. */
requestedFromDate: string
/**
* The date_from the bank actually answered. Equal to requestedFromDate
* unless the bank refused the window and a narrower one was used; the sync
* is then complete only from this date on (#2202). Undefined through the
* hosted connector, which does not report it.
*/
effectiveFromDate?: string
/** True when the bank refused the requested window and the history was cut. */
historyNarrowed: boolean
}
/** The subset of a converted bank transaction that the ingest mapping below reads. */
interface BookedTransactionFields {
amount: number
currency: string
description: string
counterparty_name?: string
counterparty_account?: string
reference?: string
merchant_category_code?: string
bank_transaction_code?: string
proprietary_bank_transaction_code?: string
}
const CONNECTOR_SYNC_TIMEOUT_MS = 120_000
/**
* Booked transactions through the hosted connector. The session id is the
* installation's own (it stays here); the service proves ownership from its
* ledger, does the provider work, and answers with the contract's response
* shape. A 410 means the consent is over and maps onto the same
* SessionExpiredError the direct path throws, so callers flip the connection
* to expired identically.
*/
async function fetchBookedViaConnector(
connector: { baseUrl: string; key: string },
args: {
supabase: SupabaseClient
companyId: string
connectionId: string
account: StoredAccount
fromDate: string
toDate: string
strategy?: TransactionsFetchStrategy
},
): Promise<ReturnType<typeof bankSyncResponseSchema.parse>> {
// The PSD2 session id rests on the connection row (never on the account
// payload), and it stays on this installation: the service only receives
// it per call and proves ownership from its own ledger.
const { data: row, error } = await args.supabase
.from('bank_connections')
.select('session_id')
.eq('id', args.connectionId)
.maybeSingle()
if (error) throw new Error(`Connector bank sync could not read the connection: ${error.message}`)
const sessionId = (row as { session_id: string | null } | null)?.session_id
if (!sessionId) throw new Error('Connector bank sync requires a connection with a session id')
// The body is read INSIDE the timeout window: a service that sends headers
// and then stalls the body must not hold the sync open past the budget.
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), CONNECTOR_SYNC_TIMEOUT_MS)
let response: Response
let text: string
try {
try {
response = await fetch(`${connector.baseUrl}/sync`, {
method: 'POST',
signal: controller.signal,
redirect: 'error',
headers: {
Authorization: `Bearer ${connector.key}`,
'Content-Type': 'application/json',
Accept: 'application/json',
[CONNECTOR_COMPANY_HEADER]: args.companyId,
},
body: JSON.stringify({
session_id: sessionId,
account_uid: args.account.uid,
account_currency: args.account.currency,
date_from: args.fromDate,
date_to: args.toDate,
...(args.strategy ? { strategy: args.strategy } : {}),
}),
})
text = await response.text()
} catch (err) {
// Transport failure or the abort above: the connector hop failed, the
// PSD2 session is untouched. Never a status flip (ConnectorSyncError).
const aborted = err instanceof Error && err.name === 'AbortError'
throw new ConnectorSyncError(
null,
aborted ? 'CONNECTOR_TIMEOUT' : 'CONNECTOR_TRANSPORT',
err instanceof Error ? err.message : String(err),
)
}
} finally {
clearTimeout(timeout)
}
let json: unknown = null
try {
json = text ? JSON.parse(text) : null
} catch {
json = null
}
if (!response.ok) {
const envelope = connectorErrorSchema.safeParse(json)
const code = envelope.success ? envelope.data.code : `HTTP_${response.status}`
if (response.status === 410 || code === 'CONNECTOR_BANK_SESSION_EXPIRED') {
throw new SessionExpiredError(response.status, text)
}
throw new ConnectorSyncError(response.status, code, text.slice(0, 500))
}
const parsed = bankSyncResponseSchema.safeParse(json)
if (!parsed.success) {
// The field paths are the only thing that lets the service side be fixed:
// a bare "unexpected shape" left the 2026-09-04 canary failure undiagnosable.
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
console.warn('[enable-banking] Connector sync response failed the wire contract', {
connectionId: args.connectionId,
accountUid: args.account.uid,
status: response.status,
issues,
})
throw new ConnectorSyncError(response.status, 'CONNECTOR_BAD_SHAPE', text.slice(0, 500), issues)
}
return parsed.data
}
/**
* Sync transactions for a single bank account via Enable Banking PSD2.
*
* Fetches transactions from the Enable Banking API, converts to RawTransaction
* format, and delegates to the shared ingestion pipeline. Raw API responses
* are archived as räkenskapsinformation per BFL 7 kap.
*
* @param ingest - Optional ingest function override (defaults to core ingestTransactions).
* When called from an extension handler with ctx.services.ingestTransactions,
* pass that function to avoid direct @/lib imports.
*/
export async function syncAccountTransactions(
supabase: SupabaseClient,
companyId: string,
userId: string,
connectionId: string,
account: StoredAccount,
fromDate: string,
toDate: string,
ingest: IngestFn = defaultIngest,
syncOptions?: SyncOptions
): Promise<SyncResult> {
console.log('[enable-banking] syncAccountTransactions starting', {
connectionId,
accountUid: account.uid,
accountIban: account.iban,
fromDate,
toDate,
strategy: syncOptions?.strategy,
})
// Two ways to obtain booked transactions. Direct: this installation's own
// Enable Banking credentials, exactly as before. Connector: the hosted
// service does the paging, the booked-only filter and the normalization
// (POST /api/connect/bank/sync) and returns rows plus the raw pages. Both
// paths converge on bookedEntries + rawPages, and everything below
// (external ids, ingest, archive, balance) is shared, so a company moved to
// the connector produces byte-identical stored keys.
const connector = bankConnectorMode(companyId)
let rawPages: string[]
let bookedEntries: Array<{ tx: BookedTransactionFields; bookingDate: string }>
let totalFetched: number
let effectiveFromDate: string | undefined
let historyNarrowed = false
if (connector) {
const remote = await fetchBookedViaConnector(connector, {
supabase,
companyId,
connectionId,
account,
fromDate,
toDate,
strategy: syncOptions?.strategy,
})
rawPages = remote.raw_pages
totalFetched = remote.transactions.length + remote.skipped_pending
bookedEntries = remote.transactions.map((tx) => ({
tx: {
amount: tx.amount,
currency: tx.currency,
description: tx.description,
counterparty_name: tx.counterparty_name ?? undefined,
counterparty_account: tx.counterparty_account ?? undefined,
reference: tx.reference ?? undefined,
merchant_category_code: tx.merchant_category_code ?? undefined,
bank_transaction_code: tx.bank_transaction_code ?? undefined,
proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code ?? undefined,
},
bookingDate: tx.booking_date,
}))
} else {
const fetched = await getAllTransactionsWithRaw(
account.uid,
fromDate,
toDate,
syncOptions?.strategy,
{ acceptedHistoryDays: account.accepted_history_days },
)
rawPages = fetched.rawPages
totalFetched = fetched.transactions.length
effectiveFromDate = fetched.effectiveDateFrom
historyNarrowed = fetched.narrowed === true
// Remember the widest window this bank has ANSWERED for the account, so a
// later rejection of a window no wider than it is read as the bank being
// unavailable rather than as a too-wide window (#2202). Never shrinks:
// an incremental 7-day sync must not forget that 90 days once worked.
// Stamped on the account object; the caller's accounts_data write-back
// persists it, exactly like dedup_scope and the balance fields.
const acceptedDays = historyWindowDays(fetched.effectiveDateFrom, toDate)
if (acceptedDays !== undefined && acceptedDays > (account.accepted_history_days ?? -1)) {
account.accepted_history_days = acceptedDays
}
if (historyNarrowed) {
console.warn('[enable-banking] Bank refused the requested history window; synced a narrower one', {
connectionId,
accountUid: account.uid,
requestedFromDate: fromDate,
effectiveFromDate,
toDate,
})
}
const bankTransactions = fetched.transactions.map(tx => convertTransaction(tx, account.currency))
// Only ingest BOOKED transactions: those the ASPSP returned with a real
// booking_date. Pending entries are intentionally skipped: a pending row is
// unstable across syncs (a later "synka nu" returns the same transaction
// either still pending or finally booked, often with a *different* effective
// date). Because BOTH the dedup external_id and the content-dedup key are
// date-derived, that drift mints a brand-new id and re-imports a transaction
// that already exists. Gating the import set on a stable booking_date
// removes the drift at the source, and leaves booked rows' ids byte-identical.
//
// booking_date is read from the RAW transaction, index-aligned with
// bankTransactions: convertTransaction's booking_date already falls back to
// value_date/today, so it cannot tell booked from pending.
bookedEntries = bankTransactions.flatMap((tx, i) => {
const bookingDate = fetched.transactions[i]?.booking_date
return typeof bookingDate === 'string' && bookingDate.trim() !== ''
? [{ tx, bookingDate: bookingDate.trim() }]
: []
})
}
// Log the actual date range returned so we can compare against the requested
// window. Helps diagnose when an ASPSP truncates history below what we asked for.
let minBookingDate: string | undefined
let maxBookingDate: string | undefined
for (const { bookingDate } of bookedEntries) {
if (!minBookingDate || bookingDate < minBookingDate) minBookingDate = bookingDate
if (!maxBookingDate || bookingDate > maxBookingDate) maxBookingDate = bookingDate
}
console.log('[enable-banking] Fetched transactions', {
connectionId,
accountUid: account.uid,
via: connector ? 'connector' : 'direct',
transactionCount: totalFetched,
rawPageCount: rawPages.length,
requestedFromDate: fromDate,
effectiveFromDate,
requestedToDate: toDate,
returnedMinBookingDate: minBookingDate,
returnedMaxBookingDate: maxBookingDate,
strategy: syncOptions?.strategy,
})
const skippedPending = totalFetched - bookedEntries.length
if (skippedPending > 0) {
console.log('[enable-banking] Skipped pending transactions (no booking_date)', {
connectionId,
accountUid: account.uid,
skippedPending,
total: totalFetched,
})
}
// Derive a stable, content-based external_id per booked transaction. We
// deliberately do NOT key off the bank's transaction id (entry_reference/
// transaction_id): many Swedish ASPSPs regenerate those across requests, so a
// repeat "synka nu" produced a fresh id and re-imported transactions the user
// had already booked. buildStableExternalIds derives the id from (account,
// booking_date, amount) plus an occurrence index, so re-syncs collide on
// (company_id, external_id) and dedupe while genuinely identical transactions
// are still kept apart. Normalize the IBAN (strip whitespace, uppercase) so
// formatting variants from the ASPSP ("SE45 5000 …" vs "SE455000…") don't
// change the scope and orphan every prior external_id. Falls back to the
// provider account uid.
//
// account.dedup_scope, when present, wins outright: it pins the scope the
// account was FIRST ingested under, so a re-authorization that mints a new
// uid (common for no-IBAN accounts) keeps producing byte-identical ids
// instead of re-importing the whole history. The id FORMAT itself is frozen
// (see lib/transactions/external-id.ts); only the scope input is stabilized.
// Stamped back onto the account here for legacy rows so the caller's
// accounts_data write-back persists it.
const accountScope =
account.dedup_scope || account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid
if (!account.dedup_scope) account.dedup_scope = accountScope
const externalIds = buildStableExternalIds(
'eb',
accountScope,
bookedEntries.map(({ tx, bookingDate }) => ({ date: bookingDate, amount: tx.amount }))
)
// Convert Enable Banking format to generic RawTransaction. counterparty
// identification: prefer IBAN (international, normalized) over BBAN/BG
// numbers: the own-account detector matches on IBAN first, falling back
// to counterparty_account for Swedish domestic transfers.
const rawTransactions: RawTransaction[] = bookedEntries.map(({ tx, bookingDate }, i) => {
const cpAccount = tx.counterparty_account ?? null
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
return {
// The booked date is both the stable dedup anchor (see bookedEntries) and
// the accounting-correct ledger date; keep it identical to the value the
// external_id was derived from.
date: bookingDate,
// tx.description is already non-empty (convertTransaction guarantees a
// label); the trailing fallbacks are defensive. Ingest re-normalizes.
description: tx.description || tx.counterparty_name || FALLBACK_DESCRIPTION,
amount: tx.amount,
currency: tx.currency || account.currency,
external_id: externalIds[i],
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
bank_connection_id: connectionId,
import_source: 'enable_banking',
counterparty_iban: looksLikeIban ? cpAccount!.replace(/\s+/g, '') : null,
counterparty_account: !looksLikeIban ? cpAccount : null,
// Verbatim transaction-type codes: the ingest boundary classifies them
// into transaction_method and persists them as evidence columns.
bank_transaction_code: tx.bank_transaction_code || null,
proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code || null,
}
})
const ingestOptions: IngestOptions = {}
if (syncOptions?.skipAutoCategorization) ingestOptions.skipAutoCategorization = true
if (syncOptions?.rawInsertOnly) ingestOptions.rawInsertOnly = true
// Per-account ledger routing: the mapping engine consumes settlementAccount
// for the bank-side leg, falling back to '1930' when unset.
if (account.ledger_account) ingestOptions.settlementAccount = account.ledger_account
const ingestResult = await ingest(supabase, companyId, userId, rawTransactions, ingestOptions)
console.log('[enable-banking] Ingest result', {
connectionId,
accountUid: account.uid,
imported: ingestResult.imported,
duplicates: ingestResult.duplicates,
errors: ingestResult.errors,
})
// Archive raw PSD2 API responses as räkenskapsinformation (BFL 7 kap)
for (let i = 0; i < rawPages.length; i++) {
try {
const fileName = `psd2-response_${connectionId}_${account.uid}_${new Date().toISOString().replace(/[:.]/g, '-')}_p${i + 1}.json`
const buffer = new TextEncoder().encode(rawPages[i]).buffer as ArrayBuffer
await uploadDocument(supabase, userId, companyId,
{ name: fileName, buffer, type: 'application/json' },
{ upload_source: 'api' }
)
} catch (archiveError) {
console.error(`[enable-banking] Failed to archive raw response page ${i + 1}:`, archiveError)
// Archival failure must not fail the sync
}
}
// Update account balance, but only when the stored one has gone stale:
// every skipped call preserves the account's scarce daily BALANCES quota
// (see BALANCE_MAX_AGE_MS). balance_updated_at is written ONLY on a
// successful refresh below, so a stale/missing/invalid timestamp always
// falls through to a refresh attempt (NaN and Infinity both fail the
// freshness comparison). A FUTURE timestamp (clock skew, bad data) yields a
// negative age; treat it as stale too, or refreshes would be suppressed
// until the wall clock catches up.
const balanceAgeMs = account.balance_updated_at
? Date.now() - new Date(account.balance_updated_at).getTime()
: Number.POSITIVE_INFINITY
const balanceIsFresh = balanceAgeMs >= 0 && balanceAgeMs < BALANCE_MAX_AGE_MS
if (balanceIsFresh) {
console.log('[enable-banking] Skipping balance refresh (stored balance is fresh)', {
connectionId,
accountUid: account.uid,
balanceUpdatedAt: account.balance_updated_at,
})
} else {
try {
const balance = await getAccountBalance(account.uid)
// null = the ASPSP returned no balances at all. Keep the previous
// stored value and timestamp; writing a fabricated 0 with a fresh
// timestamp would pin "the bank reports 0 kr" for the next 12h on
// every balance surface.
if (balance) {
account.balance = balance.amount
// Overwrite (not keep) on null: a stale available figure next to a
// fresh booked figure would misstate what can be spent.
account.available_balance = balance.available ?? undefined
account.balance_updated_at = new Date().toISOString()
}
} catch {
// Keep previous balance, don't update timestamp
}
}
return {
imported: ingestResult.imported,
duplicates: ingestResult.duplicates,
errors: ingestResult.errors,
returnedMinBookingDate: minBookingDate,
returnedMaxBookingDate: maxBookingDate,
requestedFromDate: fromDate,
effectiveFromDate,
historyNarrowed,
}
}