feat(connect): extract the connector wire contract into packages/connect-contract (#2179)

* feat(connect): extract the connector wire contract into packages/connect-contract

The definitions in lib/connect/contract.ts (key prefix, headers, entitlements
path, entitlement and sync-report shapes) move into a standalone MIT package,
packages/connect-contract (published as @accounted/connect-contract), joined
by the error envelope, the stable error codes, and Zod schemas for every
Peppol connector operation (lookup, submit, status, evidence, register,
unregister, inbound list and xml) with an operation table. Shape only: no
behaviour, no provider code. In-repo callers import through the tsconfig and
vitest alias; lib/connect/contract.ts re-exports so nothing else changes.
The point of the package is that either side of the connection can be built
outside this repository: a self-hosted ledger talking to Accounted Connect,
or another connector service talking to the open ledger.

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

* docs(decisions): record the Connect direction, the Peppol proxy shape, and the contract package

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-02 20:57:47 +02:00
committed by GitHub
parent 8c8996773f
commit fcfa1ba974
10 changed files with 555 additions and 34 deletions
+3
View File
@@ -1505,3 +1505,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] parties children/roles reference parties(id, company_id) with composite FKs, not parties(id): a party UUID from another tenant is rejected by construction instead of relying on each writer to check; ON DELETE SET NULL (party_id) on customers/suppliers because a plain SET NULL would null company_id too (Superagent P2 on #2162)
[2026-09-02] Party suggestions attach only by explicit party_id, org number or an exact ledger key already in alias_keys; same-core text is reported as similar_to for a person to decide and identities are withheld when a key mixes org numbers: the selection eval measured 9% false merges on trade names shared by distinct legal entities (Fortnox AB / Fortnox Finans), so text never merges
[2026-09-02] Edited migration 20260902160000 after merge: its backfill failed on prod (ensure_party: name is required; 3 nameless rows) so it was never applied there, the Supabase main branch sat in MIGRATIONS_FAILED and every later migration was blocked behind it. An unapplied file is not a shipped schema; a follow-up migration could not run before it
[2026-09-02] Accounted Connect direction (founder decision after a fork was resold): the ledger stays AGPL with no licence change and no ee/ split; provider integration logic moves behind the connector (hosted app/api/connect/* today, a separate Connect service later) one upstream at a time on the existing connector keys, ledger and entitlement sync. Rejected: FSL/BSL relicensing (58 public forks keep the AGPL version; DCO-only contributions cannot be relicensed without consent) and closed first-party extensions alone (the code still ships to every self-hoster, and the provider extensions do not honour the Extension API boundary).
[2026-09-02] Peppol connector proxy (#2177) is operation-shaped (lookup/submit/status/evidence/recipient/inbound), NOT a path passthrough like the bank proxy: Qvalia URLs embed Arcim's partner and account numbers, the account is shared by every hosted company and every instance so reads must be scoped to what the caller owns, and Qvalia's inbound "read" endpoint marks documents read for the whole account. Ownership is bound to (key, company_ref); participants a key may publish are recorded on the key at issuance (connector_keys.peppol_participants) because the hosted side cannot otherwise know which organisations an instance legitimately hosts. Inbound is served from the hosted archive, never by calling Qvalia on the instance's behalf.
[2026-09-02] The connector wire contract is an MIT package (packages/connect-contract, @accounted/connect-contract) consumed in-repo from source through a tsconfig/vitest alias (#2179), and check:guards ratchets the set of files naming a provider API host (#2178): the open repo keeps the contract and the manual file paths, either side of the connection can be implemented outside it, and the grandfathered provider-host set may only shrink. Declined a NOT VALID + later VALIDATE pair for the ledger service CHECK: connector_connections has zero prod rows until keys are issued.
+13 -33
View File
@@ -1,7 +1,9 @@
/**
* The wire contract between a self-hosted Accounted instance and the hosted
* connector service (app.gnubok.se/api/connect/*). Shared by both sides so
* the instance sync and the hosted endpoint cannot drift apart.
* connector service (app.gnubok.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.
*
* Background: a self-hosted instance runs everything itself except the
* services only Accounted can operate (bank sync via our PSD2/AISP
@@ -12,34 +14,12 @@
* hosted proxy, never a licence check inside the instance.
*/
export const CONNECTOR_KEY_PREFIX = 'gnubok_ck_'
/** Header alternative to `Authorization: Bearer`, for proxied calls where Authorization carries an upstream token. */
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'
export type ConnectorKeyStatus = 'active' | 'suspended' | 'revoked'
/** What the hosted service tells an instance about its key. */
export interface ConnectorEntitlements {
status: ConnectorKeyStatus
/** Capability keys the subscription covers (subset of CONNECTOR_CAPABILITIES). */
scopes: string[]
/** End of the paid period, ISO; null for an open-ended (manually issued) key. */
current_period_end: string | null
org_number: string
/** The instance origin this key is pinned to; null until the first sync claims it. */
instance_url: string | null
server_time: string
}
/** What an instance reports on every sync (quantity billing input). */
export interface ConnectorSyncReport {
active_company_count: number
instance_url?: string
app_version?: string
}
export {
CONNECTOR_KEY_PREFIX,
CONNECTOR_KEY_HEADER,
CONNECTOR_ENTITLEMENTS_PATH,
DEFAULT_CONNECT_BASE_URL,
type ConnectorKeyStatus,
type ConnectorEntitlements,
type ConnectorSyncReport,
} from '@accounted/connect-contract'
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Arcim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+41
View File
@@ -0,0 +1,41 @@
# @accounted/connect-contract
The wire contract between an Accounted ledger installation (the hosted service
or a self-hosted instance) and Accounted Connect, the service that operates
the provider integrations only Accounted can run: bank feeds through its PSD2
credentials, the Skatteverket API client, the Peppol access point, company
lookup, the migration sources.
This package is shape only: constants, Zod schemas and the TypeScript types
inferred from them. There is no behaviour and no provider code in it. Both
sides of the connection validate with the same schemas so they cannot drift
apart, and the package is MIT so that anyone may implement either side.
What is in it:
- key prefix, header names and the entitlements path an installation uses;
- the entitlements and sync-report payloads of the hourly key sync;
- the error envelope and the stable error codes the service answers with;
- the Peppol operations (`/api/connect/peppol/*`): request and response
schemas plus the operation table (method, path, company header required).
Versioning: `CONTRACT_VERSION` is a date. Fields are only ever added; a
breaking change is a new operation or family, never a changed one.
## Use
```ts
import { peppolSubmissionSchema, PEPPOL_OPERATIONS, CONTRACT_VERSION } from '@accounted/connect-contract'
const parsed = peppolSubmissionSchema.safeParse(body)
if (!parsed.success) return badRequest(parsed.error)
```
Inside the Accounted repository the package is consumed from source through a
path alias. To publish, build from the repository root with the root
dependencies installed:
```bash
npx tsc -p packages/connect-contract/tsconfig.json
cd packages/connect-contract && npm publish --access public
```
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@accounted/connect-contract",
"version": "0.1.0",
"description": "Wire contract between an Accounted ledger installation and the Accounted Connect service: constants, Zod schemas and types. Shape only, no behaviour.",
"type": "module",
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"keywords": [
"accounted",
"gnubok",
"connector",
"peppol",
"bookkeeping"
],
"repository": {
"type": "git",
"url": "git+https://github.com/erp-mafia/accounted.git",
"directory": "packages/connect-contract"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
CONNECTOR_ERROR_CODES,
CONNECTOR_KEY_PREFIX,
CONTRACT_VERSION,
PEPPOL_OPERATIONS,
connectorEntitlementsSchema,
connectorErrorSchema,
connectorSyncReportSchema,
peppolInboundMessageSchema,
peppolLookupResultSchema,
peppolParticipantSchema,
peppolRecipientRegistrationRequestSchema,
peppolSubmissionSchema,
} from '../index'
const participant = { scheme: '0007', identifier: '5561234567' }
describe('contract constants', () => {
it('pins the version as a date and the key prefix as the frozen wire format', () => {
expect(CONTRACT_VERSION).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(CONNECTOR_KEY_PREFIX).toBe('gnubok_ck_')
})
it('lists every operation with a method, path and schemas', () => {
for (const [name, op] of Object.entries(PEPPOL_OPERATIONS)) {
expect(op.path.startsWith('/'), name).toBe(true)
expect(['POST', 'PUT', 'DELETE']).toContain(op.method)
expect(typeof op.request.safeParse).toBe('function')
expect(typeof op.response.safeParse).toBe('function')
}
expect(new Set(CONNECTOR_ERROR_CODES).size).toBe(CONNECTOR_ERROR_CODES.length)
})
})
describe('entitlements and errors', () => {
it('round-trips the entitlements and sync payloads', () => {
expect(
connectorEntitlementsSchema.safeParse({
status: 'active',
scopes: ['bank_sync', 'peppol'],
current_period_end: null,
org_number: '5561234567',
instance_url: null,
server_time: '2026-09-02T12:00:00Z',
}).success,
).toBe(true)
expect(connectorEntitlementsSchema.safeParse({ status: 'gone', scopes: [] }).success).toBe(false)
expect(connectorSyncReportSchema.safeParse({ active_company_count: 3 }).success).toBe(true)
expect(connectorSyncReportSchema.safeParse({ active_company_count: -1 }).success).toBe(false)
})
it('accepts the error envelope with or without the retry hint', () => {
expect(connectorErrorSchema.safeParse({ error: 'x', code: 'CONNECTOR_NOT_OWNED' }).success).toBe(true)
expect(connectorErrorSchema.safeParse({ error: 'x', code: 'CONNECTOR_UPSTREAM_ERROR', retryable: true, detail: null }).success).toBe(true)
expect(connectorErrorSchema.safeParse({ code: 'x' }).success).toBe(false)
})
})
describe('peppol schemas', () => {
it('validates participants as four-digit ICD scheme plus identifier', () => {
expect(peppolParticipantSchema.safeParse(participant).success).toBe(true)
expect(peppolParticipantSchema.safeParse({ scheme: '007', identifier: 'x' }).success).toBe(false)
expect(peppolParticipantSchema.safeParse({ scheme: 'abcd', identifier: 'x' }).success).toBe(false)
expect(peppolParticipantSchema.safeParse({ scheme: '0007', identifier: '' }).success).toBe(false)
})
it('discriminates lookup results on reachable', () => {
expect(peppolLookupResultSchema.safeParse({ reachable: true, participant, capabilities: [], checkedAt: 't' }).success).toBe(true)
expect(peppolLookupResultSchema.safeParse({ reachable: false, participant, reasonCode: 'participant_not_found', checkedAt: 't' }).success).toBe(true)
expect(peppolLookupResultSchema.safeParse({ reachable: false, participant, checkedAt: 't' }).success).toBe(false)
})
it('requires an XML submission with a hex sha256 and rejects non-xml content types', () => {
const base = {
idempotencyKey: 'k',
tenantReference: 'c',
sender: participant,
recipient: participant,
documentTypeId: 'd',
processId: 'p',
filename: 'f.xml',
contentType: 'application/xml',
document: '<Invoice/>',
documentSha256: 'a'.repeat(64),
}
expect(peppolSubmissionSchema.safeParse(base).success).toBe(true)
expect(peppolSubmissionSchema.safeParse({ ...base, contentType: 'application/json' }).success).toBe(false)
expect(peppolSubmissionSchema.safeParse({ ...base, documentSha256: 'zz' }).success).toBe(false)
})
it('validates registrations and inbound messages', () => {
expect(
peppolRecipientRegistrationRequestSchema.safeParse({
participant,
businessCard: { companyName: 'AB', countryCode: 'SE' },
documentTypes: [{ processId: 'p', documentTypeId: 'd' }],
}).success,
).toBe(true)
expect(
peppolRecipientRegistrationRequestSchema.safeParse({ participant, businessCard: { companyName: 'AB', countryCode: 'SWE' }, documentTypes: [] }).success,
).toBe(false)
expect(
peppolInboundMessageSchema.safeParse({ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: {}, receivedAt: null }).success,
).toBe(true)
expect(peppolInboundMessageSchema.safeParse({ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Order', payload: {} }).success).toBe(false)
})
})
+310
View File
@@ -0,0 +1,310 @@
import { z } from 'zod'
/**
* @accounted/connect-contract
*
* The wire contract between an Accounted ledger installation (hosted, or a
* self-hosted instance) and the Accounted Connect service that operates the
* provider integrations only Accounted can run: bank feeds through its PSD2
* credentials, the Skatteverket API client, the Peppol access point, company
* lookup, the migration sources.
*
* Everything here is shape, never behaviour: constants, Zod schemas and the
* TypeScript types inferred from them. Both sides validate with the same
* schemas so they cannot drift apart. The package is MIT so that anyone may
* implement either side of it: a self-hosted ledger talking to Accounted
* Connect, or an alternative connector service talking to the open ledger.
*
* Versioning: `CONTRACT_VERSION` is a date. Fields are only ever added; a
* breaking change is a new operation or family name, never a changed one.
*/
export const CONTRACT_VERSION = '2026-09-02'
// ---------------------------------------------------------------------------
// Keys, headers and paths
// ---------------------------------------------------------------------------
/** Connector keys start with this prefix; the rest is 32 random bytes, base64url. */
export const CONNECTOR_KEY_PREFIX = 'gnubok_ck_'
/** Header alternative to `Authorization: Bearer`, for proxied calls where Authorization carries an upstream token. */
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'
/**
* Request headers an installation sends alongside its key. The company header
* is the installation's own opaque company reference: the service never
* resolves it to anything and only uses it to scope quotas and ownership.
*/
export const CONNECTOR_HEADERS = {
company: 'X-Connector-Company',
upstreamAuthorization: 'X-Connector-Upstream-Authorization',
upstreamContentType: 'X-Connector-Upstream-Content-Type',
} as const
// ---------------------------------------------------------------------------
// Entitlements (installation <-> service)
// ---------------------------------------------------------------------------
export const connectorKeyStatusSchema = z.enum(['active', 'suspended', 'revoked'])
export type ConnectorKeyStatus = z.infer<typeof connectorKeyStatusSchema>
/** What the service tells an installation about its key. */
export const connectorEntitlementsSchema = z.object({
status: connectorKeyStatusSchema,
/** Capability keys the subscription covers. */
scopes: z.array(z.string()),
/** End of the paid period, ISO; null for an open-ended (manually issued) key. */
current_period_end: z.string().nullable(),
org_number: z.string(),
/** The installation origin this key is pinned to; null until the first sync claims it. */
instance_url: z.string().nullable(),
server_time: z.string(),
})
export type ConnectorEntitlements = z.infer<typeof connectorEntitlementsSchema>
/** What an installation reports on every sync (quantity billing input). */
export const connectorSyncReportSchema = z.object({
active_company_count: z.number().int().min(0),
instance_url: z.string().optional(),
app_version: z.string().optional(),
})
export type ConnectorSyncReport = z.infer<typeof connectorSyncReportSchema>
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/**
* Every refusal from the service is this shape. `code` is stable and machine
* readable; `retryable` tells the installation whether backing off helps.
*/
export const connectorErrorSchema = z.object({
error: z.string(),
code: z.string(),
retryable: z.boolean().optional(),
detail: z.string().nullable().optional(),
})
export type ConnectorError = z.infer<typeof connectorErrorSchema>
/** Codes the service may answer with, in addition to upstream-specific ones. */
export const CONNECTOR_ERROR_CODES = [
'BAD_REQUEST',
'CONNECTOR_SCOPE_MISSING',
'CONNECTOR_COMPANY_MISSING',
'CONNECTOR_PATH_NOT_ALLOWED',
'CONNECTOR_NOT_OWNED',
'CONNECTOR_QUOTA_EXCEEDED',
'CONNECTOR_RATE_LIMITED',
'CONNECTOR_STATE_INVALID',
'CONNECTOR_STATE_CONSUMED',
'CONNECTOR_REDIRECT_INVALID',
'CONNECTOR_LEDGER_FAILED',
'CONNECTOR_UPSTREAM_ERROR',
'CONNECTOR_UPSTREAM_UNCONFIGURED',
'CONNECTOR_PEPPOL_PARTICIPANT_TAKEN',
'CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED',
'CONNECTOR_PEPPOL_SENDER_NOT_REGISTERED',
'PEPPOL_RECEIVING_UNSUPPORTED',
'PEPPOL_REGISTRATION_CAP_REACHED',
] as const
export type ConnectorErrorCode = (typeof CONNECTOR_ERROR_CODES)[number]
// ---------------------------------------------------------------------------
// Peppol operations (installation -> service, /api/connect/peppol/*)
// ---------------------------------------------------------------------------
/**
* The Peppol upstream speaks transport operations, not provider paths: the
* access-point account is shared, so the service scopes every read to what
* the calling key and company own. These schemas mirror the ledger's
* PeppolTransport interface one to one.
*/
export const PEPPOL_MAX_DOCUMENT_CHARS = 5_000_000
const peppolFourDigitScheme = z
.string()
.length(4)
.regex(/^\d+$/, 'ISO 6523 ICD scheme: four digits')
export const peppolParticipantSchema = z.object({
scheme: peppolFourDigitScheme,
identifier: z.string().trim().min(1).max(64),
})
export type PeppolParticipant = z.infer<typeof peppolParticipantSchema>
export const peppolDocumentTypeSchema = z.enum(['Invoice', 'CreditNote'])
export type PeppolInboundDocumentType = z.infer<typeof peppolDocumentTypeSchema>
export const peppolDeliveryStatusSchema = z.enum([
'staged',
'recipient_verified',
'submitting',
'retryable_failure',
'submission_accepted',
'transport_succeeded',
'recipient_acknowledged',
'business_accepted',
'business_rejected',
'no_route',
'failed',
])
export type PeppolDeliveryStatus = z.infer<typeof peppolDeliveryStatusSchema>
export const peppolRecipientCapabilitySchema = z.object({
documentTypeId: z.string(),
processId: z.string(),
})
export const peppolLookupRequestSchema = z.object({ participant: peppolParticipantSchema })
export type PeppolLookupRequest = z.infer<typeof peppolLookupRequestSchema>
export const peppolLookupResultSchema = z.discriminatedUnion('reachable', [
z.object({
reachable: z.literal(true),
participant: peppolParticipantSchema,
capabilities: z.array(peppolRecipientCapabilitySchema),
checkedAt: z.string(),
}),
z.object({
reachable: z.literal(false),
participant: peppolParticipantSchema,
reasonCode: z.string(),
checkedAt: z.string(),
}),
])
export type PeppolLookupResult = z.infer<typeof peppolLookupResultSchema>
export const peppolSubmissionSchema = z.object({
idempotencyKey: z.string().trim().min(1).max(128),
/** The installation's own company reference; the service overrides it with the company header. */
tenantReference: z.string().trim().min(1).max(128),
sender: peppolParticipantSchema,
recipient: peppolParticipantSchema,
documentTypeId: z.string().trim().min(1).max(512),
processId: z.string().trim().min(1).max(512),
filename: z.string().trim().min(1).max(255),
contentType: z.literal('application/xml'),
document: z.string().min(1).max(PEPPOL_MAX_DOCUMENT_CHARS),
documentSha256: z.string().regex(/^[0-9a-f]{64}$/),
})
export type PeppolSubmission = z.infer<typeof peppolSubmissionSchema>
export const peppolSubmissionReceiptSchema = z.object({
provider: z.string(),
providerSubmissionId: z.string(),
idempotencyKey: z.string(),
tenantReference: z.string(),
acceptedAt: z.string(),
})
export type PeppolSubmissionReceipt = z.infer<typeof peppolSubmissionReceiptSchema>
export const peppolSubmissionRefSchema = z.object({
providerSubmissionId: z.string().trim().min(1).max(128),
})
export type PeppolSubmissionRef = z.infer<typeof peppolSubmissionRefSchema>
export const peppolVerifiedEventSchema = z.object({
provider: z.string(),
providerTenantId: z.string().nullable(),
providerSubmissionId: z.string().nullable(),
providerEventId: z.string().nullable(),
idempotencyKey: z.string().nullable(),
eventCode: z.string(),
normalizedStatus: peppolDeliveryStatusSchema,
isTerminal: z.boolean(),
detail: z.string().nullable(),
occurredAt: z.string(),
rawPayload: z.record(z.string(), z.unknown()),
eventSha256: z.string(),
verificationMethod: z.string(),
})
export type PeppolVerifiedEvent = z.infer<typeof peppolVerifiedEventSchema>
export const peppolDeliveryEvidenceSchema = z.object({
provider: z.string(),
evidenceType: z.string(),
payload: z.record(z.string(), z.unknown()),
exactDocument: z.string().nullable(),
exactDocumentSha256: z.string().nullable(),
evidenceSha256: z.string(),
retrievedAt: z.string(),
})
export type PeppolDeliveryEvidence = z.infer<typeof peppolDeliveryEvidenceSchema>
export const peppolBusinessCardSchema = z.object({
companyName: z.string().trim().min(1).max(200),
countryCode: z.string().trim().length(2),
geographicalInformation: z.string().max(500).nullish(),
vatNumber: z.string().max(64).nullish(),
orgNumber: z.string().max(64).nullish(),
})
export const peppolRecipientRegistrationRequestSchema = z.object({
participant: peppolParticipantSchema,
businessCard: peppolBusinessCardSchema,
documentTypes: z
.array(z.object({ processId: z.string().min(1).max(512), documentTypeId: z.string().min(1).max(512) }))
.min(1)
.max(20),
description: z.string().max(200).nullish(),
/** The installation's own company reference; the service uses the company header. */
tenantReference: z.string().max(128).nullish(),
})
export type PeppolRecipientRegistrationRequest = z.infer<typeof peppolRecipientRegistrationRequestSchema>
export const peppolRecipientRegistrationResultSchema = z.object({
status: z.enum(['registered', 'updated']),
participant: peppolParticipantSchema,
/** Opaque on the connector: the service never reveals its provider account reference. */
providerAccountReference: z.string().nullable(),
raw: z.record(z.string(), z.unknown()),
})
export type PeppolRecipientRegistrationResult = z.infer<typeof peppolRecipientRegistrationResultSchema>
export const peppolInboundListRequestSchema = z.object({
documentType: peppolDocumentTypeSchema,
limit: z.number().int().min(1).max(100).optional(),
includeRead: z.boolean().optional(),
})
export type PeppolInboundListRequest = z.infer<typeof peppolInboundListRequestSchema>
export const peppolInboundMessageSchema = z.object({
provider: z.string(),
providerDocumentId: z.string(),
documentType: peppolDocumentTypeSchema,
payload: z.record(z.string(), z.unknown()),
receivedAt: z.string().nullable(),
})
export type PeppolInboundMessage = z.infer<typeof peppolInboundMessageSchema>
export const peppolInboundXmlRequestSchema = z.object({
providerDocumentId: z.string().trim().min(1).max(128),
documentType: peppolDocumentTypeSchema,
})
export type PeppolInboundXmlRequest = z.infer<typeof peppolInboundXmlRequestSchema>
export const peppolInboundXmlResultSchema = z.object({ xml: z.string().nullable() })
export type PeppolInboundXmlResult = z.infer<typeof peppolInboundXmlResultSchema>
/**
* The operation table: method, path under `/api/connect/peppol`, whether the
* company header is required, and the request and response schemas.
*/
export const PEPPOL_OPERATIONS = {
lookup: { method: 'POST', path: '/lookup', company: false, request: peppolLookupRequestSchema, response: peppolLookupResultSchema },
submit: { method: 'POST', path: '/submit', company: true, request: peppolSubmissionSchema, response: peppolSubmissionReceiptSchema },
status: { method: 'POST', path: '/status', company: true, request: peppolSubmissionRefSchema, response: z.array(peppolVerifiedEventSchema) },
evidence: { method: 'POST', path: '/evidence', company: true, request: peppolSubmissionRefSchema, response: z.array(peppolDeliveryEvidenceSchema) },
register: { method: 'PUT', path: '/recipient', company: true, request: peppolRecipientRegistrationRequestSchema, response: peppolRecipientRegistrationResultSchema },
unregister: { method: 'DELETE', path: '/recipient', company: true, request: peppolParticipantSchema, response: z.null() },
inboundList: { method: 'POST', path: '/inbound/list', company: false, request: peppolInboundListRequestSchema, response: z.array(peppolInboundMessageSchema) },
inboundXml: { method: 'POST', path: '/inbound/xml', company: false, request: peppolInboundXmlRequestSchema, response: peppolInboundXmlResultSchema },
} as const
export type PeppolOperation = keyof typeof PEPPOL_OPERATIONS
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}
+2 -1
View File
@@ -19,7 +19,8 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@accounted/connect-contract": ["./packages/connect-contract/src/index.ts"]
},
"types": ["vitest/globals"]
},
+2
View File
@@ -3,6 +3,8 @@ import path from 'path'
const alias = {
'@': path.resolve(__dirname, '.'),
// The connect contract is consumed from source in-repo (published separately).
'@accounted/connect-contract': path.resolve(__dirname, 'packages/connect-contract/src/index.ts'),
// `server-only` is a build-time guard whose real entry point always throws;
// Next.js swaps it out during bundling, Vitest cannot. Without this stub any
// test that transitively imports a server-only module fails at import time.