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:
Jakob Wennberg
2026-08-21 12:45:11 +02:00
committed by GitHub
parent 8249fcab5e
commit 05c3c6ebd9
24 changed files with 2896 additions and 113 deletions
+189
View File
@@ -0,0 +1,189 @@
/**
* Qvalia sandbox probe: the first live contact with the Access Point.
*
* Reads QVALIA_* from the environment (npx dotenv -e .env.local, or export)
* and runs one of:
*
* npx tsx scripts/peppol/qvalia-probe.ts auth
* Exchanges the API key for a JWT (GET /token/{partnerRegNo}) with both
* header schemes, so we learn which one this key accepts.
* npx tsx scripts/peppol/qvalia-probe.ts lookup 0007:5567321707
* Recipient lookup through the adapter (Qvalia's own id by default).
* npx tsx scripts/peppol/qvalia-probe.ts accounts
* Lists child accounts under the partner (tells us consolidated vs
* multi-tenant as Qvalia set it up).
* npx tsx scripts/peppol/qvalia-probe.ts peppol-ids
* Lists the Peppol identifiers registered on the partner account.
* npx tsx scripts/peppol/qvalia-probe.ts outgoing
* Lists the three latest outgoing invoice statuses (read-only).
* npx tsx scripts/peppol/qvalia-probe.ts send path/to/invoice.xml
* Submits a BIS Billing 3 XML through the adapter. Sandbox only: the
* script refuses a production base URL.
* npx tsx scripts/peppol/qvalia-probe.ts webhook
* Shows the partner webhook subscription (URL, event types, auth type).
* npx tsx scripts/peppol/qvalia-probe.ts webhook-configure https://host/api/webhooks/peppol/qvalia
* Creates or updates the partner webhook subscription for all three
* event types and attaches QVALIA_WEBHOOK_SECRET as the api_key header
* (QVALIA_WEBHOOK_HEADER) that our route verifies. Requires the secret
* in the environment; prints the webhook id.
*
* Nothing here touches Accounted's database. The API key is never printed.
*/
import { readFileSync } from 'node:fs'
import {
QVALIA_PRODUCTION_BASE_URL,
createQvaliaTransport,
readQvaliaConfigFromEnv,
} from '@/lib/invoices/transports/qvalia'
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'
const [, , command = 'auth', argument] = process.argv
const config = readQvaliaConfigFromEnv()
if (!config) {
console.error('Set QVALIA_API_KEY, QVALIA_PARTNER_REG_NO and QVALIA_BASE_URL first (see .env.example).')
process.exit(2)
}
const partner = encodeURIComponent(config.partnerRegNo)
const account = encodeURIComponent(config.accountRegNo)
function headerFor(scheme: 'apikey' | 'raw'): string {
return scheme === 'raw' ? config!.apiKey : `ApiKey ${config!.apiKey}`
}
async function show(label: string, response: Response): Promise<unknown> {
const text = await response.text()
let body: unknown = text
try { body = JSON.parse(text) } catch { /* keep text */ }
console.log(`\n== ${label}: HTTP ${response.status}`)
const integrationId = response.headers.get('integrationid')
if (integrationId) console.log(`integrationid header: ${integrationId}`)
console.log(typeof body === 'string' ? body.slice(0, 2000) : JSON.stringify(body, null, 2).slice(0, 4000))
return body
}
async function rawGet(path: string, scheme: 'apikey' | 'raw' = config!.authScheme): Promise<Response> {
return fetch(`${config!.baseUrl}${path}`, {
headers: { Authorization: headerFor(scheme), accept: 'application/json' },
})
}
async function rawSend(method: 'PUT' | 'POST', path: string, body: unknown): Promise<Response> {
return fetch(`${config!.baseUrl}${path}`, {
method,
headers: {
Authorization: headerFor(config!.authScheme),
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
}
async function main(): Promise<void> {
console.log(`Qvalia probe against ${config!.baseUrl} as partner ${config!.partnerRegNo} (account ${config!.accountRegNo})`)
switch (command) {
case 'auth': {
for (const scheme of ['apikey', 'raw'] as const) {
await show(`GET /token/{partnerRegNo} with Authorization scheme "${scheme}"`, await rawGet(`/token/${partner}`, scheme))
}
return
}
case 'accounts': {
await show('GET /partner/{p}/account', await rawGet(`/partner/${partner}/account?limit=25`))
return
}
case 'peppol-ids': {
await show('GET /partner/{p}/account/{a}/peppol', await rawGet(`/partner/${partner}/account/${account}/peppol`))
return
}
case 'outgoing': {
await show(
'GET /partner/{p}/transaction/{a}/invoices/outgoing/status',
await rawGet(`/partner/${partner}/transaction/${account}/invoices/outgoing/status?includeRead=true&limit=3`),
)
return
}
case 'webhook': {
await show('GET /partner/{p}/webhook/configure', await rawGet(`/partner/${partner}/webhook/configure`))
return
}
case 'webhook-configure': {
if (!argument || !/^https:\/\//.test(argument)) {
throw new Error('webhook-configure expects an https URL, e.g. https://app.accounted.se/api/webhooks/peppol/qvalia')
}
if (!config!.webhookSecret) throw new Error('Set QVALIA_WEBHOOK_SECRET first (openssl rand -hex 32)')
const configured = await show(
'PUT /partner/{p}/webhook/configure',
await rawSend('PUT', `/partner/${partner}/webhook/configure`, {
url: argument,
types: ['new_document', 'document_delivery', 'document_error'],
}),
)
const webhookId = configured && typeof configured === 'object' && 'id' in configured
? String((configured as { id: unknown }).id)
: null
if (!webhookId) throw new Error('Qvalia did not return a webhook id')
await show(
'POST /partner/{p}/webhook/{id}/auth (api_key header)',
await rawSend('POST', `/partner/${partner}/webhook/${encodeURIComponent(webhookId)}/auth`, {
type: 'api_key',
header: config!.webhookHeader,
value: config!.webhookSecret,
}),
)
return
}
case 'lookup': {
const peppolId = argument ?? '0007:5567321707'
const [scheme, identifier] = peppolId.split(':')
if (!scheme || !identifier) throw new Error('lookup expects scheme:identifier, e.g. 0007:5567321707')
const transport = createQvaliaTransport(config!)
console.log(JSON.stringify(await transport.lookupRecipient({ scheme, identifier }), null, 2))
return
}
case 'send': {
if (config!.baseUrl === QVALIA_PRODUCTION_BASE_URL) {
throw new Error('Refusing to send through the probe against production. Use the product flow.')
}
if (!argument) throw new Error('send expects a path to a BIS Billing 3 XML file')
const xml = readFileSync(argument, 'utf8')
const sender = /AccountingSupplierParty[\s\S]*?<cbc:EndpointID schemeID="(\d{4})">([^<]+)</.exec(xml)
const recipient = /AccountingCustomerParty[\s\S]*?<cbc:EndpointID schemeID="(\d{4})">([^<]+)</.exec(xml)
if (!sender || !recipient) throw new Error('Could not read EndpointID for seller and buyer from the XML')
const transport = createQvaliaTransport(config!)
const receipt = await transport.submit({
idempotencyKey: crypto.randomUUID(),
tenantReference: 'probe',
sender: { scheme: sender[1], identifier: sender[2] },
recipient: { scheme: recipient[1], identifier: recipient[2] },
documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
processId: PEPPOL_BIS_BILLING_PROFILE_ID,
filename: argument.split('/').pop() ?? 'invoice.xml',
contentType: 'application/xml',
document: xml,
documentSha256: sha256Hex(xml),
})
console.log(JSON.stringify(receipt, null, 2))
console.log('\nEvidence after submit:')
console.log(JSON.stringify(await transport.retrieveEvidence(receipt.providerSubmissionId), null, 2).slice(0, 4000))
return
}
default:
throw new Error(`Unknown command "${command}". See the header comment for the list.`)
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? `${error.name}: ${error.message}` : String(error))
if (error && typeof error === 'object' && 'detail' in error) {
console.error('detail:', (error as { detail?: unknown }).detail)
}
process.exit(1)
})