* feat(peppol): poll outbound delivery status from the access point Qvalia's webhook API answers 404 on its production host (the sandbox answers 204), so without this the prod lifecycle would stop at submission_accepted. The transport gains pollDeliveryStatus(); the Qvalia adapter reads /invoices/outgoing/status and maps the message-log status through the same tolerant mapping as a document_delivery webhook, with the same dedupe key, so a later webhook for the same transition is a harmless duplicate. A cron four times an hour walks the open deliveries of the last 45 days, records the answer through the append-only lifecycle RPC and fetches evidence once a delivery reaches transport or a terminal state. Kept as the safety net for a missed webhook once Qvalia ships them to prod. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * feat(peppol): cap receiving registrations at the contracted tenant count The Qvalia partner contract is priced per tenant (10 to start), so the registration refuses the next company with PEPPOL_REGISTRATION_CAP_REACHED once PEPPOL_RECEIVING_MAX_REGISTRATIONS live registrations exist, instead of silently exceeding the contract. A company that already holds a live row is never counted twice; unset means no cap (own provider account). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
238 lines
7.5 KiB
TypeScript
238 lines
7.5 KiB
TypeScript
/**
|
|
* Provider-neutral boundary between Accounted's Peppol lifecycle and a
|
|
* contracted Access Point. Core code must not infer network delivery from an
|
|
* HTTP response: submissions, asynchronous events, and evidence are distinct.
|
|
*/
|
|
|
|
export type PeppolDeliveryStatus =
|
|
| 'staged'
|
|
| 'recipient_verified'
|
|
| 'submitting'
|
|
| 'retryable_failure'
|
|
| 'submission_accepted'
|
|
| 'transport_succeeded'
|
|
| 'recipient_acknowledged'
|
|
| 'business_accepted'
|
|
| 'business_rejected'
|
|
| 'no_route'
|
|
| 'failed'
|
|
|
|
export interface PeppolParticipant {
|
|
scheme: string
|
|
identifier: string
|
|
}
|
|
export interface PeppolRecipientCapability {
|
|
documentTypeId: string
|
|
processId: string
|
|
}
|
|
|
|
export type PeppolRecipientLookup =
|
|
| {
|
|
reachable: true
|
|
participant: PeppolParticipant
|
|
capabilities: PeppolRecipientCapability[]
|
|
checkedAt: string
|
|
}
|
|
| {
|
|
reachable: false
|
|
participant: PeppolParticipant
|
|
reasonCode: string
|
|
checkedAt: string
|
|
}
|
|
|
|
export interface PeppolSubmission {
|
|
idempotencyKey: string
|
|
tenantReference: string
|
|
sender: PeppolParticipant
|
|
recipient: PeppolParticipant
|
|
documentTypeId: string
|
|
processId: string
|
|
filename: string
|
|
contentType: 'application/xml'
|
|
document: string
|
|
documentSha256: string
|
|
}
|
|
|
|
export interface PeppolSubmissionReceipt {
|
|
provider: string
|
|
providerSubmissionId: string
|
|
idempotencyKey: string
|
|
tenantReference: string
|
|
acceptedAt: string
|
|
}
|
|
|
|
export interface PeppolVerifiedEvent {
|
|
provider: string
|
|
providerTenantId: string | null
|
|
providerSubmissionId: string | null
|
|
providerEventId: string | null
|
|
/**
|
|
* Accounted's delivery idempotency key. A provider webhook only knows its
|
|
* own submission id, so an adapter returns `null` here and the webhook route
|
|
* resolves the key from `providerSubmissionId` before persisting.
|
|
*/
|
|
idempotencyKey: string | null
|
|
eventCode: string
|
|
normalizedStatus: PeppolDeliveryStatus
|
|
isTerminal: boolean
|
|
detail: string | null
|
|
occurredAt: string
|
|
rawPayload: Record<string, unknown>
|
|
eventSha256: string
|
|
verificationMethod: string
|
|
}
|
|
|
|
export interface PeppolDeliveryEvidence {
|
|
provider: string
|
|
evidenceType: string
|
|
payload: Record<string, unknown>
|
|
exactDocument: string | null
|
|
exactDocumentSha256: string | null
|
|
evidenceSha256: string
|
|
retrievedAt: string
|
|
}
|
|
|
|
export interface PeppolWebhookRequest {
|
|
headers: Headers
|
|
rawBody: Uint8Array
|
|
}
|
|
|
|
/** What the SMP publishes about a receiving participant (Peppol Directory business card). */
|
|
export interface PeppolBusinessCard {
|
|
companyName: string
|
|
countryCode: string
|
|
geographicalInformation?: string | null
|
|
vatNumber?: string | null
|
|
orgNumber?: string | null
|
|
}
|
|
|
|
export interface PeppolDocumentTypeRegistration {
|
|
/** Process identifier, e.g. urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 */
|
|
processId: string
|
|
/** Document type identifier, e.g. the BIS Billing 3 Invoice id. */
|
|
documentTypeId: string
|
|
}
|
|
|
|
export interface PeppolRecipientRegistrationInput {
|
|
participant: PeppolParticipant
|
|
businessCard: PeppolBusinessCard
|
|
documentTypes: PeppolDocumentTypeRegistration[]
|
|
description?: string | null
|
|
}
|
|
|
|
export interface PeppolRecipientRegistration {
|
|
status: 'registered' | 'updated'
|
|
participant: PeppolParticipant
|
|
/** Provider account the identifier is attached to (Qvalia accountRegNo). */
|
|
providerAccountReference: string | null
|
|
raw: Record<string, unknown>
|
|
}
|
|
|
|
export type PeppolInboundDocumentType = 'Invoice' | 'CreditNote'
|
|
|
|
/** One inbound document as the provider hands it over, before Accounted reads it. */
|
|
export interface PeppolInboundMessage {
|
|
provider: string
|
|
providerDocumentId: string
|
|
documentType: PeppolInboundDocumentType
|
|
/** UBL-JSON payload (the provider's rendering of the received XML). */
|
|
payload: Record<string, unknown>
|
|
receivedAt: string | null
|
|
}
|
|
|
|
export interface PeppolInboundListOptions {
|
|
documentType: PeppolInboundDocumentType
|
|
limit?: number
|
|
/** Include documents already handed over once (re-sync); default only unread. */
|
|
includeRead?: boolean
|
|
}
|
|
|
|
/**
|
|
* Provider-neutral failure raised by an adapter. `retryable` separates an
|
|
* operational problem (network, rate limit, credentials) from a verdict on the
|
|
* document itself (rejected, duplicate); the send route records them as
|
|
* different lifecycle events.
|
|
*/
|
|
export class PeppolTransportError extends Error {
|
|
readonly retryable: boolean
|
|
readonly detail: string | null
|
|
|
|
constructor(message: string, options: { retryable: boolean; detail?: string | null; cause?: unknown }) {
|
|
super(message, options.cause !== undefined ? { cause: options.cause } : undefined)
|
|
this.name = 'PeppolTransportError'
|
|
this.retryable = options.retryable
|
|
this.detail = options.detail ?? null
|
|
}
|
|
}
|
|
|
|
export function isPeppolTransportError(error: unknown): error is PeppolTransportError {
|
|
return error instanceof PeppolTransportError
|
|
}
|
|
|
|
export interface PeppolTransport {
|
|
readonly provider: string
|
|
lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup>
|
|
submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt>
|
|
verifyWebhook(request: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]>
|
|
retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]>
|
|
/**
|
|
* Receiving side. Optional: a send-only provider leaves these undefined and
|
|
* the product keeps receiving switched off for it.
|
|
*/
|
|
registerRecipient?(input: PeppolRecipientRegistrationInput): Promise<PeppolRecipientRegistration>
|
|
unregisterRecipient?(participant: PeppolParticipant): Promise<void>
|
|
listInboundDocuments?(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]>
|
|
/** The exact received document, for the archive (räkenskapsinformation). */
|
|
fetchInboundDocumentXml?(
|
|
providerDocumentId: string,
|
|
documentType: PeppolInboundDocumentType,
|
|
): Promise<string | null>
|
|
/**
|
|
* Pull the provider's current delivery status for an outbound submission
|
|
* and return it as verified events (same shape as a webhook, `idempotencyKey`
|
|
* unresolved). For providers without webhooks, or as a safety net when a
|
|
* webhook was missed. Returns [] when the provider has nothing new to say.
|
|
*/
|
|
pollDeliveryStatus?(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]>
|
|
}
|
|
|
|
const transports = new Map<string, PeppolTransport>()
|
|
|
|
export function registerPeppolTransport(transport: PeppolTransport): () => void {
|
|
const provider = transport.provider.trim().toLowerCase()
|
|
if (!provider) throw new Error('Peppol transport provider is required')
|
|
if (transports.has(provider)) {
|
|
throw new Error(`Peppol transport already registered: ${provider}`)
|
|
}
|
|
|
|
transports.set(provider, transport)
|
|
return () => {
|
|
if (transports.get(provider) === transport) transports.delete(provider)
|
|
}
|
|
}
|
|
|
|
export function getPeppolTransport(provider: string): PeppolTransport | null {
|
|
return transports.get(provider.trim().toLowerCase()) ?? null
|
|
}
|
|
|
|
export type PeppolTransportAvailability =
|
|
| { available: true; provider: string }
|
|
| {
|
|
available: false
|
|
provider: null
|
|
reason: 'provider_selection_required' | 'provider_adapter_unavailable'
|
|
}
|
|
|
|
export function getPeppolTransportAvailability(): PeppolTransportAvailability {
|
|
const configuredProvider = process.env.PEPPOL_TRANSPORT_PROVIDER?.trim().toLowerCase()
|
|
if (!configuredProvider) {
|
|
return { available: false, provider: null, reason: 'provider_selection_required' }
|
|
}
|
|
|
|
if (!transports.has(configuredProvider)) {
|
|
return { available: false, provider: null, reason: 'provider_adapter_unavailable' }
|
|
}
|
|
|
|
return { available: true, provider: configuredProvider }
|
|
}
|