From 1c474ca7444666c31b5806cd217a201d70607cf7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sun, 17 May 2026 23:24:24 +0300 Subject: [PATCH] feat: ( payment ) add GET /payments/intents/:bookingId for status polling --- .../modules/payments/payments.controller.ts | 1 + .../src/modules/payments/payments.dto.ts | 10 +++ .../src/modules/payments/payments.service.ts | 75 ++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) 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 e943bd1e5..adeee8b22 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -33,3 +33,13 @@ export class InitiateResponseDto { @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.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 6c7e1d845..4eeb6bbe4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -4,9 +4,9 @@ import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto } from './payments.dto'; +import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto'; import { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters'; -import { PaymentProvider } from './payments.types'; +import { PaymentProvider, ProviderStatus } from './payments.types'; import { TelebirrProvider } from './providers/telebirr.provider'; import { createMerchantOrderId } from './providers/telebirr.crypto'; @@ -221,6 +221,77 @@ export class PaymentsService { }; } + 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) { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } }); if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');