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
co-authored by Claude Fable 5 Jakob Wennberg
parent 8249fcab5e
commit 05c3c6ebd9
24 changed files with 2896 additions and 113 deletions
@@ -14,6 +14,10 @@ import type { InvoiceItem } from '@/types'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
@@ -9,6 +9,10 @@ import {
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
@@ -3,9 +3,12 @@ import { z } from 'zod'
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 { listPeppolDeliverySummaries } from '@/lib/invoices/peppol-delivery'
import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport'
ensureInitialized()
const paramsSchema = z.object({ id: z.uuid() })
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
+6 -98
View File
@@ -1,111 +1,19 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
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 { generatePeppolBisBillingInvoice } from '@/lib/invoices/peppol-bis-billing'
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'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
// 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() })
type GeneratedPeppolInvoice = Extract<
ReturnType<typeof generatePeppolBisBillingInvoice>,
{ ok: true }
>
type LoadPeppolDocumentResult =
| { ok: true; document: GeneratedPeppolInvoice }
| { ok: false; response: NextResponse }
async function loadPeppolDocument(args: {
supabase: SupabaseClient
companyId: string
invoiceId: string
log: Parameters<typeof errorResponseFromCode>[1]
requestId: string
}): Promise<LoadPeppolDocumentResult> {
const { data: invoice, error: invoiceError } = await args.supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', args.invoiceId)
.eq('company_id', args.companyId)
.single()
if (invoiceError || !invoice) {
return {
ok: false,
response: privateNoStore(errorResponseFromCode(
'INVOICE_NOT_FOUND',
args.log,
{ requestId: args.requestId },
)),
}
}
const { data: company, error: companyError } = await args.supabase
.from('company_settings')
.select('*')
.eq('company_id', args.companyId)
.single()
if (companyError || !company) {
return {
ok: false,
response: privateNoStore(errorResponseFromCode(
'INVOICE_SEND_COMPANY_SETTINGS_MISSING',
args.log,
{ requestId: args.requestId },
)),
}
}
const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] }
if (!typedInvoice.customer) {
return {
ok: false,
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
requestId: args.requestId,
messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.',
messageEn: 'The invoice has no customer available for Peppol export.',
details: { field: 'invoice.customer' },
})),
}
}
const document = generatePeppolBisBillingInvoice({
invoice: typedInvoice,
customer: typedInvoice.customer,
items: typedInvoice.items ?? [],
company: company as CompanySettings,
})
if (!document.ok) {
const first = document.issues[0]
return {
ok: false,
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
requestId: args.requestId,
messageSv: first?.messageSv,
messageEn: first?.messageEn,
details: {
issues: document.issues.map((item) => ({
code: item.code,
field: item.field,
message_sv: item.messageSv,
message_en: item.messageEn,
})),
},
})),
}
}
return { ok: true, document }
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol',
@@ -0,0 +1,427 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeCompanySettings,
makeCustomer,
makeInvoice,
} from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
import {
PeppolTransportError,
registerPeppolTransport,
type PeppolTransport,
} from '@/lib/invoices/peppol-transport'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
const serviceRpcMock = vi.fn()
const issueAndBookMock = vi.fn()
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({ rpc: (...args: unknown[]) => serviceRpcMock(...args) }),
}))
vi.mock('@/lib/invoices/issue-and-book-invoice', () => ({
issueAndBookInvoice: (...args: unknown[]) => issueAndBookMock(...args),
}))
import { POST } from '../route'
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
const IDEMPOTENCY_KEY = '33333333-3333-4333-8333-333333333333'
const user = { id: 'user-1', email: 'owner@example.test' }
const customer = makeCustomer({
name: 'Kund AB',
org_number: '556677-8899',
vat_number: 'SE556677889901',
})
const company = makeCompanySettings({
company_name: 'Säljare AB',
entity_type: 'aktiebolag',
org_number: '556016-0680',
vat_number: 'SE556016068001',
bankgiro: '991-2346',
})
const item: InvoiceItem = {
id: 'item-1',
invoice_id: INVOICE_ID,
sort_order: 0,
line_type: 'product',
description: 'Rådgivning',
quantity: 1,
unit: 'tim',
unit_price: 100,
line_total: 100,
vat_rate: 25,
vat_amount: 25,
created_at: '2026-08-13T00:00:00.000Z',
}
function invoiceRow(overrides: Partial<ReturnType<typeof makeInvoice>> = {}) {
return makeInvoice({
id: INVOICE_ID,
invoice_number: 'F-2026-42',
invoice_date: '2026-08-13',
due_date: '2026-09-12',
status: 'sent',
subtotal: 100,
vat_amount: 25,
total: 125,
remaining_amount: 125,
vat_treatment: 'standard_25',
your_reference: 'KST-100',
customer,
items: [item],
...overrides,
})
}
const stagedDelivery = {
id: '22222222-2222-4222-8222-222222222222',
invoice_id: INVOICE_ID,
idempotency_key: IDEMPOTENCY_KEY,
recipient_scheme: '0007',
recipient_identifier: '5566778899',
xml_sha256: 'a'.repeat(64),
provider: null,
provider_submission_id: null,
status: 'staged',
status_at: '2026-08-21T10:00:00.000Z',
status_detail: null,
submitted_at: null,
terminal_at: null,
evidence_retrieved_at: null,
filename: 'peppol-invoice-F-2026-42.xml',
created_at: '2026-08-21T10:00:00.000Z',
}
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
return {
provider: 'qvalia',
lookupRecipient: vi.fn().mockResolvedValue({
reachable: true,
participant: { scheme: '0007', identifier: '5566778899' },
capabilities: [],
checkedAt: '2026-08-21T10:00:01.000Z',
}),
submit: vi.fn().mockResolvedValue({
provider: 'qvalia',
providerSubmissionId: 'int-1',
idempotencyKey: IDEMPOTENCY_KEY,
tenantReference: 'company-1',
acceptedAt: '2026-08-21T10:00:02.000Z',
}),
verifyWebhook: vi.fn().mockResolvedValue([]),
retrieveEvidence: vi.fn().mockResolvedValue([]),
...overrides,
}
}
/** The service-role RPC echoes the event's status back as the projection. */
function serviceRpcEcho() {
serviceRpcMock.mockImplementation(async (_fn: string, args: Record<string, unknown>) => ({
data: {
...stagedDelivery,
provider: args.p_provider,
provider_submission_id: args.p_provider_submission_id ?? null,
status: args.p_normalized_status,
status_at: args.p_occurred_at,
status_detail: args.p_detail ?? null,
terminal_at: args.p_is_terminal ? args.p_occurred_at : null,
},
error: null,
}))
}
describe('POST /api/invoices/[id]/peppol/send', () => {
let unregister: (() => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
reset()
serviceRpcEcho()
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
process.env.QVALIA_PARTNER_REG_NO = 'SE5560000000'
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
issueAndBookMock.mockResolvedValue({ ok: true, journalEntryId: 'je-1', partialFailures: [] })
})
afterEach(() => {
unregister?.()
unregister = null
delete process.env.PEPPOL_TRANSPORT_PROVIDER
delete process.env.QVALIA_PARTNER_REG_NO
})
function send() {
return POST(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/send`, { method: 'POST' }),
createMockRouteParams({ id: INVOICE_ID }),
)
}
it('returns 401 when the caller is not authenticated', async () => {
unregister = registerPeppolTransport(makeTransport())
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await send()
expect(response.status).toBe(401)
})
it('returns 400 for an invalid invoice id', async () => {
unregister = registerPeppolTransport(makeTransport())
const response = await POST(
createMockRequest('/api/invoices/nope/peppol/send', { method: 'POST' }),
createMockRouteParams({ id: 'nope' }),
)
expect(response.status).toBe(400)
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
})
it('refuses truthfully when no access point is switched on', async () => {
delete process.env.PEPPOL_TRANSPORT_PROVIDER
const response = await send()
expect(response.status).toBe(503)
const body = await response.json()
expect(body.error.code).toBe('PEPPOL_TRANSPORT_UNAVAILABLE')
expect(body.error.details.reason).toBe('provider_selection_required')
})
it('returns 404 when the invoice is not in the active company', async () => {
unregister = registerPeppolTransport(makeTransport())
enqueue({ data: null, error: { message: 'not found' } })
const response = await send()
expect(response.status).toBe(404)
expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND')
})
it('rejects cancelled and proforma invoices with a state conflict', async () => {
unregister = registerPeppolTransport(makeTransport())
enqueue({ data: invoiceRow({ status: 'cancelled' }), error: null })
enqueue({ data: company, error: null })
const response = await send()
expect(response.status).toBe(409)
expect((await response.json()).error.code).toBe('PEPPOL_SEND_INVALID_STATUS')
})
it('stops before the network when the recipient has no Peppol registration', async () => {
const transport = makeTransport({
lookupRecipient: vi.fn().mockResolvedValue({
reachable: false,
participant: { scheme: '0007', identifier: '5566778899' },
reasonCode: 'participant_not_registered',
checkedAt: '2026-08-21T10:00:01.000Z',
}),
})
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow(), error: null })
enqueue({ data: company, error: null })
enqueue({ data: stagedDelivery, error: null })
const response = await send()
expect(response.status).toBe(422)
const body = await response.json()
expect(body.error.code).toBe('PEPPOL_RECIPIENT_NOT_REACHABLE')
expect(body.error.details).toMatchObject({ identifier: '5566778899', reason: 'participant_not_registered' })
expect(transport.submit).not.toHaveBeenCalled()
expect(serviceRpcMock).not.toHaveBeenCalled()
})
it('looks up, submits the staged XML and records the lifecycle for an already issued invoice', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow(), error: null })
enqueue({ data: company, error: null })
enqueue({ data: stagedDelivery, error: null })
const response = await send()
const body = await response.json()
expect(response.status).toBe(201)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(body.data).toMatchObject({
network_submitted: true,
already_submitted: false,
recipient: { scheme: '0007', identifier: '5566778899' },
invoice_status: 'sent',
issuance: null,
delivery: { status: 'submission_accepted', provider: 'qvalia', provider_submission_id: 'int-1' },
})
expect(transport.lookupRecipient).toHaveBeenCalledWith({ scheme: '0007', identifier: '5566778899' })
const submission = (transport.submit as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(submission).toMatchObject({
idempotencyKey: IDEMPOTENCY_KEY,
tenantReference: 'company-1',
sender: { scheme: '0007', identifier: '5560160680' },
recipient: { scheme: '0007', identifier: '5566778899' },
contentType: 'application/xml',
filename: 'peppol-invoice-F-2026-42.xml',
})
expect(submission.document).toContain('<cbc:ID>F-2026-42</cbc:ID>')
const statuses = serviceRpcMock.mock.calls.map((call) => (call[1] as Record<string, unknown>).p_normalized_status)
expect(statuses).toEqual(['recipient_verified', 'submitting', 'submission_accepted'])
for (const call of serviceRpcMock.mock.calls) {
expect(call[0]).toBe('record_peppol_delivery_event')
expect((call[1] as Record<string, unknown>).p_provider_tenant_id).toBe('SE5560000000')
}
expect(issueAndBookMock).not.toHaveBeenCalled()
})
it('issues and books a draft only after the network accepted it', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow({ status: 'draft' }), error: null })
enqueue({ data: company, error: null })
enqueue({ data: stagedDelivery, error: null })
const response = await send()
const body = await response.json()
expect(response.status).toBe(201)
expect(body.data).toMatchObject({
invoice_status: 'sent',
journal_entry_id: 'je-1',
issuance: { ok: true, partial_failures: [] },
})
expect(issueAndBookMock).toHaveBeenCalledTimes(1)
const submitOrder = (transport.submit as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]
const issueOrder = issueAndBookMock.mock.invocationCallOrder[0]
expect(submitOrder).toBeLessThan(issueOrder)
})
it('reports a failed issuance without pretending the network send did not happen', async () => {
unregister = registerPeppolTransport(makeTransport())
issueAndBookMock.mockResolvedValue({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' })
enqueue({ data: invoiceRow({ status: 'draft' }), error: null })
enqueue({ data: company, error: null })
enqueue({ data: stagedDelivery, error: null })
const response = await send()
const body = await response.json()
expect(response.status).toBe(201)
expect(body.data).toMatchObject({
network_submitted: true,
invoice_status: 'draft',
issuance: { ok: false, error_code: 'INVOICE_MARK_SENT_RACE' },
})
})
it('replays idempotently when the exact XML was already handed to the network', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow(), error: null })
enqueue({ data: company, error: null })
enqueue({
data: { ...stagedDelivery, provider: 'qvalia', provider_submission_id: 'int-1', status: 'submission_accepted' },
error: null,
})
const response = await send()
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toMatchObject({ already_submitted: true, network_submitted: true })
expect(transport.lookupRecipient).not.toHaveBeenCalled()
expect(transport.submit).not.toHaveBeenCalled()
})
it('records a terminal failure and answers 422 when the access point rejects the document', async () => {
const transport = makeTransport({
submit: vi.fn().mockRejectedValue(
new PeppolTransportError('Qvalia rejected the document (422)', {
retryable: false,
detail: 'BR-CO-10 Sum of invoice line net amount',
}),
),
})
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow({ status: 'draft' }), error: null })
enqueue({ data: company, error: null })
enqueue({ data: stagedDelivery, error: null })
const response = await send()
const body = await response.json()
expect(response.status).toBe(422)
expect(body.error.code).toBe('PEPPOL_SUBMISSION_REJECTED')
expect(body.error.details.reason).toContain('BR-CO-10')
const last = serviceRpcMock.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(last).toMatchObject({
p_provider_event_code: 'submit_rejected',
p_normalized_status: 'failed',
p_is_terminal: true,
})
expect(issueAndBookMock).not.toHaveBeenCalled()
})
it('records a retryable failure and answers 502 when the access point is unreachable', async () => {
const transport = makeTransport({
submit: vi.fn().mockRejectedValue(
new PeppolTransportError('Could not reach Qvalia', { retryable: true }),
),
})
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow(), error: null })
enqueue({ data: company, error: null })
enqueue({ data: stagedDelivery, error: null })
const response = await send()
expect(response.status).toBe(502)
expect((await response.json()).error.code).toBe('PEPPOL_SUBMISSION_FAILED')
const last = serviceRpcMock.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(last).toMatchObject({
p_provider_event_code: 'submit_failed',
p_normalized_status: 'retryable_failure',
p_is_terminal: false,
})
})
it('refuses to resend an exact document the access point already rejected', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
enqueue({ data: invoiceRow(), error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
...stagedDelivery,
provider: 'qvalia',
status: 'failed',
status_detail: 'BR-CO-10',
terminal_at: '2026-08-21T09:00:00.000Z',
},
error: null,
})
const response = await send()
expect(response.status).toBe(422)
expect((await response.json()).error.code).toBe('PEPPOL_SUBMISSION_REJECTED')
expect(transport.submit).not.toHaveBeenCalled()
})
})
+365
View File
@@ -0,0 +1,365 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
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 { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { issueAndBookInvoice, type IssueAndBookResult } from '@/lib/invoices/issue-and-book-invoice'
import { hasRequiredInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import {
PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
PEPPOL_BIS_BILLING_PROFILE_ID,
} from '@/lib/invoices/peppol-bis-billing'
import {
persistVerifiedPeppolEvent,
sha256Hex,
stagePeppolDelivery,
type PeppolDeliverySummary,
} from '@/lib/invoices/peppol-delivery'
import { generatePeppolDocumentOrResponse, loadPeppolRecords } from '@/lib/invoices/peppol-document'
import {
getPeppolTransport,
getPeppolTransportAvailability,
isPeppolTransportError,
type PeppolDeliveryStatus,
type PeppolTransport,
type PeppolVerifiedEvent,
} from '@/lib/invoices/peppol-transport'
import { createServiceClient } from '@/lib/supabase/server'
import type { Invoice } from '@/types'
// Registers the configured Access Point adapter and wires the event bus that
// issueAndBookInvoice() emits on.
ensureInitialized()
const paramsSchema = z.object({ id: z.uuid() })
/** Invoice states that may still be handed to the network. */
const SENDABLE_STATUSES = new Set<Invoice['status']>(['draft', 'sent', 'overdue'])
/** Provider-source lifecycle events written by this route (service role). */
function routeEvent(args: {
provider: string
tenantId: string
idempotencyKey: string
providerSubmissionId: string | null
code: string
status: PeppolDeliveryStatus
terminal: boolean
statusDetail: string | null
occurredAt: string
payload?: Record<string, unknown>
}): PeppolVerifiedEvent {
return {
provider: args.provider,
providerTenantId: args.tenantId,
providerSubmissionId: args.providerSubmissionId,
providerEventId: null,
idempotencyKey: args.idempotencyKey,
eventCode: args.code,
normalizedStatus: args.status,
isTerminal: args.terminal,
detail: args.statusDetail,
occurredAt: args.occurredAt,
rawPayload: { source: 'invoice.peppol.send', ...(args.payload ?? {}) },
eventSha256: sha256Hex(
`${args.provider}|${args.idempotencyKey}|${args.code}|${args.status}|${args.occurredAt}|${args.statusDetail ?? ''}`,
),
verificationMethod: 'accounted_route',
}
}
function summaryPayload(delivery: PeppolDeliverySummary) {
return {
id: delivery.id,
idempotency_key: delivery.idempotency_key,
recipient_scheme: delivery.recipient_scheme,
recipient_identifier: delivery.recipient_identifier,
xml_sha256: delivery.xml_sha256,
provider: delivery.provider,
provider_submission_id: delivery.provider_submission_id,
status: delivery.status,
status_at: delivery.status_at,
status_detail: delivery.status_detail,
submitted_at: delivery.submitted_at,
terminal_at: delivery.terminal_at,
}
}
const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'no_route', 'business_rejected'])
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol.send',
async (_request, { supabase, companyId, user, 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 availability = getPeppolTransportAvailability()
const transport: PeppolTransport | null = availability.available
? getPeppolTransport(availability.provider)
: null
if (!availability.available || !transport) {
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, {
requestId,
details: { reason: availability.available ? 'provider_adapter_unavailable' : availability.reason },
}))
}
const records = await loadPeppolRecords({ supabase, companyId, invoiceId, log, requestId })
if (!records.ok) return records.response
const { invoice, company } = records
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
if (
!isRealInvoice
|| invoice.credited_invoice_id
|| invoice.is_self_billed
|| !SENDABLE_STATUSES.has(invoice.status)
) {
return privateNoStore(errorResponseFromCode('PEPPOL_SEND_INVALID_STATUS', log, {
requestId,
details: { status: invoice.status, document_type: invoice.document_type ?? 'invoice' },
}))
}
const wasDraft = invoice.status === 'draft'
// A draft is issued (numbered, marked sent, booked) after the network
// accepts it. Refuse up front what issuance would refuse afterwards, so an
// invoice never reaches the buyer and then fails to book.
if (wasDraft && !hasRequiredInvoicePaymentAccount(company, invoice)) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: invoice.currency },
}))
}
if (wasDraft && !invoice.invoice_number) {
try {
invoice.invoice_number = await ensureInvoiceNumber(supabase, companyId, invoice)
} catch (err) {
log.error('failed to assign invoice number before Peppol send', err as Error)
return privateNoStore(errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId }))
}
}
const generated = generatePeppolDocumentOrResponse({ invoice, company, log, requestId })
if (!generated.ok) return generated.response
const document = generated.document
const service = createServiceClient()
const provider = transport.provider
// Consolidated Qvalia setup: one provider account for every company. The
// adapter resolves the account; the lifecycle only needs a stable label.
const tenantId = process.env.QVALIA_ACCOUNT_REG_NO?.trim()
|| process.env.QVALIA_PARTNER_REG_NO?.trim()
|| provider
try {
let delivery: PeppolDeliverySummary = await stagePeppolDelivery({
supabase,
companyId,
invoiceId,
document,
})
if (delivery.provider_submission_id) {
// Exact XML already handed to the network: idempotent replay, never a
// second transmission.
return privateNoStore(NextResponse.json({
data: {
delivery: summaryPayload(delivery),
network_submitted: true,
already_submitted: true,
invoice_status: invoice.status,
},
}))
}
if (delivery.terminal_at && TERMINAL_FAILURE_STATUSES.has(delivery.status)) {
return privateNoStore(errorResponseFromCode('PEPPOL_SUBMISSION_REJECTED', log, {
requestId,
details: { status: delivery.status, detail: delivery.status_detail },
}))
}
const lookup = await transport.lookupRecipient(document.recipient)
if (!lookup.reachable) {
return privateNoStore(errorResponseFromCode('PEPPOL_RECIPIENT_NOT_REACHABLE', log, {
requestId,
details: {
scheme: document.recipient.scheme,
identifier: document.recipient.identifier,
reason: lookup.reasonCode,
},
}))
}
const supportsInvoice = lookup.capabilities.length === 0
|| lookup.capabilities.some((c) => c.documentTypeId === PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID)
if (!supportsInvoice) {
return privateNoStore(errorResponseFromCode('PEPPOL_RECIPIENT_NOT_REACHABLE', log, {
requestId,
details: {
scheme: document.recipient.scheme,
identifier: document.recipient.identifier,
reason: 'document_type_not_supported',
},
}))
}
delivery = await persistVerifiedPeppolEvent({
supabase: service,
companyId,
event: routeEvent({
provider,
tenantId,
idempotencyKey: delivery.idempotency_key,
providerSubmissionId: null,
code: 'recipient_lookup',
status: 'recipient_verified',
terminal: false,
statusDetail: `${lookup.participant.scheme}:${lookup.participant.identifier}`,
occurredAt: lookup.checkedAt,
payload: { capabilities: lookup.capabilities.length },
}),
})
const submittingAt = new Date().toISOString()
delivery = await persistVerifiedPeppolEvent({
supabase: service,
companyId,
event: routeEvent({
provider,
tenantId,
idempotencyKey: delivery.idempotency_key,
providerSubmissionId: null,
code: 'submit_attempt',
status: 'submitting',
terminal: false,
statusDetail: null,
occurredAt: submittingAt,
}),
})
let providerSubmissionId: string
let acceptedAt: string
try {
const receipt = await transport.submit({
idempotencyKey: delivery.idempotency_key,
tenantReference: companyId,
sender: document.sender,
recipient: document.recipient,
documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
processId: PEPPOL_BIS_BILLING_PROFILE_ID,
filename: document.filename,
contentType: 'application/xml',
document: document.xml,
documentSha256: delivery.xml_sha256,
})
providerSubmissionId = receipt.providerSubmissionId
acceptedAt = receipt.acceptedAt
} catch (err) {
const retryable = isPeppolTransportError(err) ? err.retryable : true
// The provider's own explanation (validation rule, duplicate notice) is
// what the user can act on; the adapter's error message stays in the
// event log and never reaches the response.
const providerReason = isPeppolTransportError(err) ? err.detail : null
const eventDetail = isPeppolTransportError(err)
? [err.message, providerReason].filter(Boolean).join(': ').slice(0, 500)
: (err instanceof Error ? err.message : 'unknown transport error')
log.error('Peppol submission failed', err as Error, {
invoiceId,
retryable,
})
await persistVerifiedPeppolEvent({
supabase: service,
companyId,
event: routeEvent({
provider,
tenantId,
idempotencyKey: delivery.idempotency_key,
providerSubmissionId: null,
code: retryable ? 'submit_failed' : 'submit_rejected',
status: retryable ? 'retryable_failure' : 'failed',
terminal: !retryable,
statusDetail: eventDetail,
occurredAt: new Date().toISOString(),
}),
})
return privateNoStore(errorResponseFromCode(
retryable ? 'PEPPOL_SUBMISSION_FAILED' : 'PEPPOL_SUBMISSION_REJECTED',
log,
{ requestId, details: { reason: providerReason } },
))
}
delivery = await persistVerifiedPeppolEvent({
supabase: service,
companyId,
event: routeEvent({
provider,
tenantId,
idempotencyKey: delivery.idempotency_key,
providerSubmissionId,
code: 'submit_accepted',
status: 'submission_accepted',
terminal: false,
statusDetail: null,
occurredAt: acceptedAt,
payload: { provider_submission_id: providerSubmissionId },
}),
})
// The network has the document. A draft now becomes an issued invoice
// with exactly the mark-sent semantics (number, status, verifikat under
// faktureringsmetoden, PDF archived as underlag).
let issuance: IssueAndBookResult | null = null
let invoiceStatus: Invoice['status'] = invoice.status
if (wasDraft) {
issuance = await issueAndBookInvoice({
supabase,
companyId,
userId: user.id,
invoice,
settings: company,
log,
})
if (issuance.ok) {
invoiceStatus = 'sent'
} else {
log.error('Peppol send accepted but issuance failed', {
invoiceId,
errorCode: issuance.errorCode,
})
}
}
return privateNoStore(NextResponse.json({
data: {
delivery: summaryPayload(delivery),
network_submitted: true,
already_submitted: false,
recipient: {
scheme: lookup.participant.scheme,
identifier: lookup.participant.identifier,
},
invoice_status: invoiceStatus,
journal_entry_id: issuance?.ok ? issuance.journalEntryId : null,
issuance: issuance === null
? null
: issuance.ok
? { ok: true, partial_failures: issuance.partialFailures }
: { ok: false, error_code: issuance.errorCode },
},
}, { status: 201 }))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)
@@ -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)
})
})
+135
View File
@@ -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 })
}