Files
edr-platform/apps/edr-passenger-api/src/modules/search/search.service.ts
Muluhabt 1d6f89df29 fix(search): compute next-day date bound with real arithmetic, not string padding
Searching on the last day of a month (e.g. 2026-07-31) built an invalid
"next day" string like "2026-07-32" for the Prisma date-range filter,
crashing POST /search with a PrismaClientValidationError in production.
Affected searchSchedules, searchAlternatives, classifyEmptySearch, and
searchTransitOptions — all four built the bound the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 09:28:34 +03:00

1399 lines
49 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../common/prisma.service";
import {
SearchTripsDto,
FareQuoteDto,
FareBreakdownRequestDto,
FareBreakdownPassengerDto,
AvailableDatesQueryDto,
} from "./search.dto";
import { CurrencyService } from "../currency/currency.service";
import { FareEngineService } from "../fare-engine/fare-engine.service";
import { SegmentsService } from "../segments/segments.service";
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
import { Currency, Prisma } from "@prisma/client";
import { Passenger } from "@edr/types";
const POINTS_TO_MINOR = 10;
// Shape returned by the heavy schedule include used throughout this service
type ScheduleWithIncludes = {
id: string;
routeId: string | null;
departureAt: Date;
arrivalAt: Date;
status: string;
train: any;
originStation: any;
destinationStation: any;
route: {
checkinMinutesBefore: number;
stops: Array<{ stationId: string; checkinMinutesBefore: number | null }>;
} | null;
stopTimes: Array<{
stationId: string;
sequence: number;
plannedArrivalAt: Date | null;
plannedDepartureAt: Date | null;
station: any;
}>;
coachAssignments: Array<{
coach: {
id: string;
seats: any[];
coachType: {
id: string;
name: string;
code: string;
seatClasses: any[];
} | null;
};
}>;
};
const SCHEDULE_INCLUDE = {
train: true,
originStation: true,
destinationStation: true,
route: {
select: {
checkinMinutesBefore: true,
stops: { select: { stationId: true, checkinMinutesBefore: true } },
},
},
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
coachAssignments: {
include: {
coach: {
include: { seats: true, coachType: { include: { seatClasses: true } } },
},
},
},
} as const;
@Injectable()
export class SearchService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
) {}
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
this.searchTransitOptions(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
]);
const outbound = [...direct, ...transit];
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
const [alternativesOutbound, outboundReason] = await Promise.all([
this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
]);
return {
journeyType: "ONE_WAY",
outbound: [],
alternativeOutbound: alternativesOutbound,
requestedDate: dto.date,
outboundReason,
};
}
if (dto.journeyType === "ROUND_TRIP") {
const [returnDirect, returnTransit] = await Promise.all([
this.searchSchedules(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
this.searchTransitOptions(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
]);
const allReturn = [...returnDirect, ...returnTransit];
const latestOutboundArrival =
outbound.length > 0
? Math.max(
...outbound.map((s: any) =>
new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime(),
),
)
: Date.now();
const inbound = allReturn.filter(
(s: any) =>
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() >
latestOutboundArrival,
);
const returnDate = dto.returnDate ?? dto.date;
if (outbound.length === 0 || inbound.length === 0) {
const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
outbound.length === 0
? this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
)
: Promise.resolve([]),
inbound.length === 0
? this.searchAlternatives(
dto.destinationStationId,
dto.originStationId,
returnDate,
dto.adultCount,
dto.childCount,
dto.nationality,
)
: Promise.resolve([]),
outbound.length === 0
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
: Promise.resolve(undefined),
inbound.length === 0
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
: Promise.resolve(undefined),
]);
return {
journeyType: "ROUND_TRIP",
outbound,
inbound,
alternativeOutbound,
alternativeInbound,
requestedDate: dto.date,
requestedReturnDate: returnDate,
outboundReason,
inboundReason,
};
}
return {
journeyType: "ROUND_TRIP",
outbound,
inbound,
requestedDate: dto.date,
requestedReturnDate: returnDate,
};
}
return { journeyType: "ONE_WAY", outbound };
}
private async searchAlternatives(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const requestedDate = new Date(`${dateStr}T00:00:00+03:00`);
const requestedNextDay = new Date(requestedDate.getTime() + 24 * 60 * 60 * 1000);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
// 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: {} },
};
// 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.
const FETCH_LIMIT = NEEDED * 5;
const [beforeCandidates, afterCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
...baseWhere,
departureAt: {
gte: now < requestedDate ? now : new Date(0),
lt: requestedDate,
},
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: "desc" },
take: FETCH_LIMIT,
}),
this.prisma.trainSchedule.findMany({
where: {
...baseWhere,
departureAt: { gte: requestedNextDay > now ? requestedNextDay : now },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: "asc" },
take: FETCH_LIMIT,
}),
]);
const pickN = async (
candidates: typeof beforeCandidates,
limit: number,
) => {
const out: NonNullable<
Awaited<ReturnType<typeof this.buildScheduleResult>>
>[] = [];
for (const schedule of candidates) {
if (out.length >= limit) break;
const r = await this.buildScheduleResult(
schedule as any,
originStationId,
destinationStationId,
totalPassengers,
nationality,
);
if (r?.hasAvailability) out.push(r);
}
return out;
};
const [before, after] = await Promise.all([
pickN(beforeCandidates, NEEDED),
pickN(afterCandidates, NEEDED),
]);
// before was fetched desc (closest first); reverse so result is chronological
return [...before.reverse(), ...after];
}
private async searchSchedules(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const date = new Date(`${dateStr}T00:00:00+03:00`);
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
const totalPassengers = adultCount + (childCount ?? 0);
// 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: {
// 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: date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
});
const results = await Promise.all(
schedules.map((schedule) =>
this.buildScheduleResult(
schedule as any,
originStationId,
destinationStationId,
totalPassengers,
nationality,
),
),
);
return results.filter(
(r): r is NonNullable<typeof r> => !!r && r.hasAvailability,
);
}
/**
* Only called when searchSchedules/searchTransitOptions found zero bookable results for a
* leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
* everything. Priority order (most specific/actionable first): a station pair EDR never
* connects at all beats "nothing on this exact date", which beats "something exists but
* every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
*/
private async classifyEmptySearch(
originStationId: string,
destinationStationId: string,
dateStr: string,
): Promise<Passenger.ISearchEmptyReason> {
const [origin, destination] = await Promise.all([
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
]);
const originStationName = origin?.name ?? "the origin station";
const destinationStationName = destination?.name ?? "the destination station";
const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
code,
originStationName,
destinationStationName,
});
// 1. Does any active route connect these two stations, in this direction, at all —
// ignoring date entirely?
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
}
// 2. A route exists — is there any schedule at all on the requested date for this pair
// (regardless of status/package/coach/cutoff — those are checked next)?
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const date = new Date(`${dateStr}T00:00:00+03:00`);
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
const dayCandidates = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
select: {
status: true,
isPackageOnly: true,
departureAt: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
coachAssignments: { select: { id: true } },
},
});
const sameDayForPair = dayCandidates.filter((s) => {
const o = s.stopTimes.find((st) => st.stationId === originStationId);
const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
return !!o && !!dst && o.sequence < dst.sequence;
});
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
// (right status, not package-only, has at least one coach assigned).
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
if (bookable.length === 0) {
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
if (sameDayForPair.every((s) => s.isPackageOnly))
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
}
// 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
// for this origin stop?
const allCutoffPassed = bookable.every((s) => {
const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
});
if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);
// 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
// searchSchedules dropped it is zero/insufficient seat availability.
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
}
/**
* Whether any active route connects originStationId → destinationStationId in this
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
* getAvailableDates.
*/
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
const candidateRoutes = await this.prisma.route.findMany({
where: { active: true, stops: { some: { stationId: originStationId } } },
select: { stops: { select: { stationId: true, sequence: true } } },
});
return candidateRoutes.some((r) => {
const o = r.stops.find((s) => s.stationId === originStationId);
const d = r.stops.find((s) => s.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence;
});
}
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
return (
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.coachAssignments.length > 0
);
}
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
private toAddisDateStr(d: Date): string {
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
}
/**
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
* for originStationId → destinationStationId — used to disable schedule-less dates on the
* search date picker before the user submits a search. Reuses the same route-existence and
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
* — a date can be marked available and still turn out fully booked when actually searched.
*/
async getAvailableDates(dto: AvailableDatesQueryDto) {
const { originStationId, destinationStationId } = dto;
const todayStr = this.toAddisDateStr(new Date());
const from = dto.from > todayStr ? dto.from : todayStr;
const fromDate = new Date(`${from}T00:00:00+03:00`);
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
const to = this.toAddisDateStr(toDate);
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
return {
originStationId,
destinationStationId,
from,
to,
routeExists: false,
dates: [] as { date: string; available: boolean }[],
};
}
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
departureAt: { gte: fromDate, lt: rangeEnd },
stopTimes: { some: { stationId: originStationId } },
},
select: {
departureAt: true,
status: true,
isPackageOnly: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
coachAssignments: { select: { id: true } },
},
});
const now = Date.now();
const availableDays = new Set<string>();
for (const s of schedules) {
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
if (!this.isBookableSchedule(s)) continue;
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
availableDays.add(this.toAddisDateStr(s.departureAt));
}
const dates: { date: string; available: boolean }[] = [];
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
const dateStr = this.toAddisDateStr(cursor);
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
}
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
}
// ── Transit search ─────────────────────────────────────────────────────────
private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360;
private async searchTransitOptions(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const dayStart = new Date(`${dateStr}T00:00:00+03:00`);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
const leg2WindowEnd = new Date(
dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000,
);
const totalPassengers = adultCount + (childCount ?? 0);
// Load leg1 and all potential leg2 candidates in one parallel round-trip
// instead of firing a separate DB query per transit stop.
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
// 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 } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
this.prisma.trainSchedule.findMany({
where: {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
]);
const results: any[] = [];
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find(
(s) => s.stationId === originStationId,
);
if (!originStop) continue;
const candidateTransitStops = leg1.stopTimes.filter(
(s) => s.sequence > originStop.sequence,
);
for (const transitStop of candidateTransitStops) {
const leg1HasDest = leg1.stopTimes.some(
(s) => s.stationId === destinationStationId,
);
if (leg1HasDest) continue;
const transitStationId = transitStop.stationId;
const leg1ArrivalAt =
transitStop.plannedArrivalAt ??
transitStop.plannedDepartureAt ??
leg1.arrivalAt;
const connWindowStart = new Date(
new Date(leg1ArrivalAt).getTime() +
this.MIN_CONNECTION_MINUTES * 60_000,
);
const connWindowEnd = new Date(
new Date(leg1ArrivalAt).getTime() +
this.MAX_CONNECTION_MINUTES * 60_000,
);
// Filter from pre-loaded candidates in memory — no extra DB query
const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(
(s) => {
const dep = new Date(s.departureAt).getTime();
return (
dep >= connWindowStart.getTime() &&
dep <= connWindowEnd.getTime() &&
s.stopTimes.some((st) => st.stationId === transitStationId)
);
},
);
for (const leg2 of leg2Schedules) {
const leg2TransitStop = leg2.stopTimes.find(
(s) => s.stationId === transitStationId,
);
const leg2DestStop = leg2.stopTimes.find(
(s) => s.stationId === destinationStationId,
);
if (!leg2TransitStop || !leg2DestStop) continue;
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
const [leg1Result, leg2Result] = await Promise.all([
this.buildScheduleResult(
leg1,
originStationId,
transitStationId,
totalPassengers,
nationality,
),
this.buildScheduleResult(
leg2,
transitStationId,
destinationStationId,
totalPassengers,
nationality,
),
]);
if (!leg1Result || !leg2Result) continue;
if (!leg1Result.hasAvailability || !leg2Result.hasAvailability)
continue;
const leg2DepartureAt =
leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
const connectionMinutes = Math.round(
(new Date(leg2DepartureAt).getTime() -
new Date(leg1ArrivalAt).getTime()) /
60_000,
);
const leg1MinFare = Math.min(
...(leg1Result.faresByClass as any[])
.map((f: any) => f.baseFareMinor)
.filter((n: number) => n > 0),
Infinity,
);
const leg2MinFare = Math.min(
...(leg2Result.faresByClass as any[])
.map((f: any) => f.baseFareMinor)
.filter((n: number) => n > 0),
Infinity,
);
const leg1MinDisplay = Math.min(
...(leg1Result.faresByClass as any[])
.map((f: any) => f.displayAmountMinor)
.filter((n: number) => n > 0),
Infinity,
);
const leg2MinDisplay = Math.min(
...(leg2Result.faresByClass as any[])
.map((f: any) => f.displayAmountMinor)
.filter((n: number) => n > 0),
Infinity,
);
const combinedMinFareMinor =
(isFinite(leg1MinFare) ? leg1MinFare : 0) +
(isFinite(leg2MinFare) ? leg2MinFare : 0);
const combinedMinFareDisplay =
(isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) +
(isFinite(leg2MinDisplay) ? leg2MinDisplay : 0);
const displayCurrency =
leg1Result.displayCurrency ??
leg2Result.displayCurrency ??
Currency.ETB;
results.push({
type: "TRANSIT",
transitStationId,
transitStationName: transitStop.station.name,
connectionMinutes,
leg1: leg1Result,
leg2: leg2Result,
displayCurrency,
combinedMinFareMinor,
combinedMinFareDisplay,
departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt,
totalDurationMinutes:
leg1Result.durationMinutes +
connectionMinutes +
leg2Result.durationMinutes,
});
}
}
}
return results;
}
private async buildScheduleResult(
schedule: ScheduleWithIncludes,
originStationId: string,
destinationStationId: string,
totalPassengers: number,
nationality?: string,
) {
const originStop = schedule.stopTimes.find(
(s) => s.stationId === originStationId,
);
const destStop = schedule.stopTimes.find(
(s) => s.stationId === destinationStationId,
);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence)
return null;
// Segment-level cutoff: use the origin stop's 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. 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
const allValidSeatIds = schedule.coachAssignments.flatMap((a) =>
a.coach.seats
.filter((s: any) => s.status !== "BLOCKED" && s.seatNumber?.trim())
.map((s: any) => s.id as string),
);
// Exclude schedules with no seats at all
if (allValidSeatIds.length === 0) return null;
// Run availability batch, fare calculation, and schedule-scoped seat blocks in parallel
const [freeSeatsRaw, faresByClass, scheduleBlocks] = await Promise.all([
this.segmentsService.getFreeSeatIds(
schedule.id,
allValidSeatIds,
schedule.stopTimes,
originStop.sequence,
destStop.sequence,
),
this.calculateFaresForSegment(
schedule,
originStationId,
destinationStationId,
nationality,
),
this.prisma.seatBlock.findMany({
where: {
scheduleId: schedule.id,
seatId: { in: allValidSeatIds },
},
select: { seatId: true },
}),
]);
const scheduleBlockedIds = new Set(
scheduleBlocks.map((b: any) => b.seatId),
);
// Seats that are free from holds/bookings AND not schedule-blocked
const freeSeats = new Set(
[...freeSeatsRaw].filter((id) => !scheduleBlockedIds.has(id)),
);
// Compute per-class availability using the pre-computed free seat set. A coach type
// has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND
// "VIP Bed Upper (Intl)" on the same coach) — filter to the searching passenger's own
// nationality first, otherwise a name-based `.find()` across both tiers would credit
// all availability to whichever tier happens to come first in the query result,
// leaving the other tier's class permanently at 0 ("Fully booked") even when seats
// are actually free. Matched via the class's own bedPosition field (case-insensitive:
// Seat.bedPosition is lowercase, SeatClass.bedPosition is uppercase) rather than a
// name substring, since that's an exact, unambiguous signal.
const nationalityUpper = (nationality ?? "").toUpperCase();
const resolvedNationalityType =
nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN"
? "LOCAL"
: "INTERNATIONAL";
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const seatClasses = (
assignment.coach.coachType?.seatClasses ?? []
).filter(
(sc: any) =>
!sc.nationalityType || sc.nationalityType === resolvedNationalityType,
);
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
if (isBedCoach) {
for (const bedPosition of ["upper", "middle", "lower"]) {
let count = 0;
for (const seat of assignment.coach.seats) {
if (
seat.bedPosition !== bedPosition ||
seat.status === "BLOCKED" ||
!seat.seatNumber?.trim()
)
continue;
if (freeSeats.has(seat.id)) count++;
}
if (count > 0) {
const matchingClass = seatClasses.find(
(sc: any) => sc.bedPosition?.toLowerCase() === bedPosition,
);
if (matchingClass)
availabilityByClass[matchingClass.name] =
(availabilityByClass[matchingClass.name] ?? 0) + count;
}
}
} else {
let available = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === "BLOCKED" || !seat.seatNumber?.trim()) continue;
if (freeSeats.has(seat.id)) available++;
}
const names =
seatClasses.length > 0
? seatClasses.map((sc: any) => sc.name)
: ["Standard"];
for (const name of names)
availabilityByClass[name] =
(availabilityByClass[name] ?? 0) + available;
}
}
const coachTypes = this.buildCoachTypeDetails(
schedule,
faresByClass,
nationality,
availabilityByClass,
);
// 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 ??
resolveCurrencyFromNationality(nationality);
return {
type: "DIRECT",
scheduleId: schedule.id,
trainNumber: schedule.train.number,
trainName: schedule.train.name,
origin: {
id: originStop.stationId,
code: originStop.station.code,
name: originStop.station.name,
city: originStop.station.city,
sequence: originStop.sequence,
},
destination: {
id: destStop.stationId,
code: destStop.station.code,
name: destStop.station.name,
city: destStop.station.city,
sequence: destStop.sequence,
},
departureAt: legDepartureAt,
arrivalAt: legArrivalAt,
durationMinutes: Math.round(
(new Date(legArrivalAt).getTime() -
new Date(legDepartureAt).getTime()) /
60_000,
),
status: schedule.status,
stops: schedule.stopTimes
.filter(
(st) =>
st.sequence >= originStop.sequence &&
st.sequence <= destStop.sequence,
)
.map((st) => ({
stationId: st.stationId,
stationName: st.station.name,
sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt,
plannedDepartureAt: st.plannedDepartureAt,
})),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(
(n) => n >= totalPassengers,
),
displayCurrency,
faresByClass,
coachTypes,
};
}
async getFareQuote(dto: FareQuoteDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
},
});
if (!schedule) throw new NotFoundException("Schedule not found");
if (!schedule.routeId)
throw new NotFoundException(
"Schedule has no route configured for fare calculation",
);
const originStop = schedule.stopTimes.find(
(s: any) => s.stationId === dto.originStationId,
);
const destStop = schedule.stopTimes.find(
(s: any) => s.stationId === dto.destinationStationId,
);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
throw new NotFoundException(
"Origin or destination not found on this schedule",
);
}
const seatClass = await this.prisma.seatClass.findFirst({
where: { name: dto.seatClassName },
});
if (!seatClass)
throw new NotFoundException(
`Seat class '${dto.seatClassName}' not found`,
);
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: seatClass.id,
nationality: dto.nationality,
scheduleId: dto.scheduleId,
adultCount: dto.adultCount,
childCount: dto.childCount ?? 0,
promoCode: dto.promoCode,
});
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const displayCurrency =
dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor =
displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(
totalMinor,
Currency.ETB,
displayCurrency,
)
: totalMinor;
return {
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
segmentRoute,
seatClassName: dto.seatClassName,
nationality: dto.nationality,
adultCount: fare.adultCount,
childCount: fare.childCount,
baseFareMinor: fare.baseFarePerPassengerMinor,
adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
freeChildrenCount: fare.freeChildrenCount,
paidChildrenCount: fare.paidChildrenCount,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
totalBaseFareMinor: fare.subtotalMinor,
discountMinor: fare.discountMinor,
taxesFeesMinor: 0,
loyaltyRedemptionMinor: loyaltyMinor,
totalMinor: displayTotalMinor,
currency: displayCurrency,
displayCurrency,
displayTotalMinor,
};
}
async getFareBreakdown(dto: FareBreakdownRequestDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: {
routeId: true,
originStationId: true,
destinationStationId: true,
},
});
if (!schedule) throw new NotFoundException("Schedule not found");
if (!schedule.routeId)
throw new NotFoundException(
"Schedule has no route configured for fare calculation",
);
const now = new Date();
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
let parsedPassengers: FareBreakdownPassengerDto[];
try {
parsedPassengers = JSON.parse(dto.passengers as unknown as string);
} catch {
throw new NotFoundException("passengers must be a valid JSON array");
}
// Categorise passengers by age
const categorised = parsedPassengers.map((p) => {
const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
return {
...p,
category: (ageYears >= 5 ? "ADULT" : "CHILD") as "ADULT" | "CHILD",
ageYears,
};
});
const adultCount = categorised.filter((p) => p.category === "ADULT").length;
const childCount = categorised.filter((p) => p.category === "CHILD").length;
// Ask the fare engine for the authoritative free-child count using the full group
// Use the first passenger's seatClassId as a representative — freeChildrenCount
// depends only on adultCount/childCount, not on seat class.
const groupFare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: categorised[0].seatClassId,
nationality: categorised[0].nationality,
scheduleId: dto.scheduleId,
adultCount,
childCount,
});
const freeChildrenAllowed = groupFare.freeChildrenCount;
// Pre-compute isFree per passenger index synchronously so the race-free
// counter assignment isn't corrupted by concurrent Promise.all resolution.
let freeChildrenUsed = 0;
const isFreeByIndex = categorised.map((p) => {
if (p.category === "CHILD" && freeChildrenUsed < freeChildrenAllowed) {
freeChildrenUsed++;
return true;
}
return false;
});
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
const passengerLines = await Promise.all(
categorised.map(async (p, idx) => {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: p.seatClassId,
nationality: p.nationality,
scheduleId: dto.scheduleId,
adultCount: 1,
childCount: 0,
});
const isFree = isFreeByIndex[idx];
const fareMinor = isFree
? fare.premiumPerPassenger + fare.insurancePerPassenger
: fare.farePerPassengerMinor;
const displayFareMinor =
displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(
fareMinor,
Currency.ETB,
displayCurrency,
)
: fareMinor;
return {
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
category: p.category,
ageYears: Math.floor(p.ageYears),
seatClassId: fare.seatClassId,
seatClassName: fare.seatClassName,
nationality: p.nationality ?? null,
baseFareMinor: fare.baseFarePerPassengerMinor,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
fareMinor,
isFree,
displayCurrency,
displayFareMinor,
};
}),
);
let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({
where: { code: dto.promoCode },
});
if (promo?.active && promo.validUntil > now) {
discountMinor = promo.percentOff
? Math.round((subtotalMinor * promo.percentOff) / 100)
: (promo.amountOffMinor ?? 0);
}
}
const totalMinor = subtotalMinor - discountMinor;
const displayTotalMinor =
displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(
totalMinor,
Currency.ETB,
displayCurrency,
)
: totalMinor;
return {
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: passengerLines,
subtotalMinor,
discountMinor,
totalMinor: displayTotalMinor,
currency: displayCurrency,
displayCurrency,
displayTotalMinor,
};
}
private async calculateFaresForSegment(
schedule: ScheduleWithIncludes,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<
Array<{
seatClassName: string;
baseFareMinor: number;
displayCurrency: Currency;
displayAmountMinor: number;
}>
> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const nationalityUpper = (nationality ?? "").toUpperCase();
const nationalityType =
nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN"
? "LOCAL"
: "INTERNATIONAL";
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
for (const a of schedule.coachAssignments) {
for (const sc of a.coach.coachType?.seatClasses ?? []) {
if (sc.isActive) seatClassIdSet.add(sc.id);
}
}
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: {
id: { in: Array.from(seatClassIdSet) },
isActive: true,
OR: [{ nationalityType: null }, { nationalityType: nationalityType }],
},
});
const seatClassMap = new Map(freshSeatClasses.map((sc) => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort(
(a, b) => a.baseFareMinor - b.baseFareMinor,
);
if (seatClasses.length === 0) return [];
if (schedule.routeId) {
const results = await Promise.all(
seatClasses.map(async (sc) => {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId,
destinationStationId,
seatClassId: sc.id,
nationality,
scheduleId: schedule.id,
});
return {
// Use the input seat class name (sc.name) so it always matches what
// buildCoachTypeDetails looks up via coachType.seatClasses. The fare
// engine may resolve a nationality-specific variant (nationalitySeatClass)
// whose name differs from sc.name, which would cause the class to be
// silently dropped from coachTypes and show N/A on the results page.
seatClassName: sc.name,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: fare.totalInBillingCurrency,
};
} catch {
return null;
}
}),
);
const validResults = results.filter(
(
r,
): r is {
seatClassName: string;
baseFareMinor: number;
displayCurrency: Currency;
displayAmountMinor: number;
} => r !== null,
);
if (validResults.length > 0) return validResults;
}
// Fallback: use station codes from already-loaded stopTimes when available
const originStop = schedule.stopTimes.find(
(st) => st.stationId === originStationId,
);
const destStop = schedule.stopTimes.find(
(st) => st.stationId === destinationStationId,
);
const originCode = originStop?.station?.code;
const destCode = destStop?.station?.code;
if (originCode && destCode) {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
if (fareRules.length > 0) {
const exchangeRate = await this.currencyService.getExchangeRate(
Currency.ETB,
displayCurrency,
);
const TAX_RATE = 0.05;
return fareRules.map((rule) => {
const totalMinor =
rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
return {
seatClassName:
seatClassMap.get(rule.seatClassId)?.name ?? "Unknown",
baseFareMinor: totalMinor,
displayCurrency,
displayAmountMinor: Math.round(totalMinor * exchangeRate),
};
});
}
}
return [];
}
// buildCoachTypeDetails is pure in-memory — no async needed
private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{
seatClassName: string;
baseFareMinor: number;
displayCurrency: Currency;
displayAmountMinor: number;
}>,
nationality?: string,
availabilityByClass: Record<string, number> = {},
): Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: Array<{
name: string;
baseFareMinor: number;
displayCurrency: Currency;
displayAmountMinor: number;
available: number;
}>;
}> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string>; coachId: string }
>();
for (const assignment of schedule.coachAssignments) {
const coachType = assignment.coach.coachType;
if (!coachType) continue;
if (!coachTypeMap.has(coachType.id)) {
coachTypeMap.set(coachType.id, {
coachType,
classNames: new Set(),
coachId: assignment.coach.id,
});
}
const nationalityUpper = (nationality ?? "").toUpperCase();
const resolvedNationalityType =
nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN"
? "LOCAL"
: "INTERNATIONAL";
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => {
// Exclude classes that belong to the wrong nationality type
if (
sc.nationalityType &&
sc.nationalityType !== resolvedNationalityType
)
return;
if (faresByClass.some((f) => f.seatClassName === sc.name))
entry.classNames.add(sc.name);
});
}
const result = [];
for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find(
(f) => f.seatClassName === className,
);
if (!fareInfo) return null;
return {
name: className,
baseFareMinor: fareInfo.baseFareMinor,
displayCurrency: fareInfo.displayCurrency,
displayAmountMinor: fareInfo.displayAmountMinor,
available: availabilityByClass[className] ?? 0,
};
})
.filter(
(
c,
): c is {
name: string;
baseFareMinor: number;
displayCurrency: Currency;
displayAmountMinor: number;
available: number;
} => c !== null,
)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (classes.length === 0) continue;
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,
coachTypeCode: coachType.code,
coachId,
classes,
});
}
return result.sort((a, b) => {
const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
return minPriceA - minPriceB;
});
}
}