mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
feat: ( payment ) create payment microservice
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { ProviderMethod } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Idempotency + audit record for every inbound provider webhook. The unique
|
||||
* (provider, external_event_id) pair is the dedupe key: a duplicate insert hits the unique
|
||||
* violation and the handler short-circuits with a 200 ack.
|
||||
*/
|
||||
@Entity({ name: "payment_webhook_event" })
|
||||
@Index("uq_payment_webhook_event_external", ["provider", "externalEventId"], {
|
||||
unique: true,
|
||||
})
|
||||
export class PaymentWebhookEvent extends BaseEntity {
|
||||
@Column({ name: "provider", type: "varchar", length: 16 })
|
||||
provider!: ProviderMethod;
|
||||
|
||||
/** Provider event id when given (e.g. Waafi X-Webhook-Event-Id), else derived from the payload. */
|
||||
@Column({ name: "external_event_id", type: "varchar", length: 191 })
|
||||
externalEventId!: string;
|
||||
|
||||
@Column({
|
||||
name: "merchant_order_id",
|
||||
type: "varchar",
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
merchantOrderId?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "provider_txn_id",
|
||||
type: "varchar",
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
providerTxnId?: string | null;
|
||||
|
||||
@Column({ name: "signature_valid", type: "boolean", default: false })
|
||||
signatureValid!: boolean;
|
||||
|
||||
/** Raw provider status string as sent (pre-mapping). */
|
||||
@Column({ name: "status", type: "varchar", length: 64, nullable: true })
|
||||
status?: string | null;
|
||||
|
||||
/** Full webhook body — hostile input, stored verbatim for audit/replay analysis. */
|
||||
@Column({ name: "payload", type: "jsonb" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@Column({ name: "received_at", type: "timestamptz", default: () => "now()" })
|
||||
receivedAt!: Date;
|
||||
|
||||
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
|
||||
processedAt?: Date | null;
|
||||
|
||||
@Column({ name: "processing_error", type: "text", nullable: true })
|
||||
processingError?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CardProvider, CardWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class CardWebhookService {
|
||||
constructor(
|
||||
private readonly provider: CardProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
signature,
|
||||
);
|
||||
const object = payload.data.object;
|
||||
const mapped = this.provider.mapWebhookStatus(object.status);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.id}_${payload.type}`,
|
||||
merchantOrderId: object.metadata.merchantOrderId,
|
||||
providerTxnId: object.transaction_id,
|
||||
signatureValid,
|
||||
rawStatus: object.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: object.transaction_id,
|
||||
failureCode: object.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CbeBirrProvider, CbeBirrWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrWebhookService {
|
||||
constructor(
|
||||
private readonly provider: CbeBirrProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
const providerTxnId = payload.transactionId ?? payload.orderId;
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.orderId}_${payload.status}`,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
providerTxnId,
|
||||
signatureValid,
|
||||
rawStatus: payload.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: { status: mapped, providerTxnId, failureCode: payload.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DMoneyProvider, DMoneyWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class DMoneyWebhookService {
|
||||
constructor(
|
||||
private readonly provider: DMoneyProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: DMoneyWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.orderId}_${payload.status}`,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
providerTxnId: payload.transactionId,
|
||||
signatureValid,
|
||||
rawStatus: payload.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: payload.transactionId,
|
||||
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
||||
failureCode: payload.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class EBirrWebhookService {
|
||||
constructor(
|
||||
private readonly provider: EBirrProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: EBirrWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
|
||||
merchantOrderId: payload.orderNo,
|
||||
providerTxnId: payload.tradeNo,
|
||||
signatureValid,
|
||||
rawStatus: payload.tradeStatus,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: payload.tradeNo,
|
||||
failureCode: payload.tradeStatus,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
TelebirrProvider,
|
||||
TelebirrWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
constructor(
|
||||
private readonly provider: TelebirrProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
||||
// TODO: re-enable Telebirr public-key signature verification — skipped for now
|
||||
// (carried over from the passenger handler; see telebirr.provider verifyWebhookSignature).
|
||||
const signatureValid = true;
|
||||
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
const providerTxnId = payload.trans_id ?? payload.payment_order_id;
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.payment_order_id}_${payload.trade_status}`,
|
||||
merchantOrderId: payload.merch_order_id,
|
||||
providerTxnId,
|
||||
signatureValid,
|
||||
rawStatus: payload.trade_status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
||||
failureCode: payload.trade_status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return new Date(n * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
WaafiProvider,
|
||||
WaafiWebhookHeaders,
|
||||
WaafiWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
/** Reject webhooks whose timestamp is older than this (replay protection). */
|
||||
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
|
||||
|
||||
@Injectable()
|
||||
export class WaafiWebhookService {
|
||||
private readonly logger = new Logger(WaafiWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly provider: WaafiProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(
|
||||
payload: WaafiWebhookPayload,
|
||||
rawBody: string,
|
||||
headers: WaafiWebhookHeaders,
|
||||
): Promise<void> {
|
||||
// Unsigned validation ping sent on registration — acknowledge without verifying or persisting.
|
||||
if (payload.event === "webhook.test") {
|
||||
this.logger.log("Waafi webhook.test ping received");
|
||||
return;
|
||||
}
|
||||
|
||||
const { payment } = payload;
|
||||
const eventId = headers["x-webhook-event-id"];
|
||||
const timestamp = headers["x-webhook-timestamp"];
|
||||
const signature = headers["x-webhook-signature"];
|
||||
|
||||
const signatureValid =
|
||||
this.isFresh(timestamp) &&
|
||||
this.provider.verifyWebhookSignature(
|
||||
rawBody,
|
||||
signature,
|
||||
timestamp,
|
||||
eventId,
|
||||
);
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payment.status);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
|
||||
externalEventId: eventId ?? `${payment.transaction_id}_${payment.status}`,
|
||||
merchantOrderId: payment.reference_id,
|
||||
providerTxnId: payment.transaction_id,
|
||||
signatureValid,
|
||||
rawStatus: payment.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: payment.transaction_id,
|
||||
paidAt: this.parseDate(payment.date),
|
||||
failureCode: payment.status,
|
||||
failureMessage: payment.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the webhook timestamp (unix seconds) is within the replay window. */
|
||||
private isFresh(timestamp: string | undefined): boolean {
|
||||
if (!timestamp) return false;
|
||||
const ts = parseInt(timestamp, 10);
|
||||
if (Number.isNaN(ts)) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
/** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */
|
||||
private parseDate(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { QueryFailedError, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
@Injectable()
|
||||
export class WebhookEventsRepository extends BaseRepository<PaymentWebhookEvent> {
|
||||
constructor(
|
||||
@InjectRepository(PaymentWebhookEvent)
|
||||
repository: Repository<PaymentWebhookEvent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the event, relying on the unique (provider, external_event_id) index for dedupe.
|
||||
* Returns null when the event was already recorded (duplicate delivery / provider replay).
|
||||
*/
|
||||
async createDeduped(
|
||||
data: Partial<PaymentWebhookEvent>,
|
||||
): Promise<PaymentWebhookEvent | null> {
|
||||
try {
|
||||
return await this.create(data);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QueryFailedError &&
|
||||
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async markProcessed(id: string, processingError?: string): Promise<void> {
|
||||
await this.update(id, {
|
||||
processedAt: new Date(),
|
||||
processingError: processingError ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
Normal file
143
apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
All,
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
CardWebhookPayload,
|
||||
CbeBirrWebhookPayload,
|
||||
DMoneyWebhookPayload,
|
||||
EBirrWebhookPayload,
|
||||
TelebirrWebhookPayload,
|
||||
WaafiWebhookHeaders,
|
||||
WaafiWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
|
||||
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
|
||||
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
|
||||
import { CardWebhookService } from "./handlers/card-webhook.service";
|
||||
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
|
||||
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
|
||||
|
||||
/**
|
||||
* The ONLY public surface of the payment service — the single registered webhook URL per
|
||||
* provider for the whole platform. No service auth here (provider-facing); trust comes from
|
||||
* signature verification inside each handler. Every route acks 2xx fast and never rethrows:
|
||||
* Waafi times out at 5s and does NOT retry.
|
||||
*/
|
||||
@ApiTags("Provider Webhooks")
|
||||
@Controller("webhooks")
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
constructor(
|
||||
private readonly telebirr: TelebirrWebhookService,
|
||||
private readonly cbeBirr: CbeBirrWebhookService,
|
||||
private readonly eBirr: EBirrWebhookService,
|
||||
private readonly card: CardWebhookService,
|
||||
private readonly waafi: WaafiWebhookService,
|
||||
private readonly dMoney: DMoneyWebhookService,
|
||||
) {}
|
||||
|
||||
@All("telebirr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Telebirr payment notification callback (Ethiopia)",
|
||||
})
|
||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
||||
this.logger.log("Telebirr webhook called");
|
||||
try {
|
||||
await this.telebirr.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`Telebirr webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { code: "0", message: "OK" };
|
||||
}
|
||||
|
||||
@Post("cbe-birr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "CBE Birr payment notification callback (Ethiopia)",
|
||||
})
|
||||
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
||||
try {
|
||||
await this.cbeBirr.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`CBE Birr webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post("ebirr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
|
||||
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
||||
try {
|
||||
await this.eBirr.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { code: "0000", message: "success" };
|
||||
}
|
||||
|
||||
@Post("card")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Card payment notification callback (International)",
|
||||
})
|
||||
async receiveCard(
|
||||
@Body() payload: CardWebhookPayload,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
) {
|
||||
try {
|
||||
await this.card.handle(payload, signature);
|
||||
} catch (err) {
|
||||
this.logger.error(`Card webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
@Post("waafi")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Waafi payment notification callback (Djibouti)" })
|
||||
async receiveWaafi(
|
||||
@Body() payload: WaafiWebhookPayload,
|
||||
@Headers() headers: WaafiWebhookHeaders,
|
||||
@Req() req: { rawBody?: Buffer },
|
||||
) {
|
||||
this.logger.log(
|
||||
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
|
||||
);
|
||||
try {
|
||||
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
|
||||
const rawBody = req.rawBody?.toString("utf8") ?? "";
|
||||
await this.waafi.handle(payload, rawBody, headers);
|
||||
} catch (err) {
|
||||
this.logger.error(`Waafi webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { responseCode: "2001", responseMsg: "Success" };
|
||||
}
|
||||
|
||||
@Post("dmoney")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" })
|
||||
async receiveDMoney(@Body() payload: DMoneyWebhookPayload) {
|
||||
try {
|
||||
await this.dMoney.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private message(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
34
apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts
Normal file
34
apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { IntentsModule } from "../intents/intents.module";
|
||||
import { ProvidersModule } from "../providers/providers.module";
|
||||
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||
import { WebhookEventsRepository } from "./webhook-events.repository";
|
||||
import { WebhookProcessorService } from "./webhook-processor.service";
|
||||
import { WebhooksController } from "./webhooks.controller";
|
||||
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
|
||||
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
|
||||
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
|
||||
import { CardWebhookService } from "./handlers/card-webhook.service";
|
||||
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
|
||||
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PaymentWebhookEvent]),
|
||||
IntentsModule,
|
||||
ProvidersModule,
|
||||
],
|
||||
controllers: [WebhooksController],
|
||||
providers: [
|
||||
WebhookEventsRepository,
|
||||
WebhookProcessorService,
|
||||
TelebirrWebhookService,
|
||||
CbeBirrWebhookService,
|
||||
EBirrWebhookService,
|
||||
CardWebhookService,
|
||||
WaafiWebhookService,
|
||||
DMoneyWebhookService,
|
||||
],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
Reference in New Issue
Block a user