feat: ( payment ) create payment microservice

This commit is contained in:
Abubeker Yasin
2026-06-11 15:25:24 +03:00
parent 3235567b41
commit 2b430f8e76
54 changed files with 2809 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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,
},
});
}
}

View File

@@ -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 },
});
}
}

View File

@@ -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,
},
});
}
}

View File

@@ -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,
},
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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,
});
}
}

View File

@@ -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}`,
);
}
}
}

View 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);
}
}

View 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 {}