Files
accounted/app/api/invoices/[id]/peppol/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

91 lines
3.1 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { contentDisposition } from '@/lib/api/content-disposition'
import { privateNoStore } from '@/lib/api/private-no-store'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { ensureInitialized } from '@/lib/init'
import { stagePeppolDelivery } from '@/lib/invoices/peppol-delivery'
import { loadPeppolDocument } from '@/lib/invoices/peppol-document'
import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport'
// Registers the configured Access Point adapter so `transport` below reports
// the truth for this process, not just "nothing registered yet".
ensureInitialized()
const paramsSchema = z.object({ id: z.uuid() })
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const parsedParams = paramsSchema.safeParse(await params)
if (!parsedParams.success) {
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { fields: parsedParams.error.flatten().fieldErrors },
}))
}
const { id } = parsedParams.data
const loaded = await loadPeppolDocument({ supabase, companyId, invoiceId: id, log, requestId })
if (!loaded.ok) return loaded.response
const result = loaded.document
return new NextResponse(result.xml, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Disposition': contentDisposition('attachment', result.filename),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
})
},
)
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol.stage',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const parsedParams = paramsSchema.safeParse(await params)
if (!parsedParams.success) {
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { fields: parsedParams.error.flatten().fieldErrors },
}))
}
const invoiceId = parsedParams.data.id
const loaded = await loadPeppolDocument({
supabase,
companyId,
invoiceId,
log,
requestId,
})
if (!loaded.ok) return loaded.response
try {
const delivery = await stagePeppolDelivery({
supabase,
companyId,
invoiceId,
document: loaded.document,
})
return privateNoStore(NextResponse.json({
data: {
id: delivery.id,
idempotency_key: delivery.idempotency_key,
xml_sha256: delivery.xml_sha256,
status: delivery.status,
created_at: delivery.created_at,
network_submitted: false,
transport: getPeppolTransportAvailability(),
},
}, { status: 201 }))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)