Merge pull request #958 from Tria-plc/dev

syncing
This commit is contained in:
Nathnael Wondisha
2026-07-25 12:25:56 +03:00
committed by GitHub
179 changed files with 28129 additions and 1083 deletions

View File

@@ -0,0 +1,43 @@
/**
* Resolves the booking/check-in cutoff for one boarding stop.
*
* Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30.
*
* Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt.
* - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival).
* - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore).
* cutoffAt = departureAt checkinMinutesBefore = arrivalAt, so booking closes the
* moment the train reaches the stop — independent of how long ago it left the origin.
*
* Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both
* apply it; GuestBookingService.createGuestBooking also applies it per boarding stop.
*/
export interface CheckinCutoff {
/** The stop's planned departure time (or arrival / schedule departure as fallback). */
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?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt;
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
return {
segmentTime,
checkinMinutes,
cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000),
};
}

View File

@@ -0,0 +1,76 @@
import { Logger } from '@nestjs/common';
const logger = new Logger('ScheduleTimesUtils');
export type StopForTiming = {
sequence: number;
distanceKm: number | null;
travelMinutesToStop: number | null;
checkinMinutesBefore: number | null;
};
export type PlannedStopTime = {
sequence: number;
plannedArrivalAt: string | undefined;
plannedDepartureAt: string | undefined;
};
/**
* Computes each stop's planned arrival/departure time by walking the route in sequence order.
*
* Model per intermediate stop:
* arrival = departureCursor + travelMinutesToStop (falls back to distance interpolation)
* departure = arrival + checkinMinutesBefore (dwell time; 0 if null)
* next-stop travel starts from this departure, not from arrival.
*
* This means booking for stop B closes at B.departureAt checkinMinutesBefore = B.arrivalAt,
* i.e. the train must not yet have arrived at the stop for a booking to succeed.
*
* The last stop is always locked to arr so schedule.arrivalAt stays authoritative.
*/
export function computePlannedStopTimes(
route: { id: string; stops: StopForTiming[] },
dep: Date,
arr: Date,
): PlannedStopTime[] {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
// cursor tracks the DEPARTURE time from the most-recently processed stop.
let departureCursor = dep;
return route.stops.map((stop, index) => {
if (index === 0) {
// Origin: train starts here, no arrival.
departureCursor = dep;
return { sequence: stop.sequence, plannedArrivalAt: undefined, plannedDepartureAt: dep.toISOString() };
}
if (index === route.stops.length - 1) {
// Final destination: arrival is authoritative; no departure.
return { sequence: stop.sequence, plannedArrivalAt: arr.toISOString(), plannedDepartureAt: undefined };
}
// Intermediate stop: compute arrival from the previous stop's departure.
let arrivalAt: Date;
if (stop.travelMinutesToStop != null) {
arrivalAt = new Date(departureCursor.getTime() + stop.travelMinutesToStop * 60_000);
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
arrivalAt = new Date(dep.getTime() + totalDuration * progress);
logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
}
// Dwell at this stop = checkinMinutesBefore (the boarding window).
const dwell = stop.checkinMinutesBefore ?? 0;
const departureAt = new Date(arrivalAt.getTime() + dwell * 60_000);
departureCursor = departureAt;
return {
sequence: stop.sequence,
plannedArrivalAt: arrivalAt.toISOString(),
plannedDepartureAt: departureAt.toISOString(),
};
});
}

View File

@@ -0,0 +1,37 @@
/**
* Resolves a booking's actual boarding/alighting station AND time for one leg from
* originStationId/destinationStationId (set when the booking covers only part of a
* longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D), via
* the schedule's stopTimes — falling back to the schedule's own full-route
* station/time when there's no segment override (older records, or a booking that
* covers the whole run).
*
* Single source of truth for this resolution — station-only lookups used to be
* duplicated ad hoc across bookings/tickets/notifications while the departureAt/
* arrivalAt kept being read straight off the schedule (the train's full-route span),
* which showed the wrong boarding/alighting time for any stop-based booking.
*/
export interface ResolvedSegment {
origin: any;
destination: any;
departureAt: any;
arrivalAt: any;
}
export function resolveBookingSegment(
schedule: any,
originStationId: string | null | undefined,
destinationStationId: string | null | undefined,
): ResolvedSegment {
const stopTimes: any[] = schedule?.stopTimes ?? [];
const findStop = (stationId: string | null | undefined) =>
stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined;
const originStop = findStop(originStationId);
const destStop = findStop(destinationStationId);
return {
origin: originStop?.station ?? schedule?.originStation ?? null,
destination: destStop?.station ?? schedule?.destinationStation ?? null,
departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null,
arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null,
};
}

View File

@@ -32,9 +32,11 @@ import {
import {
CreateGuestBookingDto,
GetSavedPassengersDto,
IssueReservationBookingDto,
} from "./guest-booking.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin } from "../../common/passenger-guards";
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Booking")
@Controller("bookings")
@@ -349,6 +351,35 @@ export class BookingsController {
return this.guestService.createGuestBooking(dto, req);
}
@Post("reservations/:seatId/issue")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Issue a booking from a reserved (blocked) seat",
description:
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.",
})
@ApiBody({ type: IssueReservationBookingDto })
issueBookingFromReservation(
@Param("seatId") seatId: string,
@Body() dto: IssueReservationBookingDto,
@Req() req: any,
) {
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
}
@Get("pay/:token")
@SetMetadata("isPublic", true)
@ApiOperation({
summary: "Resolve a reservation booking by its pay token (public)",
description:
"Used by the portal's standalone pay-by-link page for a reservation booking awaiting passenger payment — no login required.",
})
getByPayToken(@Param("token") token: string) {
return this.service.getByPayToken(token);
}
@Get("saved-passengers")
@SetMetadata("isPublic", true)
@ApiOperation({

View File

@@ -10,9 +10,11 @@ import { CurrencyModule } from '../currency/currency.module';
import { AuthModule } from '../auth/auth.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -140,7 +141,7 @@ export class BookingsService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
@@ -148,31 +149,34 @@ export class BookingsService {
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
items: items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
@@ -270,7 +274,7 @@ export class BookingsService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
seats: { select: { id: true } },
priceTier: { select: { priceMinor: true } },
@@ -294,7 +298,9 @@ export class BookingsService {
this.prisma.packageBooking.count({ where: pkgWhere }),
]);
const mappedBookings = items.map(booking => ({
const mappedBookings = items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
@@ -309,14 +315,15 @@ export class BookingsService {
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
}));
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
@@ -397,7 +404,7 @@ export class BookingsService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
@@ -405,9 +412,11 @@ export class BookingsService {
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
items: items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
@@ -422,14 +431,15 @@ export class BookingsService {
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
};
}),
meta: {
page,
pageSize,
@@ -532,7 +542,7 @@ export class BookingsService {
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
@@ -554,9 +564,10 @@ export class BookingsService {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null;
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
@@ -567,11 +578,11 @@ export class BookingsService {
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: booking.schedule ? {
schedule: segment ? {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
} : null,
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
@@ -715,16 +726,15 @@ export class BookingsService {
verifaydaVerified: s.verifaydaVerified,
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
})),
schedule: {
train: booking.schedule.train,
originStation: (booking as any).originStationId
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
: booking.schedule.originStation,
destinationStation: (booking as any).destinationStationId
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
schedule: (() => {
const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
};
})(),
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
@@ -1850,32 +1860,49 @@ 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);
/**
* Resolves a reservation booking by its standalone payToken the public entry point for
* the portal's pay-by-link page (a traveler who never had a portal session, texted a link
* by a reservation-issuing staff member). Payment itself still goes through the already-
* public /payments/* endpoints (initiate/methods/booking-amount/status), keyed by the
* booking id this returns — no change needed there.
*/
async getByPayToken(token: string) {
const booking = await this.prisma.booking.findUnique({
where: { payToken: token },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) throw new NotFoundException('Payment link not found');
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('This booking is no longer awaiting payment');
if ((booking as any).payTokenExpiresAt && (booking as any).payTokenExpiresAt < new Date()) {
throw new BadRequestException('This payment link has expired');
}
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).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,
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: booking.currency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
schedule: {
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: segment.origin.id, name: segment.origin.name, code: segment.origin.code },
destination: { id: segment.destination.id, name: segment.destination.name, code: segment.destination.code },
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
seats: (booking as any).seats.map((s: any) => ({
passengerName: s.passengerName,
seatNumber: s.seat?.seatNumber ?? null,
coach: s.seat?.coach?.number ?? null,
})),
};
}
@@ -1987,13 +2014,13 @@ export class BookingsService {
if (refreshed) Object.assign(booking, refreshed);
}
const outboundSegment = this.resolveSegmentStations(
const outboundSegment = resolveBookingSegment(
(booking as any).schedule,
(booking as any).originStationId,
(booking as any).destinationStationId,
);
const returnSegment = (booking as any).returnSchedule
? this.resolveSegmentStations(
? resolveBookingSegment(
(booking as any).returnSchedule,
(booking as any).returnOriginStationId,
(booking as any).returnDestinationStationId,

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -174,6 +174,67 @@ export class SavedPassengerProfileDto {
}
export class GetSavedPassengersDto {
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
@IsOptional() @IsString() deviceId?: string;
}
export enum ReservationBookingKind {
STAFF = 'STAFF',
PASSENGER = 'PASSENGER',
}
export const RESERVATION_NATIONALITIES = ['Ethiopian', 'Djiboutian', 'Other'] as const;
export type ReservationNationality = (typeof RESERVATION_NATIONALITIES)[number];
/**
* Issues a real booking against a seat an admin/staff previously reserved (SeatBlock) —
* one traveler per seat, fare always server-computed (no seatFareMinor/reviewedTotalMinor
* override: unlike the guest DTO, there's no untrusted client-displayed price to defend
* against here). seatId comes from the route param, not the body.
*
* No seatClassId — the seat (and therefore its coach/class) is already fixed by the
* reservation being converted; the service resolves the correct seat class itself from the
* seat's own coach type and nationality (LOCAL vs INTERNATIONAL pricing tier), the same
* matching search results already use. No passportCountry — nationality alone is what
* drives both fare-tier selection and passport-vs-national-ID validation.
*/
export class IssueReservationBookingDto {
@ApiProperty({ example: 'schedule-uuid' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid' })
@IsString() destinationStationId: string;
@ApiProperty({ enum: ReservationBookingKind, description: 'STAFF: no fee, ticket issued immediately. PASSENGER: a payment link is sent to phone.' })
@IsEnum(ReservationBookingKind) bookingKind: ReservationBookingKind;
@ApiProperty({ example: 'Abebe Kebede' })
@IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' })
@IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType })
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({ example: 'ET123456789' })
@IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567' })
@IsOptional() @IsString() passportNumber?: string;
@ApiProperty({ example: 'Ethiopian', enum: RESERVATION_NATIONALITIES, description: 'Drives both the LOCAL/INTERNATIONAL fare tier and passport-vs-national-ID validation.' })
@IsIn(RESERVATION_NATIONALITIES) nationality: ReservationNationality;
@ApiPropertyOptional({ example: '+251912345678', description: 'Required when bookingKind is PASSENGER — the payment link is sent here.' })
@IsOptional() @IsString() phone?: string;
@ApiPropertyOptional({ example: 'abebe@email.com' })
@IsOptional() @IsString() email?: string;
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}

View File

@@ -6,11 +6,52 @@ import { CurrencyService } from '../currency/currency.service';
import { PassengerAuthService } from '../auth/passenger-auth.service';
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 { PaymentsService } from '../payments/payments.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { randomUUID } from 'crypto';
/** 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`,
);
}
}
/**
* Resolves the seat class for a specific, already-known seat (its coach type only ever
* offers a fixed set of classes) given the traveler's nationality tier — mirrors
* search.service.ts's own LOCAL/INTERNATIONAL + bed-position matching so the price a
* reservation-issued booking charges is the exact same "already configured price setup"
* search results would have quoted, without asking the admin to redundantly re-pick a class
* for a seat whose class is already fixed.
*/
function resolveSeatClassForSeat(seat: any, nationality: string): { id: string; name: string } {
const nationalityUpper = (nationality ?? '').toUpperCase();
const resolvedNationalityType = nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
const candidates = (seat.coach?.coachType?.seatClasses ?? []).filter(
(sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
);
const matchingClass = seat.bedPosition
? candidates.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition)
: candidates[0];
if (!matchingClass) {
throw new BadRequestException('No seat class is configured for this seat and nationality — set up seat classes for this coach type first.');
}
return matchingClass;
}
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -52,6 +93,9 @@ export class GuestBookingService {
private passengerAuthService: PassengerAuthService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
private paymentsService: PaymentsService,
private auditService: AuditService,
private smsClient: SmsClientService,
) { }
/**
@@ -107,20 +151,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}`;
@@ -369,6 +415,228 @@ export class GuestBookingService {
};
}
/**
* Converts an admin-reserved seat (SeatBlock) into a real booking for one traveler —
* no SeatHold involved (the seat was already set aside), so this mirrors
* createGuestOneWayBooking's schedule/fare/passenger resolution but skips the hold check
* and instead validates+releases the SeatBlock. STAFF bookings are fee-waived and
* finalized immediately via the same PaymentsService.finalizePaymentSuccess() path every
* real payment webhook uses; PASSENGER bookings are left PENDING_PAYMENT with a payToken
* texted to the traveler so they can pay via the existing, already-public /payments/*
* endpoints without a portal session.
*/
async issueBookingFromReservation(
seatId: string,
dto: IssueReservationBookingDto,
actingUserId: string | null,
): Promise<{ booking: any; payUrl?: string }> {
if (dto.bookingKind === ReservationBookingKind.PASSENGER && !dto.phone) {
throw new BadRequestException('Phone number is required for a passenger booking');
}
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID && dto.nationality !== 'Ethiopian') {
throw new BadRequestException('National ID is only valid for Ethiopian nationality — use a passport instead');
}
const seatBlock = await this.prisma.seatBlock.findFirst({
where: { seatId, OR: [{ scheduleId: dto.scheduleId }, { scheduleId: null }] },
});
if (!seatBlock) throw new NotFoundException('Seat is not reserved');
// The seat (and therefore its coach) is already fixed by the reservation — resolve the
// seat class from the seat's own coach type + the traveler's nationality tier, the same
// LOCAL/INTERNATIONAL + bed-position matching search results already use, instead of
// asking the admin to redundantly pick a class.
const seat = await this.prisma.seat.findUnique({
where: { id: seatId },
include: { coach: { include: { coachType: { include: { seatClasses: true } } } } },
});
if (!seat) throw new NotFoundException('Seat not found');
const resolvedSeatClass = resolveSeatClassForSeat(seat, dto.nationality);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
route: { include: { stops: true } },
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
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');
assertWithinCheckinCutoff(schedule, originStop, dto.originStationId);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
// Single-traveler passenger processing. nationality is a fixed dropdown value (Ethiopian/
// Djiboutian/Other), so — unlike the guest-booking loop this mirrors — there's no need to
// infer it from document type/country; only the Verifayda check (NATIONAL_ID) and the
// passport-number requirement (PASSPORT) still depend on the chosen document type.
const dateOfBirth = new Date(dto.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
let passengerName = dto.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (dto.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(dto.idDocumentNumber);
if (!verification.verified) {
throw new BadRequestException(`Verifayda verification failed for ${dto.passengerName}: ${verification.failureReason}`);
}
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
}
} else if (dto.idDocumentType === IdDocumentType.PASSPORT) {
if (!dto.passportNumber) {
throw new BadRequestException(`Passport number required for ${dto.passengerName}`);
}
}
const passengerData = {
passengerName,
dateOfBirth,
category,
verifaydaVerified,
verifaydaData,
nationality: dto.nationality,
idDocumentType: dto.idDocumentType,
idDocumentNumber: dto.idDocumentNumber,
passportNumber: dto.passportNumber,
phone: dto.phone,
email: dto.email,
};
const baseFareMinor = await this.getBaseFare(
dto.scheduleId,
resolvedSeatClass.id,
segmentRoute,
fullRoute,
dto.nationality,
dto.originStationId,
dto.destinationStationId,
);
const isStaff = dto.bookingKind === ReservationBookingKind.STAFF;
const displayCurrency = dto.displayCurrency || Currency.ETB;
const totalMinor = isStaff ? 0 : baseFareMinor;
if (!isStaff) {
// Defense-in-depth — there's no client-forgeable price on this DTO, but keep the
// same authoritative-fare floor every other booking path enforces.
this.assertTotalNotUnderAuthoritative(totalMinor, baseFareMinor, 'issueBookingFromReservation');
}
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
const payToken = isStaff ? undefined : randomUUID();
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
totalMinor,
currency: Currency.ETB,
adultCount: category === PassengerCategory.ADULT ? 1 : 0,
childCount: category === PassengerCategory.CHILD ? 1 : 0,
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
source: 'BACKOFFICE_RESERVATION',
contactEmail: dto.email || null,
contactPhone: dto.phone || null,
payToken,
payTokenExpiresAt,
seats: {
create: [{
seat: { connect: { id: seatId } },
scheduleId: dto.scheduleId,
passengerName: passengerData.passengerName,
dateOfBirth: passengerData.dateOfBirth,
passengerCategory: passengerData.category,
idDocumentType: passengerData.idDocumentType,
passportNumber: passengerData.passportNumber,
verifaydaVerified: passengerData.verifaydaVerified,
verifaydaData: passengerData.verifaydaData || undefined,
fareMinor: totalMinor,
displayCurrency,
}],
},
},
include: {
seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
await this.createTravelerProfiles(guestPassengerId, [passengerData]);
// Release the reservation using the SAME scope it was created with (global vs
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
// block; reimplementing that here would risk missing that reset.
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
await this.seatsService.confirmSeats([seatId]);
this.eventEmitter.emit('booking.created', { booking });
if (isStaff) {
await this.auditService.log({
userId: actingUserId ?? undefined,
action: 'CREATE',
entityType: 'Booking',
entityId: booking.id,
newData: { feeWaived: true, waivedBy: actingUserId, originalFareMinor: baseFareMinor },
});
const intent = await this.prisma.paymentIntent.create({
data: {
bookingId: booking.id,
amountMinor: 0,
currency: 'ETB',
// WALLET is an internal-only method that never leaves this app (see
// payments.service.ts) — safe, inert placeholder for a zero-charge waiver.
method: PaymentMethodType.WALLET,
status: PaymentIntentStatus.REQUIRES_ACTION,
},
});
await this.paymentsService.finalizePaymentSuccess({ intentId: intent.id });
// finalizePaymentSuccess mutates the booking (status -> CONFIRMED) in the DB —
// re-fetch so the caller sees the actual outcome, not the pre-finalization snapshot.
const confirmedBooking = await this.prisma.booking.findUnique({ where: { id: booking.id } });
return { booking: confirmedBooking };
}
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const payUrl = `${portalUrl}/reserve/pay/${payToken}`;
const amountStr = (totalMinor / 100).toFixed(2);
try {
await this.smsClient.sendSms({
to: dto.phone!,
message: `EDR: Your seat is reserved. Pay ${amountStr} ETB to confirm your ticket: ${payUrl}`,
});
} catch (err) {
this.logger.warn(`Reservation payment-link SMS failed for booking ${booking.bookingRef}: ${err}`);
}
return { booking, payUrl };
}
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
@@ -391,7 +659,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 +669,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 +682,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 +958,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 +968,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 +975,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 +1162,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 +1172,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 +1185,10 @@ export class GuestBookingService {
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
// Cut off relative to the passenger's actual boarding stop, using the same configurable
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId);
// Process passengers (verify once)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;

View File

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

View File

@@ -242,6 +242,61 @@ export class PaymentsService {
return this.initiateWalletPayment(booking);
}
// Double-charge guard for payment-method switches. Before opening a fresh charge over
// this booking, reconcile any still-open intent against the authoritative provider
// status — the booking-status check above only blocks once the booking is CONFIRMED,
// which leaves a window where the first attempt actually paid but the mark-paid
// webhook/poll hasn't landed yet.
const existingIntent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: booking.id },
});
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
);
}
// The previous attempt actually paid (provider SUCCEEDED, event just late):
// converge the booking now and return it — never charge a second time.
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
let intent = await this.syncIntentProjection(booking.id, snapshot);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(intent);
}
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
// service was unreachable and the local status is non-terminal. Block the switch:
// return the existing intent so the payer completes or waits out the open attempt
// rather than opening a second concurrent charge.
if (
!snapshot ||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
snapshot.status === ProviderPaymentStatus.PROCESSING
) {
const intent = snapshot
? await this.syncIntentProjection(booking.id, snapshot)
: existingIntent;
return this.formatIntentResponse(intent);
}
// Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
// the newly selected method below.
}
const { returnUrl, failureUrl } = this.resolveReturnUrls(
method,
requestOrigin,

View File

@@ -53,6 +53,12 @@ export class ReportsController {
return this.service.getSeatStatusReport(scheduleId);
}
@Get("boarding")
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) {
return this.service.getBoardingReport(scheduleId);
}
@Get("payments")
@ApiOperation({ summary: "Payments collected for a schedule" })
getPaymentsReport(@Query('scheduleId') scheduleId: string) {

View File

@@ -599,32 +599,31 @@ export class ReportsService {
sortBy?: string;
search?: string;
}) {
// Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies).
// Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate).
// Load all exchange rates once — we need conversions in both directions.
const rateRows = await this.prisma.currencyExchangeRate.findMany({
where: { toCurrency: 'ETB' as any },
orderBy: { effectiveDate: 'desc' },
});
const rateToEtb = new Map<string, number>();
// Most-recent rate for each fromCurrency→toCurrency pair
const rateMap = new Map<string, number>();
for (const r of rateRows) {
if (!rateToEtb.has(r.fromCurrency)) {
rateToEtb.set(r.fromCurrency, Number(r.rate));
}
const key = `${r.fromCurrency}${r.toCurrency}`;
if (!rateMap.has(key)) rateMap.set(key, Number(r.rate));
}
// Convert any minor amount to its ETB equivalent using stored exchange rates.
// b.totalMinor is the booking's canonical ETB amount (always stored in ETB),
// so callers should pass that directly rather than converting displayTotalMinor.
const toEtbMinor = (minor: number, currency: string): number => {
if (currency === 'ETB') return minor;
const rate = rateToEtb.get(currency);
// If no rate is on file fall back to the raw value (avoids silently hiding
// cross-currency bookings, at the cost of an approximate comparison).
return rate ? Math.round(minor * rate) : minor;
// Convert minor amount from one currency to another.
const convertMinor = (minor: number, from: string, to: string): number => {
if (from === to) return minor;
const direct = rateMap.get(`${from}${to}`);
if (direct) return Math.round(minor * direct);
// Try via ETB as pivot
const toEtb = rateMap.get(`${from}→ETB`);
const fromEtb = rateMap.get(`ETB→${to}`);
if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb);
return minor; // fallback: no rate on file
};
if (params.search?.trim()) {
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor);
return this.getDiscrepancyForRef(params.search.trim(), convertMinor);
}
const dateFilter: Record<string, Date> = {};
@@ -679,20 +678,18 @@ export class ReportsService {
.map(b => {
const pi = b.paymentIntent!;
// Display amounts shown to the passenger (may be in DJF).
// Display amounts shown to the passenger (may be in DJF/USD).
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
const paidMinor = pi.amountMinor;
const paidCurrency = pi.currency;
// b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount
// (the gateway receives major units — displayMinorToChargeMajor divides by 100 before
// sending). Multiply by 100 to convert back to minor before the ETB comparison.
const owedEtb = b.totalMinor;
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
const balanceMinor = owedEtb - paidEtb;
const balanceCurrency = 'ETB';
// Balance in the booking's display currency:
// convert paid (major units from gateway) to display currency minor, then subtract.
const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency);
const balanceMinor = actualMinor - paidInDisplayMinor;
const balanceCurrency = actualCurrency;
const firstSeat = b.seats[0];
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
@@ -731,7 +728,7 @@ export class ReportsService {
private async getDiscrepancyForRef(
search: string,
toEtbMinor: (minor: number, currency: string) => number,
convertMinor: (minor: number, from: string, to: string) => number,
) {
let bookingId: string | null = null;
const byPnr = await this.prisma.booking.findUnique({
@@ -797,10 +794,9 @@ export class ReportsService {
const paidMinor = pi?.amountMinor ?? 0;
const paidCurrency = pi?.currency ?? b.currency;
const owedEtb = b.totalMinor;
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
const balanceMinor = owedEtb - paidEtb;
const balanceCurrency = 'ETB';
const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency);
const balanceMinor = actualMinor - paidInDisplayMinor;
const balanceCurrency = actualCurrency;
const firstSeat = b.seats[0];
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
@@ -1026,6 +1022,119 @@ export class ReportsService {
return { total: rows.length, rows };
}
async getBoardingReport(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: {
id: true,
departureAt: true,
arrivalAt: true,
train: { select: { number: true, name: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
});
if (!schedule) return null;
const tickets = await this.prisma.ticket.findMany({
where: {
OR: [
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
],
},
select: {
id: true,
bookingRef: true,
passengerName: true,
boardedAt: true,
validatorId: true,
status: true,
booking: {
select: {
status: true,
originStationId: true,
destinationStationId: true,
},
},
seat: {
select: {
seatNumber: true,
bedPosition: true,
coach: {
select: {
number: true,
coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } },
},
},
},
},
},
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
const stationIds = [...new Set(
tickets.flatMap(t => [t.booking.originStationId, t.booking.destinationStationId]).filter(Boolean) as string[],
)];
const stations = stationIds.length > 0
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
: [];
const stationName = new Map(stations.map(s => [s.id, s.name]));
const resolveSeatClass = (seat: any): string | null => {
const classes = seat?.coach?.coachType?.seatClasses ?? [];
const matched = seat?.bedPosition
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase())
: null;
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
};
const rows = tickets.map(t => ({
bookingRef: t.bookingRef,
passengerName: t.passengerName,
coachNumber: t.seat?.coach?.number ?? null,
seatNumber: t.seat?.seatNumber ?? null,
seatClassName: resolveSeatClass(t.seat),
origin: t.booking.originStationId ? (stationName.get(t.booking.originStationId) ?? null) : null,
destination: t.booking.destinationStationId ? (stationName.get(t.booking.destinationStationId) ?? null) : null,
boarded: !!t.boardedAt,
boardedAt: t.boardedAt ?? null,
validatorId: t.validatorId ?? null,
bookingStatus: t.booking.status,
}));
const boardedCount = rows.filter(r => r.boarded).length;
const notBoardedCount = rows.length - boardedCount;
const byCoach = new Map<string, { coachNumber: string; total: number; boarded: number }>();
for (const r of rows) {
const key = r.coachNumber ?? 'Unknown';
if (!byCoach.has(key)) byCoach.set(key, { coachNumber: key, total: 0, boarded: 0 });
byCoach.get(key)!.total++;
if (r.boarded) byCoach.get(key)!.boarded++;
}
return {
schedule: {
id: schedule.id,
trainName: (schedule.train as any)?.name ?? (schedule.train as any)?.number,
origin: (schedule.originStation as any)?.name,
destination: (schedule.destinationStation as any)?.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
},
summary: {
total: rows.length,
boardedCount,
notBoardedCount,
boardingRate: rows.length > 0 ? +((boardedCount / rows.length) * 100).toFixed(1) : 0,
},
byCoach: [...byCoach.values()].sort((a, b) => a.coachNumber.localeCompare(b.coachNumber)),
rows,
};
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({
where: { id: reportId },

View File

@@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@ApiResponse({ status: 404, description: 'Route not found' })

View File

@@ -5,8 +5,9 @@ import { Type } from 'class-transformer';
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: 120.5, description: 'Cumulative distance in km from the route origin (not from the 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;
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
}
export class CreateRouteDto {
@@ -14,8 +15,9 @@ export class CreateRouteDto {
@ApiProperty({ example: 'Addis Ababa Djibouti' }) @IsString() name: string;
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiProperty({
type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
@@ -35,15 +37,17 @@ export class CreateRouteDto {
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: 75.5, description: 'Cumulative distance in km from the route origin (not from the 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;
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
}
export class UpdateRouteDto {
@ApiPropertyOptional({ example: 'Addis Ababa Djibouti Express' }) @IsOptional() @IsString() name?: string;
@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: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
@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[];
}

View File

@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
@Injectable()
export class RoutesService {
@@ -10,6 +12,33 @@ export class RoutesService {
// ── Route CRUD ─────────────────────────────────────────────────────────────
/**
* distanceKm is CUMULATIVE distance from the route origin, not distance from the previous
* stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance
* as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values
* across stops silently produces zero/negative segment distances, which the fare engine
* rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch
* the mistake here instead, with a message that names the exact stops involved.
*/
private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void {
const sorted = [...stops].sort((a, b) => a.sequence - b.sequence);
let prevDistance = sorted[0]?.distanceKm ?? 0;
for (let i = 1; i < sorted.length; i++) {
const stop = sorted[i];
if (stop.distanceKm == null) {
throw new BadRequestException(
`Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`,
);
}
if (stop.distanceKm <= prevDistance) {
throw new BadRequestException(
`Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`,
);
}
prevDistance = stop.distanceKm;
}
}
async createRoute(dto: CreateRouteDto) {
const existing = await this.prisma.route.findUnique({ where: { code: dto.code } });
if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`);
@@ -19,6 +48,8 @@ export class RoutesService {
const seqs = dto.stops.map(s => s.sequence);
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
this.validateStopDistances(dto.stops);
const stationIds = [...new Set(dto.stops.map(s => s.stationId))];
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
@@ -29,14 +60,16 @@ export class RoutesService {
name: dto.name,
description: dto.description,
active: dto.active ?? true,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
effectiveFrom: parseEthiopianTime(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null,
stops: {
create: dto.stops.map(s => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
travelMinutesToStop: s.travelMinutesToStop ?? null,
})),
},
},
@@ -86,13 +119,20 @@ export class RoutesService {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops);
await this.prisma.route.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}),
// effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave
// untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy.
...(dto.effectiveUntil !== undefined
? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null }
: {}),
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
},
});
@@ -106,8 +146,23 @@ export class RoutesService {
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
travelMinutesToStop: s.travelMinutesToStop ?? null,
})),
});
// Propagate new stop timing to all future schedules on this route so that
// per-stop check-in cutoffs reflect the updated travelMinutesToStop values.
const futureSchedules = await this.prisma.trainSchedule.findMany({
where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } },
select: { id: true, departureAt: true, arrivalAt: true },
});
const stopsForTiming = dto.stops
.map(s => ({ sequence: s.sequence, distanceKm: s.distanceKm ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null }))
.sort((a, b) => a.sequence - b.sequence);
for (const sched of futureSchedules) {
const times = computePlannedStopTimes({ id, stops: stopsForTiming }, new Date(sched.departureAt), new Date(sched.arrivalAt));
await this.applyRouteToSchedule(id, sched.id, Object.fromEntries(times.map(t => [t.sequence, t])));
}
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
@@ -218,6 +273,9 @@ export class RoutesService {
});
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } });
this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]);
return this.prisma.routeStop.create({
data: {
routeId,
@@ -225,6 +283,7 @@ export class RoutesService {
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
travelMinutesToStop: dto.travelMinutesToStop ?? null,
},
});
}

View File

@@ -154,6 +154,12 @@ export class SchedulesController {
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
@Post(':id/recalculate-stops')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
@Get(':id/stops')
@IsPublic()
@ApiOperation({ summary: 'List all stops for a schedule' })

View File

@@ -52,6 +52,10 @@ export class CreateScheduleDto {
})
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}
export class UpdateScheduleDto {

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
@@ -6,9 +6,12 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
import { AuditService } from '../../common/audit.service';
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
@Injectable()
export class SchedulesService {
private readonly logger = new Logger(SchedulesService.name);
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
@@ -43,20 +46,14 @@ export class SchedulesService {
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
plannedTimes: dto.plannedTimes || [],
coachIds: dto.coachIds,
};
// createSchedule applies coachIds if given, else auto-applies the route coach template,
// and rejects the day outright (caught below) if it would end up with zero coaches.
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// createSchedule already auto-applies the route coach template;
// only override if explicit coachIds are provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
}
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
@@ -135,26 +132,7 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
plannedTimes = computePlannedStopTimes(route, dep, arr);
}
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
@@ -183,15 +161,34 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
// Auto-apply route coach template if one is defined
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
where: { routeId: dto.routeId },
orderBy: { positionNumber: 'asc' },
});
if (coachTemplates.length > 0) {
// Explicit coachIds (from the schedule form's Coaches step) override the route's coach
// template; otherwise auto-apply the template if one is defined.
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
} else {
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
where: { routeId: dto.routeId },
orderBy: { positionNumber: 'asc' },
});
if (coachTemplates.length > 0) {
await this.assignCoaches(
schedule.id,
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
);
}
}
// A schedule with zero coaches has zero seats and is silently invisible to search (and
// unbookable) with no indication why — block creation instead of leaving a dead schedule.
const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } });
if (assignedCoachCount === 0) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } });
await this.prisma.trainSchedule.delete({ where: { id: schedule.id } });
throw new BadRequestException(
'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.',
);
}
@@ -306,26 +303,7 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
plannedTimes = computePlannedStopTimes(route, dep, arr);
}
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
@@ -619,6 +597,24 @@ export class SchedulesService {
return { synced, errors };
}
async recalculateStopTimes(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route');
const plannedTimes = computePlannedStopTimes(
schedule.route,
new Date(schedule.departureAt),
new Date(schedule.arrivalAt),
);
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
}
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
@@ -655,11 +651,14 @@ export class SchedulesService {
if (!schedule) throw new NotFoundException('Schedule not found');
const updateData: any = {};
let dep: Date | undefined;
let arr: Date | undefined;
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
@@ -672,6 +671,22 @@ export class SchedulesService {
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
}
// departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the
// OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left
// unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop
// arrival/departure estimates for every intermediate stop.
if (dep && arr && schedule.routeId) {
const route = await this.prisma.route.findUnique({
where: { id: schedule.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (route && route.stops.length >= 2) {
const plannedTimes = computePlannedStopTimes(route, dep, arr);
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
}
}
if (dto.coaches !== undefined) {
if (dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);

View File

@@ -10,7 +10,8 @@ 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 { Currency } from "@prisma/client";
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
import { Currency, Prisma } from "@prisma/client";
const POINTS_TO_MINOR = 10;
@@ -220,12 +221,18 @@ export class SearchService {
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
const baseWhere = {
status: "SCHEDULED",
// Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the
// schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) —
// it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable
// (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live
// check against each stop's estimated arrival/departure. Excluding BOARDING here would
// silently impose a hidden, non-configurable 30-minute cutoff on top of that.
const baseWhere: Prisma.TrainScheduleWhereInput = {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
} as const;
};
// Fetch candidates before and after in parallel; take more than needed to
// account for routes that don't serve the destination or have no availability.
@@ -300,23 +307,21 @@ export class SearchService {
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
// 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 ? now : date;
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
// A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g.
// Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would
// wrongly exclude the whole schedule for those still-bookable downstream segments. The
// per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: "SCHEDULED",
// EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: earliest, lt: nextDay },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
@@ -368,7 +373,8 @@ export class SearchService {
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
status: "SCHEDULED",
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
@@ -378,7 +384,7 @@ export class SearchService {
}),
this.prisma.trainSchedule.findMany({
where: {
status: "SCHEDULED",
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
@@ -547,24 +553,13 @@ export class SearchService {
if (!originStop || !destStop || originStop.sequence >= destStop.sequence)
return null;
// Segment-level cutoff: use the origin stop's planned departure, not the
// 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.
// 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
)
// 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
@@ -676,8 +671,11 @@ export class SearchService {
nationality,
availabilityByClass,
);
const legDepartureAt = schedule.departureAt;
const legArrivalAt = schedule.arrivalAt;
// Use the selected stop's own planned time, not the schedule's full-route span —
// for stop-based (mid-route) boarding/alighting these differ from the train's
// overall origin departure / final destination arrival.
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
const displayCurrency =
faresByClass[0]?.displayCurrency ??

View File

@@ -281,7 +281,7 @@ export class SeatsService {
}),
this.prisma.tripStopTime.findFirst({
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
select: { plannedDepartureAt: true },
select: { plannedArrivalAt: true, plannedDepartureAt: true },
}),
this.prisma.routeStop.findFirst({
where: {
@@ -295,7 +295,10 @@ export class SeatsService {
// 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;
// Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no
// arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival,
// so holding closes the moment the train reaches the boarding stop.
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt;
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
throw new BadRequestException(

View File

@@ -81,17 +81,23 @@ export class TasksService {
byRoute.get(stop.routeId)!.push(stop.stationId);
}
// Arrival basis: each stop's own estimated arrival time, not its departure. The first
// stop of a route has no arrival (nothing to arrive at), so it falls back to its
// departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt).
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).
// should reopen (arrival is still beyond the new cutoff window).
const reverted = await this.prisma.tripStopTime.updateMany({
where: {
status: 'CHECKIN_CLOSED',
plannedDepartureAt: { gt: cutoffAt },
OR: [
{ plannedArrivalAt: { gt: cutoffAt } },
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] },
],
stationId: { in: stationIds },
schedule: { routeId },
},
@@ -103,7 +109,10 @@ export class TasksService {
const closed = await this.prisma.tripStopTime.updateMany({
where: {
status: 'OPEN',
plannedDepartureAt: { lte: cutoffAt },
OR: [
{ plannedArrivalAt: { lte: cutoffAt } },
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] },
],
stationId: { in: stationIds },
schedule: { routeId },
},
@@ -169,8 +178,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
},
@@ -179,12 +188,17 @@ export class TasksService {
for (const booking of bookings) {
try {
const createdAt = booking.createdAt as Date;
// Use the booking's origin-segment departure and the route's own check-in window.
// Use the booking's origin-segment estimated arrival (falling back to its departure
// for the first stop) and that stop's own check-in window (falling back to the route
// default), same resolution as holdSeats/search.
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;
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const originRouteStop = (booking.schedule as any).route?.stops?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (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();
@@ -230,13 +244,28 @@ export class TasksService {
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
// The departure pre-filter below is a query-scoping optimization only — the real
// deadline check happens per-row further down. It must be widened to the largest
// configured checkinMinutes across all routes/stops, or a booking on a route with a
// cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here,
// silently never getting auto-cancelled.
const [maxRouteCutoff, maxStopCutoff] = await Promise.all([
this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }),
this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }),
]);
const effectiveMaxCutoffMinutes = Math.max(
CUTOFF_MINUTES,
maxRouteCutoff._max.checkinMinutesBefore ?? 0,
maxStopCutoff._max.checkinMinutesBefore ?? 0,
);
const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes)
// Deadline is reached when either branch of the MIN is in the past:
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + 30min → departure within 30 min
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
@@ -250,8 +279,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
paymentIntent: { select: { method: true } },
@@ -264,14 +293,19 @@ export class TasksService {
for (const booking of expiredBookings) {
try {
// 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.
// Use the booking's origin-segment estimated arrival (falling back to its departure
// for the first stop) and that stop's own check-in window, so 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);
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const originRouteStop = (booking.schedule as any).route?.stops?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)

View File

@@ -14,10 +14,11 @@ export class TicketsController {
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Generate tickets for all confirmed bookings that are missing them',
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed/remaining counts. Call repeatedly until remaining=0.',
})
generateMissing() {
return this.service.generateMissing();
@ApiQuery({ name: 'limit', required: false, description: 'Max bookings to process per call (default 10)' })
generateMissing(@Query('limit') limit?: string) {
return this.service.generateMissing(limit ? parseInt(limit, 10) : 10);
}
@Post('smart-assign/:bookingId')

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -129,6 +130,7 @@ export class TicketsService {
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId);
return {
id: t.id,
ticketNumber: t.barcodePayload,
@@ -152,20 +154,14 @@ export class TicketsService {
contactPhone: t.booking?.contactPhone,
returnSchedule: t.booking?.returnSchedule ?? null,
seats: t.booking?.seats ?? [],
originStation: (() => {
const id = t.booking?.originStationId;
if (!id) return t.booking?.schedule?.originStation ?? null;
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
})(),
destinationStation: (() => {
const id = t.booking?.destinationStationId;
if (!id) return t.booking?.schedule?.destinationStation ?? null;
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
})(),
originStation: segment.origin,
destinationStation: segment.destination,
},
schedule: t.booking?.schedule,
schedule: t.booking?.schedule ? {
...t.booking.schedule,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
} : null,
seat: t.seat ? {
id: t.seat.id,
seatNumber: t.seat.seatNumber,
@@ -643,11 +639,14 @@ export class TicketsService {
throw new NotFoundException('No ticket found for this booking');
}
// Check if ticket date matches today
// Check if ticket date matches today. Boarding window is relative to the
// passenger's actual boarding stop, not the train's origin — for a mid-route
// boarding these differ.
const today = new Date();
if ((booking as any).schedule?.departureAt) {
const departureTime = new Date((booking as any).schedule.departureAt);
const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
if (boardingSegment.departureAt) {
const departureTime = new Date(boardingSegment.departureAt);
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
@@ -673,18 +672,6 @@ export class TicketsService {
// Send notifications after successful boarding
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
// Resolve user-selected segment rather than the full schedule route
const _schedStops = (booking as any).schedule?.stopTimes ?? [];
const _resolveStation = (id: string | null | undefined, fallback: any) => {
if (id) {
const found = _schedStops.find((st: any) => st.stationId === id)?.station;
if (found) return found;
}
return fallback;
};
const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation);
const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation);
return {
success: true,
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
@@ -693,11 +680,11 @@ export class TicketsService {
ticketNumber: ticket.barcodePayload,
bookingRef: booking.bookingRef,
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
route: `${boardingOrigin?.name || 'N/A'}${boardingDest?.name || 'N/A'}`,
route: `${boardingSegment.origin?.name || 'N/A'}${boardingSegment.destination?.name || 'N/A'}`,
seat: seatNumber,
coach: coachNumber,
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
departureTime: (booking as any).schedule?.departureAt,
departureTime: boardingSegment.departureAt,
boardedAt: result.validatedAt,
leg: result.leg || 'OUTBOUND',
bookingType: booking.bookingType,
@@ -774,7 +761,7 @@ export class TicketsService {
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
@@ -793,7 +780,7 @@ export class TicketsService {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
@@ -826,7 +813,7 @@ export class TicketsService {
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
@@ -905,15 +892,21 @@ export class TicketsService {
};
}
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
const confirmedWithNoTickets = await this.prisma.booking.findMany({
where: {
status: 'CONFIRMED',
tickets: { none: {} },
paymentIntent: { status: 'SUCCEEDED' },
},
select: { id: true, bookingRef: true },
});
async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> {
const missingWhere = {
status: 'CONFIRMED' as const,
tickets: { none: {} },
paymentIntent: { status: 'SUCCEEDED' as const },
};
const [confirmedWithNoTickets, totalRemaining] = await Promise.all([
this.prisma.booking.findMany({
where: missingWhere,
select: { id: true, bookingRef: true },
take: limit,
}),
this.prisma.booking.count({ where: missingWhere }),
]);
const details: any[] = [];
let generated = 0;
@@ -930,7 +923,13 @@ export class TicketsService {
}
}
return { processed: confirmedWithNoTickets.length, generated, failed, details };
return {
processed: confirmedWithNoTickets.length,
generated,
failed,
remaining: Math.max(0, totalRemaining - confirmedWithNoTickets.length),
details,
};
}
async delete(id: string) {