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 6e4be1aa0..2a3906398 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -72,6 +72,6 @@ function rabbitMQImport(): DynamicModule[] { PaymentEventsConsumer, ServiceAuthGuard, ], - exports: [PaymentClientService], + exports: [PaymentClientService, PaymentsService], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index fb8a29a6e..335d11f81 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; +import { PaymentsModule } from '../payments/payments.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 4fb3a4f0f..7a9208cae 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; +import { PaymentsService } from '../payments/payments.service'; +import { PaymentClientService } from '../payments/payment-client.service'; +import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -28,6 +31,8 @@ export class TasksService { private readonly prisma: PrismaService, private readonly sms: SmsClientService, private readonly currencyService: CurrencyService, + private readonly paymentsService: PaymentsService, + private readonly paymentClient: PaymentClientService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -253,6 +258,87 @@ export class TasksService { } } + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings + // whose payment intent has moved to SUCCEEDED on the gateway but whose + // confirmation event was never delivered (missed RabbitMQ message, network + // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running + // it for an already-confirmed booking is safe. + // + // Processes at most 50 bookings per cycle to avoid hammering the payment + // service; the next tick picks up the remainder. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async syncPaymentStatuses() { + const BATCH_SIZE = 50; + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + }, + include: { paymentIntent: true }, + take: BATCH_SIZE, + orderBy: { createdAt: 'asc' }, + }); + + if (bookings.length === 0) return; + + let confirmed = 0; + let failed = 0; + let errored = 0; + + for (const booking of bookings) { + if (!booking.paymentIntent) continue; + + try { + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + booking.id, + ); + + if (!snapshot) continue; + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + const result = await this.paymentsService.finalizePaymentSuccess({ + intentId: booking.paymentIntent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + if (!result.alreadyFinalized) { + this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); + confirmed++; + } + } else if ( + snapshot.status === ProviderPaymentStatus.FAILED || + snapshot.status === ProviderPaymentStatus.CANCELLED + ) { + // The payment deadline enforcer will cancel the booking when its + // window expires; log now so operations can see failed intents early. + this.logger.warn( + `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + + `booking will be auto-cancelled at payment deadline`, + ); + failed++; + } + // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle + } catch (err) { + this.logger.error( + `Payment sync error for ${booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + errored++; + } + } + + if (confirmed > 0 || failed > 0 || errored > 0) { + this.logger.log( + `Payment sync run: ${bookings.length} checked, ` + + `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, + ); + } + } + // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ─────────────────────────────────────────────────────────────────────────