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
@@ -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<PeppolInvoiceResult, { ok: true }>
|
||||
|
||||
export type PeppolInvoiceRecord = Invoice & { customer?: Customer | null; items?: InvoiceItem[] | null }
|
||||
|
||||
type RouteLog = Parameters<typeof errorResponseFromCode>[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<LoadPeppolRecordsResult> {
|
||||
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<LoadPeppolDocumentResult> {
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user