diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts new file mode 100644 index 000000000..5a0fb50b1 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -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), + }; +} diff --git a/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts new file mode 100644 index 000000000..2fcc6581b --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts @@ -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, + }; +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b75f1eeb4..98a54528b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index d40824c36..3e2a225e6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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; diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index a1678d131..12f549e9f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -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 { Departs - ${fmt(s.departureAt)} + ${fmt(segment.departureAt)} Arrives - ${fmt(s.arrivalAt)} + ${fmt(segment.arrivalAt)} @@ -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 ?? '', diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 78fbcb955..0c905acdd 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -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 ?? diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 736b781bd..684c65880 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -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, diff --git a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts index 8cbc7d8f8..93c0386b6 100644 --- a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts +++ b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts @@ -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 }; } diff --git a/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts new file mode 100644 index 000000000..dfcc298fd --- /dev/null +++ b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts @@ -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); + }); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 6b310d137..251d034b1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -308,7 +308,12 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + // distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting + // every new stop to 0 made each one collide with the previous, so simply clicking "Add + // Intermediate Stop" a few times and saving without hand-editing every distance always failed + // validation. Default each new stop's distance a step past whatever precedes it instead. + const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0; + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]); }; const removeStop = (index: number) => {