mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user