Files
accounted/extensions/general/enable-banking/lib/trigger-sync.ts
T
Jakob Wennberg 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

318 lines
12 KiB
TypeScript

/**
* Agent-triggered bank sync: the shared runner behind the v1 REST endpoint
* POST /companies/{id}/bank-connections/{connectionId}/sync and the MCP tool
* gnubok_sync_bank.
*
* Deliberately narrower than the cookie-session "Synka nu" route in
* index.ts: the window is never caller-controlled (the gap-aware incremental
* lookback from cron-lookback.ts, 7 to 90 days), and a connection that
* synced OR was attempted within SYNC_COOLDOWN_MS answers with a cooldown
* instead of another paid Enable Banking round-trip. The attempt guard is a
* durable lease on bank_connections.sync_lease_until, claimed with one
* conditional UPDATE, so it holds across serverless instances and cold
* starts. An unattended agent loop therefore costs at most one sync per
* connection per cooldown window, regardless of how often it asks.
*
* Failures are reported as codes, never thrown, so each surface maps them
* to its own envelope (structured-errors.ts BANK_SYNC_*). A dead PSD2
* session is flipped to 'expired' here exactly like the web route does:
* nothing an API call can do revives it, only BankID in a browser.
*
* Core cannot import this module (CI guard): the v1 route reaches it via
* the extension's registered `services`, against the contract in
* lib/bank-sync/trigger-sync-contract.ts.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { syncAccountTransactions, type SyncOptions } from './sync'
import {
SessionExpiredError,
AspspUnavailableError,
REAUTH_REQUIRED_MESSAGE,
SYNC_FAILED_MESSAGE,
} from './api-client'
import { incrementalLookbackDays } from './cron-lookback'
import { updateBalancesFromSync } from '@/lib/cash-accounts/service'
import { eventBus } from '@/lib/events/bus'
import {
SYNC_COOLDOWN_MS,
type TriggerSyncInput,
type TriggerSyncResult,
} from '@/lib/bank-sync/trigger-sync-contract'
import type { StoredAccount } from '../types'
import type { Transaction } from '@/types'
export { SYNC_COOLDOWN_MS }
export type { TriggerSyncInput, TriggerSyncResult }
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
function cooldownResult(connectionId: string, nextAllowed: number, now: number): TriggerSyncResult {
return {
ok: false,
code: 'BANK_SYNC_COOLDOWN',
connection_id: connectionId,
next_allowed_at: new Date(nextAllowed).toISOString(),
retry_after_seconds: Math.max(1, Math.ceil((nextAllowed - now) / 1000)),
}
}
export async function triggerConnectionSync(
supabase: SupabaseClient,
input: TriggerSyncInput,
): Promise<TriggerSyncResult> {
const { companyId, userId, connectionId, log } = input
const now = input.now ?? Date.now()
if (!isUuid(connectionId)) {
return { ok: false, code: 'NOT_FOUND', connection_id: connectionId }
}
const { data: connection, error: connectionError } = await supabase
.from('bank_connections')
.select(
'id, company_id, bank_name, status, accounts_data, last_synced_at, error_message, sync_lease_until',
)
.eq('id', connectionId)
.eq('company_id', companyId)
.maybeSingle()
if (connectionError) throw connectionError
if (!connection) {
return { ok: false, code: 'NOT_FOUND', connection_id: connectionId }
}
// 'error' is retryable (a transient upstream failure parks the row there
// while the session is alive); 'expired' and the pending states are not:
// they need the browser flow.
if (connection.status !== 'active' && connection.status !== 'error') {
return {
ok: false,
code: 'BANK_SYNC_NOT_ACTIVE',
connection_id: connectionId,
status: connection.status,
}
}
// Membership is enforced by both callers before we run (withApiV1's
// company resolution, the MCP dispatcher's resolveMcpCompanyContext), but
// this runner writes transactions and bills a bank call, so it checks the
// caller's membership itself as well: a service-role client with the
// wrong userId must never get past this point. Viewers get raw inserts
// only, exactly like the web route.
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (membershipError) throw membershipError
if (!membership) {
return { ok: false, code: 'NOT_FOUND', connection_id: connectionId }
}
const isViewer = (membership as { role?: string }).role === 'viewer'
// A successful sync (ours, the web button's or the cron's) within the
// window: the data is fresh, say so without touching the bank.
const lastSynced = connection.last_synced_at
? new Date(connection.last_synced_at as string).getTime()
: null
if (lastSynced !== null && now - lastSynced < SYNC_COOLDOWN_MS) {
return cooldownResult(connectionId, lastSynced + SYNC_COOLDOWN_MS, now)
}
// A lease still held from a recent ATTEMPT (success or failure): cheap
// read-side answer before the write below.
const heldLease = connection.sync_lease_until
? new Date(connection.sync_lease_until as string).getTime()
: null
if (heldLease !== null && heldLease > now) {
return cooldownResult(connectionId, heldLease, now)
}
const allAccounts = ((connection.accounts_data as StoredAccount[] | null) ?? []).map((a) => ({
...a,
}))
const accounts = allAccounts.filter((a) => a.enabled !== false)
if (accounts.length === 0) {
return { ok: false, code: 'BANK_SYNC_NO_ACCOUNTS', connection_id: connectionId }
}
// Durable, atomic cooldown claim. One conditional UPDATE: the lease is
// taken only if the current one has expired (the column defaults to epoch,
// so "never claimed" needs no NULL branch), and Postgres row locking
// serialises concurrent claimers, so two agent calls landing on different
// serverless instances (or retries of a failing connection after a cold
// start) can never both reach the bank. The lease stays for the full
// window whether the sync succeeds or fails: that IS the throttle.
const nowIso = new Date(now).toISOString()
const leaseUntil = now + SYNC_COOLDOWN_MS
const { data: claimed, error: claimError } = await supabase
.from('bank_connections')
.update({ sync_lease_until: new Date(leaseUntil).toISOString() })
.eq('id', connectionId)
.eq('company_id', companyId)
.lte('sync_lease_until', nowIso)
.select('id')
if (claimError) throw claimError
if (!claimed || claimed.length === 0) {
// Lost the race: another caller claimed between our read and this write.
// Its lease started at most a moment ago, so ours is the honest estimate.
log.info('agent-triggered bank sync: lease held by a concurrent caller', { connectionId })
return cooldownResult(connectionId, leaseUntil, now)
}
const lookbackDays = incrementalLookbackDays(connection.last_synced_at as string | null, now)
const toDate = new Date(now).toISOString().split('T')[0]
const fromDate = new Date(now - lookbackDays * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const syncStartedAt = new Date(now).toISOString()
try {
// Same SIE-overlap guard as the web route and the cron: never
// auto-categorise into a range a completed SIE import already covers.
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
.eq('company_id', companyId)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
.maybeSingle()
const syncOptions: SyncOptions = {
...(sieOverlap ? { skipAutoCategorization: true } : {}),
...(isViewer ? { rawInsertOnly: true } : {}),
...(lookbackDays >= 30 ? { strategy: 'longest' as const } : {}),
}
const results = await Promise.all(
accounts.map((account) =>
syncAccountTransactions(
supabase,
companyId,
userId,
connection.id as string,
account,
fromDate,
toDate,
undefined,
syncOptions,
),
),
)
const imported = results.reduce((sum, r) => sum + r.imported, 0)
const duplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
const syncedAt = new Date().toISOString()
await updateBalancesFromSync(
supabase,
companyId,
connection.id as string,
allAccounts.map((a) => ({
external_uid: a.uid,
balance: a.balance,
available_balance: a.available_balance,
balance_updated_at: a.balance_updated_at,
})),
)
await supabase
.from('bank_connections')
.update({
accounts_data: allAccounts,
last_synced_at: syncedAt,
...(connection.status === 'error' ? { status: 'active' } : {}),
...(connection.status === 'error' || connection.error_message ? { error_message: null } : {}),
})
.eq('id', connection.id)
.eq('company_id', companyId)
if (imported > 0) {
const { data: syncedTransactions } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.eq('bank_connection_id', connection.id)
.gte('created_at', syncStartedAt)
.order('created_at', { ascending: false })
.limit(imported)
if (syncedTransactions && syncedTransactions.length > 0) {
await eventBus.emit({
type: 'transaction.synced',
payload: { transactions: syncedTransactions as Transaction[], userId, companyId },
})
}
}
log.info('agent-triggered bank sync completed', {
connectionId,
imported,
duplicates,
lookbackDays,
})
return {
ok: true,
connection_id: connection.id as string,
bank: (connection.bank_name as string | null) ?? null,
imported,
duplicates,
from_date: fromDate,
to_date: toDate,
last_synced_at: syncedAt,
}
} catch (error) {
if (error instanceof SessionExpiredError) {
log.warn('agent-triggered bank sync: session expired', { connectionId })
await supabase
.from('bank_connections')
.update({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
.eq('id', connection.id)
.eq('company_id', companyId)
return {
ok: false,
code: 'BANK_SESSION_EXPIRED',
connection_id: connectionId,
status: 'expired',
}
}
if (error instanceof AspspUnavailableError) {
// The bank is refusing right now (a window it has answered before, or
// every narrower one): retryable, not a dead session, not a broken
// row. Warn, leave error_message alone (no renewal advice), and answer
// with the retryable code (#2202).
log.warn('agent-triggered bank sync: bank unavailable', {
connectionId,
reason: error.reason,
dateFrom: error.dateFrom,
status: error.status,
})
return {
ok: false,
code: 'BANK_SYNC_FAILED',
connection_id: connectionId,
status: connection.status as string,
}
}
log.error('agent-triggered bank sync failed', {
connectionId,
message: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : undefined,
})
if (connection.status === 'error') {
await supabase
.from('bank_connections')
.update({ error_message: SYNC_FAILED_MESSAGE })
.eq('id', connection.id)
.eq('company_id', companyId)
}
return {
ok: false,
code: 'BANK_SYNC_FAILED',
connection_id: connectionId,
status: connection.status as string,
}
}
}