Files
accounted/lib/invoices/__tests__/peppol-delivery-sync.test.ts
T
9ef7de861f 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>
2026-08-21 17:06:57 +02:00

118 lines
4.9 KiB
TypeScript

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)
})
})