Files
accounted/lib/invoices/peppol-delivery-sync.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

129 lines
4.8 KiB
TypeScript

/**
* 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
}