diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql new file mode 100644 index 000000000..375f40f7e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -0,0 +1,12 @@ +-- Rename StopStatus enum values to reflect segment-level booking lifecycle. +-- UPCOMING → OPEN (segment is bookable) +-- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) +-- CURRENT → BOARDED (train has departed this stop) +-- COMPLETED stays as-is +ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN'; +ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED'; +ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; + +-- Add per-route check-in window. Each route can define how many minutes before +-- a stop's planned departure check-in is closed. Defaults to 30 minutes. +ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql new file mode 100644 index 000000000..0b0995292 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index ccc68915d..fb9db346f 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -180,10 +180,10 @@ enum NotificationCategory { } enum StopStatus { + OPEN + CHECKIN_CLOSED + BOARDED COMPLETED - APPROACHING - CURRENT - UPCOMING @@schema("passenger") } @@ -397,7 +397,7 @@ model TripStopTime { plannedArrivalAt DateTime? plannedDepartureAt DateTime? actualArrivalAt DateTime? - status StopStatus @default(UPCOMING) + status StopStatus @default(OPEN) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) station Station @relation(fields: [stationId], references: [id]) @@ -1026,14 +1026,15 @@ model PasswordResetToken { } model Route { - id String @id @default(uuid()) - code String @unique - name String - description String? - effectiveFrom DateTime - effectiveUntil DateTime? - active Boolean @default(true) - createdAt DateTime @default(now()) + id String @id @default(uuid()) + code String @unique + name String + description String? + effectiveFrom DateTime + effectiveUntil DateTime? + active Boolean @default(true) + checkinMinutesBefore Int @default(30) + createdAt DateTime @default(now()) stops RouteStop[] fareRules RouteFareRule[] segmentFares SegmentFareRule[] @@ -1043,13 +1044,14 @@ model Route { } model RouteStop { - id String @id @default(uuid()) - routeId String - stationId String - sequence Int - distanceKm Float? - createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + routeId String + stationId String + sequence Int + distanceKm Float? + checkinMinutesBefore Int? + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) @@unique([routeId, sequence]) @@index([routeId, stationId]) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index 1d4c286be..e125bb0bf 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -13,10 +13,17 @@ export const MAX_PAYMENT_HOURS = 2; export const CUTOFF_MINUTES = 30; /** - * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + * payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes) + * + * checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level + * checkinMinutesBefore so that each route's own window is respected. */ -export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { +export function computePaymentDeadline( + createdAt: Date, + departureAt: Date, + checkinMinutes: number = CUTOFF_MINUTES, +): Date { const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); - const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); + const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } diff --git a/apps/edr-passenger-api/src/modules/live/live.service.ts b/apps/edr-passenger-api/src/modules/live/live.service.ts index 7f0a858dc..353fcf46c 100644 --- a/apps/edr-passenger-api/src/modules/live/live.service.ts +++ b/apps/edr-passenger-api/src/modules/live/live.service.ts @@ -12,7 +12,7 @@ export class LiveService { }); if (!schedule) throw new NotFoundException('Schedule not found'); const live = schedule.liveStatus; - const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING'); + const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED'); return { scheduleId: schedule.id, trainName: schedule.train.name, fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name, diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index bcd4dfcee..f2db36e7c 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -6,6 +6,7 @@ export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; } export class CreateRouteDto { @@ -35,6 +36,7 @@ export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; } export class UpdateRouteDto { @@ -42,6 +44,7 @@ export class UpdateRouteDto { @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 9a9bdd30a..58e647180 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -36,6 +36,7 @@ export class RoutesService { stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, + checkinMinutesBefore: s.checkinMinutesBefore ?? null, })), }, }, @@ -92,6 +93,7 @@ export class RoutesService { description: dto.description, active: dto.active, effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, + ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), }, }); @@ -103,6 +105,7 @@ export class RoutesService { stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, + checkinMinutesBefore: s.checkinMinutesBefore ?? null, })), }); } @@ -221,6 +224,7 @@ export class RoutesService { stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, + checkinMinutesBefore: dto.checkinMinutesBefore ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 3844e57cf..675168cf9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -12,10 +12,10 @@ export enum TripStatus { } export enum StopStatus { + OPEN = 'OPEN', + CHECKIN_CLOSED = 'CHECKIN_CLOSED', + BOARDED = 'BOARDED', COMPLETED = 'COMPLETED', - APPROACHING = 'APPROACHING', - CURRENT = 'CURRENT', - UPCOMING = 'UPCOMING', } export enum PassengerCategory { @@ -65,7 +65,7 @@ export class UpdateScheduleDto { export class UpdateStopTimeDto { @ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string; @ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string; - @ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus; + @ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus; } export class CreateFareRuleDto { diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts index b7788c2fe..baadcf90c 100644 --- a/apps/edr-passenger-api/src/modules/search/search.module.ts +++ b/apps/edr-passenger-api/src/modules/search/search.module.ts @@ -4,10 +4,9 @@ import { SearchService } from './search.service'; import { CurrencyModule } from '../currency/currency.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { SegmentsModule } from '../segments/segments.module'; -import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule], + imports: [CurrencyModule, FareEngineModule, SegmentsModule], controllers: [SearchController], providers: [SearchService], exports: [SearchService], 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 340d9fac9..a51dc0142 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -6,7 +6,6 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { Currency } from '@prisma/client'; -import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; const POINTS_TO_MINOR = 10; @@ -20,6 +19,7 @@ type ScheduleWithIncludes = { train: any; originStation: any; destinationStation: any; + route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }> } | null; stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; }; @@ -28,6 +28,7 @@ const SCHEDULE_INCLUDE = { train: true, originStation: true, destinationStation: true, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, @@ -41,13 +42,8 @@ export class SearchService { private currencyService: CurrencyService, private fareEngine: FareEngineService, private segmentsService: SegmentsService, - private systemConfig: SystemConfigService, ) {} - private async getCutoffHours(): Promise { - return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE); - } - async searchTrips(dto: SearchTripsDto) { const [direct, transit] = await Promise.all([ this.searchSchedules( @@ -218,10 +214,11 @@ export class SearchService { const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); - const cutoffHours = await this.getCutoffHours(); - const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000); + // Use now as the lower bound for today so we don't fetch schedules that have + // already fully departed. The per-segment cutoff check in buildScheduleResult + // handles the exact check using each stop's own plannedDepartureAt. const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d; - const earliest = isToday ? cutoffThreshold : date; + const earliest = isToday ? now : date; const schedules = await this.prisma.trainSchedule.findMany({ where: { @@ -284,12 +281,9 @@ export class SearchService { }), ]); - const cutoffHours = await this.getCutoffHours(); - const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000); - const results: any[] = []; - for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) { + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; @@ -376,6 +370,16 @@ export class SearchService { const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Segment-level cutoff: use the origin stop's planned departure, not the + // schedule's overall departureAt (which is station A's time). This lets + // B→D remain bookable even after A→D closes. + // Cutoff resolution: stop-level override → route default → 30 min fallback. + const now = new Date(); + const segmentDepartureAt = 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) return null; + // Collect all valid seat IDs upfront for a single batch availability check const allValidSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 29d03206c..963cc571d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -268,23 +268,38 @@ export class SeatsService { if (new Set(seatIds).size !== seatIds.length) throw new BadRequestException('Duplicate seatId in passengers list'); - const [holdMinutes, cutoffHours] = await Promise.all([ - this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES), - this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE), - ]); + const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES); const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000); - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: dto.scheduleId }, - select: { departureAt: true }, - }); + const [schedule, originStopTime, originRouteStop] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { + departureAt: true, + route: { select: { checkinMinutesBefore: true } }, + }, + }), + this.prisma.tripStopTime.findFirst({ + where: { scheduleId: dto.scheduleId, stationId: dto.originStationId }, + select: { plannedDepartureAt: true }, + }), + this.prisma.routeStop.findFirst({ + where: { + route: { schedules: { some: { id: dto.scheduleId } } }, + stationId: dto.originStationId, + }, + select: { checkinMinutesBefore: true }, + }), + ]); if (!schedule) throw new NotFoundException('Schedule not found'); - const msUntilDeparture = schedule.departureAt.getTime() - Date.now(); - const cutoffMs = cutoffHours * 60 * 60 * 1000; - if (msUntilDeparture <= cutoffMs) { + // Stop-level override wins; falls back to route-level; then to 30 min. + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; + const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt; + const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); + if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( - `Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`, + `Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`, ); } diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 4fb3a4f0f..2355485d4 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -42,6 +42,7 @@ export class TasksService { const now = new Date(); const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + // ── Schedule-level transitions (operational display) ─────────────────── const [boarding, departed, arrived] = await Promise.all([ this.prisma.trainSchedule.updateMany({ where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } }, @@ -57,9 +58,71 @@ export class TasksService { }), ]); - if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) { + // ── Per-stop transitions (segment-level status) ──────────────────────── + // OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore + // override; falls back to the Route-level value when null. + // Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair. + const routeStops = await this.prisma.routeStop.findMany({ + select: { + routeId: true, + stationId: true, + checkinMinutesBefore: true, + route: { select: { checkinMinutesBefore: true } }, + }, + }); + + // Map: effectiveMins → Map + const byMins = new Map>(); + for (const stop of routeStops) { + const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore; + if (!byMins.has(mins)) byMins.set(mins, new Map()); + const byRoute = byMins.get(mins)!; + if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []); + byRoute.get(stop.routeId)!.push(stop.stationId); + } + + let reopenedCount = 0; + let checkinClosedCount = 0; + for (const [mins, byRoute] of byMins) { + const cutoffAt = new Date(now.getTime() + mins * 60 * 1000); + for (const [routeId, stationIds] of byRoute) { + // Revert first: if the cutoff was reduced, stops that were prematurely closed + // should reopen (departure is still beyond the new cutoff window). + const reverted = await this.prisma.tripStopTime.updateMany({ + where: { + status: 'CHECKIN_CLOSED', + plannedDepartureAt: { gt: cutoffAt }, + stationId: { in: stationIds }, + schedule: { routeId }, + }, + data: { status: 'OPEN' }, + }); + reopenedCount += reverted.count; + + // Forward: close stops now within the cutoff window. + const closed = await this.prisma.tripStopTime.updateMany({ + where: { + status: 'OPEN', + plannedDepartureAt: { lte: cutoffAt }, + stationId: { in: stationIds }, + schedule: { routeId }, + }, + data: { status: 'CHECKIN_CLOSED' }, + }); + checkinClosedCount += closed.count; + } + } + + const boardedStops = await this.prisma.tripStopTime.updateMany({ + where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } }, + data: { status: 'BOARDED' }, + }); + + if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 || + reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) { this.logger.log( - `Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, + `Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` + + `Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`, ); } } @@ -96,13 +159,17 @@ export class TasksService { status: 'PENDING_PAYMENT', paymentReminderSentAt: null, createdAt: { gte: threeHoursAgo }, - schedule: { departureAt: { gte: now } }, - } as any, + // Do NOT filter by schedule.departureAt here: for multi-stop routes the + // passenger's segment may depart well after the schedule's first stop, and + // that first-stop time could already be in the past even though B→C is still open. + }, include: { schedule: { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, + stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true } }, }, }, }, @@ -110,9 +177,15 @@ export class TasksService { for (const booking of bookings) { try { - const createdAt = booking.createdAt as Date; - const dep = booking.schedule.departureAt as Date; - const paymentDeadline = computePaymentDeadline(createdAt, dep); + const createdAt = booking.createdAt as Date; + // Use the booking's origin-segment departure and the route's own check-in window. + const originStop = (booking.schedule as any).stopTimes?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30; + if (dep <= now) continue; // segment has already departed; cancel job handles clean-up + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately @@ -176,6 +249,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, + stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true } }, }, }, paymentIntent: { select: { method: true } }, @@ -187,9 +262,14 @@ export class TasksService { for (const booking of expiredBookings) { try { - // Re-verify exact deadline to avoid racing with a concurrent payment confirmation - const createdAt = booking.createdAt as Date; - const dep = booking.schedule.departureAt as Date; + // Re-verify exact deadline to avoid racing with a concurrent payment confirmation. + // Use the booking's origin-segment departure for the deadline so that a B→C booking + // on an A→B→C→D schedule gets the correct payment window anchored to B, not A. + const createdAt = booking.createdAt as Date; + const originStop = (booking.schedule as any).stopTimes?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; const paymentDeadline = computePaymentDeadline(createdAt, dep); if (now < paymentDeadline) continue; @@ -201,7 +281,7 @@ export class TasksService { // this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even // though the booking is now cancelled. Scoped to this booking's own schedule, // since the same physical Seat row is reused across other recurring dates. - const seatIds = booking.seats.map(s => s.seatId); + const seatIds = booking.seats.map((s: any) => s.seatId); if (seatIds.length > 0) { await this.prisma.seatHold.deleteMany({ where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } }, 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 2524c20b0..feca7e3b5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -16,6 +16,7 @@ interface RouteStop { sequence: number; distanceKm?: number; distanceFromOrigin?: number; + checkinMinutesBefore?: number; } type Tab = 'routes' | 'coaches'; @@ -167,10 +168,13 @@ export default function RoutesPage() { const [activeTab, setActiveTab] = useState('routes'); const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); + const [editLoading, setEditLoading] = useState(false); const [stops, setStops] = useState([]); const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [destinationDistance, setDestinationDistance] = useState(undefined); + const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); + const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -241,19 +245,22 @@ export default function RoutesPage() { // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) const stopsArray = [ - { stationId: originStationId, sequence: 1, distanceKm: 0 }, + { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, ...sortedMiddleStops.map((stop, idx) => ({ stationId: stop.stationId, sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, + checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, + checkinMinutesBefore: destinationCheckinMinutes ?? undefined, }, ]; + const checkinRaw = formData.get('checkinMinutesBefore') as string; const routeData = { code: formData.get('code') as string, name: formData.get('name') as string, @@ -261,6 +268,7 @@ export default function RoutesPage() { active: !editingRoute ? (formData.get('active') !== 'false') : undefined, effectiveFrom: formData.get('effectiveFrom') as string, effectiveUntil: formData.get('effectiveUntil') as string || undefined, + checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined, stops: stopsArray, }; @@ -341,6 +349,11 @@ export default function RoutesPage() { { key: 'code', label: 'Route Code', sortable: true }, { key: 'name', label: 'Route Name', sortable: true }, { key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' }, + { + key: 'checkinMinutesBefore', + label: 'Check-in Cutoff', + render: (route: any) => `${route.checkinMinutesBefore ?? 30} min`, + }, { key: 'active', label: 'Status', @@ -363,29 +376,38 @@ export default function RoutesPage() { ); }); + const openEditModal = async (route: any) => { + setEditLoading(true); + try { + const full = await routesApi.getById(route.id) as any; + const routeStops: any[] = full?.stops || []; + setEditingRoute(full ?? route); + if (routeStops.length >= 2) { + const originStop = routeStops[0]; + const destStop = routeStops[routeStops.length - 1]; + setOriginStationId(originStop.stationId); + setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined); + setDestinationStationId(destStop.stationId); + setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); + setDestinationDistance(destStop.distanceKm || 0); + setStops(routeStops.slice(1, -1).map((s: any) => ({ + stationId: s.stationId, + sequence: s.sequence, + distanceKm: s.distanceKm, + distanceFromOrigin: s.distanceKm || 0, + checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + }))); + } + setShowModal(true); + } finally { + setEditLoading(false); + } + }; + const routeActions = [ { label: 'Edit', - onClick: (route: any) => { - setEditingRoute(route); - const routeStops = route.stops || []; - if (routeStops.length >= 2) { - setOriginStationId(routeStops[0].stationId); - setDestinationStationId(routeStops[routeStops.length - 1].stationId); - - // distanceKm is cumulative from origin — read directly - setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0); - - const middleStops = routeStops.slice(1, -1).map((stop: any) => ({ - stationId: stop.stationId, - sequence: stop.sequence, - distanceKm: stop.distanceKm, - distanceFromOrigin: stop.distanceKm || 0, - })); - setStops(middleStops); - } - setShowModal(true); - }, + onClick: openEditModal, variant: 'secondary' as const, icon: Edit, }, @@ -410,10 +432,11 @@ export default function RoutesPage() { onClick={() => { setEditingRoute(null); setOriginStationId(''); + setOriginCheckinMinutes(undefined); setDestinationStationId(''); + setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); setStops([]); - setSearch(''); setShowModal(true); }} > @@ -456,7 +479,7 @@ export default function RoutesPage() { data={displayedRoutes} columns={routeColumns} actions={routeActions} - loading={routesLoading} + loading={routesLoading || editLoading} emptyMessage={search ? 'No routes match your search' : 'No routes found'} /> @@ -488,7 +511,9 @@ export default function RoutesPage() { setShowModal(false); setEditingRoute(null); setOriginStationId(''); + setOriginCheckinMinutes(undefined); setDestinationStationId(''); + setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); setStops([]); setSearch(''); @@ -588,6 +613,23 @@ export default function RoutesPage() { /> +
+ + +

+ Booking closes and check-in ends this many minutes before each stop's departure. Default: 30. +

+
+ {!editingRoute && (
@@ -623,7 +665,7 @@ export default function RoutesPage() {
- Drag to rearrange intermediate stops + Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -641,9 +683,18 @@ export default function RoutesPage() { Select origin station above )}
-
- 0 km +
+ setOriginCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Check-in cutoff override (minutes) for this stop" + />
+
0 km
{stops.map((stop, index) => ( @@ -678,7 +729,7 @@ export default function RoutesPage() { ))}
-
+
+
+ updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Check-in cutoff override (minutes) for this stop" + /> +
-
+
+ {destinationStationId && ( + setDestinationCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Check-in cutoff override (minutes) for this stop" + /> + )} +
+
{destinationStationId && (
- {isConfirmed && ( -
- Total paid:{" "} - - {booking?.payment?.amountMinor != null - ? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}` - : `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`} - - {booking?.payment?.method && ( - - {" "} - via {booking.payment.method} - - )} -
- )}
{isConfirmed && ( @@ -1076,17 +1059,6 @@ function BookingDetailContent() {
- {isConfirmed && ( -
-
- -
-
- )}
))}
@@ -1095,17 +1067,6 @@ function BookingDetailContent() {
- {isConfirmed && ( -
-
- -
-
- )} )}