import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; 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'; /** Booking cutoff: reject new bookings within this many ms of departure. */ const BOOKING_CUTOFF_MS = 30 * 60 * 1000; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } // Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx) const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964']; function generateEthiopianPhone(): string { const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)]; const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0'); return `+251${prefix}${suffix}`; } function generateGuestEmail(uniqueId: string): string { const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et']; const domain = domains[Math.floor(Math.random() * domains.length)]; return `guest.edr.${uniqueId}@${domain}`; } function calculateAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); const monthDiff = today.getMonth() - dateOfBirth.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--; return age; } @Injectable() export class GuestBookingService { constructor( private prisma: PrismaService, private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, ) {} async createGuestBooking(dto: CreateGuestBookingDto, req?: any) { // Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline. // The portal calls /passengers/save-details before booking but doesn't re-send contact // fields in the booking payload, so we pull them from the saved profile by deviceId. if (dto.deviceId && dto.passengers?.length) { const saved = await this.prisma.savedPassengerProfile.findMany({ where: { deviceId: dto.deviceId }, orderBy: { createdAt: 'desc' }, select: { passengerName: true, phone: true, email: true }, }); if (saved.length) { dto.passengers = dto.passengers.map(p => { if (p.phone && p.email) return p; const match = saved.find(s => s.passengerName?.toLowerCase() === p.passengerName?.toLowerCase()); return { ...p, phone: p.phone || match?.phone || undefined, email: p.email || match?.email || undefined }; }); } } if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req); if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req); return this.createGuestOneWayBooking(dto, req); } private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) { // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { throw new BadRequestException('Seat hold expired or not found'); } // Get schedule 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 (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'); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; // Process passengers with Verifayda verification const passengersData = []; let adultCount = 0, childCount = 0; for (const passenger of dto.passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; let nationality = passenger.nationality; const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (passenger.idDocumentNumber) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) { throw new BadRequestException( `Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}` ); } passengerName = verification.passengerData?.fullName || passengerName; verifaydaVerified = true; verifaydaData = verification.passengerData?.profileData; } nationality = 'Ethiopian'; } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) { throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); } nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { nationality = 'Ethiopian'; } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { nationality = nationality || 'Other'; } passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, }); } // Calculate fare — package bookings use the fixed tier price, bypassing the fare engine const isPackageOneway = !!dto.packageId && !!dto.priceTierId; let baseFareMinor: number; let paidChildrenCount: number; let childUnitFare: number; if (isPackageOneway) { const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } }); baseFareMinor = tier.priceMinor; paidChildrenCount = childCount; childUnitFare = Math.round(baseFareMinor * 0.1); } else { const primaryNationality = passengersData[0]?.nationality; baseFareMinor = await this.getBaseFare( dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, dto.originStationId, dto.destinationStationId, ); paidChildrenCount = Math.max(0, childCount - 1); childUnitFare = baseFareMinor; } const adultFareMinor = baseFareMinor * adultCount; const childFareMinor = childUnitFare * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const taxesMinor = 0; // Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing). // Free children (first child, non-package) get fareMinor=0. let freeChildUsed = false; let pkgChildIdx = 0; const passengersWithFares = passengersData.map(p => { let fareMinor: number; if (p.category === PassengerCategory.ADULT) { fareMinor = p.seatFareMinor ?? baseFareMinor; } else if (isPackageOneway) { fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare); pkgChildIdx++; } else { if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } else fareMinor = p.seatFareMinor ?? childUnitFare; } return { ...p, fareMinor }; }); // Use reviewedTotalMinor from frontend as authoritative total when provided. // Fall back to per-seat sum when all seated passengers supplied seatFareMinor. const seatedPassengers = passengersData.filter(p => p.seatId); const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); const resolvedTotalMinor = dto.reviewedTotalMinor ?? (allFaresProvided ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0) : Math.max(0, totalBaseFareMinor - discountMinor)); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = resolvedTotalMinor; if (displayCurrency !== Currency.ETB) { displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency); } // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { for (const passenger of passengersData) { await this.prisma.savedPassengerProfile.create({ data: { userId: iamUserId ?? undefined, deviceId: dto.deviceId, passengerName: passenger.passengerName, dateOfBirth: passenger.dateOfBirth, idDocumentType: passenger.idDocumentType, passportNumber: passenger.passportNumber, passportCountry: passenger.passportCountry, nationality: passenger.nationality, phone: passenger.phone, email: passenger.email, }, }); } } // Create booking 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: resolvedTotalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, contactEmail: firstPassenger.email || null, contactPhone: firstPassenger.phone || null, seats: { create: passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, fareMinor: p.fareMinor, displayCurrency, })), }, }, include: { seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); // Save passenger details as traveler profiles await this.createTravelerProfiles(guestPassengerId, passengersData); // Confirm seats await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)); this.eventEmitter.emit('booking.created', { booking }); return { ...booking, createdAccount, iamUserId, fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, taxesFeesMinor: taxesMinor, totalMinor: resolvedTotalMinor, currency: booking.displayCurrency, displayCurrency, displayTotalMinor, }, }; } 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'); } // Validate both holds const [outboundHold, returnHold] = await Promise.all([ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), ]); if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found'); if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found'); // Validate passengers have returnSeatId for (const p of dto.passengers) { if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`); } // Load both schedules 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' } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, }), ]); 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 }; }; const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; 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'); const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`; // Process passengers (verify identity once — same person travels both legs) const passengersData: any[] = []; let adultCount = 0, childCount = 0; for (const passenger of dto.passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; let nationality = passenger.nationality; const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (passenger.idDocumentNumber) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); passengerName = verification.passengerData?.fullName || passengerName; verifaydaVerified = true; verifaydaData = verification.passengerData?.profileData; } nationality = 'Ethiopian'; } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { nationality = 'Ethiopian'; } else { nationality = nationality || 'Other'; } passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } // Calculate fares for both legs — package bookings use the fixed tier price split across legs const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId; let outboundBaseFare: number; let returnBaseFare: number; let paidChildrenCount: number; let outboundChildUnitFare: number; let returnChildUnitFare: number; if (isPackageRoundTrip) { const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } }); // tier.priceMinor is the full round-trip price per adult; split evenly across legs const halfMinor = Math.round(tier.priceMinor / 2); outboundBaseFare = halfMinor; returnBaseFare = tier.priceMinor - halfMinor; paidChildrenCount = childCount; outboundChildUnitFare = Math.round(outboundBaseFare * 0.1); returnChildUnitFare = Math.round(returnBaseFare * 0.1); } else { const primaryNationality = passengersData[0]?.nationality; [outboundBaseFare, returnBaseFare] = await Promise.all([ this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), ]); paidChildrenCount = Math.max(0, childCount - 1); outboundChildUnitFare = outboundBaseFare; returnChildUnitFare = returnBaseFare; } const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount; const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount; const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const taxesMinor = 0; let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; // Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present. let outboundFreeChildUsed = false; let returnFreeChildUsed = false; const passengersWithFares = passengersData.map(p => { let outboundFareMinor: number; let returnFareMinor: number; if (p.category === PassengerCategory.ADULT) { outboundFareMinor = p.seatFareMinor ?? outboundBaseFare; returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare; } else if (isPackageRoundTrip) { outboundFareMinor = 0; returnFareMinor = 0; } else { if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare; if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare; } return { ...p, outboundFareMinor, returnFareMinor }; }); // Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares // when all seated passengers supplied their fares. const rtSeatedPassengers = passengersData.filter(p => p.seatId); const allRTFaresProvided = rtSeatedPassengers.length > 0 && rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null); if (dto.reviewedTotalMinor) { totalMinor = dto.reviewedTotalMinor; displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; } else if (allRTFaresProvided && !isPackageRoundTrip) { totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; } // Create or resolve guest passenger (same as one-way) const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Create booking with outbound seats; return seats confirmed separately const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id); const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: guestPassengerId, scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, returnScheduleId: dto.returnScheduleId, returnOriginStationId: dto.returnOriginStationId, returnDestinationStationId: dto.returnDestinationStationId, returnHoldId: dto.returnHoldId, returnSeatClassId, returnLegStatus: 'NEITHER_USED', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, contactEmail: passengersData[0]?.email || null, contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, leg: 1, scheduleId: dto.scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, fareMinor: p.outboundFareMinor, displayCurrency, })), ...passengersWithFares.map((p) => ({ seat: { connect: { id: p.returnSeatId } }, leg: 2, scheduleId: dto.returnScheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, fareMinor: p.returnFareMinor, displayCurrency, })), ], }, } as any, include: { seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(outboundSeatIds), this.seatsService.confirmSeats(returnSeatIds), ]); this.eventEmitter.emit('booking.created', { booking }); return { ...booking, createdAccount, iamUserId, fareBreakdown: { outboundBaseFareMinor: outboundBaseFare, returnBaseFareMinor: returnBaseFare, adultCount, childCount, freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1), paidChildrenCount, combinedBaseFareMinor, discountMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor, }, }; } private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); } const [leg1Hold, leg2Hold] = await Promise.all([ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), ]); if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found'); if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found'); for (const p of dto.passengers) { if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`); } 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' } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, }), ]); 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); const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); 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'); // Process passengers (verify identity once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; for (const passenger of dto.passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; let nationality = passenger.nationality; const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); passengerName = verification.passengerData?.fullName || passengerName; verifaydaVerified = true; verifaydaData = verification.passengerData?.profileData; nationality = 'Ethiopian'; } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`); nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } else { nationality = nationality || 'Other'; } passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; const primaryNationality = passengersData[0]?.nationality; const paidChildrenCount = Math.max(0, childCount - 1); const [leg1BaseFare, leg2BaseFare] = await Promise.all([ this.getBaseFare(dto.scheduleId, dto.seatClassId, `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, primaryNationality, dto.originStationId, dto.transitStationId), this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, primaryNationality, dto.transitStationId, dto.leg2DestinationStationId), ]); const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount; const combinedBase = leg1Total + leg2Total; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const taxesMinor = 0; const totalMinor = Math.max(0, combinedBase - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: guestPassengerId, scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.leg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: leg2SeatClassId, userAgent: dto.deviceId, contactEmail: passengersData[0]?.email || null, contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => ({ seat: { connect: { id: p.seatId } }, leg: 1, scheduleId: dto.scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0), displayCurrency, })), ...passengersData.map(p => ({ seat: { connect: { id: p.leg2SeatId! } }, leg: 2, scheduleId: dto.leg2ScheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0), displayCurrency, })), ], }, } as any, include: { seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), ]); this.eventEmitter.emit('booking.created', { booking }); return { ...booking, createdAccount, iamUserId, fareBreakdown: { leg1BaseFareMinor: leg1BaseFare, leg2BaseFareMinor: leg2BaseFare, adultCount, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, combinedBaseFareMinor: combinedBase, discountMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: displayCurrency, displayTotalMinor, }, }; } private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { throw new BadRequestException( 'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields', ); } for (const p of dto.passengers) { if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`); if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`); if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`); } const now = new Date(); const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), ]); if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired'); if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired'); if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired'); 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.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' } } } }), ]); if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found'); if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found'); 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); const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId); const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId); if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found'); if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found'); 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'); // Process passengers (verify once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; for (const passenger of dto.passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; let nationality = passenger.nationality; const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`); passengerName = v.passengerData?.fullName || passengerName; verifaydaVerified = true; verifaydaData = v.passengerData?.profileData; nationality = 'Ethiopian'; } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`); nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } else { nationality = nationality || 'Other'; } passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } const nat = passengersData[0]?.nationality; const paidChildren = Math.max(0, childCount - 1); const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId; const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), ]); const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const taxesMinor = Math.round(combinedBase * 0.05); const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ seat: { connect: { id: seatId } }, leg, scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0), displayCurrency, }); const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: guestPassengerId, scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.returnLeg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: obL2ClassId, returnScheduleId: dto.returnScheduleId, returnOriginStationId: dto.returnOriginStationId, returnDestinationStationId: dto.returnDestinationStationId, returnSeatClassId: retL1ClassId, returnLeg2ScheduleId: dto.returnLeg2ScheduleId, returnLeg2OriginStationId: dto.returnTransitStationId, returnLeg2DestStationId: dto.returnLeg2DestinationStationId, returnLeg2SeatClassId: retL2ClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, contactEmail: passengersData[0]?.email || null, contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)), ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)), ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)), ], }, } as any, include: { seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)), this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)), ]); this.eventEmitter.emit('booking.created', { booking }); return { ...booking, createdAccount, iamUserId, fareBreakdown: { outboundLeg1FareMinor: obL1Fare, outboundLeg2FareMinor: obL2Fare, returnLeg1FareMinor: retL1Fare, returnLeg2FareMinor: retL2Fare, adultCount, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount: paidChildren, combinedBaseFareMinor: combinedBase, discountMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor, }, }; } private async resolveGuestPassenger( dto: Pick, firstPassenger: any, req?: any, ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { if (dto.createAccount && firstPassenger.email && dto.password) { const guestName = firstPassenger.passengerName ?? 'Guest'; const result = await this.passengerAuthService.registerWithPassword( { email: firstPassenger.email, username: firstPassenger.email, phoneNumber: firstPassenger.phone || `+251900000000`, name: { en: guestName, am: guestName }, password: dto.password, }, req, ); return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true }; } // Create guest passenger with basic profile const guestPassenger = await this.prisma.passenger.create({ data: {} }); await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false }; } private async createTravelerProfiles(passengerId: string, passengersData: any[]): Promise { for (const passenger of passengersData) { let gender: string | null = null; if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') { gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null; } await this.prisma.travelerProfile.create({ data: { passengerId, fullName: passenger.passengerName, gender, dateOfBirth: passenger.dateOfBirth, nationalId: passenger.idDocumentType === IdDocumentType.NATIONAL_ID ? passenger.idDocumentNumber : null, relationship: 'self', notes: JSON.stringify({ idDocumentType: passenger.idDocumentType, idDocumentNumber: passenger.idDocumentNumber, passportNumber: passenger.passportNumber, passportCountry: passenger.passportCountry, nationality: passenger.nationality, phone: passenger.phone, email: passenger.email, verifaydaVerified: passenger.verifaydaVerified, }), }, }); } } async getSavedPassengers(userId?: string, deviceId?: string): Promise { if (!userId && !deviceId) { throw new BadRequestException('Either userId or deviceId is required'); } const profiles = await this.prisma.savedPassengerProfile.findMany({ where: { OR: [ userId ? { userId } : {}, deviceId ? { deviceId } : {}, ], }, orderBy: { createdAt: 'desc' }, }); return profiles.map((p: any) => ({ passengerName: p.passengerName, dateOfBirth: p.dateOfBirth.toISOString().split('T')[0], idDocumentType: p.idDocumentType, idDocumentNumber: undefined, passportNumber: p.passportNumber || undefined, passportCountry: p.passportCountry || undefined, nationality: p.nationality || undefined, phone: p.phone || undefined, email: p.email || undefined, })); } private async getBaseFare( scheduleId: string, seatClassId: string, segmentRoute?: string, fullRoute?: string, nationality?: string, originStationId?: string, destinationStationId?: string, ): Promise { const now = new Date(); // 1. FareRule table — explicit override rules (same priority logic as the fare engine) const [candidates, seatClass] = await Promise.all([ this.prisma.fareRule.findMany({ where: { seatClassId, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }), this.prisma.seatClass.findUnique({ where: { id: seatClassId }, select: { premiumMinor: true, insuranceFeeMinor: true }, }), ]); const premiumMinor = seatClass?.premiumMinor ?? 0; const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, { tripId: scheduleId, route: segmentRoute, nationality: null }, { tripId: scheduleId, route: fullRoute, nationality }, { tripId: scheduleId, route: fullRoute, nationality: null }, { tripId: scheduleId, route: null, nationality }, { tripId: scheduleId, route: null, nationality: null }, { tripId: null, route: segmentRoute, nationality }, { tripId: null, route: segmentRoute, nationality: null }, { tripId: null, route: fullRoute, nationality }, { tripId: null, route: fullRoute, nationality: null }, { tripId: null, route: null, nationality }, { tripId: null, route: null, nationality: null }, ]; for (const priority of priorities) { const match = candidates.find( (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); // Return base fare + seat-class surcharges so the booking total matches the quoted fare if (match) return match.baseFareMinor + premiumMinor + insuranceMinor; } // 2. FareEngine — distance × rate-per-km from the booking's actual segment stations const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (schedule?.routeId) { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, // Use the booking's boarding/alighting stations so the distance reflects the // passenger's actual segment, not the full schedule route. originStationId: originStationId ?? schedule.originStationId, destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor return fare.farePerPassengerMinor; } catch { // FareEngine throws if distanceKm is missing; fall through to error } } throw new BadRequestException( `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`, ); } }