import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { ModuleRef } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { PaymentsService } from './payments.service'; // 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 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 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) { try { // Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones — // and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH // a lost confirm event (payment-api already SUCCEEDED) AND a payment recorded only at the // provider (local intent terminal). Idempotent, so a re-run is safe. const { paid } = await paymentsService.reconcileAndConfirmIfPaid(booking.id); if (paid) { this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); confirmed++; } // not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline) } catch (err) { this.logger.error( `Payment sync error for ${booking.bookingRef}: ` + `${err instanceof Error ? err.message : String(err)}`, ); errored++; } } if (confirmed > 0 || errored > 0) { this.logger.log( `Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`, ); } } }