* 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>
129 lines
4.8 KiB
TypeScript
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
|
|
}
|