diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index b2500160d..482a0df2c 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10 GITHUB_PACKAGE_TOKEN= +# --- Notification broker (RabbitMQ) ----------------------------------------------------------------- +# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker). +RABBITMQ_ENABLED=false +RABBITMQ_URL=amqp://localhost:5672 +EMAIL_QUEUE=email_queue +SMS_QUEUE=sms_queue + # --- Payment event consumer (RabbitMQ) ------------------------------------------------------- # Consumes payment.succeeded / payment.failed events from the payment microservice. Separate # from any RABBITMQ_URL used by the IAM/notification modules so the two connections are diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql new file mode 100644 index 000000000..e93fb8320 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); + +-- AlterTable: add return leg tracking columns to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', + ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), + ADD COLUMN "returnBoardedAt" TIMESTAMP(3); + +-- Set NEITHER_USED for existing confirmed round-trip bookings +UPDATE "passenger"."Booking" +SET "returnLegStatus" = 'NEITHER_USED' +WHERE "bookingType" = 'ROUND_TRIP' + AND "status" IN ('CONFIRMED', 'COMPLETED'); + +-- AlterTable: add leg column to GateValidationLog +ALTER TABLE "passenger"."GateValidationLog" + ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b2259a448..3c5d18f3a 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -115,6 +115,16 @@ enum BookingStatus { @@schema("passenger") } +enum ReturnLegStatus { + NOT_APPLICABLE // one-way booking + BOTH_USED // passenger used both legs + OUTBOUND_ONLY // return leg not used (no-show on return) + INBOUND_ONLY // outbound leg not used, return leg used + NEITHER_USED // neither leg boarded yet + + @@schema("passenger") +} + enum PaymentRegion { ETHIOPIA DJIBOUTI @@ -364,7 +374,8 @@ model TrainSchedule { originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) coachAssignments CoachAssignment[] - bookings Booking[] + bookings Booking[] @relation("OutboundSchedule") + returnBookings Booking[] @relation("ReturnSchedule") stopTimes TripStopTime[] liveStatus TripLiveStatus? menuItems MenuItem[] @@ -511,6 +522,9 @@ model Booking { returnDestinationStationId String? returnHoldId String? returnSeatClassId String? + returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE) + outboundBoardedAt DateTime? + returnBoardedAt DateTime? contactEmail String? contactPhone String? userAgent String? @@ -520,7 +534,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? @@ -1159,6 +1174,7 @@ model GateValidationLog { ticketId String validatorId String gateId String? + leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets status String reason String? validatedAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 757432c3b..f0edd6cba 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -75,7 +75,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; PaymentsModule, TicketsModule, PassengersModule, - NotificationsModule, + NotificationsModule.register(), LoyaltyModule, WalletModule, PromosModule, diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f1972b0e6..298e37c01 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -34,19 +34,23 @@ async function bootstrap() { ## Overview Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. -## 🆕 Latest Updates -- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display -- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles -- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing -- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories -- **Multi-Currency Display:** Bookings track display currency and converted amounts -- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail +## Latest Updates +- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN). +- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned. +- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch. +- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office. +- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display. +- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles. +- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing. +- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories. +- **Multi-Currency Display:** Bookings track display currency and converted amounts. +- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail. ## Key Features -### 🎫 Booking Lifecycle +### Booking Lifecycle - Search trips with real-time availability -- Age-based passenger categorization (Adult ≥5 years, Child <5 years) +- Age-based passenger categorization (Adult 5+ years, Child under 5) - Nationality-based verification (Ethiopian Fayda, International Passport) - Passenger information collection with verification - Coach and seat selection with real-time availability @@ -55,130 +59,129 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Modify bookings (seat changes, passenger updates) - Cancel bookings with automatic refunds - Multi-segment journey support -- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti) +- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti) - Round-trip booking with return journey scheduling - Coach type selection with seat class and pricing options -- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP) -- **NEW:** Display currency and converted pricing per booking +- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP) +- NEW: Display currency and converted pricing per booking +- NEW: returnLegStatus field tracks which legs of a round-trip were used +- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office -### 👤 Passenger Verification -1. **Ethiopian Nationals:** -- Automatic Fayda verification for adults (≥5 years) +### Passenger Verification +1. Ethiopian Nationals: +- Automatic Fayda verification for adults (5+ years) - Real-time national ID verification via government database - Retrieves verified passenger data (name, DOB, gender) - National IDs not stored (policy compliant) -2. **International Passengers:** +2. International Passengers: - Passport information collection - Manual verification for Djiboutian and other nationals - No government database verification required -### 💰 Age-Based Pricing -- **ADULT** (≥5 years): Pay 100% of base fare -- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% +### Age-Based Pricing +- ADULT (5+ years): Pay 100% of base fare +- CHILD (under 5): First child travels FREE, subsequent children pay 100% - Automatic age calculation from date of birth -- Example: 2 adults + 3 children = 4× base fare (first child free) -- **NEW:** Premium charges and insurance fees per seat class -- **NEW:** Transparent fee breakdown in pricing calculations +- Example: 2 adults + 3 children = 4x base fare (first child free) +- NEW: Premium charges and insurance fees per seat class +- NEW: Transparent fee breakdown in pricing calculations -### 💳 Payment Integration -1. **Ethiopian Payment Methods:** -- **Telebirr** - Ethiopia's leading mobile money -- **CBE Birr** - Commercial Bank of Ethiopia +### Payment Integration +1. Ethiopian Payment Methods: Telebirr, CBE Birr +2. Djiboutian Payment Methods: Waafi +3. International Payment Methods: Card, Wallet -2. **Djiboutian Payment Methods:** -- **Waafi** - Djibouti's mobile money service - -3. **International Payment Methods:** -- **Card** - International card payments (Visa, Mastercard) -- **Wallet** - Internal wallet system - -### 🪑 Seat Management +### Seat Management - Real-time seat availability by coach and class - Seat holds with 15-minute expiry - Auto-assign seats with contiguous algorithm - Seat blocking for maintenance - Coach-level seat maps (ordered by sequence) - Class-based seating (Economy Regular, Economy Bed, VIP Bed) -- **NEW:** Sequence-based coach ordering for consistent display +- NEW: Sequence-based coach ordering for consistent display -### 🎟️ Ticketing +### Ticketing - QR code and barcode generation - PDF ticket generation - Gate validation with audit logs - Offline validation support - Multi-passenger tickets -- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps) -- **NEW:** Complete audit trail for compliance and reporting +- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) +- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets +- NEW: Complete audit trail per leg for compliance and reporting -### 🏆 Loyalty Program -- 4 tiers: Bronze, Silver, Gold, Platinum -- Points accumulation on trips -- Reward redemption -- Tier-based benefits +### Round-Trip Leg Tracking (NEW) +- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED +- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN) +- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned +- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows +- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking -### 💰 Wallet System -- Top-up via payment methods -- Pay with wallet balance -- Transaction ledger -- Refund to wallet - -### 📍 Live Tracking -- Real-time trip status -- Location updates -- Delay notifications -- Station crowd signals - -### 🔒 Fraud Detection -- Velocity checks (multiple bookings) -- High-value transaction monitoring -- Failed payment pattern detection -- Automatic user blocking - -### 👤 Passenger Profiles -- Comprehensive profile data: gender, date of birth, nationality -- National ID for Ethiopian citizens (Fayda verified) -- Passport information for international passengers -- **NEW:** Complete demographic data for personalized services -- **NEW:** Improved user targeting and communications - -### 🌍 Internationalization -- Multi-language support (English, Amharic, French, Oromo) -- Locale-based responses -- Currency formatting (ETB, DJF, USD) -- **NEW:** Multi-currency display per booking (ETB, DJF, USD) - -### 🚌 Transit Stop Management -- Automatic detection of cross-border journeys (Ethiopia → Djibouti) -- Dire Dawa as mandatory transit hub for international journeys -- Dual-leg fare calculation (domestic + international) -- Age-based pricing applied independently per leg -- Seamless multi-segment booking workflow -- Transit stop optimization and route planning - -### 🔄 Round-Trip Booking +### Round-Trip Booking - One-way and round-trip journey options - Flexible return date selection - Combined pricing for outbound + return legs - Separate seat management per leg - Independent modification/cancellation per leg - Return journey tracking and notifications -- **NEW:** Booking type stored for analytics and reporting +- NEW: Booking type stored for analytics and reporting -### 🚐 Coach Type & Class Selection -- Browse available coach types per route (standard coaches, premium coaches) +### Loyalty Program +- 4 tiers: Bronze, Silver, Gold, Platinum +- Points accumulation on trips +- Reward redemption +- Tier-based benefits + +### Wallet System +- Top-up via payment methods +- Pay with wallet balance +- Transaction ledger +- Refund to wallet + +### Live Tracking +- Real-time trip status +- Location updates +- Delay notifications +- Station crowd signals + +### Fraud Detection +- Velocity checks (multiple bookings) +- High-value transaction monitoring +- Failed payment pattern detection +- Automatic user blocking + +### Passenger Profiles +- Comprehensive profile data: gender, date of birth, nationality +- National ID for Ethiopian citizens (Fayda verified) +- Passport information for international passengers +- NEW: Complete demographic data for personalized services + +### Internationalization +- Multi-language support (English, Amharic, French, Oromo) +- Locale-based responses +- Currency formatting (ETB, DJF, USD) +- NEW: Multi-currency display per booking (ETB, DJF, USD) + +### Transit Stop Management +- Automatic detection of cross-border journeys (Ethiopia to Djibouti) +- Dire Dawa as mandatory transit hub for international journeys +- Dual-leg fare calculation (domestic + international) +- Age-based pricing applied independently per leg +- Seamless multi-segment booking workflow + +### Coach Type & Class Selection +- Browse available coach types per route - View seat classes per coach (Economy Regular, Economy Bed, VIP Bed) - Compare base prices by coach type and class - Real-time availability per coach configuration -- Deferred pricing at seat selection stage -- Coach amenities and features display -- **NEW:** Sequence-based coach ordering for consistent UI -- **NEW:** Premium and insurance fee transparency per class +- NEW: Sequence-based coach ordering for consistent UI +- NEW: Premium and insurance fee transparency per class -### 📊 Data Organization -- **Stations:** Ordered by sequence (1-15) for consistent route display -- **Coaches:** Ordered by sequence (1+) per type for predictable configuration -- **Booking History:** Sorted chronologically with filtering options +### Data Organization +- Stations ordered by sequence (1-15) for consistent route display +- Coaches ordered by sequence (1+) per type for predictable configuration +- Booking history sorted chronologically with filtering options ## Authentication @@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. ### Step 3: Passenger Information & Verification **For Ethiopian Passengers:** -\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years) +\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years) **For International Passengers:** \`POST /passengers/register-international\` - Passport information collection @@ -265,7 +268,7 @@ Payment providers send notifications to: .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") - .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout") + .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management") .addTag("Config", "System settings, feature flags, and configuration management") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") .addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications") @@ -291,7 +294,7 @@ Payment providers send notifications to: .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") - .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails") + .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails") .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)") .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") //.addServer('http://localhost:4000', 'Development') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 9622fa656..561e36c43 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -75,21 +75,24 @@ export class BookingsController { @Get() @ApiOperation({ summary: 'List all bookings with filters (Admin/Agent)', - description: 'Returns paginated list of bookings with search and status filters' + description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('returnLegStatus') returnLegStatus?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.findAll({ search, - status, + status, + returnLegStatus, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index a588e7330..bce07b915 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service'; import { SeatsModule } from '../seats/seats.module'; import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; +import { FareEngineModule } from '../fare-engine/fare-engine.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7a52f8422..9b7cd085a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; +import { FareEngineService } from '../fare-engine/fare-engine.service'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; function generateRef(): string { @@ -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: { @@ -391,6 +399,7 @@ 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 } }, @@ -406,7 +415,7 @@ export class BookingsService { displayCurrency })) } - }, + } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } }); @@ -560,76 +569,69 @@ export class BookingsService { destStopSeq?: number, ): Promise { const now = new Date(); - - // Get schedule with route info + + // 1. SegmentFareRule — most specific explicit price const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, - include: { route: true }, + select: { routeId: true, originStationId: true, destinationStationId: true }, }); - - // Try segment fare rule first (most specific) if route info available + if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) { - // Try with nationality first const segmentFare = await this.prisma.segmentFareRule.findFirst({ where: { routeId: schedule.routeId, originStopSequence: originStopSeq, destinationStopSequence: destStopSeq, seatClassId, - nationality: nationality || null, + nationality: nationality ?? null, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, - }); - - if (segmentFare) { - return segmentFare.baseFareMinor; - } - - // If no segment fare with nationality, try without nationality filter - if (nationality) { - const segmentFareAny = await this.prisma.segmentFareRule.findFirst({ - where: { - routeId: schedule.routeId, - originStopSequence: originStopSeq, - destinationStopSequence: destStopSeq, - seatClassId, - nationality: null, - validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], - }, - }); - if (segmentFareAny) return segmentFareAny.baseFareMinor; - } + }) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: schedule.routeId, + originStopSequence: originStopSeq, + destinationStopSequence: destStopSeq, + seatClassId, + nationality: null, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }) : null); + + if (segmentFare) return segmentFare.baseFareMinor; } - - // Fall back to fare rules if no segment fare found + + // 2. FareRule table — explicit override rules const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); + const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); + if (bestMatch) return bestMatch.baseFareMinor; - const bestMatch = this.selectBestFareRule( - candidates, - scheduleId, - segmentRoute, - fullRoute, - nationality, + // 3. FareEngine — distance × rate-per-km from the schedule's route + if (schedule?.routeId) { + try { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + seatClassId, + nationality, + }); + return fare.baseFarePerPassengerMinor; + } catch { + // FareEngine throws if distanceKm is missing; fall through to error + } + } + + throw new BadRequestException( + `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`, ); - - return bestMatch?.baseFareMinor ?? 35000; } async getByRef(bookingRef: string) { @@ -646,7 +648,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 +757,37 @@ export class BookingsService { } } + // Mark round-trip bookings where the return train has departed but the return leg + // was never scanned. Runs every minute; only acts on CONFIRMED bookings whose + // returnSchedule.departureAt is in the past and returnBoardedAt is still null. + @Cron(CronExpression.EVERY_MINUTE) + async markReturnLegNoShows() { + const now = new Date(); + const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000); + + const candidates = await this.prisma.booking.findMany({ + where: { + bookingType: 'ROUND_TRIP', + status: 'CONFIRMED', + returnLegStatus: 'NEITHER_USED' as any, + outboundBoardedAt: { not: null }, + returnBoardedAt: null, + returnScheduleId: { not: null }, + }, + include: { returnSchedule: { select: { departureAt: true } } }, + } as any); + + for (const b of candidates) { + const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt; + if (returnDep && returnDep < graceCutoff) { + await this.prisma.booking.update({ + where: { id: b.id }, + data: { returnLegStatus: 'OUTBOUND_ONLY' } as any, + }); + } + } + } + private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index 8fca71aea..ea7d1132b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -4,9 +4,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; export class GuestPassengerDto { - @ApiProperty({ example: 'seat-id-uuid' }) + @ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' }) @IsString() seatId: string; + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP only)' }) + @IsOptional() @IsString() returnSeatId?: string; + @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @@ -36,24 +39,42 @@ export class GuestPassengerDto { } export class CreateGuestBookingDto { - @ApiProperty({ example: 'schedule-uuid' }) + @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], default: 'ONE_WAY' }) + @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP'; + + @ApiProperty({ example: 'schedule-uuid', description: 'Outbound schedule UUID' }) @IsString() scheduleId: string; - @ApiProperty({ example: 'hold-uuid' }) + @ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' }) @IsString() holdId: string; - @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' }) @IsString() originStationId: string; - @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' }) @IsString() destinationStationId: string; - @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' }) - @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[]; - - @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) + @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' }) @IsString() seatClassId: string; + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP only: return schedule UUID' }) + @IsOptional() @IsString() returnScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP only: return seat hold UUID' }) + @IsOptional() @IsString() returnHoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return origin station UUID' }) + @IsOptional() @IsString() returnOriginStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return destination station UUID' }) + @IsOptional() @IsString() returnDestinationStationId?: string; + + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP only: return seat class UUID (defaults to outbound seatClassId)' }) + @IsOptional() @IsString() returnSeatClassId?: string; + + @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' }) + @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[]; + @ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string; @@ -66,7 +87,7 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' }) @IsOptional() @IsString() password?: string; - @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' }) + @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' }) @IsOptional() @IsBoolean() savePassengerDetails?: boolean; @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 93cbda7b0..1687e15f4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; +import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; @@ -28,10 +29,18 @@ export class GuestBookingService { private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, + private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, ) {} async createGuestBooking(dto: CreateGuestBookingDto) { + if (dto.bookingType === 'ROUND_TRIP') { + return this.createGuestRoundTripBooking(dto); + } + return this.createGuestOneWayBooking(dto); + } + + private async createGuestOneWayBooking(dto: CreateGuestBookingDto) { // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { @@ -157,76 +166,7 @@ export class GuestBookingService { // Create or get guest passenger const firstPassenger = passengersData[0]; - let guestPassenger = null; - let userId = null; - let createdAccount = false; - - // Optional account creation - if (dto.createAccount && firstPassenger.email && dto.password) { - const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existingUser) { - throw new BadRequestException('Email already registered. Please login instead.'); - } - - let accountPhone = firstPassenger.phone || null; - if (accountPhone) { - const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); - if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); - } - if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - const passwordHash = await bcrypt.hash(dto.password, 10); - const user = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: firstPassenger.email, - phone: accountPhone, - passwordHash, - nationality: firstPassenger.nationality, - nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, - passportNumber: firstPassenger.passportNumber, - }, - }); - - guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); - await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); - await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); - - userId = user.id; - createdAccount = true; - } else { - // Create anonymous guest passenger with minimal data - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - // Check if email exists and use a unique guest email if it does - let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; - if (firstPassenger.email) { - const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existingUser) { - // Email exists, use guest email instead for anonymous booking - guestEmail = `guest-${uniqueId}@edr-platform.com`; - } - } - - // Use a guaranteed-unique guest phone to avoid constraint collisions - let guestPhone = firstPassenger.phone || null; - if (guestPhone) { - const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); - if (existingPhone) guestPhone = null; - } - if (!guestPhone) guestPhone = `+guest-${uniqueId}`; - - const tempUser = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: guestEmail, - phone: guestPhone, - passwordHash: await bcrypt.hash(Math.random().toString(36), 10), - role: 'PASSENGER', - }, - }); - guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); - } + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { @@ -302,6 +242,258 @@ export class GuestBookingService { }; } + private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) { + if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { + throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); + } + + // Validate both holds + const [outboundHold, returnHold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), + ]); + if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found'); + if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found'); + + // Validate passengers have returnSeatId + for (const p of dto.passengers) { + if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`); + } + + // Load both schedules + const [outboundSchedule, returnSchedule] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + this.prisma.trainSchedule.findUnique({ + where: { id: dto.returnScheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + ]); + if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); + if (!returnSchedule) throw new NotFoundException('Return schedule not found'); + + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); + const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); + const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); + if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); + + const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; + const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; + const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; + const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`; + + // Process passengers (verify identity once — same person travels both legs) + const passengersData: any[] = []; + let adultCount = 0, childCount = 0; + + for (const passenger of dto.passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const age = calculateAge(dateOfBirth); + const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + if (category === PassengerCategory.ADULT) adultCount++; else childCount++; + + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + + const isEthiopian = passenger.nationality === 'Ethiopian' || + passenger.nationality === 'ETHIOPIAN' || + passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { + if (passenger.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + } + nationality = 'Ethiopian'; + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + nationality = 'Ethiopian'; + } else { + nationality = nationality || 'Other'; + } + + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + + // Calculate fares for both legs + const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; + const primaryNationality = passengersData[0]?.nationality; + + const [outboundBaseFare, returnBaseFare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), + ]); + + const paidChildrenCount = Math.max(0, childCount - 1); + const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount; + const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount; + const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff + ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) + : (promo.amountOffMinor ?? 0); + } + } + + const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + // Create or resolve guest passenger (same as one-way) + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + + // Create booking with outbound seats; return seats confirmed separately + const outboundSeatIds = dto.passengers.map(p => p.seatId); + const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassenger.id, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnHoldId: dto.returnHoldId, + returnSeatClassId, + returnLegStatus: 'NEITHER_USED', + userAgent: dto.deviceId, + seats: { + create: passengersData.map((p) => ({ + seat: { connect: { id: p.seatId } }, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT + ? outboundBaseFare + returnBaseFare + : (paidChildrenCount > 0 ? outboundBaseFare + returnBaseFare : 0), + displayCurrency, + })), + }, + } as any, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(outboundSeatIds), + this.seatsService.confirmSeats(returnSeatIds), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + outboundBaseFareMinor: outboundBaseFare, + returnBaseFareMinor: returnBaseFare, + adultCount, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + combinedBaseFareMinor, + discountMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }, + }; + } + + private async resolveGuestPassenger( + dto: Pick, + firstPassenger: any, + ): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> { + if (dto.createAccount && firstPassenger.email && dto.password) { + const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); + if (existingUser) throw new BadRequestException('Email already registered. Please login instead.'); + + let accountPhone = firstPassenger.phone || null; + if (accountPhone) { + const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); + if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); + } + if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + const user = await this.prisma.user.create({ + data: { + fullName: firstPassenger.passengerName, + email: firstPassenger.email, + phone: accountPhone, + passwordHash: await bcrypt.hash(dto.password, 10), + nationality: firstPassenger.nationality, + nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, + passportNumber: firstPassenger.passportNumber, + }, + }); + const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); + await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); + await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); + return { guestPassenger, userId: user.id, createdAccount: true }; + } + + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; + if (firstPassenger.email) { + const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); + if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`; + } + let guestPhone = firstPassenger.phone || null; + if (guestPhone) { + const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); + if (existing) guestPhone = null; + } + if (!guestPhone) guestPhone = `+guest-${uniqueId}`; + + const tempUser = await this.prisma.user.create({ + data: { + fullName: firstPassenger.passengerName, + email: guestEmail, + phone: guestPhone, + passwordHash: await bcrypt.hash(Math.random().toString(36), 10), + role: 'PASSENGER', + }, + }); + const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); + return { guestPassenger, userId: null, createdAccount: false }; + } + async getSavedPassengers(userId?: string, deviceId?: string): Promise { if (!userId && !deviceId) { throw new BadRequestException('Either userId or deviceId is required'); @@ -343,6 +535,8 @@ export class GuestBookingService { nationality?: string, ): Promise { const now = new Date(); + + // 1. FareRule table — explicit override rules const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId, @@ -368,14 +562,34 @@ export class GuestBookingService { for (const priority of priorities) { const match = candidates.find( - (c) => - c.tripId === priority.tripId && - c.route === priority.route && - c.nationality === priority.nationality, + (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); if (match) return match.baseFareMinor; } - return 35000; // Default fallback + // 2. FareEngine — distance × rate-per-km from the schedule's route + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + + if (schedule?.routeId) { + try { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + seatClassId, + nationality, + }); + return fare.baseFarePerPassengerMinor; + } catch { + // FareEngine throws if distanceKm is missing; fall through to error + } + } + + throw new BadRequestException( + `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`, + ); } } diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index 8fcb82394..f925920cb 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap { private readonly emailServiceClient: ClientProxy, ) {} + private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false'; + async onApplicationBootstrap() { + if (!this.enabled) return; this.emailServiceClient .connect() .then(() => this.logger.log('Connected to Email service')) @@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap { } async sendEmail(dto: SendEmail) { - this.emailServiceClient.emit('send-email', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index f3436ee38..8578a47e8 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; @@ -38,17 +38,37 @@ import { SmsClientService } from './sms-client.service'; queueOptions: { durable: true }, }, }, - ]), - ], - controllers: [NotificationsController], - providers: [ - NotificationsService, - EmailAdapter, - SmsAdapter, - PushAdapter, - EmailClientService, - SmsClientService, - ], - exports: [NotificationsService, EmailClientService, SmsClientService], -}) -export class NotificationsModule {} + }), + }, +]); + +@Module({}) +export class NotificationsModule { + static register(): DynamicModule { + const rmqEnabled = process.env.RABBITMQ_ENABLED !== 'false'; + + return { + module: NotificationsModule, + imports: [ + HttpModule.register({ timeout: 10_000 }), + ...(rmqEnabled ? [rmqClientsModule] : []), + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + EmailAdapter, + SmsAdapter, + PushAdapter, + ...(rmqEnabled + ? [EmailClientService, SmsClientService] + : [ + { provide: 'EMAIL_SERVICE', useValue: null }, + { provide: 'SMS_SERVICE', useValue: null }, + EmailClientService, + SmsClientService, + ]), + ], + exports: [NotificationsService, EmailClientService, SmsClientService], + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 148a8a42b..7b18212cb 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -16,7 +16,10 @@ export class SmsClientService implements OnApplicationBootstrap { private smsClient: ClientProxy, ) {} + private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false'; + async onApplicationBootstrap() { + if (!this.enabled) return; this.smsClient .connect() .then(() => { @@ -28,18 +31,14 @@ export class SmsClientService implements OnApplicationBootstrap { } async sendSms(dto: SendMessage) { - this.smsClient.emit('send-sms', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } async sendBulkMessages(dto: BulkMessagesDto) { - this.smsClient.emit('ozeking-bulk-sms', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index fcad059f4..4e422f2ad 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto { @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class BulkSchedulesResponseDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index f7ef5487f..fd0776141 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -44,6 +44,15 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); + + // Assign coaches if provided + if (dto.coachIds && dto.coachIds.length > 0) { + await this.assignCoaches( + schedule.id, + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } + scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 7d237c18b..58d611e9a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -244,7 +244,8 @@ export class SearchService { nationality, ); - const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName); + const baseFareMinor = bestMatch?.baseFareMinor + ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName); const adultCount = dto.adultCount; const childCount = dto.childCount ?? 0; @@ -379,11 +380,8 @@ export class SearchService { } } - console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`); - return seatClasses.map(sc => ({ - seatClassName: sc.name, - baseFareMinor: this.getDefaultFareForClass(sc.name), - })); + console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); + return []; } private async buildCoachTypeDetails( @@ -420,11 +418,10 @@ export class SearchService { const classes = Array.from(classNames) .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); - return { - name: className, - baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className), - }; + if (!fareInfo) return null; + return { name: className, baseFareMinor: fareInfo.baseFareMinor }; }) + .filter((c): c is { name: string; baseFareMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ @@ -442,22 +439,28 @@ export class SearchService { }); } - private getDefaultFareForClass(className: string): number { - const defaults: Record = { - 'Economy Regular': 35000, - 'Economy Bed': 49000, - 'VIP Bed': 63000, - }; - return defaults[className] ?? 35000; + private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise { + if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`); + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + seatClassId, + }); + return fare.baseFarePerPassengerMinor; } - private defaultFare(seatClassName: string): number { - const fares: Record = { - 'Economy Regular': 45000, - 'Economy Bed': 65000, - 'VIP Bed': 95000, - }; - return fares[seatClassName] ?? 45000; + private getDefaultFareForClass(_className: string): never { + throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); + } + + private defaultFare(_seatClassName: string): never { + throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); } private selectBestFareRule( diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts index 2a3b84539..2c4215dc8 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts @@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => { mockPrisma.seat.findMany.mockResolvedValue(mockSeats); - const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE'); + const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR'); expect(result).toHaveLength(2); }); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index e7760b565..7d4acea76 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -30,6 +30,10 @@ export class TicketsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' }) + @ApiQuery({ name: 'skip', required: false }) + @ApiQuery({ name: 'take', required: false }) listTickets( @Query('search') search?: string, @Query('status') status?: string, @@ -77,14 +81,26 @@ 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'], description: 'Required for round-trip bookings' }, + }, + }, }) validate( @Param('bookingRef') ref: string, @Body('validatorId') validatorId: string, - @Body('gateId') gateId?: string + @Body('gateId') gateId?: string, + @Body('leg') leg?: 'OUTBOUND' | 'RETURN', ) { - return this.service.validate(ref, validatorId, gateId); + return this.service.validate(ref, validatorId, gateId, leg); } @Get(':ticketId/validation-logs') @@ -106,7 +122,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'] }, + }, + }, + }, + }, + }, + }) validateOfflineBatch(@Body() body: { validations: any[] }) { return this.service.validateOfflineBatch(body.validations); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index de9bdf4b5..fa4508dd1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -7,6 +7,7 @@ interface OfflineValidation { validatorId: string; gateId?: string; validatedAt: string; + leg?: 'OUTBOUND' | 'RETURN'; } @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, @@ -213,22 +218,67 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string) { + async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') { 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'); - if (ticket.validatedAt) { + + const isRoundTrip = booking.bookingType === 'ROUND_TRIP'; + // For one-way bookings use the original single-validation guard + if (!isRoundTrip) { + 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: new Date(), validatorId } }); await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } + data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }, }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } }); + + // Round-trip: track which leg is being boarded + const resolvedLeg = leg ?? 'OUTBOUND'; + const now = new Date(); + const bookingData: Record = {}; + + if (resolvedLeg === 'OUTBOUND') { + if ((booking as any).outboundBoardedAt) { + await this.prisma.gateValidationLog.create({ + data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any, + }); + throw new BadRequestException('Outbound leg already used'); + } + bookingData.outboundBoardedAt = now; + // Stamp the ticket's first validation + if (!ticket.validatedAt) { + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + } + } else { + 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; + } + + // Derive the new composite status + 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'; // return pending/no-show + 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, status: 'APPROVED' } + data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any, }); - return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } async getValidationLogs(ticketId: string) { @@ -256,6 +306,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 +317,13 @@ export class TicketsService { const processedRefs = new Set(); for (const v of validations) { - if (processedRefs.has(v.bookingRef)) { + const offlineLeg = v.leg; + const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef; + if (processedRefs.has(dedupKey)) { results.duplicate++; continue; } - processedRefs.add(v.bookingRef); + processedRefs.add(dedupKey); try { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } }); @@ -286,11 +340,21 @@ export class TicketsService { continue; } - if (ticket.validatedAt) { + if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') { results.duplicate++; continue; } + // For round-trip, check per-leg duplication + if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) { + const alreadyUsed = + offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt; + if (alreadyUsed) { + results.duplicate++; + continue; + } + } + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId }, @@ -301,11 +365,24 @@ 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 returnLegStatus for round-trip offline validations + if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) { + const bookingData: Record = + offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) }; + const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; + const returnUsed = offlineLeg === '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 }); + } + results.success++; } catch (err) { results.failed++; diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index ce8541f29..36fd20281 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -140,7 +140,7 @@ export default function BookingsPage() { }, { key: 'bookingType', - label: 'Class', + label: 'Type', sortable: true, render: (booking: any) => booking.bookingType || 'ONE_WAY', }, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index bf4f88a98..d755e5d2f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -145,7 +145,7 @@ export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const queryClient = useQueryClient(); // Coach Types Queries @@ -252,12 +252,17 @@ export default function CoachesPage() { }; const confirmDelete = async () => { - if (deleteConfirm.item?.isCoachType) { - await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); - } else { - await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + try { + if (deleteConfirm.item?.isCoachType) { + await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); + } else { + await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + } + setDeleteConfirm({ isOpen: false, item: null }); + } catch (err: any) { + const msg = err?.response?.data?.message || err?.message || 'Delete failed'; + setDeleteConfirm((prev) => ({ ...prev, error: msg })); } - setDeleteConfirm({ isOpen: false, item: null }); }; const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || []; @@ -536,6 +541,8 @@ export default function CoachesPage() { message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`} confirmText="Delete" isDanger={true} + isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} + error={deleteConfirm.error} warning={ deleteConfirm.item?.isCoachType ? 'This coach type may have coaches assigned. Deleting it may impact these systems.' diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 0f56f0e15..7c381e90b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -362,12 +362,16 @@ export default function RoutesPage() { type="text" name="code" className="input" - value={generateRouteCode(originStationId, destinationStationId)} - readOnly + defaultValue={editingRoute ? editingRoute.code : undefined} + key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`} + placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'} required - placeholder="Select stations to generate" - disabled={!!editingRoute} /> + {!editingRoute && originStationId && destinationStationId && ( +

+ Suggested: +

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

+ Suggested: +

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

Boarded At

-

{formatDateTime(selectedTicket.boardedAt)}

+

Validated At

+

{formatDateTime(selectedTicket.validatedAt)}

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

Round-Trip Leg Status

+
+
+

Leg Status

+

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

+
+
+

Outbound Boarded

+

{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}

+
+
+

Return Boarded

+

{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}

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

{error}

+
+ )}
{cancelText}