mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 13:43:43 +00:00
fix: (bookings) release the seat hold when a reschedule or upgrade expires and honour shortened payment windows
This commit is contained in:
@@ -433,7 +433,7 @@ export class RescheduleService {
|
||||
async expireStale(now = new Date()): Promise<number> {
|
||||
const stale = await this.prisma.bookingReschedule.findMany({
|
||||
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
|
||||
select: { id: true, supplementaryChargeId: true },
|
||||
select: { id: true, supplementaryChargeId: true, holdId: true },
|
||||
});
|
||||
for (const r of stale) {
|
||||
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
|
||||
@@ -443,6 +443,14 @@ export class RescheduleService {
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
}
|
||||
// Release the seat the instant the request dies instead of leaving it to the hold's own
|
||||
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
|
||||
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
|
||||
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
|
||||
// hold must not throw.
|
||||
if (r.holdId) {
|
||||
await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } });
|
||||
}
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
@@ -728,24 +728,42 @@ export class SeatsService {
|
||||
? 0 // unused — the override wins below
|
||||
: await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
|
||||
|
||||
let extended = 0;
|
||||
let aligned = 0;
|
||||
await Promise.all(
|
||||
holds.map(async (hold) => {
|
||||
const departureAt = departureById.get(hold.scheduleId);
|
||||
if (!departureAt) return;
|
||||
const deadline = deadlineOverride ?? computePaymentDeadline(now, departureAt, undefined, windowMinutes);
|
||||
// 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 (deadlineOverride) {
|
||||
// Authoritative in BOTH directions. The caller already issued a payment link with this
|
||||
// exact deadline, so the hold must match it — including when it is EARLIER than the
|
||||
// hold's own TTL. Extending only would leave the seat held after the link that pays for
|
||||
// it has died (reachable whenever a flow's payment window is shorter than
|
||||
// seat_hold_duration_minutes), so the seat sits unsellable in between.
|
||||
if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return;
|
||||
await this.prisma.seatHold.update({
|
||||
where: { id: hold.id },
|
||||
data: { expiresAt: deadlineOverride },
|
||||
});
|
||||
aligned++;
|
||||
return;
|
||||
}
|
||||
|
||||
const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes);
|
||||
// Normal booking path: only ever extend forward — never shorten a hold that's already
|
||||
// valid longer than the payment deadline would give it. A round trip calls confirmSeats
|
||||
// up to four times, and a later call must not pull in a hold an earlier one set.
|
||||
if (deadline <= hold.expiresAt) return;
|
||||
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
|
||||
extended++;
|
||||
aligned++;
|
||||
}),
|
||||
);
|
||||
|
||||
if (extended > 0) {
|
||||
if (aligned > 0) {
|
||||
this.logger.log(
|
||||
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
|
||||
deadlineOverride
|
||||
? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline`
|
||||
: `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +603,7 @@ export class UpgradeService {
|
||||
async expireStale(now = new Date()): Promise<number> {
|
||||
const stale = await this.prisma.bookingUpgrade.findMany({
|
||||
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
|
||||
select: { id: true, supplementaryChargeId: true },
|
||||
select: { id: true, supplementaryChargeId: true, holdId: true },
|
||||
});
|
||||
for (const u of stale) {
|
||||
await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } });
|
||||
@@ -613,6 +613,14 @@ export class UpgradeService {
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
}
|
||||
// Release the seat the instant the request dies instead of leaving it to the hold's own
|
||||
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
|
||||
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
|
||||
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
|
||||
// hold must not throw.
|
||||
if (u.holdId) {
|
||||
await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } });
|
||||
}
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user