From cfa8c6744829085a663bc113c12b42716de26195 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sun, 17 May 2026 22:57:20 +0300 Subject: [PATCH] refactor: ( telebirr ) wire async initiate flow through TelebirrProvider --- apps/edr-passenger-api/package.json | 16 +- .../src/modules/payments/payments.dto.ts | 13 + .../src/modules/payments/payments.module.ts | 3 +- .../src/modules/payments/payments.service.ts | 230 +++++++++++++++--- .../payments/providers/telebirr.provider.ts | 75 +++--- 5 files changed, 272 insertions(+), 65 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 87d0b4bbf..66558965a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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" } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 798e4751d..e943bd1e5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 50f60d52f..0b898f467 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -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], }) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 3ba957565..6c7e1d845 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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; + constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, - ) {} + private telebirrProvider: TelebirrProvider, + ) { + this.providers = new Map([ + [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 { + 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 (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + return this.formatIntentResponse(existing); + } + + 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 { + 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 (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 (!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); } - return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success }; + 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 { + 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 { + const adapters = { + [PaymentMethodType.CBE_BIRR]: cbeBirrAdapter, + [PaymentMethodType.EBIRR]: eBirrAdapter, + [PaymentMethodType.CARD]: cardAdapter, + } as Partial 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>, + ): 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) { diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts index cedf8aeeb..21980a175 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts @@ -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('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 { const fabricToken = await this.applyFabricToken(); @@ -141,7 +158,7 @@ export class TelebirrProvider implements PaymentProvider { body: CreateOrderRequest, ): Promise { return this.postJson( - `${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 = { + const map: Record = { 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, ): Promise { - 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(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; } }