Files
accounted/lib/invoices/peppol-transport.ts
T
f93152c397 feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery (#1789)
* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery

Second Peppol slice (#546). Qvalia confirmed that sending needs no
per-company account, so receiving keeps the consolidated partner account:
each company publishes its 0007:orgnr on our account and inbound documents
are routed by the AccountingCustomerParty endpoint.

- PeppolTransport grows optional receiving methods (registerRecipient,
  unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the
  Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices /
  readcreditnotes, exact XML fetch).
- lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON
  (xml2js-style prefixed keys, verified against Qvalia's real inbound test
  invoice, kept as a fixture) into a neutral document: parties, payment
  means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines,
  embedded attachments, credit notes.
- Migration 20260821170000: peppol_registrations (one live row per company
  and participant), peppol_inbound_documents (exact XML immutable and
  undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a
  per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability
  and routing.
- POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in
  Settings > Fakturering; personnummer-based companies are refused until 0088
  GLN exists; sandbox refused.
- GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver.
  lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document
  (upload_source e_invoice, extractionOwner none), an embedded PDF when
  present, and creates the inbox row with the extraction filled from the UBL
  (confidence 1, no model pass), matching the supplier by org number. The
  existing inbox review/convert flow takes over.
- document-service accepts application/xml for the archive; inbox list shows
  a Peppol icon.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables

The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES;
the pg fixture for a deregistered row now carries deregistered_at as the
status-shape constraint requires; the archive insert is an inline literal and
the one generic processing-state updater is accounted for in the ceiling.

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>
2026-08-21 16:56:32 +02:00

231 lines
7.1 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>
}
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 }
}