Cut off time configuration

This commit is contained in:
Muluhabt
2026-07-24 09:01:27 +03:00
parent 64e1130f7d
commit 7320c359d1
10 changed files with 618 additions and 207 deletions

View File

@@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -140,7 +141,7 @@ export class BookingsService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
@@ -148,31 +149,34 @@ export class BookingsService {
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
items: items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
@@ -270,7 +274,7 @@ export class BookingsService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
seats: { select: { id: true } },
priceTier: { select: { priceMinor: true } },
@@ -294,7 +298,9 @@ export class BookingsService {
this.prisma.packageBooking.count({ where: pkgWhere }),
]);
const mappedBookings = items.map(booking => ({
const mappedBookings = items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
@@ -309,14 +315,15 @@ export class BookingsService {
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
}));
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
@@ -397,7 +404,7 @@ export class BookingsService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
@@ -405,9 +412,11 @@ export class BookingsService {
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
items: items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
@@ -422,14 +431,15 @@ export class BookingsService {
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
};
}),
meta: {
page,
pageSize,
@@ -532,7 +542,7 @@ export class BookingsService {
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
@@ -554,9 +564,10 @@ export class BookingsService {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null;
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
@@ -567,11 +578,11 @@ export class BookingsService {
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: booking.schedule ? {
schedule: segment ? {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
} : null,
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
@@ -715,16 +726,15 @@ export class BookingsService {
verifaydaVerified: s.verifaydaVerified,
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
})),
schedule: {
train: booking.schedule.train,
originStation: (booking as any).originStationId
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
: booking.schedule.originStation,
destinationStation: (booking as any).destinationStationId
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
schedule: (() => {
const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
};
})(),
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
@@ -1850,35 +1860,6 @@ export class BookingsService {
);
}
// Resolves the passenger's actual boarding/alighting stations AND times for one leg
// from originStationId/destinationStationId (set when the booking covers only part of
// a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
// the schedule's stopTimes, falling back to the schedule's own full-route endpoints/
// times when there's no segment override (older records, or a booking that covers the
// whole run). Station resolution mirrors notifications.service.ts's
// resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt
// resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt
// / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page,
// confirmation) to the same behavior search results already have, instead of always
// showing the train's full-route span.
private resolveSegmentStations(
schedule: any,
originStationId: string | null | undefined,
destinationStationId: string | null | undefined,
): { origin: any; destination: any; departureAt: any; arrivalAt: any } {
const stopTimes: any[] = schedule?.stopTimes ?? [];
const findStop = (stationId: string | null | undefined) =>
stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined;
const originStop = findStop(originStationId);
const destStop = findStop(destinationStationId);
return {
origin: originStop?.station ?? schedule?.originStation ?? null,
destination: destStop?.station ?? schedule?.destinationStation ?? null,
departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null,
arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null,
};
}
async getByRef(bookingRefOrId: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
const booking = await this.prisma.booking.findUnique({
@@ -1987,13 +1968,13 @@ export class BookingsService {
if (refreshed) Object.assign(booking, refreshed);
}
const outboundSegment = this.resolveSegmentStations(
const outboundSegment = resolveBookingSegment(
(booking as any).schedule,
(booking as any).originStationId,
(booking as any).destinationStationId,
);
const returnSegment = (booking as any).returnSchedule
? this.resolveSegmentStations(
? resolveBookingSegment(
(booking as any).returnSchedule,
(booking as any).returnOriginStationId,
(booking as any).returnDestinationStationId,

View File

@@ -8,9 +8,22 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
/** Booking cutoff: reject new bookings within this many ms of departure. */
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
/**
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
* checkinMinutesBefore, same mechanism the seat hold and search results already enforce) has
* passed. Must be checked against the actual boarding stop, not the schedule's origin — a
* downstream stop's cutoff is independent of how long ago the train left its origin.
*/
function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: string | null | undefined): void {
const { cutoffAt, checkinMinutes } = resolveCheckinCutoff(schedule, stopTime, stationId);
if (Date.now() >= cutoffAt.getTime()) {
throw new BadRequestException(
`Bookings are not accepted within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
);
}
}
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -107,20 +120,22 @@ export class GuestBookingService {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
route: { include: { stops: true } },
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
// Cut off relative to the passenger's actual boarding stop, using the same
// configurable per-stop/route checkinMinutesBefore that already gated the seat hold
// and the search result — not a separate, hardcoded 30 minutes off the train's origin.
assertWithinCheckinCutoff(schedule, originStop, dto.originStationId);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
@@ -391,7 +406,7 @@ export class GuestBookingService {
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.returnScheduleId },
@@ -401,10 +416,6 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const synth = (sched: any, stationId: string, seq: number) => {
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
return { stationId, sequence: seq, station };
@@ -418,6 +429,10 @@ export class GuestBookingService {
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
// Cut off relative to the passenger's actual boarding stop, using the same configurable
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
assertWithinCheckinCutoff(outboundSchedule, outboundOriginStop, dto.originStationId);
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
@@ -690,7 +705,7 @@ export class GuestBookingService {
const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.leg2ScheduleId },
@@ -700,10 +715,6 @@ export class GuestBookingService {
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -711,6 +722,10 @@ export class GuestBookingService {
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
// Cut off relative to the passenger's actual boarding stop, using the same configurable
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
assertWithinCheckinCutoff(leg1Schedule, leg1OriginStop, dto.originStationId);
// Process passengers (verify identity once)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;
@@ -894,7 +909,7 @@ export class GuestBookingService {
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
@@ -904,10 +919,6 @@ export class GuestBookingService {
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -921,6 +932,10 @@ export class GuestBookingService {
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
// Cut off relative to the passenger's actual boarding stop, using the same configurable
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId);
// Process passengers (verify once)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;