From 87a423c859fe95f968c2a628e2877b0641056978 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 24 May 2026 16:20:55 +0300 Subject: [PATCH] Nationality, guest booking, waafi adapter, overall booking flow updates --- README.md | 141 ++-- apps/edr-passenger-api/.env.example | 10 +- apps/edr-passenger-api/REFACTORING_SUMMARY.md | 199 ----- .../migration.sql | 5 + .../migration.sql | 28 + .../migrations/add_guest_booking_support.sql | 30 + apps/edr-passenger-api/prisma/schema.prisma | 24 + apps/edr-passenger-api/prisma/seed.ts | 690 ++++++++++++++---- apps/edr-passenger-api/src/app.module.ts | 3 +- .../src/config/waafi.config.ts | 10 + apps/edr-passenger-api/src/main.ts | 104 ++- .../modules/bookings/bookings.controller.ts | 76 +- .../src/modules/bookings/bookings.dto.ts | 19 +- .../src/modules/bookings/bookings.module.ts | 5 +- .../src/modules/bookings/bookings.service.ts | 91 ++- .../src/modules/bookings/guest-booking.dto.ts | 91 +++ .../modules/bookings/guest-booking.service.ts | 333 +++++++++ .../passengers/passengers.controller.ts | 115 ++- .../src/modules/passengers/passengers.dto.ts | 51 +- .../modules/passengers/passengers.module.ts | 7 +- .../modules/passengers/passengers.service.ts | 28 +- .../modules/payments/payments.controller.ts | 45 +- .../src/modules/payments/payments.dto.ts | 19 +- .../src/modules/payments/payments.module.ts | 4 + .../src/modules/payments/payments.service.ts | 3 + .../src/modules/payments/payments.types.ts | 4 +- .../payments/providers/waafi.provider.ts | 276 +++++++ .../webhooks/waafi-webhook.service.ts | 105 +++ .../payments/webhooks/webhooks.controller.ts | 38 +- .../src/modules/schedules/schedules.dto.ts | 3 +- .../modules/schedules/schedules.service.ts | 3 +- .../src/modules/search/search.controller.ts | 38 +- .../src/modules/search/search.dto.ts | 16 +- .../src/modules/search/search.service.ts | 95 ++- .../src/modules/seats/seats.controller.ts | 23 +- .../modules/stations/stations.controller.ts | 22 +- .../src/modules/tickets/tickets.controller.ts | 16 +- 37 files changed, 2284 insertions(+), 486 deletions(-) delete mode 100644 apps/edr-passenger-api/REFACTORING_SUMMARY.md create mode 100644 apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql create mode 100644 apps/edr-passenger-api/src/config/waafi.config.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts diff --git a/README.md b/README.md index 0a47b6a90..63e3c6b8b 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,18 @@ Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger bookin - **DJF** (Djiboutian Franc) - Display option - **USD** (US Dollar) - Display option - Real-time exchange rate conversion -- **Booking Management** - Complete booking lifecycle with modification, cancellation, refunds, and fare breakdown +- **Booking Management** - Complete booking lifecycle: + - **Guest Booking**: Book without login, optional account creation + - **Saved Passengers**: Store passenger details for quick rebooking + - Modification, cancellation, refunds, and fare breakdown + - Multi-segment journey support - **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling -- **Seat Management** - Real-time seat inventory, holds, releases, and blocking with coach/class management +- **Seat Management** - Real-time seat inventory: + - Seat holds with 15-minute expiry + - Seat releases and blocking with coach/class management + - Segment-based seat availability (partial journey bookings) + - Auto-assign seats with contiguous algorithm + - CSV import/export for seat configurations - **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs - **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation - **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences @@ -173,7 +182,7 @@ pnpm --filter @edr/passenger-api run prisma:seed - 21 Stations (Complete Ethiopian-Djibouti Railway with country codes) - 1 Route with 21 stops and fare rules - 2 Train services with 4 trips -- 360 seats across 12 coaches (Economy, Bed, VIP classes) +- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes) - 3 User accounts (Admin, Passenger, Agent) - Fare rules for ADULT and CHILD passenger categories - Currency exchange rates (ETB, DJF, USD) @@ -182,6 +191,7 @@ pnpm --filter @edr/passenger-api run prisma:seed - Promotions and FAQ content - Menu items and station crowd signals - Fraud detection rules +- Saved passenger profiles for testing ### 5. Start Development Server ```bash @@ -228,25 +238,28 @@ The API uses two authentication schemes: | Module | Base Path | Auth Type | Description | |--------|-----------|-----------|-------------| | **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset | -| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | -| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | -| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics | -| **Stations** | `/stations` | JWT | Station directory and information | -| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | -| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | -| **Search** | `/search` | JWT | Trip search, availability, fare quotes | +| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles | +| **Search** | `/search` | Public | Trip search, availability, fare quotes | +| **Stations** | `/stations` | Public/JWT | Station directory and information | | **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking | -| **Bookings** | `/bookings` | JWT | Create, modify, cancel bookings | +| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings | | **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds | | **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation | -| **Passengers** | `/passengers` | JWT | Profile management, traveler profiles | | **Notifications** | `/notifications` | JWT | In-app notifications, preferences | | **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption | | **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history | -| **Promotions** | `/promos` | JWT | Active promotions, promo code validation | -| **Live Tracking** | `/live` | JWT | Real-time trip status, crowd signals | -| **Support** | `/support` | JWT | FAQ, chat conversations | +| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation | +| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals | +| **Support** | `/support` | Public/JWT | FAQ, chat conversations | | **Dashboard** | `/dashboard` | JWT | Home screen aggregated data | +| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops | +| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | +| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | +| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration | +| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking | +| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | +| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | +| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics | ### Example API Calls @@ -280,30 +293,60 @@ Content-Type: application/json } ``` -#### 2b. Agent Booking (IAM Auth) +#### 3. Verify Ethiopian National ID (Verifayda) ```bash -POST /agents/bookings -Authorization: Bearer +POST /passengers/verify-fayda Content-Type: application/json { - "tripId": "uuid", - "seats": [...], - "paymentMethod": "CASH", - "cashReceived": 50000 + "nationalId": "ET123456789" +} + +# Response with verified passenger data +{ + "verified": true, + "passengerData": { + "fullName": "Abebe Kebede", + "dateOfBirth": "1985-03-15T00:00:00.000Z", + "gender": "Male", + "nationality": "Ethiopian" + } } ``` -#### 3. Search Trips +#### 4. Register International Passenger ```bash -GET /search/trips?originStationId={id}&destinationStationId={id}&date=2026-06-15&adultCount=2&childCount=1 -Authorization: Bearer {token} +POST /passengers/register-international +Content-Type: application/json + +{ + "passengerName": "John Smith", + "dateOfBirth": "1990-07-20", + "passportNumber": "P1234567", + "passportCountry": "Kenya", + "nationality": "Kenyan", + "phone": "+254712345678", + "email": "john@example.com" +} ``` -#### 4. Get Fare Quote +#### 5. Search Trips +```bash +POST /search +Content-Type: application/json + +{ + "originStationId": "uuid", + "destinationStationId": "uuid", + "date": "2026-06-15", + "adultCount": 2, + "childCount": 1 +} +``` + +#### 6. Get Fare Quote ```bash POST /search/fare-quote -Authorization: Bearer {token} Content-Type: application/json { @@ -330,17 +373,16 @@ Content-Type: application/json } ``` -#### 5. Create Booking +#### 7. Guest Booking (No Login Required) ```bash -POST /bookings -Authorization: Bearer {token} +POST /bookings/guest Content-Type: application/json { "tripId": "uuid", "holdId": "uuid", "serviceClass": "ECONOMY_REGULAR", - "displayCurrency": "DJF", + "displayCurrency": "ETB", "passengers": [ { "seatId": "uuid", @@ -348,23 +390,25 @@ Content-Type: application/json "dateOfBirth": "1985-03-15", "idDocumentType": "NATIONAL_ID", "idDocumentNumber": "ET123456789" - }, - { - "seatId": "uuid", - "passengerName": "Sara Abebe", - "dateOfBirth": "2023-01-10", - "idDocumentType": "NATIONAL_ID", - "idDocumentNumber": "ET987654321" - }, - { - "seatId": "uuid", - "passengerName": "John Smith", - "dateOfBirth": "1990-07-20", - "idDocumentType": "PASSPORT", - "passportNumber": "P1234567", - "passportCountry": "Kenya" } - ] + ], + "createAccount": false, + "savePassengerDetails": true, + "deviceId": "device-uuid" +} +``` + +#### 8. Agent Booking (IAM Auth) +```bash +POST /agents/bookings +Authorization: Bearer +Content-Type: application/json + +{ + "tripId": "uuid", + "seats": [...], + "paymentMethod": "CASH", + "cashReceived": 50000 } ``` @@ -453,6 +497,9 @@ apps/edr-passenger-api/ - `SeatBlock`, `SeatHold` - `CurrencyExchangeRate` (Multi-currency) - `VerifaydaVerification` (National ID verification) +- `SavedPassengerProfile` (Guest booking) +- `SeatClass` (Seat class configuration) +- `JourneySegment` (Multi-segment journeys) ## πŸ”§ Available Scripts diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index d0e64691d..75d2df681 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -65,8 +65,16 @@ CARD_WEBHOOK_SECRET= CARD_WEBHOOK_URL= CARD_RETURN_URL= +# Waafi (Djibouti Mobile Money) +WAAFI_BASE_URL=https://api.waafipay.net +WAAFI_MERCHANT_UID= +WAAFI_API_USER_ID= +WAAFI_API_KEY= +WAAFI_NOTIFY_URL= +WAAFI_RETURN_URL= + # Payment Configuration -PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET +PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI # Session Configuration SESSION_INACTIVITY_MINUTES=30 diff --git a/apps/edr-passenger-api/REFACTORING_SUMMARY.md b/apps/edr-passenger-api/REFACTORING_SUMMARY.md deleted file mode 100644 index 30648fa3f..000000000 --- a/apps/edr-passenger-api/REFACTORING_SUMMARY.md +++ /dev/null @@ -1,199 +0,0 @@ -# Train Reservation System Refactoring - Complete - -## βœ… Refactoring Summary - -Successfully refactored the train reservation system from an incorrect tight-coupling model to a flexible, realistic railway architecture. - ---- - -## πŸ”„ Architecture Changes - -### Before (Incorrect) -``` -TrainService β†’ Trip β†’ Coach β†’ Seat -``` -- Coaches were permanently bound to specific trips -- No reusability of physical coaches -- Inflexible train composition - -### After (Correct) -``` -Train β†’ TrainSchedule ↔ CoachAssignment ↔ Coach β†’ Seat -``` -- **Train**: Logical service entity (e.g., "Express 301") -- **TrainSchedule**: Specific journey with date/time -- **Coach**: Physical reusable railway carriage -- **CoachAssignment**: Join table linking schedules to coaches -- **Seat**: Belongs strictly to physical coach - ---- - -## πŸ“‹ Files Modified - -### Schema & Database -- βœ… `prisma/schema.prisma` - Complete entity redesign -- βœ… `prisma/seed.ts` - Rewritten for new architecture - -### DTOs -- βœ… `fleet/fleet.dto.ts` - New Train/Coach/Assignment DTOs -- βœ… `schedules/schedules.dto.ts` - TrainSchedule DTOs -- βœ… `bookings/bookings.dto.ts` - scheduleId instead of tripId - -### Services -- βœ… `fleet/fleet.service.ts` - Physical coach management -- βœ… `fleet/fleet.controller.ts` - New endpoints -- βœ… `schedules/schedules.service.ts` - TrainSchedule operations -- βœ… `schedules/schedules.controller.ts` - Updated routes -- βœ… `bookings/bookings.service.ts` - scheduleId references -- βœ… `seats/seats.service.ts` - CoachAssignment queries -- βœ… `search/search.service.ts` - TrainSchedule search -- βœ… `segments/segments.service.ts` - scheduleId throughout -- βœ… `segments/enhanced-seats.service.ts` - Fixed references -- βœ… `passengers/passengers.service.ts` - schedule.train -- βœ… `live/live.service.ts` - TrainSchedule live tracking -- βœ… `live/live.controller.ts` - scheduleId routes -- βœ… `dashboard/dashboard.service.ts` - schedule references -- βœ… `reports/reports.service.ts` - Occupancy with assignments - ---- - -## πŸ—„οΈ Database Schema Changes - -### New Models -```prisma -model Train { - id String @id @default(uuid()) - number String @unique - name String - schedules TrainSchedule[] -} - -model TrainSchedule { - id String @id @default(uuid()) - trainId String - departureAt DateTime - train Train @relation(...) - coachAssignments CoachAssignment[] -} - -model Coach { - id String @id @default(uuid()) - coachNumber String @unique // Physical identifier - label String - seatClassId String - mode String // 'seat', 'bed', 'convertible' - totalUnits Int - seats Seat[] - assignments CoachAssignment[] -} - -model CoachAssignment { - id String @id @default(uuid()) - scheduleId String - coachId String - positionNumber Int - schedule TrainSchedule @relation(...) - coach Coach @relation(...) -} -``` - -### Renamed Models -- `TrainService` β†’ `Train` -- `Trip` β†’ `TrainSchedule` -- `TripStopTime.tripId` β†’ `scheduleId` -- `TripLiveStatus.tripId` β†’ `scheduleId` -- `Booking.tripId` β†’ `scheduleId` -- `SeatHold.tripId` β†’ `scheduleId` -- `MenuItem.tripId` β†’ `scheduleId` -- `JourneySegment.tripId` β†’ `scheduleId` - ---- - -## 🎯 Key Benefits - -1. **Reusability**: Physical coaches can be assigned to different schedules -2. **Flexibility**: Train composition can change per schedule -3. **Realistic**: Matches real-world railway operations -4. **Maintainability**: Clear separation of logical vs physical entities -5. **Scalability**: Easy to add/remove coaches from schedules - ---- - -## πŸš‚ Example Usage - -### Creating a Physical Coach -```typescript -const coach = await prisma.coach.create({ - data: { - coachNumber: 'C-A1', - label: 'A', - seatClassId: economyClassId, - mode: 'seat', - totalUnits: 60, - }, -}); -``` - -### Assigning Coach to Schedule -```typescript -await prisma.coachAssignment.create({ - data: { - scheduleId: schedule1.id, - coachId: coach.id, - positionNumber: 1, - }, -}); -``` - -### Querying Schedule with Coaches -```typescript -const schedule = await prisma.trainSchedule.findUnique({ - where: { id: scheduleId }, - include: { - train: true, - coachAssignments: { - include: { - coach: { - include: { seats: true, seatClass: true }, - }, - }, - orderBy: { positionNumber: 'asc' }, - }, - }, -}); -``` - ---- - -## πŸ”‘ Seed Data - -- **2 Trains**: Express 301, Express 302 -- **6 Physical Coaches**: C-A1, C-B1, C-C1, C-A2, C-B2, C-C2 -- **4 Train Schedules**: With flexible coach assignments -- **3 Seat Classes**: Economy Regular, Economy Bed, VIP Bed -- **Users**: Admin, Passenger (with wallet/loyalty), Agent - ---- - -## ✨ Migration Status - -βœ… Schema pushed to database successfully -βœ… Seed data populated -βœ… All services updated -βœ… All controllers updated -βœ… All DTOs updated - ---- - -## πŸ“ Notes - -- Coaches are now reusable physical entities -- Same coach can serve different schedules at different times -- Seats belong to coaches, not schedules -- CoachAssignment provides the many-to-many relationship -- All references to `tripId` changed to `scheduleId` -- All references to `service` changed to `train` - ---- - -**Refactoring completed successfully! πŸŽ‰** diff --git a/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql new file mode 100644 index 000000000..d5b4c9fa2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI'; + +-- AlterTable +ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql new file mode 100644 index 000000000..1698c0c23 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql @@ -0,0 +1,28 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "contactEmail" TEXT, +ADD COLUMN "contactPhone" TEXT; + +-- CreateTable +CREATE TABLE "SavedPassengerProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "deviceId" TEXT, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "idDocumentType" "IdDocumentType" NOT NULL, + "passportNumber" TEXT, + "passportCountry" TEXT, + "nationality" TEXT, + "phone" TEXT, + "email" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId"); + +-- CreateIndex +CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId"); diff --git a/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql b/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql new file mode 100644 index 000000000..fc3d83a8d --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql @@ -0,0 +1,30 @@ +-- Add contact fields to Booking table +ALTER TABLE "passenger"."Booking" +ADD COLUMN "contactEmail" TEXT, +ADD COLUMN "contactPhone" TEXT; + +-- Create SavedPassengerProfile table +CREATE TABLE "passenger"."SavedPassengerProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "deviceId" TEXT, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "idDocumentType" "passenger"."IdDocumentType" NOT NULL, + "passportNumber" TEXT, + "passportCountry" TEXT, + "nationality" TEXT, + "phone" TEXT, + "email" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id") +); + +-- Create indexes +CREATE INDEX "SavedPassengerProfile_userId_idx" ON "passenger"."SavedPassengerProfile"("userId"); +CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "passenger"."SavedPassengerProfile"("deviceId"); + +-- Add comment +COMMENT ON TABLE "passenger"."SavedPassengerProfile" IS 'Stores passenger details for quick rebooking (by userId or deviceId)'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 046faf2b1..a6ed4f9e0 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -103,6 +103,7 @@ enum PaymentMethodType { EBIRR CARD WALLET + WAAFI @@schema("passenger") } @@ -443,6 +444,7 @@ model FareRule { id String @id @default(uuid()) tripId String? route String? + nationality String? // Ethiopian, Djiboutian, Other seatClassId String baseFareMinor Int seatClass SeatClass @relation(fields: [seatClassId], references: [id]) @@ -468,6 +470,8 @@ model Booking { displayCurrency Currency? displayTotalMinor Int? bookingType String @default("ONE_WAY") + contactEmail String? + contactPhone String? userAgent String? source String @default("WEB") promoCode String? @@ -1221,3 +1225,23 @@ model VerifaydaVerification { @@schema("passenger") } + +model SavedPassengerProfile { + id String @id @default(uuid()) + userId String? + deviceId String? + passengerName String + dateOfBirth DateTime + idDocumentType IdDocumentType + passportNumber String? + passportCountry String? + nationality String? + phone String? + email String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([userId]) + @@index([deviceId]) + + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 4e8c1a68f..11f3af07c 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -3,182 +3,618 @@ import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); -async function main() { - console.log('🌱 Starting comprehensive seed...'); +// ============================================================================ +// SECTION 1: STATIONS (18 STATIONS) +// ============================================================================ +async function seedStations() { + console.log('πŸ“ Seeding 18 stations...'); + + const stations = [ + { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 }, + { code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 }, + { code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 }, + { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 }, + { code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 }, + { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 }, + { code: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.4500, lng: 39.4000 }, + { code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 }, + { code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2400, lng: 40.7500 }, + { code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4200, lng: 41.2000 }, + { code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 }, + { code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.2000, lng: 42.1500 }, + { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.8500, lng: 42.4000 }, + { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 }, + { code: 'DWL', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', lat: 11.4000, lng: 42.9500 }, + { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 11.1667, lng: 42.7167 }, + { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.3500, lng: 43.0500 }, + { code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 }, + ]; - // Stations - const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa Central', city: 'Addis Ababa', countryCode: 'ET', lat: 9.0054, lng: 38.7636 } }); - const sebeta = await prisma.station.upsert({ where: { code: 'SBT' }, update: {}, create: { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 } }); - const adama = await prisma.station.upsert({ where: { code: 'ADM' }, update: {}, create: { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 } }); - const awash = await prisma.station.upsert({ where: { code: 'AWS' }, update: {}, create: { code: 'AWS', name: 'Awash', city: 'Awash', countryCode: 'ET', lat: 8.9833, lng: 40.1667 } }); - const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 } }); - const aysha = await prisma.station.upsert({ where: { code: 'AYS' }, update: {}, create: { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 } }); - const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } }); + const created = []; + for (const station of stations) { + const s = await prisma.station.upsert({ + where: { code: station.code }, + update: {}, + create: station, + }); + created.push(s); + } - // Seat Classes - const scEconomyRegular = await prisma.seatClass.upsert({ where: { name: 'Economy Regular' }, update: {}, create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true } }); - const scEconomyBed = await prisma.seatClass.upsert({ where: { name: 'Economy Bed' }, update: {}, create: { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 65000, isActive: true } }); - const scVipBed = await prisma.seatClass.upsert({ where: { name: 'VIP Bed' }, update: {}, create: { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 95000, isActive: true } }); + console.log(` βœ… Created ${created.length} stations`); + return created; +} - // Trains (logical services) - const train301 = await prisma.train.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301', description: 'Addis-Djibouti Express' } }); - const train302 = await prisma.train.upsert({ where: { number: '302' }, update: {}, create: { number: '302', name: 'Express 302', description: 'Djibouti-Addis Express' } }); +// ============================================================================ +// SECTION 2: SEAT CLASSES +// ============================================================================ +async function seedSeatClasses() { + console.log('πŸ’Ί Seeding seat classes...'); + + const classes = [ + { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 25000 }, + { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 35000 }, + { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 55000 }, + ]; - // Physical Coaches (reusable) - const coachA1 = await prisma.coach.upsert({ where: { coachNumber: 'C-A1' }, update: {}, create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } }); - const coachB1 = await prisma.coach.upsert({ where: { coachNumber: 'C-B1' }, update: {}, create: { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } }); - const coachC1 = await prisma.coach.upsert({ where: { coachNumber: 'C-C1' }, update: {}, create: { coachNumber: 'C-C1', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } }); - const coachA2 = await prisma.coach.upsert({ where: { coachNumber: 'C-A2' }, update: {}, create: { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } }); - const coachB2 = await prisma.coach.upsert({ where: { coachNumber: 'C-B2' }, update: {}, create: { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } }); - const coachC2 = await prisma.coach.upsert({ where: { coachNumber: 'C-C2' }, update: {}, create: { coachNumber: 'C-C2', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } }); + const created = []; + for (const cls of classes) { + const c = await prisma.seatClass.upsert({ + where: { name: cls.name }, + update: {}, + create: { ...cls, isActive: true }, + }); + created.push(c); + } - // Create seats for each physical coach - for (const coach of [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2]) { + console.log(` βœ… Created ${created.length} seat classes`); + return created; +} + +// ============================================================================ +// SECTION 3: TRAINS +// ============================================================================ +async function seedTrains() { + console.log('πŸš‚ Seeding trains...'); + + const trains = [ + { number: '301', name: 'Express 301', description: 'Sebeta-Nagad Express' }, + { number: '302', name: 'Express 302', description: 'Nagad-Sebeta Express' }, + { number: '303', name: 'Local 303', description: 'Regional Service' }, + ]; + + const created = []; + for (const train of trains) { + const t = await prisma.train.upsert({ + where: { number: train.number }, + update: {}, + create: train, + }); + created.push(t); + } + + console.log(` βœ… Created ${created.length} trains`); + return created; +} + +// ============================================================================ +// SECTION 4: COACHES & SEATS +// ============================================================================ +async function seedCoachesAndSeats(seatClasses: any[]) { + console.log('πŸšƒ Seeding coaches and seats...'); + + const [scEconomy, scEconomyBed, scVip] = seatClasses; + + const coachConfigs = [ + { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 }, + { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 }, + { coachNumber: 'C-C1', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 }, + { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 }, + { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 }, + { coachNumber: 'C-C2', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 }, + ]; + + const coaches = []; + for (const config of coachConfigs) { + const coach = await prisma.coach.upsert({ + where: { coachNumber: config.coachNumber }, + update: {}, + create: config, + }); + coaches.push(coach); + + // Create seats for this coach const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } }); if (existingSeats === 0) { const seats = []; - const rows = Math.ceil(coach.totalUnits / 4); + const rows = Math.ceil(config.totalUnits / 4); for (let row = 1; row <= rows; row++) { for (const col of ['A', 'B', 'C', 'D']) { - if (seats.length >= coach.totalUnits) break; - seats.push({ coachId: coach.id, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}`, kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind }); + if (seats.length >= config.totalUnits) break; + seats.push({ + coachId: coach.id, + row, + col, + label: `${row}${col}`, + seatNumber: `${config.label}${row}${col}`, + kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind, + }); } } await prisma.seat.createMany({ data: seats }); } } - // Train Schedules β€” delete dependents first to avoid FK violations + console.log(` βœ… Created ${coaches.length} coaches with seats`); + return coaches; +} + +// ============================================================================ +// SECTION 5: SCHEDULES (15+ SEGMENTS) +// ============================================================================ +async function seedSchedules(trains: any[], stations: any[]) { + console.log('πŸ“… Seeding schedules with 15+ segments...'); + + const [train301, train302, train303] = trains; + const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; + + // Clean up existing schedules const existingScheduleIds = (await prisma.trainSchedule.findMany({ - where: { trainId: { in: [train301.id, train302.id] } }, + where: { trainId: { in: [train301.id, train302.id, train303.id] } }, select: { id: true }, })).map((s) => s.id); + if (existingScheduleIds.length > 0) { await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } }); await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); await prisma.trainSchedule.deleteMany({ where: { id: { in: existingScheduleIds } } }); } - const schedule1 = await prisma.trainSchedule.create({ - data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-15T08:00:00Z'), arrivalAt: new Date('2026-06-15T20:00:00Z'), durationMinutes: 720, stopsCount: 6 }, - }); - const schedule2 = await prisma.trainSchedule.create({ - data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-16T09:00:00Z'), arrivalAt: new Date('2026-06-16T21:30:00Z'), durationMinutes: 750, stopsCount: 5 }, - }); - const schedule3 = await prisma.trainSchedule.create({ - data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-17T07:30:00Z'), arrivalAt: new Date('2026-06-17T19:45:00Z'), durationMinutes: 735, stopsCount: 5 }, - }); - const schedule4 = await prisma.trainSchedule.create({ - data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-18T08:30:00Z'), arrivalAt: new Date('2026-06-18T21:00:00Z'), durationMinutes: 750, stopsCount: 5 }, - }); - // Assign coaches to schedules - await prisma.coachAssignment.createMany({ - data: [ - { scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 }, - { scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 }, - { scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 }, - { scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 }, - { scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 }, - { scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 }, - { scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 }, - { scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 }, - { scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 }, - { scheduleId: schedule4.id, coachId: coachA2.id, positionNumber: 1 }, - { scheduleId: schedule4.id, coachId: coachB2.id, positionNumber: 2 }, - { scheduleId: schedule4.id, coachId: coachC2.id, positionNumber: 3 }, - ], - skipDuplicates: true, - }); + const schedules = [ + // Full route: Sebeta to Nagad (18 stations) + { + trainId: train301.id, + originStationId: sebeta.id, + destinationStationId: nagad.id, + departureAt: new Date('2026-06-15T06:00:00Z'), + arrivalAt: new Date('2026-06-15T22:00:00Z'), + durationMinutes: 960, + stopsCount: 18, + }, + // Return route: Nagad to Sebeta + { + trainId: train302.id, + originStationId: nagad.id, + destinationStationId: sebeta.id, + departureAt: new Date('2026-06-16T07:00:00Z'), + arrivalAt: new Date('2026-06-16T23:30:00Z'), + durationMinutes: 990, + stopsCount: 18, + }, + // Regional service: Sebeta to Diredawa + { + trainId: train303.id, + originStationId: sebeta.id, + destinationStationId: diredawa.id, + departureAt: new Date('2026-06-17T08:00:00Z'), + arrivalAt: new Date('2026-06-17T18:00:00Z'), + durationMinutes: 600, + stopsCount: 11, + }, + // Additional schedules for next day + { + trainId: train301.id, + originStationId: sebeta.id, + destinationStationId: nagad.id, + departureAt: new Date('2026-06-18T06:30:00Z'), + arrivalAt: new Date('2026-06-18T22:45:00Z'), + durationMinutes: 975, + stopsCount: 18, + }, + { + trainId: train302.id, + originStationId: nagad.id, + destinationStationId: sebeta.id, + departureAt: new Date('2026-06-19T07:15:00Z'), + arrivalAt: new Date('2026-06-19T23:45:00Z'), + durationMinutes: 990, + stopsCount: 18, + }, + ]; - // Stop Times - await prisma.tripStopTime.createMany({ - data: [ - { scheduleId: schedule1.id, stationId: addis.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T08:00:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule1.id, stationId: adama.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T09:30:00Z'), plannedDepartureAt: new Date('2026-06-15T09:45:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule1.id, stationId: awash.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T11:30:00Z'), plannedDepartureAt: new Date('2026-06-15T11:45:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule1.id, stationId: direDawa.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule1.id, stationId: aysha.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T18:00:00Z'), plannedDepartureAt: new Date('2026-06-15T18:10:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule1.id, stationId: djibouti.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), status: 'UPCOMING' }, - ], - }); - - // Fare Rules - for (const schedule of [schedule1, schedule2, schedule3, schedule4]) { - await prisma.fareRule.createMany({ - data: [ - { tripId: schedule.id, seatClassId: scEconomyRegular.id, baseFareMinor: 45000, validFrom: new Date('2026-01-01'), refundable: true }, - { tripId: schedule.id, seatClassId: scEconomyBed.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true }, - { tripId: schedule.id, seatClassId: scVipBed.id, baseFareMinor: 95000, validFrom: new Date('2026-01-01'), refundable: true }, - ], - skipDuplicates: true, - }); + const created = []; + for (const schedule of schedules) { + const s = await prisma.trainSchedule.create({ data: schedule }); + created.push(s); } - // Users + console.log(` βœ… Created ${created.length} schedules`); + return created; +} + +// ============================================================================ +// SECTION 6: COACH ASSIGNMENTS +// ============================================================================ +async function seedCoachAssignments(schedules: any[], coaches: any[]) { + console.log('πŸ”— Seeding coach assignments...'); + + const [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2] = coaches; + const [schedule1, schedule2, schedule3] = schedules; + + const assignments = [ + { scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 }, + { scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 }, + { scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 }, + { scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 }, + { scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 }, + { scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 }, + { scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 }, + { scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 }, + { scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 }, + ]; + + await prisma.coachAssignment.createMany({ data: assignments, skipDuplicates: true }); + console.log(` βœ… Created ${assignments.length} coach assignments`); +} + +// ============================================================================ +// SECTION 7: STOP TIMES (ALL 18 STATIONS) +// ============================================================================ +async function seedStopTimes(schedules: any[], stations: any[]) { + console.log('⏱️ Seeding stop times for all stations...'); + + const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; + const [schedule1, schedule2, schedule3] = schedules; + + // Full route stop times (Sebeta to Nagad) + const fullRouteStops = [ + { scheduleId: schedule1.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T06:30:00Z'), plannedDepartureAt: new Date('2026-06-15T06:35:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T07:30:00Z'), plannedDepartureAt: new Date('2026-06-15T07:40:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T08:15:00Z'), plannedDepartureAt: new Date('2026-06-15T08:25:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:15:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:45:00Z'), plannedDepartureAt: new Date('2026-06-15T09:50:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T10:30:00Z'), plannedDepartureAt: new Date('2026-06-15T10:45:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-15T12:00:00Z'), plannedDepartureAt: new Date('2026-06-15T12:10:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T13:30:00Z'), plannedDepartureAt: new Date('2026-06-15T13:40:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: arawa.id, sequence: 12, plannedArrivalAt: new Date('2026-06-15T16:30:00Z'), plannedDepartureAt: new Date('2026-06-15T16:35:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: adigala.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T17:45:00Z'), plannedDepartureAt: new Date('2026-06-15T17:50:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: aysha.id, sequence: 14, plannedArrivalAt: new Date('2026-06-15T18:30:00Z'), plannedDepartureAt: new Date('2026-06-15T18:40:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: dawanle.id, sequence: 15, plannedArrivalAt: new Date('2026-06-15T19:15:00Z'), plannedDepartureAt: new Date('2026-06-15T19:20:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: alisabieh.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), plannedDepartureAt: new Date('2026-06-15T20:05:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: holhol.id, sequence: 17, plannedArrivalAt: new Date('2026-06-15T21:00:00Z'), plannedDepartureAt: new Date('2026-06-15T21:05:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule1.id, stationId: nagad.id, sequence: 18, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' as const }, + ]; + + // Regional route stop times (Sebeta to Diredawa) + const regionalStops = [ + { scheduleId: schedule3.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-17T08:00:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-17T08:30:00Z'), plannedDepartureAt: new Date('2026-06-17T08:35:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-17T09:00:00Z'), plannedDepartureAt: new Date('2026-06-17T09:05:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-17T09:30:00Z'), plannedDepartureAt: new Date('2026-06-17T09:40:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-17T10:15:00Z'), plannedDepartureAt: new Date('2026-06-17T10:25:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-17T11:00:00Z'), plannedDepartureAt: new Date('2026-06-17T11:15:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-17T11:45:00Z'), plannedDepartureAt: new Date('2026-06-17T11:50:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-17T12:30:00Z'), plannedDepartureAt: new Date('2026-06-17T12:45:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-17T14:00:00Z'), plannedDepartureAt: new Date('2026-06-17T14:10:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-17T15:30:00Z'), plannedDepartureAt: new Date('2026-06-17T15:40:00Z'), status: 'UPCOMING' as const }, + { scheduleId: schedule3.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-17T18:00:00Z'), status: 'UPCOMING' as const }, + ]; + + const allStops = [...fullRouteStops, ...regionalStops]; + await prisma.tripStopTime.createMany({ data: allStops }); + console.log(` βœ… Created ${allStops.length} stop times`); +} + +// ============================================================================ +// SECTION 8: FARE RULES (COMPREHENSIVE SEGMENTS) +// ============================================================================ +async function seedFareRules(schedules: any[], seatClasses: any[]) { + console.log('πŸ’° Seeding comprehensive fare rules...'); + + const [scEconomy, scEconomyBed, scVip] = seatClasses; + + // Segment-based fare rules (15+ segments) + const segmentRules = [ + // Short segments (1-3 stations) + { route: 'SBT-LBU', seatClassId: scEconomy.id, baseFareMinor: 5000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'LBU-IND', seatClassId: scEconomy.id, baseFareMinor: 4500, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'IND-BSH', seatClassId: scEconomy.id, baseFareMinor: 5500, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'BSH-MJO', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'MJO-ADM', seatClassId: scEconomy.id, baseFareMinor: 7000, validFrom: new Date('2026-01-01'), refundable: true }, + + // Medium segments (3-6 stations) + { route: 'SBT-BSH', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'SBT-ADM', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'ADM-MTH', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'MTH-MSO', seatClassId: scEconomy.id, baseFareMinor: 9500, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'MSO-BKE', seatClassId: scEconomy.id, baseFareMinor: 8000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'BKE-DDW', seatClassId: scEconomy.id, baseFareMinor: 7500, validFrom: new Date('2026-01-01'), refundable: true }, + + // Long segments (6+ stations) + { route: 'SBT-DDW', seatClassId: scEconomy.id, baseFareMinor: 35000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'DDW-AYS', seatClassId: scEconomy.id, baseFareMinor: 15000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'AYS-NGD', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'SBT-NGD', seatClassId: scEconomy.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true }, + + // Cross-border segments + { route: 'DDW-DWL', seatClassId: scEconomy.id, baseFareMinor: 22000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'DWL-ALI', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'ALI-HOL', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'HOL-NGD', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true }, + ]; + + // Add Economy Bed prices (40% higher) + const bedRules = segmentRules.map(rule => ({ + ...rule, + seatClassId: scEconomyBed.id, + baseFareMinor: Math.round(rule.baseFareMinor * 1.4), + })); + + // Add VIP prices (80% higher) + const vipRules = segmentRules.map(rule => ({ + ...rule, + seatClassId: scVip.id, + baseFareMinor: Math.round(rule.baseFareMinor * 1.8), + })); + + const allRules = [...segmentRules, ...bedRules, ...vipRules]; + await prisma.fareRule.createMany({ data: allRules, skipDuplicates: true }); + + // Nationality-specific discounts + const nationalityRules = [ + // Ethiopian nationals - 10% discount on domestic routes + { route: 'SBT-DDW', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 31500, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'SBT-ADM', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 16200, validFrom: new Date('2026-01-01'), refundable: true }, + + // Djiboutian nationals - 5% discount on cross-border routes + { route: 'DDW-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 42750, validFrom: new Date('2026-01-01'), refundable: true }, + { route: 'SBT-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 61750, validFrom: new Date('2026-01-01'), refundable: true }, + ]; + + await prisma.fareRule.createMany({ data: nationalityRules, skipDuplicates: true }); + + console.log(` βœ… Created ${allRules.length + nationalityRules.length} fare rules`); +} + +// ============================================================================ +// SECTION 9: USERS & PASSENGERS +// ============================================================================ +async function seedUsers() { + console.log('πŸ‘₯ Seeding users...'); + const hash = await bcrypt.hash('password123', 10); const adminHash = await bcrypt.hash('admin123', 10); const agentHash = await bcrypt.hash('agent123', 10); - await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: adminHash, role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' } }); - const passengerUser = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash, nationality: 'Ethiopian', nationalId: 'ET123456789' } }); - let passenger = await prisma.passenger.findUnique({ where: { userId: passengerUser.id } }); - if (!passenger) { - passenger = await prisma.passenger.create({ data: { userId: passengerUser.id } }); - await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } }); - await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } }); + // Admin + await prisma.user.upsert({ + where: { email: 'admin@edr-platform.com' }, + update: { passwordHash: adminHash, role: 'ADMIN' }, + create: { + fullName: 'EDR Admin', + email: 'admin@edr-platform.com', + phone: '+251900000000', + passwordHash: adminHash, + role: 'ADMIN', + }, + }); + + // Ethiopian Passenger + const ethiopianUser = await prisma.user.upsert({ + where: { email: 'abebe@email.com' }, + update: {}, + create: { + fullName: 'Abebe Kebede', + email: 'abebe@email.com', + phone: '+251912345678', + passwordHash: hash, + nationality: 'Ethiopian', + nationalId: 'ET123456789', + }, + }); + + let ethiopianPassenger = await prisma.passenger.findUnique({ where: { userId: ethiopianUser.id } }); + if (!ethiopianPassenger) { + ethiopianPassenger = await prisma.passenger.create({ data: { userId: ethiopianUser.id } }); + await prisma.loyaltyAccount.create({ data: { passengerId: ethiopianPassenger.id, pointsBalance: 2450, tier: 'SILVER' } }); + await prisma.walletAccount.create({ data: { passengerId: ethiopianPassenger.id, balanceMinor: 125000 } }); } - await prisma.userPreferences.upsert({ where: { userId: passengerUser.id }, update: {}, create: { userId: passengerUser.id, language: 'en' } }); + await prisma.userPreferences.upsert({ + where: { userId: ethiopianUser.id }, + update: {}, + create: { userId: ethiopianUser.id, language: 'en' }, + }); - const agentUser = await prisma.user.upsert({ where: { email: 'agent@edr-platform.com' }, update: { passwordHash: agentHash, role: 'AGENT' }, create: { fullName: 'Agent Abebe', email: 'agent@edr-platform.com', phone: '+251911111111', passwordHash: agentHash, role: 'AGENT' } }); - await prisma.agent.upsert({ where: { userId: agentUser.id }, update: {}, create: { userId: agentUser.id, agentCode: 'AG001', stationId: addis.id, commissionRate: 5, active: true } }); + // Djiboutian Passenger + const djiboutianUser = await prisma.user.upsert({ + where: { email: 'ahmed@email.com' }, + update: {}, + create: { + fullName: 'Ahmed Hassan', + email: 'ahmed@email.com', + phone: '+25377123456', + passwordHash: hash, + nationality: 'Djiboutian', + passportNumber: 'DJ1234567', + }, + }); + let djiboutianPassenger = await prisma.passenger.findUnique({ where: { userId: djiboutianUser.id } }); + if (!djiboutianPassenger) { + djiboutianPassenger = await prisma.passenger.create({ data: { userId: djiboutianUser.id } }); + await prisma.loyaltyAccount.create({ data: { passengerId: djiboutianPassenger.id, pointsBalance: 1200, tier: 'BRONZE' } }); + await prisma.walletAccount.create({ data: { passengerId: djiboutianPassenger.id, balanceMinor: 85000 } }); + } + await prisma.userPreferences.upsert({ + where: { userId: djiboutianUser.id }, + update: {}, + create: { userId: djiboutianUser.id, language: 'fr' }, + }); + + // Agent + const agentUser = await prisma.user.upsert({ + where: { email: 'agent@edr-platform.com' }, + update: { passwordHash: agentHash, role: 'AGENT' }, + create: { + fullName: 'Agent Abebe', + email: 'agent@edr-platform.com', + phone: '+251911111111', + passwordHash: agentHash, + role: 'AGENT', + }, + }); + + const stations = await prisma.station.findMany(); + await prisma.agent.upsert({ + where: { userId: agentUser.id }, + update: {}, + create: { + userId: agentUser.id, + agentCode: 'AG001', + stationId: stations[0].id, + commissionRate: 5, + active: true, + }, + }); + + console.log(` βœ… Created 4 users (Admin, Ethiopian, Djiboutian, Agent)`); +} + +// ============================================================================ +// SECTION 10: SUPPORTING DATA +// ============================================================================ +async function seedSupportingData(seatClasses: any[]) { + console.log('πŸ“¦ Seeding supporting data...'); + // Baggage Allowance await prisma.baggageAllowance.deleteMany({}); await prisma.baggageAllowance.createMany({ data: [ - { seatClassId: scEconomyRegular.id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 }, - { seatClassId: scEconomyBed.id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 }, - { seatClassId: scVipBed.id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 }, + { seatClassId: seatClasses[0].id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 }, + { seatClassId: seatClasses[1].id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 }, + { seatClassId: seatClasses[2].id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 }, ], }); // Notification Templates - await prisma.notificationTemplate.upsert({ where: { code: 'BOOKING_CONFIRMED' }, update: {}, create: { code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{tripDate}}.', active: true } }); - await prisma.notificationTemplate.upsert({ where: { code: 'PAYMENT_SUCCESS' }, update: {}, create: { code: 'PAYMENT_SUCCESS', channel: 'SMS', bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB', active: true } }); + await prisma.notificationTemplate.upsert({ + where: { code: 'BOOKING_CONFIRMED' }, + update: {}, + create: { + code: 'BOOKING_CONFIRMED', + channel: 'EMAIL', + subject: 'Booking Confirmed', + bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{tripDate}}.', + active: true, + }, + }); + + await prisma.notificationTemplate.upsert({ + where: { code: 'PAYMENT_SUCCESS' }, + update: {}, + create: { + code: 'PAYMENT_SUCCESS', + channel: 'SMS', + bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB', + active: true, + }, + }); // Promotions - await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', subtitle: '15% off all trips', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31'), ctaLabel: 'Book Now', active: true } }); - - // FAQ - await prisma.faqArticle.deleteMany({}); - await prisma.faqCategory.deleteMany({}); - const faqBooking = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'confirmation_number' } }); - await prisma.faqArticle.createMany({ data: [{ categoryId: faqBooking.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, select origin and destination stations, choose date, select seats, and proceed to payment.', rank: 1 }] }); - - // Station Crowd Signals - await prisma.stationCrowdSignal.deleteMany({}); - await prisma.stationCrowdSignal.createMany({ data: [{ stationId: addis.id, level: 'MODERATE', label: 'Moderate', statusLabel: 'Normal operations' }, { stationId: djibouti.id, level: 'HIGH', label: 'High', statusLabel: 'Busy terminal' }] }); - - // Fraud Detection Rules - await prisma.fraudRule.upsert({ where: { type: 'VELOCITY' }, update: {}, create: { type: 'VELOCITY', enabled: true, threshold: 3, config: { windowMinutes: 60, action: 'FLAG' } } }); + await prisma.promotion.upsert({ + where: { code: 'WEEKEND15' }, + update: {}, + create: { + title: 'Weekend Sale', + subtitle: '15% off all trips', + code: 'WEEKEND15', + percentOff: 15, + validUntil: new Date('2026-12-31'), + ctaLabel: 'Book Now', + active: true, + }, + }); // Currency Exchange Rates await prisma.currencyExchangeRate.deleteMany({}); - await prisma.currencyExchangeRate.createMany({ data: [{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() }, { fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() }] }); + await prisma.currencyExchangeRate.createMany({ + data: [ + { fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() }, + { fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() }, + { fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() }, + { fromCurrency: 'DJF', toCurrency: 'ETB', rate: 0.3125, effectiveDate: new Date() }, + { fromCurrency: 'DJF', toCurrency: 'DJF', rate: 1.0, effectiveDate: new Date() }, + ], + }); - console.log('βœ… Comprehensive seed complete'); - console.log('\nπŸ“‹ Seed Summary:'); - console.log(' - 2 Trains (Express 301, Express 302)'); - console.log(' - 6 Physical Coaches (reusable across schedules)'); - console.log(' - 4 Train Schedules with coach assignments'); - console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)'); - console.log(' - 3 Users: Admin, Passenger (Silver tier + wallet), Agent'); - console.log('\nπŸ”‘ Login Credentials:'); - console.log(' Admin: admin@edr-platform.com / admin123'); - console.log(' Passenger: kelemu@email.com / password123'); - console.log(' Agent: agent@edr-platform.com / agent123'); - console.log('\nπŸš‚ Architecture: Train β†’ TrainSchedule ↔ CoachAssignment ↔ Coach β†’ Seat'); + // Fraud Rules + await prisma.fraudRule.upsert({ + where: { type: 'VELOCITY' }, + update: {}, + create: { + type: 'VELOCITY', + enabled: true, + threshold: 3, + config: { windowMinutes: 60, action: 'FLAG' }, + }, + }); + + console.log(` βœ… Created supporting data`); } -main().catch(console.error).finally(() => prisma.$disconnect()); +// ============================================================================ +// MAIN SEED FUNCTION +// ============================================================================ +async function main() { + console.log('🌱 Starting comprehensive modular seed with 18 stations...\n'); + + const stations = await seedStations(); + const seatClasses = await seedSeatClasses(); + const trains = await seedTrains(); + const coaches = await seedCoachesAndSeats(seatClasses); + const schedules = await seedSchedules(trains, stations); + await seedCoachAssignments(schedules, coaches); + await seedStopTimes(schedules, stations); + await seedFareRules(schedules, seatClasses); + await seedUsers(); + await seedSupportingData(seatClasses); + + console.log('\nβœ… Comprehensive seed complete!\n'); + console.log('πŸ“‹ Seed Summary:'); + console.log(' - 18 Stations: SBT, LBU, IND, BSH, MJO, ADM, FTO, MTH, MSO, BKE, DDW, ARW, ADG, AYS, DWL, ALI, HOL, NGD'); + console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)'); + console.log(' - 3 Trains (Express 301, Express 302, Local 303)'); + console.log(' - 6 Physical Coaches with seats'); + console.log(' - 5 Train Schedules covering full and regional routes'); + console.log(' - 15+ Fare Segments with nationality-based pricing'); + console.log(' - 4 Users: Admin, Ethiopian Passenger, Djiboutian Passenger, Agent'); + console.log(' - Currency rates: ETB, USD, DJF'); + console.log('\nπŸ”‘ Login Credentials:'); + console.log(' Admin: admin@edr-platform.com / admin123'); + console.log(' Ethiopian Passenger: abebe@email.com / password123'); + console.log(' Djiboutian Passenger: ahmed@email.com / password123'); + console.log(' Agent: agent@edr-platform.com / agent123'); + console.log('\nπŸ’° Booking Flow Ready:'); + console.log(' - Search: 18 stations with multiple route combinations'); + console.log(' - Select: 3 seat classes with dynamic pricing'); + console.log(' - Book: Complete passenger details and payment'); + console.log(' - Pay: Multiple payment methods (Telebirr, CBE, Card, Wallet)'); + console.log(' - Ticket: QR code generation and validation'); + console.log('\nπŸš‚ Sample Routes:'); + console.log(' - Full Route: Sebeta β†’ Nagad (18 stations, 16 hours)'); + console.log(' - Regional: Sebeta β†’ Diredawa (11 stations, 10 hours)'); + console.log(' - Short: Sebeta β†’ Adama (6 stations, 3 hours)'); + console.log(' - Cross-border: Diredawa β†’ Nagad (8 stations, 7 hours)'); +} + +main() + .catch((e) => { + console.error('❌ Seed failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 7b2f460d3..261463e27 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -12,6 +12,7 @@ import telebirrConfig from './config/telebirr.config'; import cbeConfig from './config/cbe.config'; import ebirrConfig from './config/ebirr.config'; import cardConfig from './config/card.config'; +import waafiConfig from './config/waafi.config'; import { AuthModule } from './modules/auth/auth.module'; import { StationsModule } from './modules/stations/stations.module'; import { FleetModule } from './modules/fleet/fleet.module'; @@ -39,7 +40,7 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig], + load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), diff --git a/apps/edr-passenger-api/src/config/waafi.config.ts b/apps/edr-passenger-api/src/config/waafi.config.ts new file mode 100644 index 000000000..c3afab7ce --- /dev/null +++ b/apps/edr-passenger-api/src/config/waafi.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('waafi', () => ({ + baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net', + merchantUid: process.env.WAAFI_MERCHANT_UID ?? '', + apiUserId: process.env.WAAFI_API_USER_ID ?? '', + apiKey: process.env.WAAFI_API_KEY ?? '', + notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '', + returnUrl: process.env.WAAFI_RETURN_URL ?? '', +})); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 1528aa0da..f292e5242 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -32,46 +32,65 @@ 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. -## Authentication - -### Passenger Authentication (JWT-auth) -Used for passenger-facing endpoints. Obtain token via \`POST /auth/login\`. - -**Usage:** Add header \`Authorization: Bearer \` - -### Back-office Authentication (IAM-auth) -Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. - -**Usage:** Add header \`Authorization: Bearer \` - ## Key Features ### 🎫 Booking Lifecycle - Search trips with real-time availability -- Create bookings with seat selection +- Age-based passenger categorization (Adult β‰₯5 years, Child <5 years) +- Nationality-based verification (Ethiopian Fayda, International Passport) +- Passenger information collection with verification +- Coach and seat selection with real-time availability +- Seat holding (15-minute expiry) +- Create bookings with verified passenger data - Modify bookings (seat changes, passenger updates) - Cancel bookings with automatic refunds - Multi-segment journey support +### πŸ‘€ 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:** +- 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% +- Automatic age calculation from date of birth +- Example: 2 adults + 3 children = 4Γ— base fare (first child free) + ### πŸ’³ Payment Integration +1. **Ethiopian Payment Methods:** - **Telebirr** - Ethiopia's leading mobile money - **CBE Birr** - Commercial Bank of Ethiopia - **eBirr** - Electronic payment gateway -- **Card** - International card payments + +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 -- Real-time seat availability -- Seat holds (15-minute expiry) +- 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 +- Class-based seating (Economy Regular, Economy Bed, VIP Bed) ### 🎟️ Ticketing - QR code and barcode generation - PDF ticket generation - Gate validation with audit logs - Offline validation support +- Multi-passenger tickets ### πŸ† Loyalty Program - 4 tiers: Bronze, Silver, Gold, Platinum @@ -100,7 +119,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. ### 🌍 Internationalization - Multi-language support (English, Amharic, French, Oromo) - Locale-based responses -- Currency formatting +- Currency formatting (ETB, DJF, USD) ### πŸ‘¨β€πŸ’Ό Agent Operations - Counter booking @@ -108,6 +127,48 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. - Commission tracking - Cash reconciliation +## Authentication + +### Passenger Authentication (JWT-auth) +Used for passenger-facing endpoints. Obtain token via \`POST /auth/login\`. + +**Usage:** Add header \`Authorization: Bearer \` + +### Back-office Authentication (IAM-auth) +Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. + +**Usage:** Add header \`Authorization: Bearer \` + +## Passenger Booking Flow + +### Step 1: Search Trips +\`POST /search\` with origin, destination, date, passenger counts, and nationality + +### Step 2: Get Fare Quote +\`POST /search/fare-quote\` with passenger counts and display currency + +### Step 3: Passenger Information & Verification +**For Ethiopian Passengers:** +\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (β‰₯5 years) + +**For International Passengers:** +\`POST /passengers/register-international\` - Passport information collection + +### Step 4: View Seat Map +\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats + +### Step 5: Login & Hold Seats +\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes + +### Step 6: Create Booking +\`POST /bookings/guest\` with verified passenger details and held seats + +### Step 7: Process Payment +\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) + +### Step 8: Get Tickets +\`GET /payments/{paymentId}/status\` to confirm payment and retrieve tickets with QR codes + ## Rate Limiting - Auth endpoints: 5 requests/minute - General endpoints: 100 requests/minute @@ -132,10 +193,11 @@ List endpoints support pagination: ## Webhooks Payment providers send notifications to: -- \`POST /payments/webhooks/telebirr\` -- \`POST /payments/webhooks/cbe-birr\` -- \`POST /payments/webhooks/ebirr\` -- \`POST /payments/webhooks/card\` +- \`POST /payments/webhooks/telebirr\` (Ethiopia) +- \`POST /payments/webhooks/cbe-birr\` (Ethiopia) +- \`POST /payments/webhooks/ebirr\` (Ethiopia) +- \`POST /payments/webhooks/waafi\` (Djibouti) +- \`POST /payments/webhooks/card\` (International) ## Support - **Email:** support@edr-platform.com 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 a0fb8afba..f7d1710a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,25 +1,71 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; +import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; +import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Booking') @Controller('bookings') -@UseGuards(JwtGuard) -@ApiBearerAuth('JWT-auth') export class BookingsController { - constructor(private service: BookingsService) {} + constructor( + private service: BookingsService, + private guestService: GuestBookingService, + ) {} + + @Post('guest') + @ApiOperation({ + summary: 'Create guest booking without login (optional account creation)', + description: `Creates a booking without requiring login. Features: + +**Guest Checkout:** +- No login required +- Contact details from first passenger +- Booking confirmation sent to email/phone + +**Optional Account Creation:** +- Set createAccount=true with password +- Account created using first passenger details +- Automatic login after booking +- Loyalty points and wallet created + +**Passenger Details Storage:** +- savePassengerDetails=true: Save for future bookings +- Stored by userId (if account created) or deviceId +- Retrieve saved passengers for quick booking + +**Verifayda Verification:** +- Ethiopian nationals: National ID verified via Verifayda +- Other nationals: Passport details (no verification) + +**Age-Based Pricing:** +- ADULT (β‰₯5 years): Full fare +- CHILD (<5 years): First child FREE, subsequent children full fare` + }) + @ApiResponse({ status: 201, description: 'Booking created successfully' }) + @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' }) + createGuest(@Body() dto: CreateGuestBookingDto) { + return this.guestService.createGuestBooking(dto); + } + + @Get('saved-passengers') + @ApiOperation({ + summary: 'Get saved passenger profiles', + description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' + }) + @ApiResponse({ status: 200, description: 'List of saved passenger profiles' }) + getSavedPassengers(@Query() query: GetSavedPassengersDto) { + return this.guestService.getSavedPassengers(undefined, query.deviceId); + } @Post() + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Create booking with age-based pricing and Verifayda verification', - description: `Creates a booking with the following features: - - Age-based pricing: CHILD (<5 years) first child free, ADULT (>=5 years) full fare - - Ethiopian nationals: Verified via Verifayda 2.0 (national ID NOT stored) - - Non-Ethiopians: Passport required, no verification - - Multi-currency: Display in ETB, DJF, or USD (transaction always in ETB) - - All passengers require dateOfBirth for age calculation` + summary: 'Create booking (requires login)', + description: `Creates a booking for logged-in users with saved passenger profiles. + Use POST /bookings/guest for guest checkout without login.` }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) @@ -30,8 +76,8 @@ export class BookingsController { @Get(':bookingRef') @ApiOperation({ - summary: 'Get booking details by reference', - description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts' + summary: 'Get booking details by reference (no auth required)', + description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.' }) @ApiResponse({ status: 200, description: 'Booking details with adult/child counts and currency conversion' }) @ApiResponse({ status: 404, description: 'Booking not found' }) @@ -40,6 +86,8 @@ export class BookingsController { } @Patch(':bookingRef/modify') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Modify booking seats or trip', description: 'Allows modification of confirmed bookings before departure' @@ -51,6 +99,8 @@ export class BookingsController { } @Delete(':bookingRef') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Cancel booking with refund', description: 'Cancels booking and processes refund (80% for confirmed bookings)' diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 82d75f2dc..dc9989fdb 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -5,12 +5,13 @@ import { Currency, IdDocumentType } from '@prisma/client'; export class PassengerInputDto { @ApiProperty() @IsString() seatId: string; - @ApiProperty({ example: 'John Doe' }) @IsString() passengerName: string; - @ApiProperty({ example: '1990-05-15', description: 'Date of birth for age calculation' }) @IsDateString() dateOfBirth: string; - @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; - @ApiPropertyOptional({ example: 'ET123456789', description: 'For Ethiopian nationals only - used for Verifayda verification' }) @IsOptional() @IsString() idDocumentNumber?: string; - @ApiPropertyOptional({ example: 'P1234567', description: 'For non-Ethiopians' }) @IsOptional() @IsString() passportNumber?: string; - @ApiPropertyOptional({ example: 'Kenya', description: 'For non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: 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; + @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; + @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; + @ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; } export class CreateBookingDto { @@ -19,13 +20,13 @@ export class CreateBookingDto { @ApiProperty() @IsString() holdId: string; @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string; @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string; - @ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; - @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) + @ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; + @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @IsString() seatClassId: string; @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; @ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; - @ApiPropertyOptional({ example: 'ETB', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; } export class ModifyBookingDto { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index a73d16796..d14390386 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { BookingsController } from './bookings.controller'; import { BookingsService } from './bookings.service'; +import { GuestBookingService } from './guest-booking.service'; import { SeatsModule } from '../seats/seats.module'; import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; @@ -8,7 +9,7 @@ import { CurrencyModule } from '../currency/currency.module'; @Module({ imports: [SeatsModule, VerifaydaModule, CurrencyModule], controllers: [BookingsController], - providers: [BookingsService], - exports: [BookingsService] + providers: [BookingsService, GuestBookingService], + exports: [BookingsService, GuestBookingService] }) export class BookingsModule {} 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 6645f6f63..7a87e1adc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -34,9 +34,23 @@ export class BookingsService { async create(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); - const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true } }); + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }); if (!schedule) throw new NotFoundException('Schedule not found'); + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; + const seatIds = dto.passengers.map((p) => p.seatId); const passengersData = []; let adultCount = 0, childCount = 0; @@ -50,6 +64,7 @@ export class BookingsService { let passengerName = passenger.passengerName; let verifaydaVerified = false; let verifaydaData: Record | undefined; + let nationality = passenger.nationality; if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); @@ -57,14 +72,18 @@ export class BookingsService { passengerName = verification.passengerData?.fullName || passengerName; verifaydaVerified = true; verifaydaData = verification.passengerData?.profileData; + nationality = nationality || 'Ethiopian'; } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } - const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId); + // Use first passenger's nationality for fare lookup (or allow per-passenger pricing) + const primaryNationality = passengersData[0]?.nationality; + const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; @@ -125,9 +144,34 @@ export class BookingsService { }; } - private async getBaseFare(scheduleId: string, seatClassId: string): Promise { - const fareRule = await this.prisma.fareRule.findFirst({ where: { tripId: scheduleId, seatClassId } }); - return fareRule?.baseFareMinor ?? 35000; + private async getBaseFare( + scheduleId: string, + seatClassId: string, + segmentRoute?: string, + fullRoute?: string, + nationality?: string, + ): Promise { + const now = new Date(); + const candidates = await this.prisma.fareRule.findMany({ + where: { + seatClassId, + validFrom: { lte: now }, + OR: [ + { validUntil: null }, + { validUntil: { gte: now } }, + ], + }, + }); + + const bestMatch = this.selectBestFareRule( + candidates, + scheduleId, + segmentRoute, + fullRoute, + nationality, + ); + + return bestMatch?.baseFareMinor ?? 35000; } async getByRef(bookingRef: string) { @@ -194,4 +238,39 @@ export class BookingsService { await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); } } + + private selectBestFareRule( + candidates: any[], + scheduleId: string, + segmentRoute?: string, + fullRoute?: string, + nationality?: string, + ): any | null { + const priorities = [ + { tripId: scheduleId, route: segmentRoute, nationality }, + { tripId: scheduleId, route: segmentRoute, nationality: null }, + { tripId: scheduleId, route: fullRoute, nationality }, + { tripId: scheduleId, route: fullRoute, nationality: null }, + { tripId: scheduleId, route: null, nationality }, + { tripId: scheduleId, route: null, nationality: null }, + { tripId: null, route: segmentRoute, nationality }, + { tripId: null, route: segmentRoute, nationality: null }, + { tripId: null, route: fullRoute, nationality }, + { tripId: null, route: fullRoute, nationality: null }, + { tripId: null, route: null, nationality }, + { tripId: null, route: null, nationality: null }, + ]; + + for (const priority of priorities) { + const match = candidates.find( + (c) => + c.tripId === priority.tripId && + c.route === priority.route && + c.nationality === priority.nationality, + ); + if (match) return match; + } + + return null; + } } 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 new file mode 100644 index 000000000..4c3f442d9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -0,0 +1,91 @@ +import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Currency, IdDocumentType } from '@prisma/client'; + +export class GuestPassengerDto { + @ApiProperty({ example: 'seat-id-uuid' }) + @IsString() seatId: string; + + @ApiProperty({ example: 'Abebe Kebede' }) + @IsString() passengerName: string; + + @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) + @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; + + @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored)' }) + @IsOptional() @IsString() idDocumentNumber?: string; + + @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' }) + @IsOptional() @IsString() passportNumber?: string; + + @ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' }) + @IsOptional() @IsString() passportCountry?: string; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda), Djiboutian, Other' }) + @IsOptional() @IsString() nationality?: string; + + @ApiPropertyOptional({ example: '+251912345678', description: 'Contact phone number' }) + @IsOptional() @IsString() phone?: string; + + @ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' }) + @IsOptional() @IsString() email?: string; +} + +export class CreateGuestBookingDto { + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'hold-uuid' }) + @IsString() holdId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: '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' }) + @IsString() seatClassId: string; + + @ApiPropertyOptional({ example: 'WEEKEND15' }) + @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'ETB', enum: Currency, description: 'Display currency (ETB, DJF, USD)' }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + + @ApiPropertyOptional({ example: true, description: 'Create account using first passenger details' }) + @IsOptional() @IsBoolean() createAccount?: boolean; + + @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)' }) + @IsOptional() @IsBoolean() savePassengerDetails?: boolean; + + @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) + @IsOptional() @IsString() deviceId?: string; +} + +export class SavedPassengerProfileDto { + @ApiProperty() passengerName: string; + @ApiProperty() dateOfBirth: string; + @ApiProperty({ enum: IdDocumentType }) idDocumentType: IdDocumentType; + @ApiPropertyOptional() idDocumentNumber?: string; + @ApiPropertyOptional() passportNumber?: string; + @ApiPropertyOptional() passportCountry?: string; + @ApiPropertyOptional() nationality?: string; + @ApiPropertyOptional() phone?: string; + @ApiPropertyOptional() email?: string; +} + +export class GetSavedPassengersDto { + @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' }) + @IsOptional() @IsString() deviceId?: string; +} 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 new file mode 100644 index 000000000..965aeedaf --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -0,0 +1,333 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +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 { EventEmitter2 } from '@nestjs/event-emitter'; +import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; +import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +function generateRef(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +function calculateAge(dateOfBirth: Date): number { + const today = new Date(); + let age = today.getFullYear() - dateOfBirth.getFullYear(); + const monthDiff = today.getMonth() - dateOfBirth.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--; + return age; +} + +@Injectable() +export class GuestBookingService { + constructor( + private prisma: PrismaService, + private seatsService: SeatsService, + private verifaydaService: VerifaydaService, + private currencyService: CurrencyService, + private eventEmitter: EventEmitter2, + ) {} + + async createGuestBooking(dto: CreateGuestBookingDto) { + // Validate hold + const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); + if (!hold || hold.expiresAt < new Date()) { + throw new BadRequestException('Seat hold expired or not found'); + } + + // Get schedule + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; + + // Process passengers with Verifayda verification + const passengersData = []; + 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; + + // Verifayda verification for Ethiopian nationals + if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) { + throw new BadRequestException( + `Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}` + ); + } + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + nationality = nationality || 'Ethiopian'; + } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) { + throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); + } + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } + + passengersData.push({ + ...passenger, + passengerName, + dateOfBirth, + category, + verifaydaVerified, + verifaydaData, + nationality, + }); + } + + // Calculate fare + const primaryNationality = passengersData[0]?.nationality; + const baseFareMinor = await this.getBaseFare( + dto.scheduleId, + dto.seatClassId, + segmentRoute, + fullRoute, + primaryNationality + ); + + const adultFareMinor = baseFareMinor * adultCount; + const paidChildrenCount = Math.max(0, childCount - 1); + const childFareMinor = baseFareMinor * paidChildrenCount; + const totalBaseFareMinor = adultFareMinor + childFareMinor; + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff + ? Math.round(totalBaseFareMinor * promo.percentOff / 100) + : (promo.amountOffMinor ?? 0); + } + } + + const taxesMinor = Math.round(totalBaseFareMinor * 0.05); + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } + + // 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.'); + } + + const passwordHash = await bcrypt.hash(dto.password, 10); + const user = await this.prisma.user.create({ + data: { + fullName: firstPassenger.passengerName, + email: firstPassenger.email, + phone: firstPassenger.phone || '', + 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 tempUser = await this.prisma.user.create({ + data: { + fullName: firstPassenger.passengerName, + email: firstPassenger.email || `guest-${Date.now()}@edr-platform.com`, + phone: firstPassenger.phone || `+251${Date.now()}`, + passwordHash: await bcrypt.hash(Math.random().toString(36), 10), + role: 'PASSENGER', + }, + }); + guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); + } + + // Save passenger details for future use (if requested) + if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { + for (const passenger of passengersData) { + // Note: SavedPassengerProfile will be available after migration + // Temporarily disabled until prisma generate completes + // await this.prisma.savedPassengerProfile.create({ ... }); + } + } + + // Create booking + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassenger.id, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + bookingType: 'ONE_WAY', + // contactEmail: firstPassenger.email, // Temporarily disabled until migration + // contactPhone: firstPassenger.phone, // Temporarily disabled until migration + 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 ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), + displayCurrency, + })), + }, + }, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + // Confirm seats + await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + baseFareMinor, + adultCount, + adultFareMinor, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + childFareMinor, + totalBaseFareMinor, + discountMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }, + }; + } + + async getSavedPassengers(userId?: string, deviceId?: string): Promise { + if (!userId && !deviceId) { + throw new BadRequestException('Either userId or deviceId is required'); + } + + // Temporarily return empty array until Prisma client is regenerated + return []; + + /* Uncomment after running migration and prisma generate + const profiles = await this.prisma.savedPassengerProfile.findMany({ + where: { + OR: [ + userId ? { userId } : {}, + deviceId ? { deviceId } : {}, + ], + }, + orderBy: { createdAt: 'desc' }, + }); + + return profiles.map((p: any) => ({ + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth.toISOString().split('T')[0], + idDocumentType: p.idDocumentType, + idDocumentNumber: undefined, // Never return sensitive data + passportNumber: p.passportNumber || undefined, + passportCountry: p.passportCountry || undefined, + nationality: p.nationality || undefined, + phone: p.phone || undefined, + email: p.email || undefined, + })); + */ + } + + private async getBaseFare( + scheduleId: string, + seatClassId: string, + segmentRoute?: string, + fullRoute?: string, + nationality?: string, + ): Promise { + const now = new Date(); + const candidates = await this.prisma.fareRule.findMany({ + where: { + seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }); + + const priorities = [ + { tripId: scheduleId, route: segmentRoute, nationality }, + { tripId: scheduleId, route: segmentRoute, nationality: null }, + { tripId: scheduleId, route: fullRoute, nationality }, + { tripId: scheduleId, route: fullRoute, nationality: null }, + { tripId: scheduleId, route: null, nationality }, + { tripId: scheduleId, route: null, nationality: null }, + { tripId: null, route: segmentRoute, nationality }, + { tripId: null, route: segmentRoute, nationality: null }, + { tripId: null, route: fullRoute, nationality }, + { tripId: null, route: fullRoute, nationality: null }, + { tripId: null, route: null, nationality }, + { tripId: null, route: null, nationality: null }, + ]; + + for (const priority of priorities) { + const match = candidates.find( + (c) => + c.tripId === priority.tripId && + c.route === priority.route && + c.nationality === priority.nationality, + ); + if (match) return match.baseFareMinor; + } + + return 35000; // Default fallback + } +} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 5eb701c45..0f9184143 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,19 +1,112 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { PassengersService } from './passengers.service'; -import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto'; +import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { VerifaydaService } from '../verifayda/verifayda.service'; @ApiTags('Passenger') @Controller('passengers') -@UseGuards(JwtGuard) -@ApiBearerAuth('JWT-auth') export class PassengersController { - constructor(private service: PassengersService) {} - @Get(':id/profile') @ApiOperation({ summary: 'Get passenger profile' }) getProfile(@Param('id') id: string) { return this.service.getProfile(id); } - @Get(':id/stats') @ApiOperation({ summary: 'Get passenger stats' }) getStats(@Param('id') id: string) { return this.service.getStats(id); } - @Post('traveler-profiles') @ApiOperation({ summary: 'Add traveler profile (family member)' }) createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { return this.service.createTravelerProfile(dto); } - @Get(':id/traveler-profiles') @ApiOperation({ summary: 'Get traveler profiles for passenger' }) getTravelerProfiles(@Param('id') id: string) { return this.service.getTravelerProfiles(id); } - @Post('saved-routes') @ApiOperation({ summary: 'Save a route' }) createSavedRoute(@Body() dto: CreateSavedRouteDto) { return this.service.createSavedRoute(dto); } - @Get(':id/saved-routes') @ApiOperation({ summary: 'Get saved routes' }) getSavedRoutes(@Param('id') id: string) { return this.service.getSavedRoutes(id); } + constructor( + private service: PassengersService, + private verifaydaService: VerifaydaService, + ) {} + + @Get(':id/profile') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get passenger profile' }) + getProfile(@Param('id') id: string) { + return this.service.getProfile(id); + } + + @Get(':id/stats') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get passenger stats' }) + getStats(@Param('id') id: string) { + return this.service.getStats(id); + } + + @Post('verify-fayda') + @ApiOperation({ + summary: 'Verify Ethiopian national ID via Verifayda 2.0', + description: `Verifies Ethiopian national ID and retrieves passenger data from government database. + +- Real-time verification via Verifayda 2.0 API +- Retrieves verified passenger data (name, DOB, gender, nationality) +- National IDs NOT stored (policy compliant) +- Only for Ethiopian nationals with national ID +- Non-Ethiopians should use passport (no verification required) +- Returns passenger details for booking form auto-fill`, + }) + @ApiResponse({ + status: 200, + description: 'Verification successful with passenger data', + schema: { + example: { + verified: true, + passengerData: { + fullName: 'Abebe Kebede', + dateOfBirth: '1985-03-15T00:00:00.000Z', + gender: 'Male', + nationality: 'Ethiopian' + } + } + } + }) + @ApiResponse({ status: 400, description: 'Verification failed or Verifayda disabled' }) + verifyFayda(@Body() dto: VerifyFaydaDto) { + return this.verifaydaService.verifyNationalId(dto.nationalId); + } + + @Post('register-international') + @ApiOperation({ + summary: 'Register international passenger with passport details', + description: `Saves international passenger profile for booking. + +- For non-Ethiopian passengers (Djiboutian, Kenyan, etc.) +- Collects passport information +- No government verification required +- Profile saved for future bookings +- Can be used by logged-in users or guest users (via deviceId)`, + }) + @ApiResponse({ status: 201, description: 'International passenger profile saved successfully' }) + @ApiResponse({ status: 400, description: 'Invalid passport details' }) + registerInternational(@Body() dto: RegisterInternationalPassengerDto) { + return this.service.registerInternational(dto); + } + + @Post('traveler-profiles') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Add traveler profile (family member)' }) + createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { + return this.service.createTravelerProfile(dto); + } + + @Get(':id/traveler-profiles') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get traveler profiles for passenger' }) + getTravelerProfiles(@Param('id') id: string) { + return this.service.getTravelerProfiles(id); + } + + @Post('saved-routes') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Save a route' }) + createSavedRoute(@Body() dto: CreateSavedRouteDto) { + return this.service.createSavedRoute(dto); + } + + @Get(':id/saved-routes') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get saved routes' }) + getSavedRoutes(@Param('id') id: string) { + return this.service.getSavedRoutes(id); + } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts index d76fbfc48..bbdb3e5db 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsDateString } from 'class-validator'; +import { IsString, IsOptional, IsDateString, IsEnum } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateTravelerProfileDto { @@ -17,3 +17,52 @@ export class CreateSavedRouteDto { @ApiProperty({ example: 'Addis Ababa' }) @IsString() fromName: string; @ApiProperty({ example: 'Dire Dawa' }) @IsString() toName: string; } + +export class VerifyFaydaDto { + @ApiProperty({ example: 'ET123456789', description: 'Ethiopian national ID number' }) + @IsString() + nationalId: string; +} + +export class RegisterInternationalPassengerDto { + @ApiProperty({ example: 'John Smith', description: 'Full name as on passport' }) + @IsString() + passengerName: string; + + @ApiProperty({ example: '1990-07-20', description: 'Date of birth' }) + @IsDateString() + dateOfBirth: string; + + @ApiProperty({ example: 'P1234567', description: 'Passport number' }) + @IsString() + passportNumber: string; + + @ApiProperty({ example: 'Kenya', description: 'Passport issuing country' }) + @IsString() + passportCountry: string; + + @ApiPropertyOptional({ example: 'Kenyan', description: 'Nationality' }) + @IsOptional() + @IsString() + nationality?: string; + + @ApiPropertyOptional({ example: '+254712345678', description: 'Phone number' }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ example: 'john@example.com', description: 'Email address' }) + @IsOptional() + @IsString() + email?: string; + + @ApiPropertyOptional({ description: 'User ID if logged in' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: 'Device ID for guest users' }) + @IsOptional() + @IsString() + deviceId?: string; +} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts index c5c7c52e3..c1b270123 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts @@ -1,6 +1,11 @@ import { Module } from '@nestjs/common'; import { PassengersController } from './passengers.controller'; import { PassengersService } from './passengers.service'; +import { VerifaydaModule } from '../verifayda/verifayda.module'; -@Module({ controllers: [PassengersController], providers: [PassengersService] }) +@Module({ + imports: [VerifaydaModule], + controllers: [PassengersController], + providers: [PassengersService] +}) export class PassengersModule {} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index a454fdd70..9706b8c85 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto'; +import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterInternationalPassengerDto } from './passengers.dto'; @Injectable() export class PassengersService { @@ -45,6 +45,32 @@ export class PassengersService { return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 }; } + async registerInternational(dto: RegisterInternationalPassengerDto) { + const profile = await this.prisma.savedPassengerProfile.create({ + data: { + userId: dto.userId, + deviceId: dto.deviceId, + passengerName: dto.passengerName, + dateOfBirth: new Date(dto.dateOfBirth), + idDocumentType: 'PASSPORT', + passportNumber: dto.passportNumber, + passportCountry: dto.passportCountry, + nationality: dto.nationality, + phone: dto.phone, + email: dto.email, + }, + }); + return { + id: profile.id, + passengerName: profile.passengerName, + dateOfBirth: profile.dateOfBirth, + passportNumber: profile.passportNumber, + passportCountry: profile.passportCountry, + nationality: profile.nationality, + message: 'International passenger profile saved successfully', + }; + } + createTravelerProfile(dto: CreateTravelerProfileDto) { return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } }); } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 9e1689df9..5c070c8da 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -10,9 +10,44 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiBearerAuth('JWT-auth') export class PaymentsController { constructor(private service: PaymentsService) {} - @Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); } - @Get('intents/:bookingId') @ApiOperation({ summary: 'Get payment intent status for a booking' }) getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); } - @Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } - @Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } - @Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); } + + @Post('initiate') + @ApiOperation({ + summary: 'Initiate payment with nationality-based payment methods', + description: `Initiates payment for a booking with support for multiple payment providers: + +**Ethiopian Payment Methods:** +- TELEBIRR - Ethiopia's leading mobile money +- CBE_BIRR - Commercial Bank of Ethiopia +- EBIRR - Electronic payment gateway + +**Djiboutian Payment Methods:** +- WAAFI - Djibouti's mobile money service + +**International Payment Methods:** +- CARD - Visa, Mastercard +- WALLET - Internal wallet balance + +**Multi-Currency:** +- All transactions processed in ETB +- Display amounts in ETB, DJF, or USD +- Real-time exchange rate conversion` + }) + initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); } + + @Get('intents/:bookingId') + @ApiOperation({ summary: 'Get payment intent status for a booking' }) + getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); } + + @Post('refund') + @ApiOperation({ summary: 'Refund a confirmed booking' }) + refund(@Body() dto: RefundDto) { return this.service.refund(dto); } + + @Post('methods') + @ApiOperation({ summary: 'Add a payment method' }) + addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } + + @Get('methods/:userId') + @ApiOperation({ summary: 'Get payment methods for user' }) + getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index adeee8b22..1ad1e06c6 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -2,12 +2,23 @@ import { IsString, IsEnum, IsOptional } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PaymentIntentStatus } from '@prisma/client'; -export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' } +export enum PaymentMethodTypeEnum { + TELEBIRR = 'TELEBIRR', // Ethiopia + CBE_BIRR = 'CBE_BIRR', // Ethiopia + EBIRR = 'EBIRR', // Ethiopia + WAAFI = 'WAAFI', // Djibouti + CARD = 'CARD', // International + WALLET = 'WALLET' // Internal +} export class InitiatePaymentDto { - @ApiProperty() @IsString() bookingId: string; - @ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional() @IsOptional() @IsString() paymentMethodId?: string; + @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiProperty({ + enum: PaymentMethodTypeEnum, + description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)', + example: 'TELEBIRR' + }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; + @ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string; } export class RefundDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 387ce5244..6c145ef9b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -8,11 +8,13 @@ import { TelebirrProvider } from './providers/telebirr.provider'; import { CbeBirrProvider } from './providers/cbe-birr.provider'; import { EBirrProvider } from './providers/ebirr.provider'; import { CardProvider } from './providers/card.provider'; +import { WaafiProvider } from './providers/waafi.provider'; import { WebhooksController } from './webhooks/webhooks.controller'; import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service'; import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service'; import { EBirrWebhookService } from './webhooks/ebirr-webhook.service'; import { CardWebhookService } from './webhooks/card-webhook.service'; +import { WaafiWebhookService } from './webhooks/waafi-webhook.service'; @Module({ imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })], @@ -23,10 +25,12 @@ import { CardWebhookService } from './webhooks/card-webhook.service'; CbeBirrProvider, EBirrProvider, CardProvider, + WaafiProvider, TelebirrWebhookService, CbeBirrWebhookService, EBirrWebhookService, CardWebhookService, + WaafiWebhookService, ], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 9f1ae5341..24ccf9a56 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -10,6 +10,7 @@ import { TelebirrProvider } from './providers/telebirr.provider'; import { CbeBirrProvider } from './providers/cbe-birr.provider'; import { EBirrProvider } from './providers/ebirr.provider'; import { CardProvider } from './providers/card.provider'; +import { WaafiProvider } from './providers/waafi.provider'; import { createMerchantOrderId } from './providers/telebirr.crypto'; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ @@ -32,12 +33,14 @@ export class PaymentsService { private cbeBirrProvider: CbeBirrProvider, private eBirrProvider: EBirrProvider, private cardProvider: CardProvider, + private waafiProvider: WaafiProvider, ) { this.providers = new Map([ [PaymentMethodType.TELEBIRR, this.telebirrProvider], [PaymentMethodType.CBE_BIRR, this.cbeBirrProvider], [PaymentMethodType.EBIRR, this.eBirrProvider], [PaymentMethodType.CARD, this.cardProvider], + [PaymentMethodType.WAAFI, this.waafiProvider], ]); } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts index 336f4b511..1c763ae45 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.types.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -1,8 +1,8 @@ import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; export interface ClientAction { - type: 'REDIRECT'; - url: string; + type: 'REDIRECT' | 'NONE'; + url?: string; } export interface ProviderInitiationInput { diff --git a/apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts b/apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts new file mode 100644 index 000000000..231983ae7 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts @@ -0,0 +1,276 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, +} from '../payments.types'; + +const WAAFI_HTTP_TIMEOUT_MS = 10_000; + +interface WaafiInitiateRequest { + schemaVersion: string; + requestId: string; + timestamp: string; + channelName: string; + serviceName: string; + serviceParams: { + merchantUid: string; + apiUserId: string; + apiKey: string; + paymentMethod: string; + payerInfo: { + accountNo: string; + }; + transactionInfo: { + referenceId: string; + invoiceId: string; + amount: number; + currency: string; + description: string; + }; + }; +} + +interface WaafiInitiateResponse { + responseCode: string; + responseMsg: string; + params?: { + state: string; + referenceId: string; + transactionId: string; + checkoutUrl?: string; + }; +} + +interface WaafiQueryRequest { + schemaVersion: string; + requestId: string; + timestamp: string; + channelName: string; + serviceName: string; + serviceParams: { + merchantUid: string; + apiUserId: string; + apiKey: string; + transactionId?: string; + referenceId?: string; + }; +} + +interface WaafiQueryResponse { + responseCode: string; + responseMsg: string; + params?: { + state: string; + referenceId: string; + transactionId: string; + amount: number; + currency: string; + paidAmount?: number; + }; +} + +@Injectable() +export class WaafiProvider implements PaymentProvider { + readonly method = PaymentMethodType.WAAFI; + private readonly logger = new Logger(WaafiProvider.name); + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + async initiate(input: ProviderInitiationInput): Promise { + const requestBody = this.buildInitiateRequest(input); + const response = await this.postJson( + `${this.baseUrl}/asm`, + requestBody, + ); + + if (response.responseCode !== '2001') { + throw new Error( + `Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`, + ); + } + + const transactionId = response.params?.transactionId; + const checkoutUrl = response.params?.checkoutUrl; + + if (!transactionId) { + throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`); + } + + const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes + + return { + providerOrderId: transactionId, + clientAction: checkoutUrl + ? { type: 'REDIRECT', url: checkoutUrl } + : { type: 'NONE' }, + expiresAt, + rawInitiation: { + request: this.sanitize(requestBody), + response, + }, + }; + } + + async queryStatus(merchantOrderId: string): Promise { + const requestBody = this.buildQueryRequest(merchantOrderId); + const response = await this.postJson( + `${this.baseUrl}/asm`, + requestBody, + ); + + const state = response.params?.state; + const transactionId = response.params?.transactionId; + const mapped = this.mapState(state); + + return { + status: mapped, + providerTxnId: transactionId, + failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined, + rawResponse: response as unknown as Record, + }; + } + + mapState(state: string | undefined): PaymentIntentStatus { + switch (state) { + case 'APPROVED': + case 'SUCCESS': + return PaymentIntentStatus.SUCCEEDED; + case 'FAILED': + case 'DECLINED': + case 'CANCELLED': + case 'EXPIRED': + return PaymentIntentStatus.FAILED; + case 'PENDING': + case 'INITIATED': + return PaymentIntentStatus.REQUIRES_ACTION; + case 'PROCESSING': + return PaymentIntentStatus.PROCESSING; + default: + return PaymentIntentStatus.PROCESSING; + } + } + + verifyWebhookSignature(payload: Record): boolean { + // Waafi webhook signature verification + // Implementation depends on Waafi's webhook signature mechanism + const signature = payload.signature as string; + const apiKey = this.apiKey; + + if (!signature || !apiKey) { + this.logger.error('Waafi webhook missing signature or API key not configured'); + return false; + } + + // TODO: Implement actual signature verification based on Waafi documentation + // For now, basic validation + return signature.length > 0; + } + + private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest { + const amount = input.amountMinor / 100; // Convert minor units to major + + return { + schemaVersion: '1.0', + requestId: this.generateRequestId(), + timestamp: new Date().toISOString(), + channelName: 'WEB', + serviceName: 'API_PURCHASE', + serviceParams: { + merchantUid: this.merchantUid, + apiUserId: this.apiUserId, + apiKey: this.apiKey, + paymentMethod: 'MWALLET_ACCOUNT', + payerInfo: { + accountNo: 'CUSTOMER', // Customer enters their number on Waafi page + }, + transactionInfo: { + referenceId: input.merchantOrderId, + invoiceId: input.bookingRef, + amount, + currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF + description: `EDR Train Booking ${input.bookingRef}`, + }, + }, + }; + } + + private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest { + return { + schemaVersion: '1.0', + requestId: this.generateRequestId(), + timestamp: new Date().toISOString(), + channelName: 'WEB', + serviceName: 'API_QUERY', + serviceParams: { + merchantUid: this.merchantUid, + apiUserId: this.apiUserId, + apiKey: this.apiKey, + referenceId: merchantOrderId, + }, + }; + } + + private generateRequestId(): string { + return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + } + + private async postJson(url: string, body: unknown): Promise { + const config: AxiosRequestConfig = { + headers: { + 'Content-Type': 'application/json', + }, + timeout: WAAFI_HTTP_TIMEOUT_MS, + }; + + const started = Date.now(); + try { + const res = await firstValueFrom(this.http.post(url, body, config)); + this.logger.debug( + `Waafi POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); + return res.data; + } catch (err) { + if (err instanceof AxiosError) { + this.logger.error( + `Waafi POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`, + ); + } else { + this.logger.error( + `Waafi POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); + } + throw err; + } + } + + private sanitize(body: WaafiInitiateRequest): Record { + const sanitized = { ...body }; + if (sanitized.serviceParams?.apiKey) { + sanitized.serviceParams.apiKey = '***REDACTED***'; + } + return sanitized as unknown as Record; + } + + private get baseUrl(): string { + return this.config.get('waafi.baseUrl') ?? 'https://api.waafipay.net'; + } + private get merchantUid(): string { + return this.config.get('waafi.merchantUid') ?? ''; + } + private get apiUserId(): string { + return this.config.get('waafi.apiUserId') ?? ''; + } + private get apiKey(): string { + return this.config.get('waafi.apiKey') ?? ''; + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts new file mode 100644 index 000000000..f69b7c3d3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma.service'; +import { PaymentsService } from '../payments.service'; +import { WaafiProvider } from '../providers/waafi.provider'; +import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; + +interface WaafiWebhookPayload { + schemaVersion: string; + requestId: string; + timestamp: string; + eventType: string; + params: { + state: string; + referenceId: string; + transactionId: string; + amount: number; + currency: string; + description?: string; + }; + signature?: string; +} + +@Injectable() +export class WaafiWebhookService { + private readonly logger = new Logger(WaafiWebhookService.name); + + constructor( + private prisma: PrismaService, + private paymentsService: PaymentsService, + private waafiProvider: WaafiProvider, + ) {} + + async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> { + this.logger.log( + `Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`, + ); + + const signatureValid = this.waafiProvider.verifyWebhookSignature( + payload as unknown as Record, + ); + + const merchantOrderId = payload.params?.referenceId; + const transactionId = payload.params?.transactionId; + const state = payload.params?.state; + + await this.prisma.paymentWebhookEvent.create({ + data: { + provider: PaymentMethodType.WAAFI, + externalEventId: payload.requestId, + merchantOrderId, + providerTxnId: transactionId, + signatureValid, + status: state || 'UNKNOWN', + payload: payload as any, + }, + }); + + if (!signatureValid) { + this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`); + return { received: true }; + } + + if (!merchantOrderId) { + this.logger.error('Waafi webhook missing referenceId'); + return { received: true }; + } + + const intent = await this.prisma.paymentIntent.findFirst({ + where: { merchantOrderId }, + }); + + if (!intent) { + this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`); + return { received: true }; + } + + const mappedStatus = this.waafiProvider.mapState(state); + + if (mappedStatus === PaymentIntentStatus.SUCCEEDED) { + await this.paymentsService.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: transactionId, + }); + this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`); + } else if (mappedStatus === PaymentIntentStatus.FAILED) { + await this.paymentsService.markPaymentFailed({ + intentId: intent.id, + failureCode: state, + failureMessage: payload.params?.description, + }); + this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`); + } else { + await this.prisma.paymentIntent.update({ + where: { id: intent.id }, + data: { + status: mappedStatus, + providerTxnId: transactionId, + }, + }); + this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`); + } + + return { received: true }; + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts index 4edbe58a8..59e45722b 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -16,6 +16,7 @@ import { CardWebhookPayload, CardWebhookService, } from './card-webhook.service'; +import { WaafiWebhookService } from './waafi-webhook.service'; @ApiTags('Payment Webhooks') @Controller('payments/webhooks') @@ -27,11 +28,15 @@ export class WebhooksController { private readonly cbeBirr: CbeBirrWebhookService, private readonly eBirr: EBirrWebhookService, private readonly card: CardWebhookService, + private readonly waafi: WaafiWebhookService, ) {} @Post('telebirr') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Telebirr payment notification callback' }) + @ApiOperation({ + summary: 'Telebirr payment notification callback (Ethiopia)', + description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.' + }) async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { try { await this.telebirr.handle(payload); @@ -44,7 +49,10 @@ export class WebhooksController { @Post('cbe-birr') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'CBE Birr payment notification callback' }) + @ApiOperation({ + summary: 'CBE Birr payment notification callback (Ethiopia)', + description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.' + }) async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) { try { await this.cbeBirr.handle(payload); @@ -57,7 +65,10 @@ export class WebhooksController { @Post('ebirr') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'eBirr payment notification callback' }) + @ApiOperation({ + summary: 'eBirr payment notification callback (Ethiopia)', + description: 'Webhook endpoint for eBirr electronic payment gateway status updates.' + }) async receiveEBirr(@Body() payload: EBirrWebhookPayload) { try { await this.eBirr.handle(payload); @@ -70,7 +81,10 @@ export class WebhooksController { @Post('card') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Card payment notification callback' }) + @ApiOperation({ + summary: 'Card payment notification callback (International)', + description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.' + }) async receiveCard( @Body() payload: CardWebhookPayload, @Headers('stripe-signature') signature: string, @@ -83,4 +97,20 @@ export class WebhooksController { } return { received: true }; } + + @Post('waafi') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Waafi payment notification callback (Djibouti)', + description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.' + }) + async receiveWaafi(@Body() payload: any) { + try { + await this.waafi.handleWebhook(payload); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Waafi webhook handler threw: ${message}`); + } + return { responseCode: '2001', responseMsg: 'Success' }; + } } 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 8ff79c527..64ab95384 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -42,7 +42,8 @@ export class UpdateStopTimeDto { export class CreateFareRuleDto { @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string; - @ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI)' }) @IsOptional() @IsString() route?: string; + @ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string; + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string; @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string; @ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number; @ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string; 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 e962be741..17f2b554b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -137,11 +137,12 @@ export class SchedulesService { // ── Fare Rules ───────────────────────────────────────────────────────────── createFareRule(dto: CreateFareRuleDto) { - const { validFrom, validUntil, scheduleId, ...rest } = dto; + const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto; return this.prisma.fareRule.create({ data: { ...rest, tripId: scheduleId, + nationality, validFrom: new Date(validFrom), validUntil: validUntil ? new Date(validUntil) : null, }, diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 5dbbd7759..8720a97fc 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -10,14 +10,16 @@ export class SearchController { @Post() @ApiOperation({ - summary: 'Search schedules by any origin–destination stop pair', - description: `Finds all train schedules where both origin and destination appear as stops (not just terminals). + summary: 'Search trips by origin, destination, date, passengers, and nationality', + description: `Finds all train schedules matching search criteria with real-time seat availability. -Example: A train running Aβ†’Bβ†’Cβ†’D will appear in results for Aβ†’B, Aβ†’C, Aβ†’D, Bβ†’C, Bβ†’D, and Cβ†’D searches. - -Availability is computed per seat per segment β€” a seat booked Aβ†’B is still shown as available for Bβ†’D. - -Returns departure/arrival times for the requested leg, the full stop list, and per-class seat counts.` +- Any originβ†’destination stop pair (not just terminals) +- Age-based passenger counts (adults β‰₯5 years, children <5 years) +- Nationality filtering (Ethiopian, Djiboutian, Other) +- Real-time seat availability per class +- Multi-currency fare display +- Example: Train Aβ†’Bβ†’Cβ†’D appears in results for Aβ†’B, Aβ†’C, Aβ†’D, Bβ†’C, Bβ†’D, Cβ†’D +- Availability: Segment-based (seat booked Aβ†’B is still available Bβ†’D)` }) @ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' }) searchTrips(@Body() dto: SearchTripsDto) { @@ -26,17 +28,29 @@ Returns departure/arrival times for the requested leg, the full stop list, and p @Post('fare-quote') @ApiOperation({ - summary: 'Get fare quote for a specific schedule leg', - description: `Calculates fare for the requested originβ†’destination leg on a schedule. + summary: 'Get fare quote with age-based pricing and multi-currency support', + description: `Calculates detailed fare breakdown for a specific schedule leg. -Pricing rules (in priority order): +Age-Based Pricing: +- ADULT (β‰₯5 years): 100% of base fare +- CHILD (<5 years): First child FREE, subsequent children 100% +- Example: 2 adults + 3 children = 4Γ— base fare + +Pricing Rules (priority order): 1. Schedule-scoped FareRule (tripId = scheduleId) 2. Segment route FareRule (e.g. ADD-DRE) 3. Full-route FareRule (e.g. ADD-DJI) 4. Default hardcoded fare -Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare. -Supports multi-currency display (ETB, DJF, USD).` +Multi-Currency: +- Transaction currency: ETB +- Display currencies: ETB, DJF, USD +- Real-time exchange rate conversion + +Nationality-Based: +- Ethiopian: National ID verification required +- Djiboutian: Passport details, Waafi payment available +- Other: Passport details, international payments` }) @ApiResponse({ status: 200, description: 'Fare breakdown with adult/child pricing, discounts, taxes, and currency conversion' }) @ApiResponse({ status: 404, description: 'Schedule not found or origin/destination not on schedule' }) diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index 571b2a354..b49c35259 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -13,11 +13,14 @@ export class SearchTripsDto { @ApiProperty({ example: '2026-06-15', description: 'Departure date (YYYY-MM-DD)' }) @IsDateString() date: string; - @ApiProperty({ example: 2, description: 'Number of adult passengers (age β‰₯ 5)' }) + @ApiProperty({ example: 2, description: 'Number of adult passengers (age β‰₯5 years) - pay 100% of base fare' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number; - @ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age < 5). First child travels free.' }) + @ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age <5 years). First child travels FREE, subsequent children pay 100%.' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' }) + @IsOptional() @IsString() nationality?: string; } export class FareQuoteDto { @@ -33,10 +36,10 @@ export class FareQuoteDto { @ApiProperty({ example: 'Economy Regular', description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed"' }) @IsString() seatClassName: string; - @ApiProperty({ example: 2, description: 'Number of adult passengers' }) + @ApiProperty({ example: 2, description: 'Number of adult passengers (β‰₯5 years) - each pays 100% of base fare' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number; - @ApiPropertyOptional({ example: 1 }) + @ApiPropertyOptional({ example: 1, description: 'Number of child passengers (<5 years) - first child FREE, subsequent children 100%' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number; @ApiPropertyOptional({ example: 'WEEKEND15' }) @@ -45,6 +48,9 @@ export class FareQuoteDto { @ApiPropertyOptional({ example: 450, description: 'Loyalty points to redeem (10 points = 1 ETB minor unit)' }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number; - @ApiPropertyOptional({ example: 'ETB', enum: Currency }) + @ApiPropertyOptional({ example: 'USD', enum: Currency, description: 'Display currency: ETB (default), DJF, USD. Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' }) + @IsOptional() @IsString() nationality?: string; } 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 062736d2e..9141da4cb 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -129,11 +129,23 @@ export class SearchService { const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); - // Look up fare rule: prefer schedule-scoped, then segment route, then global + // Compute route codes for fare lookup const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; const now = new Date(); + const nationality = dto.nationality; + // Query fare rules with specificity ordering: + // 1. schedule+segment+nationality + // 2. schedule+segment + // 3. schedule+full-route+nationality + // 4. schedule+full-route + // 5. schedule+global + // 6. segment+nationality + // 7. segment + // 8. full-route+nationality + // 9. full-route + // 10. global const fareRule = await this.prisma.fareRule.findFirst({ where: { seatClassId: seatClass?.id, @@ -144,13 +156,36 @@ export class SearchService { ], }, orderBy: [ - // Most specific first: schedule-scoped > segment route > full route > global - { tripId: 'desc' }, + // Prioritize schedule-specific rules + { tripId: { sort: 'desc', nulls: 'last' } }, + // Then prioritize nationality match + { nationality: { sort: 'desc', nulls: 'last' } }, + // Most recent validFrom { validFrom: 'desc' }, ], }); - const baseFareMinor = fareRule?.baseFareMinor ?? this.defaultFare(dto.seatClassName); + // Manual specificity filtering to find best match + const candidates = await this.prisma.fareRule.findMany({ + where: { + seatClassId: seatClass?.id, + validFrom: { lte: now }, + OR: [ + { validUntil: null }, + { validUntil: { gte: now } }, + ], + }, + }); + + const bestMatch = this.selectBestFareRule( + candidates, + dto.scheduleId, + segmentRoute, + fullRoute, + nationality, + ); + + const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName); const adultCount = dto.adultCount; const childCount = dto.childCount ?? 0; @@ -184,6 +219,7 @@ export class SearchService { destinationStationId: dto.destinationStationId, segmentRoute, seatClassName: dto.seatClassName, + nationality: dto.nationality, adultCount, childCount, baseFareMinor, adultFareMinor, childFareMinor, freeChildrenCount: Math.min(childCount, 1), @@ -266,4 +302,55 @@ export class SearchService { }; return fares[seatClassName] ?? 45000; } + + /** + * Select the best matching fare rule based on specificity: + * 1. schedule+segment+nationality + * 2. schedule+segment + * 3. schedule+full-route+nationality + * 4. schedule+full-route + * 5. schedule+global + * 6. segment+nationality + * 7. segment + * 8. full-route+nationality + * 9. full-route + * 10. global + */ + private selectBestFareRule( + candidates: any[], + scheduleId: string, + segmentRoute: string, + fullRoute: string, + nationality?: string, + ): any | null { + const priorities = [ + // Schedule-specific rules + { tripId: scheduleId, route: segmentRoute, nationality }, + { tripId: scheduleId, route: segmentRoute, nationality: null }, + { tripId: scheduleId, route: fullRoute, nationality }, + { tripId: scheduleId, route: fullRoute, nationality: null }, + { tripId: scheduleId, route: null, nationality }, + { tripId: scheduleId, route: null, nationality: null }, + // Route-specific rules (no schedule) + { tripId: null, route: segmentRoute, nationality }, + { tripId: null, route: segmentRoute, nationality: null }, + { tripId: null, route: fullRoute, nationality }, + { tripId: null, route: fullRoute, nationality: null }, + // Global rules + { tripId: null, route: null, nationality }, + { tripId: null, route: null, nationality: null }, + ]; + + for (const priority of priorities) { + const match = candidates.find( + (c) => + c.tripId === priority.tripId && + c.route === priority.route && + c.nationality === priority.nationality, + ); + if (match) return match; + } + + return null; + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 9059ba6f0..4c93b5f61 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -11,7 +11,15 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get('seatmap/:scheduleId') - @ApiOperation({ summary: 'Get seat map for a schedule' }) + @ApiOperation({ + summary: 'Get seat map with real-time availability by class', + description: `Returns seat map for a schedule with availability by seat class: +- Economy Regular +- Economy Bed +- VIP Bed + +Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED` + }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) @ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' }) @ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' }) @@ -20,8 +28,17 @@ export class SeatsController { // ── Hold / Release ──────────────────────────────────────────────────────── @Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Hold seats for 15 minutes' }) - @ApiResponse({ status: 201, description: 'Seats held successfully' }) + @ApiOperation({ + summary: 'Hold seats for 15 minutes before booking', + description: `Temporarily reserves seats for a passenger to complete booking. + +**Features:** +- 15-minute hold duration +- Auto-release after expiry +- Prevents double booking +- Required before creating booking` + }) + @ApiResponse({ status: 201, description: 'Seats held successfully with holdId' }) @ApiResponse({ status: 409, description: 'One or more seats unavailable' }) holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index a1be3762d..353204644 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -8,8 +8,24 @@ import { JwtGuard } from '../../common/jwt.guard'; @Controller('stations') export class StationsController { constructor(private service: StationsService) {} - @Get() @ApiOperation({ summary: 'List all stations' }) findAll() { return this.service.findAll(); } - @Get(':id') @ApiOperation({ summary: 'Get station by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); } - @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create station' }) + + @Get() + @ApiOperation({ + summary: 'List all stations with country information', + description: 'Returns all stations on the Ethio-Djibouti Railway with country codes (ET for Ethiopia, DJ for Djibouti)' + }) + findAll() { return this.service.findAll(); } + + @Get(':id') + @ApiOperation({ + summary: 'Get station details by ID', + description: 'Returns station information including name, code, country, coordinates, and facilities' + }) + findOne(@Param('id') id: string) { return this.service.findOne(id); } + + @Post() + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create new station' }) create(@Body() dto: CreateStationDto) { return this.service.create(dto); } } 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 7b871db3f..cb9aeeb76 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -11,13 +11,25 @@ export class TicketsController { constructor(private service: TicketsService) {} @Get(':bookingRef') - @ApiOperation({ summary: 'Get ticket by booking reference' }) + @ApiOperation({ + summary: 'Get ticket with QR code and passenger details', + description: `Returns ticket information including: +- QR code for gate scanning +- Barcode for offline validation +- Passenger details (name, age category, nationality) +- Journey details (origin, destination, seat, coach) +- Fare breakdown with currency +- PDF download link` + }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } @Post(':bookingRef/validate') - @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) + @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.' + }) validate( @Param('bookingRef') ref: string, @Body('validatorId') validatorId: string,