* 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>
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
PEPPOL_BIS_BILLING_CUSTOMIZATION_ID,
|
|
PEPPOL_BIS_BILLING_PROFILE_ID,
|
|
type PeppolInvoiceResult,
|
|
} from '@/lib/invoices/peppol-bis-billing'
|
|
import type {
|
|
PeppolDeliveryEvidence,
|
|
PeppolVerifiedEvent,
|
|
} from '@/lib/invoices/peppol-transport'
|
|
|
|
type GeneratedPeppolInvoice = Extract<PeppolInvoiceResult, { ok: true }>
|
|
|
|
export interface PeppolDeliverySummary {
|
|
id: string
|
|
idempotency_key: string
|
|
recipient_scheme: string
|
|
recipient_identifier: string
|
|
xml_sha256: string
|
|
provider: string | null
|
|
provider_submission_id: string | null
|
|
status: string
|
|
status_at: string
|
|
status_detail: string | null
|
|
submitted_at: string | null
|
|
terminal_at: string | null
|
|
evidence_retrieved_at: string | null
|
|
created_at: string
|
|
}
|
|
export interface StagedPeppolDelivery extends PeppolDeliverySummary {
|
|
invoice_id: string
|
|
filename: string
|
|
}
|
|
|
|
export function sha256Hex(value: string | Uint8Array): string {
|
|
return createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
export async function stagePeppolDelivery(args: {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
invoiceId: string
|
|
document: GeneratedPeppolInvoice
|
|
}): Promise<StagedPeppolDelivery> {
|
|
const { data, error } = await args.supabase.rpc('stage_peppol_delivery', {
|
|
p_company_id: args.companyId,
|
|
p_invoice_id: args.invoiceId,
|
|
p_recipient_scheme: args.document.recipient.scheme,
|
|
p_recipient_identifier: args.document.recipient.identifier,
|
|
p_customization_id: PEPPOL_BIS_BILLING_CUSTOMIZATION_ID,
|
|
p_profile_id: PEPPOL_BIS_BILLING_PROFILE_ID,
|
|
p_filename: args.document.filename,
|
|
p_xml_payload: args.document.xml,
|
|
p_xml_sha256: sha256Hex(args.document.xml),
|
|
})
|
|
|
|
if (error) throw error
|
|
if (!data) throw new Error('Failed to stage Peppol delivery: no data returned')
|
|
return data as StagedPeppolDelivery
|
|
}
|
|
|
|
export async function listPeppolDeliverySummaries(args: {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
invoiceId: string
|
|
}): Promise<PeppolDeliverySummary[]> {
|
|
const { data, error } = await args.supabase.rpc('list_peppol_delivery_summaries', {
|
|
p_company_id: args.companyId,
|
|
p_invoice_id: args.invoiceId,
|
|
})
|
|
if (error) {
|
|
throw new Error(`Failed to list Peppol deliveries: ${error.message}`)
|
|
}
|
|
return (data ?? []) as PeppolDeliverySummary[]
|
|
}
|
|
|
|
export async function persistVerifiedPeppolEvent(args: {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
event: PeppolVerifiedEvent
|
|
}): Promise<PeppolDeliverySummary> {
|
|
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,
|
|
p_provider: event.provider,
|
|
p_provider_tenant_id: event.providerTenantId,
|
|
p_provider_submission_id: event.providerSubmissionId,
|
|
p_provider_event_id: event.providerEventId,
|
|
p_provider_event_code: event.eventCode,
|
|
p_normalized_status: event.normalizedStatus,
|
|
p_is_terminal: event.isTerminal,
|
|
p_detail: event.detail,
|
|
p_raw_payload: event.rawPayload,
|
|
p_event_sha256: event.eventSha256,
|
|
p_verification_method: event.verificationMethod,
|
|
p_occurred_at: event.occurredAt,
|
|
})
|
|
if (error || !data) {
|
|
throw new Error(`Failed to record Peppol event: ${error?.message ?? 'unknown error'}`)
|
|
}
|
|
return data as PeppolDeliverySummary
|
|
}
|
|
|
|
export async function persistPeppolEvidence(args: {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
idempotencyKey: string
|
|
evidence: PeppolDeliveryEvidence
|
|
}): Promise<string> {
|
|
const { evidence } = args
|
|
const { data, error } = await args.supabase.rpc('record_peppol_delivery_evidence', {
|
|
p_company_id: args.companyId,
|
|
p_idempotency_key: args.idempotencyKey,
|
|
p_provider: evidence.provider,
|
|
p_evidence_type: evidence.evidenceType,
|
|
p_evidence_payload: evidence.payload,
|
|
p_document_payload: evidence.exactDocument,
|
|
p_document_sha256: evidence.exactDocumentSha256,
|
|
p_evidence_sha256: evidence.evidenceSha256,
|
|
p_retrieved_at: evidence.retrievedAt,
|
|
})
|
|
if (error || !data) {
|
|
throw new Error(`Failed to record Peppol evidence: ${error?.message ?? 'unknown error'}`)
|
|
}
|
|
return data as string
|
|
}
|