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
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* The wire contract between a self-hosted Accounted instance and the hosted
* connector service (app.gnubok.se/api/connect/*). The definitions live in
* connector service (connect.accounted.se/api/connect/*). The definitions live in
* the MIT package packages/connect-contract (published as
* @accounted/connect-contract) so that either side can be implemented outside
* this repository; this module re-exports them for in-repo callers.
@@ -10,10 +10,10 @@ describe('getConnectorConfig', () => {
expect(isConnectorConfigured()).toBe(false)
})
it('defaults the hosted origin to app.gnubok.se and strips trailing slashes from an override', () => {
it('defaults to the connector service origin and strips trailing slashes from an override', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('GNUBOK_CONNECT_URL', '')
expect(getConnectorConfig()).toEqual({ key: 'gnubok_ck_x', baseUrl: 'https://app.gnubok.se' })
expect(getConnectorConfig()).toEqual({ key: 'gnubok_ck_x', baseUrl: 'https://connect.accounted.se' })
vi.stubEnv('GNUBOK_CONNECT_URL', 'https://connect.example.se/')
expect(getConnectorConfig()?.baseUrl).toBe('https://connect.example.se')
})
@@ -8,7 +8,7 @@ import {
hasOwnSkatteverketCredentials,
} from '../upstreams'
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO'] as const
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO', 'CONNECT_BANK_CANARY_COMPANIES'] as const
afterEach(() => vi.unstubAllEnvs())
function clear() {
@@ -39,8 +39,8 @@ describe('connector-mode detection', () => {
it('routes to the hosted proxy when a key is set and no own credentials exist', () => {
clear()
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
expect(bankConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/bank', key: 'gnubok_ck_x' })
expect(skatteverketConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/skv', key: 'gnubok_ck_x' })
expect(bankConnectorMode()).toEqual({ baseUrl: 'https://connect.accounted.se/api/connect/bank', key: 'gnubok_ck_x' })
expect(skatteverketConnectorMode()).toEqual({ baseUrl: 'https://connect.accounted.se/api/connect/skv', key: 'gnubok_ck_x' })
})
it('honours GNUBOK_CONNECT_URL and strips a trailing slash', () => {
@@ -64,9 +64,30 @@ describe('peppol connector mode', () => {
clear()
expect(peppolConnectorMode()).toBeNull()
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
expect(peppolConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/peppol', key: 'gnubok_ck_x' })
expect(peppolConnectorMode()).toEqual({ baseUrl: 'https://connect.accounted.se/api/connect/peppol', key: 'gnubok_ck_x' })
vi.stubEnv('QVALIA_PARTNER_REG_NO', '5560000000')
expect(hasOwnPeppolCredentials()).toBe(true)
expect(peppolConnectorMode()).toBeNull()
})
})
describe('bank canary companies', () => {
it('routes only the listed companies through the connector while own credentials exist', () => {
clear()
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('ENABLE_BANKING_APP_ID', 'app-id')
vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY', 'pk')
vi.stubEnv('CONNECT_BANK_CANARY_COMPANIES', 'c-1, c-2')
expect(bankConnectorMode()).toBeNull()
expect(bankConnectorMode('c-9')).toBeNull()
expect(bankConnectorMode('c-1')).toEqual({ baseUrl: 'https://connect.accounted.se/api/connect/bank', key: 'gnubok_ck_x' })
expect(bankConnectorMode('c-2')).not.toBeNull()
})
it('ignores the canary list without a connector key', () => {
clear()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'app-id')
vi.stubEnv('CONNECT_BANK_CANARY_COMPANIES', 'c-1')
expect(bankConnectorMode('c-1')).toBeNull()
})
})
+15 -2
View File
@@ -34,10 +34,23 @@ export interface ConnectorUpstream {
key: string
}
export function bankConnectorMode(): ConnectorUpstream | null {
if (hasOwnEnableBankingCredentials()) return null
/**
* Company ids that use the connector for bank sync even though this
* installation has its own Enable Banking credentials: the canary switch for
* moving an installation upstream by upstream (hosted Accounted moves its
* bank sync to Connect a few companies at a time before dropping its own
* keys). Comma-separated. Ignored without a connector key.
*/
function bankCanaryCompanies(): Set<string> {
const raw = process.env.CONNECT_BANK_CANARY_COMPANIES?.trim()
if (!raw) return new Set()
return new Set(raw.split(',').map((v) => v.trim()).filter(Boolean))
}
export function bankConnectorMode(companyId?: string): ConnectorUpstream | null {
const cfg = getConnectorConfig()
if (!cfg) return null
if (hasOwnEnableBankingCredentials() && !(companyId && bankCanaryCompanies().has(companyId))) return null
return { baseUrl: `${cfg.baseUrl}/api/connect/bank`, key: cfg.key }
}