diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 26c9c9eb3..b2259a448 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -494,38 +494,44 @@ model FareRule { } model Booking { - id String @id @default(uuid()) - bookingRef String @unique - passengerId String - scheduleId String - status BookingStatus @default(DRAFT) - currency String @default("ETB") - totalMinor Int - adultCount Int @default(1) - childCount Int @default(0) - displayCurrency Currency? - displayTotalMinor Int? - bookingType String @default("ONE_WAY") - contactEmail String? - contactPhone String? - userAgent String? - source String @default("WEB") - promoCode String? - paidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - seats BookingSeat[] - paymentIntent PaymentIntent? - ticket Ticket? - foodOrders FoodOrder[] - agentBooking AgentBooking? - modifications BookingModification[] - cancellation BookingCancellation? - baggage BaggageBooking[] + id String @id @default(uuid()) + bookingRef String @unique + passengerId String + scheduleId String + bookingType String @default("ONE_WAY") + status BookingStatus @default(DRAFT) + currency String @default("ETB") + totalMinor Int + adultCount Int @default(1) + childCount Int @default(0) + displayCurrency Currency? + displayTotalMinor Int? + returnScheduleId String? + returnOriginStationId String? + returnDestinationStationId String? + returnHoldId String? + returnSeatClassId String? + contactEmail String? + contactPhone String? + userAgent String? + source String @default("WEB") + promoCode String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + seats BookingSeat[] + paymentIntent PaymentIntent? + ticket Ticket? + foodOrders FoodOrder[] + agentBooking AgentBooking? + modifications BookingModification[] + cancellation BookingCancellation? + baggage BaggageBooking[] @@index([passengerId, status]) + @@index([bookingType]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index d7f87550f..9622fa656 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -144,9 +144,20 @@ export class BookingsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Create booking (requires login)', - description: `Creates a booking for logged-in users with saved passenger profiles. - Use POST /bookings/guest for guest checkout without login.` + summary: 'Create booking (one-way or round-trip)', + description: `Creates a one-way or round-trip booking for logged-in users. + +ONE_WAY booking: +- scheduleId, holdId, originStationId, destinationStationId +- passengers: array of PassengerInputDto with seatId +- Single PNR, single payment + +ROUND_TRIP booking: +- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId +- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId +- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId +- Combined PNR, single payment for both legs +- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction` }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index b36790ce2..875bf40a2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -15,53 +15,130 @@ export class PassengerInputDto { } export class RoundTripPassengerDto { - @ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string; - @ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string; - @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; - @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string; - @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; - @ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string; - @ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string; - @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; - @ApiPropertyOptional() @IsOptional() @IsString() nationality?: string; + @ApiProperty({ + description: 'Outbound journey seat ID', + example: 'seat-uuid-outbound' + }) + @IsString() outboundSeatId: string; + + @ApiProperty({ + description: 'Return journey seat ID', + example: 'seat-uuid-return' + }) + @IsString() returnSeatId: string; + + @ApiProperty({ + example: 'Abebe Kebede', + description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)' + }) + @IsString() passengerName: string; + + @ApiProperty({ + example: '1990-05-15', + description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)' + }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ + example: 'NATIONAL_ID', + enum: IdDocumentType, + description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' + }) + @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; + + @ApiPropertyOptional({ + example: 'ET123456789', + description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' + }) + @IsOptional() @IsString() idDocumentNumber?: string; + + @ApiPropertyOptional({ + example: 'P1234567', + description: 'Passport number for non-Ethiopian passengers (no verification)' + }) + @IsOptional() @IsString() passportNumber?: string; + + @ApiPropertyOptional({ + example: 'Djibouti', + description: 'Passport issuing country for non-Ethiopians' + }) + @IsOptional() @IsString() passportCountry?: string; + + @ApiPropertyOptional({ + example: 'Ethiopian', + description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' + }) + @IsOptional() @IsString() nationality?: string; } export class CreateBookingDto { - @ApiProperty() @IsString() passengerId: string; - @ApiProperty() @IsString() scheduleId: string; - @ApiProperty() @IsString() holdId: string; - @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string; - @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string; - @ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; - @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) + @ApiProperty({ description: 'Passenger ID' }) + @IsString() passengerId: string; + + @ApiProperty({ description: 'Outbound schedule ID' }) + @IsString() scheduleId: string; + + @ApiProperty({ description: 'Outbound seat hold ID' }) + @IsString() holdId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @IsString() seatClassId: string; - @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; - @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; - @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; -} -export class CreateRoundTripBookingDto { - @ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string; + @ApiProperty({ + example: 'ONE_WAY', + enum: ['ONE_WAY', 'ROUND_TRIP'], + description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`, + default: 'ONE_WAY' + }) + @IsOptional() @IsString() bookingType?: string; - @ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string; - @ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string; - @ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string; - @ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string; + @ApiProperty({ + type: [PassengerInputDto], + description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare` + }) + @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) + passengers: PassengerInputDto[]; + + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) + @IsOptional() @IsString() promoCode?: string; - @ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string; - @ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string; - @ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string; - @ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string; + @ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' }) + @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' }) - @IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[]; + @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + + // Round-trip specific fields + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)' + }) + @IsOptional() @IsString() returnScheduleId?: string; - @ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string; + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)' + }) + @IsOptional() @IsString() returnOriginStationId?: string; - @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; - @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)' + }) + @IsOptional() @IsString() returnDestinationStationId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)' + }) + @IsOptional() @IsString() returnHoldId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)' + }) + @IsOptional() @IsString() returnSeatClassId?: string; } export class ModifyBookingDto { 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 3c8928ea6..7a52f8422 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -246,15 +246,19 @@ export class BookingsService { } async create(dto: CreateBookingDto) { + if (dto.bookingType === 'ROUND_TRIP') { + return this.createRoundTripBooking(dto); + } + return this.createOneWayBooking(dto); + } + + 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 schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -262,18 +266,182 @@ export class BookingsService { const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); - const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; + const passengersData = await this.processPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = fareCalculation.totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); + } - const seatIds = dto.passengers.map((p) => p.seatId); - const passengersData = []; - let adultCount = 0, childCount = 0; + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ONE_WAY', + totalMinor: fareCalculation.totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + 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, + fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0), + displayCurrency + })) + } + }, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } + }); - for (const passenger of dto.passengers) { + await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + this.eventEmitter.emit('booking.created', { booking }); + 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 [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 = await this.processRoundTripPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + + const [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) + ]); + + const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.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(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + + const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + 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: dto.returnSeatClassId, + seats: { + create: passengersData.map(p => ({ + seat: { connect: { id: p.outboundSeatId } }, + 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.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0, + displayCurrency + })) + } + }, + 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) + ]); + + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + fareBreakdown: { + outboundFare: outboundFare.baseFareMinor, + returnFare: returnFare.baseFareMinor, + combinedBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + 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; - if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; @@ -292,68 +460,93 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } + return processedPassengers; + } - const primaryNationality = passengersData[0]?.nationality; - const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence); + 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 }); + } + 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 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); + const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + 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 = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); - const displayCurrency = dto.displayCurrency || Currency.ETB; - let displayTotalMinor = totalMinor; - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); - } - - const booking = await this.prisma.booking.create({ - data: { - bookingRef: generateRef(), - passengerId: dto.passengerId, - scheduleId: dto.scheduleId, - status: 'PENDING_PAYMENT', - totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, - bookingType: dto.bookingType ?? 'ONE_WAY', - seats: { - create: passengersData.map((p) => ({ - seat: { connect: { id: p.seatId } }, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, - passengerCategory: p.category, - idDocumentType: p.idDocumentType, - idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, - verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), - displayCurrency, - })), - }, - }, - include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, - }); - - await this.seatsService.confirmSeats(seatIds); - this.eventEmitter.emit('booking.created', { booking }); - return { - ...booking, - fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor }, + baseFareMinor, + adultCount, + adultFareMinor, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + childFareMinor, + totalBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor }; } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 0662da595..b7f4695a1 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -134,9 +134,28 @@ export class FleetService { } async deleteCoachType(id: string) { - const coachType = await this.prisma.coachType.findUnique({ where: { id } }); + const coachType = await this.prisma.coachType.findUnique({ + where: { id }, + include: { + coaches: true, + seatClasses: true, + }, + }); if (!coachType) throw new NotFoundException('Coach type not found'); + // Check for related records + if (coachType.coaches.length > 0) { + throw new BadRequestException( + `Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.` + ); + } + + if (coachType.seatClasses.length > 0) { + throw new BadRequestException( + `Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.` + ); + } + return this.prisma.coachType.delete({ where: { id } }); } @@ -183,9 +202,29 @@ export class FleetService { } async deleteClass(id: string) { - const seatClass = await this.prisma.seatClass.findUnique({ where: { id } }); + const seatClass = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + fareRules: true, + routeFareRules: true, + segmentFares: true, + }, + }); if (!seatClass) throw new NotFoundException('Seat class not found'); + // Check for related records + const relatedRecords = [ + ...seatClass.fareRules, + ...seatClass.routeFareRules, + ...seatClass.segmentFares, + ]; + + if (relatedRecords.length > 0) { + throw new BadRequestException( + `Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.` + ); + } + return this.prisma.seatClass.delete({ where: { id } }); } @@ -220,8 +259,21 @@ export class FleetService { } async deleteTrain(id: string) { - const train = await this.prisma.train.findUnique({ where: { id } }); + const train = await this.prisma.train.findUnique({ + where: { id }, + include: { + schedules: true, + }, + }); if (!train) throw new NotFoundException('Train not found'); + + // Check for active schedules + if (train.schedules.length > 0) { + throw new BadRequestException( + `Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.` + ); + } + return this.prisma.train.delete({ where: { id } }); } @@ -305,10 +357,53 @@ export class FleetService { } async deleteCoach(id: string) { - const coach = await this.prisma.coach.findUnique({ where: { id } }); + const coach = await this.prisma.coach.findUnique({ + where: { id }, + include: { + assignments: true, + seats: { + include: { + bookingSeats: true, + blocks: true, + ticketSeats: true, + }, + }, + }, + }); if (!coach) throw new NotFoundException('Coach not found'); - // Delete related seats first + // Check for active assignments + if (coach.assignments.length > 0) { + throw new BadRequestException( + `Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.` + ); + } + + // Check for booked seats + const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0); + if (bookedSeats.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.` + ); + } + + // Check for blocked seats + const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0); + if (blockedSeats.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.` + ); + } + + // Check for tickets + const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0); + if (seatsWithTickets.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.` + ); + } + + // Delete related seats first (now safe to do) await this.prisma.seat.deleteMany({ where: { coachId: id } }); return this.prisma.coach.delete({ where: { id } }); diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index e816ca8e1..f7ef5487f 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -545,8 +545,13 @@ export class SchedulesService { }); } - if (dto.coaches && dto.coaches.length > 0) { - await this.assignCoaches(id, dto.coaches); + if (dto.coaches !== undefined) { + if (dto.coaches.length > 0) { + await this.assignCoaches(id, dto.coaches); + } else { + // Remove all coach assignments when empty array is sent + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); + } } return this.getSchedule(id); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index e60de15c2..1207a01e4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -204,14 +204,11 @@ export default function SchedulesPage() { departureAt: editForm.departureAt, arrivalAt: editForm.arrivalAt, status: editForm.status, - }; - - if (editForm.coachIds.length > 0) { - payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({ + coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, - })); - } + })), + }; await updateScheduleMutation.mutateAsync({ id: editingSchedule.id, @@ -327,14 +324,20 @@ export default function SchedulesPage() { ), }, { - key: 'originStation.name', - label: 'From', - render: (schedule: Schedule) => {schedule.originStation?.name}, - }, - { - key: 'destinationStation.name', - label: 'To', - render: (schedule: Schedule) => {schedule.destinationStation?.name}, + key: 'route', + label: 'Route', + sortable: true, + render: (schedule: Schedule) => ( +
+ + {schedule.originStation?.name || 'Unknown'} + + + + {schedule.destinationStation?.name || 'Unknown'} + +
+ ), }, { key: 'departureAt', diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index ba561b24e..8b1ad4072 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -420,7 +420,12 @@ export default function SeatsPage() { const seats = (coach.seats || []).filter((s: any) => s.seatNumber); return seats.length > 0; }) - .sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0)); + .sort((a: any, b: any) => { + // Try multiple sequence field possibilities + const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999; + const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999; + return seqA - seqB; + }); return (
@@ -460,8 +465,30 @@ export default function SeatsPage() {

Loading seats...

) : coachesWithSeats.length === 0 ? ( -
-

No coaches with seats found for this schedule

+
+
+ + +
+
+

No coaches with seats found for this schedule

+
) : (
@@ -526,7 +553,7 @@ export default function SeatsPage() { const seats = (coach.seats || []).filter((s: any) => s.seatNumber); const isExpanded = expandedCoaches.has(coach.id); const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; - const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1; + const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1; return (
diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 6c512462e..97a1cf7ba 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -75,7 +75,6 @@ export default function StationsPage() { lat: parseFloat(formData.get('lat') as string) || null, lng: parseFloat(formData.get('lng') as string) || null, timezone: formData.get('timezone') as string, - distance: parseFloat(formData.get('distance') as string) || 0, sequence, isOperational: formData.get('isOperational') === 'true', }; @@ -136,13 +135,7 @@ export default function StationsPage() {
), }, - { - key: 'distance', - label: 'Distance (km)', - render: (station: any) => ( - {station.distance ? `${station.distance}` : '0'} - ), - }, + { key: 'isOperational', label: 'Status', @@ -346,18 +339,6 @@ export default function StationsPage() { ))}
-
- - -