import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { ModuleRef } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { PaymentClientService } from './payment-client.service'; import { PaymentsService } from './payments.service'; import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; // PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up). // This service keeps only singleton deps so its @Cron method registers correctly, // then resolves PaymentsService per-tick via ModuleRef (same pattern as // PaymentEventsConsumer). @Injectable() export class PaymentSyncService { private readonly logger = new Logger(PaymentSyncService.name); constructor( private readonly prisma: PrismaService, private readonly paymentClient: PaymentClientService, private readonly moduleRef: ModuleRef, ) {} // ───────────────────────────────────────────────────────────────────────── // 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; // resolve() (not get()) because PaymentsService is scoped — same pattern // as PaymentEventsConsumer. const paymentsService = await this.moduleRef.resolve( PaymentsService, undefined, { strict: false }, ); 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 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 ) { 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`, ); } } }