import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto, } 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 { Currency } from "@prisma/client"; 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 = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ); return { journeyType: "ONE_WAY", outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, }; } 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] = 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([]), ]); return { journeyType: "ROUND_TRIP", outbound, inbound, alternativeOutbound, alternativeInbound, requestedDate: dto.date, requestedReturnDate: returnDate, }; } 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, ) { const [y, m, d] = dateStr.split("-").map(Number); const requestedDate = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`, ); const requestedNextDay = 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); const NEEDED = 3; const baseWhere = { status: "SCHEDULED", 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. 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> >[] = []; 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, ) { const [y, m, d] = dateStr.split("-").map(Number); const date = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`, ); 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; const schedules = await this.prisma.trainSchedule.findMany({ where: { status: "SCHEDULED", isPackageOnly: false, departureAt: { gte: earliest, 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 => !!r && r.hasAvailability, ); } // ── 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, ) { const [y, m, d] = dateStr.split("-").map(Number); const dayStart = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`, ); const dayEnd = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, ); 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: { status: "SCHEDULED", isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), this.prisma.trainSchedule.findMany({ where: { status: "SCHEDULED", 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 planned departure, not the // schedule's overall departureAt (which is station A's time). This lets // B→D remain bookable even after A→D closes. // Cutoff resolution: stop-level override → route default → 30 min fallback. const now = new Date(); const segmentDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const routeStop = schedule.route?.stops?.find( (s) => s.stationId === originStationId, ); const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; if ( segmentDepartureAt.getTime() - now.getTime() <= checkinMinutes * 60 * 1000 ) return null; // Collect all valid seat IDs upfront for a single batch availability check const allValidSeatIds = schedule.coachAssignments.flatMap((a) => a.coach.seats .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 = {}; 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, ); const legDepartureAt = schedule.departureAt; const legArrivalAt = 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(); 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 = {}, ): 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; 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; }); } }