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
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const rpcMock = vi.fn()
|
||||
const maybeSingleMock = vi.fn()
|
||||
const fromMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => ({
|
||||
rpc: (...args: unknown[]) => rpcMock(...args),
|
||||
from: (...args: unknown[]) => fromMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const SECRET = 'shared-secret-1234567890'
|
||||
const ENV = {
|
||||
QVALIA_API_KEY: 'k',
|
||||
QVALIA_PARTNER_REG_NO: 'SE5560000000',
|
||||
QVALIA_BASE_URL: 'https://api-qa.qvalia.com',
|
||||
QVALIA_WEBHOOK_SECRET: SECRET,
|
||||
}
|
||||
|
||||
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 request(body: unknown, secret: string | null = SECRET): Request {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
if (secret) headers['X-Accounted-Webhook-Key'] = secret
|
||||
return new Request('http://localhost:3000/api/webhooks/peppol/qvalia', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function queryChain(result: { data: unknown; error: unknown }) {
|
||||
const chain = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
maybeSingle: maybeSingleMock.mockResolvedValue(result),
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
describe('POST /api/webhooks/peppol/qvalia', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(process.env, ENV)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
rpcMock.mockResolvedValue({ data: { id: 'delivery-1' }, error: null })
|
||||
fromMock.mockReturnValue(queryChain({
|
||||
data: { company_id: 'company-1', idempotency_key: '33333333-3333-4333-8333-333333333333' },
|
||||
error: null,
|
||||
}))
|
||||
// Evidence retrieval: status list, then XML copy.
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify([{ uuid: 'int-1', metadata: { status: 'processed' } }]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
fetchMock.mockResolvedValueOnce(new Response('<Invoice/>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/xml' },
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
for (const key of Object.keys(ENV)) delete process.env[key]
|
||||
})
|
||||
|
||||
it('answers 503 when the webhook secret is not configured', async () => {
|
||||
delete process.env.QVALIA_WEBHOOK_SECRET
|
||||
const response = await POST(request(delivered))
|
||||
expect(response.status).toBe(503)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 401 for a missing or wrong shared secret and records nothing', async () => {
|
||||
expect((await POST(request(delivered, null))).status).toBe(401)
|
||||
expect((await POST(request(delivered, 'wrong'))).status).toBe(401)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 400 for a body that is not JSON', async () => {
|
||||
const response = await POST(request('not json'))
|
||||
expect(response.status).toBe(400)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the delivery by integrationId, records the verified event and stores evidence', async () => {
|
||||
const response = await POST(request(delivered))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toEqual({ received: true, recorded: 1, unmatched: 0, failed: 0 })
|
||||
|
||||
expect(fromMock).toHaveBeenCalledWith('peppol_deliveries')
|
||||
const eventCall = rpcMock.mock.calls.find((call) => call[0] === 'record_peppol_delivery_event')
|
||||
expect(eventCall?.[1]).toMatchObject({
|
||||
p_company_id: 'company-1',
|
||||
p_idempotency_key: '33333333-3333-4333-8333-333333333333',
|
||||
p_provider: 'qvalia',
|
||||
p_provider_submission_id: 'int-1',
|
||||
p_provider_event_id: 'document_delivery:int-1:processed',
|
||||
p_normalized_status: 'transport_succeeded',
|
||||
p_is_terminal: false,
|
||||
p_verification_method: 'shared_secret_header',
|
||||
})
|
||||
const evidenceCall = rpcMock.mock.calls.find((call) => call[0] === 'record_peppol_delivery_evidence')
|
||||
expect(evidenceCall?.[1]).toMatchObject({
|
||||
p_company_id: 'company-1',
|
||||
p_provider: 'qvalia',
|
||||
p_evidence_type: 'qvalia_message_record',
|
||||
p_document_payload: '<Invoice/>',
|
||||
})
|
||||
})
|
||||
|
||||
it('acknowledges events for unknown submissions with 200 so Qvalia stops retrying', async () => {
|
||||
fromMock.mockReturnValue(queryChain({ data: null, error: null }))
|
||||
const response = await POST(request(delivered))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ received: true, recorded: 0, unmatched: 1, failed: 0 })
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores inbound-direction events and never touches the database for them', async () => {
|
||||
const response = await POST(request({ ...delivered, direction: 'incoming', eventType: 'new_document' }))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ received: true, recorded: 0, unmatched: 0, failed: 0 })
|
||||
expect(fromMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 500 when our own persistence fails so Qvalia retries later', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: { message: 'db down' } })
|
||||
const response = await POST(request(delivered))
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.json()).toMatchObject({ received: true, recorded: 0, failed: 1 })
|
||||
})
|
||||
|
||||
it('keeps the verified event when evidence retrieval fails', async () => {
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||
const response = await POST(request(delivered))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ received: true, recorded: 1, unmatched: 0, failed: 0 })
|
||||
expect(rpcMock.mock.calls.filter((call) => call[0] === 'record_peppol_delivery_evidence')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user