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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
a2c9a12cfc
commit
9ef7de861f
@@ -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
|
||||
Reference in New Issue
Block a user