From 05c3c6ebd91c809fbae2e3e2c5cfd84593461aec Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 21 Aug 2026 12:45:11 +0200 Subject: [PATCH] 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 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 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 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 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 --- .env.example | 19 + DECISIONS.md | 1 + app/(dashboard)/invoices/[id]/page.tsx | 185 ++++- .../[id]/peppol/__tests__/route.test.ts | 4 + .../peppol/deliveries/__tests__/route.test.ts | 4 + .../invoices/[id]/peppol/deliveries/route.ts | 3 + app/api/invoices/[id]/peppol/route.ts | 104 +-- .../[id]/peppol/send/__tests__/route.test.ts | 427 ++++++++++++ app/api/invoices/[id]/peppol/send/route.ts | 365 ++++++++++ .../peppol/qvalia/__tests__/route.test.ts | 164 +++++ app/api/webhooks/peppol/qvalia/route.ts | 135 ++++ docs/PEPPOL_FOUNDATION.md | 17 +- lib/errors/structured-errors.ts | 28 + lib/init.ts | 4 + .../__tests__/peppol-qvalia-transport.test.ts | 458 +++++++++++++ lib/invoices/peppol-bis-billing.ts | 3 + lib/invoices/peppol-delivery.ts | 3 + lib/invoices/peppol-document.ts | 151 +++++ lib/invoices/peppol-transport.ts | 29 +- lib/invoices/transports/index.ts | 34 + lib/invoices/transports/qvalia.ts | 632 ++++++++++++++++++ messages/en.json | 25 + messages/sv.json | 25 + scripts/peppol/qvalia-probe.ts | 189 ++++++ 24 files changed, 2896 insertions(+), 113 deletions(-) create mode 100644 app/api/invoices/[id]/peppol/send/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/peppol/send/route.ts create mode 100644 app/api/webhooks/peppol/qvalia/__tests__/route.test.ts create mode 100644 app/api/webhooks/peppol/qvalia/route.ts create mode 100644 lib/invoices/__tests__/peppol-qvalia-transport.test.ts create mode 100644 lib/invoices/peppol-document.ts create mode 100644 lib/invoices/transports/index.ts create mode 100644 lib/invoices/transports/qvalia.ts create mode 100644 scripts/peppol/qvalia-probe.ts diff --git a/.env.example b/.env.example index 81660f87..a49fe903 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,25 @@ RECEIPT_HUNT_COMPANY_IDS= # Bank connections (Enable Banking) # ENABLE_BANKING_APP_ID= # ENABLE_BANKING_PRIVATE_KEY= +# Peppol e-invoicing via Qvalia (certified Access Point + SMP, partner model). +# The adapter registers itself when QVALIA_API_KEY, QVALIA_PARTNER_REG_NO and +# QVALIA_BASE_URL are all set; PEPPOL_TRANSPORT_PROVIDER=qvalia switches it on +# for users. Sandbox https://api-test.qvalia.com, production https://api.qvalia.com, +# separate keys per environment. QVALIA_ACCOUNT_REG_NO defaults to the partner +# number (consolidated setup). QVALIA_WEBHOOK_SECRET is the value Qvalia echoes +# in the QVALIA_WEBHOOK_HEADER on every delivery event (configure it with +# POST /partner/{p}/webhook/{id}/auth, type api_key). The key is sent bare in +# the Authorization header (what the sandbox accepts); QVALIA_AUTH_SCHEME=apikey +# switches to the "ApiKey " form from the newest docs. +# PEPPOL_TRANSPORT_PROVIDER=qvalia +# QVALIA_API_KEY= +# QVALIA_PARTNER_REG_NO= +# QVALIA_ACCOUNT_REG_NO= +# QVALIA_BASE_URL=https://api-test.qvalia.com +# QVALIA_WEBHOOK_SECRET= +# QVALIA_WEBHOOK_HEADER=x-accounted-webhook-key +# QVALIA_AUTH_SCHEME=raw + # Accounting integrations # FORTNOX_CLIENT_ID= # FORTNOX_CLIENT_SECRET= diff --git a/DECISIONS.md b/DECISIONS.md index 03116393..0726082f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1138,6 +1138,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] Reverted #1765: the company switcher is NOT mounted at the top of the desktop sidebar. Founder call after seeing it live: switching belongs in the bottom user block only (the UserMenu flyout), so the sidebar top stays brand + collapse and the nav starts immediately below. #1664's "one-click from the top" framing is therefore declined, not merely unimplemented; the logo title tooltip went back with the revert since it shipped inside the same commit. Do not re-add a top-of-sidebar switcher from #1664 without a new founder decision. [2026-08-20] Fortnox voucher-attachment scopes (Arkivplats + Koppla filer) are requested per authorize call from the underlag follow-up only, never from an ordinary connect, and gated on FORTNOX_DOCUMENT_SCOPES_APPROVED in lib/providers/fortnox/oauth.ts (the portal-registration switch). Two reasons: Fortnox derives customer licence requirements from what the integration requests, so an all-connects request would put an Arkivplats licence in front of customers who never import a receipt (the portal says so in as many words); and a scope the registered app lacks makes authorize reject with invalid_scope before login, so keeping it off the default connect caps the blast radius at the underlag flow instead of every Fortnox connection (incident 2026-08-13). A document consent is always a superset of an ordinary one, because the callback overwrites the consent's tokens in place and a narrower grant would revoke the migration's own ledger access. While the flag is false the attachment 403 reports PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE with no action offered, instead of reconnect advice for a permission we never ask for: that advice sent Klura AB around the OAuth loop four times and to buy the Fortnox Arkiv module for nothing (support case 2026-08-20). Portal registration alone changes nothing observable, which is why turning the scopes on and back off that day neither caused nor fixed the error. [2026-08-21] Flipped FORTNOX_DOCUMENT_SCOPES_APPROVED to true: Arkivplats and Koppla filer are now enabled for integration 39254 in the Fortnox Developer Portal (founder confirmed). Only the opt-in underlag reconnect requests them, so the ordinary connect is unchanged and no customer is asked for an Arkivplats licence to connect. Set it back to false if the portal ever loses the scopes, since authorize then rejects with invalid_scope before login. +[2026-08-21] Qvalia is the Peppol Access Point (contract signed 2026-08-21); the adapter lives in lib/invoices/transports/qvalia.ts behind the provider-neutral PeppolTransport seam, switched on per environment by PEPPOL_TRANSPORT_PROVIDER=qvalia plus QVALIA_* credentials: Storecove was the doc's technical preference, Qvalia won on the commercial track (Swedish AP+SMP, partner model, existing relationship); v1 is consolidated-account send-only (recipient lookup, submit, shared-secret webhook, evidence), a draft is issued with mark-sent semantics only after the network accepts it, and a sync rejection freezes that exact XML as terminal failed so the identical document is never re-sent; inbound, credit notes, 0088 GLN for enskild firma and per-company registration are separate PRs. [2026-08-21] Korjournal distance suggestions via OSM (Nominatim+OSRM public endpoints) instead of Google Maps API: no key, no cost, AGPL-friendly, works self-hosted; addresses proxied server-side without identifiers and OSMF disclosed on the privacy page. [2026-08-21] Skeptic pass on #1778: routing endpoint switched from router.project-osrm.org (demo server, non-commercial only) to FOSSGIS routing.openstreetmap.de (fair use, attribution shown in UI); lookup click-gated instead of as-you-type per Nominatim's no-autocomplete policy; OSMF/FOSSGIS disclosed as independent recipients, not underbiträden. [2026-08-21] SCHABLONINTAKT_RATE_BY_CLOSING_YEAR backfilled 2020-2024 (SLR 30 Nov per Riksgalden: -0.09/-0.10/0.23 floored to 0.5 %, 1.94 %, 2.62 %) and the rate now resolves lazily (resolveSchablonintaktRate: 0 when no 212X account carried an opening balance): the table only covered 2025/2026 and the builder consulted it unconditionally, so every AB closing a pre-2025 year got a generic 500 at bokslut step 3 (126 open FY2024 periods on prod, incl. a byra trial). 2019 and earlier stay unmapped on purpose: the 100 %-of-SLR rule keys on beskattningsar STARTING 2019-01-01+ (prop. 2017/18:245), so a 2019 closing can be a brutet ar under the old 72 % factor. Unmapped-year-with-fonder now raises SCHABLONINTAKT_RATE_NOT_CONFIGURED (typed, 500 so runtime-error clustering still flags the missed December update) instead of INTERNAL_ERROR. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 4a4e99c7..82ac17e2 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -79,6 +79,23 @@ import { DialogTitle, } from '@/components/ui/dialog' import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types' + +/** Minimized Peppol delivery projection from GET /api/invoices/[id]/peppol/deliveries. */ +interface PeppolDeliveryView { + id: string + recipient_scheme: string + recipient_identifier: string + status: string + status_at: string + status_detail: string | null + provider_submission_id: string | null +} +const PEPPOL_STATUS_KEYS = new Set([ + 'staged', 'recipient_verified', 'submitting', 'retryable_failure', 'submission_accepted', + 'transport_succeeded', 'recipient_acknowledged', 'business_accepted', 'business_rejected', + 'no_route', 'failed', +]) +const PEPPOL_SENDABLE_STATUSES = new Set(['draft', 'sent', 'overdue']) import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' // Why the downloaded file is not the invoice the customer received. One key @@ -205,6 +222,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [isDownloadingConfirmation, setIsDownloadingConfirmation] = useState(false) const [showConfirmationSendDialog, setShowConfirmationSendDialog] = useState(false) const [isPreparingPeppol, setIsPreparingPeppol] = useState(false) + const [isSendingPeppol, setIsSendingPeppol] = useState(false) + const [showPeppolSendDialog, setShowPeppolSendDialog] = useState(false) + // Whether this deployment has a contracted Access Point switched on; the + // menu item stays a truthful "provider required" note otherwise. + const [peppolTransportAvailable, setPeppolTransportAvailable] = useState(false) + const [peppolDeliveries, setPeppolDeliveries] = useState([]) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [showFinalizeDialog, setShowFinalizeDialog] = useState(false) @@ -234,6 +257,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st fetchInvoice() }, [id]) + // Peppol status and transport availability for invoices that can carry an + // e-invoice; refreshed when the invoice changes state (draft -> sent). + useEffect(() => { + if (!invoice || invoice.id !== id) return + const eligible = (!invoice.document_type || invoice.document_type === 'invoice') + && !invoice.credited_invoice_id + && !invoice.is_self_billed + if (!eligible) return + void loadPeppolDeliveries() + }, [id, invoice?.id, invoice?.status, invoice?.invoice_number]) // eslint-disable-line react-hooks/exhaustive-deps + /** * Read the delivery history, keeping "read failed" distinct from "nothing * has been sent". Both used to arrive as `[]`, which is what let a network @@ -869,6 +903,66 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st } } + async function loadPeppolDeliveries() { + try { + const response = await fetch(`/api/invoices/${encodeURIComponent(id)}/peppol/deliveries`) + if (!response.ok) return + const payload = (await response.json()) as { + data?: PeppolDeliveryView[] + transport?: { available?: boolean } + } + const rows = Array.isArray(payload.data) ? [...payload.data] : [] + rows.sort((a, b) => (a.status_at < b.status_at ? 1 : a.status_at > b.status_at ? -1 : 0)) + setPeppolDeliveries(rows) + setPeppolTransportAvailable(payload.transport?.available === true) + } catch { + // Peppol status is supplementary; the page stays usable without it. + } + } + + async function sendViaPeppol() { + if (!invoice) return + setIsSendingPeppol(true) + + try { + const response = await fetch(`/api/invoices/${invoice.id}/peppol/send`, { method: 'POST' }) + const body = await response.json().catch(() => null) as { + data?: { already_submitted?: boolean; issuance?: { ok: boolean } | null } + error?: { code?: string; message?: string; message_en?: string } + } | null + if (!response.ok) { + throw body?.error ?? new Error(t('peppol_send_failed_description')) + } + + setShowPeppolSendDialog(false) + const issuanceFailed = !!body?.data?.issuance && !body.data.issuance.ok + toast({ + title: t('peppol_sent_title'), + description: body?.data?.already_submitted + ? t('peppol_already_sent_description') + : issuanceFailed + ? t('peppol_issue_failed_description') + : t('peppol_sent_description'), + ...(issuanceFailed ? { variant: 'destructive' as const } : {}), + }) + await fetchInvoice() + await loadPeppolDeliveries() + } catch (error) { + toast({ + title: t('peppol_send_failed_title'), + description: error instanceof Error + ? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' }) + : getUserErrorMessage(error, { + context: 'invoice', + locale: locale.startsWith('sv') ? 'sv' : 'en', + }), + variant: 'destructive', + }) + } finally { + setIsSendingPeppol(false) + } + } + /** * Show one specific document in the browser instead of saving it (#1190): * granskning should not require leaving the app for the Downloads folder. @@ -1236,7 +1330,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st (invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice && !creditNote - const showPeppolActions = !isSelfBilled && isRealInvoice && !isCreditNote && !!invoice.invoice_number + // Download/prepare need the F-number (the XML carries it); sending a draft + // assigns the number server-side, so the menu shows once a provider is on. + const showPeppolActions = !isSelfBilled && isRealInvoice && !isCreditNote + && (!!invoice.invoice_number || peppolTransportAvailable) + const canSendPeppol = peppolTransportAvailable && PEPPOL_SENDABLE_STATUSES.has(invoice.status) + const peppolRecipientLabel = invoice.customer?.org_number + ? `0007:${invoice.customer.org_number.replace(/\D/g, '')}` + : '0007' + const peppolStatusLabel = (status: string) => + PEPPOL_STATUS_KEYS.has(status) ? t(`peppol_status_${status}`) : status + const latestPeppolDelivery = peppolDeliveries[0] ?? null const showDestructive = invoice.status !== 'cancelled' && invoice.status !== 'credited' && @@ -1486,26 +1590,39 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {showPeppolActions && ( <> - void downloadPeppolXml()} disabled={isDownloadingPeppol}> + void downloadPeppolXml()} + disabled={isDownloadingPeppol || !invoice.invoice_number} + > {t('download_peppol_xml')} void preparePeppolDelivery()} - disabled={isPreparingPeppol || !canWrite} + disabled={isPreparingPeppol || !canWrite || !invoice.invoice_number} > {t('prepare_peppol_delivery')} - - - - {t('send_via_peppol')} - - {t('peppol_provider_required')} + {peppolTransportAvailable ? ( + setShowPeppolSendDialog(true)} + disabled={isSendingPeppol || !canWrite || !canSendPeppol} + > + + {t('send_via_peppol')} + + ) : ( + + + + {t('send_via_peppol')} + + {t('peppol_provider_required')} + - - + + )} )} {showDestructive && ( @@ -2018,6 +2135,27 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* The legacy empty state asserts "sent before delivery history existed". A failed read produces the same empty list, so that claim would be a guess: say what actually happened instead. */} + {latestPeppolDelivery && ( + + + + {latestPeppolDelivery.recipient_scheme}:{latestPeppolDelivery.recipient_identifier} + + + + {peppolStatusLabel(latestPeppolDelivery.status)} + {latestPeppolDelivery.status_detail && ( + + {latestPeppolDelivery.status_detail} + + )} + + + {formatDate(latestPeppolDelivery.status_at)} + + + )} + {isRealInvoice && !isSelfBilled && deliveriesUnreadable && (

@@ -2049,6 +2187,31 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* Remove/cancel confirmation. An unissued credit-note draft and an unnumbered invoice draft are hard deleted; other numbered drafts are retained as cancelled to preserve their number series. */} + {/* Peppol send confirmation (convention 10: confirm up front). */} +

+ + + {t('peppol_send_confirm_title')} + + {t('peppol_send_confirm_description', { recipient: peppolRecipientLabel })} + + + + + + + + + diff --git a/app/api/invoices/[id]/peppol/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/__tests__/route.test.ts index c84c940e..a2e5a02b 100644 --- a/app/api/invoices/[id]/peppol/__tests__/route.test.ts +++ b/app/api/invoices/[id]/peppol/__tests__/route.test.ts @@ -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), })) diff --git a/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts index 7dde2fff..f1e55211 100644 --- a/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts +++ b/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts @@ -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), })) diff --git a/app/api/invoices/[id]/peppol/deliveries/route.ts b/app/api/invoices/[id]/peppol/deliveries/route.ts index e416887c..0d4f5cdb 100644 --- a/app/api/invoices/[id]/peppol/deliveries/route.ts +++ b/app/api/invoices/[id]/peppol/deliveries/route.ts @@ -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 }> }>( diff --git a/app/api/invoices/[id]/peppol/route.ts b/app/api/invoices/[id]/peppol/route.ts index 7bef4c9c..2e0d3e8b 100644 --- a/app/api/invoices/[id]/peppol/route.ts +++ b/app/api/invoices/[id]/peppol/route.ts @@ -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, - { ok: true } -> -type LoadPeppolDocumentResult = - | { ok: true; document: GeneratedPeppolInvoice } - | { ok: false; response: NextResponse } - -async function loadPeppolDocument(args: { - supabase: SupabaseClient - companyId: string - invoiceId: string - log: Parameters[1] - requestId: string -}): Promise { - 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', diff --git a/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts new file mode 100644 index 00000000..49152ca9 --- /dev/null +++ b/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts @@ -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> = {}) { + 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 { + 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) => ({ + 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).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('F-2026-42') + + const statuses = serviceRpcMock.mock.calls.map((call) => (call[1] as Record).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).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).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 + 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 + 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() + }) +}) diff --git a/app/api/invoices/[id]/peppol/send/route.ts b/app/api/invoices/[id]/peppol/send/route.ts new file mode 100644 index 00000000..bafb8719 --- /dev/null +++ b/app/api/invoices/[id]/peppol/send/route.ts @@ -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(['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 +}): 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 }, +) diff --git a/app/api/webhooks/peppol/qvalia/__tests__/route.test.ts b/app/api/webhooks/peppol/qvalia/__tests__/route.test.ts new file mode 100644 index 00000000..c476dcad --- /dev/null +++ b/app/api/webhooks/peppol/qvalia/__tests__/route.test.ts @@ -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() + +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 = { '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('', { + 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: '', + }) + }) + + 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) + }) +}) diff --git a/app/api/webhooks/peppol/qvalia/route.ts b/app/api/webhooks/peppol/qvalia/route.ts new file mode 100644 index 00000000..7cb8fb50 --- /dev/null +++ b/app/api/webhooks/peppol/qvalia/route.ts @@ -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 }) +} diff --git a/docs/PEPPOL_FOUNDATION.md b/docs/PEPPOL_FOUNDATION.md index 3e688ba8..e8e6fc6a 100644 --- a/docs/PEPPOL_FOUNDATION.md +++ b/docs/PEPPOL_FOUNDATION.md @@ -80,11 +80,22 @@ Inbound invoices are a separate acceptance slice. It requires provider webhook a The UI downloads a locally checked XML file and can prepare an immutable delivery snapshot. Both actions state that they did not send the invoice. Once an adapter exists, sending must be a distinct confirmation flow that performs recipient lookup, shows the discovered participant and capabilities, and records the resulting timeline. The download remains available for diagnosis and interoperability testing. -### Credentials and commercial decision +### Access point: Qvalia (decided 2026-08-21) -Emil must choose and contract a certified access-point provider before full send or receive can be completed. +The contract with Qvalia (certified Swedish Access Point + SMP, partner model) was signed on 2026-08-21. The adapter lives in `lib/invoices/transports/qvalia.ts` and implements the `PeppolTransport` boundary: -### Storecove versus Qvalia +- recipient lookup: `GET /partner/{partnerRegNo}/peppol/lookup/{scheme:id}?docTypeRoot=Invoice`; +- submission: `POST /partner/{partnerRegNo}/transaction/{accountRegNo}/invoices/outgoing` with the staged UBL XML (`content-type: application/xml`); the returned `integrationId` is the provider submission id; a `409` (same document id and receiver) is recovered to the existing `integrationId` only when Qvalia's stored copy carries the same seller endpoint, otherwise it stays a duplicate error; +- webhooks: `POST /api/webhooks/peppol/qvalia`, authenticated by the shared secret Accounted configures as Qvalia's outbound auth header (Qvalia does not sign webhooks); events are at-least-once and deduplicated on `eventType + globalTransactionId + status.status`; `status.status` is free text, so the mapping is tolerant and unknown wording never advances beyond `submission_accepted`; +- evidence: the message-log status and Qvalia's stored XML copy, recorded as `qvalia_message_record`. + +Configuration is environment-only (`PEPPOL_TRANSPORT_PROVIDER=qvalia` plus `QVALIA_API_KEY`, `QVALIA_PARTNER_REG_NO`, `QVALIA_BASE_URL`, `QVALIA_WEBHOOK_SECRET`, optional `QVALIA_ACCOUNT_REG_NO`, `QVALIA_WEBHOOK_HEADER`, `QVALIA_AUTH_SCHEME`; see `.env.example`). `lib/init.ts` registers the adapter when the credentials are present; the product only sends when the provider is also selected. `scripts/peppol/qvalia-probe.ts` is the first-contact probe against the sandbox (auth scheme, child accounts, registered Peppol IDs, lookup, send). + +`POST /api/invoices/{id}/peppol/send` performs the send: stage the exact XML, look up the recipient, record `recipient_verified` and `submitting`, submit, record `submission_accepted` with the provider submission id, and only then issue a draft with the mark-sent semantics (`issueAndBookInvoice`: F-number, status, verifikat under faktureringsmetoden, PDF archived as underlag). A synchronous rejection is recorded as a terminal `failed` event so the identical document is never re-sent; an operational failure is `retryable_failure` and a retry is allowed. Resending an exact XML that already carries a provider submission id is an idempotent replay, never a second transmission. + +v1 uses Qvalia's consolidated setup (every company's documents under Accounted's partner account, `accountRegNo = partnerRegNo`). Still open: per-company participant registration (`PUT /partner/{p}/account/{a}/peppol/{0007:orgnr}` with business card and Invoice + CreditNote document types), the multi-tenant child-account model if pricing favours it, credit notes, `0088` GLN for enskild firma, inbound documents, and the release-pinned validation stack. + +### Storecove versus Qvalia (historical, pre-contract) Storecove is the stronger fit for the lifecycle already modeled. Its official API documents recipient discovery, caller-supplied `idempotencyGuid`, a returned submission `guid`, tenant correlation, asynchronous sending webhooks, and a dedicated evidence endpoint. Its sandbox supports webhook simulation and the OpenPeppol test network. A Storecove adapter still requires a commercial contract and credentials; these public semantics do not prove Accounted's tenant is authorized or onboarded. diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index c33ef4e9..ac20b8fc 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1266,6 +1266,34 @@ const INVOICE: Record = { message_sv: 'Detta dokument är inte en offert.', message_en: 'This document is not a quote.', }, + // POST /api/invoices/{id}/peppol/send. The Access Point is an environment + // decision (PEPPOL_TRANSPORT_PROVIDER + adapter credentials); the product + // never pretends to send when no adapter is switched on. + PEPPOL_TRANSPORT_UNAVAILABLE: { + httpStatus: 503, + message_sv: 'Peppol-utskick är inte aktiverat i den här miljön. En avtalad Peppol-operatör måste vara konfigurerad.', + message_en: 'Peppol sending is not enabled in this environment. A contracted Peppol access point must be configured.', + }, + PEPPOL_SEND_INVALID_STATUS: { + httpStatus: 409, + message_sv: 'Bara utkast och skickade fakturor kan skickas via Peppol. Makulerade, krediterade och proformafakturor kan inte skickas.', + message_en: 'Only draft and sent invoices can be sent via Peppol. Cancelled, credited and proforma invoices cannot be sent.', + }, + PEPPOL_RECIPIENT_NOT_REACHABLE: { + httpStatus: 422, + message_sv: 'Mottagaren är inte registrerad för att ta emot e-fakturor via Peppol. Kontrollera organisationsnumret eller skicka fakturan på annat sätt.', + message_en: 'The recipient is not registered to receive e-invoices via Peppol. Check the organisation number or deliver the invoice another way.', + }, + PEPPOL_SUBMISSION_REJECTED: { + httpStatus: 422, + message_sv: 'Peppol-operatören avvisade fakturan vid valideringen. Fakturan har inte skickats.', + message_en: 'The Peppol access point rejected the invoice during validation. The invoice has not been sent.', + }, + PEPPOL_SUBMISSION_FAILED: { + httpStatus: 502, + message_sv: 'Peppol-operatören kunde inte nås just nu. Fakturan har inte skickats; försök igen om en stund.', + message_en: 'The Peppol access point could not be reached. The invoice has not been sent; try again shortly.', + }, } const SUPPLIER_INVOICE: Record = { diff --git a/lib/init.ts b/lib/init.ts index 56323332..9294a136 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -4,6 +4,7 @@ import { createExtensionContext } from '@/lib/extensions/context-factory' import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler' import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler' import { registerWebhookHandler } from '@/lib/webhooks/handler' +import { registerConfiguredPeppolTransports } from '@/lib/invoices/transports' import { registerObservabilitySink } from '@/lib/observability' import { postHogSink } from '@/lib/analytics/posthog-observability' import { isAnalyticsEnabled } from '@/lib/analytics/enabled' @@ -96,6 +97,9 @@ export function ensureInitialized(): void { registerSupplierInvoiceHandler() registerEventLogHandler() registerWebhookHandler() + // Peppol Access Point adapters are registered from the environment here so + // every route that reports transport availability sees the same answer. + registerConfiguredPeppolTransports() loadExtensions() initialized = true diff --git a/lib/invoices/__tests__/peppol-qvalia-transport.test.ts b/lib/invoices/__tests__/peppol-qvalia-transport.test.ts new file mode 100644 index 00000000..749541cc --- /dev/null +++ b/lib/invoices/__tests__/peppol-qvalia-transport.test.ts @@ -0,0 +1,458 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID, + PEPPOL_BIS_BILLING_PROFILE_ID, +} from '@/lib/invoices/peppol-bis-billing' +import { sha256Hex } from '@/lib/invoices/peppol-delivery' +import type { PeppolSubmission } from '@/lib/invoices/peppol-transport' +import { + QvaliaApiError, + createQvaliaTransport, + describeQvaliaErrorBody, + extractUblDocumentId, + extractUblJsonSupplierEndpoint, + normalizePeppolDocumentTypeId, + normalizeQvaliaWebhook, + readQvaliaConfigFromEnv, + type QvaliaConfig, +} from '@/lib/invoices/transports/qvalia' +import { registerConfiguredPeppolTransports } from '@/lib/invoices/transports' +import { getPeppolTransport } from '@/lib/invoices/peppol-transport' + +const config: QvaliaConfig = { + apiKey: 'test-key', + partnerRegNo: 'SE5560000000', + accountRegNo: 'SE5560000000', + baseUrl: 'https://api-qa.qvalia.com', + authScheme: 'apikey', + webhookSecret: 'shared-secret-1234567890', + webhookHeader: 'x-accounted-webhook-key', +} + +const XML = [ + '', + '', + ' urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0', + ' urn:fdc:peppol.eu:2017:poacc:billing:01:1.0', + ' F-2026-42', + '', +].join('\n') + +function submission(overrides: Partial = {}): PeppolSubmission { + return { + idempotencyKey: '33333333-3333-4333-8333-333333333333', + tenantReference: 'company-1', + sender: { scheme: '0007', identifier: '5560160680' }, + recipient: { scheme: '0007', identifier: '5566778899' }, + documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID, + processId: PEPPOL_BIS_BILLING_PROFILE_ID, + filename: 'peppol-invoice-F-2026-42.xml', + contentType: 'application/xml', + document: XML, + documentSha256: sha256Hex(XML), + ...overrides, + } +} + +function jsonResponse(status: number, body: unknown, headers: Record = {}): Response { + return new Response(body === null ? null : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) +} + +describe('readQvaliaConfigFromEnv', () => { + it('returns null until key, partner number and base URL are all present', () => { + expect(readQvaliaConfigFromEnv({})).toBeNull() + expect(readQvaliaConfigFromEnv({ QVALIA_API_KEY: 'k', QVALIA_PARTNER_REG_NO: 'p' })).toBeNull() + expect(readQvaliaConfigFromEnv({ + QVALIA_API_KEY: 'k', + QVALIA_PARTNER_REG_NO: 'p', + QVALIA_BASE_URL: 'http://insecure.example', + })).toBeNull() + }) + + it('defaults the account to the partner number, the bare-key auth that the sandbox accepts, and the documented header', () => { + const parsed = readQvaliaConfigFromEnv({ + QVALIA_API_KEY: ' k ', + QVALIA_PARTNER_REG_NO: 'SE1', + QVALIA_BASE_URL: 'https://api-test.qvalia.com/', + QVALIA_WEBHOOK_SECRET: 's', + }) + expect(parsed).toEqual({ + apiKey: 'k', + partnerRegNo: 'SE1', + accountRegNo: 'SE1', + baseUrl: 'https://api-test.qvalia.com', + authScheme: 'raw', + webhookSecret: 's', + webhookHeader: 'x-accounted-webhook-key', + }) + }) + + it('honours an explicit account number, the ApiKey prefix and a custom header', () => { + const parsed = readQvaliaConfigFromEnv({ + QVALIA_API_KEY: 'k', + QVALIA_PARTNER_REG_NO: 'SE1', + QVALIA_ACCOUNT_REG_NO: 'SE2', + QVALIA_BASE_URL: 'https://api.qvalia.com', + QVALIA_AUTH_SCHEME: 'apikey', + QVALIA_WEBHOOK_HEADER: 'X-Custom', + }) + expect(parsed?.accountRegNo).toBe('SE2') + expect(parsed?.authScheme).toBe('apikey') + expect(parsed?.webhookHeader).toBe('x-custom') + }) +}) + +describe('registerConfiguredPeppolTransports', () => { + it('registers nothing when Qvalia is not configured', () => { + expect(registerConfiguredPeppolTransports({})).toEqual([]) + }) + + it('registers the Qvalia adapter once when configured', () => { + const env = { + QVALIA_API_KEY: 'k', + QVALIA_PARTNER_REG_NO: 'SE1', + QVALIA_BASE_URL: 'https://api-qa.qvalia.com', + } + const first = registerConfiguredPeppolTransports(env) + expect(first.map((t) => t.provider)).toEqual(['qvalia']) + expect(getPeppolTransport('qvalia')).toBe(first[0]) + expect(registerConfiguredPeppolTransports(env)).toEqual([]) + }) +}) + +describe('Qvalia transport: lookupRecipient', () => { + const fetchMock = vi.fn() + const transport = createQvaliaTransport(config, { + fetch: fetchMock, + now: () => new Date('2026-08-21T10:00:00.000Z'), + }) + + beforeEach(() => { + fetchMock.mockReset() + }) + + it('calls the partner lookup with the ApiKey header and maps capabilities', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { + status: 'success', + data: { + exists: true, + rootDocTypeExists: true, + source: 'smp', + matches: [{ + participantID: { scheme: 'iso6523-actorid-upis', value: '0007:5566778899' }, + docTypes: [ + // Live shape: the SMP service URL wraps the document type id. + { scheme: 'busdox-docid-qns', value: `https://smp-test.qvalia.com/iso6523-actorid-upis::0007:5566778899/services/busdox-docid-qns::${PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID}` }, + { scheme: 'busdox-docid-qns', value: 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1' }, + ], + }], + }, + })) + + const result = await transport.lookupRecipient({ scheme: '0007', identifier: '5566778899' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toBe( + 'https://api-qa.qvalia.com/partner/SE5560000000/peppol/lookup/0007%3A5566778899?docTypeRoot=Invoice', + ) + expect((init?.headers as Record).Authorization).toBe('ApiKey test-key') + expect(result.reachable).toBe(true) + if (!result.reachable) throw new Error('unreachable') + expect(result.checkedAt).toBe('2026-08-21T10:00:00.000Z') + expect(result.capabilities).toHaveLength(2) + expect(result.capabilities[0]).toEqual({ + documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID, + processId: PEPPOL_BIS_BILLING_PROFILE_ID, + }) + }) + + it('reports a participant without an Invoice capability as not reachable', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { + status: 'success', + data: { exists: true, rootDocTypeExists: false, matches: [] }, + })) + const result = await transport.lookupRecipient({ scheme: '0007', identifier: '5566778899' }) + expect(result).toMatchObject({ reachable: false, reasonCode: 'document_type_not_supported' }) + }) + + it('treats 204/404 and exists=false as not registered, never as an error', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) + expect(await transport.lookupRecipient({ scheme: '0007', identifier: '1' })) + .toMatchObject({ reachable: false, reasonCode: 'participant_not_found' }) + + fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: 'success', data: { exists: false, matches: [] } })) + expect(await transport.lookupRecipient({ scheme: '0007', identifier: '1' })) + .toMatchObject({ reachable: false, reasonCode: 'participant_not_registered' }) + }) + + it('surfaces credential problems as a retryable auth error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: 'Unauthorized' })) + await expect(transport.lookupRecipient({ scheme: '0007', identifier: '1' })) + .rejects.toMatchObject({ kind: 'auth', retryable: true, httpStatus: 401 }) + }) +}) + +describe('Qvalia transport: submit', () => { + const fetchMock = vi.fn() + const transport = createQvaliaTransport(config, { + fetch: fetchMock, + now: () => new Date('2026-08-21T10:05:00.000Z'), + }) + + beforeEach(() => { + fetchMock.mockReset() + }) + + it('POSTs the exact XML to the partner-scoped outgoing endpoint and returns the integrationId', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { + status: 'success', + data: { message: 'invoice F-2026-42 sent', invoice_id: 'F-2026-42', integrationId: 'int-1' }, + })) + + const receipt = await transport.submit(submission()) + + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toBe( + 'https://api-qa.qvalia.com/partner/SE5560000000/transaction/SE5560000000/invoices/outgoing', + ) + expect(init?.method).toBe('POST') + expect((init?.headers as Record)['content-type']).toBe('application/xml') + expect(init?.body).toBe(XML) + expect(receipt).toEqual({ + provider: 'qvalia', + providerSubmissionId: 'int-1', + idempotencyKey: '33333333-3333-4333-8333-333333333333', + tenantReference: 'company-1', + acceptedAt: '2026-08-21T10:05:00.000Z', + }) + }) + + it('falls back to the integrationid response header when the body has none', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { + status: 200, + headers: { 'content-type': 'application/xml', integrationid: 'int-header' }, + })) + const receipt = await transport.submit(submission()) + expect(receipt.providerSubmissionId).toBe('int-header') + }) + + it('classifies 422 as a permanent rejection with Qvalia’s reason', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(422, { + status: 'error', + type: 'validation', + metadata: { description: 'BR-CO-10 Sum of invoice line net amount' }, + })) + const error = await transport.submit(submission()).catch((e: unknown) => e) + expect(error).toBeInstanceOf(QvaliaApiError) + expect(error).toMatchObject({ + kind: 'rejected', + retryable: false, + httpStatus: 422, + detail: 'BR-CO-10 Sum of invoice line net amount', + }) + }) + + it('classifies 5xx and network failures as retryable', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(503, { error: 'maintenance' })) + await expect(transport.submit(submission())).rejects.toMatchObject({ kind: 'unavailable', retryable: true }) + + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect(transport.submit(submission())).rejects.toMatchObject({ kind: 'network', retryable: true }) + }) + + it('recovers the integrationId on 409 only when Qvalia’s copy was sent by the same seller', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(409, { status: 'error', data: 'duplicate' })) + fetchMock.mockResolvedValueOnce(jsonResponse(200, { + status: 'success', + data: [{ + integrationId: 'int-dup', + Invoice: { + ID: [{ _: 'F-2026-42' }], + AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '556016-0680', schemeID: '0007' }] }] }], + }, + }], + })) + + const receipt = await transport.submit(submission()) + expect(receipt.providerSubmissionId).toBe('int-dup') + expect(String(fetchMock.mock.calls[1][0])).toContain('/invoices/outgoing?documentId=F-2026-42&includeRead=true') + }) + + it('keeps a 409 as a duplicate error when the stored copy belongs to another seller', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(409, { status: 'error', data: 'duplicate' })) + fetchMock.mockResolvedValueOnce(jsonResponse(200, { + status: 'success', + data: [{ + integrationId: 'int-other', + Invoice: { + AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '5599999999', schemeID: '0007' }] }] }], + }, + }], + })) + await expect(transport.submit(submission())).rejects.toMatchObject({ kind: 'duplicate', retryable: false }) + }) +}) + +describe('Qvalia transport: verifyWebhook', () => { + const fetchMock = vi.fn() + const transport = createQvaliaTransport(config, { fetch: fetchMock }) + 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 webhook(body: unknown, secret: string | null = config.webhookSecret) { + const headers = new Headers({ 'content-type': 'application/json' }) + if (secret) headers.set('X-Accounted-Webhook-Key', secret) + return { headers, rawBody: new TextEncoder().encode(JSON.stringify(body)) } + } + + it('rejects a missing or wrong shared secret before parsing', async () => { + await expect(transport.verifyWebhook(webhook(delivered, null))).rejects.toMatchObject({ kind: 'auth' }) + await expect(transport.verifyWebhook(webhook(delivered, 'wrong'))).rejects.toMatchObject({ kind: 'auth' }) + }) + + it('refuses to verify anything when no secret is configured', async () => { + const unconfigured = createQvaliaTransport({ ...config, webhookSecret: null }, { fetch: fetchMock }) + await expect(unconfigured.verifyWebhook(webhook(delivered))).rejects.toMatchObject({ kind: 'auth' }) + }) + + it('normalizes a processed delivery with the documented dedupe key and a body fingerprint', async () => { + const request = webhook(delivered) + const [event] = await transport.verifyWebhook(request) + expect(event).toMatchObject({ + provider: 'qvalia', + providerTenantId: 'SE5560000000', + providerSubmissionId: 'int-1', + providerEventId: 'document_delivery:int-1:processed', + idempotencyKey: null, + eventCode: 'document_delivery', + normalizedStatus: 'transport_succeeded', + isTerminal: false, + detail: 'processed', + occurredAt: '2026-08-19T09:26:09.881Z', + verificationMethod: 'shared_secret_header', + }) + expect(event.eventSha256).toBe(sha256Hex(request.rawBody)) + }) + + it('ignores inbound-direction events on the outbound boundary', async () => { + const events = await transport.verifyWebhook(webhook({ ...delivered, direction: 'incoming' })) + expect(events).toEqual([]) + }) + + it('keeps a recoverable error non-terminal and a validation error terminal', async () => { + const [recoverable] = await transport.verifyWebhook(webhook({ + ...delivered, + eventType: 'document_error', + status: { status: 'error', event: 'message-log/error' }, + error: 'Receiver access point timed out', + })) + expect(recoverable).toMatchObject({ normalizedStatus: 'retryable_failure', isTerminal: false }) + + const [permanent] = await transport.verifyWebhook(webhook({ + ...delivered, + eventType: 'document_error', + status: { status: 'error', event: 'message-log/error' }, + error: 'Peppol validation failed: invoice does not conform to UBL 2.1', + })) + expect(permanent).toMatchObject({ + normalizedStatus: 'failed', + isTerminal: true, + detail: 'error: Peppol validation failed: invoice does not conform to UBL 2.1', + }) + }) +}) + +describe('normalizeQvaliaWebhook', () => { + const base = { eventType: 'document_delivery', direction: 'outgoing' } + it.each([ + ['rejected by buyer', 'business_rejected', true], + ['accepted', 'business_accepted', true], + ['acknowledged', 'recipient_acknowledged', false], + ['delivered', 'transport_succeeded', false], + ['queued for sending', 'submission_accepted', false], + ['some new wording', 'submission_accepted', false], + ])('maps "%s" to %s', (status, expected, terminal) => { + expect(normalizeQvaliaWebhook({ ...base, status: { status } })).toMatchObject({ + normalizedStatus: expected, + isTerminal: terminal, + detail: status, + }) + }) + + it('maps new_document to submission_accepted and ignores unknown event types', () => { + expect(normalizeQvaliaWebhook({ eventType: 'new_document' })?.normalizedStatus).toBe('submission_accepted') + expect(normalizeQvaliaWebhook({ eventType: 'something_else' })).toBeNull() + }) +}) + +describe('Qvalia transport: retrieveEvidence', () => { + it('captures the message-log status and the provider-held XML copy', async () => { + const fetchMock = vi.fn() + fetchMock.mockResolvedValueOnce(jsonResponse(200, [{ uuid: 'int-1', readAt: null, metadata: { status: 'processed' } }])) + fetchMock.mockResolvedValueOnce(new Response(XML, { status: 200, headers: { 'content-type': 'application/xml' } })) + const transport = createQvaliaTransport(config, { + fetch: fetchMock, + now: () => new Date('2026-08-21T11:00:00.000Z'), + }) + + const [evidence] = await transport.retrieveEvidence('int-1') + + expect(String(fetchMock.mock.calls[0][0])).toContain('/invoices/outgoing/status?integrationId=int-1') + expect((fetchMock.mock.calls[1][1]?.headers as Record).accept).toBe('application/xml') + expect(evidence).toMatchObject({ + provider: 'qvalia', + evidenceType: 'qvalia_message_record', + exactDocument: XML, + exactDocumentSha256: sha256Hex(XML), + retrievedAt: '2026-08-21T11:00:00.000Z', + }) + expect(evidence.payload).toMatchObject({ integrationId: 'int-1', status: [{ uuid: 'int-1' }] }) + }) +}) + +describe('helpers', () => { + it('reduces SMP service URLs to bare Peppol document type ids', () => { + expect(normalizePeppolDocumentTypeId( + `https://smp-test.qvalia.com/iso6523-actorid-upis::0007:5567321707/services/busdox-docid-qns::${PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID}`, + )).toBe(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID) + expect(normalizePeppolDocumentTypeId( + 'https://smp-test.qvalia.com/iso6523-actorid-upis::0007:1/services/peppol-doctype-wildcard::urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:peppol:pint:selfbilling-1%40aunz-1::2.1', + )).toBe('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:peppol:pint:selfbilling-1@aunz-1::2.1') + expect(normalizePeppolDocumentTypeId(`busdox-docid-qns::${PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID}`)) + .toBe(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID) + expect(normalizePeppolDocumentTypeId(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID)) + .toBe(PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID) + }) + + it('extracts the UBL document id and the seller endpoint', () => { + expect(extractUblDocumentId(XML)).toBe('F-2026-42') + expect(extractUblDocumentId('A & B')).toBe('A & B') + // Entities are decoded in one pass: "&lt;" is the literal text "<". + expect(extractUblDocumentId('X &lt; Y')).toBe('X < Y') + expect(extractUblDocumentId('')).toBeNull() + expect(extractUblJsonSupplierEndpoint({ + Invoice: { AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '1', schemeID: '0007' }] }] }] }, + })).toEqual({ scheme: '0007', identifier: '1' }) + expect(extractUblJsonSupplierEndpoint({ Invoice: {} })).toBeNull() + }) + + it('describes Qvalia error bodies from the documented envelopes', () => { + expect(describeQvaliaErrorBody({ statusCode: 400, error: 'Bad Request', message: 'missing ID' })).toBe('missing ID') + expect(describeQvaliaErrorBody({ metadata: { details: { rule: 'BR-01' } } })).toBe('{"rule":"BR-01"}') + expect(describeQvaliaErrorBody('plain text')).toBe('plain text') + expect(describeQvaliaErrorBody(null)).toBeNull() + }) +}) diff --git a/lib/invoices/peppol-bis-billing.ts b/lib/invoices/peppol-bis-billing.ts index 162bd3ca..14e678f9 100644 --- a/lib/invoices/peppol-bis-billing.ts +++ b/lib/invoices/peppol-bis-billing.ts @@ -12,6 +12,9 @@ export const PEPPOL_BIS_BILLING_CUSTOMIZATION_ID = 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0' export const PEPPOL_BIS_BILLING_PROFILE_ID = 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0' +/** Peppol document type identifier for a BIS Billing 3 UBL 2.1 invoice (SMP capability key). */ +export const PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID = + 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1' const SUPPORTED_VAT_RATES = new Set([6, 12, 25]) const UNIT_CODES: Record = { diff --git a/lib/invoices/peppol-delivery.ts b/lib/invoices/peppol-delivery.ts index 59683011..d54b2d2b 100644 --- a/lib/invoices/peppol-delivery.ts +++ b/lib/invoices/peppol-delivery.ts @@ -81,6 +81,9 @@ export async function persistVerifiedPeppolEvent(args: { event: PeppolVerifiedEvent }): Promise { const { event } = args + if (!event.idempotencyKey) { + throw new Error('Peppol event idempotency key must be resolved before it is recorded') + } const { data, error } = await args.supabase.rpc('record_peppol_delivery_event', { p_company_id: args.companyId, p_idempotency_key: event.idempotencyKey, diff --git a/lib/invoices/peppol-document.ts b/lib/invoices/peppol-document.ts new file mode 100644 index 00000000..89147236 --- /dev/null +++ b/lib/invoices/peppol-document.ts @@ -0,0 +1,151 @@ +import type { NextResponse } from 'next/server' +import type { SupabaseClient } from '@supabase/supabase-js' +import { privateNoStore } from '@/lib/api/private-no-store' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + generatePeppolBisBillingInvoice, + type PeppolInvoiceResult, +} from '@/lib/invoices/peppol-bis-billing' +import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' + +export type GeneratedPeppolInvoice = Extract + +export type PeppolInvoiceRecord = Invoice & { customer?: Customer | null; items?: InvoiceItem[] | null } + +type RouteLog = Parameters[1] + +export type LoadPeppolRecordsResult = + | { ok: true; invoice: PeppolInvoiceRecord; company: CompanySettings } + | { ok: false; response: NextResponse } + +export type LoadPeppolDocumentResult = + | { ok: true; document: GeneratedPeppolInvoice; invoice: PeppolInvoiceRecord; company: CompanySettings } + | { ok: false; response: NextResponse } + +/** + * Fetch the invoice (with customer and lines) and the company settings the + * Peppol generator needs, with the same explicit `company_id` isolation as the + * other invoice routes. Shared by the export, stage and send routes. + */ +export async function loadPeppolRecords(args: { + supabase: SupabaseClient + companyId: string + invoiceId: string + log: RouteLog + requestId: string +}): Promise { + 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 }, + )), + } + } + + return { + ok: true, + invoice: invoice as PeppolInvoiceRecord, + company: company as CompanySettings, + } +} + +/** + * Run the BIS Billing 3 generator on already-loaded records and turn a failed + * preflight into the structured, field-addressable VALIDATION_ERROR envelope. + */ +export function generatePeppolDocumentOrResponse(args: { + invoice: PeppolInvoiceRecord + company: CompanySettings + log: RouteLog + requestId: string +}): { ok: true; document: GeneratedPeppolInvoice } | { ok: false; response: NextResponse } { + if (!args.invoice.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: args.invoice, + customer: args.invoice.customer, + items: args.invoice.items ?? [], + company: args.company, + }) + 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 async function loadPeppolDocument(args: { + supabase: SupabaseClient + companyId: string + invoiceId: string + log: RouteLog + requestId: string +}): Promise { + const records = await loadPeppolRecords(args) + if (!records.ok) return records + const generated = generatePeppolDocumentOrResponse({ + invoice: records.invoice, + company: records.company, + log: args.log, + requestId: args.requestId, + }) + if (!generated.ok) return generated + return { ok: true, document: generated.document, invoice: records.invoice, company: records.company } +} diff --git a/lib/invoices/peppol-transport.ts b/lib/invoices/peppol-transport.ts index 480de184..635eb61b 100644 --- a/lib/invoices/peppol-transport.ts +++ b/lib/invoices/peppol-transport.ts @@ -66,7 +66,12 @@ export interface PeppolVerifiedEvent { providerTenantId: string | null providerSubmissionId: string | null providerEventId: string | null - idempotencyKey: string + /** + * Accounted's delivery idempotency key. A provider webhook only knows its + * own submission id, so an adapter returns `null` here and the webhook route + * resolves the key from `providerSubmissionId` before persisting. + */ + idempotencyKey: string | null eventCode: string normalizedStatus: PeppolDeliveryStatus isTerminal: boolean @@ -92,6 +97,28 @@ export interface PeppolWebhookRequest { rawBody: Uint8Array } +/** + * Provider-neutral failure raised by an adapter. `retryable` separates an + * operational problem (network, rate limit, credentials) from a verdict on the + * document itself (rejected, duplicate); the send route records them as + * different lifecycle events. + */ +export class PeppolTransportError extends Error { + readonly retryable: boolean + readonly detail: string | null + + constructor(message: string, options: { retryable: boolean; detail?: string | null; cause?: unknown }) { + super(message, options.cause !== undefined ? { cause: options.cause } : undefined) + this.name = 'PeppolTransportError' + this.retryable = options.retryable + this.detail = options.detail ?? null + } +} + +export function isPeppolTransportError(error: unknown): error is PeppolTransportError { + return error instanceof PeppolTransportError +} + export interface PeppolTransport { readonly provider: string lookupRecipient(participant: PeppolParticipant): Promise diff --git a/lib/invoices/transports/index.ts b/lib/invoices/transports/index.ts new file mode 100644 index 00000000..b62cb040 --- /dev/null +++ b/lib/invoices/transports/index.ts @@ -0,0 +1,34 @@ +/** + * Registers the Peppol Access Point adapters that the environment configures. + * Core ships the Qvalia adapter; `PEPPOL_TRANSPORT_PROVIDER` still decides + * which registered adapter the product is allowed to use, so an adapter can be + * configured (for the probe script, for a preview) without being switched on. + */ + +import { + getPeppolTransport, + registerPeppolTransport, + type PeppolTransport, +} from '@/lib/invoices/peppol-transport' +import { + QVALIA_PROVIDER, + createQvaliaTransport, + readQvaliaConfigFromEnv, +} from '@/lib/invoices/transports/qvalia' + +export function registerConfiguredPeppolTransports( + env: Record = process.env, +): PeppolTransport[] { + const registered: PeppolTransport[] = [] + + if (!getPeppolTransport(QVALIA_PROVIDER)) { + const qvaliaConfig = readQvaliaConfigFromEnv(env) + if (qvaliaConfig) { + const transport = createQvaliaTransport(qvaliaConfig) + registerPeppolTransport(transport) + registered.push(transport) + } + } + + return registered +} diff --git a/lib/invoices/transports/qvalia.ts b/lib/invoices/transports/qvalia.ts new file mode 100644 index 00000000..a5f8561d --- /dev/null +++ b/lib/invoices/transports/qvalia.ts @@ -0,0 +1,632 @@ +/** + * Qvalia Peppol Access Point adapter. + * + * Qvalia (PSE000094) is the contracted Access Point + SMP. This module is the + * only place that knows Qvalia's HTTP surface; everything else speaks the + * provider-neutral `PeppolTransport` boundary. + * + * API facts (https://api.qvalia.io, verified 2026-08-21): + * - Production `https://api.qvalia.com`, sandbox `https://api-test.qvalia.com` + * (the public docs say api-qa; the onboarding mail says api-test and that one + * answers), separate keys per environment. + * - Auth: the bare key in `Authorization: ` (verified live against the + * sandbox 2026-08-21; the `ApiKey ` form in the newest docs answers 401 + * for this key, so it is opt-in via QVALIA_AUTH_SCHEME=apikey). + * - Partner model: every call is `/partner/{partnerRegNo}/...`; transactions + * are `/partner/{partnerRegNo}/transaction/{accountRegNo}/invoices/outgoing`. + * In the consolidated setup all customer Peppol IDs live under one account + * and `accountRegNo` equals `partnerRegNo`. + * - Outgoing invoice: POST the BIS Billing 3 UBL XML with + * `content-type: application/xml`; the response carries an `integrationId` + * (UUID) that identifies the message at Qvalia. The same document id for the + * same receiver answers `409`. + * - Recipient lookup: GET `/partner/{p}/peppol/lookup/{scheme:id}?docTypeRoot=Invoice`. + * - Webhooks are plain HTTPS POSTs without a signature; the partner attaches + * an outbound auth header of its own choosing. Delivery is at-least-once and + * the documented dedupe key is eventType + globalTransactionId + status.status. + */ + +import { timingSafeEqual } from 'node:crypto' +import { sha256Hex } from '@/lib/invoices/peppol-delivery' +import { + PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID, + PEPPOL_BIS_BILLING_PROFILE_ID, +} from '@/lib/invoices/peppol-bis-billing' +import { + PeppolTransportError, + type PeppolDeliveryEvidence, + type PeppolDeliveryStatus, + type PeppolParticipant, + type PeppolRecipientCapability, + type PeppolRecipientLookup, + type PeppolSubmission, + type PeppolSubmissionReceipt, + type PeppolTransport, + type PeppolVerifiedEvent, + type PeppolWebhookRequest, +} from '@/lib/invoices/peppol-transport' + +export const QVALIA_PROVIDER = 'qvalia' +export const QVALIA_PRODUCTION_BASE_URL = 'https://api.qvalia.com' +export const QVALIA_SANDBOX_BASE_URL = 'https://api-test.qvalia.com' +export const QVALIA_DEFAULT_WEBHOOK_HEADER = 'x-accounted-webhook-key' + +export type QvaliaAuthScheme = 'apikey' | 'raw' + +export interface QvaliaConfig { + apiKey: string + /** Partner registration number issued by Qvalia. */ + partnerRegNo: string + /** + * Account registration number the documents are sent from. Consolidated + * setup: the partner account itself. Multi-tenant setup (later): one per + * Accounted company. + */ + accountRegNo: string + baseUrl: string + authScheme: QvaliaAuthScheme + /** Shared secret Qvalia sends back on every webhook delivery. */ + webhookSecret: string | null + /** Header name carrying the shared secret (compared case-insensitively). */ + webhookHeader: string +} + +export interface QvaliaTransportDeps { + fetch?: typeof fetch + now?: () => Date +} + +/** + * Read the adapter configuration from the environment. Returns `null` when + * the mandatory values are absent so the transport stays unregistered and the + * product truthfully reports "provider adapter unavailable". + */ +export function readQvaliaConfigFromEnv( + env: Record = process.env, +): QvaliaConfig | null { + const apiKey = env.QVALIA_API_KEY?.trim() + const partnerRegNo = env.QVALIA_PARTNER_REG_NO?.trim() + const baseUrl = env.QVALIA_BASE_URL?.trim().replace(/\/+$/, '') + if (!apiKey || !partnerRegNo || !baseUrl) return null + if (!/^https:\/\//i.test(baseUrl)) return null + + const authSchemeRaw = env.QVALIA_AUTH_SCHEME?.trim().toLowerCase() + const authScheme: QvaliaAuthScheme = authSchemeRaw === 'apikey' ? 'apikey' : 'raw' + + return { + apiKey, + partnerRegNo, + accountRegNo: env.QVALIA_ACCOUNT_REG_NO?.trim() || partnerRegNo, + baseUrl, + authScheme, + webhookSecret: env.QVALIA_WEBHOOK_SECRET?.trim() || null, + webhookHeader: (env.QVALIA_WEBHOOK_HEADER?.trim() || QVALIA_DEFAULT_WEBHOOK_HEADER).toLowerCase(), + } +} + +export type QvaliaErrorKind = + | 'rejected' + | 'duplicate' + | 'auth' + | 'rate_limited' + | 'unavailable' + | 'network' + | 'protocol' + +/** + * One error type for every Qvalia failure. `kind` tells the caller whether a + * retry can help: `rejected` and `duplicate` are permanent for this document, + * everything else is operational. + */ +export class QvaliaApiError extends PeppolTransportError { + readonly kind: QvaliaErrorKind + readonly httpStatus: number | null + + constructor(kind: QvaliaErrorKind, message: string, options: { + httpStatus?: number | null + detail?: string | null + cause?: unknown + } = {}) { + super(message, { + retryable: kind !== 'rejected' && kind !== 'duplicate', + detail: options.detail ?? null, + cause: options.cause, + }) + this.name = 'QvaliaApiError' + this.kind = kind + this.httpStatus = options.httpStatus ?? null + } +} + +export function isQvaliaApiError(error: unknown): error is QvaliaApiError { + return error instanceof QvaliaApiError +} + +function authorizationHeader(config: QvaliaConfig): string { + return config.authScheme === 'raw' ? config.apiKey : `ApiKey ${config.apiKey}` +} + +function encodePathSegment(value: string): string { + return encodeURIComponent(value) +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +/** Pull a human-readable reason out of Qvalia's varied error envelopes. */ +export function describeQvaliaErrorBody(body: unknown): string | null { + const record = asRecord(body) + if (!record) return typeof body === 'string' && body.trim() ? body.trim().slice(0, 500) : null + const metadata = asRecord(record.metadata) + const candidates: unknown[] = [ + metadata?.description, + metadata?.debug_error_message, + record.message, + record.error, + record.data, + ] + for (const candidate of candidates) { + const text = asString(candidate) + if (text) return text.slice(0, 500) + } + const details = metadata?.details + if (details && typeof details === 'object') { + try { + return JSON.stringify(details).slice(0, 500) + } catch { + return null + } + } + return null +} + +async function readBody(response: Response): Promise<{ text: string; json: unknown }> { + const text = await response.text() + if (!text) return { text, json: null } + try { + return { text, json: JSON.parse(text) as unknown } + } catch { + return { text, json: null } + } +} + +function classifyHttpFailure(status: number, body: unknown, text: string): QvaliaApiError { + const detail = describeQvaliaErrorBody(body) ?? (text.trim() ? text.trim().slice(0, 500) : null) + if (status === 400 || status === 422) { + return new QvaliaApiError('rejected', `Qvalia rejected the document (${status})`, { + httpStatus: status, + detail, + }) + } + if (status === 409) { + return new QvaliaApiError('duplicate', 'Qvalia already holds a document with this id for this receiver', { + httpStatus: status, + detail, + }) + } + if (status === 401 || status === 403) { + return new QvaliaApiError('auth', `Qvalia refused the API credentials (${status})`, { + httpStatus: status, + detail, + }) + } + if (status === 429) { + return new QvaliaApiError('rate_limited', 'Qvalia rate limit reached', { + httpStatus: status, + detail, + }) + } + return new QvaliaApiError('unavailable', `Qvalia answered ${status}`, { + httpStatus: status, + detail, + }) +} + +/** First `` of a UBL document is the document number (after CustomizationID/ProfileID). */ +export function extractUblDocumentId(xml: string): string | null { + const match = /]*)?>([^<]+)<\/cbc:ID>/.exec(xml) + if (!match) return null + // Single pass: a sequential chain would double-unescape "&lt;". + const entities: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } + const value = match[1] + .replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => entities[name]) + .trim() + return value || null +} + +/** + * Dig the seller endpoint identifier out of a UBL-JSON invoice as Qvalia + * returns it (OASIS UBL 2.1 JSON: arrays everywhere, text under `_`). + */ +export function extractUblJsonSupplierEndpoint(message: unknown): PeppolParticipant | null { + const record = asRecord(message) + if (!record) return null + const invoice = asRecord(record.Invoice) ?? record + const supplierParty = firstRecord(invoice.AccountingSupplierParty) + const party = firstRecord(supplierParty?.Party) + const endpoint = firstRecord(party?.EndpointID) + const identifier = asString(endpoint?._) + const scheme = asString(endpoint?.schemeID) + if (!identifier || !scheme) return null + return { scheme, identifier } +} + +function firstRecord(value: unknown): Record | null { + if (Array.isArray(value)) return asRecord(value[0]) + return asRecord(value) +} + +function extractIntegrationId(message: unknown): string | null { + const record = asRecord(message) + if (!record) return null + return asString(record.integrationId) ?? asString(asRecord(record.Invoice)?.integrationId) +} + +function participantsEqual(a: PeppolParticipant, b: PeppolParticipant): boolean { + return a.scheme === b.scheme + && a.identifier.replace(/[\s-]/g, '') === b.identifier.replace(/[\s-]/g, '') +} + +/** Webhook payload shape as documented on api.qvalia.io (one flat object). */ +export interface QvaliaWebhookPayload { + eventType: 'new_document' | 'document_delivery' | 'document_error' | string + accountRegNo?: string + documentType?: string + direction?: 'outgoing' | 'incoming' | string + integrationId?: string + occurredAt?: string + documentId?: string + globalTransactionId?: string + status?: { + status?: string + event?: string + deliveryMethod?: string + updatedAt?: string + } + error?: string | null + peppol_metadata?: Record | null +} + +export interface QvaliaNormalizedStatus { + eventCode: string + normalizedStatus: PeppolDeliveryStatus + isTerminal: boolean + detail: string | null +} + +const PERMANENT_ERROR_RE = /validat|schema|conform|invalid|malformed|not registered|unknown (?:recipient|receiver|participant)|no (?:such )?(?:recipient|receiver|participant)|not found in (?:smp|sml|peppol)/i + +/** + * Map Qvalia's free-text delivery statuses onto the lifecycle. `status.status` + * is explicitly "not a fixed enum and may change" in Qvalia's docs, so the + * mapping is tolerant: unknown wording never advances beyond what the event + * type itself proves, and the raw wording is kept in `detail`. + */ +export function normalizeQvaliaWebhook(payload: QvaliaWebhookPayload): QvaliaNormalizedStatus | null { + const rawStatus = payload.status?.status?.trim() ?? '' + const lower = rawStatus.toLowerCase() + const detailParts = [rawStatus, payload.error?.trim()].filter((part): part is string => !!part) + const detail = detailParts.length ? detailParts.join(': ').slice(0, 500) : null + + switch (payload.eventType) { + case 'new_document': + return { + eventCode: 'new_document', + normalizedStatus: 'submission_accepted', + isTerminal: false, + detail, + } + case 'document_delivery': { + if (/reject|refus|denied/.test(lower)) { + return { eventCode: 'document_delivery', normalizedStatus: 'business_rejected', isTerminal: true, detail } + } + if (/accept|approv|\bpaid\b/.test(lower)) { + return { eventCode: 'document_delivery', normalizedStatus: 'business_accepted', isTerminal: true, detail } + } + if (/acknowledg|\back\b|confirmed/.test(lower)) { + return { eventCode: 'document_delivery', normalizedStatus: 'recipient_acknowledged', isTerminal: false, detail } + } + if (/processed|delivered|sent|transport|success|complete|received/.test(lower)) { + return { eventCode: 'document_delivery', normalizedStatus: 'transport_succeeded', isTerminal: false, detail } + } + if (/error|fail|undeliver|bounce/.test(lower)) { + return { eventCode: 'document_delivery', normalizedStatus: 'retryable_failure', isTerminal: false, detail } + } + return { eventCode: 'document_delivery', normalizedStatus: 'submission_accepted', isTerminal: false, detail } + } + case 'document_error': { + const reason = `${rawStatus} ${payload.error ?? ''}` + // Qvalia retries transport errors for 24 h by default and then sends a + // document_delivery if it succeeds; only structural rejections are final. + const permanent = PERMANENT_ERROR_RE.test(reason) + return { + eventCode: 'document_error', + normalizedStatus: permanent ? 'failed' : 'retryable_failure', + isTerminal: permanent, + detail, + } + } + default: + return null + } +} + +/** + * Qvalia's lookup returns document types as SMP service URLs, e.g. + * `https://smp-test.qvalia.com/iso6523-actorid-upis::0007:5567321707/services/busdox-docid-qns::urn:oasis:...::2.1` + * (verified live 2026-08-21), sometimes percent-encoded. Reduce them to the + * bare Peppol document type identifier so capabilities compare by value. + */ +export function normalizePeppolDocumentTypeId(value: string): string { + let candidate = value.trim() + const servicesIndex = candidate.indexOf('/services/') + if (/^https?:\/\//i.test(candidate) && servicesIndex !== -1) { + candidate = candidate.slice(servicesIndex + '/services/'.length) + } + try { + candidate = decodeURIComponent(candidate) + } catch { + // keep as-is when not percent-encoded + } + const schemeMatch = /^(busdox-docid-qns|peppol-doctype-wildcard)::/.exec(candidate) + if (schemeMatch) candidate = candidate.slice(schemeMatch[0].length) + return candidate +} + +function capabilityFromDocType(value: string): PeppolRecipientCapability { + const documentTypeId = normalizePeppolDocumentTypeId(value) + return { + documentTypeId, + processId: documentTypeId === PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID ? PEPPOL_BIS_BILLING_PROFILE_ID : '', + } +} + +export function createQvaliaTransport( + config: QvaliaConfig, + deps: QvaliaTransportDeps = {}, +): PeppolTransport { + const fetchImpl = deps.fetch ?? globalThis.fetch + const now = deps.now ?? (() => new Date()) + const partner = encodePathSegment(config.partnerRegNo) + const account = encodePathSegment(config.accountRegNo) + const transactionBase = `${config.baseUrl}/partner/${partner}/transaction/${account}` + + async function request( + method: 'GET' | 'POST', + url: string, + init: { headers?: Record; body?: string } = {}, + ): Promise { + try { + return await fetchImpl(url, { + method, + headers: { + Authorization: authorizationHeader(config), + accept: 'application/json', + ...init.headers, + }, + body: init.body, + cache: 'no-store', + }) + } catch (error) { + throw new QvaliaApiError('network', 'Could not reach Qvalia', { cause: error }) + } + } + + async function lookupRecipient(participant: PeppolParticipant): Promise { + const checkedAt = now().toISOString() + const peppolId = `${participant.scheme}:${participant.identifier}` + const url = `${config.baseUrl}/partner/${partner}/peppol/lookup/${encodePathSegment(peppolId)}?docTypeRoot=Invoice` + const response = await request('GET', url) + + if (response.status === 204 || response.status === 404) { + return { reachable: false, participant, reasonCode: 'participant_not_found', checkedAt } + } + const { text, json } = await readBody(response) + if (response.status === 422 || response.status === 400) { + return { reachable: false, participant, reasonCode: 'invalid_identifier', checkedAt } + } + if (!response.ok) throw classifyHttpFailure(response.status, json, text) + + const data = asRecord(asRecord(json)?.data) ?? asRecord(json) + if (!data) { + throw new QvaliaApiError('protocol', 'Qvalia lookup answered without a data object', { + httpStatus: response.status, + }) + } + + const exists = data.exists === true + const rootDocTypeExists = data.rootDocTypeExists + const matches = Array.isArray(data.matches) ? data.matches : [] + const capabilities: PeppolRecipientCapability[] = [] + for (const match of matches) { + const docTypes = asRecord(match)?.docTypes + if (!Array.isArray(docTypes)) continue + for (const docType of docTypes) { + const value = asString(asRecord(docType)?.value) + if (value) capabilities.push(capabilityFromDocType(value)) + } + } + + if (!exists) { + return { reachable: false, participant, reasonCode: 'participant_not_registered', checkedAt } + } + if (rootDocTypeExists === false) { + return { reachable: false, participant, reasonCode: 'document_type_not_supported', checkedAt } + } + return { reachable: true, participant, capabilities, checkedAt } + } + + async function recoverDuplicateSubmission( + submission: PeppolSubmission, + duplicate: QvaliaApiError, + ): Promise { + const documentId = extractUblDocumentId(submission.document) + if (!documentId) throw duplicate + + const url = `${transactionBase}/invoices/outgoing?documentId=${encodeURIComponent(documentId)}&includeRead=true&limit=10` + const response = await request('GET', url) + const { json } = await readBody(response) + if (!response.ok) throw duplicate + + const data = asRecord(json)?.data ?? json + const messages = Array.isArray(data) ? data : data ? [data] : [] + for (const message of messages) { + const integrationId = extractIntegrationId(message) + const supplier = extractUblJsonSupplierEndpoint(message) + if (!integrationId || !supplier) continue + if (!participantsEqual(supplier, submission.sender)) continue + return { + provider: QVALIA_PROVIDER, + providerSubmissionId: integrationId, + idempotencyKey: submission.idempotencyKey, + tenantReference: submission.tenantReference, + acceptedAt: now().toISOString(), + } + } + throw duplicate + } + + async function submit(submission: PeppolSubmission): Promise { + if (submission.contentType !== 'application/xml') { + throw new QvaliaApiError('rejected', 'Qvalia adapter only submits UBL XML', { + detail: `unsupported content type ${submission.contentType}`, + }) + } + const response = await request('POST', `${transactionBase}/invoices/outgoing`, { + headers: { 'content-type': 'application/xml' }, + body: submission.document, + }) + const { text, json } = await readBody(response) + + if (!response.ok) { + const failure = classifyHttpFailure(response.status, json, text) + if (failure.kind === 'duplicate') return recoverDuplicateSubmission(submission, failure) + throw failure + } + + const data = asRecord(asRecord(json)?.data) + const integrationId = asString(data?.integrationId) + ?? asString(response.headers.get('integrationid')) + if (!integrationId) { + throw new QvaliaApiError('protocol', 'Qvalia accepted the document without an integrationId', { + httpStatus: response.status, + detail: text.trim().slice(0, 500) || null, + }) + } + + return { + provider: QVALIA_PROVIDER, + providerSubmissionId: integrationId, + idempotencyKey: submission.idempotencyKey, + tenantReference: submission.tenantReference, + acceptedAt: now().toISOString(), + } + } + + function webhookAuthorized(headers: Headers): boolean { + if (!config.webhookSecret) return false + const presented = headers.get(config.webhookHeader) + if (!presented) return false + const a = Buffer.from(presented) + const b = Buffer.from(config.webhookSecret) + return a.length === b.length && timingSafeEqual(a, b) + } + + async function verifyWebhook(webhook: PeppolWebhookRequest): Promise { + if (!webhookAuthorized(webhook.headers)) { + throw new QvaliaApiError('auth', 'Qvalia webhook secret missing or mismatched') + } + + let parsed: unknown + try { + parsed = JSON.parse(Buffer.from(webhook.rawBody).toString('utf8')) + } catch (error) { + throw new QvaliaApiError('protocol', 'Qvalia webhook body is not JSON', { cause: error }) + } + const payloads = Array.isArray(parsed) ? parsed : [parsed] + const eventSha256 = sha256Hex(webhook.rawBody) + const events: PeppolVerifiedEvent[] = [] + + for (const [index, candidate] of payloads.entries()) { + const payload = asRecord(candidate) as QvaliaWebhookPayload | null + if (!payload || typeof payload.eventType !== 'string') continue + // Inbound documents are a separate flow; this boundary is outbound-only. + if (payload.direction && payload.direction !== 'outgoing') continue + const normalized = normalizeQvaliaWebhook(payload) + if (!normalized) continue + + const integrationId = asString(payload.integrationId) ?? asString(payload.globalTransactionId) + const transactionId = asString(payload.globalTransactionId) ?? integrationId ?? 'unknown' + const statusKey = payload.status?.status ?? payload.status?.event ?? normalized.eventCode + const occurredAt = asString(payload.status?.updatedAt) ?? asString(payload.occurredAt) ?? now().toISOString() + + events.push({ + provider: QVALIA_PROVIDER, + providerTenantId: asString(payload.accountRegNo) ?? config.accountRegNo, + providerSubmissionId: integrationId, + providerEventId: `${payload.eventType}:${transactionId}:${statusKey}`, + idempotencyKey: null, + eventCode: normalized.eventCode, + normalizedStatus: normalized.normalizedStatus, + isTerminal: normalized.isTerminal, + detail: normalized.detail, + occurredAt, + rawPayload: payload as unknown as Record, + eventSha256: payloads.length === 1 ? eventSha256 : sha256Hex(`${eventSha256}:${index}`), + verificationMethod: 'shared_secret_header', + }) + } + + return events + } + + async function retrieveEvidence(providerSubmissionId: string): Promise { + const retrievedAt = now().toISOString() + const query = `integrationId=${encodeURIComponent(providerSubmissionId)}&includeRead=true&limit=1` + + const statusResponse = await request('GET', `${transactionBase}/invoices/outgoing/status?${query}`) + const statusBody = await readBody(statusResponse) + if (!statusResponse.ok && statusResponse.status !== 204) { + throw classifyHttpFailure(statusResponse.status, statusBody.json, statusBody.text) + } + + const documentResponse = await request('GET', `${transactionBase}/invoices/outgoing?${query}`, { + headers: { accept: 'application/xml' }, + }) + const documentText = documentResponse.ok ? await documentResponse.text() : '' + const exactDocument = documentText.trim().startsWith('<') ? documentText : null + + const payload: Record = { + integrationId: providerSubmissionId, + status: statusBody.json ?? null, + document_http_status: documentResponse.status, + note: 'Provider-held copy of the submitted document and its latest message-log status.', + } + const exactDocumentSha256 = exactDocument ? sha256Hex(exactDocument) : null + + return [{ + provider: QVALIA_PROVIDER, + evidenceType: 'qvalia_message_record', + payload, + exactDocument, + exactDocumentSha256, + evidenceSha256: sha256Hex(JSON.stringify({ payload, exactDocumentSha256 })), + retrievedAt, + }] + } + + return { + provider: QVALIA_PROVIDER, + lookupRecipient, + submit, + verifyWebhook, + retrieveEvidence, + } +} diff --git a/messages/en.json b/messages/en.json index 3bc00e89..2144dcef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4028,6 +4028,31 @@ "peppol_prepared_description": "An immutable XML copy was saved for traceability. The invoice has not been sent to Peppol.", "peppol_prepare_failed_title": "Could not prepare Peppol delivery", "peppol_prepare_failed_description": "The invoice could not be saved as a prepared Peppol delivery.", + "peppol_send_confirm_title": "Send via Peppol?", + "peppol_send_confirm_description": "The invoice is sent as an e-invoice to Peppol id {recipient}. A draft gets its invoice number, is marked as sent and is booked once the Peppol access point has accepted it.", + "peppol_send_confirm_action": "Send", + "peppol_sending": "Sending via Peppol…", + "peppol_sent_title": "Sent via Peppol", + "peppol_sent_description": "The invoice has been handed to the Peppol network. Delivery status updates here when the recipient has received it.", + "peppol_already_sent_description": "This invoice has already been sent via Peppol. It is not sent again.", + "peppol_issue_failed_description": "The invoice was sent via Peppol but could not be marked as sent. Use Mark as sent to complete the bookkeeping.", + "peppol_send_failed_title": "Could not send via Peppol", + "peppol_send_failed_description": "The invoice has not been sent via Peppol.", + "peppol_status_title": "Peppol", + "peppol_status_recipient": "Recipient", + "peppol_status_label": "Status", + "peppol_status_updated": "Updated", + "peppol_status_staged": "Prepared, not sent", + "peppol_status_recipient_verified": "Recipient verified", + "peppol_status_submitting": "Sending", + "peppol_status_retryable_failure": "Temporary failure, try again", + "peppol_status_submission_accepted": "Accepted by the Peppol access point", + "peppol_status_transport_succeeded": "Delivered to the recipient's access point", + "peppol_status_recipient_acknowledged": "Acknowledged by the recipient", + "peppol_status_business_accepted": "Approved by the recipient", + "peppol_status_business_rejected": "Rejected by the recipient", + "peppol_status_no_route": "Recipient has no Peppol registration", + "peppol_status_failed": "Failed", "pdf_rerender_downloaded_title": "Freshly generated PDF downloaded", "pdf_rerender_preview_title": "Showing a freshly generated PDF", "pdf_preview_blocked_title": "Could not open the preview", diff --git a/messages/sv.json b/messages/sv.json index d71b3e3f..f7df1a3e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4028,6 +4028,31 @@ "peppol_prepared_description": "En oföränderlig XML-kopia har sparats för spårbarhet. Fakturan har inte skickats till Peppol.", "peppol_prepare_failed_title": "Kunde inte förbereda Peppol-leveransen", "peppol_prepare_failed_description": "Fakturan kunde inte sparas som en förberedd Peppol-leverans.", + "peppol_send_confirm_title": "Skicka via Peppol?", + "peppol_send_confirm_description": "Fakturan skickas som e-faktura till Peppol-id {recipient}. Ett utkast får fakturanummer, markeras som skickat och bokförs när Peppol-operatören tagit emot den.", + "peppol_send_confirm_action": "Skicka", + "peppol_sending": "Skickar via Peppol…", + "peppol_sent_title": "Skickad via Peppol", + "peppol_sent_description": "Fakturan har lämnats till Peppol-nätverket. Leveransstatus uppdateras här när mottagaren tagit emot den.", + "peppol_already_sent_description": "Den här fakturan är redan skickad via Peppol. Den skickas inte igen.", + "peppol_issue_failed_description": "Fakturan skickades via Peppol men kunde inte markeras som skickad. Använd Markera som skickad för att slutföra bokföringen.", + "peppol_send_failed_title": "Kunde inte skicka via Peppol", + "peppol_send_failed_description": "Fakturan har inte skickats via Peppol.", + "peppol_status_title": "Peppol", + "peppol_status_recipient": "Mottagare", + "peppol_status_label": "Status", + "peppol_status_updated": "Uppdaterad", + "peppol_status_staged": "Förberedd, inte skickad", + "peppol_status_recipient_verified": "Mottagare verifierad", + "peppol_status_submitting": "Skickas", + "peppol_status_retryable_failure": "Tillfälligt fel, försök igen", + "peppol_status_submission_accepted": "Mottagen av Peppol-operatören", + "peppol_status_transport_succeeded": "Levererad till mottagarens operatör", + "peppol_status_recipient_acknowledged": "Bekräftad av mottagaren", + "peppol_status_business_accepted": "Godkänd av mottagaren", + "peppol_status_business_rejected": "Avvisad av mottagaren", + "peppol_status_no_route": "Mottagaren saknar Peppol-registrering", + "peppol_status_failed": "Misslyckades", "pdf_rerender_downloaded_title": "Nyskapad PDF nedladdad", "pdf_rerender_preview_title": "Nyskapad PDF visas", "pdf_preview_blocked_title": "Kunde inte öppna förhandsgranskningen", diff --git a/scripts/peppol/qvalia-probe.ts b/scripts/peppol/qvalia-probe.ts new file mode 100644 index 00000000..d0744c9d --- /dev/null +++ b/scripts/peppol/qvalia-probe.ts @@ -0,0 +1,189 @@ +/** + * Qvalia sandbox probe: the first live contact with the Access Point. + * + * Reads QVALIA_* from the environment (npx dotenv -e .env.local, or export) + * and runs one of: + * + * npx tsx scripts/peppol/qvalia-probe.ts auth + * Exchanges the API key for a JWT (GET /token/{partnerRegNo}) with both + * header schemes, so we learn which one this key accepts. + * npx tsx scripts/peppol/qvalia-probe.ts lookup 0007:5567321707 + * Recipient lookup through the adapter (Qvalia's own id by default). + * npx tsx scripts/peppol/qvalia-probe.ts accounts + * Lists child accounts under the partner (tells us consolidated vs + * multi-tenant as Qvalia set it up). + * npx tsx scripts/peppol/qvalia-probe.ts peppol-ids + * Lists the Peppol identifiers registered on the partner account. + * npx tsx scripts/peppol/qvalia-probe.ts outgoing + * Lists the three latest outgoing invoice statuses (read-only). + * npx tsx scripts/peppol/qvalia-probe.ts send path/to/invoice.xml + * Submits a BIS Billing 3 XML through the adapter. Sandbox only: the + * script refuses a production base URL. + * npx tsx scripts/peppol/qvalia-probe.ts webhook + * Shows the partner webhook subscription (URL, event types, auth type). + * npx tsx scripts/peppol/qvalia-probe.ts webhook-configure https://host/api/webhooks/peppol/qvalia + * Creates or updates the partner webhook subscription for all three + * event types and attaches QVALIA_WEBHOOK_SECRET as the api_key header + * (QVALIA_WEBHOOK_HEADER) that our route verifies. Requires the secret + * in the environment; prints the webhook id. + * + * Nothing here touches Accounted's database. The API key is never printed. + */ + +import { readFileSync } from 'node:fs' +import { + QVALIA_PRODUCTION_BASE_URL, + createQvaliaTransport, + readQvaliaConfigFromEnv, +} from '@/lib/invoices/transports/qvalia' +import { + PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID, + PEPPOL_BIS_BILLING_PROFILE_ID, +} from '@/lib/invoices/peppol-bis-billing' +import { sha256Hex } from '@/lib/invoices/peppol-delivery' + +const [, , command = 'auth', argument] = process.argv + +const config = readQvaliaConfigFromEnv() +if (!config) { + console.error('Set QVALIA_API_KEY, QVALIA_PARTNER_REG_NO and QVALIA_BASE_URL first (see .env.example).') + process.exit(2) +} +const partner = encodeURIComponent(config.partnerRegNo) +const account = encodeURIComponent(config.accountRegNo) + +function headerFor(scheme: 'apikey' | 'raw'): string { + return scheme === 'raw' ? config!.apiKey : `ApiKey ${config!.apiKey}` +} + +async function show(label: string, response: Response): Promise { + const text = await response.text() + let body: unknown = text + try { body = JSON.parse(text) } catch { /* keep text */ } + console.log(`\n== ${label}: HTTP ${response.status}`) + const integrationId = response.headers.get('integrationid') + if (integrationId) console.log(`integrationid header: ${integrationId}`) + console.log(typeof body === 'string' ? body.slice(0, 2000) : JSON.stringify(body, null, 2).slice(0, 4000)) + return body +} + +async function rawGet(path: string, scheme: 'apikey' | 'raw' = config!.authScheme): Promise { + return fetch(`${config!.baseUrl}${path}`, { + headers: { Authorization: headerFor(scheme), accept: 'application/json' }, + }) +} + +async function rawSend(method: 'PUT' | 'POST', path: string, body: unknown): Promise { + return fetch(`${config!.baseUrl}${path}`, { + method, + headers: { + Authorization: headerFor(config!.authScheme), + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }) +} + +async function main(): Promise { + console.log(`Qvalia probe against ${config!.baseUrl} as partner ${config!.partnerRegNo} (account ${config!.accountRegNo})`) + + switch (command) { + case 'auth': { + for (const scheme of ['apikey', 'raw'] as const) { + await show(`GET /token/{partnerRegNo} with Authorization scheme "${scheme}"`, await rawGet(`/token/${partner}`, scheme)) + } + return + } + case 'accounts': { + await show('GET /partner/{p}/account', await rawGet(`/partner/${partner}/account?limit=25`)) + return + } + case 'peppol-ids': { + await show('GET /partner/{p}/account/{a}/peppol', await rawGet(`/partner/${partner}/account/${account}/peppol`)) + return + } + case 'outgoing': { + await show( + 'GET /partner/{p}/transaction/{a}/invoices/outgoing/status', + await rawGet(`/partner/${partner}/transaction/${account}/invoices/outgoing/status?includeRead=true&limit=3`), + ) + return + } + case 'webhook': { + await show('GET /partner/{p}/webhook/configure', await rawGet(`/partner/${partner}/webhook/configure`)) + return + } + case 'webhook-configure': { + if (!argument || !/^https:\/\//.test(argument)) { + throw new Error('webhook-configure expects an https URL, e.g. https://app.accounted.se/api/webhooks/peppol/qvalia') + } + if (!config!.webhookSecret) throw new Error('Set QVALIA_WEBHOOK_SECRET first (openssl rand -hex 32)') + const configured = await show( + 'PUT /partner/{p}/webhook/configure', + await rawSend('PUT', `/partner/${partner}/webhook/configure`, { + url: argument, + types: ['new_document', 'document_delivery', 'document_error'], + }), + ) + const webhookId = configured && typeof configured === 'object' && 'id' in configured + ? String((configured as { id: unknown }).id) + : null + if (!webhookId) throw new Error('Qvalia did not return a webhook id') + await show( + 'POST /partner/{p}/webhook/{id}/auth (api_key header)', + await rawSend('POST', `/partner/${partner}/webhook/${encodeURIComponent(webhookId)}/auth`, { + type: 'api_key', + header: config!.webhookHeader, + value: config!.webhookSecret, + }), + ) + return + } + case 'lookup': { + const peppolId = argument ?? '0007:5567321707' + const [scheme, identifier] = peppolId.split(':') + if (!scheme || !identifier) throw new Error('lookup expects scheme:identifier, e.g. 0007:5567321707') + const transport = createQvaliaTransport(config!) + console.log(JSON.stringify(await transport.lookupRecipient({ scheme, identifier }), null, 2)) + return + } + case 'send': { + if (config!.baseUrl === QVALIA_PRODUCTION_BASE_URL) { + throw new Error('Refusing to send through the probe against production. Use the product flow.') + } + if (!argument) throw new Error('send expects a path to a BIS Billing 3 XML file') + const xml = readFileSync(argument, 'utf8') + const sender = /AccountingSupplierParty[\s\S]*?([^<]+)([^<]+) { + console.error(error instanceof Error ? `${error.name}: ${error.message}` : String(error)) + if (error && typeof error === 'object' && 'detail' in error) { + console.error('detail:', (error as { detail?: unknown }).detail) + } + process.exit(1) +})