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 * * * *" } ] }