diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index bee21ed65..fa6f76e90 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -18,6 +18,15 @@ SENDGRID_API_KEY= SENDGRID_FROM_EMAIL=noreply@edr-platform.com # Telebirr -TELEBIRR_API_URL=https://api.telebirr.com -TELEBIRR_APP_ID= -TELEBIRR_APP_KEY= +TELEBIRR_BASE_URL= +TELEBIRR_WEB_BASE_URL= +TELEBIRR_FABRIC_APP_ID= +TELEBIRR_APP_SECRET= +TELEBIRR_MERCHANT_APP_ID= +TELEBIRR_MERCHANT_CODE= +TELEBIRR_NOTIFY_URL= +TELEBIRR_RETURN_URL= +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false \ No newline at end of file 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/prisma/migrations/20260516170103_add_telebirr_async_fields/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260516170103_add_telebirr_async_fields/migration.sql new file mode 100644 index 000000000..d3cd87873 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260516170103_add_telebirr_async_fields/migration.sql @@ -0,0 +1,63 @@ +/* + Warnings: + + - A unique constraint covering the columns `[merchantOrderId]` on the table `PaymentIntent` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "PaymentIntent" ADD COLUMN "expiresAt" TIMESTAMP(3), +ADD COLUMN "failureCode" TEXT, +ADD COLUMN "failureMessage" TEXT, +ADD COLUMN "merchantOrderId" TEXT, +ADD COLUMN "paidAt" TIMESTAMP(3), +ADD COLUMN "providerOrderId" TEXT, +ADD COLUMN "providerTxnId" TEXT, +ADD COLUMN "rawInitiation" JSONB; + +-- CreateTable +CREATE TABLE "PaymentWebhookEvent" ( + "id" TEXT NOT NULL, + "provider" "PaymentMethodType" NOT NULL, + "externalEventId" TEXT NOT NULL, + "merchantOrderId" TEXT, + "providerTxnId" TEXT, + "signatureValid" BOOLEAN NOT NULL, + "status" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processedAt" TIMESTAMP(3), + "processingError" TEXT, + + CONSTRAINT "PaymentWebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PaymentRefund" ( + "id" TEXT NOT NULL, + "paymentIntentId" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "reason" TEXT, + "providerRefundId" TEXT, + "status" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PaymentRefund_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"("merchantOrderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentIntent_merchantOrderId_key" ON "PaymentIntent"("merchantOrderId"); + +-- CreateIndex +CREATE INDEX "PaymentIntent_providerOrderId_idx" ON "PaymentIntent"("providerOrderId"); + +-- CreateIndex +CREATE INDEX "PaymentIntent_providerTxnId_idx" ON "PaymentIntent"("providerTxnId"); + +-- AddForeignKey +ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 3f6bef397..f55cd2b92 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -343,17 +343,55 @@ model PaymentMethod { } model PaymentIntent { - id String @id @default(uuid()) - bookingId String @unique - amountMinor Int - currency String @default("ETB") - method PaymentMethodType - status PaymentIntentStatus @default(REQUIRES_ACTION) - providerRef String? - clientAction Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - booking Booking @relation(fields: [bookingId], references: [id]) + id String @id @default(uuid()) + bookingId String @unique + amountMinor Int + currency String @default("ETB") + method PaymentMethodType + status PaymentIntentStatus @default(REQUIRES_ACTION) + providerRef String? + clientAction Json? + merchantOrderId String? @unique + providerOrderId String? + providerTxnId String? + rawInitiation Json? + paidAt DateTime? + failureCode String? + failureMessage String? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + booking Booking @relation(fields: [bookingId], references: [id]) + refunds PaymentRefund[] + @@index([providerOrderId]) + @@index([providerTxnId]) +} + +model PaymentWebhookEvent { + id String @id @default(uuid()) + provider PaymentMethodType + externalEventId String + merchantOrderId String? + providerTxnId String? + signatureValid Boolean + status String + payload Json + receivedAt DateTime @default(now()) + processedAt DateTime? + processingError String? + @@unique([provider, externalEventId]) + @@index([merchantOrderId]) +} + +model PaymentRefund { + id String @id @default(uuid()) + paymentIntentId String + amountMinor Int + reason String? + providerRefundId String? + status String + createdAt DateTime @default(now()) + paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) } model Ticket { diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 9fdfd90a6..71f8569db 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -5,6 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { PrismaModule } from './common/prisma.module'; import appConfig from './config/app.config'; import dbConfig from './config/database.config'; +import telebirrConfig from './config/telebirr.config'; import { AuthModule } from './modules/auth/auth.module'; import { StationsModule } from './modules/stations/stations.module'; import { FleetModule } from './modules/fleet/fleet.module'; @@ -27,7 +28,7 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }), + ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig, telebirrConfig] }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), PrismaModule, diff --git a/apps/edr-passenger-api/src/config/telebirr.config.ts b/apps/edr-passenger-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..45e3a79ff --- /dev/null +++ b/apps/edr-passenger-api/src/config/telebirr.config.ts @@ -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', +})); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index b153ebf80..9e1689df9 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -11,6 +11,7 @@ import { JwtGuard } from '../../common/jwt.guard'; export class PaymentsController { constructor(private service: PaymentsService) {} @Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); } + @Get('intents/:bookingId') @ApiOperation({ summary: 'Get payment intent status for a booking' }) getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } @Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } @Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); } 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..adeee8b22 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,25 @@ 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; +} + +export class IntentStatusDto { + @ApiProperty() intentId: string; + @ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus; + @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; + @ApiPropertyOptional() merchantOrderId?: string; + @ApiPropertyOptional() paidAt?: string; + @ApiPropertyOptional() failureCode?: string; + @ApiPropertyOptional() failureMessage?: 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 9b2a3d738..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,8 +1,16 @@ 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'; import { TicketsModule } from '../tickets/tickets.module'; +import { TelebirrProvider } from './providers/telebirr.provider'; +import { WebhooksController } from './webhooks/webhooks.controller'; +import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service'; -@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] }) +@Module({ + imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })], + controllers: [PaymentsController, WebhooksController], + providers: [PaymentsService, TelebirrProvider, TelebirrWebhookService], +}) export class PaymentsModule {} 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 8f1df1c7b..4eeb6bbe4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1,56 +1,295 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; 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 { 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, IntentStatusDto } from './payments.dto'; +import { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters'; +import { PaymentProvider, ProviderStatus } 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 getIntentByBookingId(bookingId: string): Promise { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId }, + }); + if (!intent) throw new NotFoundException('PaymentIntent not found'); + + const refreshable = + intent.status === PaymentIntentStatus.REQUIRES_ACTION || + intent.status === PaymentIntentStatus.PROCESSING; + const stale = intent.updatedAt.getTime() < Date.now() - 5_000; + const provider = this.providers.get(intent.method); + + if (refreshable && stale && intent.merchantOrderId && provider) { + try { + const status = await provider.queryStatus(intent.merchantOrderId); + await this.applyProviderStatus(intent.id, status); + const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, + }); + return this.formatIntentStatus(refreshed); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, + ); + } + } + + return this.formatIntentStatus(intent); + } + + private async applyProviderStatus( + intentId: string, + status: ProviderStatus, + ): Promise { + if (status.status === PaymentIntentStatus.SUCCEEDED) { + await this.finalizePaymentSuccess({ + intentId, + providerTxnId: status.providerTxnId, + }); + return; + } + if (status.status === PaymentIntentStatus.FAILED) { + await this.markPaymentFailed({ + intentId, + failureCode: status.failureCode, + failureMessage: status.failureMessage, + }); + return; + } + await this.prisma.paymentIntent.update({ + where: { id: intentId }, + data: { + status: status.status, + providerTxnId: status.providerTxnId ?? undefined, + }, + }); + } + + private formatIntentStatus( + intent: Prisma.PaymentIntentGetPayload>, + ): IntentStatusDto { + const base = this.formatIntentResponse(intent); + return { + ...base, + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failureCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; } async refund(dto: RefundDto) { @@ -69,6 +308,76 @@ export class PaymentsService { getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); } + async finalizePaymentSuccess(input: { + intentId: string; + providerTxnId?: string; + paidAt?: Date; + }): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { id: input.intentId }, + }); + if (!intent) throw new NotFoundException('PaymentIntent not found'); + if (intent.status === PaymentIntentStatus.SUCCEEDED) { + return { alreadyFinalized: true }; + } + if (intent.status === PaymentIntentStatus.CANCELLED) { + throw new BadRequestException('PaymentIntent is cancelled; cannot finalize'); + } + + const booking = await this.prisma.booking.findUnique({ + where: { id: intent.bookingId }, + include: { seats: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + const paidAt = input.paidAt ?? new Date(); + await this.prisma.$transaction(async (tx) => { + await tx.paymentIntent.update({ + where: { id: intent.id }, + data: { + status: PaymentIntentStatus.SUCCEEDED, + providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, + paidAt, + }, + }); + await tx.booking.update({ + where: { id: booking.id }, + data: { status: 'CONFIRMED' }, + }); + }); + + await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); + await this.ticketsService.generate(booking.id); + await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + this.eventEmitter.emit('payment.succeeded', { booking }); + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { id: input.intentId }, + }); + if (!intent) throw new NotFoundException('PaymentIntent not found'); + if ( + intent.status === PaymentIntentStatus.SUCCEEDED || + intent.status === PaymentIntentStatus.CANCELLED + ) { + return; + } + await this.prisma.paymentIntent.update({ + where: { id: intent.id }, + data: { + status: PaymentIntentStatus.FAILED, + failureCode: input.failureCode, + failureMessage: input.failureMessage, + }, + }); + } + private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) { const points = Math.floor(amountMinor / 100); const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts new file mode 100644 index 000000000..336f4b511 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -0,0 +1,34 @@ +import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; + +export interface ClientAction { + type: 'REDIRECT'; + url: string; +} + +export interface ProviderInitiationInput { + merchantOrderId: string; + bookingRef: string; + amountMinor: number; + currency: string; +} + +export interface ProviderInitiationResult { + providerOrderId: string; + clientAction: ClientAction; + expiresAt: Date; + rawInitiation: Record; +} + +export interface ProviderStatus { + status: PaymentIntentStatus; + providerTxnId?: string; + failureCode?: string; + failureMessage?: string; + rawResponse: Record; +} + +export interface PaymentProvider { + readonly method: PaymentMethodType; + initiate(input: ProviderInitiationInput): Promise; + queryStatus(merchantOrderId: string): Promise; +} diff --git a/apps/edr-passenger-api/src/modules/payments/providers/payment-provider.interface.ts b/apps/edr-passenger-api/src/modules/payments/providers/payment-provider.interface.ts new file mode 100644 index 000000000..a571f28be --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/payment-provider.interface.ts @@ -0,0 +1,9 @@ +export type { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, + ClientAction, +} from '../payments.types'; + +export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS'); diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts new file mode 100644 index 000000000..12ea9624a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts @@ -0,0 +1,98 @@ +import * as crypto from 'crypto'; + +const EXCLUDE_FIELDS = new Set([ + 'sign', + 'sign_type', + 'header', + 'refund_info', + 'openType', + 'raw_request', + 'biz_content', +]); + +const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +export function buildCanonicalString(requestObject: Record): string { + const fieldMap: Record = {}; + + for (const key of Object.keys(requestObject)) { + if (EXCLUDE_FIELDS.has(key)) continue; + fieldMap[key] = requestObject[key]; + } + + const biz = requestObject['biz_content']; + if (biz && typeof biz === 'object') { + for (const key of Object.keys(biz as Record)) { + if (EXCLUDE_FIELDS.has(key)) continue; + fieldMap[key] = (biz as Record)[key]; + } + } + + return Object.keys(fieldMap) + .sort() + .map((k) => `${k}=${fieldMap[k]}`) + .join('&'); +} + +export function signRequestObject( + requestObject: Record, + privateKey: string, +): string { + return signString(buildCanonicalString(requestObject), privateKey); +} + +export function verifyRequestObject( + requestObject: Record, + publicKey: string, +): boolean { + const signature = requestObject['sign']; + if (typeof signature !== 'string' || signature.length === 0) return false; + return verifySignature(buildCanonicalString(requestObject), signature, publicKey); +} + +export function signString(text: string, privateKey: string): string { + const signature = crypto.sign('sha256', Buffer.from(text), { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }); + return signature.toString('base64'); +} + +export function verifySignature( + text: string, + signatureBase64: string, + publicKey: string, +): boolean { + try { + return crypto.verify( + 'sha256', + Buffer.from(text), + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }, + Buffer.from(signatureBase64, 'base64'), + ); + } catch { + return false; + } +} + +export function createTimestamp(): string { + return Math.round(Date.now() / 1000).toString(); +} + +export function createNonceStr(length = 32): string { + const bytes = crypto.randomBytes(length); + let out = ''; + for (let i = 0; i < length; i++) { + out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length]; + } + return out; +} + +export function createMerchantOrderId(): string { + return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`; +} 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 new file mode 100644 index 000000000..21980a175 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts @@ -0,0 +1,291 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import * as https from 'node:https'; +import { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, +} from '../payments.types'; +import { + createNonceStr, + createTimestamp, + signRequestObject, + verifyRequestObject, +} from './telebirr.crypto'; +import { + CreateOrderRequest, + CreateOrderResponse, + FabricTokenResponse, + QueryOrderResponse, +} from './telebirr.types'; + +const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; + +@Injectable() +export class TelebirrProvider implements PaymentProvider { + readonly method = PaymentMethodType.TELEBIRR; + private readonly logger = new Logger(TelebirrProvider.name); + private readonly httpsAgent: https.Agent; + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) { + const insecure = this.config.get('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(); + const requestBody = this.buildCreateOrderRequest(input); + const response = await this.requestCreateOrder(fabricToken, requestBody); + + const prepayId = response.biz_content?.prepay_id; + if (!prepayId) { + throw new Error( + `Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`, + ); + } + + const checkoutUrl = this.buildCheckoutUrl(prepayId); + const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express); + + return { + providerOrderId: prepayId, + clientAction: { type: 'REDIRECT', url: checkoutUrl }, + expiresAt, + rawInitiation: { + request: this.sanitize(requestBody), + response, + }, + }; + } + + async queryStatus(merchantOrderId: string): Promise { + const fabricToken = await this.applyFabricToken(); + const requestBody = this.buildQueryOrderRequest(merchantOrderId); + const response = await this.postJson( + `${this.baseUrl}/payment/v1/merchant/queryOrder`, + requestBody, + { + 'Content-Type': 'application/json', + 'X-APP-Key': this.fabricAppId, + Authorization: fabricToken, + }, + ); + + const tradeStatus = response.biz_content?.trade_status; + const providerTxnId = + response.biz_content?.trans_id ?? response.biz_content?.payment_order_id; + const mapped = this.mapTradeStatus(tradeStatus); + + return { + status: mapped, + providerTxnId, + failureCode: + mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined, + rawResponse: response as Record, + }; + } + + mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { + switch (tradeStatus) { + case 'PAY_SUCCESS': + return PaymentIntentStatus.SUCCEEDED; + case 'PAY_FAILED': + case 'ORDER_CLOSED': + return PaymentIntentStatus.FAILED; + case 'WAIT_PAY': + return PaymentIntentStatus.REQUIRES_ACTION; + case 'PAYING': + return PaymentIntentStatus.PROCESSING; + default: + return PaymentIntentStatus.PROCESSING; + } + } + + mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { + switch (tradeStatus) { + case 'Completed': + return PaymentIntentStatus.SUCCEEDED; + case 'Failure': + case 'Expired': + return PaymentIntentStatus.FAILED; + case 'Paying': + case 'Pending': + return PaymentIntentStatus.PROCESSING; + default: + return PaymentIntentStatus.PROCESSING; + } + } + + verifyWebhookSignature(payload: Record): boolean { + if (!this.publicKey) { + this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks'); + return false; + } + return verifyRequestObject(payload, this.publicKey); + } + + private async applyFabricToken(): Promise { + const response = await this.postJson( + `${this.baseUrl}/payment/v1/token`, + { appSecret: this.appSecret }, + { + 'Content-Type': 'application/json', + 'X-APP-Key': this.fabricAppId, + }, + ); + if (!response?.token) { + throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`); + } + return response.token; + } + + private async requestCreateOrder( + fabricToken: string, + body: CreateOrderRequest, + ): Promise { + return this.postJson( + `${this.baseUrl}/payment/v1/inapp/createOrder`, + body, + { + 'Content-Type': 'application/json', + 'X-APP-Key': this.fabricAppId, + Authorization: fabricToken, + }, + ); + } + + private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { + const totalAmount = String(input.amountMinor / 100); + const req = { + timestamp: createTimestamp(), + nonce_str: createNonceStr(), + method: 'payment.preorder' as const, + version: '1.0' as const, + biz_content: { + notify_url: this.notifyUrl, + appid: this.merchantAppId, + merch_code: this.merchantCode, + merch_order_id: input.merchantOrderId, + trade_type: 'Checkout' as const, + title: `EDR Booking ${input.bookingRef}`, + total_amount: totalAmount, + trans_currency: input.currency, + timeout_express: this.timeoutExpress, + }, + }; + const sign = signRequestObject(req as unknown as Record, this.privateKey); + return { ...req, sign, sign_type: 'SHA256WithRSA' }; + } + + private buildQueryOrderRequest(merchantOrderId: string): Record { + const req = { + timestamp: createTimestamp(), + nonce_str: createNonceStr(), + method: 'payment.queryorder', + version: '1.0', + biz_content: { + appid: this.merchantAppId, + merch_code: this.merchantCode, + merch_order_id: merchantOrderId, + }, + }; + const sign = signRequestObject(req as Record, this.privateKey); + return { ...req, sign, sign_type: 'SHA256WithRSA' }; + } + + private buildCheckoutUrl(prepayId: string): string { + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + }; + const sign = signRequestObject(map, this.privateKey); + const rawRequest = [ + `appid=${map.appid}`, + `merch_code=${map.merch_code}`, + `nonce_str=${map.nonce_str}`, + `prepay_id=${map.prepay_id}`, + `timestamp=${map.timestamp}`, + 'sign_type=SHA256WithRSA', + `sign=${sign}`, + 'version=1.0', + 'trade_type=Checkout', + ].join('&'); + return `${this.webBaseUrl}${rawRequest}`; + } + + private computeExpiresAt(timeoutExpress: string): Date { + const match = /^(\d+)([smhd])$/.exec(timeoutExpress); + const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15; + return new Date(Date.now() + minutes * 60_000); + } + + private toMinutes(n: number, unit: string): number { + switch (unit) { + case 's': return Math.max(1, Math.round(n / 60)); + case 'm': return n; + case 'h': return n * 60; + case 'd': return n * 60 * 24; + default: return 15; + } + } + + private async postJson( + url: string, + body: unknown, + headers: Record, + ): Promise { + const config: AxiosRequestConfig = { + headers, + timeout: TELEBIRR_HTTP_TIMEOUT_MS, + httpsAgent: this.httpsAgent, + }; + const started = Date.now(); + try { + 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}`); + } + throw err; + } + } + + private sanitize(body: CreateOrderRequest): Record { + const { sign: _sign, ...rest } = body; + return rest; + } + + private get baseUrl(): string { return this.config.get('telebirr.baseUrl') ?? ''; } + private get webBaseUrl(): string { return this.config.get('telebirr.webBaseUrl') ?? ''; } + private get fabricAppId(): string { return this.config.get('telebirr.fabricAppId') ?? ''; } + private get appSecret(): string { return this.config.get('telebirr.appSecret') ?? ''; } + private get merchantAppId(): string { return this.config.get('telebirr.merchantAppId') ?? ''; } + private get merchantCode(): string { return this.config.get('telebirr.merchantCode') ?? ''; } + private get notifyUrl(): string { return this.config.get('telebirr.notifyUrl') ?? ''; } + private get timeoutExpress(): string { return this.config.get('telebirr.timeoutExpress') ?? '15m'; } + private get privateKey(): string { return this.config.get('telebirr.privateKey') ?? ''; } + private get publicKey(): string { return this.config.get('telebirr.publicKey') ?? ''; } +} diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts new file mode 100644 index 000000000..b540c1551 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts @@ -0,0 +1,69 @@ +export interface FabricTokenResponse { + token: string; + expires_in?: number | string; +} + +export interface CreateOrderBizContent { + notify_url: string; + appid: string; + merch_code: string; + merch_order_id: string; + trade_type: 'Checkout' | 'InApp' | 'MiniApp'; + title: string; + total_amount: string; + trans_currency: string; + timeout_express: string; +} + +export interface CreateOrderRequest { + timestamp: string; + nonce_str: string; + method: 'payment.preorder'; + version: '1.0'; + biz_content: CreateOrderBizContent; + sign: string; + sign_type: 'SHA256WithRSA'; +} + +export interface CreateOrderResponse { + code?: string; + msg?: string; + biz_content?: { + prepay_id?: string; + receiveCode?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type TelebirrTradeStatus = + | 'PAY_SUCCESS' + | 'PAY_FAILED' + | 'WAIT_PAY' + | 'ORDER_CLOSED' + | 'PAYING' + | 'ACCEPTED' + | 'REFUNDING' + | 'REFUND_SUCCESS' + | 'REFUND_FAILED'; + +export interface QueryOrderResponse { + result?: 'SUCCESS' | 'FAIL'; + code?: string; + msg?: string; + nonce_str?: string; + sign?: string; + sign_type?: string; + biz_content?: { + merch_order_id?: string; + order_status?: string; + trade_status?: TelebirrTradeStatus | string; + payment_order_id?: string; + trans_id?: string; + trans_time?: string; + trans_currency?: string; + total_amount?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts new file mode 100644 index 000000000..bb9b2127e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts @@ -0,0 +1,153 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +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 { + private readonly logger = new Logger(TelebirrWebhookService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly provider: TelebirrProvider, + private readonly payments: PaymentsService, + ) {} + + async handle(payload: TelebirrWebhookPayload): Promise { + const merchantOrderId = payload.merch_order_id; + const externalEventId = this.buildExternalEventId(payload); + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + + const eventRow = await this.persistEvent({ + externalEventId, + merchantOrderId, + providerTxnId: payload.trans_id ?? payload.payment_order_id, + signatureValid, + status: payload.trade_status, + payload, + }); + + if (!eventRow) { + this.logger.log( + `Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`, + ); + return; + } + + if (!signatureValid) { + this.logger.warn( + `Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`, + ); + await this.markProcessed(eventRow.id, 'signature-invalid'); + return; + } + + const intent = await this.prisma.paymentIntent.findUnique({ + where: { merchantOrderId }, + }); + if (!intent) { + this.logger.warn( + `Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`, + ); + await this.markProcessed(eventRow.id, 'intent-not-found'); + return; + } + + const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); + + try { + if (mapped === PaymentIntentStatus.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) { + await this.payments.markPaymentFailed({ + intentId: intent.id, + failureCode: payload.trade_status, + }); + } else { + await this.prisma.paymentIntent.update({ + where: { id: intent.id }, + data: { status: mapped, providerTxnId: payload.trans_id ?? undefined }, + }); + } + await this.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.markProcessed(eventRow.id, `processing-error: ${message}`); + throw err; + } + } + + private buildExternalEventId(payload: TelebirrWebhookPayload): string { + return `${payload.payment_order_id}_${payload.trade_status}`; + } + + private async persistEvent(input: { + externalEventId: string; + merchantOrderId: string; + providerTxnId?: string; + signatureValid: boolean; + status: string; + payload: TelebirrWebhookPayload; + }): Promise<{ id: string } | null> { + try { + return await this.prisma.paymentWebhookEvent.create({ + data: { + provider: PaymentMethodType.TELEBIRR, + externalEventId: input.externalEventId, + merchantOrderId: input.merchantOrderId, + providerTxnId: input.providerTxnId, + signatureValid: input.signatureValid, + status: input.status, + payload: input.payload as unknown as Prisma.InputJsonValue, + }, + select: { id: true }, + }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + return null; + } + throw err; + } + } + + private async markProcessed(eventId: string, processingError?: string): Promise { + await this.prisma.paymentWebhookEvent.update({ + where: { id: eventId }, + data: { processedAt: new Date(), processingError }, + }); + } + + 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); + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts new file mode 100644 index 000000000..3d5e0a3a0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + TelebirrWebhookPayload, + 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) {} + + @Post('telebirr') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Telebirr payment notification callback' }) + async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { + 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' }; + } +}