mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
feat: ( payment ) create payment microservice
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
MERCHANT_ORDER_PREFIX,
|
||||
PaymentService,
|
||||
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;
|
||||
}
|
||||
|
||||
// Integrity guard (§10): the stateless prefix and the stored discriminator must agree.
|
||||
const expectedPrefix =
|
||||
MERCHANT_ORDER_PREFIX[intent.service as PaymentService];
|
||||
if (expectedPrefix && !merchantOrderId.startsWith(expectedPrefix)) {
|
||||
this.logger.error(
|
||||
`${provider} webhook: merchantOrderId ${merchantOrderId} prefix does not match stored service ${intent.service} — refusing to process`,
|
||||
);
|
||||
await this.webhookEvents.markProcessed(
|
||||
eventRow.id,
|
||||
"service-prefix-mismatch",
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user