mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
95 lines
3.2 KiB
TypeScript
95 lines
3.2 KiB
TypeScript
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<string, unknown>;
|
|
/** 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<void> {
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|