feat(bank-sync): close the F2 report: gap backfill, consent and paused chip states, agent-triggered sync (#2165)

* fix(bank-sync): cron backfills the gap since the last successful sync

The daily incremental sync always asked the bank for the last 7 days. Any
pause longer than that (a lapsed subscription paid again, a consent renewed
after expiry, an outage) silently lost the days in between: the connection
came back, looked healthy, and the missing transactions never arrived.

The lookback now widens to cover the gap since last_synced_at plus one day
of overlap, capped at the 90-day PSD2 limit, and a gap of a month or more
asks for strategy=longest like the manual sync route does. Dedup via
external_id makes the overlap harmless. First syncs keep their 90-day path.

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

* feat(bank-sync): chip warns seven days before a bank consent expires

The transactions-page chip only reacted once a connection was already dead
(expired/error) or had gone stale. A consent that is about to end looked
healthy until the morning it stopped syncing. New "expiring" state when a
live connection's consent_expires is within seven days, the same threshold
as the consent-expiry email in the sync cron. Precedence: attention,
expiring, stale, healthy.

getChipState moves to lib/transactions/bank-sync-chip-state.ts so the
precedence is unit-tested; the component keeps the rendering only.

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

* feat(bank-sync): chip says paused when the subscription lapsed

The daily cron filters connections by the bank_sync capability, so a
company whose trial or subscription ended keeps status=active rows with a
frozen last_synced_at. The chip read that as "stale, check the connection",
which sends the user to re-authorise a connection that is perfectly alive.
56 of 191 active connections on prod were in this state on 2026-09-01.

New "paused" state, ranked above everything else, when the company lacks
bank_sync: hosted points at billing, self-host at the connector key, the
same split BankSyncNowButton already makes. getChipState takes an options
object so the clock stays out of render (react-hooks/purity).

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

* feat(api): agent-triggerable bank sync in v1 and MCP

Closes the first wish in the F2 report: an integration could read bank
data but never refresh it. New POST /api/v1/companies/{id}/bank-connections/
{connectionId}/sync and MCP gnubok_sync_bank, both on a shared runner
(extensions/general/enable-banking/lib/trigger-sync.ts).

Cost is bounded structurally, not by policy: the window is never
caller-controlled (the cron's gap-aware 7 to 90 day lookback), a connection
synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at
(429 + Retry-After on v1; synced=false in-band on MCP so the agent reads on
instead of retrying), and a failing connection is throttled per process by
attempt time. A dead session is flipped to expired with a remediation that
hands the user the connect link: no API call revives a consent.

Gated on bank_sync like gnubok_connect_bank; scope transactions:write.
Registry, scope map, load-routes, spec snapshot and the generated
accounted-api skill updated; five BANK_SYNC_* / BANK_SESSION_EXPIRED codes
added to the structured-error registry. The web Synka-nu route is left as
is (see DECISIONS.md).

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

* test(bank-sync): use the options object in the remaining chip-state calls

Four multi-line calls still passed the clock positionally after
getChipState moved to an options object; tsc flagged them (vitest did not,
the extra argument was ignored at runtime).

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

* fix(api): address skeptic findings on the agent-triggered bank sync

Three refutations from the pre-publish skeptic pass:

1. Core imported the extension. The v1 sync route pulled the runner
   straight from @/extensions, which the core-build gate rejects and which
   left a live bank endpoint on zero-extension builds. The route now
   resolves it through the registry's services channel against a contract
   in lib/bank-sync/trigger-sync-contract.ts (same pattern as the
   Skatteverket read service) and answers EXTENSION_DISABLED when the
   extension is absent.

2. The idempotency cache stored the handler-level 429. A same-key retry
   after Retry-After, which is the documented retry, replayed the stale
   cooldown as a 400 for the cache's 24-hour TTL. withApiV1 no longer
   caches 429 responses; regression test added. The endpoint's pitfall no
   longer claims Idempotency-Key is mandatory (it was never enforced).

3. Two cron tests read the clock twice and failed whenever a millisecond
   passed between the reads. They now pin the clock with fake timers.

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

* fix(bank-sync): durable cooldown lease and review wording

Resolves the PR #2165 review findings in one pass.

Superagent P1: the attempt throttle was a process-local Map, so two agent
calls on different serverless instances (or a retry after a cold start on
a failing connection) could each bill an Enable Banking call, contradicting
the one-sync-per-15-minutes promise. New bank_connections.sync_lease_until
(migration 20260902150000), claimed with one conditional UPDATE before the
bank is called; Postgres row locking makes exactly one claimer win, the
rest answer BANK_SYNC_COOLDOWN. The lease stays for the full window on
success and failure. Tests cover the claim order, a failed attempt seen
from a second instance, a lost race, and an expired lease.

CodeRabbit: the =1 plural branch now reads "in 1 day" / "om 1 dag"
(daysUntilConsentExpiry rounds a partial day up, so "tomorrow" could be
today); the cooldown pitfall on the v1 endpoint, the MCP description and
the in-band cooldown instruction now say a cooldown can follow a failed
attempt and tell the agent to compare last_synced_at before deciding.

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

* fix(bank-sync): lease claim as a literal filter for the schema guard

CI's no-phantom-columns guard counts runtime-built query expressions and
its ceiling is exact; the templated `.or('sync_lease_until.is.null,...')`
claim added one. The column now defaults to epoch (NOT NULL), so "never
claimed" is just "expired long ago" and the atomic claim is a single
literal `.lte('sync_lease_until', now)` the guard can check. Migration is
unshipped (same PR), so it is edited in place.

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

* fix(bank-sync): runner verifies company membership before the lease

Superagent (round 3): the MCP path reached the shared runner without a
membership check of its own. Both callers do enforce it upstream
(withApiV1's company resolution and resolveMcpCompanyContext in the MCP
dispatcher), but the runner writes transactions and bills a bank call, so
it now checks company_members itself, before the cooldown and the lease
claim, and answers NOT_FOUND for a non-member. The viewer check that was
buried inside the sync block moves up with it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 17:17:41 +02:00
committed by GitHub
parent 723a0f537b
commit b68c082ef5
30 changed files with 1914 additions and 53 deletions
+2
View File
@@ -1495,4 +1495,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] Removed the skattekonto drift email (skattekonto.drift_detected event, handler, /api/extensions/skatteverket/skattekonto/drift route, cron hook) instead of fixing it: it alerted on raw saldo-vs-1630 gaps that unbooked rows explain by construction (2026-09-02: Arcim 35 842 kr, 100% explained, while the Hem notice and reconciliation page said nothing was wrong), repeated every 24 h, and was the only surface of a May-2026 feature whose promised dashboard tile was never built. Since 2026-08-25 the reconciliation page and the Hem notice (detectSkvUnexplained, gated on unexplained_difference) are the surface. Considered gating the mail on unexplained_difference + once per episode (built, then dropped): after that gate it only fires on integrity findings the engine itself calls 'never a user task'. skattekonto_drift_tolerance stays (Hem notice reads it); stale skattekonto_drift_last_alert_at rows in extension_data are inert.
[2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/.
[2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented).
[2026-09-02] Agent-triggerable bank sync shipped (v1 POST /bank-connections/{id}/sync + MCP gnubok_sync_bank), lifting the 2026-09-01 deferral: Emil chose to close every open F2 item in one PR. The cost worry is bounded structurally instead of by policy: the window is never caller-controlled (gap-aware 7 to 90 days, same helper as the cron), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (MCP returns it in-band as synced=false so agents read on instead of retrying), and failures are throttled per process by attempt time. The web Synka-nu route is left untouched rather than refactored onto the shared runner: it carries UI-only behaviour (caller-chosen days_back up to 365, SIE sweep stamping) and a regression there would hit every user for a code-sharing win.
[2026-09-02] Bank-sync cooldown is a durable lease column (bank_connections.sync_lease_until, migration 20260902150000) claimed with one conditional UPDATE, not a process-local attempt map: the security scan on PR #2165 showed the map is bypassed by a second serverless instance or a cold start, so two agent calls could each bill Enable Banking. A column add was chosen over reusing extension_data because PostgREST cannot express an atomic conditional upsert there; the nightly cron deliberately ignores the lease.
[2026-09-02] Grok links carry auth=required like the claude.ai link (#2159), decided from a live test: on the lazy URL Grok's connector dialog listed all 150+ tools and never opened the sign-in, so it reads the 200 probe as an authless server exactly as claude.ai does. The flag lives in one helper (mcpServerUrl / sideDoorServerUrl in lib/onboarding/checklist.ts) so the settings row, the onboarding side door and the deep link cannot drift; ChatGPT stays lazy because its developer mode honours the 401 on the first protected call.
@@ -457,3 +457,67 @@ describe('GET /api/extensions/enable-banking/sync/cron: failure log level', () =
consoleError.mockRestore()
})
})
describe('GET /api/extensions/enable-banking/sync/cron: incremental lookback', () => {
const DAY_MS = 24 * 60 * 60 * 1000
// Pin the clock: the route reads Date.now() after the fixture does, and a
// single elapsed millisecond makes Math.ceil in incrementalLookbackDays
// count one more day than the fixture intended.
const NOW = Date.parse('2026-09-02T05:00:00.000Z')
const isoDate = (msAgo: number) => new Date(NOW - msAgo).toISOString().split('T')[0]
const syncedDaysAgo = (days: number) => new Date(NOW - days * DAY_MS).toISOString()
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('uses the 7-day window when the connection synced yesterday', async () => {
state.active = [connection({ last_synced_at: syncedDaysAgo(1) })]
mocks.probeSessionHealth.mockResolvedValue('alive')
await GET(cronRequest())
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(1)
const [, , , , , fromDate, , , options] = mocks.syncAccountTransactions.mock.calls[0]
expect(fromDate).toBe(isoDate(7 * DAY_MS))
expect(options).not.toHaveProperty('strategy')
})
it('widens the window to cover a gap since the last sync', async () => {
// A subscription that lapsed for 20 days and was paid again: a fixed
// 7-day window would silently drop the 13 days in between.
state.active = [connection({ last_synced_at: syncedDaysAgo(20) })]
mocks.probeSessionHealth.mockResolvedValue('alive')
await GET(cronRequest())
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(1)
const [, , , , , fromDate] = mocks.syncAccountTransactions.mock.calls[0]
expect(fromDate).toBe(isoDate(21 * DAY_MS))
})
it('asks for the deepest history the bank serves on a gap of a month or more', async () => {
state.active = [connection({ last_synced_at: syncedDaysAgo(40) })]
mocks.probeSessionHealth.mockResolvedValue('alive')
await GET(cronRequest())
const [, , , , , fromDate, , , options] = mocks.syncAccountTransactions.mock.calls[0]
expect(fromDate).toBe(isoDate(41 * DAY_MS))
expect(options).toMatchObject({ strategy: 'longest' })
})
it('caps the widened window at 90 days', async () => {
state.active = [connection({ last_synced_at: syncedDaysAgo(200) })]
mocks.probeSessionHealth.mockResolvedValue('alive')
await GET(cronRequest())
const [, , , , , fromDate] = mocks.syncAccountTransactions.mock.calls[0]
expect(fromDate).toBe(isoDate(90 * DAY_MS))
})
})
@@ -29,6 +29,11 @@ import { getBranding } from '@/lib/branding/service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { updateBalancesFromSync } from '@/lib/cash-accounts/service'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import {
INCREMENTAL_LOOKBACK_DAYS,
MAX_LOOKBACK_DAYS,
incrementalLookbackDays,
} from '@/extensions/general/enable-banking/lib/cron-lookback'
ensureInitialized()
@@ -194,17 +199,28 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
}
const toDate = new Date().toISOString().split('T')[0]
// First sync: 90-day lookback (PSD2 max). Subsequent: 7-day window.
// First sync: 90-day lookback (PSD2 max). Subsequent: 7-day window,
// widened to cover any gap since the last successful sync (a paused
// subscription that was paid again, a renewed consent) so the days in
// between are not lost. See cron-lookback.ts.
// Gate on initial_sync_completed_at, not last_synced_at: manual "Sync now"
// sets last_synced_at without doing the deep backfill, and we want the cron
// to still fall back to 90 days if the inline activation backfill failed.
const isFirstSync = !connection.initial_sync_completed_at
const lookbackDays = isFirstSync ? 90 : 7
const lookbackDays = isFirstSync
? MAX_LOOKBACK_DAYS
: incrementalLookbackDays(connection.last_synced_at)
if (isFirstSync) {
ctx.log.info('first sync for connection: using 90-day lookback', {
connectionId: connection.id,
lookbackDays,
})
} else if (lookbackDays > INCREMENTAL_LOOKBACK_DAYS) {
ctx.log.info('gap since last sync: widening lookback', {
connectionId: connection.id,
lastSyncedAt: connection.last_synced_at,
lookbackDays,
})
}
const fromDate = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000)
.toISOString()
@@ -246,11 +262,12 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
.maybeSingle()
// First sync uses strategy=longest to pull the deepest history available
// from the ASPSP. Incremental syncs skip it: the implicit default is
// faster and we already have the older data.
// from the ASPSP, and so does a gap backfill of a month or more (same
// threshold as the manual sync route). Routine incremental syncs skip
// it: the implicit default is faster and we already have the older data.
const syncOptions = {
...(sieOverlap ? { skipAutoCategorization: true } : {}),
...(isFirstSync ? { strategy: 'longest' as const } : {}),
...(isFirstSync || lookbackDays >= 30 ? { strategy: 'longest' as const } : {}),
}
const syncResults = await Promise.all(
@@ -0,0 +1,254 @@
/**
* Tests for POST /api/v1/companies/{companyId}/bank-connections/{connectionId}/sync.
*
* Exercises the real withApiV1 wrapper (auth, scope, company membership,
* idempotency header) with the Supabase client and the sync runner mocked.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const { requireCapabilityMock, triggerSyncMock, registryGetMock } = vi.hoisted(() => ({
requireCapabilityMock: vi.fn(),
triggerSyncMock: vi.fn(),
registryGetMock: vi.fn(),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: requireCapabilityMock,
}))
// Core cannot import the extension: the route resolves the runner through
// the registry's `services` channel, so that is what gets stubbed.
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: { get: registryGetMock },
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const CONNECTION_ID = '11111111-1111-4111-8111-111111111111'
const BASE = `http://localhost/api/v1/companies/${COMPANY_ID}/bank-connections/${CONNECTION_ID}/sync`
function req(opts: { idempotencyKey?: string | null; dryRun?: boolean } = {}): Request {
const headers: Record<string, string> = {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
}
if (opts.idempotencyKey !== null) headers['Idempotency-Key'] = opts.idempotencyKey ?? 'idem-1'
const url = opts.dryRun ? `${BASE}?dry_run=true` : BASE
return new Request(url, { method: 'POST', headers, body: '{}' })
}
function authOk(scopes: string[], mode: 'live' | 'test' = 'live') {
mockValidate.mockResolvedValue({
valid: true,
userId: 'user-1',
keyId: 'key-1',
keyName: 'Test key',
scopes,
mode,
})
}
const params = { params: Promise.resolve({ companyId: COMPANY_ID, connectionId: CONNECTION_ID }) }
const OK_RESULT = {
ok: true,
connection_id: CONNECTION_ID,
bank: 'Swedbank',
imported: 3,
duplicates: 12,
from_date: '2026-08-26',
to_date: '2026-09-02',
last_synced_at: '2026-09-02T09:14:03.000Z',
}
describe('v1 bank-connections sync', () => {
beforeEach(() => {
vi.clearAllMocks()
requireCapabilityMock.mockResolvedValue(null)
triggerSyncMock.mockResolvedValue(OK_RESULT)
registryGetMock.mockImplementation((id: string) =>
id === 'enable-banking' ? { services: { triggerConnectionSync: triggerSyncMock } } : undefined,
)
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { role: 'owner' } },
idempotency_keys: { data: null },
}),
)
})
it('401 without a valid key', async () => {
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
const res = await POST(req(), params)
expect(res.status).toBe(401)
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('403 INSUFFICIENT_SCOPE when the key lacks transactions:write', async () => {
authOk(['transactions:read', 'companies:read'])
const res = await POST(req(), params)
expect(res.status).toBe(403)
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('returns the capability-blocked response when bank_sync is not entitled', async () => {
authOk(['transactions:write'])
requireCapabilityMock.mockResolvedValue(
Response.json({ error: { code: 'CAPABILITY_BLOCKED' } }, { status: 403 }),
)
const res = await POST(req(), params)
expect(res.status).toBe(403)
expect(requireCapabilityMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'bank_sync')
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('404 when the key user is not a member of the company', async () => {
authOk(['transactions:write'])
mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: { data: null } }))
const res = await POST(req(), params)
expect(res.status).toBe(404)
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('blocks a test key: the bank call cannot be simulated', async () => {
authOk(['transactions:write'], 'test')
const res = await POST(req(), params)
expect(res.status).toBeGreaterThanOrEqual(400)
expect(res.status).toBeLessThan(500)
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('refuses ?dry_run=true on a live key with a VALIDATION_ERROR', async () => {
authOk(['transactions:write'])
const res = await POST(req({ dryRun: true }), params)
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { code: string; details: { field: string } } }
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('dry_run')
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('answers EXTENSION_DISABLED when the enable-banking extension is not registered', async () => {
authOk(['transactions:write'])
registryGetMock.mockReturnValue(undefined)
const res = await POST(req(), params)
const body = (await res.json()) as { error: { code: string } }
expect(body.error.code).toBe('EXTENSION_DISABLED')
expect(triggerSyncMock).not.toHaveBeenCalled()
})
it('runs the sync for the path connection and returns the outcome', async () => {
authOk(['transactions:write'])
const res = await POST(req(), params)
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: Record<string, unknown>
meta: { request_id: string }
}
expect(triggerSyncMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ companyId: COMPANY_ID, userId: 'user-1', connectionId: CONNECTION_ID }),
)
expect(body.data).toEqual({
connection_id: CONNECTION_ID,
bank: 'Swedbank',
imported: 3,
duplicates: 12,
from_date: '2026-08-26',
to_date: '2026-09-02',
last_synced_at: '2026-09-02T09:14:03.000Z',
})
expect(body.meta.request_id).toMatch(/^req_/)
})
it('429 BANK_SYNC_COOLDOWN with next_allowed_at and Retry-After', async () => {
authOk(['transactions:write'])
triggerSyncMock.mockResolvedValue({
ok: false,
code: 'BANK_SYNC_COOLDOWN',
connection_id: CONNECTION_ID,
next_allowed_at: '2026-09-02T09:29:03.000Z',
retry_after_seconds: 600,
})
const res = await POST(req(), params)
expect(res.status).toBe(429)
expect(res.headers.get('Retry-After')).toBe('600')
const body = (await res.json()) as {
error: { code: string; details: { next_allowed_at: string } }
}
expect(body.error.code).toBe('BANK_SYNC_COOLDOWN')
expect(body.error.details.next_allowed_at).toBe('2026-09-02T09:29:03.000Z')
})
it('409 BANK_SESSION_EXPIRED with a recovery hint pointing at the connect link', async () => {
authOk(['transactions:write'])
triggerSyncMock.mockResolvedValue({
ok: false,
code: 'BANK_SESSION_EXPIRED',
connection_id: CONNECTION_ID,
status: 'expired',
})
const res = await POST(req(), params)
expect(res.status).toBe(409)
const body = (await res.json()) as { error: { code: string; recovery_hint?: string } }
expect(body.error.code).toBe('BANK_SESSION_EXPIRED')
expect(body.error.recovery_hint).toContain('connect_url')
})
it('404 NOT_FOUND for a connection outside the company', async () => {
authOk(['transactions:write'])
triggerSyncMock.mockResolvedValue({ ok: false, code: 'NOT_FOUND', connection_id: CONNECTION_ID })
const res = await POST(req(), params)
expect(res.status).toBe(404)
})
it('maps a thrown database error into the v1 error envelope', async () => {
authOk(['transactions:write'])
triggerSyncMock.mockRejectedValue({ message: 'boom', code: '57014' })
const res = await POST(req(), params)
expect(res.status).toBeGreaterThanOrEqual(500)
const body = (await res.json()) as { error: { code: string } }
expect(body.error.code).toBeTruthy()
})
})
@@ -0,0 +1,157 @@
/**
* POST /api/v1/companies/{companyId}/bank-connections/{connectionId}/sync
*
* Trigger a PSD2 sync of one bank connection now, instead of waiting for
* the nightly cron. The window is not caller-controlled: the same gap-aware
* incremental lookback the cron uses (7 days, widened to cover any gap since
* last_synced_at, capped at 90). A connection synced or attempted within the
* last 15 minutes answers 429 BANK_SYNC_COOLDOWN with next_allowed_at (the
* attempt guard is a durable lease on the row, claimed atomically), so an
* unattended agent can never run up the Enable Banking bill by polling, even
* across serverless instances.
*
* What this cannot do: revive a dead consent. status=expired (or a session
* the bank reports dead mid-sync) needs BankID in a browser; the response
* says so and points at the connect link.
*
* The runner lives in the enable-banking extension. Core cannot import it
* (CI guard), so it is reached through the registry-resolved `services`
* channel declared in lib/bank-sync/trigger-sync-contract.ts, and a
* deployment without the extension answers EXTENSION_DISABLED.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import {
SYNC_COOLDOWN_MS,
type EnableBankingServices,
} from '@/lib/bank-sync/trigger-sync-contract'
ensureInitialized()
const SyncResponse = z.object({
connection_id: z.string().uuid(),
bank: z.string().nullable(),
imported: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
from_date: z.string(),
to_date: z.string(),
last_synced_at: z.string(),
})
registerEndpoint({
operation: 'bank-connections.sync',
method: 'POST',
path: '/api/v1/companies/:companyId/bank-connections/:connectionId/sync',
summary: 'Sync one bank connection now instead of waiting for the nightly run.',
description:
'Fetches new transactions and balances for one PSD2 bank connection right away. The window is chosen server-side: the last 7 days, widened to cover any gap since last_synced_at, capped at 90 days. Returns how many transactions were imported and the new last_synced_at. A connection synced within the last 15 minutes is refused with 429 BANK_SYNC_COOLDOWN and next_allowed_at: the data is already fresh. Not dry-runnable: the bank call itself is the side effect.',
useWhen:
'GET /bank-connections shows a stale last_synced_at on an active connection and you need current bank data before building on it (liquidity, reconciliation, a report), or the user asks for the latest transactions now.',
doNotUseFor:
'Polling. Connections sync every night on their own; call this once when freshness matters, then read /transactions. Fixing a dead connection: status=expired needs BankID in a browser, not a sync.',
pitfalls: [
'Idempotency-Key is optional here. If you send one, use a fresh key per attempt: a cooldown answer is never cached, but a completed sync is, and replaying it fetches nothing new.',
'429 BANK_SYNC_COOLDOWN follows a recent successful sync OR a recent attempt that failed (the 15-minute lease is taken before the bank is called, on every instance). Compare last_synced_at from GET /bank-connections: if it is fresh, use the data you have; if it is still stale, the previous attempt failed, so retry once after next_allowed_at (Retry-After is set).',
'409 BANK_SESSION_EXPIRED means the bank reported the consent dead during the sync; the connection is now status=expired. Hand the user the connect link; no API call revives it.',
'imported: 0 is normal on a quiet account. Banks report with up to 48 hours of delay, so today\'s transactions often arrive tomorrow.',
'Costs one Enable Banking call per enabled account: 403 CAPABILITY_BLOCKED when the company has no bank_sync entitlement.',
],
example: {
response: {
data: {
connection_id: '4f6c…',
bank: 'Swedbank',
imported: 3,
duplicates: 12,
from_date: '2026-08-26',
to_date: '2026-09-02',
last_synced_at: '2026-09-02T09:14:03Z',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'transactions:write',
risk: 'low',
idempotent: false,
reversible: false,
dryRunSupported: false,
response: {
success: dataEnvelope(SyncResponse),
errorCodes: [
'BANK_SYNC_COOLDOWN',
'BANK_SYNC_NOT_ACTIVE',
'BANK_SYNC_NO_ACCOUNTS',
'BANK_SESSION_EXPIRED',
'BANK_SYNC_FAILED',
'CAPABILITY_BLOCKED',
'EXTENSION_DISABLED',
'NOT_FOUND',
],
},
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; connectionId: string }> }>(
'bank-connections.sync',
async (_request, ctx, params) => {
const { connectionId } = await params.params
const capBlocked = await requireCapability(ctx.supabase, ctx.companyId!, CAPABILITY.bank_sync)
if (capBlocked) return capBlocked
// The bank round-trip IS the side effect, so there is nothing to simulate.
// Test keys never reach this line (the wrapper blocks them on endpoints
// that cannot dry-run); a live key passing ?dry_run=true is told why.
if (ctx.dryRun) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'dry_run',
message: 'dry_run is not supported: the bank call is the side effect.',
cooldown_ms: SYNC_COOLDOWN_MS,
},
})
}
// The enable-banking extension is opt-in (extensions.config.json) and the
// registry is the runtime source of truth: absent registration means the
// deployment does not offer PSD2 sync at all.
const services = extensionRegistry.get('enable-banking')?.services as
| Partial<EnableBankingServices>
| undefined
if (!services?.triggerConnectionSync) {
return v1ErrorResponseFromCode('EXTENSION_DISABLED', ctx.log, {
requestId: ctx.requestId,
})
}
try {
const result = await services.triggerConnectionSync(ctx.supabase, {
companyId: ctx.companyId!,
userId: ctx.userId,
connectionId,
log: ctx.log,
})
if (!result.ok) {
const { ok: _ok, code, ...details } = result
return v1ErrorResponseFromCode(code, ctx.log, {
requestId: ctx.requestId,
details,
retryAfterSeconds: result.retry_after_seconds,
})
}
const { ok: _ok, ...data } = result
return ok(data, { requestId: ctx.requestId })
} catch (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
},
)
+47 -40
View File
@@ -6,49 +6,18 @@ import { useTranslations } from 'next-intl'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { onBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany } from '@/contexts/CompanyContext'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { isSelfHosted } from '@/lib/env/public-flags'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/info-tooltip'
interface ConnectionRow {
id: string
status: string | null
last_synced_at: string | null
}
const STALE_THRESHOLD_MS = 36 * 60 * 60 * 1000
type ChipState =
| { kind: 'none' }
| { kind: 'attention'; count: number }
| { kind: 'stale'; mostRecent: string }
| { kind: 'healthy'; mostRecent: string | null }
export function getChipState(rows: ConnectionRow[], now: number = Date.now()): ChipState {
if (rows.length === 0) return { kind: 'none' }
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
if (needsAttention.length > 0) {
return { kind: 'attention', count: needsAttention.length }
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
if (mostRecent && now - new Date(mostRecent).getTime() > STALE_THRESHOLD_MS) {
return { kind: 'stale', mostRecent }
}
return { kind: 'healthy', mostRecent: mostRecent ?? null }
}
import {
getChipState,
type ConnectionRow,
} from '@/lib/transactions/bank-sync-chip-state'
export function useAgeFormatter() {
const t = useTranslations('transactions')
@@ -68,6 +37,7 @@ export default function BankSyncStatusChip() {
const t = useTranslations('transactions')
const formatAge = useAgeFormatter()
const { company } = useCompany()
const hasBankSync = useCapability(CAPABILITY.bank_sync)
const [rows, setRows] = useState<ConnectionRow[] | null>(null)
useEffect(() => {
@@ -79,7 +49,7 @@ export default function BankSyncStatusChip() {
const load = () => {
supabase
.from('bank_connections')
.select('id, status, last_synced_at')
.select('id, status, last_synced_at, consent_expires')
.eq('company_id', companyId)
.then(({ data, error }) => {
if (cancelled) return
@@ -102,10 +72,29 @@ export default function BankSyncStatusChip() {
if (!rows) return null
const state = getChipState(rows)
const state = getChipState(rows, { hasBankSync })
if (state.kind === 'none') return null
if (state.kind === 'paused') {
// Same remedy split as BankSyncNowButton: hosted points at billing,
// self-host at the connector key (never the Stripe page).
const selfHosted = isSelfHosted()
return (
<Link
href={selfHosted ? '/settings/banking' : '/settings/billing'}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/30 px-2.5 py-1 text-xs text-attn transition-colors hover:bg-muted/50"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{selfHosted
? t('bank_sync_paused_connector_key')
: t('bank_sync_paused_subscription')}
</span>
</Link>
)
}
if (state.kind === 'attention') {
return (
<Link
@@ -122,6 +111,24 @@ export default function BankSyncStatusChip() {
)
}
if (state.kind === 'expiring') {
// The consent is still alive, so the connection syncs today; the ochre
// text says "act before it dies" without the terracotta of a dead one.
return (
<Link
href="/settings/banking"
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/30 px-2.5 py-1 text-xs text-attn transition-colors hover:bg-muted/50"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{state.count === 1
? t('bank_sync_expiring_one', { days: state.daysLeft })
: t('bank_sync_expiring_many', { count: state.count, days: state.daysLeft })}
</span>
</Link>
)
}
if (state.kind === 'stale') {
// Same neutral shape as the healthy chip: the ochre text is the signal,
// never an amber box (convention 12: status colors are data, not chrome).
@@ -12,6 +12,7 @@ import {
type ASPSP,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import { triggerConnectionSync } from './lib/trigger-sync'
import { findReusableSessions, countLiveSiblings } from './lib/session-sharing'
import {
runUnattendedReconciliationSweep,
@@ -62,6 +63,13 @@ export const enableBankingExtension: Extension = {
path: '/settings/banking',
},
// Registry-resolved services for core callers (core cannot import
// @/extensions). Contract: lib/bank-sync/trigger-sync-contract.ts.
services: {
// Agent-triggered sync behind POST /api/v1/.../bank-connections/{id}/sync.
triggerConnectionSync,
},
apiRoutes: [
{
method: 'GET',
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { incrementalLookbackDays } from '../cron-lookback'
const NOW = Date.parse('2026-09-02T05:00:00Z')
const daysAgo = (d: number) => new Date(NOW - d * 24 * 60 * 60 * 1000).toISOString()
describe('incrementalLookbackDays', () => {
it('keeps the 7-day window when the last sync is recent', () => {
expect(incrementalLookbackDays(daysAgo(0), NOW)).toBe(7)
expect(incrementalLookbackDays(daysAgo(1), NOW)).toBe(7)
expect(incrementalLookbackDays(daysAgo(5), NOW)).toBe(7)
})
it('widens the window to cover a gap, with one day of overlap', () => {
expect(incrementalLookbackDays(daysAgo(7), NOW)).toBe(8)
expect(incrementalLookbackDays(daysAgo(20), NOW)).toBe(21)
})
it('rounds a partial day up so the gap is never under-covered', () => {
expect(incrementalLookbackDays(daysAgo(10.4), NOW)).toBe(12)
})
it('caps at the 90-day PSD2 limit', () => {
expect(incrementalLookbackDays(daysAgo(200), NOW)).toBe(90)
expect(incrementalLookbackDays(daysAgo(89), NOW)).toBe(90)
})
it('falls back to 7 days when there is no usable timestamp', () => {
expect(incrementalLookbackDays(null, NOW)).toBe(7)
expect(incrementalLookbackDays(undefined, NOW)).toBe(7)
expect(incrementalLookbackDays('not a date', NOW)).toBe(7)
})
it('never goes below 7 days for a future timestamp (clock skew)', () => {
expect(incrementalLookbackDays(daysAgo(-2), NOW)).toBe(7)
})
})
@@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
syncAccountTransactions: vi.fn(),
updateBalancesFromSync: vi.fn(),
emit: vi.fn(),
}))
vi.mock('../sync', () => ({
syncAccountTransactions: (...args: unknown[]) => mocks.syncAccountTransactions(...args),
}))
vi.mock('@/lib/cash-accounts/service', () => ({
updateBalancesFromSync: (...args: unknown[]) => mocks.updateBalancesFromSync(...args),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: (...args: unknown[]) => mocks.emit(...args) },
}))
import { SessionExpiredError, REAUTH_REQUIRED_MESSAGE } from '../api-client'
import { SYNC_COOLDOWN_MS, triggerConnectionSync } from '../trigger-sync'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const CONNECTION_ID = '11111111-1111-4111-8111-111111111111'
const NOW = Date.parse('2026-09-02T09:00:00Z')
const DAY_MS = 24 * 60 * 60 * 1000
interface State {
connection: Record<string, unknown> | null
membershipRole: string | null
sieOverlap: boolean
updates: Record<string, unknown>[]
/**
* The durable lease as the database holds it (epoch when never claimed).
* The conditional UPDATE the runner issues (`sync_lease_until <= now`) is
* reproduced here: a held lease makes the claim return no row.
*/
leaseUntil: string
}
const EPOCH = '1970-01-01T00:00:00.000Z'
function makeClient(state: State) {
return {
from: (table: string) => {
let updatePayload: Record<string, unknown> | null = null
let lteFilter: { column: string; value: string } | null = null
const chain: Record<string, unknown> = {}
const passthrough = ['select', 'eq', 'gte', 'order', 'limit', 'in']
for (const m of passthrough) chain[m] = vi.fn(() => chain)
chain.lte = vi.fn((column: string, value: string) => {
lteFilter = { column, value }
return chain
})
chain.update = vi.fn((payload: Record<string, unknown>) => {
updatePayload = payload
return chain
})
const resolve = () => {
if (updatePayload && 'sync_lease_until' in updatePayload) {
// Atomic claim: `.lte('sync_lease_until', <now>)`.
if (lteFilter?.column !== 'sync_lease_until') {
throw new Error('lease claim must carry the conditional filter')
}
if (state.leaseUntil > lteFilter.value) return { data: [], error: null }
state.leaseUntil = updatePayload.sync_lease_until as string
state.updates.push(updatePayload)
return { data: [{ id: CONNECTION_ID }], error: null }
}
if (updatePayload) {
state.updates.push(updatePayload)
return { data: null, error: null }
}
if (table === 'bank_connections') return { data: state.connection, error: null }
if (table === 'company_members')
return { data: state.membershipRole ? { role: state.membershipRole } : null, error: null }
if (table === 'sie_imports') return { data: state.sieOverlap ? { id: 'sie-1' } : null, error: null }
if (table === 'transactions') return { data: [{ id: 'tx-1' }], error: null }
return { data: null, error: null }
}
chain.maybeSingle = vi.fn(() => Promise.resolve(resolve()))
chain.then = (onFulfilled: (v: unknown) => unknown) => Promise.resolve(resolve()).then(onFulfilled)
return chain
},
}
}
function connection(overrides: Record<string, unknown> = {}) {
return {
id: CONNECTION_ID,
company_id: COMPANY_ID,
bank_name: 'Swedbank',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true, balance: 100 },
{ uid: 'acc-2', currency: 'SEK', enabled: false, balance: 5 },
],
last_synced_at: new Date(NOW - 2 * DAY_MS).toISOString(),
error_message: null,
sync_lease_until: EPOCH,
...overrides,
}
}
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
let state: State
function run(now = NOW) {
return triggerConnectionSync(makeClient(state) as never, {
companyId: COMPANY_ID,
userId: 'user-1',
connectionId: CONNECTION_ID,
log,
now,
})
}
describe('triggerConnectionSync', () => {
beforeEach(() => {
vi.clearAllMocks()
state = {
connection: connection(),
membershipRole: 'owner',
sieOverlap: false,
updates: [],
leaseUntil: EPOCH,
}
mocks.syncAccountTransactions.mockResolvedValue({ imported: 2, duplicates: 5, errors: 0 })
mocks.updateBalancesFromSync.mockResolvedValue(undefined)
mocks.emit.mockResolvedValue(undefined)
})
it('syncs only the enabled accounts over the gap-aware window and stamps last_synced_at', async () => {
const result = await run()
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result).toMatchObject({ connection_id: CONNECTION_ID, bank: 'Swedbank', imported: 2, duplicates: 5 })
// Synced 2 days ago: the 7-day floor applies.
expect(result.from_date).toBe(new Date(NOW - 7 * DAY_MS).toISOString().split('T')[0])
expect(result.to_date).toBe('2026-09-02')
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(1)
expect(mocks.syncAccountTransactions.mock.calls[0][4]).toMatchObject({ uid: 'acc-1' })
expect(state.updates.at(-1)).toMatchObject({ last_synced_at: result.last_synced_at })
// Write-back keeps the disabled account so the user's selection survives.
expect((state.updates.at(-1)!.accounts_data as unknown[]).length).toBe(2)
expect(mocks.updateBalancesFromSync).toHaveBeenCalledTimes(1)
expect(mocks.emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'transaction.synced' }))
})
it('widens the window to cover a longer gap and asks for the deepest history past a month', async () => {
state.connection = connection({ last_synced_at: new Date(NOW - 40 * DAY_MS).toISOString() })
const result = await run()
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.from_date).toBe(new Date(NOW - 41 * DAY_MS).toISOString().split('T')[0])
expect(mocks.syncAccountTransactions.mock.calls[0][8]).toMatchObject({ strategy: 'longest' })
})
it('refuses with a cooldown when the connection synced within 15 minutes', async () => {
const syncedAt = NOW - 5 * 60 * 1000
state.connection = connection({ last_synced_at: new Date(syncedAt).toISOString() })
const result = await run()
expect(result).toMatchObject({
ok: false,
code: 'BANK_SYNC_COOLDOWN',
next_allowed_at: new Date(syncedAt + SYNC_COOLDOWN_MS).toISOString(),
retry_after_seconds: 600,
})
expect(mocks.syncAccountTransactions).not.toHaveBeenCalled()
})
it('claims the durable lease before calling the bank', async () => {
await run()
expect(state.leaseUntil).toBe(new Date(NOW + SYNC_COOLDOWN_MS).toISOString())
// The claim is issued before syncAccountTransactions: the lease row
// update is the first write recorded.
expect(state.updates[0]).toEqual({ sync_lease_until: state.leaseUntil })
})
it('throttles a failing connection by the lease, not only by last_synced_at', async () => {
mocks.syncAccountTransactions.mockRejectedValue(new Error('ASPSP 500'))
const first = await run()
expect(first).toMatchObject({ ok: false, code: 'BANK_SYNC_FAILED' })
// A second instance re-reads the row: the lease is now held.
state.connection = connection({ sync_lease_until: state.leaseUntil })
const second = await run(NOW + 60 * 1000)
expect(second).toMatchObject({
ok: false,
code: 'BANK_SYNC_COOLDOWN',
next_allowed_at: state.leaseUntil,
retry_after_seconds: 14 * 60,
})
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(1)
})
it('loses the race to a concurrent claimer and never calls the bank', async () => {
// Our read saw no lease; between the read and the claim another
// serverless instance took it. The conditional UPDATE returns no row.
state.leaseUntil = new Date(NOW + SYNC_COOLDOWN_MS - 1000).toISOString()
const result = await run()
expect(result).toMatchObject({ ok: false, code: 'BANK_SYNC_COOLDOWN' })
expect(mocks.syncAccountTransactions).not.toHaveBeenCalled()
expect(mocks.updateBalancesFromSync).not.toHaveBeenCalled()
})
it('accepts a sync once a previous lease has expired', async () => {
state.leaseUntil = new Date(NOW - 1000).toISOString()
state.connection = connection({ sync_lease_until: state.leaseUntil })
const result = await run()
expect(result.ok).toBe(true)
expect(state.leaseUntil).toBe(new Date(NOW + SYNC_COOLDOWN_MS).toISOString())
})
it('answers NOT_FOUND for a connection outside the company or a non-uuid id', async () => {
state.connection = null
expect(await run()).toMatchObject({ ok: false, code: 'NOT_FOUND' })
const bogus = await triggerConnectionSync(makeClient(state) as never, {
companyId: COMPANY_ID,
userId: 'user-1',
connectionId: 'not-a-uuid',
log,
now: NOW,
})
expect(bogus).toMatchObject({ ok: false, code: 'NOT_FOUND' })
})
it('answers NOT_FOUND when the caller is not a member of the company, before any lease or bank call', async () => {
state.membershipRole = null
expect(await run()).toMatchObject({ ok: false, code: 'NOT_FOUND' })
expect(state.leaseUntil).toBe(EPOCH)
expect(mocks.syncAccountTransactions).not.toHaveBeenCalled()
})
it('refuses an expired or pending connection: only BankID can fix those', async () => {
state.connection = connection({ status: 'expired' })
expect(await run()).toMatchObject({ ok: false, code: 'BANK_SYNC_NOT_ACTIVE', status: 'expired' })
state.connection = connection({ status: 'pending_selection' })
expect(await run()).toMatchObject({ ok: false, code: 'BANK_SYNC_NOT_ACTIVE' })
expect(mocks.syncAccountTransactions).not.toHaveBeenCalled()
})
it('retries an errored connection and recovers it to active on success', async () => {
state.connection = connection({ status: 'error', error_message: 'Banksynkningen misslyckades.' })
const result = await run()
expect(result.ok).toBe(true)
expect(state.updates.at(-1)).toMatchObject({ status: 'active', error_message: null })
})
it('refuses when every account is deselected', async () => {
state.connection = connection({ accounts_data: [{ uid: 'acc-1', enabled: false }] })
expect(await run()).toMatchObject({ ok: false, code: 'BANK_SYNC_NO_ACCOUNTS' })
})
it('flips the connection to expired when the bank reports the session dead', async () => {
mocks.syncAccountTransactions.mockRejectedValue(new SessionExpiredError(401, 'consent closed'))
const result = await run()
expect(result).toMatchObject({ ok: false, code: 'BANK_SESSION_EXPIRED', status: 'expired' })
expect(state.updates.at(-1)).toMatchObject({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
})
it('suppresses auto-categorisation over a completed SIE import and for viewers', async () => {
state.sieOverlap = true
state.membershipRole = 'viewer'
await run()
expect(mocks.syncAccountTransactions.mock.calls[0][8]).toMatchObject({
skipAutoCategorization: true,
rawInsertOnly: true,
})
})
})
@@ -0,0 +1,30 @@
/**
* How far back the daily cron asks the bank for transactions on an
* incremental (non-first) sync.
*
* A fixed 7-day window silently loses data whenever a connection pauses for
* longer than a week: a lapsed subscription that is paid again, a consent
* renewed after it expired, an outage. The row still carries the last
* successful sync, so the window is widened to cover the gap, with one day
* of overlap for late-booked transactions (dedup via external_id makes the
* overlap harmless). Capped at the PSD2 90-day limit a bank will serve
* without fresh SCA.
*
* Pure module so the arithmetic is unit-testable.
*/
export const INCREMENTAL_LOOKBACK_DAYS = 7
export const MAX_LOOKBACK_DAYS = 90
const DAY_MS = 24 * 60 * 60 * 1000
export function incrementalLookbackDays(
lastSyncedAt: string | null | undefined,
now: number = Date.now(),
): number {
if (!lastSyncedAt) return INCREMENTAL_LOOKBACK_DAYS
const syncedAt = new Date(lastSyncedAt).getTime()
if (!Number.isFinite(syncedAt)) return INCREMENTAL_LOOKBACK_DAYS
const daysSince = Math.ceil(Math.max(0, now - syncedAt) / DAY_MS)
return Math.min(MAX_LOOKBACK_DAYS, Math.max(INCREMENTAL_LOOKBACK_DAYS, daysSince + 1))
}
@@ -0,0 +1,297 @@
/**
* 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,
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',
}
}
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,
}
}
}
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Agent-triggered PSD2 sync: the MCP twin of
// POST /api/v1/companies/{id}/bank-connections/{connectionId}/sync.
// The runner itself is covered in extensions/general/enable-banking; this
// file pins the tool's contract: scope, capability gate, the in-band
// cooldown answer, and that real failures flow through the coded envelope.
const mocks = vi.hoisted(() => ({
triggerConnectionSync: vi.fn(),
}))
vi.mock('@/extensions/general/enable-banking/lib/trigger-sync', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/enable-banking/lib/trigger-sync')
>('@/extensions/general/enable-banking/lib/trigger-sync')
return {
...actual,
triggerConnectionSync: (...args: unknown[]) => mocks.triggerConnectionSync(...args),
}
})
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { eventBus } from '@/lib/events/bus'
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
import { tools } from '../server'
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const CONNECTION_ID = '22222222-2222-4222-8222-222222222222'
const tool = tools.find((t) => t.name === 'gnubok_sync_bank')!
describe('gnubok_sync_bank', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('is a transactions:write tool gated on bank_sync and flagged open-world', () => {
expect(TOOL_SCOPE_MAP.gnubok_sync_bank).toBe('transactions:write')
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_sync_bank).toBe('bank_sync')
expect(tool.annotations.readOnlyHint).toBe(false)
expect(tool.annotations.openWorldHint).toBe(true)
expect(tool.inputSchema).toMatchObject({ additionalProperties: false, required: ['connection_id'] })
})
it('runs the shared sync runner for the company and reports the outcome', async () => {
mocks.triggerConnectionSync.mockResolvedValue({
ok: true,
connection_id: CONNECTION_ID,
bank: 'Swedbank',
imported: 3,
duplicates: 12,
from_date: '2026-08-26',
to_date: '2026-09-02',
last_synced_at: '2026-09-02T09:14:03.000Z',
})
const supabase = {} as never
const result = (await tool.execute(
{ connection_id: CONNECTION_ID },
COMPANY_ID,
'user-1',
supabase,
)) as Record<string, unknown>
expect(mocks.triggerConnectionSync).toHaveBeenCalledWith(
supabase,
expect.objectContaining({ companyId: COMPANY_ID, userId: 'user-1', connectionId: CONNECTION_ID }),
)
expect(result).toMatchObject({
synced: true,
connection_id: CONNECTION_ID,
bank: 'Swedbank',
imported: 3,
duplicates: 12,
last_synced_at: '2026-09-02T09:14:03.000Z',
next_allowed_at: null,
})
expect(result.instructions).toContain('3 new transaction')
})
it('tells the agent nothing was missing when the bank had no news', async () => {
mocks.triggerConnectionSync.mockResolvedValue({
ok: true,
connection_id: CONNECTION_ID,
bank: 'SEB',
imported: 0,
duplicates: 4,
from_date: '2026-08-26',
to_date: '2026-09-02',
last_synced_at: '2026-09-02T09:14:03.000Z',
})
const result = (await tool.execute(
{ connection_id: CONNECTION_ID },
COMPANY_ID,
'user-1',
{} as never,
)) as Record<string, unknown>
expect(result.synced).toBe(true)
expect(result.instructions).toContain('nothing new')
})
it('answers a cooldown in-band with next_allowed_at instead of throwing', async () => {
mocks.triggerConnectionSync.mockResolvedValue({
ok: false,
code: 'BANK_SYNC_COOLDOWN',
connection_id: CONNECTION_ID,
next_allowed_at: '2026-09-02T09:29:03.000Z',
retry_after_seconds: 600,
})
const result = (await tool.execute(
{ connection_id: CONNECTION_ID },
COMPANY_ID,
'user-1',
{} as never,
)) as Record<string, unknown>
expect(result).toMatchObject({
synced: false,
connection_id: CONNECTION_ID,
next_allowed_at: '2026-09-02T09:29:03.000Z',
})
expect(result.instructions).toContain('next_allowed_at')
// A cooldown can follow a FAILED attempt too (durable lease): the agent
// must be told to check freshness rather than assume it.
expect(result.instructions).toContain('last_synced_at')
})
it.each([
'NOT_FOUND',
'BANK_SYNC_NOT_ACTIVE',
'BANK_SYNC_NO_ACCOUNTS',
'BANK_SESSION_EXPIRED',
'BANK_SYNC_FAILED',
] as const)('throws a coded error for %s so the dispatch envelope carries the remediation', async (code) => {
mocks.triggerConnectionSync.mockResolvedValue({ ok: false, code, connection_id: CONNECTION_ID })
await expect(
tool.execute({ connection_id: CONNECTION_ID }, COMPANY_ID, 'user-1', {} as never),
).rejects.toMatchObject({ code })
})
it('passes a blank connection_id through as an empty string (runner answers NOT_FOUND)', async () => {
mocks.triggerConnectionSync.mockResolvedValue({ ok: false, code: 'NOT_FOUND', connection_id: '' })
await expect(tool.execute({}, COMPANY_ID, 'user-1', {} as never)).rejects.toMatchObject({
code: 'NOT_FOUND',
})
expect(mocks.triggerConnectionSync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ connectionId: '' }),
)
})
})
+80
View File
@@ -251,6 +251,7 @@ import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplica
import { getEmailService } from '@/lib/email/service'
import { hasCapability, capabilityBlockedError } from '@/lib/entitlements/has-capability'
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
import { triggerConnectionSync } from '@/extensions/general/enable-banking/lib/trigger-sync'
import {
completePendingDocumentUpload,
createPendingDocumentUpload,
@@ -3759,6 +3760,85 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_sync_bank',
keywords: ['synka bank', 'banksynk', 'hämta banktransaktioner', 'uppdatera bank', 'synka nu'],
title: 'Sync Bank Now',
description:
'Sync one PSD2 bank connection now instead of waiting for the nightly run. Use when gnubok_connect_bank shows a stale last_synced_at on an active connection. Server picks the window; synced=false with next_allowed_at means a sync ran or was attempted within 15 min.',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['connection_id'],
properties: {
connection_id: {
type: 'string',
description: 'connection_id from gnubok_connect_bank.',
},
},
},
outputSchema: {
type: 'object',
properties: {
synced: { type: 'boolean' },
connection_id: { type: 'string' },
bank: { type: ['string', 'null'] },
imported: { type: 'integer' },
duplicates: { type: 'integer' },
last_synced_at: { type: ['string', 'null'] },
next_allowed_at: { type: ['string', 'null'] },
instructions: { type: 'string' },
},
required: ['synced', 'connection_id', 'instructions'],
},
annotations: ANNOTATIONS_WRITE_OPEN_WORLD,
async execute(args, companyId, userId, supabase) {
const connectionId = typeof args.connection_id === 'string' ? args.connection_id.trim() : ''
const result = await triggerConnectionSync(supabase, {
companyId,
userId,
connectionId,
log,
})
if (!result.ok) {
// Cooldown is not a failure: the data is fresh. Say so in-band so the
// agent reads on instead of retrying; everything else is a real
// error and flows through the structured envelope (BANK_SYNC_* codes
// carry the remediation, incl. "hand the user the connect link").
if (result.code === 'BANK_SYNC_COOLDOWN') {
return {
synced: false,
connection_id: result.connection_id,
bank: null,
last_synced_at: null,
next_allowed_at: result.next_allowed_at ?? null,
instructions:
'A sync ran or was attempted on this connection within the last 15 minutes. Check last_synced_at via gnubok_connect_bank: if it is fresh, transactions and balances are already current, continue with gnubok_list_uncategorized_transactions. If it is still stale, the previous attempt failed; retry once after next_allowed_at, never before.',
}
}
throw Object.assign(
new Error(`Bank sync refused for connection ${result.connection_id}: ${result.code}`),
{ code: result.code },
)
}
return {
synced: true,
connection_id: result.connection_id,
bank: result.bank,
imported: result.imported,
duplicates: result.duplicates,
last_synced_at: result.last_synced_at,
next_allowed_at: null,
instructions:
result.imported > 0
? `${result.imported} new transaction(s) fetched from the bank (${result.from_date} to ${result.to_date}). Continue with gnubok_list_uncategorized_transactions.`
: `The bank had nothing new for ${result.from_date} to ${result.to_date}: the data was already complete. Banks report with up to 48 hours of delay, so today's transactions often arrive tomorrow; do not call again for that.`,
}
},
},
{
name: 'gnubok_connect_skatteverket',
keywords: ['skatteverket', 'koppla skatteverket', 'deklarationsombud'],
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `144`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `145`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -87,6 +87,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"PATCH /api/v1/companies/:companyId/suppliers/:id",
"PATCH /api/v1/companies/:companyId/webhooks/:id",
"POST /api/v1/companies",
"POST /api/v1/companies/:companyId/bank-connections/:connectionId/sync",
"POST /api/v1/companies/:companyId/customers",
"POST /api/v1/companies/:companyId/customers/bulk-create",
"POST /api/v1/companies/:companyId/dimensions/:id/values",
@@ -65,6 +65,7 @@ import {
} from '@/lib/api/idempotency'
import { withApiV1 } from '../with-api-v1'
import { dryRunPreview } from '../dry-run'
import { v1ErrorResponseFromCode } from '../errors'
import { created } from '../response'
import { registerEndpoint } from '../registry'
@@ -340,6 +341,39 @@ describe('withApiV1: idempotent replay of real commits (must not regress)', () =
expect(committed).toEqual(['inv-1'])
})
// A handler-level 429 (e.g. bank-connections.sync's cooldown) says "not
// now". Caching it would replay the throttle under the same key until the
// cache TTL, long after the cooldown itself has passed, and as a 400.
it('never caches a 429 so a same-key retry after Retry-After runs the handler', async () => {
let calls = 0
const route = withApiV1<{ params: Promise<{ companyId: string }> }>(
'invoices.create',
async (_request, ctx) => {
calls += 1
if (calls === 1) {
return v1ErrorResponseFromCode('RATE_LIMITED', ctx.log, {
requestId: ctx.requestId,
retryAfterSeconds: 1,
})
}
return created({ id: 'inv-after-cooldown' }, { requestId: ctx.requestId })
},
{ requireScope: 'invoices:write' },
)
const first = await route(postInvoice({ key: 'key-7' }), companyParams(COMPANY_ID))
expect(first.status).toBe(429)
expect(mockStoreIdempotency).not.toHaveBeenCalled()
const second = await route(postInvoice({ key: 'key-7' }), companyParams(COMPANY_ID))
expect(second.status).toBe(201)
// The real commit is cached as before; only the throttle was not.
expect(mockStoreIdempotency).toHaveBeenCalledTimes(1)
expect(second.headers.get('Idempotent-Replayed')).toBeNull()
expect((await second.json()).data.id).toBe('inv-after-cooldown')
expect(calls).toBe(2)
})
it('still rejects the same key carrying a different body', async () => {
const { route, committed } = makeInvoiceRoute()
+1
View File
@@ -76,6 +76,7 @@ import '@/app/api/v1/companies/[companyId]/cash-accounts/route'
// F2: PSD2 bank-connection health (last_synced_at, consent_expires) so
// integrations can detect stale bank data instead of trusting it blindly.
import '@/app/api/v1/companies/[companyId]/bank-connections/route'
import '@/app/api/v1/companies/[companyId]/bank-connections/[connectionId]/sync/route'
// Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals.
import '@/app/api/v1/companies/[companyId]/suppliers/route'
+13 -1
View File
@@ -608,7 +608,19 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
// happen, and caching it under a real Idempotency-Key is exactly how
// the documented "preview, then commit with the same key" flow used
// to lose the commit. A simulation has nothing worth replaying.
if (idempotencyKey && isMutation && companyId && !dryRun && response.status < 500) {
//
// Never cache a 429 either: a throttle says "not now", and replaying
// it under the same key would turn a 15-minute cooldown into the
// cache's 24-hour TTL (the documented retry is "same request after
// Retry-After", which is exactly a same-key retry).
if (
idempotencyKey &&
isMutation &&
companyId &&
!dryRun &&
response.status < 500 &&
response.status !== 429
) {
try {
const body = await response.clone().json().catch(() => ({}))
const reqHash = buildRequestHash({
+1
View File
@@ -209,6 +209,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_create_company: 'companies:write',
gnubok_lookup_company: 'companies:read',
gnubok_connect_bank: 'companies:read',
gnubok_sync_bank: 'transactions:write',
gnubok_connect_skatteverket: 'companies:read',
gnubok_connect_migration: 'companies:read',
gnubok_get_company_settings: 'companies:read',
+3
View File
@@ -152,6 +152,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// consent_expires). companies:read, mirroring the MCP gnubok_connect_bank
// mapping: connection metadata, no transaction data.
'GET /api/v1/companies/:companyId/bank-connections': 'companies:read',
// Triggering a sync writes transactions: transactions:write, like the
// MCP gnubok_sync_bank twin.
'POST /api/v1/companies/:companyId/bank-connections/:connectionId/sync': 'transactions:write',
// Reconciliation (legacy bank-only routes; kept as aliases of the
// account-keyed routes below, with their original scopes)
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
+68
View File
@@ -0,0 +1,68 @@
/**
* Core <-> Enable Banking extension boundary for the agent-triggered sync.
*
* `lib/` and `app/api/v1/` cannot import from `@/extensions/` (CI guard,
* core-build.yml), so the v1 REST endpoint
* POST /companies/{id}/bank-connections/{connectionId}/sync reaches the
* runner only through the registry-resolved `services` channel: same
* pattern as lib/skatteverket/declaration-status.ts. This module holds the
* SHARED shapes so the extension (which may import core freely) and the v1
* route agree on the contract without core ever importing the extension.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
/** A connection synced more recently than this answers with a cooldown. */
export const SYNC_COOLDOWN_MS = 15 * 60 * 1000
export interface TriggerSyncLogger {
info: (message: string, meta?: Record<string, unknown>) => void
warn: (message: string, meta?: Record<string, unknown>) => void
error: (message: string, meta?: Record<string, unknown>) => void
}
export interface TriggerSyncInput {
companyId: string
userId: string
connectionId: string
log: TriggerSyncLogger
/** Clock override for tests. */
now?: number
}
export type TriggerSyncFailureCode =
| 'NOT_FOUND'
| 'BANK_SYNC_NOT_ACTIVE'
| 'BANK_SYNC_NO_ACCOUNTS'
| 'BANK_SYNC_COOLDOWN'
| 'BANK_SESSION_EXPIRED'
| 'BANK_SYNC_FAILED'
export type TriggerSyncResult =
| {
ok: true
connection_id: string
bank: string | null
imported: number
duplicates: number
from_date: string
to_date: string
last_synced_at: string
}
| {
ok: false
code: TriggerSyncFailureCode
connection_id: string
status?: string
/** ISO timestamp after which a sync is accepted again (cooldown only). */
next_allowed_at?: string
/** Seconds until next_allowed_at (cooldown only). */
retry_after_seconds?: number
}
/** Services the enable-banking extension registers for core callers. */
export interface EnableBankingServices {
triggerConnectionSync: (
supabase: SupabaseClient,
input: TriggerSyncInput,
) => Promise<TriggerSyncResult>
}
@@ -29,6 +29,8 @@ const DISPATCH_ONLY_MCP_TOOLS = new Set<string>([
// Onboarding connect-link tools: read status + hand out a browser link; no commit counterpart.
'gnubok_connect_bank',
'gnubok_connect_skatteverket',
// Agent-triggered PSD2 sync: inline Enable Banking call, no staged operation.
'gnubok_sync_bank',
])
describe('MCP_TOOL_CAPABILITY_MAP', () => {
@@ -38,6 +40,7 @@ describe('MCP_TOOL_CAPABILITY_MAP', () => {
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
gnubok_agi_submit: CAPABILITY.skatteverket,
gnubok_connect_bank: CAPABILITY.bank_sync,
gnubok_sync_bank: CAPABILITY.bank_sync,
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
// Dispatch-only AI tools: inline Bedrock OCR, no staged operation. The
// signed-URL pair is gated at create AND complete so a free-tier key can
+5 -3
View File
@@ -122,9 +122,9 @@ export function isConnectorCapability(key: CapabilityKey): boolean {
* The document upload tools invoke AI (Bedrock document OCR via
* extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall
* the HTTP inbox upload/attach/retry paths enforce. Without these entries a
* free-tier API key could trigger paid AI extraction. bank_sync gates only
* gnubok_connect_bank (the onboarding connect link); the sync itself is
* cron/HTTP only.
* free-tier API key could trigger paid AI extraction. bank_sync gates
* gnubok_connect_bank (the onboarding connect link) and gnubok_sync_bank
* (the agent-triggered PSD2 sync, a paid Enable Banking call per account).
*/
export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
gnubok_send_invoice: CAPABILITY.email_send,
@@ -132,6 +132,8 @@ export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, Capability
gnubok_agi_submit: CAPABILITY.skatteverket,
// Onboarding connect-link tools (issue #1814): gated like the links' targets.
gnubok_connect_bank: CAPABILITY.bank_sync,
// Agent-triggered PSD2 sync: a paid Enable Banking call per account.
gnubok_sync_bank: CAPABILITY.bank_sync,
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
// AI document OCR (Bedrock): the inbox's paid extraction, reachable via MCP.
gnubok_create_document_upload: CAPABILITY.ai,
+43
View File
@@ -1989,6 +1989,48 @@ const BANK_FILE: Record<string, StructuredErrorEntry> = {
},
}
/**
* Agent-triggered PSD2 sync (v1 bank-connections sync + MCP gnubok_sync_bank).
* Emitted by extensions/general/enable-banking/lib/trigger-sync.ts.
*/
const BANK_SYNC: Record<string, StructuredErrorEntry> = {
BANK_SYNC_NOT_ACTIVE: {
httpStatus: 409,
message_sv: 'Bankanslutningen är inte aktiv och kan inte synkas. Förnya den med BankID i webbläsaren.',
message_en: 'The bank connection is not active and cannot be synced. It needs BankID re-authorisation in a browser.',
remediation: {
description: 'Give the user the connect_url from gnubok_connect_bank (or GET /bank-connections); only they can re-authorise with BankID.',
tool: 'gnubok_connect_bank',
},
},
BANK_SYNC_NO_ACCOUNTS: {
httpStatus: 409,
message_sv: 'Inga konton är valda för synkning. Aktivera minst ett konto under Inställningar, Bank.',
message_en: 'No accounts are selected for syncing. The user must enable at least one under Settings, Bank.',
},
BANK_SYNC_COOLDOWN: {
httpStatus: 429,
message_sv: 'Anslutningen synkades nyligen. Vänta tills next_allowed_at innan du synkar igen.',
message_en: 'This connection was synced recently. Wait until next_allowed_at before syncing again; the data you have is already fresh.',
retryable: true,
},
BANK_SESSION_EXPIRED: {
httpStatus: 409,
message_sv: 'Bankanslutningen har löpt ut. Förnya anslutningen med BankID för att fortsätta synka.',
message_en: 'The bank session has expired. The connection is now marked expired; only the user can renew it with BankID in a browser.',
remediation: {
description: 'Give the user the connect_url from gnubok_connect_bank (or GET /bank-connections). Do not retry: no API call can revive a dead consent.',
tool: 'gnubok_connect_bank',
},
},
BANK_SYNC_FAILED: {
httpStatus: 502,
message_sv: 'Banksynkningen misslyckades. Försök igen om en stund, eller förnya anslutningen om felet kvarstår.',
message_en: 'The bank sync failed upstream. Retry after the cooldown; if it keeps failing the user should renew the connection.',
retryable: true,
},
}
const SKATTEKONTO_FILE: Record<string, StructuredErrorEntry> = {
SKATTEKONTO_FILE_NO_FILE: {
httpStatus: 400,
@@ -4172,6 +4214,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...TAX_DECL,
...SIE_IMPORT,
...BANK_FILE,
...BANK_SYNC,
...SKATTEKONTO_FILE,
...OPENING_BALANCE_IMPORT,
...REGISTER_IMPORT,
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { daysUntilConsentExpiry, getChipState } from '../bank-sync-chip-state'
const NOW = Date.parse('2026-09-02T08:00:00Z')
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
const at = (msFromNow: number) => new Date(NOW + msFromNow).toISOString()
function row(overrides: Partial<Parameters<typeof getChipState>[0][number]> = {}) {
return {
id: 'conn-1',
status: 'active',
last_synced_at: at(-2 * HOUR_MS),
consent_expires: at(60 * DAY_MS),
...overrides,
}
}
describe('daysUntilConsentExpiry', () => {
it('rounds a partial day up so "1 day left" never reads as 0', () => {
expect(daysUntilConsentExpiry(at(0.4 * DAY_MS), NOW)).toBe(1)
expect(daysUntilConsentExpiry(at(6.5 * DAY_MS), NOW)).toBe(7)
})
it('floors at zero once the consent has passed', () => {
expect(daysUntilConsentExpiry(at(-3 * DAY_MS), NOW)).toBe(0)
})
it('is null without a usable timestamp', () => {
expect(daysUntilConsentExpiry(null, NOW)).toBeNull()
expect(daysUntilConsentExpiry(undefined, NOW)).toBeNull()
expect(daysUntilConsentExpiry('nope', NOW)).toBeNull()
})
})
describe('getChipState', () => {
it('is hidden without connections', () => {
expect(getChipState([], { now: NOW })).toEqual({ kind: 'none' })
})
it('reads healthy for a recent sync with a distant consent', () => {
expect(getChipState([row()], { now: NOW })).toEqual({ kind: 'healthy', mostRecent: at(-2 * HOUR_MS) })
})
it('warns when a live consent ends within seven days', () => {
expect(getChipState([row({ consent_expires: at(7 * DAY_MS) })], { now: NOW })).toEqual({
kind: 'expiring',
daysLeft: 7,
count: 1,
})
})
it('stays quiet at eight days', () => {
expect(getChipState([row({ consent_expires: at(8 * DAY_MS) })], { now: NOW }).kind).toBe('healthy')
})
it('reports the soonest expiry and how many are affected', () => {
const state = getChipState(
[
row({ id: 'a', consent_expires: at(5 * DAY_MS) }),
row({ id: 'b', consent_expires: at(2 * DAY_MS) }),
row({ id: 'c', consent_expires: at(30 * DAY_MS) }),
],
{ now: NOW },
)
expect(state).toEqual({ kind: 'expiring', daysLeft: 2, count: 2 })
})
it('ranks a dead connection above an expiring one', () => {
const state = getChipState(
[
row({ id: 'a', status: 'expired' }),
row({ id: 'b', consent_expires: at(1 * DAY_MS) }),
],
{ now: NOW },
)
expect(state).toEqual({ kind: 'attention', count: 1 })
})
it('ranks expiring above stale: the deadline matters more than the age', () => {
const state = getChipState(
[row({ last_synced_at: at(-3 * DAY_MS), consent_expires: at(3 * DAY_MS) })],
{ now: NOW },
)
expect(state.kind).toBe('expiring')
})
it('ignores the consent on rows that are not live yet', () => {
const state = getChipState(
[row({ status: 'pending_selection', consent_expires: at(1 * DAY_MS), last_synced_at: null })],
{ now: NOW },
)
expect(state).toEqual({ kind: 'healthy', mostRecent: null })
})
it('reads stale after 36 hours without a sync', () => {
expect(getChipState([row({ last_synced_at: at(-37 * HOUR_MS) })], { now: NOW })).toEqual({
kind: 'stale',
mostRecent: at(-37 * HOUR_MS),
})
})
it('reads paused when the company lacks the bank_sync entitlement', () => {
// 56 of 191 active connections on prod sat in this state on 2026-09-01:
// the cron skips them, so they are neither dead nor merely stale.
const state = getChipState([row({ last_synced_at: at(-20 * DAY_MS) })], {
now: NOW,
hasBankSync: false,
})
expect(state).toEqual({ kind: 'paused' })
})
it('ranks paused above a dead connection: renewing without a subscription changes nothing', () => {
const state = getChipState([row({ status: 'expired' })], { now: NOW, hasBankSync: false })
expect(state).toEqual({ kind: 'paused' })
})
it('stays hidden without connections even when unentitled', () => {
expect(getChipState([], { now: NOW, hasBankSync: false })).toEqual({ kind: 'none' })
})
it('tolerates rows without the consent column', () => {
const state = getChipState([{ id: 'x', status: 'active', last_synced_at: at(-HOUR_MS) }], { now: NOW })
expect(state.kind).toBe('healthy')
})
})
+95
View File
@@ -0,0 +1,95 @@
/**
* State machine for the bank sync status chip on the transactions page.
*
* Pure module (no React, no fetch) so the precedence between the states is
* unit-testable. Precedence, highest first:
*
* paused the company has no bank_sync entitlement: the cron skips it,
* so nothing below applies until the subscription is back
* attention a connection is expired or errored: only BankID fixes it
* expiring a live consent ends within CONSENT_WARNING_DAYS: renew in time
* stale nothing synced for STALE_THRESHOLD_MS: check the connection
* healthy synced recently
*
* The expiring threshold matches the 7-day consent-expiry email in the sync
* cron, so the chip and the mail warn on the same day.
*/
export interface ConnectionRow {
id: string
status: string | null
last_synced_at: string | null
consent_expires?: string | null
}
export const STALE_THRESHOLD_MS = 36 * 60 * 60 * 1000
export const CONSENT_WARNING_DAYS = 7
const DAY_MS = 24 * 60 * 60 * 1000
export type ChipState =
| { kind: 'none' }
| { kind: 'paused' }
| { kind: 'attention'; count: number }
| { kind: 'expiring'; daysLeft: number; count: number }
| { kind: 'stale'; mostRecent: string }
| { kind: 'healthy'; mostRecent: string | null }
/** Whole days until the consent ends, floored at 0; null when unknown. */
export function daysUntilConsentExpiry(
consentExpires: string | null | undefined,
now: number,
): number | null {
if (!consentExpires) return null
const expiresAt = new Date(consentExpires).getTime()
if (!Number.isFinite(expiresAt)) return null
return Math.max(0, Math.ceil((expiresAt - now) / DAY_MS))
}
export interface ChipStateOptions {
/** Clock override for tests; defaults to Date.now() at call time. */
now?: number
/**
* Whether the company holds the bank_sync capability. Without it the daily
* cron skips every connection, so rows keep status=active with a frozen
* last_synced_at and would otherwise read as a mysterious "stale".
*/
hasBankSync?: boolean
}
export function getChipState(
rows: ConnectionRow[],
{ now = Date.now(), hasBankSync = true }: ChipStateOptions = {},
): ChipState {
if (rows.length === 0) return { kind: 'none' }
if (!hasBankSync) return { kind: 'paused' }
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
if (needsAttention.length > 0) {
return { kind: 'attention', count: needsAttention.length }
}
// Only live connections can be "about to expire": a pending row has no
// consent yet, and expired ones were caught above.
const expiring = rows
.filter((r) => r.status === 'active')
.map((r) => daysUntilConsentExpiry(r.consent_expires, now))
.filter((d): d is number => d !== null && d <= CONSENT_WARNING_DAYS)
if (expiring.length > 0) {
return { kind: 'expiring', daysLeft: Math.min(...expiring), count: expiring.length }
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
if (mostRecent && now - new Date(mostRecent).getTime() > STALE_THRESHOLD_MS) {
return { kind: 'stale', mostRecent }
}
return { kind: 'healthy', mostRecent: mostRecent ?? null }
}
+4
View File
@@ -5860,6 +5860,10 @@
"bank_sync_age_hours": "{count} h ago",
"bank_sync_age_days": "{count} d ago",
"bank_sync_stale_warning": "Last sync was over 36 hours ago: check the connection",
"bank_sync_expiring_one": "Bank consent expires {days, plural, =0 {today} =1 {in 1 day} other {in # days}}: renew with BankID",
"bank_sync_expiring_many": "{count} bank consents expire, the first {days, plural, =0 {today} =1 {in 1 day} other {in # days}}: renew with BankID",
"bank_sync_paused_subscription": "Bank sync is paused: no subscription, upgrade to continue",
"bank_sync_paused_connector_key": "Bank sync is paused: connector key missing",
"bank_sync_latency_hint": "Banks report transactions with up to 48 hours of delay. Today's transactions often only appear the next morning.",
"bank_sync_button_now": "Sync now",
"bank_sync_button_syncing": "Syncing…",
+4
View File
@@ -5860,6 +5860,10 @@
"bank_sync_age_hours": "{count} tim sedan",
"bank_sync_age_days": "{count} d sedan",
"bank_sync_stale_warning": "Senaste synk var över 36 timmar sedan: kontrollera anslutningen",
"bank_sync_expiring_one": "Banksamtycket löper ut {days, plural, =0 {idag} =1 {om 1 dag} other {om # dagar}}: förnya med BankID",
"bank_sync_expiring_many": "{count} banksamtycken löper ut, det första {days, plural, =0 {idag} =1 {om 1 dag} other {om # dagar}}: förnya med BankID",
"bank_sync_paused_subscription": "Banksynken är pausad: abonnemanget saknas, uppgradera för att fortsätta",
"bank_sync_paused_connector_key": "Banksynken är pausad: connector-nyckel saknas",
"bank_sync_latency_hint": "Banker rapporterar transaktioner med upp till 48 timmars fördröjning. Dagens transaktioner syns ofta först nästa morgon.",
"bank_sync_button_now": "Synka nu",
"bank_sync_button_syncing": "Synkar…",
+4 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 144 endpoints.
pagination, scopes), and all 145 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -142,7 +142,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 144 operations. Paths are shown without
API version `2026-05-12`, 145 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (5)
@@ -255,12 +255,13 @@ POST /companies/{companyId}/documents/{id}/link : Link a document to a journal e
POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent]
```
### Banking (26)
### Banking (27)
Full detail: [references/banking.md](references/banking.md)
```text
GET /companies/{companyId}/bank-connections : List PSD2 bank connections with sync freshness and consent expiry [scope:companies:read risk:low idempotent]
POST /companies/{companyId}/bank-connections/{connectionId}/sync : Sync one bank connection now instead of waiting for the nightly run [scope:transactions:write risk:low]
GET /companies/{companyId}/cash-accounts : List bank/cash accounts with the bank-reported balance [scope:transactions:read risk:low idempotent]
POST /companies/{companyId}/imports/bank : Import a bank-file (CSV / XML / CAMT053) [scope:transactions:write risk:medium idempotent]
POST /companies/{companyId}/imports/sie : Import a SIE4 file [scope:bookkeeping:write risk:high idempotent]
@@ -68,6 +68,71 @@ Example response `200`:
---
### `POST /api/v1/companies/{companyId}/bank-connections/{connectionId}/sync`
**Sync one bank connection now instead of waiting for the nightly run.**
`scope:transactions:write · risk:low`
Fetches new transactions and balances for one PSD2 bank connection right away. The window is chosen server-side: the last 7 days, widened to cover any gap since last_synced_at, capped at 90 days. Returns how many transactions were imported and the new last_synced_at. A connection synced within the last 15 minutes is refused with 429 BANK_SYNC_COOLDOWN and next_allowed_at: the data is already fresh. Not dry-runnable: the bank call itself is the side effect.
**Use when:** GET /bank-connections shows a stale last_synced_at on an active connection and you need current bank data before building on it (liquidity, reconciliation, a report), or the user asks for the latest transactions now.
**Do not use for:** Polling. Connections sync every night on their own; call this once when freshness matters, then read /transactions. Fixing a dead connection: status=expired needs BankID in a browser, not a sync.
**Pitfalls:**
- Idempotency-Key is optional here. If you send one, use a fresh key per attempt: a cooldown answer is never cached, but a completed sync is, and replaying it fetches nothing new.
- 429 BANK_SYNC_COOLDOWN follows a recent successful sync OR a recent attempt that failed (the 15-minute lease is taken before the bank is called, on every instance). Compare last_synced_at from GET /bank-connections: if it is fresh, use the data you have; if it is still stale, the previous attempt failed, so retry once after next_allowed_at (Retry-After is set).
- 409 BANK_SESSION_EXPIRED means the bank reported the consent dead during the sync; the connection is now status=expired. Hand the user the connect link; no API call revives it.
- imported: 0 is normal on a quiet account. Banks report with up to 48 hours of delay, so today's transactions often arrive tomorrow.
- Costs one Enable Banking call per enabled account: 403 CAPABILITY_BLOCKED when the company has no bank_sync entitlement.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `connectionId` | path | `string` | yes | |
Response `200`:
```ts
{
data: {
connection_id: string,
bank: string,
imported: number,
duplicates: number,
from_date: string,
to_date: string,
last_synced_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"connection_id": "4f6c…",
"bank": "Swedbank",
"imported": 3,
"duplicates": 12,
"from_date": "2026-08-26",
"to_date": "2026-09-02",
"last_synced_at": "2026-09-02T09:14:03Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/cash-accounts`
**List bank/cash accounts with the bank-reported balance.**
@@ -0,0 +1,23 @@
-- Durable cooldown lease for the agent-triggered bank sync (v1
-- POST /bank-connections/{id}/sync and MCP gnubok_sync_bank).
--
-- The runner promises at most one paid Enable Banking round-trip per
-- connection per 15 minutes. last_synced_at only advances on success, and a
-- process-local attempt map does not survive a cold start or a second
-- serverless instance, so two concurrent agent calls (or retries of a
-- failing connection routed to fresh instances) could each bill the bank.
--
-- The claim is one conditional UPDATE: set sync_lease_until = now + 15 min
-- WHERE sync_lease_until <= now. Postgres row locking serialises concurrent
-- claimers, so exactly one wins; the rest read the held lease and answer
-- BANK_SYNC_COOLDOWN. Failures keep the lease (that is the throttle),
-- success is also covered by last_synced_at. The nightly cron ignores it.
--
-- NOT NULL with an epoch default so the claim is a single literal `<=`
-- filter (a NULL-or-past OR would have to be built at runtime, which the
-- schema guard cannot check): "never claimed" is simply "expired long ago".
ALTER TABLE public.bank_connections
ADD COLUMN IF NOT EXISTS sync_lease_until timestamptz NOT NULL DEFAULT 'epoch';
COMMENT ON COLUMN public.bank_connections.sync_lease_until IS
'Agent-triggered sync cooldown lease: no on-demand sync is accepted before this instant. Claimed atomically by extensions/general/enable-banking/lib/trigger-sync.ts; epoch means never claimed.';