import { Injectable, Logger } from "@nestjs/common"; import { ProviderMethod } from "@edr/types"; import { IntentsRepository } from "../intents/intents.repository"; import { IntentsService, ProviderResultInput, } from "../intents/intents.service"; import { WebhookEventsRepository } from "./webhook-events.repository"; /** A provider webhook reduced to the fields the shared pipeline needs. */ export interface NormalizedWebhook { provider: ProviderMethod; /** Provider event id (or a deterministic derivation) — the dedupe key. */ externalEventId: string; merchantOrderId: string; providerTxnId?: string; signatureValid: boolean; /** Raw provider status string, stored for audit. */ rawStatus: string; payload: Record; /** Mapped outcome to feed the intent state machine. */ result: ProviderResultInput; } /** * The shared webhook pipeline every provider handler funnels into: * persist+dedupe → signature gate → intent lookup → prefix/service cross-check → * state machine → mark processed. Always returns (never throws) so controllers can * ack 200 fast — providers like Waafi time out at 5s and do not retry. */ @Injectable() export class WebhookProcessorService { private readonly logger = new Logger(WebhookProcessorService.name); constructor( private readonly webhookEvents: WebhookEventsRepository, private readonly intentsRepository: IntentsRepository, private readonly intentsService: IntentsService, ) {} async process(webhook: NormalizedWebhook): Promise { const { provider, merchantOrderId } = webhook; const eventRow = await this.webhookEvents.createDeduped({ provider, externalEventId: webhook.externalEventId, merchantOrderId, providerTxnId: webhook.providerTxnId ?? null, signatureValid: webhook.signatureValid, status: webhook.rawStatus, payload: webhook.payload, }); if (!eventRow) { this.logger.log( `${provider} webhook duplicate: ${webhook.externalEventId} — short-circuit OK`, ); return; } if (!webhook.signatureValid) { this.logger.warn( `${provider} webhook signature invalid/stale for ref=${merchantOrderId}`, ); await this.webhookEvents.markProcessed(eventRow.id, "signature-invalid"); return; } const intent = await this.intentsRepository.findByMerchantOrderId(merchantOrderId); if (!intent) { // Tolerated: webhook may have raced the intent commit, or the reference is foreign. // The provider gets a 200; retry/poll/reconciliation converges later. this.logger.warn( `${provider} webhook: no PaymentIntent for ref=${merchantOrderId}`, ); await this.webhookEvents.markProcessed(eventRow.id, "intent-not-found"); return; } try { await this.intentsService.applyProviderResult(intent.id, webhook.result); await this.webhookEvents.markProcessed(eventRow.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error( `${provider} webhook processing failed for ${merchantOrderId}: ${message}`, ); await this.webhookEvents.markProcessed( eventRow.id, `processing-error: ${message}`, ); } } }