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
@@ -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 }> }>(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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