feat(connect): bank sync through the connector operation, with a per-company canary (#2205)

* feat(connect): bank sync through the connector operation, with a per-company canary

In connector mode the enable-banking sync no longer pages Enable Banking on
the instance: it calls POST /api/connect/bank/sync on the hosted service with
the session id it holds and the account, and receives booked, normalized
rows plus the raw provider pages to archive. Everything downstream is shared
with the direct path (stored external ids computed here from booking_date,
amount and the account scope; ingest; archive; balance refresh), so a company
that moves to the connector produces byte-identical keys. A 410 from the
service maps onto the same SessionExpiredError the direct path throws.

bankConnectorMode(companyId) gains CONNECT_BANK_CANARY_COMPANIES: listed
companies use the connector even while the installation has its own Enable
Banking credentials, which is how hosted Accounted moves its bank sync to
Connect a few companies at a time before dropping its keys. The contract
package gains the bank sync request/response schemas (2026-09-03).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(connect): calendar-valid dates, body read inside the timeout, service origin as the default

Review follow-ups on #2205. The contract validates date_from, date_to and
booking_date with z.iso.date() (2026-02-30 and an empty booking date are
refused; the installation derives its stored keys from booking_date). The
connector sync reads the response body inside the timeout window so a
service that stalls the body cannot hold the sync open. DEFAULT_CONNECT_BASE_URL
now names the connector service (connect.accounted.se), which is where the
sync operation exists; the hosted app's copy of the connector routes is
legacy and hosted Accounted itself sets GNUBOK_CONNECT_URL explicitly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 11:06:47 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 828628d882
commit 4c6feea64d
10 changed files with 413 additions and 56 deletions
@@ -4,6 +4,8 @@ import {
CONNECTOR_KEY_PREFIX,
CONTRACT_VERSION,
PEPPOL_OPERATIONS,
bankSyncRequestSchema,
bankSyncResponseSchema,
connectorEntitlementsSchema,
connectorErrorSchema,
connectorSyncReportSchema,
@@ -106,3 +108,19 @@ describe('peppol schemas', () => {
expect(peppolInboundMessageSchema.safeParse({ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Order', payload: {} }).success).toBe(false)
})
})
describe('bank sync operation', () => {
it('validates the request and response shapes', () => {
expect(bankSyncRequestSchema.safeParse({ session_id: 's', account_uid: 'a', account_currency: 'SEK', date_from: '2026-08-01', date_to: '2026-09-03', strategy: 'longest' }).success).toBe(true)
expect(bankSyncRequestSchema.safeParse({ session_id: 's', account_uid: 'a', account_currency: 'SEKK' }).success).toBe(false)
expect(bankSyncRequestSchema.safeParse({ session_id: 's', account_uid: 'a', account_currency: 'SEK', date_from: '2026/08/01' }).success).toBe(false)
expect(bankSyncRequestSchema.safeParse({ session_id: 's', account_uid: 'a', account_currency: 'SEK', date_from: '2026-02-30' }).success).toBe(false)
expect(bankSyncResponseSchema.safeParse({ transactions: [{ booking_date: '', amount: 1, currency: 'SEK', description: 'x', counterparty_name: null, counterparty_account: null, reference: null, merchant_category_code: null, bank_transaction_code: null, proprietary_bank_transaction_code: null }], raw_pages: [], skipped_pending: 0, returned_min_booking_date: null, returned_max_booking_date: null, effective_date_from: null, pages: 0 }).success).toBe(false)
expect(
bankSyncResponseSchema.safeParse({
transactions: [{ booking_date: '2026-09-01', amount: -12.5, currency: 'SEK', description: 'x', counterparty_name: null, counterparty_account: null, reference: null, merchant_category_code: null, bank_transaction_code: null, proprietary_bank_transaction_code: null }],
raw_pages: ['{}'], skipped_pending: 0, returned_min_booking_date: '2026-09-01', returned_max_booking_date: '2026-09-01', effective_date_from: null, pages: 1,
}).success,
).toBe(true)
})
})
+56 -3
View File
@@ -19,7 +19,7 @@ import { z } from 'zod'
* breaking change is a new operation or family name, never a changed one.
*/
export const CONTRACT_VERSION = '2026-09-02'
export const CONTRACT_VERSION = '2026-09-03'
// ---------------------------------------------------------------------------
// Keys, headers and paths
@@ -33,8 +33,8 @@ export const CONNECTOR_KEY_HEADER = 'x-connector-key'
export const CONNECTOR_ENTITLEMENTS_PATH = '/api/connect/entitlements'
/** Default hosted origin. app.gnubok.se stays the machine-facing host for API traffic. */
export const DEFAULT_CONNECT_BASE_URL = 'https://app.gnubok.se'
/** Default origin of the connector service. Installations that pointed at the hosted app's copy of the routes set GNUBOK_CONNECT_URL explicitly. */
export const DEFAULT_CONNECT_BASE_URL = 'https://connect.accounted.se'
/**
* Request headers an installation sends alongside its key. The company header
@@ -115,6 +115,59 @@ export const CONNECTOR_ERROR_CODES = [
] as const
export type ConnectorErrorCode = (typeof CONNECTOR_ERROR_CODES)[number]
// ---------------------------------------------------------------------------
// Bank sync operation (installation -> service, POST /api/connect/bank/sync)
// ---------------------------------------------------------------------------
/**
* The installation holds the PSD2 session and the account; the service does
* the provider paging, the booked-only filter and the normalization, and
* returns what the installation ingests plus the raw provider pages it
* archives. Stored keys (external ids) stay computed on the installation from
* booking_date, amount and its own account scope, exactly as before.
*/
export const bankSyncRequestSchema = z.object({
/** The Enable Banking session id the installation obtained (ownership is checked). */
session_id: z.string().trim().min(1).max(200),
account_uid: z.string().trim().min(1).max(200),
account_currency: z.string().trim().length(3),
date_from: z.iso.date().optional(),
date_to: z.iso.date().optional(),
strategy: z.enum(['default', 'longest']).optional(),
})
export type BankSyncRequest = z.infer<typeof bankSyncRequestSchema>
export const normalizedBankTransactionSchema = z.object({
/** A real calendar date: the installation's stored keys and ledger date derive from it. */
booking_date: z.iso.date(),
amount: z.number(),
currency: z.string(),
description: z.string(),
counterparty_name: z.string().nullable(),
counterparty_account: z.string().nullable(),
reference: z.string().nullable(),
merchant_category_code: z.string().nullable(),
bank_transaction_code: z.string().nullable(),
proprietary_bank_transaction_code: z.string().nullable(),
})
export type NormalizedBankTransaction = z.infer<typeof normalizedBankTransactionSchema>
export const bankSyncResponseSchema = z.object({
transactions: z.array(normalizedBankTransactionSchema),
/** Raw provider pages, verbatim, for the installation's archive. */
raw_pages: z.array(z.string()),
skipped_pending: z.number().int().min(0),
returned_min_booking_date: z.string().nullable(),
returned_max_booking_date: z.string().nullable(),
/** Set when the provider rejected the window and a narrower date_from was used. */
effective_date_from: z.string().nullable(),
pages: z.number().int().min(0),
})
export type BankSyncResponse = z.infer<typeof bankSyncResponseSchema>
/** Error codes specific to the bank sync operation. */
export const BANK_SYNC_ERROR_CODES = ['CONNECTOR_BANK_SESSION_EXPIRED', 'CONNECTOR_BANK_UPSTREAM_ERROR'] as const
// ---------------------------------------------------------------------------
// Peppol operations (installation -> service, /api/connect/peppol/*)
// ---------------------------------------------------------------------------