mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 09:28:19 +00:00
Extend seat hold time if booking created
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for how long a PENDING_PAYMENT booking has to be paid for,
|
||||||
|
* shared by TasksService (which auto-cancels bookings past this deadline) and
|
||||||
|
* SeatsService (which extends the seat hold to cover exactly this window when a
|
||||||
|
* booking/PNR is created — without this, the seat hold reverted to its original
|
||||||
|
* short seat-selection TTL and could expire mid-payment, letting a second customer
|
||||||
|
* grab the same seat).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||||
|
export const MAX_PAYMENT_HOURS = 2;
|
||||||
|
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||||
|
export const CUTOFF_MINUTES = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||||
|
*/
|
||||||
|
export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
||||||
|
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||||
|
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
||||||
|
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { SegmentsService } from '../segments/segments.service';
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatsService {
|
export class SeatsService {
|
||||||
@@ -625,7 +626,50 @@ export class SeatsService {
|
|||||||
return { released: true, holdId };
|
return { released: true, holdId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmSeats(_seatIds: string[]) {}
|
// Called right after a booking (PNR) is created, and again on successful payment.
|
||||||
|
// Extends the SeatHold(s) covering these seats to the booking's actual payment
|
||||||
|
// deadline — the same MIN(createdAt + 2h, departureAt - 30min) window TasksService
|
||||||
|
// uses to auto-cancel unpaid bookings — instead of leaving them on the original
|
||||||
|
// short seat-selection hold (5 min by default). Without this, the hold could expire
|
||||||
|
// while the customer was still on the payment page, and a second customer could
|
||||||
|
// hold/book the exact same seat out from under them.
|
||||||
|
async confirmSeats(seatIds: string[], now: Date = new Date()): Promise<void> {
|
||||||
|
if (seatIds.length === 0) return;
|
||||||
|
|
||||||
|
const holds = await this.prisma.seatHold.findMany({
|
||||||
|
where: { seatIds: { hasSome: seatIds } },
|
||||||
|
select: { id: true, scheduleId: true, expiresAt: true },
|
||||||
|
});
|
||||||
|
if (holds.length === 0) return;
|
||||||
|
|
||||||
|
const scheduleIds = Array.from(new Set(holds.map(h => h.scheduleId)));
|
||||||
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
|
where: { id: { in: scheduleIds } },
|
||||||
|
select: { id: true, departureAt: true },
|
||||||
|
});
|
||||||
|
const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
|
||||||
|
|
||||||
|
let extended = 0;
|
||||||
|
await Promise.all(
|
||||||
|
holds.map(async (hold) => {
|
||||||
|
const departureAt = departureById.get(hold.scheduleId);
|
||||||
|
if (!departureAt) return;
|
||||||
|
const deadline = computePaymentDeadline(now, departureAt);
|
||||||
|
// Only ever extend forward — never shorten a hold that's already valid longer
|
||||||
|
// than the payment deadline would give it (e.g. a second confirmSeats call on
|
||||||
|
// the same booking, or a hold that was already extended).
|
||||||
|
if (deadline <= hold.expiresAt) return;
|
||||||
|
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
|
||||||
|
extended++;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extended > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||||
async releaseSeats(bookingId: string) {
|
async releaseSeats(bookingId: string) {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import { Cron } from '@nestjs/schedule';
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
|
||||||
const MAX_PAYMENT_HOURS = 2;
|
|
||||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
|
||||||
const CUTOFF_MINUTES = 30;
|
|
||||||
|
|
||||||
// Retention windows
|
// Retention windows
|
||||||
const OTP_RETENTION_HOURS = 1;
|
const OTP_RETENTION_HOURS = 1;
|
||||||
@@ -16,15 +12,6 @@ const AUDIT_LOG_RETENTION_DAYS = 365;
|
|||||||
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
|
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
|
||||||
const GATE_LOG_RETENTION_DAYS = 180;
|
const GATE_LOG_RETENTION_DAYS = 180;
|
||||||
|
|
||||||
/**
|
|
||||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
|
||||||
*/
|
|
||||||
function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
|
||||||
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
|
||||||
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
|
||||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(d: Date): string {
|
function fmtTime(d: Date): string {
|
||||||
return d.toLocaleTimeString('en-GB', {
|
return d.toLocaleTimeString('en-GB', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -192,6 +179,7 @@ export class TasksService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
paymentIntent: { select: { method: true } },
|
paymentIntent: { select: { method: true } },
|
||||||
|
seats: { select: { seatId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,9 +193,21 @@ export class TasksService {
|
|||||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||||
if (now < paymentDeadline) continue;
|
if (now < paymentDeadline) continue;
|
||||||
|
|
||||||
// 1. Release held seats (Journey rows are the occupancy source of truth)
|
// 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 });
|
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||||
|
|
||||||
|
// 1b. Also release the SeatHold(s) covering this booking's seats — SeatsService
|
||||||
|
// extends these to the payment deadline when the booking is created, so without
|
||||||
|
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
|
||||||
|
// though the booking is now cancelled. Scoped to this booking's own schedule,
|
||||||
|
// since the same physical Seat row is reused across other recurring dates.
|
||||||
|
const seatIds = booking.seats.map(s => s.seatId);
|
||||||
|
if (seatIds.length > 0) {
|
||||||
|
await this.prisma.seatHold.deleteMany({
|
||||||
|
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Audit record (no refund — payment was never completed)
|
// 2. Audit record (no refund — payment was never completed)
|
||||||
await this.prisma.bookingCancellation.create({
|
await this.prisma.bookingCancellation.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in New Issue
Block a user