mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,6 +28,3 @@ coverage/
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<ReturnType<BookingsService['calculateFare']>>) => ({
|
||||
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<number> {
|
||||
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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<string, any> | 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<string, any> | 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<string, any> | 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<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
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<SavedPassengerProfileDto[]> {
|
||||
if (!userId && !deviceId) {
|
||||
throw new BadRequestException('Either userId or deviceId is required');
|
||||
@@ -343,6 +926,8 @@ export class GuestBookingService {
|
||||
nationality?: string,
|
||||
): Promise<number> {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
body?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
context?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('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<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('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 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -20,7 +20,7 @@ export class NotificationsService {
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
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<string, number> = {
|
||||
'Economy Regular': 35000,
|
||||
'Economy Bed': 49000,
|
||||
'VIP Bed': 63000,
|
||||
};
|
||||
return defaults[className] ?? 35000;
|
||||
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
|
||||
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<string, number> = {
|
||||
'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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<number, any[]>();
|
||||
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<string, any> = {};
|
||||
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<string, any> = {};
|
||||
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<string>();
|
||||
|
||||
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<string, any> = {};
|
||||
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++;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function BookingsPage() {
|
||||
},
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Class',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function CoachesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(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.'
|
||||
|
||||
@@ -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 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Route Name *</label>
|
||||
@@ -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 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,8 +318,8 @@ export default function SchedulesPage() {
|
||||
label: 'Train',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<div className="font-medium">
|
||||
{schedule.train?.name} ({schedule.train?.number})
|
||||
<div className="font-medium font-mono">
|
||||
{schedule.train?.number}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -204,15 +204,31 @@ export default function TicketsPage() {
|
||||
key: 'boarded',
|
||||
label: 'Boarded',
|
||||
render: (ticket: any) => (
|
||||
ticket.boardedAt ? (
|
||||
ticket.validatedAt ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Not boarded</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'returnLegStatus',
|
||||
label: 'Return Leg',
|
||||
render: (ticket: any) => {
|
||||
const status = ticket.booking?.returnLegStatus;
|
||||
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
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 <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
@@ -444,10 +460,30 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTicket.boardedAt && (
|
||||
{selectedTicket.validatedAt && (
|
||||
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Boarded At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
|
||||
<p className="text-sm text-muted-foreground">Validated At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Leg Status</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
|
||||
{cancelText}
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -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<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||
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 */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
<h2 className="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
|
||||
Order summary
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.origin} → {selectedSchedule?.destination}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.trainNumber}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-6">
|
||||
{isRoundTrip ? (
|
||||
<>
|
||||
{/* Outbound Journey */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Outbound Journey</span>
|
||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||
{outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule?.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{outboundSchedule?.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {outboundSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule?.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Return Journey */}
|
||||
<div className="pt-4 border-t-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Return Journey</span>
|
||||
<span className="ml-auto text-xs px-2 py-0.5 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-medium">
|
||||
{inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule?.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{inboundSchedule?.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {inboundSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule?.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* One-Way Journey */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||
{selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule?.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{selectedSchedule?.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {selectedSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule?.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Passengers and Total */}
|
||||
<div className="pt-4 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between text-sm mb-3">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Class
|
||||
Passengers
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
|
||||
{passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Passengers
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{passengers.length} passenger
|
||||
{passengers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-base font-bold text-gray-900 dark:text-gray-100">
|
||||
Total amount
|
||||
</span>
|
||||
<span className="text-primary dark:text-gray-100">
|
||||
<span className="text-2xl font-bold text-primary dark:text-gray-100">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Telebirr payment was received.</p>
|
||||
{merchantOrderId && <p className="text-xs text-gray-400">Order ID: {merchantOrderId}</p>}
|
||||
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
|
||||
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation…</p>
|
||||
</>
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [outboundSelected, setOutboundSelected] = useState(false);
|
||||
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(null);
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Searching for trains...</p>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Progress Header */}
|
||||
<div className="mb-8">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Searching for trains...
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Finding the best options for your journey
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{
|
||||
animation: 'progressBar 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Cards */}
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card animate-pulse">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1">
|
||||
{/* Train info skeleton */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-24"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time and route skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
>
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-12 mt-2"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right space-y-3">
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto lg:ml-auto"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-32 mx-auto lg:ml-auto"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-16 mx-auto lg:ml-auto"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-full"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -381,7 +524,7 @@ export default function ResultsPage() {
|
||||
return (
|
||||
<button
|
||||
key={fareClass.seatClassName}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
||||
disabled={!isAvailable}
|
||||
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
isSelected
|
||||
@@ -425,7 +568,7 @@ export default function ResultsPage() {
|
||||
disabled={!selectedClass}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}</span>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return Flight' : 'Continue'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedClass && (
|
||||
@@ -494,8 +637,8 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
|
||||
<div>
|
||||
{isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && (
|
||||
<div id="inbound-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
|
||||
|
||||
@@ -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<string>('');
|
||||
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
|
||||
|
||||
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<string, string> = {};
|
||||
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() {
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip details</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.trainNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.origin} → {selectedSchedule.destination}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Departure</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'}
|
||||
{/* Outbound Trip Details */}
|
||||
{isRoundTrip && outboundSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Outbound Journey</h2>
|
||||
<span className="ml-auto text-xs px-2.5 py-1 bg-primary/10 text-primary rounded-full font-semibold">
|
||||
{outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Arrival</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Duration</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.duration}</span>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{outboundSchedule.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {outboundSchedule.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inbound Trip Details */}
|
||||
{isRoundTrip && inboundSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Return Journey</h2>
|
||||
<span className="ml-auto text-xs px-2.5 py-1 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-semibold">
|
||||
{inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{inboundSchedule.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {inboundSchedule.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One-Way Trip Details */}
|
||||
{!isRoundTrip && selectedSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Trip Details</h2>
|
||||
<span className="ml-auto text-xs px-2.5 py-1 bg-primary/10 text-primary rounded-full font-semibold">
|
||||
{selectedSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{selectedSchedule.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {selectedSchedule.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Passengers</h2>
|
||||
<div className="space-y-3">
|
||||
{passengers.map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
|
||||
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isRoundTrip ? (
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -393,10 +697,23 @@ export default function ReviewPage() {
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Base fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
{isRoundTrip ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Base fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@ SeatButton.displayName = 'SeatButton';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
|
||||
@@ -59,11 +59,12 @@ export default function SeatsPage() {
|
||||
});
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
|
||||
|
||||
const { data: seatMapData, isLoading, error } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule?.id],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
|
||||
enabled: !!selectedSchedule?.id,
|
||||
queryKey: ['seatmap', currentSchedule?.id, currentJourneyType],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${currentSchedule?.id}`),
|
||||
enabled: !!currentSchedule?.id,
|
||||
});
|
||||
|
||||
const holdMutation = useMutation({
|
||||
@@ -79,7 +80,7 @@ export default function SeatsPage() {
|
||||
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
|
||||
|
||||
return apiClient.post(`/seats/hold`, {
|
||||
scheduleId: selectedSchedule?.id,
|
||||
scheduleId: currentSchedule?.id,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
passengers: passengersForHold,
|
||||
@@ -108,20 +109,20 @@ export default function SeatsPage() {
|
||||
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
||||
|
||||
const filteredCoaches = useMemo(() => {
|
||||
if (!selectedSchedule?.selectedSeatClass) {
|
||||
if (!currentSchedule?.selectedSeatClass) {
|
||||
return coaches.filter((c: any) => c.seats && c.seats.length > 0);
|
||||
}
|
||||
|
||||
let filtered = coaches.filter((c: any) => {
|
||||
const seatClasses = c.seatClasses || [c.seatClass] || [];
|
||||
return seatClasses.some((seatClassName: string) =>
|
||||
seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase()
|
||||
seatClassName === currentSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase()
|
||||
);
|
||||
});
|
||||
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
|
||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||
}, [coaches, currentSchedule?.selectedSeatClass]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredCoaches.length > 0 && !selectedCoach) {
|
||||
@@ -144,15 +145,15 @@ export default function SeatsPage() {
|
||||
let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||
|
||||
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(currentSchedule.selectedSeatClass);
|
||||
if (selectedBedPosition) {
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return seats;
|
||||
}, [allSeats, selectedCoachData, selectedSchedule?.selectedSeatClass]);
|
||||
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
||||
|
||||
const handleSeatClick = useCallback((seatId: string) => {
|
||||
setSelectedSeats(prev => {
|
||||
@@ -166,7 +167,6 @@ export default function SeatsPage() {
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isRoundTrip && currentJourneyType === 'outbound') {
|
||||
// Save outbound seats and show inbound
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
@@ -174,8 +174,8 @@ export default function SeatsPage() {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
outboundSeatId: selectedSeats[i],
|
||||
outboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
@@ -195,12 +195,18 @@ export default function SeatsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Final continue (one-way or round-trip inbound)
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
if (isRoundTrip && currentJourneyType === 'inbound') {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: selectedSeats[i],
|
||||
inboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
@@ -264,10 +270,16 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
if (isRoundTrip) {
|
||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
} else {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}
|
||||
}, [selectedSchedule, passengers.length, router]);
|
||||
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId && selectedSeats.length > 0) {
|
||||
@@ -595,8 +607,8 @@ export default function SeatsPage() {
|
||||
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
||||
const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
||||
let fSeats = coachSeats;
|
||||
if (isBed && selectedSchedule?.selectedSeatClass) {
|
||||
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||
if (isBed && currentSchedule?.selectedSeatClass) {
|
||||
const bedPos = getBedPosition(currentSchedule.selectedSeatClass);
|
||||
if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
||||
}
|
||||
const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
|
||||
|
||||
@@ -30,7 +30,13 @@
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
@apply bg-[rgb(20_113_76)] bg-opacity-10 dark:bg-[rgb(20_113_76)] dark:bg-opacity-20;
|
||||
background-color: rgba(20, 113, 76, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@@ -108,13 +114,22 @@
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressBar {
|
||||
0% { width: 0%; }
|
||||
50% { width: 60%; }
|
||||
100% { width: 90%; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -137,6 +152,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@@ -158,11 +184,6 @@
|
||||
animation: slide-in-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
|
||||
export default function AppHeader() {
|
||||
const pathname = usePathname();
|
||||
@@ -12,47 +13,50 @@ export default function AppHeader() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
setIsDark(isDarkMode);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDarkMode = html.classList.contains('dark');
|
||||
const isDarkMode = html.classList.contains("dark");
|
||||
if (isDarkMode) {
|
||||
html.classList.remove('dark');
|
||||
html.classList.remove("dark");
|
||||
setIsDark(false);
|
||||
localStorage.setItem('theme', 'light');
|
||||
localStorage.setItem("theme", "light");
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
html.classList.add("dark");
|
||||
setIsDark(true);
|
||||
localStorage.setItem('theme', 'dark');
|
||||
localStorage.setItem("theme", "dark");
|
||||
}
|
||||
};
|
||||
|
||||
const isLandingPage = ['/', '/services', '/about', '/contact', '/help'].includes(pathname);
|
||||
|
||||
const isLandingPage = [
|
||||
"/",
|
||||
"/services",
|
||||
"/about",
|
||||
"/contact",
|
||||
"/help",
|
||||
].includes(pathname);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">
|
||||
Ethio-Djibouti Railway
|
||||
</h1>
|
||||
<p className="text-xs text-gray-100">
|
||||
Book your train journey with us
|
||||
</p>
|
||||
</div>
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-14 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu - only show for landing pages */}
|
||||
@@ -103,7 +107,7 @@ export default function AppHeader() {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
title={isDark ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
@@ -117,7 +121,11 @@ export default function AppHeader() {
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
|
||||
>
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
{isOpen ? (
|
||||
<X className="w-5 h-5" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +165,7 @@ export default function AppHeader() {
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<Link
|
||||
href="/help"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
|
||||
@@ -14,14 +14,17 @@ import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1),
|
||||
destinationStationId: z.string().min(1),
|
||||
departureDate: z.string().min(1),
|
||||
originStationId: z.string().min(1, 'Please select a departure station'),
|
||||
destinationStationId: z.string().min(1, 'Please select an arrival station'),
|
||||
departureDate: z.string().min(1, 'Please select a departure date'),
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).max(9),
|
||||
childCount: z.number().min(0).max(9),
|
||||
adultCount: z.number().min(1, 'At least 1 adult is required').max(9, 'Maximum 9 adults allowed'),
|
||||
childCount: z.number().min(0, 'Cannot be negative').max(9, 'Maximum 9 children allowed'),
|
||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||
}).refine((data) => 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<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
||||
// @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)
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
|
||||
<select
|
||||
{...register('originStationId')}
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
{...register('originStationId', {
|
||||
onChange: (e) => {
|
||||
if (e.target.value) clearErrors('originStationId');
|
||||
}
|
||||
})}
|
||||
className={`w-full pl-11 pr-4 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ${
|
||||
errors.originStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select departure</option>
|
||||
@@ -100,7 +114,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
</select>
|
||||
</div>
|
||||
{errors.originStationId && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.originStationId.message}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.originStationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -110,8 +124,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
|
||||
<select
|
||||
{...register('destinationStationId')}
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
{...register('destinationStationId', {
|
||||
onChange: (e) => {
|
||||
if (e.target.value) clearErrors('destinationStationId');
|
||||
}
|
||||
})}
|
||||
className={`w-full pl-11 pr-4 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ${
|
||||
errors.destinationStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select arrival</option>
|
||||
@@ -121,7 +141,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
</select>
|
||||
</div>
|
||||
{errors.destinationStationId && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.destinationStationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.departureDate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +288,17 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
|
||||
{/* Row 3: Search Button */}
|
||||
<div>
|
||||
{/* Debug info - remove after testing */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="mb-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200 mb-1">Validation Errors:</p>
|
||||
<ul className="text-xs text-red-700 dark:text-red-300 list-disc list-inside">
|
||||
{Object.entries(errors).map(([key, value]) => (
|
||||
<li key={key}>{key}: {value?.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl"
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface SeatHold {
|
||||
interface BookingState {
|
||||
searchCriteria: SearchCriteria | null;
|
||||
selectedSchedule: SelectedSchedule | null;
|
||||
outboundSchedule: SelectedSchedule | null;
|
||||
inboundSchedule: SelectedSchedule | null;
|
||||
passengers: PassengerDetail[];
|
||||
seatHold: SeatHold | null;
|
||||
bookingId: string | null;
|
||||
@@ -66,6 +68,8 @@ interface BookingState {
|
||||
|
||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||
setOutboundSchedule: (schedule: SelectedSchedule) => void;
|
||||
setInboundSchedule: (schedule: SelectedSchedule) => void;
|
||||
setPassengers: (passengers: PassengerDetail[]) => void;
|
||||
setSeatHold: (hold: SeatHold | null) => void;
|
||||
setBookingId: (id: string) => void;
|
||||
@@ -80,6 +84,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
(set) => (({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
outboundSchedule: null,
|
||||
inboundSchedule: null,
|
||||
passengers: [],
|
||||
seatHold: null,
|
||||
bookingId: null,
|
||||
@@ -90,6 +96,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
|
||||
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
||||
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
||||
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
|
||||
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
|
||||
setPassengers: (passengers) => set({ passengers }),
|
||||
setSeatHold: (hold) => set({ seatHold: hold }),
|
||||
setBookingId: (id) => set({ bookingId: id }),
|
||||
@@ -100,6 +108,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
clearBooking: () => set({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
outboundSchedule: null,
|
||||
inboundSchedule: null,
|
||||
passengers: [],
|
||||
seatHold: null,
|
||||
bookingId: null,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -86,6 +86,21 @@ export class WaafiProvider implements PaymentProvider {
|
||||
requestBody,
|
||||
);
|
||||
|
||||
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
|
||||
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
|
||||
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
|
||||
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
|
||||
// wrongly resolved off a "no info" response.
|
||||
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
|
||||
this.logger.debug(
|
||||
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
|
||||
const transactionId = response.params?.transactionId;
|
||||
const mapped = this.mapStatus(rawState);
|
||||
|
||||
Reference in New Issue
Block a user