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
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerPeppolTransport, type PeppolTransport } from '@/lib/invoices/peppol-transport'
const syncMock = vi.fn()
const deliverMock = vi.fn()
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({ from: vi.fn() }),
}))
vi.mock('@/lib/invoices/peppol-inbound', () => ({
syncInboundPeppolDocuments: (...args: unknown[]) => syncMock(...args),
}))
vi.mock('@/lib/invoices/peppol-inbox-delivery', () => ({
deliverPeppolDocumentToInbox: (...args: unknown[]) => deliverMock(...args),
}))
import { GET } from '../route'
function request(secret: string | null): Request {
return new Request('http://localhost:3000/api/peppol/inbound/cron', {
headers: secret ? { authorization: `Bearer ${secret}` } : {},
})
}
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
return {
provider: 'qvalia',
lookupRecipient: vi.fn(),
submit: vi.fn(),
verifyWebhook: vi.fn(),
retrieveEvidence: vi.fn(),
listInboundDocuments: vi.fn().mockResolvedValue([]),
fetchInboundDocumentXml: vi.fn(),
...overrides,
}
}
describe('GET /api/peppol/inbound/cron', () => {
let unregister: (() => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
process.env.CRON_SECRET = 'cron-secret'
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
syncMock.mockResolvedValue({ listed: 1, archived: 1, duplicates: 0, routed: 0, unrouted: 0, delivered: 1, failed: 0, errors: [] })
})
afterEach(() => {
unregister?.()
unregister = null
delete process.env.PEPPOL_TRANSPORT_PROVIDER
delete process.env.CRON_SECRET
})
it('rejects a call without the cron secret', async () => {
unregister = registerPeppolTransport(makeTransport())
const response = await GET(request(null))
expect(response.status).toBe(401)
expect(syncMock).not.toHaveBeenCalled()
})
it('is a truthful no-op when no access point is switched on', async () => {
delete process.env.PEPPOL_TRANSPORT_PROVIDER
const response = await GET(request('cron-secret'))
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { skipped: true, reason: 'provider_selection_required' } })
expect(syncMock).not.toHaveBeenCalled()
})
it('skips a send-only transport', async () => {
unregister = registerPeppolTransport(makeTransport({ listInboundDocuments: undefined }))
const response = await GET(request('cron-secret'))
expect(await response.json()).toEqual({ data: { skipped: true, reason: 'receiving_unsupported' } })
})
it('runs the sync with the inbox deliverer and reports the summary', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
const response = await GET(request('cron-secret'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toMatchObject({ listed: 1, delivered: 1 })
expect(syncMock).toHaveBeenCalledTimes(1)
const args = syncMock.mock.calls[0][0] as { transport: PeppolTransport; deliver: (d: unknown) => unknown }
expect(args.transport).toBe(transport)
await args.deliver({ row: {}, companyId: 'c', document: {}, xml: null })
expect(deliverMock).toHaveBeenCalledTimes(1)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { ensureInitialized } from '@/lib/init'
import { syncInboundPeppolDocuments } from '@/lib/invoices/peppol-inbound'
import { deliverPeppolDocumentToInbox } from '@/lib/invoices/peppol-inbox-delivery'
import {
getPeppolTransport,
getPeppolTransportAvailability,
} from '@/lib/invoices/peppol-transport'
ensureInitialized()
export const maxDuration = 300
/**
* GET /api/peppol/inbound/cron: every 10 minutes.
*
* Pulls the documents the Access Point received for our registered
* participants, archives the exact XML, routes each to its company and hands
* it to the supplier-invoice inbox. One provider account carries every
* company's identifier, so this is one poll for all of them; a document
* nobody is registered for is kept as `unrouted`, never dropped.
*
* Truthful no-op when no access point is switched on in this environment.
*/
export const GET = withCronContext('cron.peppol_inbound', async (_request, ctx) => {
const availability = getPeppolTransportAvailability()
const transport = availability.available ? getPeppolTransport(availability.provider) : null
if (!transport) {
return NextResponse.json({ data: { skipped: true, reason: availability.available ? 'provider_adapter_unavailable' : availability.reason } })
}
if (!transport.listInboundDocuments) {
return NextResponse.json({ data: { skipped: true, reason: 'receiving_unsupported' } })
}
const service = createServiceClientNoCookies()
const summary = await syncInboundPeppolDocuments({
service,
transport,
deliver: (delivery) => deliverPeppolDocumentToInbox(service, delivery),
log: ctx.log,
})
ctx.log.info('peppol inbound sync complete', { ...summary, errors: summary.errors.length })
return NextResponse.json({ data: summary })
})
export const POST = GET
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase } from '@/tests/helpers'
import { registerPeppolTransport, type PeppolTransport } from '@/lib/invoices/peppol-transport'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const service = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => service.supabase,
}))
import { DELETE, GET, POST } from '../route'
const user = { id: 'user-1', email: 'owner@example.test' }
const registeredRow = {
id: 'reg-1',
company_id: 'company-1',
user_id: 'user-1',
provider: 'qvalia',
provider_account_reference: 'SE5595386219',
participant_scheme: '0007',
participant_identifier: '5595386219',
status: 'registered',
business_card: {},
document_types: [],
registered_at: '2026-08-21T16:00:00.000Z',
deregistered_at: null,
last_error: null,
created_at: '2026-08-21T15:59:00.000Z',
updated_at: '2026-08-21T16:00:00.000Z',
}
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
return {
provider: 'qvalia',
lookupRecipient: vi.fn(),
submit: vi.fn(),
verifyWebhook: vi.fn(),
retrieveEvidence: vi.fn(),
registerRecipient: vi.fn().mockResolvedValue({
status: 'registered',
participant: { scheme: '0007', identifier: '5595386219' },
providerAccountReference: 'SE5595386219',
raw: {},
}),
unregisterRecipient: vi.fn().mockResolvedValue(undefined),
...overrides,
}
}
describe('/api/settings/peppol', () => {
let unregister: (() => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
reset()
service.reset()
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
})
afterEach(() => {
unregister?.()
unregister = null
delete process.env.PEPPOL_TRANSPORT_PROVIDER
})
it('GET returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(createMockRequest('/api/settings/peppol'))
expect(response.status).toBe(401)
})
it('GET tells the truth when no access point is switched on', async () => {
delete process.env.PEPPOL_TRANSPORT_PROVIDER
const response = await GET(createMockRequest('/api/settings/peppol'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toMatchObject({
transport: { available: false },
receiving_supported: false,
registration: null,
})
})
it('GET returns the live registration when the adapter supports receiving', async () => {
unregister = registerPeppolTransport(makeTransport())
enqueue({ data: [registeredRow], error: null })
const response = await GET(createMockRequest('/api/settings/peppol'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.receiving_supported).toBe(true)
expect(body.data.registration).toMatchObject({ status: 'registered', participant_identifier: '5595386219' })
expect(body.data.registration).not.toHaveProperty('business_card')
})
it('POST refuses without a transport and in the sandbox', async () => {
delete process.env.PEPPOL_TRANSPORT_PROVIDER
expect((await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))).status).toBe(503)
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
unregister = registerPeppolTransport(makeTransport())
enqueue({ data: { is_sandbox: true }, error: null })
const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))
expect(response.status).toBe(403)
expect((await response.json()).error.code).toBe('PEPPOL_SANDBOX_NOT_ALLOWED')
})
it('POST registers the company and returns the minimized registration', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
enqueue({ data: { is_sandbox: false }, error: null })
enqueue({ data: { org_number: '559538-6219', company_name: 'Arcim Technology AB', vat_number: 'SE559538621901', city: 'Stockholm', country: 'SE' }, error: null })
service.enqueue({ data: [], error: null }) // existing
service.enqueue({ data: { id: 'reg-1' }, error: null }) // insert pending
service.enqueue({ data: registeredRow, error: null }) // finalize
const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.data.registration).toMatchObject({ status: 'registered', participant_scheme: '0007' })
expect(transport.registerRecipient).toHaveBeenCalledTimes(1)
})
it('POST maps a personnummer-based company to a 422 with the reason', async () => {
unregister = registerPeppolTransport(makeTransport())
enqueue({ data: { is_sandbox: false }, error: null })
enqueue({ data: { org_number: '800101-1234', company_name: 'Firma', vat_number: null, city: null, country: 'SE' }, error: null })
const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))
expect(response.status).toBe(422)
expect((await response.json()).error.code).toBe('PEPPOL_REGISTRATION_PERSONAL_NUMBER')
})
it('DELETE withdraws the identifier and 404s when nothing is live', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
service.enqueue({ data: [registeredRow], error: null })
service.enqueue({ data: { ...registeredRow, status: 'deregistered', deregistered_at: '2026-08-21T17:00:00.000Z' }, error: null })
const ok = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' }))
expect(ok.status).toBe(200)
expect((await ok.json()).data.registration.status).toBe('deregistered')
expect(transport.unregisterRecipient).toHaveBeenCalledWith({ scheme: '0007', identifier: '5595386219' })
service.enqueue({ data: [], error: null })
const missing = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' }))
expect(missing.status).toBe(404)
})
})
+141
View File
@@ -0,0 +1,141 @@
import { NextResponse } from 'next/server'
import { privateNoStore } from '@/lib/api/private-no-store'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { ensureInitialized } from '@/lib/init'
import {
deregisterCompanyFromPeppolReceiving,
getPeppolRegistration,
registerCompanyForPeppolReceiving,
type PeppolRegistrationRow,
} from '@/lib/invoices/peppol-registration'
import {
getPeppolTransport,
getPeppolTransportAvailability,
type PeppolTransport,
} from '@/lib/invoices/peppol-transport'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import { createServiceClient } from '@/lib/supabase/server'
import type { CompanySettings } from '@/types'
ensureInitialized()
function registrationPayload(row: PeppolRegistrationRow | null) {
if (!row) return null
return {
id: row.id,
provider: row.provider,
participant_scheme: row.participant_scheme,
participant_identifier: row.participant_identifier,
status: row.status,
registered_at: row.registered_at,
deregistered_at: row.deregistered_at,
last_error: row.last_error,
updated_at: row.updated_at,
}
}
function resolveTransport(): { transport: PeppolTransport; provider: string } | null {
const availability = getPeppolTransportAvailability()
if (!availability.available) return null
const transport = getPeppolTransport(availability.provider)
return transport ? { transport, provider: availability.provider } : null
}
/** GET /api/settings/peppol: receiving status for the active company. */
export const GET = withRouteContext(
'settings.peppol.get',
async (_request, { supabase, companyId, log, requestId }) => {
const availability = getPeppolTransportAvailability()
const resolved = resolveTransport()
try {
const registration = resolved
? await getPeppolRegistration({ supabase, companyId, provider: resolved.provider })
: null
return privateNoStore(NextResponse.json({
data: {
transport: availability,
receiving_supported: !!resolved?.transport.registerRecipient,
registration: registrationPayload(registration),
},
}))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
)
/** POST /api/settings/peppol: publish the company's Peppol identifier for receiving. */
export const POST = withRouteContext(
'settings.peppol.register',
async (_request, { supabase, companyId, user, log, requestId }) => {
const resolved = resolveTransport()
if (!resolved) {
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, { requestId }))
}
if (await isSandboxCompany(supabase, companyId)) {
return privateNoStore(errorResponseFromCode('PEPPOL_SANDBOX_NOT_ALLOWED', log, { requestId }))
}
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('org_number, company_name, vat_number, city, country')
.eq('company_id', companyId)
.single()
if (settingsError || !settings) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId }))
}
try {
const result = await registerCompanyForPeppolReceiving({
service: createServiceClient(),
companyId,
userId: user.id,
transport: resolved.transport,
settings: settings as Pick<CompanySettings, 'org_number' | 'company_name' | 'vat_number' | 'city' | 'country'>,
})
if (!result.ok) {
return privateNoStore(errorResponseFromCode(result.code, log, {
requestId,
...('detail' in result && result.detail ? { details: { reason: result.detail } } : {}),
}))
}
return privateNoStore(NextResponse.json({
data: { registration: registrationPayload(result.registration) },
}, { status: 201 }))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)
/** DELETE /api/settings/peppol: withdraw the identifier from the Access Point. */
export const DELETE = withRouteContext(
'settings.peppol.deregister',
async (_request, { companyId, log, requestId }) => {
const resolved = resolveTransport()
if (!resolved) {
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, { requestId }))
}
try {
const result = await deregisterCompanyFromPeppolReceiving({
service: createServiceClient(),
companyId,
transport: resolved.transport,
})
if (!result.ok) {
return privateNoStore(errorResponseFromCode(result.code, log, {
requestId,
...('detail' in result && result.detail ? { details: { reason: result.detail } } : {}),
}))
}
return privateNoStore(NextResponse.json({
data: { registration: registrationPayload(result.registration) },
}))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)