feat: ( payment ) create payment microservice

This commit is contained in:
Abubeker Yasin
2026-06-05 21:29:55 +03:00
parent 3922d813b1
commit 1d11cbda4d
44 changed files with 1172 additions and 266 deletions

View File

@@ -5,3 +5,17 @@ DB_PORT=5433
DB_USER=postgres
DB_PASSWORD=
DB_NAME=edr_freight
# Telebirr payment gateway (freight merchant credentials)
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
TELEBIRR_FABRIC_APP_ID=
TELEBIRR_APP_SECRET=
TELEBIRR_MERCHANT_APP_ID=
TELEBIRR_MERCHANT_CODE=
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false

View File

@@ -16,14 +16,18 @@
"@tria-plc/api-common": "^0.1.0",
"@tria-plc/iamapi-common": "^0.1.0",
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.13.0",

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -12,13 +14,15 @@ import { CustomersModule } from "./modules/customers/customers.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { PaymentsModule } from "./modules/payments/payments.module";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig],
load: [appConfig, databaseConfig, telebirrConfig],
}),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -31,6 +35,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
TrackingModule,
BillingModule,
NotificationsModule,
PaymentsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,16 @@
import { registerAs } from "@nestjs/config";
export default registerAs("telebirr", () => ({
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
}));

View File

@@ -0,0 +1,65 @@
import { BaseEntity } from "@edr/api-common";
import { ProviderMethod, ProviderPaymentStatus } from "@edr/types";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { Booking } from "../../bookings/entities/booking.entity";
@Entity({ name: "payment_intents" })
export class PaymentIntent extends BaseEntity {
@Column({ name: "booking_id", type: "uuid", unique: true })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "booking_id" })
booking?: Booking;
@Column({ name: "amount_minor", type: "int" })
amountMinor!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({ name: "method", type: "enum", enum: ProviderMethod })
method!: ProviderMethod;
@Column({
name: "status",
type: "enum",
enum: ProviderPaymentStatus,
default: ProviderPaymentStatus.REQUIRES_ACTION,
})
status!: ProviderPaymentStatus;
@Column({
name: "merchant_order_id",
type: "varchar",
length: 64,
unique: true,
nullable: true,
})
merchantOrderId?: string | null;
@Column({ name: "provider_order_id", type: "varchar", length: 128, nullable: true })
providerOrderId?: string | null;
@Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true })
providerTxnId?: string | null;
@Column({ name: "client_action", type: "jsonb", nullable: true })
clientAction?: Record<string, unknown> | null;
@Column({ name: "raw_initiation", type: "jsonb", nullable: true })
rawInitiation?: Record<string, unknown> | null;
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
expiresAt?: Date | null;
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
@Column({ name: "failure_code", type: "varchar", length: 128, nullable: true })
failureCode?: string | null;
@Column({ name: "failure_message", type: "text", nullable: true })
failureMessage?: string | null;
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from "@edr/api-common";
import { ProviderMethod } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "payment_webhook_events" })
export class PaymentWebhookEvent extends BaseEntity {
@Column({ name: "provider", type: "enum", enum: ProviderMethod })
provider!: ProviderMethod;
@Column({ name: "external_event_id", type: "varchar", length: 256, unique: true })
externalEventId!: string;
@Column({ name: "merchant_order_id", type: "varchar", length: 64 })
merchantOrderId!: string;
@Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true })
providerTxnId?: string | null;
@Column({ name: "signature_valid", type: "boolean" })
signatureValid!: boolean;
@Column({ name: "status", type: "varchar", length: 64 })
status!: string;
@Column({ name: "payload", type: "jsonb" })
payload!: Record<string, unknown>;
@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,27 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
} from "./payments.dto";
import { PaymentsService } from "./payments.service";
@ApiTags("Payments")
@Controller("payments")
export class PaymentsController {
constructor(private readonly payments: PaymentsService) {}
@Post("initiate")
@ApiOperation({ summary: "Initiate a payment for a freight booking" })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.payments.initiatePayment(dto);
}
@Get(":bookingId")
@ApiOperation({ summary: "Get the payment intent status for a booking" })
getStatus(@Param("bookingId") bookingId: string): Promise<IntentStatusDto> {
return this.payments.getIntentByBookingId(bookingId);
}
}

View File

@@ -0,0 +1,62 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
type!: "REDIRECT" | "LAUNCH_APP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
receiveCode?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
}
export class InitiateResponseDto {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

@@ -0,0 +1,33 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { TelebirrProvider } from "@edr/payment-providers";
import { BookingsModule } from "../bookings/bookings.module";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
import { PaymentsController } from "./payments.controller";
import {
PaymentIntentRepository,
PaymentWebhookEventRepository,
} from "./payments.repository";
import { PaymentsService } from "./payments.service";
import { TelebirrWebhookService } from "./webhooks/telebirr-webhook.service";
import { WebhooksController } from "./webhooks/webhooks.controller";
@Module({
imports: [
BookingsModule,
TypeOrmModule.forFeature([PaymentIntent, PaymentWebhookEvent]),
HttpModule.register({ timeout: 10_000 }),
],
controllers: [PaymentsController, WebhooksController],
providers: [
PaymentsService,
PaymentIntentRepository,
PaymentWebhookEventRepository,
TelebirrProvider,
TelebirrWebhookService,
],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,67 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DeepPartial, QueryFailedError, Repository } from "typeorm";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
const PG_UNIQUE_VIOLATION = "23505";
function isUniqueViolation(err: unknown): boolean {
return (
err instanceof QueryFailedError &&
(err as QueryFailedError & { driverError?: { code?: string } }).driverError
?.code === PG_UNIQUE_VIOLATION
);
}
@Injectable()
export class PaymentIntentRepository extends BaseRepository<PaymentIntent> {
constructor(
@InjectRepository(PaymentIntent)
repository: Repository<PaymentIntent>,
) {
super(repository);
}
findByBookingId(bookingId: string): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { bookingId } });
}
findByMerchantOrderId(merchantOrderId: string): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { merchantOrderId } });
}
}
@Injectable()
export class PaymentWebhookEventRepository extends BaseRepository<PaymentWebhookEvent> {
constructor(
@InjectRepository(PaymentWebhookEvent)
repository: Repository<PaymentWebhookEvent>,
) {
super(repository);
}
/**
* Insert a webhook event, returning null if an event with the same
* externalEventId already exists (deduplication via unique constraint).
*/
async createIfNew(
data: DeepPartial<PaymentWebhookEvent>,
): Promise<PaymentWebhookEvent | null> {
try {
return await this.repository.save(this.repository.create(data));
} catch (err) {
if (isUniqueViolation(err)) return null;
throw err;
}
}
async markProcessed(id: string, processingError?: string): Promise<void> {
await this.repository.update(id, {
processedAt: new Date(),
processingError: processingError ?? null,
});
}
}

View File

@@ -0,0 +1,212 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import {
ClientAction,
createMerchantOrderId,
PaymentProvider,
ProviderMethod,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { Freight } from "@edr/types";
import { DataSource } from "typeorm";
import { BookingsService } from "../bookings/bookings.service";
import { Booking } from "../bookings/entities/booking.entity";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { PaymentIntentRepository } from "./payments.repository";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
} from "./payments.dto";
const NON_TERMINAL_STATUSES: ProviderPaymentStatus[] = [
ProviderPaymentStatus.REQUIRES_ACTION,
ProviderPaymentStatus.PROCESSING,
ProviderPaymentStatus.SUCCEEDED,
];
const DEFAULT_CURRENCY = "ETB";
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly providers: Map<ProviderMethod, PaymentProvider>;
constructor(
private readonly dataSource: DataSource,
private readonly intents: PaymentIntentRepository,
private readonly bookings: BookingsService,
private readonly telebirrProvider: TelebirrProvider,
private readonly eventEmitter: EventEmitter2,
) {
this.providers = new Map<ProviderMethod, PaymentProvider>([
[ProviderMethod.TELEBIRR, this.telebirrProvider],
// add ProviderMethod.CBE_BIRR etc. here when enabled for freight
]);
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.bookings.findById(dto.bookingId);
if (booking.paymentStatus !== Freight.PaymentStatus.Pending) {
throw new BadRequestException("Booking is not payable");
}
const existing = await this.intents.findByBookingId(dto.bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
return this.formatIntentResponse(existing);
}
const method = dto.method as unknown as ProviderMethod;
const provider = this.providers.get(method);
if (!provider) {
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
return this.initiateProviderPayment(booking, provider, dto.platform);
}
private async initiateProviderPayment(
booking: Booking,
provider: PaymentProvider,
platform: PaymentPlatformDto | undefined,
): Promise<InitiateResponseDto> {
const merchantOrderId = createMerchantOrderId();
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
const result = await provider.initiate({
merchantOrderId,
orderRef: booking.reference,
amountMinor,
currency: DEFAULT_CURRENCY,
platform,
});
this.logger.log(
`Initiated ${provider.method} payment for booking ${booking.reference} (merchantOrderId=${merchantOrderId})`,
);
const data = {
bookingId: booking.id,
amountMinor,
currency: DEFAULT_CURRENCY,
method: provider.method,
status: ProviderPaymentStatus.REQUIRES_ACTION,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Record<string, unknown>,
rawInitiation: result.rawInitiation,
expiresAt: result.expiresAt,
failureCode: null,
failureMessage: null,
};
const existing = await this.intents.findByBookingId(booking.id);
const intent = existing
? await this.intents.update(existing.id, data)
: await this.intents.create(data);
return this.formatIntentResponse(intent!);
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const intent = await this.intents.findByBookingId(bookingId);
if (!intent) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(intent);
}
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.intents.findById(input.intentId);
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === ProviderPaymentStatus.SUCCEEDED) {
return { alreadyFinalized: true };
}
if (intent.status === ProviderPaymentStatus.CANCELLED) {
throw new BadRequestException("PaymentIntent is cancelled; cannot finalize");
}
const booking = await this.bookings.findById(intent.bookingId);
const paidAt = input.paidAt ?? new Date();
await this.dataSource.transaction(async (manager) => {
await manager.update(
PaymentIntent,
{ id: intent.id },
{
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? null,
paidAt,
},
);
// FREIGHT-SPECIFIC: mark the booking paid and confirm it.
await manager.update(
Booking,
{ id: booking.id },
{
paymentStatus: Freight.PaymentStatus.Paid,
status: Freight.BookingStatus.Confirmed,
},
);
});
this.eventEmitter.emit("payment.succeeded", {
bookingId: booking.id,
reference: booking.reference,
});
return { alreadyFinalized: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
failureMessage?: string;
}): Promise<void> {
const intent = await this.intents.findById(input.intentId);
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (
intent.status === ProviderPaymentStatus.SUCCEEDED ||
intent.status === ProviderPaymentStatus.CANCELLED
) {
return;
}
await this.intents.update(intent.id, {
status: ProviderPaymentStatus.FAILED,
failureCode: input.failureCode ?? null,
failureMessage: input.failureMessage ?? null,
});
}
private formatIntentResponse(intent: PaymentIntent): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
return {
intentId: intent.id,
status: intent.status,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
private formatIntentStatus(intent: PaymentIntent): IntentStatusDto {
return {
...this.formatIntentResponse(intent),
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
};
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger } from "@nestjs/common";
import {
ProviderMethod,
ProviderPaymentStatus,
TelebirrProvider,
TelebirrWebhookPayload,
} from "@edr/payment-providers";
import { PaymentsService } from "../payments.service";
import {
PaymentIntentRepository,
PaymentWebhookEventRepository,
} from "../payments.repository";
@Injectable()
export class TelebirrWebhookService {
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly events: PaymentWebhookEventRepository,
private readonly intents: PaymentIntentRepository,
private readonly provider: TelebirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: TelebirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.merch_order_id;
const externalEventId = this.buildExternalEventId(payload);
// TODO: re-enable Telebirr public-key signature verification — skipped for now
// (matches the passenger implementation; see TelebirrProvider.verifyWebhookSignature)
const signatureValid = true;
const eventRow = await this.events.createIfNew({
provider: ProviderMethod.TELEBIRR,
externalEventId,
merchantOrderId,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
signatureValid,
status: payload.trade_status,
payload: payload as unknown as Record<string, unknown>,
});
if (!eventRow) {
this.logger.log(
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
);
return;
}
const intent = await this.intents.findByMerchantOrderId(merchantOrderId);
if (!intent) {
this.logger.warn(
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
);
await this.events.markProcessed(eventRow.id, "intent-not-found");
return;
}
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
try {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
paidAt: this.parseEpochSeconds(payload.trans_end_time),
});
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.trade_status,
});
} else {
await this.intents.update(intent.id, {
status: mapped,
providerTxnId: payload.trans_id ?? undefined,
});
}
await this.events.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
);
await this.events.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
return `${payload.payment_order_id}_${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,37 @@
import {
All,
Body,
Controller,
HttpCode,
HttpStatus,
Logger,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { TelebirrWebhookPayload } from "@edr/payment-providers";
import { TelebirrWebhookService } from "./telebirr-webhook.service";
@ApiTags("Payment Webhooks")
@Controller("payments/webhooks")
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(private readonly telebirr: TelebirrWebhookService) {}
@All("telebirr")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Telebirr payment notification callback",
description: "Webhook endpoint for Telebirr freight payment status updates.",
})
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
this.logger.log("Telebirr webhook called");
try {
await this.telebirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);
}
return { code: "0", message: "OK" };
}
}

View File

@@ -22,6 +22,8 @@
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",

View File

@@ -4,11 +4,13 @@ import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { WaafiProvider } from './providers/waafi.provider';
import {
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
} from '@edr/payment-providers';
import { WebhooksController } from './webhooks/webhooks.controller';
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';

View File

@@ -4,10 +4,12 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import {
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
} from '@edr/payment-providers';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { BadRequestException, NotFoundException } from '@nestjs/common';

View File

@@ -5,13 +5,18 @@ import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { WaafiProvider } from './providers/waafi.provider';
import { createMerchantOrderId } from './providers/telebirr.crypto';
import {
ClientAction,
PaymentProvider,
ProviderStatus,
ProviderPaymentStatus,
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
createMerchantOrderId,
} from '@edr/payment-providers';
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
PaymentIntentStatus.REQUIRES_ACTION,
@@ -147,17 +152,18 @@ export class PaymentsService {
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
merchantOrderId,
bookingRef: booking.bookingRef,
orderRef: booking.bookingRef,
amountMinor: booking.totalMinor,
currency: booking.currency,
platform,
});
const providerMethod = provider.method as unknown as PaymentMethodType;
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: {
status: PaymentIntentStatus.REQUIRES_ACTION,
method: provider.method,
method: providerMethod,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
@@ -170,7 +176,7 @@ export class PaymentsService {
bookingId: booking.id,
amountMinor: booking.totalMinor,
currency: booking.currency,
method: provider.method,
method: providerMethod,
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId,
providerOrderId: result.providerOrderId,
@@ -235,14 +241,16 @@ export class PaymentsService {
intentId: string,
status: ProviderStatus,
): Promise<void> {
if (status.rawResponse.biz_content.order_status === "PAY_SUCCESS") {
const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } })
?.biz_content;
if (bizContent?.order_status === 'PAY_SUCCESS') {
await this.finalizePaymentSuccess({
intentId,
providerTxnId: status.providerTxnId,
});
return;
}
if (status.status === PaymentIntentStatus.FAILED) {
if (status.status === ProviderPaymentStatus.FAILED) {
await this.markPaymentFailed({
intentId,
failureCode: status.failureCode,
@@ -253,7 +261,7 @@ export class PaymentsService {
await this.prisma.paymentIntent.update({
where: { id: intentId },
data: {
status: status.status,
status: status.status as unknown as PaymentIntentStatus,
providerTxnId: status.providerTxnId ?? undefined,
},
});

View File

@@ -1,36 +1,11 @@
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; appId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
merchantOrderId: string;
bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: any;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}
// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers).
// This file remains as a thin re-export so existing local imports keep working.
export type {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
ClientAction,
PaymentPlatform,
} from '@edr/types';
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';

View File

@@ -1,218 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface CardInitiateRequest {
amount: number;
currency: string;
description: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
};
return_url: string;
webhook_url: string;
}
interface CardInitiateResponse {
id: string;
status: string;
client_secret: string;
checkout_url: string;
expires_at: number;
}
interface CardQueryResponse {
id: string;
status: string;
amount: number;
currency: string;
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
}
@Injectable()
export class CardProvider implements PaymentProvider {
readonly method = PaymentMethodType.CARD;
private readonly logger = new Logger(CardProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const requestBody: CardInitiateRequest = {
amount,
currency: input.currency,
description: `EDR Train Booking ${input.bookingRef}`,
metadata: {
merchantOrderId: input.merchantOrderId,
bookingRef: input.bookingRef,
},
return_url: this.returnUrl,
webhook_url: this.webhookUrl,
};
const response = await this.postJson<CardInitiateResponse>(
`${this.baseUrl}/v1/payment_intents`,
requestBody,
);
if (!response.id) {
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(response.expires_at * 1000);
return {
providerOrderId: response.id,
clientAction: { type: 'REDIRECT', url: response.checkout_url },
expiresAt,
rawInitiation: {
request: requestBody,
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
// For card payments, we need to find the payment intent by metadata
// In a real implementation, we'd store the provider order ID and use it directly
const response = await this.getJson<CardQueryResponse>(
`${this.baseUrl}/v1/payment_intents/search?metadata[merchantOrderId]=${merchantOrderId}`,
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transaction_id,
failureCode: response.failure_code,
failureMessage: response.failure_message,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
const payloadString = JSON.stringify(payload);
const expectedSignature = crypto
.createHmac('sha256', this.webhookSecret)
.update(payloadString)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch {
return false;
}
}
mapWebhookStatus(status: string): PaymentIntentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
switch (status?.toLowerCase()) {
case 'succeeded':
case 'paid':
return PaymentIntentStatus.SUCCEEDED;
case 'failed':
case 'canceled':
case 'expired':
return PaymentIntentStatus.FAILED;
case 'requires_payment_method':
case 'requires_confirmation':
case 'requires_action':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'processing':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private async getJson<T>(url: string): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.get<T>(url, config));
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private get baseUrl(): string {
return this.config.get<string>('card.baseUrl') ?? '';
}
private get apiKey(): string {
return this.config.get<string>('card.apiKey') ?? '';
}
private get webhookSecret(): string {
return this.config.get<string>('card.webhookSecret') ?? '';
}
private get webhookUrl(): string {
return this.config.get<string>('card.webhookUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('card.returnUrl') ?? '';
}
}

View File

@@ -1,215 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface CbeBirrInitiateRequest {
merchantId: string;
merchantOrderId: string;
amount: string;
currency: string;
description: string;
returnUrl: string;
notifyUrl: string;
timestamp: string;
signature: string;
}
interface CbeBirrInitiateResponse {
success: boolean;
orderId: string;
paymentUrl: string;
expiresIn: number;
}
interface CbeBirrQueryResponse {
success: boolean;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
paidAt?: string;
}
@Injectable()
export class CbeBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.CBE_BIRR;
private readonly logger = new Logger(CbeBirrProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = (input.amountMinor / 100).toFixed(2);
const timestamp = new Date().toISOString();
const requestBody: CbeBirrInitiateRequest = {
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
currency: input.currency,
description: `EDR Booking ${input.bookingRef}`,
returnUrl: this.returnUrl,
notifyUrl: this.notifyUrl,
timestamp,
signature: this.signRequest({
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<CbeBirrInitiateResponse>(
`${this.baseUrl}/api/v1/payment/initiate`,
requestBody,
);
if (!response.success || !response.orderId) {
throw new Error(`CBE Birr initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
return {
providerOrderId: response.orderId,
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = new Date().toISOString();
const signature = this.signRequest({
merchantId: this.merchantId,
merchantOrderId,
timestamp,
});
const response = await this.postJson<CbeBirrQueryResponse>(
`${this.baseUrl}/api/v1/payment/query`,
{
merchantId: this.merchantId,
merchantOrderId,
timestamp,
signature,
},
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { signature, ...data } = payload;
if (!signature || typeof signature !== 'string') return false;
const expectedSignature = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}
mapWebhookStatus(status: string): PaymentIntentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
switch (status?.toUpperCase()) {
case 'SUCCESS':
case 'COMPLETED':
return PaymentIntentStatus.SUCCEEDED;
case 'FAILED':
case 'REJECTED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&');
return crypto
.createHmac('sha256', this.secretKey)
.update(signString)
.digest('hex');
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
'X-Merchant-Id': this.merchantId,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CbeBirrInitiateRequest): Record<string, unknown> {
const { signature: _signature, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>('cbe.baseUrl') ?? '';
}
private get merchantId(): string {
return this.config.get<string>('cbe.merchantId') ?? '';
}
private get secretKey(): string {
return this.config.get<string>('cbe.secretKey') ?? '';
}
private get notifyUrl(): string {
return this.config.get<string>('cbe.notifyUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('cbe.returnUrl') ?? '';
}
}

View File

@@ -1,228 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface EBirrInitiateRequest {
merchantCode: string;
orderNo: string;
amount: number;
currency: string;
subject: string;
body: string;
notifyUrl: string;
returnUrl: string;
timestamp: number;
sign: string;
}
interface EBirrInitiateResponse {
code: string;
message: string;
data?: {
orderNo: string;
payUrl: string;
expireTime: number;
};
}
interface EBirrQueryResponse {
code: string;
message: string;
data?: {
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
payTime?: number;
};
}
@Injectable()
export class EBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.EBIRR;
private readonly logger = new Logger(EBirrProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const timestamp = Date.now();
const requestBody: EBirrInitiateRequest = {
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
currency: input.currency,
subject: `EDR Ticket`,
body: `Train booking ${input.bookingRef}`,
notifyUrl: this.notifyUrl,
returnUrl: this.returnUrl,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<EBirrInitiateResponse>(
`${this.baseUrl}/gateway/api/pay/create`,
requestBody,
);
if (response.code !== '0000' || !response.data?.orderNo) {
throw new Error(`eBirr initiate failed: ${response.message}`);
}
const expiresAt = new Date(response.data.expireTime);
return {
providerOrderId: response.data.orderNo,
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = Date.now();
const requestBody = {
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
}),
};
const response = await this.postJson<EBirrQueryResponse>(
`${this.baseUrl}/gateway/api/pay/query`,
requestBody,
);
if (response.code !== '0000' || !response.data) {
throw new Error(`eBirr query failed: ${response.message}`);
}
const mapped = this.mapStatus(response.data.tradeStatus);
return {
status: mapped,
providerTxnId: response.data.tradeNo,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { sign, ...data } = payload;
if (!sign || typeof sign !== 'string') return false;
const expectedSign = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(sign),
Buffer.from(expectedSign),
);
}
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
return this.mapStatus(tradeStatus);
}
private mapStatus(tradeStatus: string): PaymentIntentStatus {
switch (tradeStatus?.toUpperCase()) {
case 'TRADE_SUCCESS':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
case 'TRADE_CLOSED':
case 'TRADE_FAILED':
case 'FAILED':
return PaymentIntentStatus.FAILED;
case 'WAIT_BUYER_PAY':
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&') + `&key=${this.secretKey}`;
return crypto
.createHash('md5')
.update(signString)
.digest('hex')
.toUpperCase();
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>('ebirr.baseUrl') ?? '';
}
private get merchantCode(): string {
return this.config.get<string>('ebirr.merchantCode') ?? '';
}
private get secretKey(): string {
return this.config.get<string>('ebirr.secretKey') ?? '';
}
private get notifyUrl(): string {
return this.config.get<string>('ebirr.notifyUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('ebirr.returnUrl') ?? '';
}
}

View File

@@ -1,9 +0,0 @@
export type {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
ClientAction,
} from '../payments.types';
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');

View File

@@ -1,98 +0,0 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -1,300 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
import {
createNonceStr,
createTimestamp,
signRequestObject,
verifyRequestObject,
} from './telebirr.crypto';
import {
CreateOrderRequest,
CreateOrderResponse,
FabricTokenResponse,
QueryOrderResponse,
} from './telebirr.types';
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class TelebirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(TelebirrProvider.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
appId:this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return PaymentIntentStatus.FAILED;
case 'WAIT_PAY':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PAYING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return PaymentIntentStatus.SUCCEEDED;
case 'Failure':
case 'Expired':
return PaymentIntentStatus.FAILED;
case 'Paying':
case 'Pending':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
const totalAmount = String(input.amountMinor / 100);
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
}

View File

@@ -1,69 +0,0 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -1,274 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
interface WaafiInitiateRequest {
schemaVersion: string;
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: {
merchantUid: string;
apiUserId: string;
apiKey: string;
paymentMethod: string;
payerInfo: {
accountNo: string;
};
transactionInfo: {
referenceId: string;
invoiceId: string;
amount: number;
currency: string;
description: string;
};
};
}
interface WaafiInitiateResponse {
responseCode: string;
responseMsg: string;
params?: {
state: string;
referenceId: string;
transactionId: string;
checkoutUrl?: string;
};
}
interface WaafiQueryRequest {
schemaVersion: string;
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: {
merchantUid: string;
apiUserId: string;
apiKey: string;
transactionId?: string;
referenceId?: string;
};
}
interface WaafiQueryResponse {
responseCode: string;
responseMsg: string;
params?: {
state: string;
referenceId: string;
transactionId: string;
amount: number;
currency: string;
paidAmount?: number;
};
}
@Injectable()
export class WaafiProvider implements PaymentProvider {
readonly method = PaymentMethodType.WAAFI;
private readonly logger = new Logger(WaafiProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const requestBody = this.buildInitiateRequest(input);
const response = await this.postJson<WaafiInitiateResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
if (response.responseCode !== '2001') {
throw new Error(
`Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`,
);
}
const transactionId = response.params?.transactionId;
const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`;
if (!transactionId) {
throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes
return {
providerOrderId: transactionId,
clientAction: { type: 'REDIRECT', url: checkoutUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const requestBody = this.buildQueryRequest(merchantOrderId);
const response = await this.postJson<WaafiQueryResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
const state = response.params?.state;
const transactionId = response.params?.transactionId;
const mapped = this.mapState(state);
return {
status: mapped,
providerTxnId: transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
mapState(state: string | undefined): PaymentIntentStatus {
switch (state) {
case 'APPROVED':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
case 'FAILED':
case 'DECLINED':
case 'CANCELLED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
case 'PENDING':
case 'INITIATED':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
// Waafi webhook signature verification
// Implementation depends on Waafi's webhook signature mechanism
const signature = payload.signature as string;
const apiKey = this.apiKey;
if (!signature || !apiKey) {
this.logger.error('Waafi webhook missing signature or API key not configured');
return false;
}
// TODO: Implement actual signature verification based on Waafi documentation
// For now, basic validation
return signature.length > 0;
}
private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest {
const amount = input.amountMinor / 100; // Convert minor units to major
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
channelName: 'WEB',
serviceName: 'API_PURCHASE',
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
paymentMethod: 'MWALLET_ACCOUNT',
payerInfo: {
accountNo: 'CUSTOMER', // Customer enters their number on Waafi page
},
transactionInfo: {
referenceId: input.merchantOrderId,
invoiceId: input.bookingRef,
amount,
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
description: `EDR Train Booking ${input.bookingRef}`,
},
},
};
}
private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest {
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
channelName: 'WEB',
serviceName: 'API_QUERY',
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
referenceId: merchantOrderId,
},
};
}
private generateRequestId(): string {
return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
},
timeout: WAAFI_HTTP_TIMEOUT_MS,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(
`Waafi POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Waafi POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(
`Waafi POST ${url} threw: ${err instanceof Error ? err.message : err}`,
);
}
throw err;
}
}
private sanitize(body: WaafiInitiateRequest): Record<string, unknown> {
const sanitized = { ...body };
if (sanitized.serviceParams?.apiKey) {
sanitized.serviceParams.apiKey = '***REDACTED***';
}
return sanitized as unknown as Record<string, unknown>;
}
private get baseUrl(): string {
return this.config.get<string>('waafi.baseUrl') ?? 'https://api.waafipay.net';
}
private get merchantUid(): string {
return this.config.get<string>('waafi.merchantUid') ?? '';
}
private get apiUserId(): string {
return this.config.get<string>('waafi.apiUserId') ?? '';
}
private get apiKey(): string {
return this.config.get<string>('waafi.apiKey') ?? '';
}
}

View File

@@ -1,30 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
CardProvider,
CardWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { CardProvider } from '../providers/card.provider';
export interface CardWebhookPayload {
id: string;
type: string;
data: {
object: {
id: string;
status: string;
amount: number;
currency: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
};
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
};
};
created: number;
}
@Injectable()
export class CardWebhookService {
@@ -76,13 +58,13 @@ export class CardWebhookService {
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.data.object.transaction_id,
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.data.object.failure_code,
@@ -92,7 +74,7 @@ export class CardWebhookService {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped,
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.data.object.transaction_id ?? undefined,
},
});

View File

@@ -1,21 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
CbeBirrProvider,
CbeBirrWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { CbeBirrProvider } from '../providers/cbe-birr.provider';
export interface CbeBirrWebhookPayload {
merchantId: string;
merchantOrderId: string;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
currency?: string;
paidAt?: string;
signature: string;
[key: string]: unknown;
}
@Injectable()
export class CbeBirrWebhookService {
@@ -66,13 +57,13 @@ export class CbeBirrWebhookService {
const mapped = this.provider.mapWebhookStatus(payload.status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.transactionId ?? payload.orderId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.status,
@@ -80,7 +71,10 @@ export class CbeBirrWebhookService {
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.transactionId ?? undefined },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.transactionId ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);

View File

@@ -1,21 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
EBirrProvider,
EBirrWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { EBirrProvider } from '../providers/ebirr.provider';
export interface EBirrWebhookPayload {
merchantCode: string;
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
currency?: string;
payTime?: number;
timestamp: number;
sign: string;
[key: string]: unknown;
}
@Injectable()
export class EBirrWebhookService {
@@ -66,13 +57,13 @@ export class EBirrWebhookService {
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.tradeNo,
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.tradeStatus,
@@ -80,7 +71,10 @@ export class EBirrWebhookService {
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.tradeNo ?? undefined },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.tradeNo ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);

View File

@@ -1,22 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
TelebirrProvider,
TelebirrWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { TelebirrProvider } from '../providers/telebirr.provider';
export interface TelebirrWebhookPayload {
merch_order_id: string;
payment_order_id: string;
trade_status: string;
trans_id?: string;
total_amount?: string;
trans_currency?: string;
notify_time?: string;
trans_end_time?: string;
sign: string;
sign_type?: string;
[key: string]: unknown;
}
@Injectable()
export class TelebirrWebhookService {
@@ -76,13 +66,13 @@ export class TelebirrWebhookService {
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
paidAt: this.parseEpochSeconds(payload.trans_end_time),
});
} else if (mapped === PaymentIntentStatus.FAILED) {
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.trade_status,
@@ -90,7 +80,10 @@ export class TelebirrWebhookService {
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.trans_id ?? undefined },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.trans_id ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);

View File

@@ -1,24 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import {
WaafiProvider,
WaafiWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { WaafiProvider } from '../providers/waafi.provider';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
interface WaafiWebhookPayload {
schemaVersion: string;
requestId: string;
timestamp: string;
eventType: string;
params: {
state: string;
referenceId: string;
transactionId: string;
amount: number;
currency: string;
description?: string;
};
signature?: string;
}
@Injectable()
export class WaafiWebhookService {
@@ -76,13 +64,13 @@ export class WaafiWebhookService {
const mappedStatus = this.waafiProvider.mapState(state);
if (mappedStatus === PaymentIntentStatus.SUCCEEDED) {
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
await this.paymentsService.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: transactionId,
});
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
} else if (mappedStatus === PaymentIntentStatus.FAILED) {
} else if (mappedStatus === ProviderPaymentStatus.FAILED) {
await this.paymentsService.markPaymentFailed({
intentId: intent.id,
failureCode: state,
@@ -93,7 +81,7 @@ export class WaafiWebhookService {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mappedStatus,
status: mappedStatus as unknown as PaymentIntentStatus,
providerTxnId: transactionId,
},
});

View File

@@ -2,20 +2,14 @@ import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TelebirrWebhookPayload,
TelebirrWebhookService,
} from './telebirr-webhook.service';
import {
CbeBirrWebhookPayload,
CbeBirrWebhookService,
} from './cbe-birr-webhook.service';
import {
EBirrWebhookPayload,
EBirrWebhookService,
} from './ebirr-webhook.service';
import {
CardWebhookPayload,
CardWebhookService,
} from './card-webhook.service';
} from '@edr/payment-providers';
import { TelebirrWebhookService } from './telebirr-webhook.service';
import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
import { EBirrWebhookService } from './ebirr-webhook.service';
import { CardWebhookService } from './card-webhook.service';
import { WaafiWebhookService } from './waafi-webhook.service';
@ApiTags('Payment Webhooks')