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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
{
|
||||
"path": "/api/peppol/inbound/cron",
|
||||
"schedule": "*/10 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/peppol/outbound/status/cron",
|
||||
"schedule": "5,20,35,50 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user