feat(peppol): Qvalia access-point adapter, send flow and delivery webhook (#1780)
* feat(peppol): Qvalia access-point adapter, send flow and delivery webhook Qvalia is the contracted Peppol Access Point (signed 2026-08-21). This fills the provider-neutral PeppolTransport seam from #1595 with a real adapter and turns the disabled "Skicka via Peppol" menu item into a working send flow. Adapter (lib/invoices/transports/qvalia.ts): partner-scoped recipient lookup, XML submission to /invoices/outgoing with integrationId correlation, 409 recovery only when the stored copy carries the same seller endpoint, tolerant mapping of Qvalia's free-text webhook statuses onto the 11-state lifecycle, constant-time shared-secret webhook verification (Qvalia does not sign webhooks), and evidence retrieval of the message-log status plus Qvalia's stored XML copy. Registered from the environment in lib/init.ts; switched on per deployment with PEPPOL_TRANSPORT_PROVIDER=qvalia. POST /api/invoices/[id]/peppol/send: stage the exact XML, look up the recipient, record recipient_verified and submitting, submit, record submission_accepted, then issue a draft with the mark-sent semantics (issueAndBookInvoice) only after the network accepted it. A sync rejection is a terminal failed event so the identical document is never re-sent; an operational failure is retryable; an already-submitted XML replays idempotently. POST /api/webhooks/peppol/qvalia resolves the delivery by integrationId, persists the verified event via the service-role RPC and stores evidence best-effort; unknown submissions answer 200, our own persistence failures 500. UI: the send item is availability-driven with a confirm dialog, the invoice page shows the latest Peppol status, and drafts can be sent (the number is assigned server-side). Probe script for the first sandbox contact under scripts/peppol/qvalia-probe.ts. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): Qvalia sandbox facts from first live contact: bare-key auth, api-test host, SMP-URL document types The onboarding mail and a live probe against the sandbox (partner SE5595386219) corrected three assumptions from the public docs: the key is accepted bare in the Authorization header (the ApiKey prefix answers 401), the sandbox host is api-test.qvalia.com, and the recipient lookup returns document types as SMP service URLs, so capabilities are now normalized to bare Peppol document type ids before comparison. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * feat(peppol): probe commands to inspect and configure the Qvalia webhook subscription Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): decode UBL entities in one pass (CodeQL js/double-escaping) 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
8249fcab5e
commit
05c3c6ebd9
@@ -1266,6 +1266,34 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Detta dokument är inte en offert.',
|
||||
message_en: 'This document is not a quote.',
|
||||
},
|
||||
// POST /api/invoices/{id}/peppol/send. The Access Point is an environment
|
||||
// decision (PEPPOL_TRANSPORT_PROVIDER + adapter credentials); the product
|
||||
// never pretends to send when no adapter is switched on.
|
||||
PEPPOL_TRANSPORT_UNAVAILABLE: {
|
||||
httpStatus: 503,
|
||||
message_sv: 'Peppol-utskick är inte aktiverat i den här miljön. En avtalad Peppol-operatör måste vara konfigurerad.',
|
||||
message_en: 'Peppol sending is not enabled in this environment. A contracted Peppol access point must be configured.',
|
||||
},
|
||||
PEPPOL_SEND_INVALID_STATUS: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Bara utkast och skickade fakturor kan skickas via Peppol. Makulerade, krediterade och proformafakturor kan inte skickas.',
|
||||
message_en: 'Only draft and sent invoices can be sent via Peppol. Cancelled, credited and proforma invoices cannot be sent.',
|
||||
},
|
||||
PEPPOL_RECIPIENT_NOT_REACHABLE: {
|
||||
httpStatus: 422,
|
||||
message_sv: 'Mottagaren är inte registrerad för att ta emot e-fakturor via Peppol. Kontrollera organisationsnumret eller skicka fakturan på annat sätt.',
|
||||
message_en: 'The recipient is not registered to receive e-invoices via Peppol. Check the organisation number or deliver the invoice another way.',
|
||||
},
|
||||
PEPPOL_SUBMISSION_REJECTED: {
|
||||
httpStatus: 422,
|
||||
message_sv: 'Peppol-operatören avvisade fakturan vid valideringen. Fakturan har inte skickats.',
|
||||
message_en: 'The Peppol access point rejected the invoice during validation. The invoice has not been sent.',
|
||||
},
|
||||
PEPPOL_SUBMISSION_FAILED: {
|
||||
httpStatus: 502,
|
||||
message_sv: 'Peppol-operatören kunde inte nås just nu. Fakturan har inte skickats; försök igen om en stund.',
|
||||
message_en: 'The Peppol access point could not be reached. The invoice has not been sent; try again shortly.',
|
||||
},
|
||||
}
|
||||
|
||||
const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
|
||||
import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler'
|
||||
import { registerWebhookHandler } from '@/lib/webhooks/handler'
|
||||
import { registerConfiguredPeppolTransports } from '@/lib/invoices/transports'
|
||||
import { registerObservabilitySink } from '@/lib/observability'
|
||||
import { postHogSink } from '@/lib/analytics/posthog-observability'
|
||||
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
||||
@@ -96,6 +97,9 @@ export function ensureInitialized(): void {
|
||||
registerSupplierInvoiceHandler()
|
||||
registerEventLogHandler()
|
||||
registerWebhookHandler()
|
||||
// Peppol Access Point adapters are registered from the environment here so
|
||||
// every route that reports transport availability sees the same answer.
|
||||
registerConfiguredPeppolTransports()
|
||||
loadExtensions()
|
||||
|
||||
initialized = true
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
|
||||
PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
} from '@/lib/invoices/peppol-bis-billing'
|
||||
import { sha256Hex } from '@/lib/invoices/peppol-delivery'
|
||||
import type { PeppolSubmission } from '@/lib/invoices/peppol-transport'
|
||||
import {
|
||||
QvaliaApiError,
|
||||
createQvaliaTransport,
|
||||
describeQvaliaErrorBody,
|
||||
extractUblDocumentId,
|
||||
extractUblJsonSupplierEndpoint,
|
||||
normalizePeppolDocumentTypeId,
|
||||
normalizeQvaliaWebhook,
|
||||
readQvaliaConfigFromEnv,
|
||||
type QvaliaConfig,
|
||||
} from '@/lib/invoices/transports/qvalia'
|
||||
import { registerConfiguredPeppolTransports } from '@/lib/invoices/transports'
|
||||
import { getPeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const config: QvaliaConfig = {
|
||||
apiKey: 'test-key',
|
||||
partnerRegNo: 'SE5560000000',
|
||||
accountRegNo: 'SE5560000000',
|
||||
baseUrl: 'https://api-qa.qvalia.com',
|
||||
authScheme: 'apikey',
|
||||
webhookSecret: 'shared-secret-1234567890',
|
||||
webhookHeader: 'x-accounted-webhook-key',
|
||||
}
|
||||
|
||||
const XML = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">',
|
||||
' <cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>',
|
||||
' <cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>',
|
||||
' <cbc:ID>F-2026-42</cbc:ID>',
|
||||
'</Invoice>',
|
||||
].join('\n')
|
||||
|
||||
function submission(overrides: Partial<PeppolSubmission> = {}): PeppolSubmission {
|
||||
return {
|
||||
idempotencyKey: '33333333-3333-4333-8333-333333333333',
|
||||
tenantReference: 'company-1',
|
||||
sender: { scheme: '0007', identifier: '5560160680' },
|
||||
recipient: { scheme: '0007', identifier: '5566778899' },
|
||||
documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
|
||||
processId: PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
filename: 'peppol-invoice-F-2026-42.xml',
|
||||
contentType: 'application/xml',
|
||||
document: XML,
|
||||
documentSha256: sha256Hex(XML),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
||||
return new Response(body === null ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
describe('readQvaliaConfigFromEnv', () => {
|
||||
it('returns null until key, partner number and base URL are all present', () => {
|
||||
expect(readQvaliaConfigFromEnv({})).toBeNull()
|
||||
expect(readQvaliaConfigFromEnv({ QVALIA_API_KEY: 'k', QVALIA_PARTNER_REG_NO: 'p' })).toBeNull()
|
||||
expect(readQvaliaConfigFromEnv({
|
||||
QVALIA_API_KEY: 'k',
|
||||
QVALIA_PARTNER_REG_NO: 'p',
|
||||
QVALIA_BASE_URL: 'http://insecure.example',
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults the account to the partner number, the bare-key auth that the sandbox accepts, and the documented header', () => {
|
||||
const parsed = readQvaliaConfigFromEnv({
|
||||
QVALIA_API_KEY: ' k ',
|
||||
QVALIA_PARTNER_REG_NO: 'SE1',
|
||||
QVALIA_BASE_URL: 'https://api-test.qvalia.com/',
|
||||
QVALIA_WEBHOOK_SECRET: 's',
|
||||
})
|
||||
expect(parsed).toEqual({
|
||||
apiKey: 'k',
|
||||
partnerRegNo: 'SE1',
|
||||
accountRegNo: 'SE1',
|
||||
baseUrl: 'https://api-test.qvalia.com',
|
||||
authScheme: 'raw',
|
||||
webhookSecret: 's',
|
||||
webhookHeader: 'x-accounted-webhook-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('honours an explicit account number, the ApiKey prefix and a custom header', () => {
|
||||
const parsed = readQvaliaConfigFromEnv({
|
||||
QVALIA_API_KEY: 'k',
|
||||
QVALIA_PARTNER_REG_NO: 'SE1',
|
||||
QVALIA_ACCOUNT_REG_NO: 'SE2',
|
||||
QVALIA_BASE_URL: 'https://api.qvalia.com',
|
||||
QVALIA_AUTH_SCHEME: 'apikey',
|
||||
QVALIA_WEBHOOK_HEADER: 'X-Custom',
|
||||
})
|
||||
expect(parsed?.accountRegNo).toBe('SE2')
|
||||
expect(parsed?.authScheme).toBe('apikey')
|
||||
expect(parsed?.webhookHeader).toBe('x-custom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerConfiguredPeppolTransports', () => {
|
||||
it('registers nothing when Qvalia is not configured', () => {
|
||||
expect(registerConfiguredPeppolTransports({})).toEqual([])
|
||||
})
|
||||
|
||||
it('registers the Qvalia adapter once when configured', () => {
|
||||
const env = {
|
||||
QVALIA_API_KEY: 'k',
|
||||
QVALIA_PARTNER_REG_NO: 'SE1',
|
||||
QVALIA_BASE_URL: 'https://api-qa.qvalia.com',
|
||||
}
|
||||
const first = registerConfiguredPeppolTransports(env)
|
||||
expect(first.map((t) => t.provider)).toEqual(['qvalia'])
|
||||
expect(getPeppolTransport('qvalia')).toBe(first[0])
|
||||
expect(registerConfiguredPeppolTransports(env)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Qvalia transport: lookupRecipient', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const transport = createQvaliaTransport(config, {
|
||||
fetch: fetchMock,
|
||||
now: () => new Date('2026-08-21T10:00:00.000Z'),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
it('calls the partner lookup with the ApiKey header and maps capabilities', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
|
||||
status: 'success',
|
||||
data: {
|
||||
exists: true,
|
||||
rootDocTypeExists: true,
|
||||
source: 'smp',
|
||||
matches: [{
|
||||
participantID: { scheme: 'iso6523-actorid-upis', value: '0007:5566778899' },
|
||||
docTypes: [
|
||||
// Live shape: the SMP service URL wraps the document type id.
|
||||
{ scheme: 'busdox-docid-qns', value: `https://smp-test.qvalia.com/iso6523-actorid-upis::0007:5566778899/services/busdox-docid-qns::${PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID}` },
|
||||
{ scheme: 'busdox-docid-qns', value: 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}))
|
||||
|
||||
const result = await transport.lookupRecipient({ scheme: '0007', identifier: '5566778899' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toBe(
|
||||
'https://api-qa.qvalia.com/partner/SE5560000000/peppol/lookup/0007%3A5566778899?docTypeRoot=Invoice',
|
||||
)
|
||||
expect((init?.headers as Record<string, string>).Authorization).toBe('ApiKey test-key')
|
||||
expect(result.reachable).toBe(true)
|
||||
if (!result.reachable) throw new Error('unreachable')
|
||||
expect(result.checkedAt).toBe('2026-08-21T10:00:00.000Z')
|
||||
expect(result.capabilities).toHaveLength(2)
|
||||
expect(result.capabilities[0]).toEqual({
|
||||
documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
|
||||
processId: PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a participant without an Invoice capability as not reachable', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
|
||||
status: 'success',
|
||||
data: { exists: true, rootDocTypeExists: false, matches: [] },
|
||||
}))
|
||||
const result = await transport.lookupRecipient({ scheme: '0007', identifier: '5566778899' })
|
||||
expect(result).toMatchObject({ reachable: false, reasonCode: 'document_type_not_supported' })
|
||||
})
|
||||
|
||||
it('treats 204/404 and exists=false as not registered, never as an error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
expect(await transport.lookupRecipient({ scheme: '0007', identifier: '1' }))
|
||||
.toMatchObject({ reachable: false, reasonCode: 'participant_not_found' })
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: 'success', data: { exists: false, matches: [] } }))
|
||||
expect(await transport.lookupRecipient({ scheme: '0007', identifier: '1' }))
|
||||
.toMatchObject({ reachable: false, reasonCode: 'participant_not_registered' })
|
||||
})
|
||||
|
||||
it('surfaces credential problems as a retryable auth error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: 'Unauthorized' }))
|
||||
await expect(transport.lookupRecipient({ scheme: '0007', identifier: '1' }))
|
||||
.rejects.toMatchObject({ kind: 'auth', retryable: true, httpStatus: 401 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Qvalia transport: submit', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const transport = createQvaliaTransport(config, {
|
||||
fetch: fetchMock,
|
||||
now: () => new Date('2026-08-21T10:05:00.000Z'),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
it('POSTs the exact XML to the partner-scoped outgoing endpoint and returns the integrationId', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
|
||||
status: 'success',
|
||||
data: { message: 'invoice F-2026-42 sent', invoice_id: 'F-2026-42', integrationId: 'int-1' },
|
||||
}))
|
||||
|
||||
const receipt = await transport.submit(submission())
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toBe(
|
||||
'https://api-qa.qvalia.com/partner/SE5560000000/transaction/SE5560000000/invoices/outgoing',
|
||||
)
|
||||
expect(init?.method).toBe('POST')
|
||||
expect((init?.headers as Record<string, string>)['content-type']).toBe('application/xml')
|
||||
expect(init?.body).toBe(XML)
|
||||
expect(receipt).toEqual({
|
||||
provider: 'qvalia',
|
||||
providerSubmissionId: 'int-1',
|
||||
idempotencyKey: '33333333-3333-4333-8333-333333333333',
|
||||
tenantReference: 'company-1',
|
||||
acceptedAt: '2026-08-21T10:05:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the integrationid response header when the body has none', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('<success><message>ok</message></success>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/xml', integrationid: 'int-header' },
|
||||
}))
|
||||
const receipt = await transport.submit(submission())
|
||||
expect(receipt.providerSubmissionId).toBe('int-header')
|
||||
})
|
||||
|
||||
it('classifies 422 as a permanent rejection with Qvalia’s reason', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(422, {
|
||||
status: 'error',
|
||||
type: 'validation',
|
||||
metadata: { description: 'BR-CO-10 Sum of invoice line net amount' },
|
||||
}))
|
||||
const error = await transport.submit(submission()).catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(QvaliaApiError)
|
||||
expect(error).toMatchObject({
|
||||
kind: 'rejected',
|
||||
retryable: false,
|
||||
httpStatus: 422,
|
||||
detail: 'BR-CO-10 Sum of invoice line net amount',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies 5xx and network failures as retryable', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(503, { error: 'maintenance' }))
|
||||
await expect(transport.submit(submission())).rejects.toMatchObject({ kind: 'unavailable', retryable: true })
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
|
||||
await expect(transport.submit(submission())).rejects.toMatchObject({ kind: 'network', retryable: true })
|
||||
})
|
||||
|
||||
it('recovers the integrationId on 409 only when Qvalia’s copy was sent by the same seller', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(409, { status: 'error', data: 'duplicate' }))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
|
||||
status: 'success',
|
||||
data: [{
|
||||
integrationId: 'int-dup',
|
||||
Invoice: {
|
||||
ID: [{ _: 'F-2026-42' }],
|
||||
AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '556016-0680', schemeID: '0007' }] }] }],
|
||||
},
|
||||
}],
|
||||
}))
|
||||
|
||||
const receipt = await transport.submit(submission())
|
||||
expect(receipt.providerSubmissionId).toBe('int-dup')
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('/invoices/outgoing?documentId=F-2026-42&includeRead=true')
|
||||
})
|
||||
|
||||
it('keeps a 409 as a duplicate error when the stored copy belongs to another seller', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(409, { status: 'error', data: 'duplicate' }))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
|
||||
status: 'success',
|
||||
data: [{
|
||||
integrationId: 'int-other',
|
||||
Invoice: {
|
||||
AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '5599999999', schemeID: '0007' }] }] }],
|
||||
},
|
||||
}],
|
||||
}))
|
||||
await expect(transport.submit(submission())).rejects.toMatchObject({ kind: 'duplicate', retryable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Qvalia transport: verifyWebhook', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const transport = createQvaliaTransport(config, { fetch: fetchMock })
|
||||
const delivered = {
|
||||
eventType: 'document_delivery',
|
||||
accountRegNo: 'SE5560000000',
|
||||
documentType: 'Invoice',
|
||||
direction: 'outgoing',
|
||||
integrationId: 'int-1',
|
||||
occurredAt: '2026-08-19T09:26:10.104Z',
|
||||
globalTransactionId: 'int-1',
|
||||
status: { status: 'processed', event: 'message-log/update', deliveryMethod: 'peppol', updatedAt: '2026-08-19T09:26:09.881Z' },
|
||||
peppol_metadata: { messageId: 'abc@QVALIA-PSE000094', accessPoint: 'PSE000094' },
|
||||
}
|
||||
|
||||
function webhook(body: unknown, secret: string | null = config.webhookSecret) {
|
||||
const headers = new Headers({ 'content-type': 'application/json' })
|
||||
if (secret) headers.set('X-Accounted-Webhook-Key', secret)
|
||||
return { headers, rawBody: new TextEncoder().encode(JSON.stringify(body)) }
|
||||
}
|
||||
|
||||
it('rejects a missing or wrong shared secret before parsing', async () => {
|
||||
await expect(transport.verifyWebhook(webhook(delivered, null))).rejects.toMatchObject({ kind: 'auth' })
|
||||
await expect(transport.verifyWebhook(webhook(delivered, 'wrong'))).rejects.toMatchObject({ kind: 'auth' })
|
||||
})
|
||||
|
||||
it('refuses to verify anything when no secret is configured', async () => {
|
||||
const unconfigured = createQvaliaTransport({ ...config, webhookSecret: null }, { fetch: fetchMock })
|
||||
await expect(unconfigured.verifyWebhook(webhook(delivered))).rejects.toMatchObject({ kind: 'auth' })
|
||||
})
|
||||
|
||||
it('normalizes a processed delivery with the documented dedupe key and a body fingerprint', async () => {
|
||||
const request = webhook(delivered)
|
||||
const [event] = await transport.verifyWebhook(request)
|
||||
expect(event).toMatchObject({
|
||||
provider: 'qvalia',
|
||||
providerTenantId: 'SE5560000000',
|
||||
providerSubmissionId: 'int-1',
|
||||
providerEventId: 'document_delivery:int-1:processed',
|
||||
idempotencyKey: null,
|
||||
eventCode: 'document_delivery',
|
||||
normalizedStatus: 'transport_succeeded',
|
||||
isTerminal: false,
|
||||
detail: 'processed',
|
||||
occurredAt: '2026-08-19T09:26:09.881Z',
|
||||
verificationMethod: 'shared_secret_header',
|
||||
})
|
||||
expect(event.eventSha256).toBe(sha256Hex(request.rawBody))
|
||||
})
|
||||
|
||||
it('ignores inbound-direction events on the outbound boundary', async () => {
|
||||
const events = await transport.verifyWebhook(webhook({ ...delivered, direction: 'incoming' }))
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a recoverable error non-terminal and a validation error terminal', async () => {
|
||||
const [recoverable] = await transport.verifyWebhook(webhook({
|
||||
...delivered,
|
||||
eventType: 'document_error',
|
||||
status: { status: 'error', event: 'message-log/error' },
|
||||
error: 'Receiver access point timed out',
|
||||
}))
|
||||
expect(recoverable).toMatchObject({ normalizedStatus: 'retryable_failure', isTerminal: false })
|
||||
|
||||
const [permanent] = await transport.verifyWebhook(webhook({
|
||||
...delivered,
|
||||
eventType: 'document_error',
|
||||
status: { status: 'error', event: 'message-log/error' },
|
||||
error: 'Peppol validation failed: invoice does not conform to UBL 2.1',
|
||||
}))
|
||||
expect(permanent).toMatchObject({
|
||||
normalizedStatus: 'failed',
|
||||
isTerminal: true,
|
||||
detail: 'error: Peppol validation failed: invoice does not conform to UBL 2.1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQvaliaWebhook', () => {
|
||||
const base = { eventType: 'document_delivery', direction: 'outgoing' }
|
||||
it.each([
|
||||
['rejected by buyer', 'business_rejected', true],
|
||||
['accepted', 'business_accepted', true],
|
||||
['acknowledged', 'recipient_acknowledged', false],
|
||||
['delivered', 'transport_succeeded', false],
|
||||
['queued for sending', 'submission_accepted', false],
|
||||
['some new wording', 'submission_accepted', false],
|
||||
])('maps "%s" to %s', (status, expected, terminal) => {
|
||||
expect(normalizeQvaliaWebhook({ ...base, status: { status } })).toMatchObject({
|
||||
normalizedStatus: expected,
|
||||
isTerminal: terminal,
|
||||
detail: status,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps new_document to submission_accepted and ignores unknown event types', () => {
|
||||
expect(normalizeQvaliaWebhook({ eventType: 'new_document' })?.normalizedStatus).toBe('submission_accepted')
|
||||
expect(normalizeQvaliaWebhook({ eventType: 'something_else' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Qvalia transport: retrieveEvidence', () => {
|
||||
it('captures the message-log status and the provider-held XML copy', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, [{ uuid: 'int-1', readAt: null, metadata: { status: 'processed' } }]))
|
||||
fetchMock.mockResolvedValueOnce(new Response(XML, { status: 200, headers: { 'content-type': 'application/xml' } }))
|
||||
const transport = createQvaliaTransport(config, {
|
||||
fetch: fetchMock,
|
||||
now: () => new Date('2026-08-21T11:00:00.000Z'),
|
||||
})
|
||||
|
||||
const [evidence] = await transport.retrieveEvidence('int-1')
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/invoices/outgoing/status?integrationId=int-1')
|
||||
expect((fetchMock.mock.calls[1][1]?.headers as Record<string, string>).accept).toBe('application/xml')
|
||||
expect(evidence).toMatchObject({
|
||||
provider: 'qvalia',
|
||||
evidenceType: 'qvalia_message_record',
|
||||
exactDocument: XML,
|
||||
exactDocumentSha256: sha256Hex(XML),
|
||||
retrievedAt: '2026-08-21T11:00:00.000Z',
|
||||
})
|
||||
expect(evidence.payload).toMatchObject({ integrationId: 'int-1', status: [{ uuid: 'int-1' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('reduces SMP service URLs to bare Peppol document type ids', () => {
|
||||
expect(normalizePeppolDocumentTypeId(
|
||||
`https://smp-test.qvalia.com/iso6523-actorid-upis::0007:5567321707/services/busdox-docid-qns::${PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID}`,
|
||||
)).toBe(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID)
|
||||
expect(normalizePeppolDocumentTypeId(
|
||||
'https://smp-test.qvalia.com/iso6523-actorid-upis::0007:1/services/peppol-doctype-wildcard::urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:peppol:pint:selfbilling-1%40aunz-1::2.1',
|
||||
)).toBe('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:peppol:pint:selfbilling-1@aunz-1::2.1')
|
||||
expect(normalizePeppolDocumentTypeId(`busdox-docid-qns::${PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID}`))
|
||||
.toBe(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID)
|
||||
expect(normalizePeppolDocumentTypeId(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID))
|
||||
.toBe(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID)
|
||||
})
|
||||
|
||||
it('extracts the UBL document id and the seller endpoint', () => {
|
||||
expect(extractUblDocumentId(XML)).toBe('F-2026-42')
|
||||
expect(extractUblDocumentId('<cbc:ID schemeID="x">A & B</cbc:ID>')).toBe('A & B')
|
||||
// Entities are decoded in one pass: "&lt;" is the literal text "<".
|
||||
expect(extractUblDocumentId('<cbc:ID>X &lt; Y</cbc:ID>')).toBe('X < Y')
|
||||
expect(extractUblDocumentId('<nothing/>')).toBeNull()
|
||||
expect(extractUblJsonSupplierEndpoint({
|
||||
Invoice: { AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '1', schemeID: '0007' }] }] }] },
|
||||
})).toEqual({ scheme: '0007', identifier: '1' })
|
||||
expect(extractUblJsonSupplierEndpoint({ Invoice: {} })).toBeNull()
|
||||
})
|
||||
|
||||
it('describes Qvalia error bodies from the documented envelopes', () => {
|
||||
expect(describeQvaliaErrorBody({ statusCode: 400, error: 'Bad Request', message: 'missing ID' })).toBe('missing ID')
|
||||
expect(describeQvaliaErrorBody({ metadata: { details: { rule: 'BR-01' } } })).toBe('{"rule":"BR-01"}')
|
||||
expect(describeQvaliaErrorBody('plain text')).toBe('plain text')
|
||||
expect(describeQvaliaErrorBody(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,9 @@ export const PEPPOL_BIS_BILLING_CUSTOMIZATION_ID =
|
||||
'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0'
|
||||
export const PEPPOL_BIS_BILLING_PROFILE_ID =
|
||||
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'
|
||||
/** Peppol document type identifier for a BIS Billing 3 UBL 2.1 invoice (SMP capability key). */
|
||||
export const PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID =
|
||||
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
|
||||
|
||||
const SUPPORTED_VAT_RATES = new Set([6, 12, 25])
|
||||
const UNIT_CODES: Record<string, string> = {
|
||||
|
||||
@@ -81,6 +81,9 @@ export async function persistVerifiedPeppolEvent(args: {
|
||||
event: PeppolVerifiedEvent
|
||||
}): Promise<PeppolDeliverySummary> {
|
||||
const { event } = args
|
||||
if (!event.idempotencyKey) {
|
||||
throw new Error('Peppol event idempotency key must be resolved before it is recorded')
|
||||
}
|
||||
const { data, error } = await args.supabase.rpc('record_peppol_delivery_event', {
|
||||
p_company_id: args.companyId,
|
||||
p_idempotency_key: event.idempotencyKey,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { NextResponse } from 'next/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { privateNoStore } from '@/lib/api/private-no-store'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
generatePeppolBisBillingInvoice,
|
||||
type PeppolInvoiceResult,
|
||||
} from '@/lib/invoices/peppol-bis-billing'
|
||||
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
export type GeneratedPeppolInvoice = Extract<PeppolInvoiceResult, { ok: true }>
|
||||
|
||||
export type PeppolInvoiceRecord = Invoice & { customer?: Customer | null; items?: InvoiceItem[] | null }
|
||||
|
||||
type RouteLog = Parameters<typeof errorResponseFromCode>[1]
|
||||
|
||||
export type LoadPeppolRecordsResult =
|
||||
| { ok: true; invoice: PeppolInvoiceRecord; company: CompanySettings }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
export type LoadPeppolDocumentResult =
|
||||
| { ok: true; document: GeneratedPeppolInvoice; invoice: PeppolInvoiceRecord; company: CompanySettings }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
/**
|
||||
* Fetch the invoice (with customer and lines) and the company settings the
|
||||
* Peppol generator needs, with the same explicit `company_id` isolation as the
|
||||
* other invoice routes. Shared by the export, stage and send routes.
|
||||
*/
|
||||
export async function loadPeppolRecords(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
log: RouteLog
|
||||
requestId: string
|
||||
}): Promise<LoadPeppolRecordsResult> {
|
||||
const { data: invoice, error: invoiceError } = await args.supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', args.invoiceId)
|
||||
.eq('company_id', args.companyId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode(
|
||||
'INVOICE_NOT_FOUND',
|
||||
args.log,
|
||||
{ requestId: args.requestId },
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
const { data: company, error: companyError } = await args.supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', args.companyId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode(
|
||||
'INVOICE_SEND_COMPANY_SETTINGS_MISSING',
|
||||
args.log,
|
||||
{ requestId: args.requestId },
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
invoice: invoice as PeppolInvoiceRecord,
|
||||
company: company as CompanySettings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the BIS Billing 3 generator on already-loaded records and turn a failed
|
||||
* preflight into the structured, field-addressable VALIDATION_ERROR envelope.
|
||||
*/
|
||||
export function generatePeppolDocumentOrResponse(args: {
|
||||
invoice: PeppolInvoiceRecord
|
||||
company: CompanySettings
|
||||
log: RouteLog
|
||||
requestId: string
|
||||
}): { ok: true; document: GeneratedPeppolInvoice } | { ok: false; response: NextResponse } {
|
||||
if (!args.invoice.customer) {
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
|
||||
requestId: args.requestId,
|
||||
messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.',
|
||||
messageEn: 'The invoice has no customer available for Peppol export.',
|
||||
details: { field: 'invoice.customer' },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const document = generatePeppolBisBillingInvoice({
|
||||
invoice: args.invoice,
|
||||
customer: args.invoice.customer,
|
||||
items: args.invoice.items ?? [],
|
||||
company: args.company,
|
||||
})
|
||||
if (!document.ok) {
|
||||
const first = document.issues[0]
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
|
||||
requestId: args.requestId,
|
||||
messageSv: first?.messageSv,
|
||||
messageEn: first?.messageEn,
|
||||
details: {
|
||||
issues: document.issues.map((item) => ({
|
||||
code: item.code,
|
||||
field: item.field,
|
||||
message_sv: item.messageSv,
|
||||
message_en: item.messageEn,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, document }
|
||||
}
|
||||
|
||||
export async function loadPeppolDocument(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
log: RouteLog
|
||||
requestId: string
|
||||
}): Promise<LoadPeppolDocumentResult> {
|
||||
const records = await loadPeppolRecords(args)
|
||||
if (!records.ok) return records
|
||||
const generated = generatePeppolDocumentOrResponse({
|
||||
invoice: records.invoice,
|
||||
company: records.company,
|
||||
log: args.log,
|
||||
requestId: args.requestId,
|
||||
})
|
||||
if (!generated.ok) return generated
|
||||
return { ok: true, document: generated.document, invoice: records.invoice, company: records.company }
|
||||
}
|
||||
@@ -66,7 +66,12 @@ export interface PeppolVerifiedEvent {
|
||||
providerTenantId: string | null
|
||||
providerSubmissionId: string | null
|
||||
providerEventId: string | null
|
||||
idempotencyKey: string
|
||||
/**
|
||||
* 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
|
||||
@@ -92,6 +97,28 @@ export interface PeppolWebhookRequest {
|
||||
rawBody: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Registers the Peppol Access Point adapters that the environment configures.
|
||||
* Core ships the Qvalia adapter; `PEPPOL_TRANSPORT_PROVIDER` still decides
|
||||
* which registered adapter the product is allowed to use, so an adapter can be
|
||||
* configured (for the probe script, for a preview) without being switched on.
|
||||
*/
|
||||
|
||||
import {
|
||||
getPeppolTransport,
|
||||
registerPeppolTransport,
|
||||
type PeppolTransport,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
import {
|
||||
QVALIA_PROVIDER,
|
||||
createQvaliaTransport,
|
||||
readQvaliaConfigFromEnv,
|
||||
} from '@/lib/invoices/transports/qvalia'
|
||||
|
||||
export function registerConfiguredPeppolTransports(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): PeppolTransport[] {
|
||||
const registered: PeppolTransport[] = []
|
||||
|
||||
if (!getPeppolTransport(QVALIA_PROVIDER)) {
|
||||
const qvaliaConfig = readQvaliaConfigFromEnv(env)
|
||||
if (qvaliaConfig) {
|
||||
const transport = createQvaliaTransport(qvaliaConfig)
|
||||
registerPeppolTransport(transport)
|
||||
registered.push(transport)
|
||||
}
|
||||
}
|
||||
|
||||
return registered
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Qvalia Peppol Access Point adapter.
|
||||
*
|
||||
* Qvalia (PSE000094) is the contracted Access Point + SMP. This module is the
|
||||
* only place that knows Qvalia's HTTP surface; everything else speaks the
|
||||
* provider-neutral `PeppolTransport` boundary.
|
||||
*
|
||||
* API facts (https://api.qvalia.io, verified 2026-08-21):
|
||||
* - Production `https://api.qvalia.com`, sandbox `https://api-test.qvalia.com`
|
||||
* (the public docs say api-qa; the onboarding mail says api-test and that one
|
||||
* answers), separate keys per environment.
|
||||
* - Auth: the bare key in `Authorization: <key>` (verified live against the
|
||||
* sandbox 2026-08-21; the `ApiKey <key>` form in the newest docs answers 401
|
||||
* for this key, so it is opt-in via QVALIA_AUTH_SCHEME=apikey).
|
||||
* - Partner model: every call is `/partner/{partnerRegNo}/...`; transactions
|
||||
* are `/partner/{partnerRegNo}/transaction/{accountRegNo}/invoices/outgoing`.
|
||||
* In the consolidated setup all customer Peppol IDs live under one account
|
||||
* and `accountRegNo` equals `partnerRegNo`.
|
||||
* - Outgoing invoice: POST the BIS Billing 3 UBL XML with
|
||||
* `content-type: application/xml`; the response carries an `integrationId`
|
||||
* (UUID) that identifies the message at Qvalia. The same document id for the
|
||||
* same receiver answers `409`.
|
||||
* - Recipient lookup: GET `/partner/{p}/peppol/lookup/{scheme:id}?docTypeRoot=Invoice`.
|
||||
* - Webhooks are plain HTTPS POSTs without a signature; the partner attaches
|
||||
* an outbound auth header of its own choosing. Delivery is at-least-once and
|
||||
* the documented dedupe key is eventType + globalTransactionId + status.status.
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { sha256Hex } from '@/lib/invoices/peppol-delivery'
|
||||
import {
|
||||
PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
|
||||
PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
} from '@/lib/invoices/peppol-bis-billing'
|
||||
import {
|
||||
PeppolTransportError,
|
||||
type PeppolDeliveryEvidence,
|
||||
type PeppolDeliveryStatus,
|
||||
type PeppolParticipant,
|
||||
type PeppolRecipientCapability,
|
||||
type PeppolRecipientLookup,
|
||||
type PeppolSubmission,
|
||||
type PeppolSubmissionReceipt,
|
||||
type PeppolTransport,
|
||||
type PeppolVerifiedEvent,
|
||||
type PeppolWebhookRequest,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
|
||||
export const QVALIA_PROVIDER = 'qvalia'
|
||||
export const QVALIA_PRODUCTION_BASE_URL = 'https://api.qvalia.com'
|
||||
export const QVALIA_SANDBOX_BASE_URL = 'https://api-test.qvalia.com'
|
||||
export const QVALIA_DEFAULT_WEBHOOK_HEADER = 'x-accounted-webhook-key'
|
||||
|
||||
export type QvaliaAuthScheme = 'apikey' | 'raw'
|
||||
|
||||
export interface QvaliaConfig {
|
||||
apiKey: string
|
||||
/** Partner registration number issued by Qvalia. */
|
||||
partnerRegNo: string
|
||||
/**
|
||||
* Account registration number the documents are sent from. Consolidated
|
||||
* setup: the partner account itself. Multi-tenant setup (later): one per
|
||||
* Accounted company.
|
||||
*/
|
||||
accountRegNo: string
|
||||
baseUrl: string
|
||||
authScheme: QvaliaAuthScheme
|
||||
/** Shared secret Qvalia sends back on every webhook delivery. */
|
||||
webhookSecret: string | null
|
||||
/** Header name carrying the shared secret (compared case-insensitively). */
|
||||
webhookHeader: string
|
||||
}
|
||||
|
||||
export interface QvaliaTransportDeps {
|
||||
fetch?: typeof fetch
|
||||
now?: () => Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the adapter configuration from the environment. Returns `null` when
|
||||
* the mandatory values are absent so the transport stays unregistered and the
|
||||
* product truthfully reports "provider adapter unavailable".
|
||||
*/
|
||||
export function readQvaliaConfigFromEnv(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): QvaliaConfig | null {
|
||||
const apiKey = env.QVALIA_API_KEY?.trim()
|
||||
const partnerRegNo = env.QVALIA_PARTNER_REG_NO?.trim()
|
||||
const baseUrl = env.QVALIA_BASE_URL?.trim().replace(/\/+$/, '')
|
||||
if (!apiKey || !partnerRegNo || !baseUrl) return null
|
||||
if (!/^https:\/\//i.test(baseUrl)) return null
|
||||
|
||||
const authSchemeRaw = env.QVALIA_AUTH_SCHEME?.trim().toLowerCase()
|
||||
const authScheme: QvaliaAuthScheme = authSchemeRaw === 'apikey' ? 'apikey' : 'raw'
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
partnerRegNo,
|
||||
accountRegNo: env.QVALIA_ACCOUNT_REG_NO?.trim() || partnerRegNo,
|
||||
baseUrl,
|
||||
authScheme,
|
||||
webhookSecret: env.QVALIA_WEBHOOK_SECRET?.trim() || null,
|
||||
webhookHeader: (env.QVALIA_WEBHOOK_HEADER?.trim() || QVALIA_DEFAULT_WEBHOOK_HEADER).toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export type QvaliaErrorKind =
|
||||
| 'rejected'
|
||||
| 'duplicate'
|
||||
| 'auth'
|
||||
| 'rate_limited'
|
||||
| 'unavailable'
|
||||
| 'network'
|
||||
| 'protocol'
|
||||
|
||||
/**
|
||||
* One error type for every Qvalia failure. `kind` tells the caller whether a
|
||||
* retry can help: `rejected` and `duplicate` are permanent for this document,
|
||||
* everything else is operational.
|
||||
*/
|
||||
export class QvaliaApiError extends PeppolTransportError {
|
||||
readonly kind: QvaliaErrorKind
|
||||
readonly httpStatus: number | null
|
||||
|
||||
constructor(kind: QvaliaErrorKind, message: string, options: {
|
||||
httpStatus?: number | null
|
||||
detail?: string | null
|
||||
cause?: unknown
|
||||
} = {}) {
|
||||
super(message, {
|
||||
retryable: kind !== 'rejected' && kind !== 'duplicate',
|
||||
detail: options.detail ?? null,
|
||||
cause: options.cause,
|
||||
})
|
||||
this.name = 'QvaliaApiError'
|
||||
this.kind = kind
|
||||
this.httpStatus = options.httpStatus ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export function isQvaliaApiError(error: unknown): error is QvaliaApiError {
|
||||
return error instanceof QvaliaApiError
|
||||
}
|
||||
|
||||
function authorizationHeader(config: QvaliaConfig): string {
|
||||
return config.authScheme === 'raw' ? config.apiKey : `ApiKey ${config.apiKey}`
|
||||
}
|
||||
|
||||
function encodePathSegment(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
/** Pull a human-readable reason out of Qvalia's varied error envelopes. */
|
||||
export function describeQvaliaErrorBody(body: unknown): string | null {
|
||||
const record = asRecord(body)
|
||||
if (!record) return typeof body === 'string' && body.trim() ? body.trim().slice(0, 500) : null
|
||||
const metadata = asRecord(record.metadata)
|
||||
const candidates: unknown[] = [
|
||||
metadata?.description,
|
||||
metadata?.debug_error_message,
|
||||
record.message,
|
||||
record.error,
|
||||
record.data,
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const text = asString(candidate)
|
||||
if (text) return text.slice(0, 500)
|
||||
}
|
||||
const details = metadata?.details
|
||||
if (details && typeof details === 'object') {
|
||||
try {
|
||||
return JSON.stringify(details).slice(0, 500)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readBody(response: Response): Promise<{ text: string; json: unknown }> {
|
||||
const text = await response.text()
|
||||
if (!text) return { text, json: null }
|
||||
try {
|
||||
return { text, json: JSON.parse(text) as unknown }
|
||||
} catch {
|
||||
return { text, json: null }
|
||||
}
|
||||
}
|
||||
|
||||
function classifyHttpFailure(status: number, body: unknown, text: string): QvaliaApiError {
|
||||
const detail = describeQvaliaErrorBody(body) ?? (text.trim() ? text.trim().slice(0, 500) : null)
|
||||
if (status === 400 || status === 422) {
|
||||
return new QvaliaApiError('rejected', `Qvalia rejected the document (${status})`, {
|
||||
httpStatus: status,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
if (status === 409) {
|
||||
return new QvaliaApiError('duplicate', 'Qvalia already holds a document with this id for this receiver', {
|
||||
httpStatus: status,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return new QvaliaApiError('auth', `Qvalia refused the API credentials (${status})`, {
|
||||
httpStatus: status,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
if (status === 429) {
|
||||
return new QvaliaApiError('rate_limited', 'Qvalia rate limit reached', {
|
||||
httpStatus: status,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
return new QvaliaApiError('unavailable', `Qvalia answered ${status}`, {
|
||||
httpStatus: status,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
/** First `<cbc:ID>` of a UBL document is the document number (after CustomizationID/ProfileID). */
|
||||
export function extractUblDocumentId(xml: string): string | null {
|
||||
const match = /<cbc:ID(?:\s[^>]*)?>([^<]+)<\/cbc:ID>/.exec(xml)
|
||||
if (!match) return null
|
||||
// Single pass: a sequential chain would double-unescape "&lt;".
|
||||
const entities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
|
||||
const value = match[1]
|
||||
.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => entities[name])
|
||||
.trim()
|
||||
return value || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Dig the seller endpoint identifier out of a UBL-JSON invoice as Qvalia
|
||||
* returns it (OASIS UBL 2.1 JSON: arrays everywhere, text under `_`).
|
||||
*/
|
||||
export function extractUblJsonSupplierEndpoint(message: unknown): PeppolParticipant | null {
|
||||
const record = asRecord(message)
|
||||
if (!record) return null
|
||||
const invoice = asRecord(record.Invoice) ?? record
|
||||
const supplierParty = firstRecord(invoice.AccountingSupplierParty)
|
||||
const party = firstRecord(supplierParty?.Party)
|
||||
const endpoint = firstRecord(party?.EndpointID)
|
||||
const identifier = asString(endpoint?._)
|
||||
const scheme = asString(endpoint?.schemeID)
|
||||
if (!identifier || !scheme) return null
|
||||
return { scheme, identifier }
|
||||
}
|
||||
|
||||
function firstRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (Array.isArray(value)) return asRecord(value[0])
|
||||
return asRecord(value)
|
||||
}
|
||||
|
||||
function extractIntegrationId(message: unknown): string | null {
|
||||
const record = asRecord(message)
|
||||
if (!record) return null
|
||||
return asString(record.integrationId) ?? asString(asRecord(record.Invoice)?.integrationId)
|
||||
}
|
||||
|
||||
function participantsEqual(a: PeppolParticipant, b: PeppolParticipant): boolean {
|
||||
return a.scheme === b.scheme
|
||||
&& a.identifier.replace(/[\s-]/g, '') === b.identifier.replace(/[\s-]/g, '')
|
||||
}
|
||||
|
||||
/** Webhook payload shape as documented on api.qvalia.io (one flat object). */
|
||||
export interface QvaliaWebhookPayload {
|
||||
eventType: 'new_document' | 'document_delivery' | 'document_error' | string
|
||||
accountRegNo?: string
|
||||
documentType?: string
|
||||
direction?: 'outgoing' | 'incoming' | string
|
||||
integrationId?: string
|
||||
occurredAt?: string
|
||||
documentId?: string
|
||||
globalTransactionId?: string
|
||||
status?: {
|
||||
status?: string
|
||||
event?: string
|
||||
deliveryMethod?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
error?: string | null
|
||||
peppol_metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface QvaliaNormalizedStatus {
|
||||
eventCode: string
|
||||
normalizedStatus: PeppolDeliveryStatus
|
||||
isTerminal: boolean
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
const PERMANENT_ERROR_RE = /validat|schema|conform|invalid|malformed|not registered|unknown (?:recipient|receiver|participant)|no (?:such )?(?:recipient|receiver|participant)|not found in (?:smp|sml|peppol)/i
|
||||
|
||||
/**
|
||||
* Map Qvalia's free-text delivery statuses onto the lifecycle. `status.status`
|
||||
* is explicitly "not a fixed enum and may change" in Qvalia's docs, so the
|
||||
* mapping is tolerant: unknown wording never advances beyond what the event
|
||||
* type itself proves, and the raw wording is kept in `detail`.
|
||||
*/
|
||||
export function normalizeQvaliaWebhook(payload: QvaliaWebhookPayload): QvaliaNormalizedStatus | null {
|
||||
const rawStatus = payload.status?.status?.trim() ?? ''
|
||||
const lower = rawStatus.toLowerCase()
|
||||
const detailParts = [rawStatus, payload.error?.trim()].filter((part): part is string => !!part)
|
||||
const detail = detailParts.length ? detailParts.join(': ').slice(0, 500) : null
|
||||
|
||||
switch (payload.eventType) {
|
||||
case 'new_document':
|
||||
return {
|
||||
eventCode: 'new_document',
|
||||
normalizedStatus: 'submission_accepted',
|
||||
isTerminal: false,
|
||||
detail,
|
||||
}
|
||||
case 'document_delivery': {
|
||||
if (/reject|refus|denied/.test(lower)) {
|
||||
return { eventCode: 'document_delivery', normalizedStatus: 'business_rejected', isTerminal: true, detail }
|
||||
}
|
||||
if (/accept|approv|\bpaid\b/.test(lower)) {
|
||||
return { eventCode: 'document_delivery', normalizedStatus: 'business_accepted', isTerminal: true, detail }
|
||||
}
|
||||
if (/acknowledg|\back\b|confirmed/.test(lower)) {
|
||||
return { eventCode: 'document_delivery', normalizedStatus: 'recipient_acknowledged', isTerminal: false, detail }
|
||||
}
|
||||
if (/processed|delivered|sent|transport|success|complete|received/.test(lower)) {
|
||||
return { eventCode: 'document_delivery', normalizedStatus: 'transport_succeeded', isTerminal: false, detail }
|
||||
}
|
||||
if (/error|fail|undeliver|bounce/.test(lower)) {
|
||||
return { eventCode: 'document_delivery', normalizedStatus: 'retryable_failure', isTerminal: false, detail }
|
||||
}
|
||||
return { eventCode: 'document_delivery', normalizedStatus: 'submission_accepted', isTerminal: false, detail }
|
||||
}
|
||||
case 'document_error': {
|
||||
const reason = `${rawStatus} ${payload.error ?? ''}`
|
||||
// Qvalia retries transport errors for 24 h by default and then sends a
|
||||
// document_delivery if it succeeds; only structural rejections are final.
|
||||
const permanent = PERMANENT_ERROR_RE.test(reason)
|
||||
return {
|
||||
eventCode: 'document_error',
|
||||
normalizedStatus: permanent ? 'failed' : 'retryable_failure',
|
||||
isTerminal: permanent,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Qvalia's lookup returns document types as SMP service URLs, e.g.
|
||||
* `https://smp-test.qvalia.com/iso6523-actorid-upis::0007:5567321707/services/busdox-docid-qns::urn:oasis:...::2.1`
|
||||
* (verified live 2026-08-21), sometimes percent-encoded. Reduce them to the
|
||||
* bare Peppol document type identifier so capabilities compare by value.
|
||||
*/
|
||||
export function normalizePeppolDocumentTypeId(value: string): string {
|
||||
let candidate = value.trim()
|
||||
const servicesIndex = candidate.indexOf('/services/')
|
||||
if (/^https?:\/\//i.test(candidate) && servicesIndex !== -1) {
|
||||
candidate = candidate.slice(servicesIndex + '/services/'.length)
|
||||
}
|
||||
try {
|
||||
candidate = decodeURIComponent(candidate)
|
||||
} catch {
|
||||
// keep as-is when not percent-encoded
|
||||
}
|
||||
const schemeMatch = /^(busdox-docid-qns|peppol-doctype-wildcard)::/.exec(candidate)
|
||||
if (schemeMatch) candidate = candidate.slice(schemeMatch[0].length)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function capabilityFromDocType(value: string): PeppolRecipientCapability {
|
||||
const documentTypeId = normalizePeppolDocumentTypeId(value)
|
||||
return {
|
||||
documentTypeId,
|
||||
processId: documentTypeId === PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID ? PEPPOL_BIS_BILLING_PROFILE_ID : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function createQvaliaTransport(
|
||||
config: QvaliaConfig,
|
||||
deps: QvaliaTransportDeps = {},
|
||||
): PeppolTransport {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch
|
||||
const now = deps.now ?? (() => new Date())
|
||||
const partner = encodePathSegment(config.partnerRegNo)
|
||||
const account = encodePathSegment(config.accountRegNo)
|
||||
const transactionBase = `${config.baseUrl}/partner/${partner}/transaction/${account}`
|
||||
|
||||
async function request(
|
||||
method: 'GET' | 'POST',
|
||||
url: string,
|
||||
init: { headers?: Record<string, string>; body?: string } = {},
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetchImpl(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: authorizationHeader(config),
|
||||
accept: 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: init.body,
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch (error) {
|
||||
throw new QvaliaApiError('network', 'Could not reach Qvalia', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup> {
|
||||
const checkedAt = now().toISOString()
|
||||
const peppolId = `${participant.scheme}:${participant.identifier}`
|
||||
const url = `${config.baseUrl}/partner/${partner}/peppol/lookup/${encodePathSegment(peppolId)}?docTypeRoot=Invoice`
|
||||
const response = await request('GET', url)
|
||||
|
||||
if (response.status === 204 || response.status === 404) {
|
||||
return { reachable: false, participant, reasonCode: 'participant_not_found', checkedAt }
|
||||
}
|
||||
const { text, json } = await readBody(response)
|
||||
if (response.status === 422 || response.status === 400) {
|
||||
return { reachable: false, participant, reasonCode: 'invalid_identifier', checkedAt }
|
||||
}
|
||||
if (!response.ok) throw classifyHttpFailure(response.status, json, text)
|
||||
|
||||
const data = asRecord(asRecord(json)?.data) ?? asRecord(json)
|
||||
if (!data) {
|
||||
throw new QvaliaApiError('protocol', 'Qvalia lookup answered without a data object', {
|
||||
httpStatus: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
const exists = data.exists === true
|
||||
const rootDocTypeExists = data.rootDocTypeExists
|
||||
const matches = Array.isArray(data.matches) ? data.matches : []
|
||||
const capabilities: PeppolRecipientCapability[] = []
|
||||
for (const match of matches) {
|
||||
const docTypes = asRecord(match)?.docTypes
|
||||
if (!Array.isArray(docTypes)) continue
|
||||
for (const docType of docTypes) {
|
||||
const value = asString(asRecord(docType)?.value)
|
||||
if (value) capabilities.push(capabilityFromDocType(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
return { reachable: false, participant, reasonCode: 'participant_not_registered', checkedAt }
|
||||
}
|
||||
if (rootDocTypeExists === false) {
|
||||
return { reachable: false, participant, reasonCode: 'document_type_not_supported', checkedAt }
|
||||
}
|
||||
return { reachable: true, participant, capabilities, checkedAt }
|
||||
}
|
||||
|
||||
async function recoverDuplicateSubmission(
|
||||
submission: PeppolSubmission,
|
||||
duplicate: QvaliaApiError,
|
||||
): Promise<PeppolSubmissionReceipt> {
|
||||
const documentId = extractUblDocumentId(submission.document)
|
||||
if (!documentId) throw duplicate
|
||||
|
||||
const url = `${transactionBase}/invoices/outgoing?documentId=${encodeURIComponent(documentId)}&includeRead=true&limit=10`
|
||||
const response = await request('GET', url)
|
||||
const { json } = await readBody(response)
|
||||
if (!response.ok) throw duplicate
|
||||
|
||||
const data = asRecord(json)?.data ?? json
|
||||
const messages = Array.isArray(data) ? data : data ? [data] : []
|
||||
for (const message of messages) {
|
||||
const integrationId = extractIntegrationId(message)
|
||||
const supplier = extractUblJsonSupplierEndpoint(message)
|
||||
if (!integrationId || !supplier) continue
|
||||
if (!participantsEqual(supplier, submission.sender)) continue
|
||||
return {
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerSubmissionId: integrationId,
|
||||
idempotencyKey: submission.idempotencyKey,
|
||||
tenantReference: submission.tenantReference,
|
||||
acceptedAt: now().toISOString(),
|
||||
}
|
||||
}
|
||||
throw duplicate
|
||||
}
|
||||
|
||||
async function submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt> {
|
||||
if (submission.contentType !== 'application/xml') {
|
||||
throw new QvaliaApiError('rejected', 'Qvalia adapter only submits UBL XML', {
|
||||
detail: `unsupported content type ${submission.contentType}`,
|
||||
})
|
||||
}
|
||||
const response = await request('POST', `${transactionBase}/invoices/outgoing`, {
|
||||
headers: { 'content-type': 'application/xml' },
|
||||
body: submission.document,
|
||||
})
|
||||
const { text, json } = await readBody(response)
|
||||
|
||||
if (!response.ok) {
|
||||
const failure = classifyHttpFailure(response.status, json, text)
|
||||
if (failure.kind === 'duplicate') return recoverDuplicateSubmission(submission, failure)
|
||||
throw failure
|
||||
}
|
||||
|
||||
const data = asRecord(asRecord(json)?.data)
|
||||
const integrationId = asString(data?.integrationId)
|
||||
?? asString(response.headers.get('integrationid'))
|
||||
if (!integrationId) {
|
||||
throw new QvaliaApiError('protocol', 'Qvalia accepted the document without an integrationId', {
|
||||
httpStatus: response.status,
|
||||
detail: text.trim().slice(0, 500) || null,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerSubmissionId: integrationId,
|
||||
idempotencyKey: submission.idempotencyKey,
|
||||
tenantReference: submission.tenantReference,
|
||||
acceptedAt: now().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function webhookAuthorized(headers: Headers): boolean {
|
||||
if (!config.webhookSecret) return false
|
||||
const presented = headers.get(config.webhookHeader)
|
||||
if (!presented) return false
|
||||
const a = Buffer.from(presented)
|
||||
const b = Buffer.from(config.webhookSecret)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
async function verifyWebhook(webhook: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]> {
|
||||
if (!webhookAuthorized(webhook.headers)) {
|
||||
throw new QvaliaApiError('auth', 'Qvalia webhook secret missing or mismatched')
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(webhook.rawBody).toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new QvaliaApiError('protocol', 'Qvalia webhook body is not JSON', { cause: error })
|
||||
}
|
||||
const payloads = Array.isArray(parsed) ? parsed : [parsed]
|
||||
const eventSha256 = sha256Hex(webhook.rawBody)
|
||||
const events: PeppolVerifiedEvent[] = []
|
||||
|
||||
for (const [index, candidate] of payloads.entries()) {
|
||||
const payload = asRecord(candidate) as QvaliaWebhookPayload | null
|
||||
if (!payload || typeof payload.eventType !== 'string') continue
|
||||
// Inbound documents are a separate flow; this boundary is outbound-only.
|
||||
if (payload.direction && payload.direction !== 'outgoing') continue
|
||||
const normalized = normalizeQvaliaWebhook(payload)
|
||||
if (!normalized) continue
|
||||
|
||||
const integrationId = asString(payload.integrationId) ?? asString(payload.globalTransactionId)
|
||||
const transactionId = asString(payload.globalTransactionId) ?? integrationId ?? 'unknown'
|
||||
const statusKey = payload.status?.status ?? payload.status?.event ?? normalized.eventCode
|
||||
const occurredAt = asString(payload.status?.updatedAt) ?? asString(payload.occurredAt) ?? now().toISOString()
|
||||
|
||||
events.push({
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerTenantId: asString(payload.accountRegNo) ?? config.accountRegNo,
|
||||
providerSubmissionId: integrationId,
|
||||
providerEventId: `${payload.eventType}:${transactionId}:${statusKey}`,
|
||||
idempotencyKey: null,
|
||||
eventCode: normalized.eventCode,
|
||||
normalizedStatus: normalized.normalizedStatus,
|
||||
isTerminal: normalized.isTerminal,
|
||||
detail: normalized.detail,
|
||||
occurredAt,
|
||||
rawPayload: payload as unknown as Record<string, unknown>,
|
||||
eventSha256: payloads.length === 1 ? eventSha256 : sha256Hex(`${eventSha256}:${index}`),
|
||||
verificationMethod: 'shared_secret_header',
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
async function retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]> {
|
||||
const retrievedAt = now().toISOString()
|
||||
const query = `integrationId=${encodeURIComponent(providerSubmissionId)}&includeRead=true&limit=1`
|
||||
|
||||
const statusResponse = await request('GET', `${transactionBase}/invoices/outgoing/status?${query}`)
|
||||
const statusBody = await readBody(statusResponse)
|
||||
if (!statusResponse.ok && statusResponse.status !== 204) {
|
||||
throw classifyHttpFailure(statusResponse.status, statusBody.json, statusBody.text)
|
||||
}
|
||||
|
||||
const documentResponse = await request('GET', `${transactionBase}/invoices/outgoing?${query}`, {
|
||||
headers: { accept: 'application/xml' },
|
||||
})
|
||||
const documentText = documentResponse.ok ? await documentResponse.text() : ''
|
||||
const exactDocument = documentText.trim().startsWith('<') ? documentText : null
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
integrationId: providerSubmissionId,
|
||||
status: statusBody.json ?? null,
|
||||
document_http_status: documentResponse.status,
|
||||
note: 'Provider-held copy of the submitted document and its latest message-log status.',
|
||||
}
|
||||
const exactDocumentSha256 = exactDocument ? sha256Hex(exactDocument) : null
|
||||
|
||||
return [{
|
||||
provider: QVALIA_PROVIDER,
|
||||
evidenceType: 'qvalia_message_record',
|
||||
payload,
|
||||
exactDocument,
|
||||
exactDocumentSha256,
|
||||
evidenceSha256: sha256Hex(JSON.stringify({ payload, exactDocumentSha256 })),
|
||||
retrievedAt,
|
||||
}]
|
||||
}
|
||||
|
||||
return {
|
||||
provider: QVALIA_PROVIDER,
|
||||
lookupRecipient,
|
||||
submit,
|
||||
verifyWebhook,
|
||||
retrieveEvidence,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user