* 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>
91 lines
3.1 KiB
TypeScript
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 },
|
|
)
|