import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } /** * For package round-trip bookings, totalMinor in the DB may have been stored as a * single-leg amount before the server fix. Recompute from the tier price when needed. * tierPriceMinor is the per-leg per-adult price from PackagePackagePriceTier. */ function resolvePackageRoundTripTotal( booking: { totalMinor: number; bookingType: string; packageId?: string | null }, tierPriceMinor: number | null | undefined, adultCount: number, childCount: number, ): number { if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) { return booking.totalMinor; } // First child per adult is free; additional children pay full adult fare const adultFareMinor = tierPriceMinor * 2; const paidChildren = Math.max(0, childCount - adultCount); return adultCount * adultFareMinor + paidChildren * adultFareMinor; } /** * Returns all plausible normalised variants of a raw phone string so that the * DB query matches regardless of how the number was stored (local 09… vs international +251…). * Returns an empty array when the input is clearly invalid (< 7 digits). */ function normalizePhoneVariants(raw: string): string[] { // Strip whitespace, dashes, dots, parentheses — keep digits and a leading + const stripped = raw.replace(/[^\d+]/g, ''); const digits = stripped.replace(/^\+/, ''); if (digits.length < 7) return []; const variants = new Set([stripped]); if (stripped.startsWith('+251') && digits.length === 12) { // +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX variants.add(digits); // 251XXXXXXXXX variants.add('0' + digits.slice(3)); // 09XXXXXXXXX } else if (stripped.startsWith('251') && digits.length === 12) { // 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX variants.add('+' + stripped); // +251XXXXXXXXX variants.add('0' + digits.slice(3)); // 09XXXXXXXXX } else if (stripped.startsWith('0') && digits.length === 10) { // 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +) variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX variants.add('251' + digits.slice(1)); // 251XXXXXXXXX } else if (!stripped.startsWith('+') && digits.length >= 9) { // bare international digits without + variants.add('+' + digits); } return [...variants]; } 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; } interface BookingFilters { search?: string; status?: string; returnLegStatus?: string; bookingType?: string; paymentStatus?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number; } @Injectable() export class BookingsService { private readonly logger = new Logger(BookingsService.name); constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private readonly seatsService: SeatsService, private readonly eventEmitter: EventEmitter2, private readonly verifaydaService: VerifaydaService, private readonly currencyService: CurrencyService, private readonly fareEngine: FareEngineService, private readonly auditService: AuditService, ) {} async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } }); return this.findByPassengerId(passenger.id, filters); } async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = { passengerId }; if (search) { where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, ]; } if (status) { where.status = status; } const [items, total] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where }), ]); return { items: items.map(booking => ({ id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, adultCount: booking.adultCount, childCount: booking.childCount, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, schedule: { train: booking.schedule.train, originStation: booking.schedule.originStation, destinationStation: booking.schedule.destinationStation, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, })), meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, }; } async findByPhone(rawPhone: string, filters: BookingFilters = {}) { const variants = normalizePhoneVariants(rawPhone); if (variants.length === 0) return { items: [], meta: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }; const { status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; // Authenticated-user bookings don't store contactPhone — their phone lives in // iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup // that findAll uses for the search field. const iamRows = await this.dataSource .query<{ id: string }[]>( `SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`, [variants], ) .catch((err: unknown) => { this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); return [] as { id: string }[]; }); const iamPassengerIds = iamRows.length > 0 ? (await this.prisma.passenger.findMany({ where: { iamUserId: { in: iamRows.map(r => r.id) } }, select: { id: true }, })).map(p => p.id) : []; // Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking). // This catches cases where contactPhone was null but the phone was still recorded in the profile. const travelerRows = await this.dataSource .query<{ passengerId: string }[]>( `SELECT DISTINCT passenger_id AS "passengerId" FROM passenger.traveler_profiles WHERE notes IS NOT NULL AND (notes::jsonb->>'phone') = ANY($1::text[])`, [variants], ) .catch((err: unknown) => { this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); return [] as { passengerId: string }[]; }); const travelerPassengerIds = travelerRows.map(r => r.passengerId); // Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile // row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent. const savedProfileRows = await this.dataSource .query<{ deviceId: string }[]>( `SELECT DISTINCT device_id AS "deviceId" FROM passenger.saved_passenger_profiles WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`, [variants], ) .catch((err: unknown) => { this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); return [] as { deviceId: string }[]; }); const guestDeviceIds = savedProfileRows.map(r => r.deviceId); // Merge all passenger IDs from every source const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])]; const where: any = { OR: [ { contactPhone: { in: variants } }, { passenger: { user: { phone: { in: variants } } } }, ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), ...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []), ], }; if (status) where.status = status; // PackageBooking is a separate table with its own contactPhone field — // must be queried independently or guest package bookings are invisible. const pkgWhere: any = { OR: [ { contactPhone: { in: variants } }, ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), ], }; if (status) pkgWhere.status = status; const [items, total, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, seats: { select: { id: true } }, priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where }), this.prisma.packageBooking.findMany({ where: pkgWhere, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true } }, }, }, paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, }, }), this.prisma.packageBooking.count({ where: pkgWhere }), ]); const mappedBookings = items.map(booking => ({ id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, adultCount: booking.adultCount, childCount: booking.childCount, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, schedule: { train: booking.schedule.train, originStation: booking.schedule.originStation, destinationStation: booking.schedule.destinationStation, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, payment: booking.paymentIntent ?? undefined, seatCount: booking.seats.length, })); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, totalMinor: b.totalMinor, currency: b.currency || null, displayCurrency: b.displayCurrency ?? null, displayTotalMinor: b.displayTotalMinor ?? null, adultCount: b.adultCount, childCount: b.childCount, bookingType: 'PACKAGE', returnLegStatus: null, createdAt: b.createdAt, schedule: b.package?.outboundSchedule ? { train: null, originStation: b.package.outboundSchedule.originStation, destinationStation: b.package.outboundSchedule.destinationStation, departureAt: b.package.outboundSchedule.departureAt, arrivalAt: b.package.outboundSchedule.arrivalAt, } : null, payment: b.paymentIntent ?? undefined, seatCount: b.passengerCount, })); const allItems = [...mappedBookings, ...mappedPkg] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, pageSize); return { items: allItems, meta: { page, pageSize, total: total + pkgTotal, totalPages: Math.ceil((total + pkgTotal) / pageSize), }, }; } async findByDeviceId(deviceId: string, filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; // Find passenger linked to this device via iamUserId const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null); const passenger = device?.iamUserId ? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null) : null; const searchConditions = search ? [ { bookingRef: { contains: search, mode: 'insensitive' } }, { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, ] : []; const where: any = { OR: [ { userAgent: deviceId }, ...(passenger ? [{ passengerId: passenger.id }] : []), ], }; if (search) { where.AND = [{ OR: searchConditions }]; } if (status) { where.status = status; } const [items, total] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where }), ]); return { items: items.map(booking => ({ id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, adultCount: booking.adultCount, childCount: booking.childCount, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, schedule: { train: booking.schedule.train, originStation: booking.schedule.originStation, destinationStation: booking.schedule.destinationStation, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, })), meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, }; } async findAll(filters: BookingFilters = {}) { const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const onlyPackages = bookingType === 'PACKAGE'; const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; const where: any = {}; if (search) { const iamRows = await this.dataSource.query<{ id: string }[]>( `SELECT u.id FROM iam.users u WHERE (u.name->>'en') ILIKE $1 OR (u.name->>'am') ILIKE $1 OR u.email ILIKE $1 OR u.phone_number ILIKE $1`, [`%${search}%`], ); const matchedPassengers = iamRows.length > 0 ? await this.prisma.passenger.findMany({ where: { iamUserId: { in: iamRows.map(r => r.id) } }, select: { id: true }, }) : []; where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, { contactEmail: { contains: search, mode: 'insensitive' } }, { contactPhone: { contains: search, mode: 'insensitive' } }, ...(matchedPassengers.length > 0 ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] : []), { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } if (status) where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; if (bookingType && !onlyPackages) where.bookingType = bookingType; if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), }; } if (paymentStatus) { const statusMap: Record = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' }; const mapped = statusMap[paymentStatus] ?? paymentStatus; where.paymentIntent = { is: { status: mapped } }; } const pkgWhere: any = {}; if (search) { pkgWhere.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, { contactEmail: { contains: search, mode: 'insensitive' } }, { contactPhone: { contains: search, mode: 'insensitive' } }, { passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } if (status) pkgWhere.status = status; if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; if (onlyPackages) { // Package bookings live in two places: // 1. PackageBooking table (dedicated package bookings) // 2. Booking table with packageId != null (round-trip bookings linked to a package) const bookingPkgWhere: any = { packageId: { not: null } }; if (status) bookingPkgWhere.status = status; if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; if (search) bookingPkgWhere.OR = where.OR; const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ this.prisma.packageBooking.findMany({ where: pkgWhere, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, seatType: true } }, passengers: true, paymentIntent: true, }, }), this.prisma.packageBooking.count({ where: pkgWhere }), this.prisma.booking.findMany({ where: bookingPkgWhere, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { passenger: { select: { id: true, iamUserId: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where: bookingPkgWhere }), ]); const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, [iamUserIds], ) : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); const mappedRegPkg = regPkgItems.map((booking: any) => { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, returnLegStatus: (booking as any).returnLegStatus ?? null, adultCount: booking.adultCount, childCount: booking.childCount, createdAt: booking.createdAt, passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, schedule: booking.schedule ? { train: booking.schedule.train, originStation: booking.schedule.originStation, destinationStation: booking.schedule.destinationStation, departureAt: booking.schedule.departureAt, } : null, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, }; }); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, contactPhone: b.contactPhone, bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code, tierLabel: b.priceTier?.label ?? null, returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, createdAt: b.createdAt, passenger: null, passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount, })); const total = pkgTotal + regPkgTotal; const allItems = [...mappedPkg, ...mappedRegPkg] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, pageSize); return { items: allItems, meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { passenger: { select: { id: true, iamUserId: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } }, }, }), this.prisma.booking.count({ where }), includePackageBookings ? this.prisma.packageBooking.findMany({ where: pkgWhere, orderBy: { createdAt: 'desc' }, include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, seatType: true } }, passengers: true, paymentIntent: true, }, }) : Promise.resolve([] as any[]), includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0), ]); const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, [iamUserIds], ).catch(() => [] as { id: string; email: string; name: any; phone_number: string | null }[]) : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); // For bookings that have no contactEmail/contactPhone and no IAM match, // fall back to TravelerProfile.notes JSON. // Covers: (1) legacy authenticated bookings where IAM returns nothing, // (2) guest bookings where passenger.iamUserId is null. const passengerIdsNeedingFallback = regularItems .filter((b: any) => !b.contactEmail && !b.contactPhone && (!b.passenger?.iamUserId || !iamMap.has(b.passenger.iamUserId))) .map((b: any) => b.passengerId) .filter(Boolean) as string[]; const travelerProfileMap = new Map(); if (passengerIdsNeedingFallback.length > 0) { const profiles = await this.prisma.travelerProfile.findMany({ where: { passengerId: { in: passengerIdsNeedingFallback } }, select: { passengerId: true, notes: true }, orderBy: { createdAt: 'asc' }, }); for (const profile of profiles) { if (travelerProfileMap.has(profile.passengerId)) continue; try { const notes = profile.notes ? (typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes) : null; if (notes?.phone || notes?.email) { travelerProfileMap.set(profile.passengerId, { phone: notes.phone ?? null, email: notes.email ?? null }); } } catch { /* ignore */ } } } const mappedRegular = regularItems.map((booking: any) => { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); // Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback const fallback = booking.passengerId ? travelerProfileMap.get(booking.passengerId) : null; const resolvedEmail = booking.contactEmail ?? iam?.email ?? fallback?.email ?? null; const resolvedPhone = booking.contactPhone ?? iam?.phone_number ?? fallback?.phone ?? null; return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: resolvedEmail, contactPhone: resolvedPhone, bookingType: booking.bookingType, packageId: booking.packageId ?? null, priceTierId: (booking as any).priceTierId ?? null, packageName: (booking as any).package?.name ?? null, packageCode: (booking as any).package?.code ?? null, tierLabel: (booking as any).priceTier?.label ?? null, isPackageBooking: !!booking.packageId, returnLegStatus: (booking as any).returnLegStatus ?? null, adultCount: booking.adultCount, childCount: booking.childCount, createdAt: booking.createdAt, originStationId: (booking as any).originStationId ?? null, passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, schedule: { train: booking.schedule.train, originStation: (booking as any).originStationId ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation) : booking.schedule.originStation, destinationStation: (booking as any).destinationStationId ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation) : booking.schedule.destinationStation, departureAt: booking.schedule.departureAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, }; }); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, contactPhone: b.contactPhone, bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code, tierLabel: b.priceTier?.label ?? null, returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, createdAt: b.createdAt, passenger: null, passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount, })); const total = regularTotal + pkgTotal; const allItems = [...mappedRegular, ...mappedPkg] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, pageSize); return { items: allItems, meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } async create(dto: CreateBookingDto) { // Resolve passengerId from iamUserId when the caller is authenticated if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) { // passengerId is actually an iamUserId — resolve the passenger record const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: dto.passengerId }, select: { id: true } }); if (!passenger) throw new NotFoundException('Passenger profile not found for this account'); dto = { ...dto, passengerId: passenger.id }; } if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto); return this.createOneWayBooking(dto); } private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) { for (const seatId of requestedSeatIds) { if (!holdSeatIds.includes(seatId)) { throw new BadRequestException( `Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`, ); } } } /** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */ private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> { if (!passengerId) return { contactEmail: null, contactPhone: null }; const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, select: { iamUserId: true } }); if (!passenger?.iamUserId) return { contactEmail: null, contactPhone: null }; const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>( `SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`, [passenger.iamUserId], ); return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null }; } private async createOneWayBooking(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId); this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds); 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'); const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); const [passengersData, iamContact] = await Promise.all([ this.processPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); const fareCalculation = dto.packageId && dto.priceTierId ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality); // Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific // pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor. let freeChildUsed = false; let pkgChildIdx = 0; const passengersWithFares = passengersData.map(p => { let fareMinor: number; if (p.category === PassengerCategory.ADULT) { fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor; } else if (dto.packageId) { fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor); pkgChildIdx++; } else { if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor; } return { ...p, fareMinor }; }); // Use the sum of per-seat fares as the authoritative total when the client supplied // seatFareMinor for every seat-holding passenger — this captures berth-specific pricing // (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone. // Free children have no seatId and no seatFareMinor — exclude them from the check. const seatedPassengers = passengersData.filter(p => p.seatId); const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); // seatFareMinor values from the client are in display-currency minor units (matching // displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor. // In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor. let resolvedTotalMinor: number; let displayTotalMinor: number; if (dto.reviewedTotalMinor != null) { displayTotalMinor = dto.reviewedTotalMinor; resolvedTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; } else if (allFaresProvided) { // seatFareMinor is in display currency — sum is already the display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); resolvedTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) : displayTotalMinor; } else { resolvedTotalMinor = fareCalculation.totalMinor; displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency) : resolvedTotalMinor; } this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`); const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: dto.passengerId, scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, contactEmail: iamContact.contactEmail, contactPhone: iamContact.contactPhone, ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), 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, fareMinor: p.fareMinor, displayCurrency })) } }, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } }); await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); if (dto.packageId && dto.priceTierId) { await this.prisma.packagePriceTier.update({ where: { id: dto.priceTierId }, data: { bookedSeats: { increment: passengersData.length } }, }); } this.eventEmitter.emit('booking.created', { booking }); await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } }); return { ...booking, fareBreakdown: fareCalculation }; } private async createRoundTripBooking(dto: CreateBookingDto) { if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { throw new BadRequestException('Return trip details required for round-trip booking'); } 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'); if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired'); const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean); const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean); if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds); if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds); 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 || !returnSchedule) throw new NotFoundException('Schedule not found'); const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) { throw new NotFoundException('Origin or destination stops not found'); } const [passengersData, iamContact] = await Promise.all([ this.processRoundTripPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); // Package bookings use fixed tier price split equally across both legs let outboundFare: Awaited>; let returnFare: Awaited>; let combinedBaseFareMinor: number; let discountMinor = 0; let loyaltyMinor = 0; let totalMinor: number; if (dto.packageId && dto.priceTierId) { const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount); // pkgFare covers one leg; round-trip = both legs combined const roundTripTotal = pkgFare.totalMinor * 2; // Split evenly across both legs for per-seat fare recording const halfMinor = Math.round(pkgFare.baseFareMinor / 2); outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) }; returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) }; combinedBaseFareMinor = pkgFare.totalBaseFareMinor * 2; totalMinor = roundTripTotal; } else { [outboundFare, returnFare] = await Promise.all([ this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) ]); combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; 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); } } loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); } const taxesMinor = 0; const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality); // displayTotalMinor will be overridden below when reviewedTotalMinor is provided. let displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; // Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when // present (berth-specific pricing). Fall back to fare engine values. 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 ?? outboundFare.baseFareMinor; returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor; } else if (dto.packageId) { outboundFareMinor = 0; returnFareMinor = 0; } else { if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor; if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor; } return { ...p, outboundFareMinor, returnFareMinor }; }); // Override totalMinor with the sum of actual per-seat fares when all seated passengers // supplied their fares — free children (no seatId) are excluded from the check. const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId); const allRTFaresProvided = rtSeatedPassengers.length > 0 && rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null); if (dto.reviewedTotalMinor != null) { displayTotalMinor = dto.reviewedTotalMinor; totalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; } else if (allRTFaresProvided && !dto.packageId) { // seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); totalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) : displayTotalMinor; } const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: dto.passengerId, 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: dto.returnSeatClassId, returnLegStatus: 'NEITHER_USED', contactEmail: iamContact.contactEmail, contactPhone: iamContact.contactPhone, ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: [ ...passengersWithFares.map(p => ({ seat: { connect: { id: p.outboundSeatId } }, 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, 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, fareMinor: p.returnFareMinor, displayCurrency, })), ], }, } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } }); const outboundSeatIds = passengersData.map(p => p.outboundSeatId); const returnSeatIds = passengersData.map(p => p.returnSeatId); await Promise.all([ this.seatsService.confirmSeats(outboundSeatIds), this.seatsService.confirmSeats(returnSeatIds) ]); if (dto.packageId && dto.priceTierId) { await this.prisma.packagePriceTier.update({ where: { id: dto.priceTierId }, data: { bookedSeats: { increment: passengersData.length } }, }); } this.eventEmitter.emit('booking.created', { booking }); await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } }); return { ...booking, fareBreakdown: { outboundFare: outboundFare.baseFareMinor, returnFare: returnFare.baseFareMinor, combinedBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: booking.displayCurrency, displayCurrency, displayTotalMinor } }; } private async createTransitBooking(dto: CreateBookingDto) { 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'); if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired'); const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId); const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId); this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds); this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds); 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'); 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 station not found on leg-2 schedule'); const [passengersData, iamContact] = await Promise.all([ this.processPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; const [leg1Fare, leg2Fare] = await Promise.all([ this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount), this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount), ]); const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor; 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 loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; const taxesMinor = 0; const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; // Track which child gets free fare for leg1 and leg2 let leg1FreeChildUsed = false; let leg2FreeChildUsed = false; const passengersWithFares = passengersData.map(p => { let leg1FareMinor: number; let leg2FareMinor: number; if (p.category === PassengerCategory.ADULT) { leg1FareMinor = leg1Fare.baseFareMinor; leg2FareMinor = leg2Fare.baseFareMinor; } else { // Child fare for leg1 if (!leg1FreeChildUsed) { leg1FareMinor = 0; leg1FreeChildUsed = true; } else { leg1FareMinor = leg1Fare.baseFareMinor; } // Child fare for leg2 if (!leg2FreeChildUsed) { leg2FareMinor = 0; leg2FreeChildUsed = true; } else { leg2FareMinor = leg2Fare.baseFareMinor; } } return { ...p, leg1FareMinor, leg2FareMinor }; }); // 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: dto.passengerId, scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.transitStationId, status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId, contactEmail: iamContact.contactEmail, contactPhone: iamContact.contactPhone, 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, fareMinor: p.leg1FareMinor, displayCurrency, })), ...passengersWithFares.map(p => ({ seat: { connect: { id: p.leg2SeatId ?? p.seatId } }, 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, fareMinor: p.leg2FareMinor, displayCurrency, })), ], }, } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, }); await Promise.all([ this.seatsService.confirmSeats(passengersData.map(p => p.seatId)), this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)), ]); this.eventEmitter.emit('booking.created', { booking }); return { ...booking, fareBreakdown: { leg1BaseFareMinor: leg1Fare.baseFareMinor, leg2BaseFareMinor: leg2Fare.baseFareMinor, adultCount, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount: leg1Fare.paidChildrenCount, combinedBaseFareMinor: combinedBase, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: displayCurrency, displayTotalMinor, }, }; } private async createRoundTripTransitBooking(dto: CreateBookingDto) { 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 outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' + 'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' + 'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)', ); } // Validate all 4 holds 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 } }), ]); const now = new Date(); if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired'); if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired'); if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired'); if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired'); this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId)); this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId)); this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId)); this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId)); // Load all 4 schedules 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'); 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 station not found'); if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found'); if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found'); const [passengersData, iamContact] = await Promise.all([ this.processRoundTripPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); const nat = passengersData[0]?.nationality; const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId; const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount), this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount), this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount), this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount), ]); const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor + retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor; 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 loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; const taxesMinor = 0; const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(nat); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; // Track which child gets free fare for all 4 legs let obL1FreeChildUsed = false; let obL2FreeChildUsed = false; let retL1FreeChildUsed = false; let retL2FreeChildUsed = false; const passengersWithFares = passengersData.map(p => { let obL1FareMinor: number, obL2FareMinor: number, retL1FareMinor: number, retL2FareMinor: number; if (p.category === PassengerCategory.ADULT) { obL1FareMinor = obL1Fare.baseFareMinor; obL2FareMinor = obL2Fare.baseFareMinor; retL1FareMinor = retL1Fare.baseFareMinor; retL2FareMinor = retL2Fare.baseFareMinor; } else { // Child fares for each leg obL1FareMinor = !obL1FreeChildUsed ? (obL1FreeChildUsed = true, 0) : obL1Fare.baseFareMinor; obL2FareMinor = !obL2FreeChildUsed ? (obL2FreeChildUsed = true, 0) : obL2Fare.baseFareMinor; retL1FareMinor = !retL1FreeChildUsed ? (retL1FreeChildUsed = true, 0) : retL1Fare.baseFareMinor; retL2FareMinor = !retL2FreeChildUsed ? (retL2FreeChildUsed = true, 0) : retL2Fare.baseFareMinor; } return { ...p, obL1FareMinor, obL2FareMinor, retL1FareMinor, retL2FareMinor }; }); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fareMinor: 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, fareMinor, displayCurrency, }); const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: dto.passengerId, scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.leg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, // Outbound transit leg-2 leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: obL2SeatClassId, // Return transit returnScheduleId: dto.returnScheduleId, returnOriginStationId: dto.returnOriginStationId, returnDestinationStationId: dto.returnDestinationStationId, returnSeatClassId: retL1SeatClassId, returnLeg2ScheduleId: dto.returnLeg2ScheduleId, returnLeg2OriginStationId: dto.returnTransitStationId, returnLeg2DestStationId: dto.returnLeg2DestinationStationId, returnLeg2SeatClassId: retL2SeatClassId, returnLegStatus: 'NEITHER_USED', contactEmail: iamContact.contactEmail, contactPhone: iamContact.contactPhone, seats: { create: [ // Outbound leg-1 (sequence 1) ...passengersWithFares.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, p.obL1FareMinor)), // Outbound leg-2 (sequence 2) ...passengersWithFares.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, p.obL2FareMinor)), // Return leg-1 (sequence 3) ...passengersWithFares.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, p.retL1FareMinor)), // Return leg-2 (sequence 4) ...passengersWithFares.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, p.retL2FareMinor)), ], }, } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, }); await Promise.all([ this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)), this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)), this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)), this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)), ]); this.eventEmitter.emit('booking.created', { booking }); return { ...booking, fareBreakdown: { outboundLeg1FareMinor: obL1Fare.baseFareMinor, outboundLeg2FareMinor: obL2Fare.baseFareMinor, returnLeg1FareMinor: retL1Fare.baseFareMinor, returnLeg2FareMinor: retL2Fare.baseFareMinor, adultCount, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount: obL1Fare.paidChildrenCount, combinedBaseFareMinor: combinedBase, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: displayCurrency, displayTotalMinor, }, }; } private async processPassengers(passengers: any[]) { const processedPassengers = []; for (const passenger of passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; let nationality = passenger.nationality; if (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 = nationality || 'Ethiopian'; } else if (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'); } processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } return processedPassengers; } private async processRoundTripPassengers(passengers: any[]) { const processedPassengers = []; for (const passenger of passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; let nationality = passenger.nationality; if (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 = nationality || 'Ethiopian'; } else if (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'); } processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, // Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses // outboundSeatId/returnSeatId. Accept either form so both DTOs work. outboundSeatId: passenger.outboundSeatId ?? passenger.seatId, outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId, returnSeatId: passenger.returnSeatId, returnLeg2SeatId: passenger.returnLeg2SeatId, }); } return processedPassengers; } private countPassengers(passengersData: any[]) { let adultCount = 0, childCount = 0; for (const passenger of passengersData) { if (passenger.category === PassengerCategory.ADULT) adultCount++; else childCount++; } return { adultCount, childCount }; } private async calculatePackageFare( priceTierId: string, adultCount: number, childCount: number, ) { const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); // First child per adult travels free (no seat); additional children pay full adult fare. const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - adultCount); const adultFareMinor = tier.priceMinor * adultCount; const childTotalMinor = tier.priceMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childTotalMinor; return { baseFareMinor: tier.priceMinor, adultCount, adultFareMinor, childCount, freeChildrenCount, paidChildrenCount, childFareMinor: childTotalMinor, totalBaseFareMinor, discountMinor: 0, loyaltyRedemptionMinor: 0, taxesFeesMinor: 0, totalMinor: totalBaseFareMinor, }; } private async calculateFare( scheduleId: string, seatClassId: string, originStop: any, destStop: any, nationality?: string, adultCount = 1, childCount = 0, promoCode?: string, loyaltyRedemptionPoints?: number ) { const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; if (promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; const taxesMinor = 0; const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); return { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor }; } private async getBaseFare( scheduleId: string, seatClassId: string, segmentRoute?: string, fullRoute?: string, nationality?: string, originStopSeq?: number, destStopSeq?: number, originStationId?: string, destinationStationId?: string, ): Promise { const now = new Date(); // 1. SegmentFareRule — most specific explicit price const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) { const segmentFare = await this.prisma.segmentFareRule.findFirst({ where: { routeId: schedule.routeId, originStopSequence: originStopSeq, destinationStopSequence: destStopSeq, seatClassId, nationality: nationality ?? null, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({ where: { routeId: schedule.routeId, originStopSequence: originStopSeq, destinationStopSequence: destStopSeq, seatClassId, nationality: null, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }) : null); if (segmentFare) return segmentFare.baseFareMinor; } // 2. FareRule table — explicit override rules const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); if (bestMatch) return bestMatch.baseFareMinor; // 3. FareEngine — distance × rate-per-km from the booking's actual segment stations if (schedule?.routeId) { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, originStationId: originStationId ?? schedule.originStationId, destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); return fare.baseFarePerPassengerMinor; } 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.`, ); } // Resolves the passenger's actual boarding/alighting stations AND times for one leg // from originStationId/destinationStationId (set when the booking covers only part of // a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via // the schedule's stopTimes, falling back to the schedule's own full-route endpoints/ // times when there's no segment override (older records, or a booking that covers the // whole run). Station resolution mirrors notifications.service.ts's // resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt // resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt // / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page, // confirmation) to the same behavior search results already have, instead of always // showing the train's full-route span. private resolveSegmentStations( schedule: any, originStationId: string | null | undefined, destinationStationId: string | null | undefined, ): { origin: any; destination: any; departureAt: any; arrivalAt: any } { const stopTimes: any[] = schedule?.stopTimes ?? []; const findStop = (stationId: string | null | undefined) => stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; const originStop = findStop(originStationId); const destStop = findStop(destinationStationId); return { origin: originStop?.station ?? schedule?.originStation ?? null, destination: destStop?.station ?? schedule?.destinationStation ?? null, departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, }; } async getByRef(bookingRefOrId: string) { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: true, priceTier: { select: { priceMinor: true } }, }, }); if (!booking) { // Fall back to PackageBooking const pkgBooking = await this.prisma.packageBooking.findUnique({ where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, include: { package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } }, priceTier: true, passengers: true, paymentIntent: true, }, }); if (!pkgBooking) throw new NotFoundException('Booking not found'); return { id: pkgBooking.id, bookingRef: pkgBooking.bookingRef, status: pkgBooking.status, totalMinor: pkgBooking.totalMinor, currency: pkgBooking.currency || pkgBooking.displayCurrency, adultCount: pkgBooking.passengerCount, childCount: 0, displayCurrency: pkgBooking.displayCurrency, displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined, bookingType: 'PACKAGE', packageId: pkgBooking.packageId, priceTierId: pkgBooking.priceTierId, packageName: (pkgBooking as any).package?.name, packageCode: (pkgBooking as any).package?.code, tierLabel: (pkgBooking as any).priceTier?.label, isPackageBooking: true, returnLegStatus: null, contactEmail: pkgBooking.contactEmail, contactPhone: pkgBooking.contactPhone, createdAt: pkgBooking.createdAt, schedule: (pkgBooking as any).package?.outboundSchedule ? { id: (pkgBooking as any).package.outboundSchedule.id, trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number, trainName: (pkgBooking as any).package.outboundSchedule.train?.name, origin: (pkgBooking as any).package.outboundSchedule.originStation, destination: (pkgBooking as any).package.outboundSchedule.destinationStation, departureAt: (pkgBooking as any).package.outboundSchedule.departureAt, arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt, } : null, passengers: (pkgBooking as any).passengers?.map((p: any) => ({ fullName: p.passengerName, category: 'ADULT', leg: 1, fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), verifaydaVerified: false, seat: null, })), payment: (pkgBooking as any).paymentIntent ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status, amountMinor: (pkgBooking as any).paymentIntent.amountMinor, currency: (pkgBooking as any).paymentIntent.currency, } : undefined, tickets: [], }; } const outboundSegment = this.resolveSegmentStations( (booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId, ); const returnSegment = (booking as any).returnSchedule ? this.resolveSegmentStations( (booking as any).returnSchedule, (booking as any).returnOriginStationId, (booking as any).returnDestinationStationId, ) : null; return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, returnBoardedAt: (booking as any).returnBoardedAt ?? null, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, createdAt: booking.createdAt, schedule: { id: (booking as any).schedule.id, trainNumber: (booking as any).schedule.train.number, trainName: (booking as any).schedule.train.name, origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city }, destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city }, departureAt: outboundSegment.departureAt, arrivalAt: outboundSegment.arrivalAt, }, returnSchedule: (booking as any).returnSchedule ? { id: (booking as any).returnSchedule.id, trainNumber: (booking as any).returnSchedule.train.number, trainName: (booking as any).returnSchedule.train.name, origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city }, destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city }, departureAt: returnSegment!.departureAt, arrivalAt: returnSegment!.arrivalAt, } : null, passengers: (booking as any).seats?.map((bs: any) => { // A coach type can have several seat classes (e.g. a VIP Bed coach has separate // Upper/Lower classes) — seatClasses[0] is whichever was seeded first, so it always // showed the SAME class for every seat in the coach regardless of that seat's own // bed position. Match against the seat's actual bedPosition instead (Seat.bedPosition // is lowercase, SeatClass.bedPosition is uppercase — compare case-insensitively). // Falls back to [0] for non-bed seats (bedPosition is null, single class per coach). const classes = bs.seat.coach.coachType?.seatClasses ?? []; const matchedClass = bs.seat.bedPosition ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === bs.seat.bedPosition.toLowerCase()) : null; return { fullName: bs.passengerName, category: bs.passengerCategory, leg: bs.leg ?? 1, fareMinor: bs.fareMinor, verifaydaVerified: bs.verifaydaVerified, seat: { id: bs.seat.id, number: bs.seat.seatNumber, coach: bs.seat.coach.number, coachId: bs.seat.coach.id, seatClass: (matchedClass ?? classes[0])?.name ?? null, }, }; }), payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status, amountMinor: (booking as any).paymentIntent.amountMinor, currency: (booking as any).paymentIntent.currency, } : undefined, // One ticket per passenger per leg (round trips have a separate ticket — and // barcode — for the return leg) — matched on the frontend by passengerName + // leg, not array position, since tickets are grouped/created independently of // the passengers array. tickets: (booking as any).tickets?.map((t: any) => ({ id: t.id, passengerName: t.passengerName, leg: t.leg ?? 1, qrPayload: t.qrPayload, barcodePayload: t.barcodePayload, status: t.status, })) ?? [], }; } async modify(dto: ModifyBookingDto, iamUserId?: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } }); if (!booking) throw new NotFoundException('Booking not found'); if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified'); if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings'); const oldSeats = booking.seats.map(s => s.seatId); await this.prisma.bookingModification.create({ data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason }, }); await this.seatsService.releaseSeats(booking.id); await this.seatsService.confirmSeats(dto.newSeatIds); await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } }); return { modified: true, bookingRef: dto.bookingRef }; } async cancel(bookingRef: string, reason?: string, iamUserId?: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } }); if (!booking) throw new NotFoundException('Booking not found'); if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled'); const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0; await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); await this.seatsService.releaseSeats(booking.id); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } }); return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency}; } async update(id: string, dto: any) { const booking = await this.prisma.booking.findUnique({ where: { id } }); if (!booking) throw new NotFoundException('Booking not found'); return this.prisma.booking.update({ where: { id }, data: { status: dto.status || booking.status, totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor, displayCurrency: dto.displayCurrency || booking.displayCurrency, displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor, }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, }, }); } async delete(id: string, cascade = false) { const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); if (!booking) throw new NotFoundException('Booking not found'); if (!cascade) { const usage = await this.checkBookingUsage(id); if (usage.isInUse && usage.constraints) { throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints); } } await this.seatsService.releaseSeats(booking.id); if (cascade) { // Delete all child records that reference this booking (no onDelete: Cascade in schema) const paymentIntent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: id } }); if (paymentIntent) { await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: paymentIntent.id } }); await this.prisma.paymentIntent.delete({ where: { bookingId: id } }); } const tickets = await this.prisma.ticket.findMany({ where: { bookingId: id }, select: { id: true } }); for (const t of tickets) { await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: t.id } }); } await this.prisma.ticket.deleteMany({ where: { bookingId: id } }); await this.prisma.bookingModification.deleteMany({ where: { bookingId: id } }); await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: id } }); await this.prisma.agentBooking.deleteMany({ where: { bookingId: id } }); const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: id }, select: { id: true } }); for (const fo of foodOrders) { await this.prisma.foodOrderItem.deleteMany({ where: { orderId: fo.id } }); } await this.prisma.foodOrder.deleteMany({ where: { bookingId: id } }); await this.prisma.baggageBooking.deleteMany({ where: { bookingId: id } }); await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: id } }); const journey = await this.prisma.journey.findUnique({ where: { bookingId: id } }); if (journey) { await this.prisma.journeySegment.deleteMany({ where: { journeyId: journey.id } }); await this.prisma.journey.delete({ where: { bookingId: id } }); } } await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); await this.prisma.booking.delete({ where: { id } }); await this.auditService.log({ action: 'DELETE', entityType: 'Booking', entityId: id, oldData: { bookingRef: booking.bookingRef } }); return { deleted: true, bookingRef: booking.bookingRef }; } async checkBookingUsage(id: string) { const booking = await this.prisma.booking.findUnique({ where: { id } }); if (!booking) throw new NotFoundException('Booking not found'); const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([ this.prisma.ticket.count({ where: { bookingId: id } }), this.prisma.paymentIntent.count({ where: { bookingId: id } }), this.prisma.bookingModification.count({ where: { bookingId: id } }), this.prisma.bookingCancellation.count({ where: { bookingId: id } }), ]); const constraints = []; if (ticketCount > 0) constraints.push({ entityName: 'ticket', count: ticketCount, action: 'complete' as const }); if (paymentIntentCount > 0) constraints.push({ entityName: 'payment record', count: paymentIntentCount, action: 'complete' as const }); if (modificationsCount > 0) constraints.push({ entityName: 'modification record', count: modificationsCount, action: 'complete' as const }); if (cancellationCount > 0) constraints.push({ entityName: 'cancellation record', count: cancellationCount, action: 'complete' as const }); return { isInUse: constraints.length > 0, affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`), constraints }; } @Cron(CronExpression.EVERY_MINUTE) async expirePendingBookings() { const cutoff = new Date(Date.now() - 20 * 60 * 1000); const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } }); for (const b of expired) { await this.seatsService.releaseSeats(b.id); await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); } } // Mark round-trip bookings where the return train has departed but the return leg // was never scanned. Runs every minute; only acts on CONFIRMED bookings whose // returnSchedule.departureAt is in the past and returnBoardedAt is still null. @Cron(CronExpression.EVERY_MINUTE) async markReturnLegNoShows() { const now = new Date(); const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000); const candidates = await this.prisma.booking.findMany({ where: { bookingType: 'ROUND_TRIP', status: 'CONFIRMED', returnLegStatus: 'NEITHER_USED' as any, outboundBoardedAt: { not: null }, returnBoardedAt: null, returnScheduleId: { not: null }, }, include: { returnSchedule: { select: { departureAt: true } } }, } as any); for (const b of candidates) { const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt; if (returnDep && returnDep < graceCutoff) { await this.prisma.booking.update({ where: { id: b.id }, data: { returnLegStatus: 'OUTBOUND_ONLY' } as any, }); } } } private selectBestFareRule( candidates: any[], scheduleId: string, segmentRoute?: string, fullRoute?: string, nationality?: string, ): any | null { 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, ); if (match) return match; } return null; } }