From 25668e791680c0ae26507479f170faa3fdddcf47 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 15:52:18 +0300 Subject: [PATCH] Enhance guest booking functionality with round trip support and fare engine integration --- .../src/modules/bookings/bookings.module.ts | 3 +- .../src/modules/bookings/bookings.service.ts | 93 +++-- .../src/modules/bookings/guest-booking.dto.ts | 41 +- .../modules/bookings/guest-booking.service.ts | 364 ++++++++++++++---- .../src/modules/search/search.service.ts | 51 +-- 5 files changed, 393 insertions(+), 159 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index a588e7330..bce07b915 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service'; import { SeatsModule } from '../seats/seats.module'; import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; +import { FareEngineModule } from '../fare-engine/fare-engine.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 28dc5e97c..9b7cd085a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -6,6 +6,7 @@ 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'; function generateRef(): string { @@ -37,6 +38,7 @@ export class BookingsService { private eventEmitter: EventEmitter2, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, + private fareEngine: FareEngineService, ) {} async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { @@ -567,76 +569,69 @@ export class BookingsService { destStopSeq?: number, ): Promise { const now = new Date(); - - // Get schedule with route info + + // 1. SegmentFareRule — most specific explicit price const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, - include: { route: true }, + select: { routeId: true, originStationId: true, destinationStationId: true }, }); - - // Try segment fare rule first (most specific) if route info available + if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) { - // Try with nationality first const segmentFare = await this.prisma.segmentFareRule.findFirst({ where: { routeId: schedule.routeId, originStopSequence: originStopSeq, destinationStopSequence: destStopSeq, seatClassId, - nationality: nationality || null, + nationality: nationality ?? null, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, - }); - - if (segmentFare) { - return segmentFare.baseFareMinor; - } - - // If no segment fare with nationality, try without nationality filter - if (nationality) { - const segmentFareAny = 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 } }, - ], - }, - }); - if (segmentFareAny) return segmentFareAny.baseFareMinor; - } + }) ?? (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; } - - // Fall back to fare rules if no segment fare found + + // 2. FareRule table — explicit override rules const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); + const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); + if (bestMatch) return bestMatch.baseFareMinor; - const bestMatch = this.selectBestFareRule( - candidates, - scheduleId, - segmentRoute, - fullRoute, - nationality, + // 3. FareEngine — distance × rate-per-km from the schedule's route + if (schedule?.routeId) { + try { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: schedule.originStationId, + 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.`, ); - - return bestMatch?.baseFareMinor ?? 35000; } async getByRef(bookingRef: string) { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index 8fca71aea..ea7d1132b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -4,9 +4,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; export class GuestPassengerDto { - @ApiProperty({ example: 'seat-id-uuid' }) + @ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' }) @IsString() seatId: string; + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP only)' }) + @IsOptional() @IsString() returnSeatId?: string; + @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @@ -36,24 +39,42 @@ export class GuestPassengerDto { } export class CreateGuestBookingDto { - @ApiProperty({ example: 'schedule-uuid' }) + @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], default: 'ONE_WAY' }) + @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP'; + + @ApiProperty({ example: 'schedule-uuid', description: 'Outbound schedule UUID' }) @IsString() scheduleId: string; - @ApiProperty({ example: 'hold-uuid' }) + @ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' }) @IsString() holdId: string; - @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' }) @IsString() originStationId: string; - @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' }) @IsString() destinationStationId: string; - @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' }) - @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[]; - - @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) + @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' }) @IsString() seatClassId: string; + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP only: return schedule UUID' }) + @IsOptional() @IsString() returnScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP only: return seat hold UUID' }) + @IsOptional() @IsString() returnHoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return origin station UUID' }) + @IsOptional() @IsString() returnOriginStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return destination station UUID' }) + @IsOptional() @IsString() returnDestinationStationId?: string; + + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP only: return seat class UUID (defaults to outbound seatClassId)' }) + @IsOptional() @IsString() returnSeatClassId?: string; + + @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' }) + @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[]; + @ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string; @@ -66,7 +87,7 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' }) @IsOptional() @IsString() password?: string; - @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' }) + @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' }) @IsOptional() @IsBoolean() savePassengerDetails?: boolean; @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 93cbda7b0..1687e15f4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -3,6 +3,7 @@ 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 { 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'; @@ -28,10 +29,18 @@ export class GuestBookingService { private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, + private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, ) {} async createGuestBooking(dto: CreateGuestBookingDto) { + if (dto.bookingType === 'ROUND_TRIP') { + return this.createGuestRoundTripBooking(dto); + } + return this.createGuestOneWayBooking(dto); + } + + private async createGuestOneWayBooking(dto: CreateGuestBookingDto) { // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { @@ -157,76 +166,7 @@ export class GuestBookingService { // Create or get guest passenger const firstPassenger = passengersData[0]; - let guestPassenger = null; - let userId = null; - let createdAccount = false; - - // Optional account creation - if (dto.createAccount && firstPassenger.email && dto.password) { - const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existingUser) { - throw new BadRequestException('Email already registered. Please login instead.'); - } - - let accountPhone = firstPassenger.phone || null; - if (accountPhone) { - const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); - if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); - } - if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - const passwordHash = await bcrypt.hash(dto.password, 10); - const user = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: firstPassenger.email, - phone: accountPhone, - passwordHash, - nationality: firstPassenger.nationality, - nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, - passportNumber: firstPassenger.passportNumber, - }, - }); - - guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); - await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); - await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); - - userId = user.id; - createdAccount = true; - } else { - // Create anonymous guest passenger with minimal data - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - // Check if email exists and use a unique guest email if it does - let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; - if (firstPassenger.email) { - const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existingUser) { - // Email exists, use guest email instead for anonymous booking - guestEmail = `guest-${uniqueId}@edr-platform.com`; - } - } - - // Use a guaranteed-unique guest phone to avoid constraint collisions - let guestPhone = firstPassenger.phone || null; - if (guestPhone) { - const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); - if (existingPhone) guestPhone = null; - } - if (!guestPhone) guestPhone = `+guest-${uniqueId}`; - - const tempUser = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: guestEmail, - phone: guestPhone, - passwordHash: await bcrypt.hash(Math.random().toString(36), 10), - role: 'PASSENGER', - }, - }); - guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); - } + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { @@ -302,6 +242,258 @@ export class GuestBookingService { }; } + private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) { + 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'); + + 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) 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 + const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; + const primaryNationality = passengersData[0]?.nationality; + + const [outboundBaseFare, returnBaseFare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), + ]); + + const paidChildrenCount = Math.max(0, childCount - 1); + const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount; + const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * 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 = Math.round(combinedBaseFareMinor * 0.05); + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + // Create or resolve guest passenger (same as one-way) + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + + // Create booking with outbound seats; return seats confirmed separately + const outboundSeatIds = dto.passengers.map(p => p.seatId); + const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassenger.id, + scheduleId: dto.scheduleId, + 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', + userAgent: dto.deviceId, + seats: { + create: passengersData.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.category === PassengerCategory.ADULT + ? outboundBaseFare + returnBaseFare + : (paidChildrenCount > 0 ? outboundBaseFare + returnBaseFare : 0), + displayCurrency, + })), + }, + } as any, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(outboundSeatIds), + this.seatsService.confirmSeats(returnSeatIds), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + outboundBaseFareMinor: outboundBaseFare, + returnBaseFareMinor: returnBaseFare, + adultCount, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + combinedBaseFareMinor, + discountMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }, + }; + } + + private async resolveGuestPassenger( + dto: Pick, + firstPassenger: any, + ): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> { + if (dto.createAccount && firstPassenger.email && dto.password) { + const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); + if (existingUser) throw new BadRequestException('Email already registered. Please login instead.'); + + let accountPhone = firstPassenger.phone || null; + if (accountPhone) { + const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); + if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); + } + if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + const user = await this.prisma.user.create({ + data: { + fullName: firstPassenger.passengerName, + email: firstPassenger.email, + phone: accountPhone, + passwordHash: await bcrypt.hash(dto.password, 10), + nationality: firstPassenger.nationality, + nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, + passportNumber: firstPassenger.passportNumber, + }, + }); + const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); + 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 { guestPassenger, userId: user.id, createdAccount: true }; + } + + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; + if (firstPassenger.email) { + const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); + if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`; + } + let guestPhone = firstPassenger.phone || null; + if (guestPhone) { + const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); + if (existing) guestPhone = null; + } + if (!guestPhone) guestPhone = `+guest-${uniqueId}`; + + const tempUser = await this.prisma.user.create({ + data: { + fullName: firstPassenger.passengerName, + email: guestEmail, + phone: guestPhone, + passwordHash: await bcrypt.hash(Math.random().toString(36), 10), + role: 'PASSENGER', + }, + }); + const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); + return { guestPassenger, userId: null, createdAccount: false }; + } + async getSavedPassengers(userId?: string, deviceId?: string): Promise { if (!userId && !deviceId) { throw new BadRequestException('Either userId or deviceId is required'); @@ -343,6 +535,8 @@ export class GuestBookingService { nationality?: string, ): Promise { const now = new Date(); + + // 1. FareRule table — explicit override rules const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId, @@ -368,14 +562,34 @@ export class GuestBookingService { for (const priority of priorities) { const match = candidates.find( - (c) => - c.tripId === priority.tripId && - c.route === priority.route && - c.nationality === priority.nationality, + (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); if (match) return match.baseFareMinor; } - return 35000; // Default fallback + // 2. FareEngine — distance × rate-per-km from the schedule's route + 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, + originStationId: schedule.originStationId, + 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.`, + ); } } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 7d237c18b..58d611e9a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -244,7 +244,8 @@ export class SearchService { nationality, ); - const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName); + const baseFareMinor = bestMatch?.baseFareMinor + ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName); const adultCount = dto.adultCount; const childCount = dto.childCount ?? 0; @@ -379,11 +380,8 @@ export class SearchService { } } - console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`); - return seatClasses.map(sc => ({ - seatClassName: sc.name, - baseFareMinor: this.getDefaultFareForClass(sc.name), - })); + console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); + return []; } private async buildCoachTypeDetails( @@ -420,11 +418,10 @@ export class SearchService { const classes = Array.from(classNames) .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); - return { - name: className, - baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className), - }; + if (!fareInfo) return null; + return { name: className, baseFareMinor: fareInfo.baseFareMinor }; }) + .filter((c): c is { name: string; baseFareMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ @@ -442,22 +439,28 @@ export class SearchService { }); } - private getDefaultFareForClass(className: string): number { - const defaults: Record = { - 'Economy Regular': 35000, - 'Economy Bed': 49000, - 'VIP Bed': 63000, - }; - return defaults[className] ?? 35000; + private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise { + if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`); + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + seatClassId, + }); + return fare.baseFarePerPassengerMinor; } - private defaultFare(seatClassName: string): number { - const fares: Record = { - 'Economy Regular': 45000, - 'Economy Bed': 65000, - 'VIP Bed': 95000, - }; - return fares[seatClassName] ?? 45000; + private getDefaultFareForClass(_className: string): never { + throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); + } + + private defaultFare(_seatClassName: string): never { + throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); } private selectBestFareRule(