feat: ( payment ) add GET /payments/intents/:bookingId for status polling

This commit is contained in:
Abubeker Yasin
2026-05-17 23:24:24 +03:00
parent cfa8c67448
commit 1c474ca744
3 changed files with 84 additions and 2 deletions

View File

@@ -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); }

View File

@@ -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;
}

View File

@@ -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<IntentStatusDto> {
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<void> {
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<Record<string, never>>,
): 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');