feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery (#1789)

* 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>
This commit is contained in:
Jakob Wennberg
2026-08-21 16:56:32 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 99a872987e
commit f93152c397
33 changed files with 3791 additions and 7 deletions
@@ -43,6 +43,7 @@ import {
ChevronRight,
Sparkles,
Maximize2,
Globe,
} from 'lucide-react'
import Link from 'next/link'
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
@@ -59,7 +60,7 @@ import { copyInboxAddress, type AddressCopyState } from '@/components/extensions
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
import { renderChannelParticipant } from '@/lib/documents/channel-context-notes'
import { selectInboxFields } from '@/lib/documents/inbox-field-visibility'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
@@ -145,7 +146,7 @@ interface InboxItem {
// instant receipt ack) but the deferred AI extraction has not landed;
// extracted_data is null until the realtime flip to 'received'.
status: 'received' | 'processing' | 'error'
source: 'email' | 'upload' | 'whatsapp'
source: InboxItemSource
created_at: string
email_from: string | null
email_subject: string | null
@@ -2140,6 +2141,9 @@ function InboxRow({
<Mail className="h-3 w-3 text-muted-foreground shrink-0" />
) : item.source === 'whatsapp' ? (
<WhatsAppMark className="h-3 w-3 shrink-0" />
) : item.source === 'peppol' ? (
// Received as a structured e-invoice over the Peppol network.
<Globe className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Peppol" />
) : item.channel_context?.mail_provider === 'gmail' ? (
// The hunt records which mailbox it pulled a receipt from, so the
// brand is known rather than guessed. Mail that arrived by
@@ -0,0 +1,133 @@
'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useLocale } from 'next-intl'
interface PeppolRegistrationView {
participant_scheme: string
participant_identifier: string
status: 'pending' | 'registered' | 'failed' | 'deregistered'
registered_at: string | null
last_error: string | null
}
interface PeppolSettingsPayload {
transport: { available: boolean }
receiving_supported: boolean
registration: PeppolRegistrationView | null
}
/**
* Receiving e-invoices via Peppol: publishes the company's 0007:orgnr through
* the contracted Access Point. One switch, the truth about its state next to
* it. Sending needs no registration, so this row is only about receiving.
*/
export function PeppolReceiveSettings() {
const t = useTranslations('settings_peppol')
const locale = useLocale()
const { toast } = useToast()
const canWrite = useCanWrite()
const [state, setState] = useState<PeppolSettingsPayload | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const load = useCallback(async () => {
try {
const response = await fetch('/api/settings/peppol')
if (!response.ok) throw new Error()
const payload = (await response.json()) as { data?: PeppolSettingsPayload }
if (!payload.data) throw new Error()
setState(payload.data)
setLoadFailed(false)
} catch {
setLoadFailed(true)
}
}, [])
useEffect(() => {
void load()
}, [load])
const registration = state?.registration ?? null
const isOn = registration?.status === 'registered' || registration?.status === 'pending'
const available = !!state?.transport.available && !!state?.receiving_supported
const toggle = useCallback(async (next: boolean) => {
setIsSaving(true)
try {
const response = await fetch('/api/settings/peppol', { method: next ? 'POST' : 'DELETE' })
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
if (!response.ok) throw body?.error ?? new Error()
toast({
title: next ? t('toast_registered_title') : t('toast_deregistered_title'),
description: next ? t('toast_registered_description') : t('toast_deregistered_description'),
})
await load()
} catch (error) {
toast({
title: t('toast_failed_title'),
description: getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' }),
variant: 'destructive',
})
await load()
} finally {
setIsSaving(false)
}
}, [load, locale, t, toast])
const statusLabel = (() => {
if (!registration || registration.status === 'deregistered') return t('status_off')
return t(`status_${registration.status}`)
})()
return (
<SettingsGroup label={t('heading')}>
<SettingsRow label={t('enable_label')} help={t('enable_help')}>
<SettingsRowEnd>
<Switch
checked={isOn}
onCheckedChange={(value) => void toggle(value)}
disabled={isSaving || !canWrite || !available || state === null}
aria-label={t('enable_label')}
/>
</SettingsRowEnd>
</SettingsRow>
<SettingsRow label={t('status_label')} borderless>
<div className="min-w-0 space-y-1 text-sm">
{loadFailed ? (
<SettingsRowNote>{t('load_failed')}</SettingsRowNote>
) : state === null ? (
<SettingsRowNote>{t('loading')}</SettingsRowNote>
) : !available ? (
<SettingsRowNote>{t('provider_required')}</SettingsRowNote>
) : (
<>
<span>{statusLabel}</span>
{registration && registration.status !== 'deregistered' && (
<SettingsRowNote className="block tabular-nums">
{t('peppol_id_label')} {registration.participant_scheme}:{registration.participant_identifier}
</SettingsRowNote>
)}
{registration?.status === 'failed' && registration.last_error && (
<SettingsRowNote className="block">{registration.last_error}</SettingsRowNote>
)}
</>
)}
</div>
</SettingsRow>
</SettingsGroup>
)
}
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
import { PeppolReceiveSettings } from '@/components/settings/PeppolReceiveSettings'
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings'
import { InvoiceEmailRecipientsSettings } from '@/components/settings/InvoiceEmailRecipientsSettings'
@@ -65,6 +66,9 @@ export function InvoicingSettingsContent() {
{/* Payment link opt-in: saves individually via toggle switch */}
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
{/* Peppol receiving: one switch that publishes the company's Peppol id */}
<PeppolReceiveSettings />
{/* PDF settings: saves individually via toggle switches */}
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />