diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 26ab854d3..ce389ef3b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -10,6 +10,8 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; +import { PaymentsService } from '../payments/payments.service'; +import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @@ -109,6 +111,7 @@ export class BookingsService { private readonly currencyService: CurrencyService, private readonly fareEngine: FareEngineService, private readonly auditService: AuditService, + private readonly paymentsService: PaymentsService, ) {} async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { @@ -2129,6 +2132,14 @@ export class BookingsService { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } }); if (!booking) throw new NotFoundException('Booking not found'); if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled'); + // Verify-before-cancel: a still-PENDING_PAYMENT booking may actually be paid (its confirm event + // was lost/late). reconcileAndConfirmIfPaid confirms it synchronously if so — refuse to cancel a + // paid, or currently-unverifiable, booking as "unpaid". + if (booking.status === 'PENDING_PAYMENT') { + const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id); + if (paid) throw new BadRequestException('Payment for this booking has completed; it is now confirmed and cannot be cancelled as unpaid.'); + if (!verified) throw new BadRequestException('Could not verify payment status right now; please try again shortly.'); + } const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0; await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); await this.seatsService.releaseSeats(booking.id); @@ -2265,11 +2276,24 @@ export class BookingsService { @Cron(CronExpression.EVERY_MINUTE) async expirePendingBookings() { - const cutoff = new Date(Date.now() - 20 * 60 * 1000); + // NEUTRALIZED (was 20 minutes): the payment window is MAX_PAYMENT_HOURS (2h). Bookings must + // NEVER be cancelled at 20 minutes — the payer still has up to 2 hours, and the seat hold is + // held for exactly this window. Aligned to the 2-hour window so this cron can only ever act as + // a safe backup to the primary deadline-aware sweep (TasksService.cancelExpiredPendingBookings); + // it never cancels prematurely, and paid bookings are still protected by the guard below. + const cutoff = new Date(Date.now() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } }); for (const b of expired) { - await this.seatsService.releaseSeats(b.id); - await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); + try { + // Never cancel a paid booking whose confirm event was lost/late — verify first (this + // confirms it synchronously if paid). Skip when paid or currently unverifiable. + const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(b.id); + if (paid || !verified) continue; + await this.seatsService.releaseSeats(b.id); + await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); + } catch (err) { + this.logger.error(`expirePendingBookings failed for ${b.id}: ${err instanceof Error ? err.message : String(err)}`); + } } } diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index 1ca92e304..e723dc8ce 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -22,6 +22,17 @@ export interface PaymentDiagnostic { provider: ProviderStatus | null; } +/** Settlement check from POST /payments/reconcile (verify-before-cancel). */ +export interface SettlementResult { + /** At least one intent for the order is paid (incl. a late capture just registered). */ + paid: boolean; + /** The paying intent when `paid`. */ + intent?: PaymentIntentSnapshot; + /** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the + * payment service was unreachable. The caller MUST NOT cancel the order. */ + unverifiable: boolean; +} + /** * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; @@ -85,6 +96,31 @@ export class PaymentClientService { } } + /** + * POST /payments/reconcile — settlement check before cancelling an order. Live-queries every + * intent at the provider and registers any late capture found. A transport failure (payment + * service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so + * the caller does not cancel a booking whose payment simply could not be verified. + */ + async reconcileByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + try { + return await this.call("POST", "/payments/reconcile", { + service: PaymentService.PASSENGER, + referenceType, + referenceId, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`, + ); + return { paid: false, unverifiable: true }; + } + } + /** * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank). * A wrong/expired OTP comes back as 400 from the payment service; surface that as a diff --git a/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts index 7be85714a..292c09416 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts @@ -2,9 +2,7 @@ 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, @@ -16,7 +14,6 @@ export class PaymentSyncService { constructor( private readonly prisma: PrismaService, - private readonly paymentClient: PaymentClientService, private readonly moduleRef: ModuleRef, ) {} @@ -47,7 +44,6 @@ export class PaymentSyncService { if (bookings.length === 0) return; let confirmed = 0; - let failed = 0; let errored = 0; // resolve() (not get()) because PaymentsService is scoped — same pattern @@ -59,37 +55,17 @@ export class PaymentSyncService { ); 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++; + // 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++; } - // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle + // not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline) } catch (err) { this.logger.error( `Payment sync error for ${booking.bookingRef}: ` + @@ -99,10 +75,9 @@ export class PaymentSyncService { } } - if (confirmed > 0 || failed > 0 || errored > 0) { + if (confirmed > 0 || errored > 0) { this.logger.log( - `Payment sync run: ${bookings.length} checked, ` + - `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, + `Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`, ); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index d0520998b..6fc91556a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -833,6 +833,70 @@ export class PaymentsService { return value; } + /** + * Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is + * actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a + * verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike + * getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even + * terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider. + * + * - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is + * now CONFIRMED, so a cancellation caller must NOT cancel. + * - not paid → verified unpaid; a cancellation caller may proceed. + * - unverifiable (provider query errored, in-flight, or payment service unreachable) → a + * cancellation caller must NOT cancel this cycle; defer and retry later. + */ + async reconcileAndConfirmIfPaid( + bookingId: string, + ): Promise<{ paid: boolean; verified: boolean }> { + const settlement = await this.paymentClient.reconcileByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + + if (settlement.unverifiable) { + this.logger.warn( + `reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`, + ); + return { paid: false, verified: false }; + } + + if (settlement.paid) { + if (settlement.intent) { + // Paid, but the confirm event may have been lost. Confirm synchronously (idempotent). + const intent = await this.syncIntentProjection( + bookingId, + settlement.intent, + ); + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: settlement.intent.providerTxnId, + paidAt: settlement.intent.paidAt + ? new Date(settlement.intent.paidAt) + : undefined, + }).catch((err) => { + this.logger.error( + `reconcile-before-cancel: finalize failed for booking ${bookingId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return { alreadyFinalized: false }; + }); + this.logger.log( + `reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`, + ); + } else { + this.logger.error( + `reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`, + ); + } + return { paid: true, verified: true }; + } + + // Verified not paid — safe to cancel. + return { paid: false, verified: true }; + } + async finalizePaymentSuccess(input: { intentId: string; providerTxnId?: string; 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 99d6b4d4e..f3d0692f7 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,7 @@ 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 { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -28,6 +29,7 @@ export class TasksService { private readonly prisma: PrismaService, private readonly sms: SmsClientService, private readonly currencyService: CurrencyService, + private readonly paymentsService: PaymentsService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -308,6 +310,18 @@ export class TasksService { const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); if (now < paymentDeadline) continue; + // Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded + // event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck + // PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously + // if paid. Only proceed to cancel when settlement is VERIFIED unpaid. + const settlement = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id); + if (settlement.paid || !settlement.verified) { + this.logger.log( + `Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`, + ); + continue; + } + // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index dc47adf6c..f17913338 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -20,7 +20,10 @@ import { IntentReferenceQueryDto, } from "./dto/initiate-payment.dto"; import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; -import { IntentsService } from "./intents.service"; +import { + IntentsService, + ReconcileReferenceResult, +} from "./intents.service"; import { PaymentIntent } from "./entities/payment-intent.entity"; /** @@ -108,6 +111,26 @@ export class IntentsController { return this.intentsService.getByMerchantOrderId(merchantOrderId, provider); } + @Post("reconcile") + @ApiOperation({ + summary: "Settlement check for a domain order (reconcile-before-cancel)", + description: + "Returns whether ANY intent for the reference is paid. Live-queries every non-failed intent " + + "— including already-retired (expired/cancelled) ones — at the provider and registers any " + + "late capture found (flips it to SUCCEEDED and emits payment.succeeded). " + + "`unverifiable: true` means settlement could not be confirmed (a provider query errored or a " + + "payment is still in flight) — the caller MUST NOT cancel the order in that case.", + }) + async reconcile( + @Body() dto: IntentReferenceQueryDto, + ): Promise { + return this.intentsService.reconcileReference( + dto.service, + dto.referenceType, + dto.referenceId, + ); + } + @Post("intents/:id/confirm") @ApiOperation({ summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts index b37aa7827..dcda00222 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.repository.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -58,6 +58,18 @@ export class IntentsRepository extends BaseRepository { }); } + /** Every intent for a domain order (newest first) — input for reconcile/settlement checks. */ + async findAllByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + return this.repository.find({ + where: { service, referenceType, referenceId }, + order: { createdAt: "DESC" }, + }); + } + async findByMerchantOrderId( merchantOrderId: string, ): Promise { diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 0d8ccb120..7b389af78 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -44,6 +44,19 @@ export interface ProviderResultInput { rawResponse?: Record; } +/** Result of {@link IntentsService.reconcileReference} — a settlement check for a domain order. */ +export interface ReconcileReferenceResult { + /** True when at least one intent for the order is settled (SUCCEEDED, incl. a just-registered late capture). */ + paid: boolean; + /** Snapshot of the paying intent when `paid`. */ + intent?: PaymentIntentSnapshot; + /** + * True when we could NOT confirm "not paid": at least one candidate intent's provider status + * query errored, so its settlement is unknown. Callers must treat this as "do not cancel". + */ + unverifiable: boolean; +} + @Injectable() export class IntentsService { private readonly logger = new Logger(IntentsService.name); @@ -301,6 +314,93 @@ export class IntentsService { return { db: intent ?? null, provider: providerStatus }; } + /** + * Settlement check for a domain order, tolerant of MANY intents. Used before the owning app + * cancels a still-unpaid booking — a paid booking whose `payment.succeeded` event was lost (MQ + * down, late/missing webhook) must NOT be cancelled. Resolution: + * 1. any intent already SUCCEEDED → paid; + * 2. else live-query every non-FAILED intent (incl. expired/cancelled — the session may have + * been paid after we retired it) and feed the result through the state machine, so a paid + * terminal intent is registered SUCCEEDED (and emits payment.succeeded) → paid; + * 3. else not paid. + * `unverifiable` is set when a candidate's provider query errored, or a payment is still in + * flight (PROCESSING) — i.e. we could NOT confirm "not paid"; the caller must then NOT cancel. + */ + async reconcileReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const intents = await this.intentsRepository.findAllByReference( + service, + referenceType, + referenceId, + ); + if (intents.length === 0) { + return { paid: false, unverifiable: false }; + } + + const alreadyPaid = intents.find( + (i) => i.status === ProviderPaymentStatus.SUCCEEDED, + ); + if (alreadyPaid) { + return { + paid: true, + intent: this.toSnapshot(alreadyPaid), + unverifiable: false, + }; + } + + // Live-query every intent that could plausibly hold a payment (skip FAILED; SUCCEEDED handled + // above). A CANCELLED/EXPIRED intent may still have been paid at the provider after we retired it. + const candidates = intents.filter( + (i) => i.status !== ProviderPaymentStatus.FAILED, + ); + let providerErrors = 0; + let inFlight = false; + for (const intent of candidates) { + let status: ProviderStatus; + try { + status = await this.queryProviderStatus(intent); + } catch (err) { + providerErrors++; + this.logger.warn( + `reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + + if ( + status.status === ProviderPaymentStatus.SUCCEEDED || + status.status === ProviderPaymentStatus.PROCESSING + ) { + // Register the (possibly late) result through the state machine — a terminal intent that + // was paid flips to SUCCEEDED and emits payment.succeeded. + await this.applyProviderResult( + intent.id, + this.fromProviderStatus(status), + ); + } + + if (status.status === ProviderPaymentStatus.SUCCEEDED) { + const refreshed = + (await this.intentsRepository.findById(intent.id)) ?? intent; + return { + paid: true, + intent: this.toSnapshot(refreshed), + unverifiable: false, + }; + } + if (status.status === ProviderPaymentStatus.PROCESSING) { + inFlight = true; // money in flight — not settled, but not safe to cancel either + } + } + + return { paid: false, unverifiable: providerErrors > 0 || inFlight }; + } + /** Best-effort live provider status for a merchant order id; never throws (returns null). */ private async queryProviderForMerchantOrder( merchantOrderId: string,