From 9ef7de861fffaec46467edd41419fff7e76f4372 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 21 Aug 2026 17:06:57 +0200 Subject: [PATCH] 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 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 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 --- .env.example | 4 + .../status/cron/__tests__/route.test.ts | 73 ++++++++++ app/api/peppol/outbound/status/cron/route.ts | 42 ++++++ docker/crontab.hosted | 49 +++---- docker/crontab.self-hosted | 49 +++---- docs/PEPPOL_FOUNDATION.md | 1 + lib/errors/structured-errors.ts | 5 + .../__tests__/peppol-delivery-sync.test.ts | 117 ++++++++++++++++ .../__tests__/peppol-qvalia-transport.test.ts | 36 +++++ .../__tests__/peppol-registration.test.ts | 27 ++++ lib/invoices/peppol-delivery-sync.ts | 128 ++++++++++++++++++ lib/invoices/peppol-registration.ts | 32 +++++ lib/invoices/peppol-transport.ts | 7 + lib/invoices/transports/qvalia.ts | 50 +++++++ vercel.json | 4 + 15 files changed, 576 insertions(+), 48 deletions(-) create mode 100644 app/api/peppol/outbound/status/cron/__tests__/route.test.ts create mode 100644 app/api/peppol/outbound/status/cron/route.ts create mode 100644 lib/invoices/__tests__/peppol-delivery-sync.test.ts create mode 100644 lib/invoices/peppol-delivery-sync.ts diff --git a/.env.example b/.env.example index a49fe903..437df904 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,10 @@ RECEIPT_HUNT_COMPANY_IDS= # the Authorization header (what the sandbox accepts); QVALIA_AUTH_SCHEME=apikey # switches to the "ApiKey " 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= diff --git a/app/api/peppol/outbound/status/cron/__tests__/route.test.ts b/app/api/peppol/outbound/status/cron/__tests__/route.test.ts new file mode 100644 index 00000000..99e34378 --- /dev/null +++ b/app/api/peppol/outbound/status/cron/__tests__/route.test.ts @@ -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 { + 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) + }) +}) diff --git a/app/api/peppol/outbound/status/cron/route.ts b/app/api/peppol/outbound/status/cron/route.ts new file mode 100644 index 00000000..7ebb40b7 --- /dev/null +++ b/app/api/peppol/outbound/status/cron/route.ts @@ -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 diff --git a/docker/crontab.hosted b/docker/crontab.hosted index 0c2c9aa3..f18fbc54 100644 --- a/docker/crontab.hosted +++ b/docker/crontab.hosted @@ -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 diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted index 7752e5fc..64e245e4 100644 --- a/docker/crontab.self-hosted +++ b/docker/crontab.self-hosted @@ -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 diff --git a/docs/PEPPOL_FOUNDATION.md b/docs/PEPPOL_FOUNDATION.md index 581c9076..dc36596d 100644 --- a/docs/PEPPOL_FOUNDATION.md +++ b/docs/PEPPOL_FOUNDATION.md @@ -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) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 6eb60fab..28514e28 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1330,6 +1330,11 @@ const INVOICE: Record = { 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 = { diff --git a/lib/invoices/__tests__/peppol-delivery-sync.test.ts b/lib/invoices/__tests__/peppol-delivery-sync.test.ts new file mode 100644 index 00000000..cbf38fd2 --- /dev/null +++ b/lib/invoices/__tests__/peppol-delivery-sync.test.ts @@ -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 { + 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) + }) +}) diff --git a/lib/invoices/__tests__/peppol-qvalia-transport.test.ts b/lib/invoices/__tests__/peppol-qvalia-transport.test.ts index ea6a1234..183d7f5a 100644 --- a/lib/invoices/__tests__/peppol-qvalia-transport.test.ts +++ b/lib/invoices/__tests__/peppol-qvalia-transport.test.ts @@ -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() + 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([]) + }) +}) diff --git a/lib/invoices/__tests__/peppol-registration.test.ts b/lib/invoices/__tests__/peppol-registration.test.ts index cb0a200d..ce5a7c68 100644 --- a/lib/invoices/__tests__/peppol-registration.test.ts +++ b/lib/invoices/__tests__/peppol-registration.test.ts @@ -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({ diff --git a/lib/invoices/peppol-delivery-sync.ts b/lib/invoices/peppol-delivery-sync.ts new file mode 100644 index 00000000..61f8fd71 --- /dev/null +++ b/lib/invoices/peppol-delivery-sync.ts @@ -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 { + 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 { + 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 +} diff --git a/lib/invoices/peppol-registration.ts b/lib/invoices/peppol-registration.ts index 60829569..169f577e 100644 --- a/lib/invoices/peppol-registration.ts +++ b/lib/invoices/peppol-registration.ts @@ -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 = 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 { + 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({ diff --git a/lib/invoices/peppol-transport.ts b/lib/invoices/peppol-transport.ts index 2e17fd53..d03c82b9 100644 --- a/lib/invoices/peppol-transport.ts +++ b/lib/invoices/peppol-transport.ts @@ -187,6 +187,13 @@ export interface PeppolTransport { providerDocumentId: string, documentType: PeppolInboundDocumentType, ): Promise + /** + * 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 } const transports = new Map() diff --git a/lib/invoices/transports/qvalia.ts b/lib/invoices/transports/qvalia.ts index 30c97e29..84645bd4 100644 --- a/lib/invoices/transports/qvalia.ts +++ b/lib/invoices/transports/qvalia.ts @@ -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 { + 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, } } diff --git a/vercel.json b/vercel.json index 71cc4e5a..a2a01393 100644 --- a/vercel.json +++ b/vercel.json @@ -97,6 +97,10 @@ { "path": "/api/peppol/inbound/cron", "schedule": "*/10 * * * *" + }, + { + "path": "/api/peppol/outbound/status/cron", + "schedule": "5,20,35,50 * * * *" } ] }