feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)
* feat(api): expose bank-connection freshness in MCP and v1 REST
gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).
Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix
* fix(api): address skeptic findings on bank-connection freshness
- Map the bank-connections group into skills/accounted-api (apiskill:check
crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
a lapsed entitlement now answers with a capability error instead of
status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
connecting is normal, staleness threshold aligned to the UI's 36 hours,
and re-authorisation is only advised for expired/error/consent-out, not
for stale-but-active connections (lapsed subscription or deselected
accounts are the usual causes there).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix
* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling
The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a08bf51ced
commit
b56da5d6c5
@@ -1454,5 +1454,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents).
|
||||
[2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR).
|
||||
[2026-09-01] EB claim guard round 2 (skeptic re-verify): pending_selection rows are asymmetric, not excluded: their ENABLED accounts still claim (attach-created rows hold offered accounts with no cash rows until saved; excluding them reopened the attach-window double-booking), while their disabled flags stay out of deselection memory (unconfirmed callback output). Both fetchAllRows claim queries order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim (fail-open).
|
||||
[2026-09-01] F2 bank-data staleness: ship freshness reads only (last_synced_at/consent_expires/error_message on gnubok_connect_bank + new GET /api/v1/.../bank-connections, scope companies:read mirroring the MCP mapping): the daily cron already syncs server-side, so visibility is what the incident lacked; an agent-triggerable sync is a product bet (EB call cost, runaway agents) and was deferred by Emil.
|
||||
[2026-09-01] Verifikationsserie in the Ny verifikation modal is a closed dropdown instead of a one-letter free-text field: a typo there silently opens a brand-new series with its own number sequence, and the letters only mean anything if everyone uses the same ones. The letters are NOT prescribed by law (BFL 5 kap. 7 § requires only unbroken systematic numbering within each series), and the incumbents disagree: Björn Lundén uses A Huvudserie, F Kundfakturor, I Inbetalningar, L Leverantörsfakturor, N Löner, U Utbetalningar, J Bokslut. We ship FORTNOX's table verbatim (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport), from their own Systemdokumentation, because Fortnox is the system most companies migrate here from and an imported ledger should keep its meaning. REJECTED an earlier draft that labelled A as Kundfakturor: A is the general series manual entries land in (the one point Fortnox and BL agree on, and Fortnox allows manuell kontering ONLY in A), and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Calling it Kundfakturor would mislabel their entire history and the modal's own default. The list is closed but any letter the company already configured, or that a draft was saved with, is appended so no existing value can fall out of the picker. Also: tabbing or clicking into an untouched amount field now proposes the outstanding difference (pre-selected, so typing replaces it) when the row already has an account and the difference belongs on that side. This deliberately reverses part of the note in updateLine that said a balancing amount must never auto-fill: that note was about filling on ACCOUNT selection, which stole the amount before the user had a chance to split it. Filling on focus keeps the split case intact because the proposal is selected text, and it fixes the common moms case where the last line is just the remainder.
|
||||
[2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Tests for GET /api/v1/companies/{companyId}/bank-connections.
|
||||
*
|
||||
* Exercises the real withApiV1 wrapper (auth, scope, company membership)
|
||||
* with the Supabase client 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({}) }
|
||||
})
|
||||
|
||||
const { requireCapabilityMock } = vi.hoisted(() => ({ requireCapabilityMock: vi.fn() }))
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
requireCapability: requireCapabilityMock,
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET } 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 BASE = `http://localhost/api/v1/companies/${COMPANY_ID}/bank-connections`
|
||||
|
||||
const CONNECTION_ROW = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
bank_name: 'Swedbank',
|
||||
status: 'active',
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-31T05:04:12Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
}
|
||||
|
||||
function req(): Request {
|
||||
return new Request(BASE, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function authOk(scopes: string[]) {
|
||||
mockValidate.mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 'user-1',
|
||||
keyId: 'key-1',
|
||||
keyName: 'Test key',
|
||||
scopes,
|
||||
mode: 'live',
|
||||
})
|
||||
}
|
||||
|
||||
const params = { params: Promise.resolve({ companyId: COMPANY_ID }) }
|
||||
|
||||
describe('v1 bank-connections list', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireCapabilityMock.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { role: 'owner' } },
|
||||
bank_connections: { data: [CONNECTION_ROW] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('401 without a valid key', async () => {
|
||||
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('403 INSUFFICIENT_SCOPE when the key lacks companies:read', async () => {
|
||||
authOk(['transactions:read'])
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns the capability-blocked response when bank_sync is not entitled', async () => {
|
||||
authOk(['companies:read'])
|
||||
requireCapabilityMock.mockResolvedValue(
|
||||
Response.json({ error: { code: 'CAPABILITY_BLOCKED' } }, { status: 403 }),
|
||||
)
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBe(403)
|
||||
expect(requireCapabilityMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'bank_sync')
|
||||
})
|
||||
|
||||
it('404 when the key user is not a member of the company', async () => {
|
||||
authOk(['companies:read'])
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: null },
|
||||
}),
|
||||
)
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns connections with freshness fields under qualified names', async () => {
|
||||
authOk(['companies:read'])
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
data: { bank_connections: Array<Record<string, unknown>> }
|
||||
meta: { request_id: string }
|
||||
}
|
||||
expect(body.data.bank_connections).toEqual([
|
||||
{
|
||||
connection_id: '11111111-1111-4111-8111-111111111111',
|
||||
bank: 'Swedbank',
|
||||
status: 'active',
|
||||
since: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-31T05:04:12Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
},
|
||||
])
|
||||
expect(body.meta.request_id).toMatch(/^req_/)
|
||||
})
|
||||
|
||||
it('returns an empty list when the company has no connections', async () => {
|
||||
authOk(['companies:read'])
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { role: 'owner' } },
|
||||
bank_connections: { data: [] },
|
||||
}),
|
||||
)
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { data: { bank_connections: unknown[] } }
|
||||
expect(body.data.bank_connections).toEqual([])
|
||||
})
|
||||
|
||||
it('maps a database error into the v1 error envelope', async () => {
|
||||
authOk(['companies:read'])
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { role: 'owner' } },
|
||||
bank_connections: { data: null, error: { message: 'boom' } },
|
||||
}),
|
||||
)
|
||||
const res = await GET(req(), params)
|
||||
expect(res.status).toBeGreaterThanOrEqual(500)
|
||||
const body = (await res.json()) as { error: { code: string } }
|
||||
expect(body.error.code).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/bank-connections
|
||||
*
|
||||
* List PSD2 bank connections with freshness metadata: last_synced_at,
|
||||
* consent_expires, status, error_message. This is the API-key surface's
|
||||
* answer to "is my bank data current?": a connection whose last_synced_at
|
||||
* is stale, or whose status is expired/error, means the transaction and
|
||||
* balance data downstream is old even though it looks complete.
|
||||
*/
|
||||
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 } from '@/lib/api/v1/errors'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
const BankConnection = z.object({
|
||||
connection_id: z.string(),
|
||||
bank: z.string().nullable(),
|
||||
status: z.enum(['pending', 'pending_selection', 'active', 'expired', 'error']),
|
||||
since: z.string(),
|
||||
last_synced_at: z.string().nullable(),
|
||||
consent_expires: z.string().nullable(),
|
||||
error_message: z.string().nullable(),
|
||||
})
|
||||
|
||||
const BankConnectionsResponse = dataEnvelope(
|
||||
z.object({ bank_connections: z.array(BankConnection) }),
|
||||
)
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'bank-connections.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/bank-connections',
|
||||
summary: 'List PSD2 bank connections with sync freshness and consent expiry.',
|
||||
description:
|
||||
'Returns every bank connection for the company with its status, last successful sync (last_synced_at), consent expiry (consent_expires) and any user-facing error message. Connections sync automatically once a day server-side; this endpoint tells you whether that is still happening.',
|
||||
useWhen:
|
||||
'You need to verify bank data is current before building on it (liquidity, reconciliation, reports), or to detect a dead connection that needs BankID re-authorisation.',
|
||||
doNotUseFor:
|
||||
'Fetching transactions (use /transactions) or account balances (use /cash-accounts). Triggering a sync: not available on this surface; syncing is automatic.',
|
||||
pitfalls: [
|
||||
'last_synced_at is null until the first sync completes (about a minute after connecting); it does NOT mean the connection is broken.',
|
||||
'A connection can hold status=active with a stale last_synced_at (older than ~36 hours): treat the data as suspect, but do NOT assume re-authorisation fixes it. Common causes are a lapsed subscription (this endpoint then answers with a capability error) or every account deselected in settings.',
|
||||
'status=expired means the PSD2 consent is dead: only the user can fix it, with BankID in a browser.',
|
||||
'error_message is Swedish and user-facing: show it verbatim rather than translating.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
bank_connections: [
|
||||
{
|
||||
connection_id: '4f6c…',
|
||||
bank: 'Swedbank',
|
||||
status: 'active',
|
||||
since: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-31T05:04:12Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'companies:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: BankConnectionsResponse },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'bank-connections.list',
|
||||
async (_request, ctx) => {
|
||||
// Mirror the MCP twin (gnubok_connect_bank is gated on bank_sync via
|
||||
// MCP_TOOL_CAPABILITY_MAP): without this, a company whose entitlement
|
||||
// lapsed reads status=active with a frozen last_synced_at and the docs
|
||||
// steer the agent toward a needless BankID re-auth. The capability error
|
||||
// names the real cause instead.
|
||||
const capBlocked = await requireCapability(ctx.supabase, ctx.companyId!, CAPABILITY.bank_sync)
|
||||
if (capBlocked) return capBlocked
|
||||
|
||||
try {
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, status, created_at, last_synced_at, consent_expires, error_message')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) throw error
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
bank_name: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
last_synced_at: string | null
|
||||
consent_expires: string | null
|
||||
error_message: string | null
|
||||
}
|
||||
const bank_connections = ((data ?? []) as Row[]).map((c) => ({
|
||||
connection_id: c.id,
|
||||
bank: c.bank_name,
|
||||
status: c.status,
|
||||
since: c.created_at,
|
||||
last_synced_at: c.last_synced_at,
|
||||
consent_expires: c.consent_expires,
|
||||
error_message: c.error_message,
|
||||
}))
|
||||
return ok({ bank_connections }, { requestId: ctx.requestId })
|
||||
} catch (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -86,15 +86,52 @@ describe('onboarding connect-link tools', () => {
|
||||
expect(named.instructions).not.toContain('BETTER LINK AVAILABLE')
|
||||
})
|
||||
|
||||
it('bank: reports an active connection', async () => {
|
||||
it('bank: reports an active connection with freshness fields', async () => {
|
||||
const { from } = listClient([
|
||||
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
|
||||
{
|
||||
id: 'c1',
|
||||
bank_name: 'Swedbank',
|
||||
status: 'active',
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-30T05:00:00Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
},
|
||||
])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(true)
|
||||
expect(result.connections).toEqual([
|
||||
{ connection_id: 'c1', bank: 'Swedbank', status: 'active', since: '2026-08-01T00:00:00Z' },
|
||||
{
|
||||
connection_id: 'c1',
|
||||
bank: 'Swedbank',
|
||||
status: 'active',
|
||||
since: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-30T05:00:00Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
},
|
||||
])
|
||||
expect(result.instructions).toContain('last_synced_at')
|
||||
})
|
||||
|
||||
it('bank: surfaces the error_message and null sync stamp on a dead connection', async () => {
|
||||
const { from } = listClient([
|
||||
{
|
||||
id: 'c2',
|
||||
bank_name: 'SEB',
|
||||
status: 'expired',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_synced_at: null,
|
||||
consent_expires: '2026-07-15T00:00:00Z',
|
||||
error_message: 'Bankkopplingen behöver förnyas.',
|
||||
},
|
||||
])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(false)
|
||||
const rows = result.connections as Array<Record<string, unknown>>
|
||||
expect(rows[0].last_synced_at).toBeNull()
|
||||
expect(rows[0].consent_expires).toBe('2026-07-15T00:00:00Z')
|
||||
expect(rows[0].error_message).toBe('Bankkopplingen behöver förnyas.')
|
||||
})
|
||||
|
||||
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
|
||||
|
||||
@@ -3616,7 +3616,24 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
connected: { type: 'boolean' },
|
||||
connections: { type: 'array', items: { type: 'object' } },
|
||||
connections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connection_id: { type: 'string' },
|
||||
bank: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
since: { type: 'string' },
|
||||
// Semantics live in the instructions string (runtime output), not
|
||||
// here: every byte of this schema is charged against the
|
||||
// tools/list context-budget ceiling (payload-size.bench.test.ts).
|
||||
last_synced_at: { type: ['string', 'null'] },
|
||||
consent_expires: { type: ['string', 'null'] },
|
||||
error_message: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
connect_url: { type: 'string' },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
@@ -3632,12 +3649,20 @@ export const tools: McpTool[] = [
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, status, created_at')
|
||||
.select('id, bank_name, status, created_at, last_synced_at, consent_expires, error_message')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) throw error
|
||||
const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }>
|
||||
const connections = (data ?? []) as Array<{
|
||||
id: string
|
||||
bank_name: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
last_synced_at: string | null
|
||||
consent_expires: string | null
|
||||
error_message: string | null
|
||||
}>
|
||||
const active = connections.filter((c) => c.status === 'active')
|
||||
// A named bank deep-links straight into that bank's consent (the page
|
||||
// auto-starts it; unknown names fall back to the prefilled picker).
|
||||
@@ -3652,11 +3677,14 @@ export const tools: McpTool[] = [
|
||||
bank: c.bank_name,
|
||||
status: c.status,
|
||||
since: c.created_at,
|
||||
last_synced_at: c.last_synced_at,
|
||||
consent_expires: c.consent_expires,
|
||||
error_message: c.error_message,
|
||||
})),
|
||||
connect_url: connectUrl,
|
||||
instructions:
|
||||
active.length > 0
|
||||
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
|
||||
? 'At least one bank is connected and syncing. Check last_synced_at on each connection: older than 36 hours means transactions and balances may be STALE; warn the user before building on them. A null last_synced_at right after connecting is normal (the first sync lands within a minute). Match the remedy to the cause: status expired/error or consent_expires near or past needs BankID re-authorisation via the connect_url; a stale last_synced_at while status is active usually means the subscription lapsed or every account is deselected, so point the user to Installningar -> Bank instead of re-authorising. To add another bank, give the user the connect_url.'
|
||||
: (requestedBank
|
||||
? ''
|
||||
: 'BETTER LINK AVAILABLE: if you know (or can ask) which bank the company uses, call this tool again with bank=<name>; the link then opens that bank\'s consent directly instead of a picker. ') +
|
||||
|
||||
@@ -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`] = `143`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `144`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -19,6 +19,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"GET /api/v1/companies",
|
||||
"GET /api/v1/companies/:companyId/accounts",
|
||||
"GET /api/v1/companies/:companyId/articles",
|
||||
"GET /api/v1/companies/:companyId/bank-connections",
|
||||
"GET /api/v1/companies/:companyId/cash-accounts",
|
||||
"GET /api/v1/companies/:companyId/compliance/check",
|
||||
"GET /api/v1/companies/:companyId/customers",
|
||||
|
||||
@@ -73,6 +73,10 @@ import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
|
||||
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'
|
||||
|
||||
// Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals.
|
||||
import '@/app/api/v1/companies/[companyId]/suppliers/route'
|
||||
import '@/app/api/v1/companies/[companyId]/suppliers/[id]/route'
|
||||
|
||||
@@ -148,6 +148,10 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
// Cash accounts: the bank/kassa register incl. the bank-reported balance
|
||||
// (booked + available + balance_updated_at) from the PSD2 sync.
|
||||
'GET /api/v1/companies/:companyId/cash-accounts': 'transactions:read',
|
||||
// Bank connections: PSD2 connection health (status, last_synced_at,
|
||||
// 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',
|
||||
// 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',
|
||||
|
||||
@@ -109,10 +109,11 @@ const GROUPS: Array<{ file: string; title: string; members: string[]; blurb: str
|
||||
{
|
||||
file: 'banking.md',
|
||||
title: 'Banking',
|
||||
members: ['transactions', 'cash-accounts', 'reconciliation', 'imports'],
|
||||
members: ['transactions', 'cash-accounts', 'bank-connections', 'reconciliation', 'imports'],
|
||||
blurb:
|
||||
'Bank transactions (ingest, categorize, match against invoices), cash accounts with the ' +
|
||||
'bank-reported balance, bank reconciliation runs, and file imports (SIE, bank statements).',
|
||||
'bank-reported balance, PSD2 connection health (sync freshness, consent expiry), ' +
|
||||
'bank reconciliation runs, and file imports (SIE, bank statements).',
|
||||
},
|
||||
{
|
||||
file: 'employees.md',
|
||||
|
||||
@@ -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 143 endpoints.
|
||||
pagination, scopes), and all 144 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`, 143 operations. Paths are shown without
|
||||
API version `2026-05-12`, 144 operations. Paths are shown without
|
||||
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
|
||||
|
||||
### Core (5)
|
||||
@@ -255,11 +255,12 @@ 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 (25)
|
||||
### Banking (26)
|
||||
|
||||
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]
|
||||
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]
|
||||
|
||||
@@ -2,11 +2,72 @@
|
||||
|
||||
# Banking endpoints
|
||||
|
||||
Bank transactions (ingest, categorize, match against invoices), cash accounts with the bank-reported balance, bank reconciliation runs, and file imports (SIE, bank statements).
|
||||
Bank transactions (ingest, categorize, match against invoices), cash accounts with the bank-reported balance, PSD2 connection health (sync freshness, consent expiry), bank reconciliation runs, and file imports (SIE, bank statements).
|
||||
|
||||
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)
|
||||
are in SKILL.md and are not repeated per endpoint.
|
||||
|
||||
### `GET /api/v1/companies/{companyId}/bank-connections`
|
||||
|
||||
**List PSD2 bank connections with sync freshness and consent expiry.**
|
||||
`scope:companies:read · risk:low · idempotent`
|
||||
|
||||
Returns every bank connection for the company with its status, last successful sync (last_synced_at), consent expiry (consent_expires) and any user-facing error message. Connections sync automatically once a day server-side; this endpoint tells you whether that is still happening.
|
||||
|
||||
**Use when:** You need to verify bank data is current before building on it (liquidity, reconciliation, reports), or to detect a dead connection that needs BankID re-authorisation.
|
||||
**Do not use for:** Fetching transactions (use /transactions) or account balances (use /cash-accounts). Triggering a sync: not available on this surface; syncing is automatic.
|
||||
|
||||
**Pitfalls:**
|
||||
- last_synced_at is null until the first sync completes (about a minute after connecting); it does NOT mean the connection is broken.
|
||||
- A connection can hold status=active with a stale last_synced_at (older than ~36 hours): treat the data as suspect, but do NOT assume re-authorisation fixes it. Common causes are a lapsed subscription (this endpoint then answers with a capability error) or every account deselected in settings.
|
||||
- status=expired means the PSD2 consent is dead: only the user can fix it, with BankID in a browser.
|
||||
- error_message is Swedish and user-facing: show it verbatim rather than translating.
|
||||
|
||||
| Parameter | In | Type | Required | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `companyId` | path | `string` | yes | |
|
||||
|
||||
Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
bank_connections: { connection_id: string, bank: string, status: "pending" | "pending_selection" | "active" | "expired" | "error", since: string, last_synced_at: string, consent_expires: string, error_message: 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": {
|
||||
"bank_connections": [
|
||||
{
|
||||
"connection_id": "4f6c…",
|
||||
"bank": "Swedbank",
|
||||
"status": "active",
|
||||
"since": "2026-08-01T00:00:00Z",
|
||||
"last_synced_at": "2026-08-31T05:04:12Z",
|
||||
"consent_expires": "2026-11-01T00:00:00Z",
|
||||
"error_message": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"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.**
|
||||
|
||||
Reference in New Issue
Block a user