feat(peppol): poll outbound delivery status + cap receiving registrations (#1793)

* feat(peppol): poll outbound delivery status from the access point

Qvalia's webhook API answers 404 on its production host (the sandbox answers
204), so without this the prod lifecycle would stop at submission_accepted.
The transport gains pollDeliveryStatus(); the Qvalia adapter reads
/invoices/outgoing/status and maps the message-log status through the same
tolerant mapping as a document_delivery webhook, with the same dedupe key, so
a later webhook for the same transition is a harmless duplicate. A cron four
times an hour walks the open deliveries of the last 45 days, records the
answer through the append-only lifecycle RPC and fetches evidence once a
delivery reaches transport or a terminal state. Kept as the safety net for a
missed webhook once Qvalia ships them to prod.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* feat(peppol): cap receiving registrations at the contracted tenant count

The Qvalia partner contract is priced per tenant (10 to start), so the
registration refuses the next company with PEPPOL_REGISTRATION_CAP_REACHED
once PEPPOL_RECEIVING_MAX_REGISTRATIONS live registrations exist, instead of
silently exceeding the contract. A company that already holds a live row is
never counted twice; unset means no cap (own provider account).

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 17:06:57 +02:00
committed by GitHub
parent a2c9a12cfc
commit 9ef7de861f
15 changed files with 576 additions and 48 deletions
+4
View File
@@ -115,6 +115,10 @@ RECEIPT_HUNT_COMPANY_IDS=
# the Authorization header (what the sandbox accepts); QVALIA_AUTH_SCHEME=apikey
# switches to the "ApiKey <key>" form from the newest docs.
# PEPPOL_TRANSPORT_PROVIDER=qvalia
# Cap on companies that may publish a receiving identifier through the
# provider account (the Qvalia partner contract is priced per tenant, 10 to
# start). Unset = no cap.
# PEPPOL_RECEIVING_MAX_REGISTRATIONS=10
# QVALIA_API_KEY=
# QVALIA_PARTNER_REG_NO=
# QVALIA_ACCOUNT_REG_NO=
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerPeppolTransport, type PeppolTransport } from '@/lib/invoices/peppol-transport'
const pollMock = vi.fn()
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({ from: vi.fn() }),
}))
vi.mock('@/lib/invoices/peppol-delivery-sync', () => ({
pollOpenPeppolDeliveries: (...args: unknown[]) => pollMock(...args),
}))
import { GET } from '../route'
function request(secret: string | null): Request {
return new Request('http://localhost:3000/api/peppol/outbound/status/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(),
pollDeliveryStatus: vi.fn().mockResolvedValue([]),
...overrides,
}
}
describe('GET /api/peppol/outbound/status/cron', () => {
let unregister: (() => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
process.env.CRON_SECRET = 'cron-secret'
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
pollMock.mockResolvedValue({ polled: 2, advanced: 1, unchanged: 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())
expect((await GET(request(null))).status).toBe(401)
expect(pollMock).not.toHaveBeenCalled()
})
it('is a truthful no-op without an access point or without polling support', async () => {
delete process.env.PEPPOL_TRANSPORT_PROVIDER
expect(await (await GET(request('cron-secret'))).json()).toEqual({ data: { skipped: true, reason: 'provider_selection_required' } })
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
unregister = registerPeppolTransport(makeTransport({ pollDeliveryStatus: undefined }))
expect(await (await GET(request('cron-secret'))).json()).toEqual({ data: { skipped: true, reason: 'polling_unsupported' } })
})
it('polls the open deliveries and reports the summary', async () => {
const transport = makeTransport()
unregister = registerPeppolTransport(transport)
const response = await GET(request('cron-secret'))
expect(response.status).toBe(200)
expect((await response.json()).data).toMatchObject({ polled: 2, advanced: 1 })
expect((pollMock.mock.calls[0][0] as { transport: PeppolTransport }).transport).toBe(transport)
})
})
@@ -0,0 +1,42 @@
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 { pollOpenPeppolDeliveries } from '@/lib/invoices/peppol-delivery-sync'
import {
getPeppolTransport,
getPeppolTransportAvailability,
} from '@/lib/invoices/peppol-transport'
ensureInitialized()
export const maxDuration = 300
/**
* GET /api/peppol/outbound/status/cron: four times an hour.
*
* Asks the Access Point about every open outbound delivery and records what
* it says through the same append-only lifecycle a webhook uses. Needed
* because Qvalia's webhooks are not available on its production host yet, and
* kept afterwards as the safety net for a missed webhook.
*/
export const GET = withCronContext('cron.peppol_outbound_status', 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.pollDeliveryStatus) {
return NextResponse.json({ data: { skipped: true, reason: 'polling_unsupported' } })
}
const summary = await pollOpenPeppolDeliveries({
service: createServiceClientNoCookies(),
transport,
log: ctx.log,
})
ctx.log.info('peppol outbound status poll complete', { ...summary, errors: summary.errors.length })
return NextResponse.json({ data: summary })
})
export const POST = GET
+25 -24
View File
@@ -23,27 +23,28 @@
# through SCHEDULE_OVERRIDES in scripts/generate-crontabs.ts, never by
# editing this file.
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
30 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/settings/booking-templates/sync/cron
0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/cloud-backup/auto-sync/cron
30 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/idempotency/cleanup/cron
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
*/15 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/agi/kvittenser/cron
30 */2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/vat/kvittenser/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/webhooks/dispatch/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
30 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/settings/booking-templates/sync/cron
0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/cloud-backup/auto-sync/cron
30 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/idempotency/cleanup/cron
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
*/15 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/agi/kvittenser/cron
30 */2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/vat/kvittenser/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/webhooks/dispatch/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
+25 -24
View File
@@ -23,27 +23,28 @@
# through SCHEDULE_OVERRIDES in scripts/generate-crontabs.ts, never by
# editing this file.
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
30 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/settings/booking-templates/sync/cron
0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/cloud-backup/auto-sync/cron
30 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/idempotency/cleanup/cron
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
*/15 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/agi/kvittenser/cron
30 */2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/vat/kvittenser/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/webhooks/dispatch/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
30 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/settings/booking-templates/sync/cron
0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/cloud-backup/auto-sync/cron
30 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/idempotency/cleanup/cron
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
*/15 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/agi/kvittenser/cron
30 */2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/vat/kvittenser/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/webhooks/dispatch/cron
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
+1
View File
@@ -100,6 +100,7 @@ v1 uses Qvalia's consolidated setup (every company's documents under Accounted's
- `peppol_registrations`: one live row per company and per participant; written by `POST/DELETE /api/settings/peppol` (service role after the membership check) through `lib/invoices/peppol-registration.ts`, which publishes `0007:orgnr` with the company's business card and the BIS Billing 3 Invoice + CreditNote document types via `transport.registerRecipient()`. Personnummer-based identifiers are refused (`0088` GLN pending). The switch lives in Settings > Fakturering ("E-faktura via Peppol").
- `peppol_inbound_documents`: every document the Access Point hands us, with the exact XML (immutable, undeletable) and the provider's UBL-JSON; routed to a company by the `AccountingCustomerParty` endpoint through the registrations; states `received`, `routed`, `unrouted`, `converted`, `ignored`, `failed`.
- `GET /api/peppol/inbound/cron` every 10 minutes: `lib/invoices/peppol-inbound.ts` lists unread invoices and credit notes, archives (`archiveInboundPeppolMessage`), routes and delivers; `lib/invoices/peppol-inbox-delivery.ts` archives the XML as a WORM document (`upload_source: 'e_invoice'`, no AI extraction), the embedded PDF when present, and creates the `invoice_inbox_items` row (`source: 'peppol'`) with the extraction filled from the structured UBL (`lib/invoices/peppol-inbound-ubl.ts`, confidence 1). The existing inbox review and convert flows take over from there.
- Outbound status without webhooks: `GET /api/peppol/outbound/status/cron` (four times an hour) asks the Access Point about every open delivery (`transport.pollDeliveryStatus`, Qvalia: `/invoices/outgoing/status`) and records the answer through the same lifecycle RPC a webhook uses, with the same dedupe key, so a later webhook for the same transition is a harmless duplicate. Needed because Qvalia's webhook API answers 404 on its production host (2026-08-21); kept as the safety net afterwards.
- Still open: the Qvalia `new_document` webhook for inbound (today polled), credit-note conversion from the inbox, `0088` GLN for enskild firma, the release-pinned validation stack, and a UI surface for `unrouted` documents.
### Storecove versus Qvalia (historical, pre-contract)
+5
View File
@@ -1330,6 +1330,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Bolaget är inte registrerat för Peppol-mottagning.',
message_en: 'The company is not registered for Peppol receiving.',
},
PEPPOL_REGISTRATION_CAP_REACHED: {
httpStatus: 409,
message_sv: 'Alla platser för Peppol-mottagning är upptagna just nu. Hör av dig till support så öppnar vi fler. Att skicka e-fakturor fungerar ändå.',
message_en: 'All Peppol receiving slots are taken right now. Contact support and we will open more. Sending e-invoices works regardless.',
},
}
const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { createLogger } from '@/lib/logger'
import { pollOpenPeppolDeliveries } from '@/lib/invoices/peppol-delivery-sync'
import type { PeppolTransport, PeppolVerifiedEvent } from '@/lib/invoices/peppol-transport'
const { supabase: mockService, enqueue, reset } = createQueuedMockSupabase()
const service = mockService as unknown as SupabaseClient
const log = createLogger('test')
/** The queued mock records rpc() invocations on the vi.fn itself. */
const rpcCalls = () => (mockService.rpc as unknown as { mock: { calls: unknown[][] } }).mock.calls
const openDelivery = {
id: 'delivery-1',
company_id: 'company-1',
idempotency_key: '33333333-3333-4333-8333-333333333333',
provider_submission_id: 'int-1',
status: 'submission_accepted',
status_at: '2026-08-21T10:00:00.000Z',
submitted_at: '2026-08-21T10:00:00.000Z',
evidence_retrieved_at: null,
}
function event(status: PeppolVerifiedEvent['normalizedStatus'], terminal = false): PeppolVerifiedEvent {
return {
provider: 'qvalia',
providerTenantId: 'SE5595386219',
providerSubmissionId: 'int-1',
providerEventId: `document_delivery:int-1:${status}`,
idempotencyKey: null,
eventCode: 'status_poll',
normalizedStatus: status,
isTerminal: terminal,
detail: status,
occurredAt: '2026-08-21T11:00:00.000Z',
rawPayload: {},
eventSha256: 'b'.repeat(64),
verificationMethod: 'provider_poll',
}
}
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
return {
provider: 'qvalia',
lookupRecipient: vi.fn(),
submit: vi.fn(),
verifyWebhook: vi.fn(),
retrieveEvidence: vi.fn().mockResolvedValue([{
provider: 'qvalia',
evidenceType: 'qvalia_message_record',
payload: {},
exactDocument: null,
exactDocumentSha256: null,
evidenceSha256: 'c'.repeat(64),
retrievedAt: '2026-08-21T11:00:01.000Z',
}]),
pollDeliveryStatus: vi.fn().mockResolvedValue([event('transport_succeeded')]),
...overrides,
}
}
describe('pollOpenPeppolDeliveries', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('records the provider status through the lifecycle RPC with the delivery identity and fetches evidence', async () => {
const transport = makeTransport()
enqueue({ data: [openDelivery], error: null }) // open deliveries
enqueue({ data: { ...openDelivery, status: 'transport_succeeded', status_at: '2026-08-21T11:00:00.000Z' }, error: null }) // event rpc
enqueue({ data: 'evidence-1', error: null }) // evidence rpc
const result = await pollOpenPeppolDeliveries({ service, transport, log })
expect(result).toMatchObject({ polled: 1, advanced: 1, unchanged: 0, failed: 0 })
const eventCall = rpcCalls().find((c) => c[0] === 'record_peppol_delivery_event')
expect(eventCall?.[1]).toMatchObject({
p_company_id: 'company-1',
p_idempotency_key: '33333333-3333-4333-8333-333333333333',
p_provider_submission_id: 'int-1',
p_provider_event_id: 'document_delivery:int-1:transport_succeeded',
p_normalized_status: 'transport_succeeded',
p_verification_method: 'provider_poll',
})
expect(transport.retrieveEvidence).toHaveBeenCalledWith('int-1')
const evidenceCall = rpcCalls().find((c) => c[0] === 'record_peppol_delivery_evidence')
expect(evidenceCall?.[1]).toMatchObject({ p_company_id: 'company-1', p_evidence_type: 'qvalia_message_record' })
})
it('counts a delivery the provider has nothing new about as unchanged', async () => {
const transport = makeTransport({ pollDeliveryStatus: vi.fn().mockResolvedValue([]) })
enqueue({ data: [openDelivery], error: null })
const result = await pollOpenPeppolDeliveries({ service, transport, log })
expect(result).toMatchObject({ polled: 1, advanced: 0, unchanged: 1 })
expect(rpcCalls()).toHaveLength(0)
})
it('isolates a failing poll and keeps the pass going', async () => {
const transport = makeTransport({
pollDeliveryStatus: vi.fn()
.mockRejectedValueOnce(new Error('Qvalia answered 503'))
.mockResolvedValueOnce([]),
})
enqueue({ data: [openDelivery, { ...openDelivery, id: 'delivery-2', provider_submission_id: 'int-2' }], error: null })
const result = await pollOpenPeppolDeliveries({ service, transport, log })
expect(result).toMatchObject({ polled: 2, failed: 1, unchanged: 1 })
expect(result.errors).toEqual([{ providerSubmissionId: 'int-1', reason: 'Qvalia answered 503' }])
})
it('is a no-op for a transport without polling', async () => {
const result = await pollOpenPeppolDeliveries({ service, transport: makeTransport({ pollDeliveryStatus: undefined }), log })
expect(result.polled).toBe(0)
expect(rpcCalls()).toHaveLength(0)
})
})
@@ -539,3 +539,39 @@ describe('Qvalia transport: receiving side', () => {
expect(await transport.fetchInboundDocumentXml!('in-2', 'Invoice')).toBeNull()
})
})
describe('Qvalia transport: pollDeliveryStatus', () => {
const fetchMock = vi.fn<typeof fetch>()
const transport = createQvaliaTransport(config, { fetch: fetchMock, now: () => new Date('2026-08-21T12:00:00.000Z') })
beforeEach(() => {
fetchMock.mockReset()
})
it('turns a message-log status into the same event a webhook would carry', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
status: 'success',
data: [{ uuid: 'int-1', readAt: null, metadata: { status: 'processed', updatedAt: '2026-08-21T11:59:00.000Z' } }],
}))
const [event] = await transport.pollDeliveryStatus!('int-1')
expect(String(fetchMock.mock.calls[0][0])).toContain('/invoices/outgoing/status?integrationId=int-1&includeRead=true&limit=1')
expect(event).toMatchObject({
provider: 'qvalia',
providerSubmissionId: 'int-1',
providerEventId: 'document_delivery:int-1:processed',
idempotencyKey: null,
eventCode: 'status_poll',
normalizedStatus: 'transport_succeeded',
isTerminal: false,
occurredAt: '2026-08-21T11:59:00.000Z',
verificationMethod: 'provider_poll',
})
})
it('yields nothing when the provider has no status yet or answers 204', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: 'success', data: [{ uuid: 'int-1', readAt: null, metadata: {} }] }))
expect(await transport.pollDeliveryStatus!('int-1')).toEqual([])
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))
expect(await transport.pollDeliveryStatus!('int-1')).toEqual([])
})
})
@@ -136,6 +136,33 @@ describe('registerCompanyForPeppolReceiving', () => {
expect(failed?.args[0]).toMatchObject({ status: 'failed', last_error: 'Qvalia answered 500: smp down' })
})
it('refuses the registration past the contracted cap, and lets an already-registered company through', async () => {
process.env.PEPPOL_RECEIVING_MAX_REGISTRATIONS = '10'
try {
const transport = makeTransport()
enqueue({ data: [], error: null }) // no registration for this company
enqueue({ data: null, error: null, count: 10 }) // live count at the cap
expect(await registerCompanyForPeppolReceiving({
service, companyId: 'company-1', userId: 'user-1', transport, settings,
})).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_CAP_REACHED' })
expect(transport.registerRecipient).not.toHaveBeenCalled()
// A company that already holds a live row re-registers without consuming a slot.
reset()
enqueue({ data: [{ ...registeredRow, status: 'failed', registered_at: null }], error: null })
enqueue({ data: registeredRow, error: null })
// failed is not live, so the cap applies: count below cap lets it in
reset()
enqueue({ data: [registeredRow], error: null }) // live row exists
enqueue({ data: registeredRow, error: null }) // finalize update
expect((await registerCompanyForPeppolReceiving({
service, companyId: 'company-1', userId: 'user-1', transport, settings,
})).ok).toBe(true)
} finally {
delete process.env.PEPPOL_RECEIVING_MAX_REGISTRATIONS
}
})
it('stops before the network on a personnummer and on a send-only transport', async () => {
const transport = makeTransport()
expect(await registerCompanyForPeppolReceiving({
+128
View File
@@ -0,0 +1,128 @@
/**
* Outbound delivery status by polling. Qvalia's webhook API is not available
* on its production host yet (2026-08-21: `/webhook/configure` answers 404
* there while the sandbox answers 204), and webhooks can be missed even when
* they exist, so the open deliveries are asked about on a schedule. Every
* event still goes through the same append-only, deduplicated lifecycle RPC
* as a webhook would.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { persistPeppolEvidence, persistVerifiedPeppolEvent } from '@/lib/invoices/peppol-delivery'
import type { PeppolTransport } from '@/lib/invoices/peppol-transport'
/** Deliveries the provider may still say something new about. */
const OPEN_STATUSES = ['submitting', 'submission_accepted', 'transport_succeeded', 'recipient_acknowledged'] as const
const EVIDENCE_STATUSES = new Set([
'transport_succeeded',
'recipient_acknowledged',
'business_accepted',
'business_rejected',
'failed',
])
export interface OpenPeppolDeliveryRow {
id: string
company_id: string
idempotency_key: string
provider_submission_id: string
status: string
status_at: string
submitted_at: string | null
evidence_retrieved_at: string | null
}
export interface PeppolDeliveryPollResult {
polled: number
advanced: number
unchanged: number
failed: number
errors: Array<{ providerSubmissionId: string; reason: string }>
}
function describeError(err: unknown): string {
return (err instanceof Error ? err.message : String(err)).slice(0, 500)
}
/** Open deliveries for a provider, oldest status first; default horizon 45 days. */
export async function listOpenPeppolDeliveries(args: {
service: SupabaseClient
provider: string
limit?: number
horizonDays?: number
}): Promise<OpenPeppolDeliveryRow[]> {
const since = new Date(Date.now() - (args.horizonDays ?? 45) * 24 * 60 * 60 * 1000).toISOString()
const { data, error } = await args.service
.from('peppol_deliveries')
.select('id, company_id, idempotency_key, provider_submission_id, status, status_at, submitted_at, evidence_retrieved_at')
.eq('provider', args.provider)
.in('status', [...OPEN_STATUSES])
.is('terminal_at', null)
.not('provider_submission_id', 'is', null)
.gte('submitted_at', since)
.order('status_at', { ascending: true })
.limit(args.limit ?? 200)
if (error) throw new Error(`Failed to list open Peppol deliveries: ${error.message}`)
return (data ?? []) as OpenPeppolDeliveryRow[]
}
/**
* One polling pass over the open deliveries. A delivery whose status the
* provider moved forward gets the event recorded (and evidence fetched on the
* states worth keeping); one the provider has nothing new about is left alone.
*/
export async function pollOpenPeppolDeliveries(args: {
service: SupabaseClient
transport: PeppolTransport
log: Logger
limit?: number
}): Promise<PeppolDeliveryPollResult> {
const { service, transport, log } = args
const result: PeppolDeliveryPollResult = { polled: 0, advanced: 0, unchanged: 0, failed: 0, errors: [] }
if (!transport.pollDeliveryStatus) return result
const open = await listOpenPeppolDeliveries({ service, provider: transport.provider, limit: args.limit })
for (const delivery of open) {
result.polled += 1
try {
const events = await transport.pollDeliveryStatus(delivery.provider_submission_id)
let advanced = false
for (const event of events) {
const recorded = await persistVerifiedPeppolEvent({
supabase: service,
companyId: delivery.company_id,
event: { ...event, idempotencyKey: delivery.idempotency_key },
})
if (recorded.status !== delivery.status || recorded.status_at !== delivery.status_at) advanced = true
if (EVIDENCE_STATUSES.has(event.normalizedStatus) && !delivery.evidence_retrieved_at) {
try {
const evidence = await transport.retrieveEvidence(delivery.provider_submission_id)
for (const item of evidence) {
await persistPeppolEvidence({
supabase: service,
companyId: delivery.company_id,
idempotencyKey: delivery.idempotency_key,
evidence: item,
})
}
} catch (err) {
log.warn('peppol evidence retrieval failed during status poll', {
providerSubmissionId: delivery.provider_submission_id,
reason: describeError(err),
})
}
}
}
if (advanced) result.advanced += 1
else result.unchanged += 1
} catch (err) {
result.failed += 1
result.errors.push({ providerSubmissionId: delivery.provider_submission_id, reason: describeError(err) })
log.error('peppol status poll failed', err as Error, { providerSubmissionId: delivery.provider_submission_id })
}
}
return result
}
+32
View File
@@ -95,6 +95,32 @@ export function preparePeppolParticipant(settings: ParticipantSettings): PeppolP
const LIVE_STATUSES: PeppolRegistrationStatus[] = ['pending', 'registered']
/**
* How many companies may publish a receiving identifier through our provider
* account. The Qvalia partner contract is priced per tenant (10 to start), so
* the product refuses the eleventh instead of silently exceeding the contract.
* Unset or invalid means no cap (self-hosted with an own provider account).
*/
export function getPeppolReceivingCap(env: Record<string, string | undefined> = process.env): number | null {
const raw = env.PEPPOL_RECEIVING_MAX_REGISTRATIONS?.trim()
if (!raw) return null
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
}
export async function countLivePeppolRegistrations(args: {
supabase: SupabaseClient
provider: string
}): Promise<number> {
const { count, error } = await args.supabase
.from('peppol_registrations')
.select('id', { count: 'exact', head: true })
.eq('provider', args.provider)
.in('status', LIVE_STATUSES)
if (error) throw new Error(`Failed to count Peppol registrations: ${error.message}`)
return count ?? 0
}
/** The live registration for a company at a provider, else the most recent history row. */
export async function getPeppolRegistration(args: {
supabase: SupabaseClient
@@ -122,6 +148,7 @@ export type RegisterPeppolResult =
| 'PEPPOL_REGISTRATION_PERSONAL_NUMBER'
| 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED'
| 'PEPPOL_RECEIVING_UNSUPPORTED'
| 'PEPPOL_REGISTRATION_CAP_REACHED'
}
| { ok: false; code: 'PEPPOL_REGISTRATION_FAILED'; detail: string | null }
@@ -150,6 +177,11 @@ export async function registerCompanyForPeppolReceiving(args: {
if (live) {
rowId = live.id
} else {
const cap = getPeppolReceivingCap()
if (cap !== null) {
const liveCount = await countLivePeppolRegistrations({ supabase: service, provider: transport.provider })
if (liveCount >= cap) return { ok: false, code: 'PEPPOL_REGISTRATION_CAP_REACHED' }
}
const { data, error } = await service
.from('peppol_registrations')
.insert({
+7
View File
@@ -187,6 +187,13 @@ export interface PeppolTransport {
providerDocumentId: string,
documentType: PeppolInboundDocumentType,
): Promise<string | null>
/**
* Pull the provider's current delivery status for an outbound submission
* and return it as verified events (same shape as a webhook, `idempotencyKey`
* unresolved). For providers without webhooks, or as a safety net when a
* webhook was missed. Returns [] when the provider has nothing new to say.
*/
pollDeliveryStatus?(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]>
}
const transports = new Map<string, PeppolTransport>()
+50
View File
@@ -732,6 +732,55 @@ export function createQvaliaTransport(
return text.trim().startsWith('<') ? text : null
}
/**
* Outbound status by polling `/invoices/outgoing/status`: the message-log
* status is the same free text the `document_delivery` webhook carries, so
* it goes through the same mapping. An empty `metadata` (nothing has
* happened since acceptance) yields no event.
*/
async function pollDeliveryStatus(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]> {
const url = `${transactionBase}/invoices/outgoing/status?integrationId=${encodeURIComponent(providerSubmissionId)}&includeRead=true&limit=1`
const response = await request('GET', url)
if (response.status === 204 || response.status === 404) return []
const { text, json } = await readBody(response)
if (!response.ok) throw classifyHttpFailure(response.status, json, text)
const data = asRecord(json)?.data ?? json
const items = Array.isArray(data) ? data : data ? [data] : []
const events: PeppolVerifiedEvent[] = []
for (const item of items) {
const record = asRecord(item)
const metadata = asRecord(record?.metadata)
const status = asString(metadata?.status)
if (!status) continue
const normalized = normalizeQvaliaWebhook({
eventType: 'document_delivery',
direction: 'outgoing',
integrationId: providerSubmissionId,
status: { status },
})
if (!normalized) continue
const occurredAt = asString(metadata?.updatedAt) ?? asString(record?.updatedAt) ?? now().toISOString()
events.push({
provider: QVALIA_PROVIDER,
providerTenantId: config.accountRegNo,
providerSubmissionId,
// Same dedupe key as the webhook would use for this transition, so a
// later webhook for the same status is a harmless duplicate.
providerEventId: `document_delivery:${providerSubmissionId}:${status}`,
idempotencyKey: null,
eventCode: 'status_poll',
normalizedStatus: normalized.normalizedStatus,
isTerminal: normalized.isTerminal,
detail: normalized.detail,
occurredAt,
rawPayload: record ?? {},
eventSha256: sha256Hex(`${providerSubmissionId}:${status}:${text}`),
verificationMethod: 'provider_poll',
})
}
return events
}
return {
provider: QVALIA_PROVIDER,
lookupRecipient,
@@ -742,5 +791,6 @@ export function createQvaliaTransport(
unregisterRecipient,
listInboundDocuments,
fetchInboundDocumentXml,
pollDeliveryStatus,
}
}
+4
View File
@@ -97,6 +97,10 @@
{
"path": "/api/peppol/inbound/cron",
"schedule": "*/10 * * * *"
},
{
"path": "/api/peppol/outbound/status/cron",
"schedule": "5,20,35,50 * * * *"
}
]
}