* 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>
152 lines
4.5 KiB
TypeScript
152 lines
4.5 KiB
TypeScript
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 }
|
|
}
|