mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat: ( payment ): verify-before-cancel never cancel a paid booking
This commit is contained in:
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SettlementResult> {
|
||||
try {
|
||||
return await this.call<SettlementResult>("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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user