Files
edr-platform/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts

85 lines
3.5 KiB
TypeScript

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`,
);
}
}
}