refactor: ( telebirr ) wire async initiate flow through TelebirrProvider

This commit is contained in:
Abubeker Yasin
2026-05-17 22:57:20 +03:00
parent f9156e95ee
commit cfa8c67448
5 changed files with 272 additions and 65 deletions

View File

@@ -19,6 +19,7 @@
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
@@ -30,6 +31,7 @@
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^5.8.0",
"@sendgrid/mail": "^8.1.0",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@@ -61,11 +63,19 @@
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"collectCoverageFrom": ["**/*.(t|j)s"],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}

View File

@@ -1,5 +1,6 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentIntentStatus } from '@prisma/client';
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
@@ -20,3 +21,15 @@ export class AddPaymentMethodDto {
@ApiProperty() @IsString() displayName: string;
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
}
export class ClientActionDto {
@ApiProperty({ enum: ['REDIRECT'] }) type: 'REDIRECT';
@ApiProperty() url: string;
}
export class InitiateResponseDto {
@ApiProperty() intentId: string;
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
}

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
@@ -8,7 +9,7 @@ import { WebhooksController } from './webhooks/webhooks.controller';
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
@Module({
imports: [SeatsModule, TicketsModule],
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
controllers: [PaymentsController, WebhooksController],
providers: [PaymentsService, TelebirrProvider, TelebirrWebhookService],
})

View File

@@ -3,56 +3,222 @@ 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 { PaymentIntentStatus } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto } from './payments.dto';
import { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
import { PaymentProvider } from './payments.types';
import { TelebirrProvider } from './providers/telebirr.provider';
import { createMerchantOrderId } from './providers/telebirr.crypto';
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
PaymentIntentStatus.REQUIRES_ACTION,
PaymentIntentStatus.PROCESSING,
PaymentIntentStatus.SUCCEEDED,
];
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
) {}
private telebirrProvider: TelebirrProvider,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
]);
}
async initiatePayment(dto: InitiatePaymentDto) {
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: { seats: true },
});
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
let result;
if (dto.method === 'WALLET') {
result = await this.prisma.$transaction(async (tx) => {
const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } });
if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' };
const newBalance = wallet.balanceMinor - booking.totalMinor;
await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } });
await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } });
return { success: true, providerRef: `WALLET-${Date.now()}` };
});
} else {
const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any;
result = await adapters[dto.method](booking.totalMinor, booking.bookingRef);
if (booking.status !== 'PENDING_PAYMENT') {
throw new BadRequestException('Booking not payable');
}
const status = result.success ? 'SUCCEEDED' : 'FAILED';
const intent = await this.prisma.paymentIntent.upsert({
const existing = await this.prisma.paymentIntent.findUnique({
where: { bookingId: dto.bookingId },
update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any },
create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any },
});
if (result.success) {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } });
await this.ticketsService.generate(dto.bookingId);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
return this.formatIntentResponse(existing);
}
return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success };
const method = dto.method as PaymentMethodType;
if (method === PaymentMethodType.WALLET) {
return this.initiateWalletPayment(booking);
}
const provider = this.providers.get(method);
if (provider) {
return this.initiateProviderPayment(booking, provider);
}
// TODO: convert CBE_BIRR, EBIRR, CARD into PaymentProvider implementations.
return this.initiateStubPayment(booking, method);
}
private async initiateWalletPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
): Promise<InitiateResponseDto> {
const debitResult = await this.prisma.$transaction(async (tx) => {
const wallet = await tx.walletAccount.findUnique({
where: { passengerId: booking.passengerId },
});
if (!wallet || wallet.balanceMinor < booking.totalMinor) {
return { success: false };
}
const newBalance = wallet.balanceMinor - booking.totalMinor;
await tx.walletAccount.update({
where: { passengerId: booking.passengerId },
data: { balanceMinor: newBalance },
});
await tx.walletLedgerEntry.create({
data: {
walletId: wallet.id,
type: 'DEBIT',
amountMinor: booking.totalMinor,
balanceAfterMinor: newBalance,
description: `Train Ticket - ${booking.bookingRef}`,
relatedBookingId: booking.id,
},
});
return { success: true };
});
if (!debitResult.success) {
const failed = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: {
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
},
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method: PaymentMethodType.WALLET,
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
},
});
return this.formatIntentResponse(failed);
}
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: { status: PaymentIntentStatus.PROCESSING },
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method: PaymentMethodType.WALLET,
status: PaymentIntentStatus.PROCESSING,
providerRef: `WALLET-${Date.now()}`,
},
});
await this.finalizePaymentSuccess({ intentId: intent.id });
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(refreshed);
}
private async initiateProviderPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
provider: PaymentProvider,
): Promise<InitiateResponseDto> {
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
merchantOrderId,
bookingRef: booking.bookingRef,
amountMinor: booking.totalMinor,
currency: booking.currency,
});
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: {
status: PaymentIntentStatus.REQUIRES_ACTION,
method: provider.method,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
expiresAt: result.expiresAt,
failureCode: null,
failureMessage: null,
},
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
currency: booking.currency,
method: provider.method,
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
expiresAt: result.expiresAt,
},
});
return this.formatIntentResponse(intent);
}
private async initiateStubPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
method: PaymentMethodType,
): Promise<InitiateResponseDto> {
const adapters = {
[PaymentMethodType.CBE_BIRR]: cbeBirrAdapter,
[PaymentMethodType.EBIRR]: eBirrAdapter,
[PaymentMethodType.CARD]: cardAdapter,
} as Partial<Record<PaymentMethodType, (a: number, ref: string) => Promise<{ success: boolean; providerRef: string }>>>;
const adapter = adapters[method];
if (!adapter) {
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
const result = await adapter(booking.totalMinor, booking.bookingRef);
const status = result.success ? PaymentIntentStatus.PROCESSING : PaymentIntentStatus.FAILED;
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: { status, providerRef: result.providerRef },
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method,
status,
providerRef: result.providerRef,
},
});
if (result.success) {
await this.finalizePaymentSuccess({ intentId: intent.id });
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(refreshed);
}
return this.formatIntentResponse(intent);
}
private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === 'object'
? (intent.clientAction as unknown as { type: 'REDIRECT'; url: string })
: undefined;
return {
intentId: intent.id,
status: intent.status,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
async refund(dto: RefundDto) {

View File

@@ -1,6 +1,10 @@
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,
@@ -26,8 +30,21 @@ const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
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) {}
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();
@@ -141,7 +158,7 @@ export class TelebirrProvider implements PaymentProvider {
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/preOrder`,
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
@@ -152,7 +169,7 @@ export class TelebirrProvider implements PaymentProvider {
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
const totalAmount = (input.amountMinor / 100).toFixed(2);
const totalAmount = String(input.amountMinor / 100);
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
@@ -191,26 +208,26 @@ export class TelebirrProvider implements PaymentProvider {
}
private buildCheckoutUrl(prepayId: string): string {
const fields: Record<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(fields, this.privateKey);
const query = [
`appid=${fields.appid}`,
`merch_code=${fields.merch_code}`,
`nonce_str=${fields.nonce_str}`,
`prepay_id=${fields.prepay_id}`,
`timestamp=${fields.timestamp}`,
`sign=${encodeURIComponent(sign)}`,
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}${query}`;
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
@@ -234,25 +251,25 @@ export class TelebirrProvider implements PaymentProvider {
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TELEBIRR_HTTP_TIMEOUT_MS);
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
const latency = Date.now() - started;
const text = await res.text();
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${latency}ms`);
if (!res.ok) {
throw new Error(`Telebirr request failed: ${res.status} ${text}`);
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}`);
}
return JSON.parse(text) as T;
} finally {
clearTimeout(timer);
throw err;
}
}