Files
accounted/app/api/webhooks/peppol/qvalia/route.ts
T
05c3c6ebd9 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>
2026-08-21 12:45:11 +02:00

136 lines
4.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import {
persistPeppolEvidence,
persistVerifiedPeppolEvent,
} from '@/lib/invoices/peppol-delivery'
import { isPeppolTransportError, type PeppolTransport } from '@/lib/invoices/peppol-transport'
import {
QVALIA_PROVIDER,
createQvaliaTransport,
readQvaliaConfigFromEnv,
} from '@/lib/invoices/transports/qvalia'
import { createLogger } from '@/lib/logger'
import { createServiceClient } from '@/lib/supabase/server'
ensureInitialized()
const log = createLogger('peppol.qvalia.webhook')
/** Statuses worth fetching the provider's message record for. */
const EVIDENCE_STATUSES = new Set([
'transport_succeeded',
'recipient_acknowledged',
'business_accepted',
'business_rejected',
'failed',
])
/**
* POST /api/webhooks/peppol/qvalia
*
* Unauthenticated by design: Qvalia does not sign webhooks, so authenticity
* comes from the shared secret Accounted configured as Qvalia's outbound auth
* header (`QVALIA_WEBHOOK_SECRET`), checked constant-time in the adapter. The
* raw body is hashed before parsing so every verified event keeps an exact
* fingerprint.
*
* Delivery is at-least-once; the append-only event table dedupes on the
* provider event id, so replays are harmless. Unknown submissions answer 200:
* they are logged, and a retry would not make them known.
*/
export async function POST(request: Request) {
const config = readQvaliaConfigFromEnv()
if (!config || !config.webhookSecret) {
return NextResponse.json({ error: 'webhook_not_configured' }, { status: 503 })
}
const transport: PeppolTransport = createQvaliaTransport(config)
const rawBody = new Uint8Array(await request.arrayBuffer())
let events
try {
events = await transport.verifyWebhook({ headers: request.headers, rawBody })
} catch (err) {
if (isPeppolTransportError(err) && /secret/i.test(err.message)) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
log.warn('Qvalia webhook rejected', { reason: err instanceof Error ? err.message : String(err) })
return NextResponse.json({ error: 'invalid_payload' }, { status: 400 })
}
const service = createServiceClient()
let recorded = 0
let unmatched = 0
let failed = 0
for (const event of events) {
if (!event.providerSubmissionId) {
unmatched += 1
continue
}
const { data: delivery, error } = await service
.from('peppol_deliveries')
.select('company_id, idempotency_key')
.eq('provider', QVALIA_PROVIDER)
.eq('provider_submission_id', event.providerSubmissionId)
.maybeSingle()
if (error) {
failed += 1
log.error('Qvalia webhook delivery lookup failed', error, {
providerSubmissionId: event.providerSubmissionId,
})
continue
}
if (!delivery) {
unmatched += 1
log.warn('Qvalia webhook for unknown submission', {
providerSubmissionId: event.providerSubmissionId,
eventCode: event.eventCode,
})
continue
}
try {
await persistVerifiedPeppolEvent({
supabase: service,
companyId: delivery.company_id as string,
event: { ...event, idempotencyKey: delivery.idempotency_key as string },
})
recorded += 1
} catch (err) {
failed += 1
log.error('Qvalia webhook event persistence failed', err as Error, {
providerSubmissionId: event.providerSubmissionId,
eventCode: event.eventCode,
})
continue
}
if (EVIDENCE_STATUSES.has(event.normalizedStatus)) {
try {
const evidence = await transport.retrieveEvidence(event.providerSubmissionId)
for (const item of evidence) {
await persistPeppolEvidence({
supabase: service,
companyId: delivery.company_id as string,
idempotencyKey: delivery.idempotency_key as string,
evidence: item,
})
}
} catch (err) {
// Evidence is best-effort: the verified event is already on record.
log.warn('Qvalia evidence retrieval failed', {
providerSubmissionId: event.providerSubmissionId,
reason: err instanceof Error ? err.message : String(err),
})
}
}
}
// A persistence failure is ours, not Qvalia's: answer 500 so they retry.
if (failed > 0 && recorded === 0) {
return NextResponse.json({ received: true, recorded, unmatched, failed }, { status: 500 })
}
return NextResponse.json({ received: true, recorded, unmatched, failed })
}