feat(peppol): Qvalia access-point adapter, send flow and delivery webhook (#1780)
* feat(peppol): Qvalia access-point adapter, send flow and delivery webhook Qvalia is the contracted Peppol Access Point (signed 2026-08-21). This fills the provider-neutral PeppolTransport seam from #1595 with a real adapter and turns the disabled "Skicka via Peppol" menu item into a working send flow. Adapter (lib/invoices/transports/qvalia.ts): partner-scoped recipient lookup, XML submission to /invoices/outgoing with integrationId correlation, 409 recovery only when the stored copy carries the same seller endpoint, tolerant mapping of Qvalia's free-text webhook statuses onto the 11-state lifecycle, constant-time shared-secret webhook verification (Qvalia does not sign webhooks), and evidence retrieval of the message-log status plus Qvalia's stored XML copy. Registered from the environment in lib/init.ts; switched on per deployment with PEPPOL_TRANSPORT_PROVIDER=qvalia. POST /api/invoices/[id]/peppol/send: stage the exact XML, look up the recipient, record recipient_verified and submitting, submit, record submission_accepted, then issue a draft with the mark-sent semantics (issueAndBookInvoice) only after the network accepted it. A sync rejection is a terminal failed event so the identical document is never re-sent; an operational failure is retryable; an already-submitted XML replays idempotently. POST /api/webhooks/peppol/qvalia resolves the delivery by integrationId, persists the verified event via the service-role RPC and stores evidence best-effort; unknown submissions answer 200, our own persistence failures 500. UI: the send item is availability-driven with a confirm dialog, the invoice page shows the latest Peppol status, and drafts can be sent (the number is assigned server-side). Probe script for the first sandbox contact under scripts/peppol/qvalia-probe.ts. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): Qvalia sandbox facts from first live contact: bare-key auth, api-test host, SMP-URL document types The onboarding mail and a live probe against the sandbox (partner SE5595386219) corrected three assumptions from the public docs: the key is accepted bare in the Authorization header (the ApiKey prefix answers 401), the sandbox host is api-test.qvalia.com, and the recipient lookup returns document types as SMP service URLs, so capabilities are now normalized to bare Peppol document type ids before comparison. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * feat(peppol): probe commands to inspect and configure the Qvalia webhook subscription Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): decode UBL entities in one pass (CodeQL js/double-escaping) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
8249fcab5e
commit
05c3c6ebd9
@@ -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<InvoiceStatus>(['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<PeppolDeliveryView[]>([])
|
||||
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 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void downloadPeppolXml()} disabled={isDownloadingPeppol}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void downloadPeppolXml()}
|
||||
disabled={isDownloadingPeppol || !invoice.invoice_number}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('download_peppol_xml')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void preparePeppolDelivery()}
|
||||
disabled={isPreparingPeppol || !canWrite}
|
||||
disabled={isPreparingPeppol || !canWrite || !invoice.invoice_number}
|
||||
>
|
||||
<FileCheck2 className="h-4 w-4" />
|
||||
{t('prepare_peppol_delivery')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled className="items-start">
|
||||
<Send className="mt-0.5 h-4 w-4" />
|
||||
<span className="min-w-0">
|
||||
<span className="block">{t('send_via_peppol')}</span>
|
||||
<span className="block text-[11px] leading-snug text-muted-foreground">
|
||||
{t('peppol_provider_required')}
|
||||
{peppolTransportAvailable ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setShowPeppolSendDialog(true)}
|
||||
disabled={isSendingPeppol || !canWrite || !canSendPeppol}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{t('send_via_peppol')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled className="items-start">
|
||||
<Send className="mt-0.5 h-4 w-4" />
|
||||
<span className="min-w-0">
|
||||
<span className="block">{t('send_via_peppol')}</span>
|
||||
<span className="block text-[11px] leading-snug text-muted-foreground">
|
||||
{t('peppol_provider_required')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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 && (
|
||||
<DetailSection kicker={t('peppol_status_title')}>
|
||||
<DefRow label={t('peppol_status_recipient')}>
|
||||
<span className="tabular-nums">
|
||||
{latestPeppolDelivery.recipient_scheme}:{latestPeppolDelivery.recipient_identifier}
|
||||
</span>
|
||||
</DefRow>
|
||||
<DefRow label={t('peppol_status_label')}>
|
||||
<span>{peppolStatusLabel(latestPeppolDelivery.status)}</span>
|
||||
{latestPeppolDelivery.status_detail && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{latestPeppolDelivery.status_detail}
|
||||
</span>
|
||||
)}
|
||||
</DefRow>
|
||||
<DefRow label={t('peppol_status_updated')}>
|
||||
<span className="tabular-nums">{formatDate(latestPeppolDelivery.status_at)}</span>
|
||||
</DefRow>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{isRealInvoice && !isSelfBilled && deliveriesUnreadable && (
|
||||
<DetailSection kicker={t('delivery_history_title')}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -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). */}
|
||||
<Dialog open={showPeppolSendDialog} onOpenChange={setShowPeppolSendDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('peppol_send_confirm_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('peppol_send_confirm_description', { recipient: peppolRecipientLabel })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPeppolSendDialog(false)}
|
||||
disabled={isSendingPeppol}
|
||||
>
|
||||
{t('delete_dialog_cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void sendViaPeppol()} disabled={isSendingPeppol}>
|
||||
{isSendingPeppol && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSendingPeppol ? t('peppol_sending') : t('peppol_send_confirm_action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user