* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery
Second Peppol slice (#546). Qvalia confirmed that sending needs no
per-company account, so receiving keeps the consolidated partner account:
each company publishes its 0007:orgnr on our account and inbound documents
are routed by the AccountingCustomerParty endpoint.
- PeppolTransport grows optional receiving methods (registerRecipient,
unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the
Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices /
readcreditnotes, exact XML fetch).
- lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON
(xml2js-style prefixed keys, verified against Qvalia's real inbound test
invoice, kept as a fixture) into a neutral document: parties, payment
means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines,
embedded attachments, credit notes.
- Migration 20260821170000: peppol_registrations (one live row per company
and participant), peppol_inbound_documents (exact XML immutable and
undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a
per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability
and routing.
- POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in
Settings > Fakturering; personnummer-based companies are refused until 0088
GLN exists; sandbox refused.
- GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver.
lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document
(upload_source e_invoice, extractionOwner none), an embedded PDF when
present, and creates the inbox row with the extraction filled from the UBL
(confidence 1, no model pass), matching the supplier by org number. The
existing inbox review/convert flow takes over.
- document-service accepts application/xml for the archive; inbox list shows
a Peppol icon.
Refs #546
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ
* test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables
The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES;
the pg fixture for a deregistered row now carries deregistered_at as the
status-shape constraint requires; the archive insert is an inline literal and
the one generic processing-state updater is accounted for in the ceiling.
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>
* 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>