Merge pull request #948 from Tria-plc/tests

Cut off time configuration
This commit is contained in:
mulish77
2026-07-24 09:02:25 +03:00
committed by GitHub
10 changed files with 618 additions and 207 deletions

View File

@@ -0,0 +1,45 @@
/**
* Resolves the booking/check-in cutoff for one boarding stop: stop-level
* `RouteStop.checkinMinutesBefore` override wins, else the route-level default
* (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with
* neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching
* that stop), not its departure or the schedule's overall origin departure — a downstream
* stop's cutoff must be independent of how long ago the train left its origin. The first stop
* of a route has no arrival (nothing to arrive at), so it falls back to its own departure.
*
* Single source of truth for this computation — SeatsService.holdSeats and
* SearchService.buildScheduleResult already applied it (search results only ever showed a
* segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking
* used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin
* departure, which could reject a booking the search/hold steps had just accepted under the
* route's actual configured cutoff.
*/
export interface CheckinCutoff {
/** The stop's own estimated arrival time (or departure, for the first stop / missing data). */
segmentTime: Date;
/** Minutes before segmentTime that booking/holding closes. */
checkinMinutes: number;
/** The moment booking/holding closes for this stop. */
cutoffAt: Date;
}
export function resolveCheckinCutoff(
schedule: {
departureAt: Date;
route?: {
checkinMinutesBefore?: number | null;
stops?: Array<{ stationId: string; checkinMinutesBefore: number | null }>;
} | null;
},
stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined,
stationId: string | null | undefined,
): CheckinCutoff {
const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt;
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
return {
segmentTime,
checkinMinutes,
cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000),
};
}

View File

@@ -0,0 +1,37 @@
/**
* Resolves a booking's actual boarding/alighting station AND time 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
* station/time when there's no segment override (older records, or a booking that
* covers the whole run).
*
* Single source of truth for this resolution — station-only lookups used to be
* duplicated ad hoc across bookings/tickets/notifications while the departureAt/
* arrivalAt kept being read straight off the schedule (the train's full-route span),
* which showed the wrong boarding/alighting time for any stop-based booking.
*/
export interface ResolvedSegment {
origin: any;
destination: any;
departureAt: any;
arrivalAt: any;
}
export function resolveBookingSegment(
schedule: any,
originStationId: string | null | undefined,
destinationStationId: string | null | undefined,
): ResolvedSegment {
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,
};
}

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;

View File

@@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@@ -353,27 +354,6 @@ export class NotificationsService {
}
}
/**
* Resolves the user's actual boarding/alighting stations from the booking's originStationId /
* destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when
* the booking has no segment override (e.g. older records or packages).
*/
private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } {
const s = booking?.schedule ?? {};
const stopTimes: any[] = s.stopTimes ?? [];
const findStation = (stationId: string | null | undefined, fallback: any) => {
if (stationId && stopTimes.length > 0) {
const stop = stopTimes.find((st: any) => st.stationId === stationId);
if (stop?.station) return stop.station;
}
return fallback ?? null;
};
return {
originStation: findStation(booking?.originStationId, s.originStation),
destinationStation: findStation(booking?.destinationStationId, s.destinationStation),
};
}
/**
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
@@ -400,17 +380,17 @@ export class NotificationsService {
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
const passengerName = seats[0]?.passengerName ?? 'Passenger';
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
return {
passengerName,
bookingRef: ref,
origin: originSt?.name ?? '',
destination: destSt?.name ?? '',
origin: segment.origin?.name ?? '',
destination: segment.destination?.name ?? '',
trainSeatLines,
travelDate: fmtDate(s.departureAt),
departureTime: fmtTime(s.departureAt),
arrivalTime: fmtTime(s.arrivalAt),
travelDate: fmtDate(segment.departureAt),
departureTime: fmtTime(segment.departureAt),
arrivalTime: fmtTime(segment.arrivalAt),
payLink,
};
}
@@ -509,12 +489,12 @@ export class NotificationsService {
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
const s = booking.schedule ?? {};
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
const dep = segment.departureAt ? new Date(segment.departureAt).toLocaleString('en-GB') : 'TBD';
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
return [
`Booking ${booking.bookingRef} confirmed.`,
`${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`,
`${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`,
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
`Departs: ${dep}`,
passengers ? `Passengers: ${passengers}` : '',
@@ -527,7 +507,9 @@ export class NotificationsService {
const s = booking.schedule ?? {};
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
const originSt = segment.origin;
const destSt = segment.destination;
const seatRows = (booking.seats ?? [])
.map((bs: any) => {
const coach = bs.seat?.coach?.number ?? '-';
@@ -568,11 +550,11 @@ export class NotificationsService {
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Departs</td>
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
<td style="padding:8px 0;text-align:right;">${fmt(segment.departureAt)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Arrives</td>
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
<td style="padding:8px 0;text-align:right;">${fmt(segment.arrivalAt)}</td>
</tr>
</table>
@@ -636,12 +618,12 @@ export class NotificationsService {
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
const origin = originSt?.name ?? '';
const dest = destSt?.name ?? '';
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
const origin = segment.origin?.name ?? '';
const dest = segment.destination?.name ?? '';
const train = s.train?.name ?? s.train?.number ?? '';
const dep = fmt(s.departureAt);
const arr = fmt(s.arrivalAt);
const dep = fmt(segment.departureAt);
const arr = fmt(segment.arrivalAt);
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
name: bs.passengerName ?? '',

View File

@@ -10,6 +10,7 @@ import { CurrencyService } from "../currency/currency.service";
import { FareEngineService } from "../fare-engine/fare-engine.service";
import { SegmentsService } from "../segments/segments.service";
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
import { Currency, Prisma } from "@prisma/client";
const POINTS_TO_MINOR = 10;
@@ -554,23 +555,11 @@ export class SearchService {
// Segment-level cutoff: use the origin stop's own estimated arrival time, not the
// schedule's overall departureAt (which is station A's time). This lets
// B→D remain bookable even after A→D closes. The first stop has no arrival (nothing
// to arrive at), so it falls back to its own departure.
// Cutoff resolution: stop-level override → route default → 30 min fallback.
const now = new Date();
const segmentDepartureAt =
originStop.plannedArrivalAt ?? originStop.plannedDepartureAt ?? schedule.departureAt;
const routeStop = schedule.route?.stops?.find(
(s) => s.stationId === originStationId,
);
const checkinMinutes =
routeStop?.checkinMinutesBefore ??
schedule.route?.checkinMinutesBefore ??
30;
if (
segmentDepartureAt.getTime() - now.getTime() <=
checkinMinutes * 60 * 1000
)
// B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override →
// route default → 30 min fallback — same resolution GuestBookingService applies at
// booking-creation time, so a segment shown as bookable here stays bookable through
// checkout instead of being rejected against a different, hardcoded cutoff.
if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime())
return null;
// Collect all valid seat IDs upfront for a single batch availability check
@@ -682,8 +671,11 @@ export class SearchService {
nationality,
availabilityByClass,
);
const legDepartureAt = schedule.departureAt;
const legArrivalAt = schedule.arrivalAt;
// Use the selected stop's own planned time, not the schedule's full-route span —
// for stop-based (mid-route) boarding/alighting these differ from the train's
// overall origin departure / final destination arrival.
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
const displayCurrency =
faresByClass[0]?.displayCurrency ??

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -129,6 +130,7 @@ export class TicketsService {
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId);
return {
id: t.id,
ticketNumber: t.barcodePayload,
@@ -152,20 +154,14 @@ export class TicketsService {
contactPhone: t.booking?.contactPhone,
returnSchedule: t.booking?.returnSchedule ?? null,
seats: t.booking?.seats ?? [],
originStation: (() => {
const id = t.booking?.originStationId;
if (!id) return t.booking?.schedule?.originStation ?? null;
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
})(),
destinationStation: (() => {
const id = t.booking?.destinationStationId;
if (!id) return t.booking?.schedule?.destinationStation ?? null;
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
})(),
originStation: segment.origin,
destinationStation: segment.destination,
},
schedule: t.booking?.schedule,
schedule: t.booking?.schedule ? {
...t.booking.schedule,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
} : null,
seat: t.seat ? {
id: t.seat.id,
seatNumber: t.seat.seatNumber,
@@ -643,11 +639,14 @@ export class TicketsService {
throw new NotFoundException('No ticket found for this booking');
}
// Check if ticket date matches today
// Check if ticket date matches today. Boarding window is relative to the
// passenger's actual boarding stop, not the train's origin — for a mid-route
// boarding these differ.
const today = new Date();
if ((booking as any).schedule?.departureAt) {
const departureTime = new Date((booking as any).schedule.departureAt);
const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
if (boardingSegment.departureAt) {
const departureTime = new Date(boardingSegment.departureAt);
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
@@ -673,18 +672,6 @@ export class TicketsService {
// Send notifications after successful boarding
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
// Resolve user-selected segment rather than the full schedule route
const _schedStops = (booking as any).schedule?.stopTimes ?? [];
const _resolveStation = (id: string | null | undefined, fallback: any) => {
if (id) {
const found = _schedStops.find((st: any) => st.stationId === id)?.station;
if (found) return found;
}
return fallback;
};
const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation);
const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation);
return {
success: true,
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
@@ -693,11 +680,11 @@ export class TicketsService {
ticketNumber: ticket.barcodePayload,
bookingRef: booking.bookingRef,
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
route: `${boardingOrigin?.name || 'N/A'}${boardingDest?.name || 'N/A'}`,
route: `${boardingSegment.origin?.name || 'N/A'}${boardingSegment.destination?.name || 'N/A'}`,
seat: seatNumber,
coach: coachNumber,
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
departureTime: (booking as any).schedule?.departureAt,
departureTime: boardingSegment.departureAt,
boardedAt: result.validatedAt,
leg: result.leg || 'OUTBOUND',
bookingType: booking.bookingType,

View File

@@ -33,20 +33,13 @@ async function createTestSchedule(
const train = await harness.prisma.train.create({
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
});
const schedule = await schedules.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: opts.departureAt.toISOString(),
arrivalAt: opts.arrivalAt.toISOString(),
} as any);
// Give the schedule a bookable coach so holdSeats has real seats to work with.
// createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's
// "must have at least one coach assigned" guard) — the coach has to exist and be passed
// via coachIds BEFORE creation, not attached afterward.
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
});
await harness.prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true },
});
const seats = await Promise.all(
["1A", "1B", "1C", "1D"].map((seatNumber, i) =>
harness.prisma.seat.create({
@@ -55,6 +48,14 @@ async function createTestSchedule(
),
);
const schedule = await schedules.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: opts.departureAt.toISOString(),
arrivalAt: opts.arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}

View File

@@ -0,0 +1,366 @@
/**
* Stop-based (mid-route) booking — segment correctness suite.
*
* Regression coverage for three bugs reported against live stop-based bookings:
*
* 1. Search results (SearchService.buildScheduleResult) showed the train's overall
* departure/arrival instead of the selected origin/destination stop's own time — e.g.
* searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in
* resolving the boarding/alighting STATION correctly for a mid-route segment while still
* reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also
* used by BookingsService, TicketsService, NotificationsService) — see
* src/common/utils/segment-resolver.utils.ts.
* 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin
* departure regardless of where the passenger actually boards, so a schedule whose origin
* had already departed >30min ago wrongly blocked booking a downstream segment that
* hadn't closed yet.
* 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30
* minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff
* that SeatsService.holdSeats and the search step already enforce. A passenger who passed
* the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly
* rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes
* of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff()
* utility as SeatsService.holdSeats and SearchService — see
* src/common/utils/checkin-cutoff.utils.ts.
*
* Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual
* cumulative travel-time interpolation in SchedulesService.createSchedule, same as
* checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService
* are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ),
* so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the
* Tier-2 pattern in money-integrity.e2e-spec.ts.
*/
import { IdDocumentType } from "@prisma/client";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { SegmentsService } from "../src/modules/segments/segments.service";
import { SearchService } from "../src/modules/search/search.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { BookingsService } from "../src/modules/bookings/bookings.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host —
* matches search.service.ts's "+03:00" date-matching window). */
function localDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
describe("Stop-based booking — segment time & cutoff correctness", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let searchService: SearchService;
let bookingsService: BookingsService;
let guestBookingService: GuestBookingService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const segmentsService = new SegmentsService(harness.prisma as any);
const systemConfig = new SystemConfigService(harness.prisma as any);
searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService);
// holdSeats() itself never touches segmentsService (only getSeatMap/availability-map
// callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts.
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
bookingsService = new BookingsService(
harness.prisma as any,
asyncStub(), // dataSource
seatsService,
{ emit: () => true } as any, // eventEmitter
asyncStub(), // verifaydaService
currencyService,
fareEngine,
asyncStub(), // auditService
);
guestBookingService = new GuestBookingService(
harness.prisma as any,
seatsService,
asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID
currencyService,
asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs
fareEngine,
{ emit: () => true } as any, // eventEmitter
);
});
afterAll(async () => {
await harness?.close();
});
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation
* (createSchedule now rejects a schedule with zero coaches). */
async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) {
const train = await harness.prisma.train.create({
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
});
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
});
const seats = await Promise.all(
["1A", "1B", "1C", "1D"].map((seatNumber, i) =>
harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
}),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: opts.departureAt.toISOString(),
arrivalAt: opts.arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}
function foreignPassenger(seatId: string) {
return {
seatId,
passengerName: "Test Passenger",
dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT,
passportNumber: "X123456",
passportCountry: "Djibouti",
nationality: "Djiboutian",
};
}
describe("search results (SearchService.searchTrips)", () => {
it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const result: any = await searchService.searchTrips({
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
date: localDateStr(dep),
adultCount: 1,
} as any);
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
expect(found).toBeTruthy();
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime());
// Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback.
expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime());
});
it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const result: any = await searchService.searchTrips({
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
date: localDateStr(dep),
adultCount: 1,
} as any);
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
expect(found).toBeTruthy();
const expectedBArrival = new Date(dep.getTime() + 60 * 60_000);
expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime());
// Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback.
expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime());
});
});
describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => {
it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the
// passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 190 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }],
} as any);
const booking: any = await guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: (hold as any).holdId,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any);
expect(booking.bookingRef).toBeTruthy();
expect(booking.originStationId).toBe(IDS.stationB);
expect(booking.destinationStationId).toBe(IDS.stationC);
});
it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created
// directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based
// cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 50 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await harness.prisma.seatHold.create({
data: {
scheduleId: schedule.id,
seatIds: [seats[0].id],
passengerId: "77777777-7777-4777-8777-777777777777",
expiresAt: new Date(Date.now() + 10 * 60_000),
},
});
await expect(
guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: hold.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any),
).rejects.toThrow(/not accepted within 30 minutes/i);
});
it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => {
// Regression for the reported bug: /booking/review still rejected a booking with
// "not accepted within 30 minutes of departure" even after the passenger passed the
// earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to
// enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route
// .checkinMinutesBefore. B's own configured cutoff here is 10 minutes.
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff,
// but outside B's own configured 10-min cutoff.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 60 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }],
} as any);
const booking: any = await guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: (hold as any).holdId,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any);
expect(booking.bookingRef).toBeTruthy();
});
it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => {
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff
// (would have wrongly been allowed), but inside B's own configured 90-min cutoff.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 80 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await harness.prisma.seatHold.create({
data: {
scheduleId: schedule.id,
seatIds: [seats[0].id],
passengerId: "99999999-9999-4999-8999-999999999999",
expiresAt: new Date(Date.now() + 10 * 60_000),
},
});
await expect(
guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: hold.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any),
).rejects.toThrow(/not accepted within 90 minutes/i);
});
});
describe("booking detail & list segment resolution (BookingsService)", () => {
it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const passenger = await harness.prisma.passenger.create({ data: {} });
const booking = await harness.prisma.booking.create({
data: {
bookingRef: `SEGDET${Date.now()}`,
passengerId: passenger.id,
scheduleId: schedule.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
status: "CONFIRMED",
totalMinor: 10000,
displayCurrency: "ETB",
},
});
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
const detail: any = await bookingsService.getByRef(booking.bookingRef);
expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
expect(detail.schedule.origin.id).toBe(IDS.stationB);
expect(detail.schedule.destination.id).toBe(IDS.stationC);
const list: any = await bookingsService.findByPassengerId(passenger.id);
expect(list.items).toHaveLength(1);
expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB);
});
});
});