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,9 +0,0 @@
export type {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
ClientAction,
} from '../payments.types';
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');

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')

View File

@@ -0,0 +1,42 @@
{
"name": "@edr/payment-providers",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -w -p tsconfig.json",
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"peerDependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"axios": "^1.7.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},
"dependencies": {
"@edr/types": "workspace:*"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@types/node": "^20.14.0",
"axios": "^1.7.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,50 @@
// Re-export the shared, ORM-agnostic payment contract from @edr/types so consumers can
// import enums, interfaces, and providers from a single entry point.
export {
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
export type {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
ClientAction,
PaymentPlatform,
} from '@edr/types';
// Providers
export { TelebirrProvider } from './providers/telebirr/telebirr.provider';
export { CbeBirrProvider } from './providers/cbe-birr/cbe-birr.provider';
export { EBirrProvider } from './providers/ebirr/ebirr.provider';
export { CardProvider } from './providers/card/card.provider';
export { WaafiProvider } from './providers/waafi/waafi.provider';
// Telebirr crypto + types (exported for apps that build/verify signatures directly)
export {
buildCanonicalString,
signRequestObject,
verifyRequestObject,
signString,
verifySignature,
createTimestamp,
createNonceStr,
createMerchantOrderId,
} from './providers/telebirr/telebirr.crypto';
export type {
FabricTokenResponse,
CreateOrderRequest,
CreateOrderResponse,
QueryOrderResponse,
TelebirrTradeStatus,
} from './providers/telebirr/telebirr.types';
// Webhook payload types
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types';
// DI token for injecting all providers as an array (future multi-provider wiring)
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');

View File

@@ -1,16 +1,17 @@
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';
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
interface CardInitiateRequest {
amount: number;
@@ -18,7 +19,7 @@ interface CardInitiateRequest {
description: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
orderRef: string;
};
return_url: string;
webhook_url: string;
@@ -45,7 +46,7 @@ interface CardQueryResponse {
@Injectable()
export class CardProvider implements PaymentProvider {
readonly method = PaymentMethodType.CARD;
readonly method = ProviderMethod.CARD;
private readonly logger = new Logger(CardProvider.name);
constructor(
@@ -55,14 +56,14 @@ export class CardProvider implements PaymentProvider {
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const requestBody: CardInitiateRequest = {
amount,
currency: input.currency,
description: `EDR Train Booking ${input.bookingRef}`,
description: `EDR ${input.orderRef}`,
metadata: {
merchantOrderId: input.merchantOrderId,
bookingRef: input.bookingRef,
orderRef: input.orderRef,
},
return_url: this.returnUrl,
webhook_url: this.webhookUrl,
@@ -114,7 +115,7 @@ export class CardProvider implements PaymentProvider {
.createHmac('sha256', this.webhookSecret)
.update(payloadString)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
@@ -125,27 +126,27 @@ export class CardProvider implements PaymentProvider {
}
}
mapWebhookStatus(status: string): PaymentIntentStatus {
mapWebhookStatus(status: string): ProviderPaymentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
private mapStatus(status: string): ProviderPaymentStatus {
switch (status?.toLowerCase()) {
case 'succeeded':
case 'paid':
return PaymentIntentStatus.SUCCEEDED;
return ProviderPaymentStatus.SUCCEEDED;
case 'failed':
case 'canceled':
case 'expired':
return PaymentIntentStatus.FAILED;
return ProviderPaymentStatus.FAILED;
case 'requires_payment_method':
case 'requires_confirmation':
case 'requires_action':
return PaymentIntentStatus.REQUIRES_ACTION;
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'processing':
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
}
}

View File

@@ -1,16 +1,17 @@
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';
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
interface CbeBirrInitiateRequest {
merchantId: string;
@@ -42,7 +43,7 @@ interface CbeBirrQueryResponse {
@Injectable()
export class CbeBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.CBE_BIRR;
readonly method = ProviderMethod.CBE_BIRR;
private readonly logger = new Logger(CbeBirrProvider.name);
constructor(
@@ -53,13 +54,13 @@ export class CbeBirrProvider implements PaymentProvider {
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}`,
description: `EDR ${input.orderRef}`,
returnUrl: this.returnUrl,
notifyUrl: this.notifyUrl,
timestamp,
@@ -116,7 +117,7 @@ export class CbeBirrProvider implements PaymentProvider {
return {
status: mapped,
providerTxnId: response.transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
@@ -124,7 +125,7 @@ export class CbeBirrProvider implements PaymentProvider {
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),
@@ -132,25 +133,25 @@ export class CbeBirrProvider implements PaymentProvider {
);
}
mapWebhookStatus(status: string): PaymentIntentStatus {
mapWebhookStatus(status: string): ProviderPaymentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
private mapStatus(status: string): ProviderPaymentStatus {
switch (status?.toUpperCase()) {
case 'SUCCESS':
case 'COMPLETED':
return PaymentIntentStatus.SUCCEEDED;
return ProviderPaymentStatus.SUCCEEDED;
case 'FAILED':
case 'REJECTED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
return ProviderPaymentStatus.FAILED;
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
}
}
@@ -159,7 +160,7 @@ export class CbeBirrProvider implements PaymentProvider {
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&');
return crypto
.createHmac('sha256', this.secretKey)
.update(signString)

View File

@@ -1,16 +1,17 @@
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';
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
interface EBirrInitiateRequest {
merchantCode: string;
@@ -49,7 +50,7 @@ interface EBirrQueryResponse {
@Injectable()
export class EBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.EBIRR;
readonly method = ProviderMethod.EBIRR;
private readonly logger = new Logger(EBirrProvider.name);
constructor(
@@ -60,14 +61,14 @@ export class EBirrProvider implements PaymentProvider {
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}`,
body: `Order ${input.orderRef}`,
notifyUrl: this.notifyUrl,
returnUrl: this.returnUrl,
timestamp,
@@ -128,7 +129,7 @@ export class EBirrProvider implements PaymentProvider {
return {
status: mapped,
providerTxnId: response.data.tradeNo,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
@@ -136,7 +137,7 @@ export class EBirrProvider implements PaymentProvider {
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),
@@ -144,26 +145,26 @@ export class EBirrProvider implements PaymentProvider {
);
}
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
return this.mapStatus(tradeStatus);
}
private mapStatus(tradeStatus: string): PaymentIntentStatus {
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
switch (tradeStatus?.toUpperCase()) {
case 'TRADE_SUCCESS':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
return ProviderPaymentStatus.SUCCEEDED;
case 'TRADE_CLOSED':
case 'TRADE_FAILED':
case 'FAILED':
return PaymentIntentStatus.FAILED;
return ProviderPaymentStatus.FAILED;
case 'WAIT_BUYER_PAY':
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
}
}
@@ -172,7 +173,7 @@ export class EBirrProvider implements PaymentProvider {
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&') + `&key=${this.secretKey}`;
return crypto
.createHash('md5')
.update(signString)

View File

@@ -1,16 +1,17 @@
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';
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import {
createNonceStr,
createTimestamp,
@@ -28,7 +29,7 @@ const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class TelebirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.TELEBIRR;
readonly method = ProviderMethod.TELEBIRR;
private readonly logger = new Logger(TelebirrProvider.name);
private readonly httpsAgent: https.Agent;
@@ -64,7 +65,7 @@ export class TelebirrProvider implements PaymentProvider {
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
appId:this.merchantAppId,
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
@@ -103,39 +104,39 @@ export class TelebirrProvider implements PaymentProvider {
status: mapped,
providerTxnId,
failureCode:
mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
return ProviderPaymentStatus.SUCCEEDED;
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return PaymentIntentStatus.FAILED;
return ProviderPaymentStatus.FAILED;
case 'WAIT_PAY':
return PaymentIntentStatus.REQUIRES_ACTION;
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'PAYING':
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
switch (tradeStatus) {
case 'Completed':
return PaymentIntentStatus.SUCCEEDED;
return ProviderPaymentStatus.SUCCEEDED;
case 'Failure':
case 'Expired':
return PaymentIntentStatus.FAILED;
return ProviderPaymentStatus.FAILED;
case 'Paying':
case 'Pending':
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
}
}
@@ -190,7 +191,7 @@ export class TelebirrProvider implements PaymentProvider {
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
title: `EDR ${input.orderRef}`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,

View File

@@ -1,15 +1,16 @@
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';
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
@@ -78,7 +79,7 @@ interface WaafiQueryResponse {
@Injectable()
export class WaafiProvider implements PaymentProvider {
readonly method = PaymentMethodType.WAAFI;
readonly method = ProviderMethod.WAAFI;
private readonly logger = new Logger(WaafiProvider.name);
constructor(
@@ -133,28 +134,28 @@ export class WaafiProvider implements PaymentProvider {
return {
status: mapped,
providerTxnId: transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined,
failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
mapState(state: string | undefined): PaymentIntentStatus {
mapState(state: string | undefined): ProviderPaymentStatus {
switch (state) {
case 'APPROVED':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
return ProviderPaymentStatus.SUCCEEDED;
case 'FAILED':
case 'DECLINED':
case 'CANCELLED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
return ProviderPaymentStatus.FAILED;
case 'PENDING':
case 'INITIATED':
return PaymentIntentStatus.REQUIRES_ACTION;
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
return ProviderPaymentStatus.PROCESSING;
}
}
@@ -193,10 +194,10 @@ export class WaafiProvider implements PaymentProvider {
},
transactionInfo: {
referenceId: input.merchantOrderId,
invoiceId: input.bookingRef,
invoiceId: input.orderRef,
amount,
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
description: `EDR Train Booking ${input.bookingRef}`,
description: `EDR ${input.orderRef}`,
},
},
};

View File

@@ -0,0 +1 @@
export { createMerchantOrderId } from '../providers/telebirr/telebirr.crypto';

View File

@@ -0,0 +1,22 @@
export interface CardWebhookPayload {
id: string;
type: string;
data: {
object: {
id: string;
status: string;
amount: number;
currency: string;
metadata: {
merchantOrderId: string;
orderRef?: string;
[key: string]: unknown;
};
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
};
};
created: number;
}

View File

@@ -0,0 +1,12 @@
export interface CbeBirrWebhookPayload {
merchantId: string;
merchantOrderId: string;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
currency?: string;
paidAt?: string;
signature: string;
[key: string]: unknown;
}

View File

@@ -0,0 +1,12 @@
export interface EBirrWebhookPayload {
merchantCode: string;
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
currency?: string;
payTime?: number;
timestamp: number;
sign: string;
[key: string]: unknown;
}

View File

@@ -0,0 +1,13 @@
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;
}

View File

@@ -0,0 +1,15 @@
export 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;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@edr/tsconfig/nestjs.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}

View File

@@ -1,3 +1,5 @@
export * from "./payments";
export interface BaseEntity {
id: string;
createdAt: string;

View File

@@ -0,0 +1,66 @@
/**
* Shared, ORM-agnostic payment provider contract.
*
* These types are consumed by the @edr/payment-providers gateway package and by each
* app's payment module. The enums intentionally mirror the string values of every app's
* ORM-generated payment enums (Prisma `PaymentIntentStatus`/`PaymentMethodType` in the
* passenger API, the TypeORM column enum in the freight API), so mapping between an app's
* ORM enum and these shared enums is a one-line cast rather than a translation table.
*/
export enum ProviderPaymentStatus {
REQUIRES_ACTION = "REQUIRES_ACTION",
PROCESSING = "PROCESSING",
SUCCEEDED = "SUCCEEDED",
FAILED = "FAILED",
CANCELLED = "CANCELLED",
}
export enum ProviderMethod {
TELEBIRR = "TELEBIRR",
CBE_BIRR = "CBE_BIRR",
EBIRR = "EBIRR",
WAAFI = "WAAFI",
CARD = "CARD",
}
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;
/** Domain-neutral order reference (booking reference, shipment order reference, etc.). */
orderRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: ProviderPaymentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: ProviderMethod;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

55
pnpm-lock.yaml generated
View File

@@ -35,9 +35,15 @@ importers:
'@edr/api-common':
specifier: workspace:*
version: link:../../packages/api-common
'@edr/payment-providers':
specifier: workspace:*
version: link:../../packages/payment-providers
'@edr/types':
specifier: workspace:*
version: link:../../packages/types
'@nestjs/axios':
specifier: ^4.0.0
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -47,6 +53,9 @@ importers:
'@nestjs/core':
specifier: ^11.0.0
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter':
specifier: ^2.0.4
version: 2.1.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)
'@nestjs/microservices':
specifier: ^11.0.0
version: 11.1.24(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -65,6 +74,9 @@ importers:
'@tria-plc/iamapi-common':
specifier: ^0.1.0
version: 0.1.6(08624e896246f4d3d786f63e867db9c1)
axios:
specifier: ^1.7.7
version: 1.16.1
class-transformer:
specifier: ^0.5.1
version: 0.5.1
@@ -135,6 +147,12 @@ importers:
apps/edr-passenger-api:
dependencies:
'@edr/payment-providers':
specifier: workspace:*
version: link:../../packages/payment-providers
'@edr/types':
specifier: workspace:*
version: link:../../packages/types
'@nestjs/axios':
specifier: ^4.0.1
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
@@ -474,6 +492,43 @@ importers:
packages/config/tsconfig: {}
packages/payment-providers:
dependencies:
'@edr/types':
specifier: workspace:*
version: link:../types
devDependencies:
'@edr/eslint-config':
specifier: workspace:*
version: link:../config/eslint-config
'@edr/tsconfig':
specifier: workspace:*
version: link:../config/tsconfig
'@nestjs/axios':
specifier: ^4.0.0
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config':
specifier: ^4.0.0
version: 4.0.4(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@types/node':
specifier: ^20.14.0
version: 20.19.41
axios:
specifier: ^1.7.0
version: 1.16.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.1
version: 7.8.2
typescript:
specifier: ^5.5.4
version: 5.9.3
packages/types:
devDependencies:
'@edr/eslint-config':