From 3f60836e5d9a64fc6cf22a76ce2ca53b8a4dd5d0 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 21 May 2026 10:22:08 +0300 Subject: [PATCH] Implemened verifayda and currency modules --- README.md | 125 +++++++++++- apps/edr-passenger-api/.env.example | 6 + apps/edr-passenger-api/package.json | 4 +- .../migration.sql | 2 - .../migration.sql | 74 ------- .../migration.sql | 171 +++++++++++++++- .../prisma/migrations/migration_lock.toml | 4 +- apps/edr-passenger-api/prisma/schema.prisma | 148 ++++++++++++-- apps/edr-passenger-api/prisma/seed.ts | 74 ++++--- .../src/modules/agents/agents.service.ts | 5 +- .../modules/bookings/bookings.controller.ts | 36 +++- .../src/modules/bookings/bookings.dto.ts | 20 +- .../src/modules/bookings/bookings.module.ts | 10 +- .../src/modules/bookings/bookings.service.ts | 189 +++++++++++++++--- .../src/modules/currency/currency.module.ts | 10 + .../src/modules/currency/currency.service.ts | 83 ++++++++ .../src/modules/search/search.controller.ts | 26 ++- .../src/modules/search/search.dto.ts | 10 +- .../src/modules/search/search.module.ts | 8 +- .../src/modules/search/search.service.ts | 95 ++++++--- .../src/modules/verifayda/verifayda.module.ts | 10 + .../modules/verifayda/verifayda.service.ts | 142 +++++++++++++ 22 files changed, 1032 insertions(+), 220 deletions(-) delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260520000002_language_field_note/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260521000001_add_fraud_detection/migration.sql rename apps/edr-passenger-api/prisma/migrations/{20260519124728_initial_migration => 20260521053749_initial_migration}/migration.sql (87%) create mode 100644 apps/edr-passenger-api/src/modules/currency/currency.module.ts create mode 100644 apps/edr-passenger-api/src/modules/currency/currency.service.ts create mode 100644 apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts create mode 100644 apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts diff --git a/README.md b/README.md index eb7cccae0..0a47b6a90 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,47 @@ Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger bookin ## 🚀 Features +### 🆕 NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency + +#### 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) + +#### Verifayda 2.0 Integration +- Real-time Ethiopian national ID verification +- Retrieves passenger data from government database +- National IDs NOT stored (policy compliant) +- Non-Ethiopians use passport (no verification required) +- Booking fails if verification unsuccessful + +#### Multi-Currency Support +- **Transaction Currency**: ETB (Ethiopian Birr) +- **Display Currencies**: ETB, DJF (Djiboutian Franc), USD (US Dollar) +- Real-time exchange rate conversion +- Prices shown in user's preferred currency +- Exchange rates: ETB→DJF=3.25, ETB→USD=0.018 + ### Core Modules - **Authentication & Authorization** - Dual authentication system: - **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout - **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins) - Role-based access control (RBAC) with granular permissions +- **Age-Based Pricing** - Smart passenger categorization: + - **ADULT** (≥5 years): Full fare + - **CHILD** (<5 years): First child free, subsequent children full fare + - Automatic age calculation from date of birth +- **Verifayda 2.0 Integration** - Ethiopian national ID verification: + - Real-time verification via government API + - Retrieves passenger data (name, DOB, nationality) + - National IDs NOT stored (policy compliant) + - Non-Ethiopians use passport (no verification) +- **Multi-Currency Support** - Display prices in multiple currencies: + - **ETB** (Ethiopian Birr) - Transaction currency + - **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 - **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 @@ -74,6 +110,16 @@ cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env | `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` | | `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` | +#### Verifayda 2.0 Configuration (Ethiopian National ID Verification) + +| Variable | Description | Example | +|----------|-------------|---------| +| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` | +| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` | +| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` | + +**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only). + #### Corporate IAM Configuration (Back-office Authentication) | Variable | Description | Example | @@ -124,15 +170,18 @@ pnpm --filter @edr/passenger-api run prisma:seed ``` **Seed Data Includes:** -- 5 Stations (Addis Ababa, Adama, Awash, Dire Dawa, Djibouti) -- 1 Route with 5 stops and fare rules -- 2 Train services with 2 trips -- 360 seats across 6 coaches (Economy, Bed, VIP classes) +- 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) - 3 User accounts (Admin, Passenger, Agent) +- Fare rules for ADULT and CHILD passenger categories +- Currency exchange rates (ETB, DJF, USD) - Baggage allowance rules - Notification templates - Promotions and FAQ content - Menu items and station crowd signals +- Fraud detection rules ### 5. Start Development Server ```bash @@ -247,11 +296,41 @@ Content-Type: application/json #### 3. Search Trips ```bash -GET /search/trips?originStationId={id}&destinationStationId={id}&date=2026-06-15 +GET /search/trips?originStationId={id}&destinationStationId={id}&date=2026-06-15&adultCount=2&childCount=1 Authorization: Bearer {token} ``` -#### 4. Create Booking +#### 4. Get Fare Quote +```bash +POST /search/fare-quote +Authorization: Bearer {token} +Content-Type: application/json + +{ + "tripId": "uuid", + "serviceClass": "ECONOMY_REGULAR", + "adultCount": 2, + "childCount": 1, + "displayCurrency": "USD" +} + +# Response includes age-based pricing breakdown +{ + "baseFareMinor": 35000, + "adultCount": 2, + "adultFareMinor": 70000, + "childCount": 1, + "freeChildrenCount": 1, + "paidChildrenCount": 0, + "childFareMinor": 0, + "totalMinor": 73500, + "currency": "ETB", + "displayCurrency": "USD", + "displayTotalMinor": 1323 +} +``` + +#### 5. Create Booking ```bash POST /bookings Authorization: Bearer {token} @@ -259,12 +338,31 @@ Content-Type: application/json { "tripId": "uuid", - "seats": [ + "holdId": "uuid", + "serviceClass": "ECONOMY_REGULAR", + "displayCurrency": "DJF", + "passengers": [ { "seatId": "uuid", - "passengerName": "John Doe", + "passengerName": "Abebe Kebede", + "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", - "idDocumentNumber": "ET123456" + "passportNumber": "P1234567", + "passportCountry": "Kenya" } ] } @@ -300,6 +398,7 @@ apps/edr-passenger-api/ │ │ ├── auth/ # Authentication & authorization (JWT) │ │ ├── agents/ # Agent operations (IAM-protected) │ │ ├── bookings/ # Booking management (JWT) +│ │ ├── currency/ # Currency conversion service │ │ ├── dashboard/ # Dashboard aggregations (JWT) │ │ ├── fleet/ # Train fleet management (JWT/IAM) │ │ ├── fraud/ # Fraud detection (IAM-protected) @@ -317,6 +416,7 @@ apps/edr-passenger-api/ │ │ ├── stations/ # Station management (JWT) │ │ ├── support/ # Customer support (JWT) │ │ ├── tickets/ # Ticketing (JWT/IAM) +│ │ ├── verifayda/ # Verifayda 2.0 integration │ │ └── wallet/ # Wallet system (JWT) │ ├── app.module.ts # Root application module │ └── main.ts # Application entry point @@ -351,6 +451,8 @@ apps/edr-passenger-api/ - `Notification`, `NotificationTemplate` - `AuditLog`, `OperationalReport` - `SeatBlock`, `SeatHold` +- `CurrencyExchangeRate` (Multi-currency) +- `VerifaydaVerification` (National ID verification) ## 🔧 Available Scripts @@ -467,6 +569,8 @@ pnpm --filter @edr/passenger-api run test:cov ### Pre-deployment Checklist - [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.) - [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY) +- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY) +- [ ] Set up currency exchange rate sync (external API) - [ ] Set NODE_ENV=production - [ ] Configure CORS origins (FRONTEND_URL, PORTAL_URL) - [ ] Set up SSL/TLS certificates @@ -475,6 +579,9 @@ pnpm --filter @edr/passenger-api run test:cov - [ ] Configure backup strategy - [ ] Test payment provider integrations - [ ] Verify IAM token validation endpoint +- [ ] Test Verifayda verification with real national IDs +- [ ] Verify currency conversion accuracy +- [ ] Test age-based pricing calculations - [ ] Review security settings and audit logs - [ ] Test both JWT and IAM authentication flows diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index d3f17cdd1..3b788ad3e 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -79,4 +79,10 @@ SUPPORTED_LOCALES=en,am,fr,om IAM_ENABLED=false IAM_API_URL=https://iam.tria-plc.com/api IAM_API_KEY= + +# Verifayda 2.0 Configuration (Ethiopian National ID Verification) +VERIFAYDA_ENABLED=false +VERIFAYDA_API_URL=https://api.verifayda.gov.et/v2 +VERIFAYDA_API_KEY= + GITHUB_PACKAGE_TOKEN= \ No newline at end of file diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 122a7897f..c8bcaea1d 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -13,7 +13,9 @@ "type-check": "tsc --noEmit", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", - "prisma:seed": "ts-node prisma/seed.ts" + "prisma:seed": "ts-node prisma/seed.ts", + "prisma:backfill": "ts-node prisma/backfill-fields.ts", + "prisma:verify": "ts-node prisma/verify-backfill.ts" }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/apps/edr-passenger-api/prisma/migrations/20260520000002_language_field_note/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260520000002_language_field_note/migration.sql deleted file mode 100644 index 70f6b75d7..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260520000002_language_field_note/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Language field already exists in UserPreferences table --- No migration needed diff --git a/apps/edr-passenger-api/prisma/migrations/20260521000001_add_fraud_detection/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260521000001_add_fraud_detection/migration.sql deleted file mode 100644 index ba2115db9..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260521000001_add_fraud_detection/migration.sql +++ /dev/null @@ -1,74 +0,0 @@ --- Migration: Add Fraud Detection Tables and User.blockedUntil field --- Date: 2026-05-21 --- Description: Adds FraudRule and FraudAlert tables, and blockedUntil field to User table - --- Add blockedUntil field to User table if not exists -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'User' AND column_name = 'blockedUntil' - ) THEN - ALTER TABLE "User" ADD COLUMN "blockedUntil" TIMESTAMP(3); - END IF; -END $$; - --- CreateTable FraudRule -CREATE TABLE IF NOT EXISTS "FraudRule" ( - "id" TEXT NOT NULL, - "type" TEXT NOT NULL, - "enabled" BOOLEAN NOT NULL DEFAULT true, - "threshold" DOUBLE PRECISION NOT NULL, - "config" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "FraudRule_pkey" PRIMARY KEY ("id") -); - --- CreateTable FraudAlert -CREATE TABLE IF NOT EXISTS "FraudAlert" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "eventType" TEXT NOT NULL, - "triggeredRules" TEXT[], - "context" JSONB NOT NULL, - "severity" TEXT NOT NULL DEFAULT 'MEDIUM', - "acknowledged" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "FraudAlert_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX IF NOT EXISTS "FraudRule_type_key" ON "FraudRule"("type"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged"); - --- AddForeignKey -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'FraudAlert_userId_fkey' - ) THEN - ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" - FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; -END $$; - --- Insert default fraud rules -INSERT INTO "FraudRule" ("id", "type", "enabled", "threshold", "config", "createdAt", "updatedAt") -VALUES - (gen_random_uuid(), 'VELOCITY', true, 5, '{"timeWindowMinutes": 30, "blockDurationMinutes": 30}'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HIGH_VALUE', true, 10000, '{"blockDurationMinutes": 60}'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAILED_PAYMENTS', true, 3, '{"timeWindowMinutes": 60, "blockDurationMinutes": 30}'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (type) DO NOTHING; - --- Add comments -COMMENT ON TABLE "FraudRule" IS 'Fraud detection rules for monitoring suspicious activities'; -COMMENT ON TABLE "FraudAlert" IS 'Fraud alerts triggered by rule violations'; -COMMENT ON COLUMN "User"."blockedUntil" IS 'Timestamp until which the user is blocked due to fraud or security reasons'; \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/migrations/20260519124728_initial_migration/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260521053749_initial_migration/migration.sql similarity index 87% rename from apps/edr-passenger-api/prisma/migrations/20260519124728_initial_migration/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260521053749_initial_migration/migration.sql index 17c28678c..f82c1f2a2 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260519124728_initial_migration/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260521053749_initial_migration/migration.sql @@ -13,6 +13,15 @@ CREATE TYPE "SeatStatus" AS ENUM ('AVAILABLE', 'HELD', 'BOOKED', 'BLOCKED'); -- CreateEnum CREATE TYPE "ServiceClass" AS ENUM ('ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER'); +-- CreateEnum +CREATE TYPE "PassengerCategory" AS ENUM ('ADULT', 'CHILD'); + +-- CreateEnum +CREATE TYPE "IdDocumentType" AS ENUM ('NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'); + +-- CreateEnum +CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD'); + -- CreateEnum CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED'); @@ -58,10 +67,13 @@ CREATE TABLE "User" ( "passwordHash" TEXT NOT NULL, "role" "UserRole" NOT NULL DEFAULT 'PASSENGER', "nationality" TEXT, + "nationalityCode" TEXT, "passportNumber" TEXT, "nationalId" TEXT, "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, "lockedUntil" TIMESTAMP(3), + "blockedUntil" TIMESTAMP(3), + "lastLoginAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -86,6 +98,8 @@ CREATE TABLE "Session" ( CREATE TABLE "Passenger" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, + "defaultTravelerProfileId" TEXT, + "preferredLanguage" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id") @@ -111,6 +125,8 @@ CREATE TABLE "Station" ( "code" TEXT NOT NULL, "name" TEXT NOT NULL, "city" TEXT NOT NULL, + "countryCode" TEXT, + "isOperational" BOOLEAN NOT NULL DEFAULT true, "timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa', "lat" DECIMAL(9,6) NOT NULL, "lng" DECIMAL(9,6) NOT NULL, @@ -124,6 +140,7 @@ CREATE TABLE "TrainService" ( "number" TEXT NOT NULL, "name" TEXT NOT NULL, "operatorId" TEXT NOT NULL DEFAULT 'op_edr', + "operatorName" TEXT, CONSTRAINT "TrainService_pkey" PRIMARY KEY ("id") ); @@ -132,6 +149,7 @@ CREATE TABLE "TrainService" ( CREATE TABLE "Trip" ( "id" TEXT NOT NULL, "serviceId" TEXT NOT NULL, + "routeId" TEXT, "originStationId" TEXT NOT NULL, "destinationStationId" TEXT NOT NULL, "departureAt" TIMESTAMP(3) NOT NULL, @@ -139,8 +157,10 @@ CREATE TABLE "Trip" ( "durationMinutes" INTEGER NOT NULL, "status" "TripStatus" NOT NULL DEFAULT 'SCHEDULED', "stopsCount" INTEGER NOT NULL DEFAULT 0, + "reservedCount" INTEGER NOT NULL DEFAULT 0, "onTimePercent" INTEGER NOT NULL DEFAULT 100, "carbonRating" TEXT NOT NULL DEFAULT 'A', + "notes" TEXT, CONSTRAINT "Trip_pkey" PRIMARY KEY ("id") ); @@ -180,6 +200,10 @@ CREATE TABLE "Coach" ( "tripId" TEXT NOT NULL, "label" TEXT NOT NULL, "serviceClass" "ServiceClass" NOT NULL, + "capacity" INTEGER, + "sequence" INTEGER, + "coachType" TEXT, + "amenities" JSONB, CONSTRAINT "Coach_pkey" PRIMARY KEY ("id") ); @@ -191,9 +215,12 @@ CREATE TABLE "Seat" ( "row" INTEGER NOT NULL, "col" TEXT NOT NULL, "label" TEXT NOT NULL, + "seatNumber" TEXT, "kind" "SeatKind" NOT NULL DEFAULT 'STANDARD', "status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE', "heldUntil" TIMESTAMP(3), + "isWindow" BOOLEAN NOT NULL DEFAULT false, + "isAisle" BOOLEAN NOT NULL DEFAULT false, "premiumFeeMinor" INTEGER NOT NULL DEFAULT 0, "eligibility" TEXT, @@ -207,6 +234,7 @@ CREATE TABLE "SeatHold" ( "seatIds" TEXT[], "fareQuoteId" TEXT, "passengerId" TEXT NOT NULL, + "createdBy" TEXT, "expiresAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -238,7 +266,15 @@ CREATE TABLE "Booking" ( "status" "BookingStatus" NOT NULL DEFAULT 'DRAFT', "currency" TEXT NOT NULL DEFAULT 'ETB', "totalMinor" INTEGER NOT NULL, + "adultCount" INTEGER NOT NULL DEFAULT 1, + "childCount" INTEGER NOT NULL DEFAULT 0, + "displayCurrency" "Currency", + "displayTotalMinor" INTEGER, "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', + "userAgent" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "promoCode" TEXT, + "paidAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -251,8 +287,18 @@ CREATE TABLE "BookingSeat" ( "bookingId" TEXT NOT NULL, "seatId" TEXT NOT NULL, "passengerName" TEXT NOT NULL, - "idDocumentType" TEXT, + "dateOfBirth" TIMESTAMP(3), + "passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT', + "idDocumentType" "IdDocumentType", "idDocumentNumber" TEXT, + "passportNumber" TEXT, + "passportCountry" TEXT, + "verifaydaVerified" BOOLEAN NOT NULL DEFAULT false, + "verifaydaData" JSONB, + "seatLabelSnapshot" TEXT, + "fareMinor" INTEGER, + "displayCurrency" "Currency", + "displayFareMinor" INTEGER, CONSTRAINT "BookingSeat_pkey" PRIMARY KEY ("id") ); @@ -264,6 +310,7 @@ CREATE TABLE "PaymentMethod" ( "type" "PaymentMethodType" NOT NULL, "displayName" TEXT NOT NULL, "maskedHint" TEXT, + "providerId" TEXT, "isDefault" BOOLEAN NOT NULL DEFAULT false, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -277,6 +324,7 @@ CREATE TABLE "PaymentIntent" ( "amountMinor" INTEGER NOT NULL, "currency" TEXT NOT NULL DEFAULT 'ETB', "method" "PaymentMethodType" NOT NULL, + "provider" TEXT, "status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION', "providerRef" TEXT, "clientAction" JSONB, @@ -285,6 +333,8 @@ CREATE TABLE "PaymentIntent" ( "providerTxnId" TEXT, "rawInitiation" JSONB, "paidAt" TIMESTAMP(3), + "refundedAt" TIMESTAMP(3), + "captureMethod" TEXT, "failureCode" TEXT, "failureMessage" TEXT, "expiresAt" TIMESTAMP(3), @@ -346,7 +396,9 @@ CREATE TABLE "LoyaltyAccount" ( "id" TEXT NOT NULL, "passengerId" TEXT NOT NULL, "pointsBalance" INTEGER NOT NULL DEFAULT 0, + "lifetimePoints" INTEGER NOT NULL DEFAULT 0, "tier" "LoyaltyTier" NOT NULL DEFAULT 'BRONZE', + "tierUpdatedAt" TIMESTAMP(3), "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id") @@ -382,6 +434,8 @@ CREATE TABLE "WalletAccount" ( "id" TEXT NOT NULL, "passengerId" TEXT NOT NULL, "balanceMinor" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "holdMinor" INTEGER NOT NULL DEFAULT 0, "currency" TEXT NOT NULL DEFAULT 'ETB', "updatedAt" TIMESTAMP(3) NOT NULL, @@ -441,6 +495,8 @@ CREATE TABLE "StationCrowdSignal" ( "level" TEXT NOT NULL, "label" TEXT NOT NULL, "statusLabel" TEXT NOT NULL, + "confidence" INTEGER, + "observedAt" TIMESTAMP(3), "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "StationCrowdSignal_pkey" PRIMARY KEY ("id") @@ -476,6 +532,7 @@ CREATE TABLE "MenuItem" ( "priceMinor" INTEGER NOT NULL, "currency" TEXT NOT NULL DEFAULT 'ETB', "available" BOOLEAN NOT NULL DEFAULT true, + "availableUntil" TIMESTAMP(3), CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id") ); @@ -487,6 +544,8 @@ CREATE TABLE "FoodOrder" ( "status" "FoodOrderStatus" NOT NULL DEFAULT 'PENDING', "totalMinor" INTEGER NOT NULL, "currency" TEXT NOT NULL DEFAULT 'ETB', + "specialInstructions" TEXT, + "estimatedReadyAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "FoodOrder_pkey" PRIMARY KEY ("id") @@ -499,6 +558,7 @@ CREATE TABLE "FoodOrderItem" ( "menuItemId" TEXT NOT NULL, "name" TEXT NOT NULL, "quantity" INTEGER NOT NULL, + "unitPriceMinor" INTEGER, "lineTotalMinor" INTEGER NOT NULL, CONSTRAINT "FoodOrderItem_pkey" PRIMARY KEY ("id") @@ -528,6 +588,7 @@ CREATE TABLE "FaqArticle" ( CREATE TABLE "SupportConversation" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, + "assignedAgentId" TEXT, "status" "SupportConversationStatus" NOT NULL DEFAULT 'OPEN', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -540,6 +601,7 @@ CREATE TABLE "SupportMessage" ( "conversationId" TEXT NOT NULL, "sender" "SupportSender" NOT NULL, "text" TEXT NOT NULL, + "attachments" JSONB, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "SupportMessage_pkey" PRIMARY KEY ("id") @@ -676,9 +738,11 @@ CREATE TABLE "RouteFareRule" ( "id" TEXT NOT NULL, "routeId" TEXT NOT NULL, "serviceClass" "ServiceClass" NOT NULL, - "passengerCategory" TEXT NOT NULL DEFAULT 'ADULT', + "passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT', "baseFareMinor" INTEGER NOT NULL, "discountPercent" INTEGER, + "taxPercent" INTEGER, + "surchargeMinor" INTEGER, "currency" TEXT NOT NULL DEFAULT 'ETB', "validFrom" TIMESTAMP(3) NOT NULL, "validUntil" TIMESTAMP(3), @@ -865,6 +929,61 @@ CREATE TABLE "OperationalReport" ( CONSTRAINT "OperationalReport_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "FraudRule" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "threshold" DOUBLE PRECISION NOT NULL, + "config" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FraudRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FraudAlert" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "triggeredRules" TEXT[], + "context" JSONB NOT NULL, + "severity" TEXT NOT NULL DEFAULT 'MEDIUM', + "acknowledged" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FraudAlert_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CurrencyExchangeRate" ( + "id" TEXT NOT NULL, + "fromCurrency" "Currency" NOT NULL, + "toCurrency" "Currency" NOT NULL, + "rate" DECIMAL(18,6) NOT NULL, + "effectiveDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "source" TEXT NOT NULL DEFAULT 'MANUAL', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CurrencyExchangeRate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerifaydaVerification" ( + "id" TEXT NOT NULL, + "bookingId" TEXT, + "nationalId" TEXT NOT NULL, + "requestPayload" JSONB NOT NULL, + "responsePayload" JSONB, + "verified" BOOLEAN NOT NULL DEFAULT false, + "failureReason" TEXT, + "verifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); @@ -877,12 +996,21 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); -- CreateIndex CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId"); +-- CreateIndex +CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId"); + -- CreateIndex CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code"); +-- CreateIndex +CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); + -- CreateIndex CREATE UNIQUE INDEX "TrainService_number_key" ON "TrainService"("number"); +-- CreateIndex +CREATE INDEX "Trip_departureAt_originStationId_idx" ON "Trip"("departureAt", "originStationId"); + -- CreateIndex CREATE UNIQUE INDEX "TripStopTime_tripId_sequence_key" ON "TripStopTime"("tripId", "sequence"); @@ -895,9 +1023,21 @@ CREATE UNIQUE INDEX "Coach_tripId_label_key" ON "Coach"("tripId", "label"); -- CreateIndex CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col"); +-- CreateIndex +CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber"); + +-- CreateIndex +CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt"); + -- CreateIndex CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef"); +-- CreateIndex +CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); + +-- CreateIndex +CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault"); + -- CreateIndex CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId"); @@ -925,6 +1065,9 @@ CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passen -- CreateIndex CREATE UNIQUE INDEX "WalletAccount_passengerId_key" ON "WalletAccount"("passengerId"); +-- CreateIndex +CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId"); + -- CreateIndex CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code"); @@ -997,6 +1140,27 @@ CREATE INDEX "SeatBlock_seatId_idx" ON "SeatBlock"("seatId"); -- CreateIndex CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"("reportType", "dateFrom"); +-- CreateIndex +CREATE UNIQUE INDEX "FraudRule_type_key" ON "FraudRule"("type"); + +-- CreateIndex +CREATE INDEX "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged"); + +-- CreateIndex +CREATE INDEX "CurrencyExchangeRate_fromCurrency_toCurrency_idx" ON "CurrencyExchangeRate"("fromCurrency", "toCurrency"); + +-- CreateIndex +CREATE UNIQUE INDEX "CurrencyExchangeRate_fromCurrency_toCurrency_effectiveDate_key" ON "CurrencyExchangeRate"("fromCurrency", "toCurrency", "effectiveDate"); + +-- CreateIndex +CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"("nationalId"); + +-- CreateIndex +CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId"); + -- AddForeignKey ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -1143,3 +1307,6 @@ ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userI -- AddForeignKey ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/migration_lock.toml b/apps/edr-passenger-api/prisma/migrations/migration_lock.toml index fbffa92c2..044d57cdb 100644 --- a/apps/edr-passenger-api/prisma/migrations/migration_lock.toml +++ b/apps/edr-passenger-api/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 72fb4b15b..4720eca82 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -46,6 +46,24 @@ enum ServiceClass { VIP_BED_UPPER } +enum PassengerCategory { + ADULT + CHILD +} + +enum IdDocumentType { + NATIONAL_ID + PASSPORT + DRIVING_LICENSE + OTHER +} + +enum Currency { + ETB + DJF + USD +} + enum BookingStatus { DRAFT PENDING_PAYMENT @@ -142,11 +160,13 @@ model User { passwordHash String role UserRole @default(PASSENGER) nationality String? + nationalityCode String? passportNumber String? nationalId String? failedLoginAttempts Int @default(0) lockedUntil DateTime? blockedUntil DateTime? + lastLoginAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger? @@ -173,6 +193,8 @@ model Session { model Passenger { id String @id @default(uuid()) userId String @unique + defaultTravelerProfileId String? + preferredLanguage String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) bookings Booking[] @@ -181,6 +203,7 @@ model Passenger { notifications Notification[] travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] + @@index([userId]) } model TravelerProfile { @@ -200,6 +223,8 @@ model Station { code String @unique name String city String + countryCode String? + isOperational Boolean @default(true) timezone String @default("Africa/Addis_Ababa") lat Decimal @db.Decimal(9, 6) lng Decimal @db.Decimal(9, 6) @@ -207,6 +232,7 @@ model Station { destinationTrips Trip[] @relation("DestinationTrips") stopTimes TripStopTime[] crowdSignals StationCrowdSignal[] + @@index([city, countryCode]) } model TrainService { @@ -214,12 +240,14 @@ model TrainService { number String @unique name String operatorId String @default("op_edr") + operatorName String? trips Trip[] } model Trip { id String @id @default(uuid()) serviceId String + routeId String? originStationId String destinationStationId String departureAt DateTime @@ -227,8 +255,10 @@ model Trip { durationMinutes Int status TripStatus @default(SCHEDULED) stopsCount Int @default(0) + reservedCount Int @default(0) onTimePercent Int @default(100) carbonRating String @default("A") + notes String? service TrainService @relation(fields: [serviceId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) @@ -238,6 +268,7 @@ model Trip { liveStatus TripLiveStatus? menuItems MenuItem[] journeySegments JourneySegment[] + @@index([departureAt, originStationId]) } model TripStopTime { @@ -272,6 +303,10 @@ model Coach { tripId String label String serviceClass ServiceClass + capacity Int? + sequence Int? + coachType String? + amenities Json? trip Trip @relation(fields: [tripId], references: [id]) seats Seat[] @@unique([tripId, label]) @@ -283,15 +318,19 @@ model Seat { row Int col String label String + seatNumber String? kind SeatKind @default(STANDARD) status SeatStatus @default(AVAILABLE) heldUntil DateTime? + isWindow Boolean @default(false) + isAisle Boolean @default(false) premiumFeeMinor Int @default(0) eligibility String? coach Coach @relation(fields: [coachId], references: [id]) bookingSeats BookingSeat[] blocks SeatBlock[] @@unique([coachId, row, col]) + @@unique([coachId, seatNumber]) } model SeatHold { @@ -300,8 +339,10 @@ model SeatHold { seatIds String[] fareQuoteId String? passengerId String + createdBy String? expiresAt DateTime createdAt DateTime @default(now()) + @@index([expiresAt]) } model FareRule { @@ -318,16 +359,24 @@ model FareRule { } model Booking { - id String @id @default(uuid()) - bookingRef String @unique - passengerId String - tripId String - status BookingStatus @default(DRAFT) - currency String @default("ETB") - totalMinor Int - bookingType String @default("ONE_WAY") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + bookingRef String @unique + passengerId String + tripId String + status BookingStatus @default(DRAFT) + currency String @default("ETB") + totalMinor Int + adultCount Int @default(1) + childCount Int @default(0) + displayCurrency Currency? + displayTotalMinor Int? + bookingType String @default("ONE_WAY") + userAgent String? + source String @default("WEB") + promoCode String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) trip Trip @relation(fields: [tripId], references: [id]) seats BookingSeat[] @@ -338,15 +387,26 @@ model Booking { modifications BookingModification[] cancellation BookingCancellation? baggage BaggageBooking[] + @@index([passengerId, status]) } model BookingSeat { - id String @id @default(uuid()) - bookingId String - seatId String - passengerName String - idDocumentType String? - idDocumentNumber String? + id String @id @default(uuid()) + bookingId String + seatId String + passengerName String + dateOfBirth DateTime? + passengerCategory PassengerCategory @default(ADULT) + idDocumentType IdDocumentType? + idDocumentNumber String? + passportNumber String? + passportCountry String? + verifaydaVerified Boolean @default(false) + verifaydaData Json? + seatLabelSnapshot String? + fareMinor Int? + displayCurrency Currency? + displayFareMinor Int? booking Booking @relation(fields: [bookingId], references: [id]) seat Seat @relation(fields: [seatId], references: [id]) } @@ -357,8 +417,10 @@ model PaymentMethod { type PaymentMethodType displayName String maskedHint String? + providerId String? isDefault Boolean @default(false) createdAt DateTime @default(now()) + @@index([userId, isDefault]) } model PaymentIntent { @@ -367,6 +429,7 @@ model PaymentIntent { amountMinor Int currency String @default("ETB") method PaymentMethodType + provider String? status PaymentIntentStatus @default(REQUIRES_ACTION) providerRef String? clientAction Json? @@ -375,6 +438,8 @@ model PaymentIntent { providerTxnId String? rawInitiation Json? paidAt DateTime? + refundedAt DateTime? + captureMethod String? failureCode String? failureMessage String? expiresAt DateTime? @@ -433,7 +498,9 @@ model LoyaltyAccount { id String @id @default(uuid()) passengerId String @unique pointsBalance Int @default(0) + lifetimePoints Int @default(0) tier LoyaltyTier @default(BRONZE) + tierUpdatedAt DateTime? updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) ledger LoyaltyLedgerEntry[] @@ -465,10 +532,13 @@ model WalletAccount { id String @id @default(uuid()) passengerId String @unique balanceMinor Int @default(0) + status String @default("ACTIVE") + holdMinor Int @default(0) currency String @default("ETB") updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) ledger WalletLedgerEntry[] + @@index([passengerId]) } model WalletLedgerEntry { @@ -516,6 +586,8 @@ model StationCrowdSignal { level String label String statusLabel String + confidence Int? + observedAt DateTime? updatedAt DateTime @updatedAt station Station @relation(fields: [stationId], references: [id]) } @@ -544,6 +616,7 @@ model MenuItem { priceMinor Int currency String @default("ETB") available Boolean @default(true) + availableUntil DateTime? trip Trip @relation(fields: [tripId], references: [id]) category MenuCategory @relation(fields: [categoryId], references: [id]) } @@ -554,6 +627,8 @@ model FoodOrder { status FoodOrderStatus @default(PENDING) totalMinor Int currency String @default("ETB") + specialInstructions String? + estimatedReadyAt DateTime? createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) items FoodOrderItem[] @@ -565,6 +640,7 @@ model FoodOrderItem { menuItemId String name String quantity Int + unitPriceMinor Int? lineTotalMinor Int order FoodOrder @relation(fields: [orderId], references: [id]) } @@ -588,6 +664,7 @@ model FaqArticle { model SupportConversation { id String @id @default(uuid()) userId String + assignedAgentId String? status SupportConversationStatus @default(OPEN) createdAt DateTime @default(now()) messages SupportMessage[] @@ -598,6 +675,7 @@ model SupportMessage { conversationId String sender SupportSender text String + attachments Json? createdAt DateTime @default(now()) conversation SupportConversation @relation(fields: [conversationId], references: [id]) } @@ -715,17 +793,19 @@ model RouteStop { } model RouteFareRule { - id String @id @default(uuid()) + id String @id @default(uuid()) routeId String serviceClass ServiceClass - passengerCategory String @default("ADULT") + passengerCategory PassengerCategory @default(ADULT) baseFareMinor Int discountPercent Int? - currency String @default("ETB") + taxPercent Int? + surchargeMinor Int? + currency String @default("ETB") validFrom DateTime validUntil DateTime? - createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) @@index([routeId, serviceClass]) } @@ -915,3 +995,29 @@ model FraudAlert { @@index([userId, createdAt]) @@index([acknowledged]) } + +model CurrencyExchangeRate { + id String @id @default(uuid()) + fromCurrency Currency + toCurrency Currency + rate Decimal @db.Decimal(18, 6) + effectiveDate DateTime @default(now()) + source String @default("MANUAL") + createdAt DateTime @default(now()) + @@unique([fromCurrency, toCurrency, effectiveDate]) + @@index([fromCurrency, toCurrency]) +} + +model VerifaydaVerification { + id String @id @default(uuid()) + bookingId String? + nationalId String + requestPayload Json + responsePayload Json? + verified Boolean @default(false) + failureReason String? + verifiedAt DateTime? + createdAt DateTime @default(now()) + @@index([nationalId]) + @@index([bookingId]) +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 65607813e..b8c276a08 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient, ServiceClass, UserRole, LoyaltyTier, SeatKind } from '@prisma/client'; +import { PrismaClient, ServiceClass, UserRole, LoyaltyTier, SeatKind, PassengerCategory, Currency } from '@prisma/client'; import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); @@ -6,28 +6,28 @@ const prisma = new PrismaClient(); async function main() { console.log('🌱 Starting comprehensive seed...'); - // All 18 Stations (Ethiopian-Djibouti Railway) - const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa Central', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } }); - const sebeta = await prisma.station.upsert({ where: { code: 'SBT' }, update: {}, create: { code: 'SBT', name: 'Sebeta', city: 'Sebeta', lat: 8.9167, lng: 38.6167 } }); - const labu = await prisma.station.upsert({ where: { code: 'LBU' }, update: {}, create: { code: 'LBU', name: 'Labu', city: 'Labu', lat: 8.8500, lng: 38.8500 } }); - const indode = await prisma.station.upsert({ where: { code: 'IND' }, update: {}, create: { code: 'IND', name: 'Indode', city: 'Indode', lat: 8.7833, lng: 39.0167 } }); - const bishoftu = await prisma.station.upsert({ where: { code: 'BSH' }, update: {}, create: { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', lat: 8.7500, lng: 38.9833 } }); - const mojo = await prisma.station.upsert({ where: { code: 'MJO' }, update: {}, create: { code: 'MJO', name: 'Mojo', city: 'Mojo', lat: 8.5833, lng: 39.1167 } }); - const adama = await prisma.station.upsert({ where: { code: 'ADM' }, update: {}, create: { code: 'ADM', name: 'Adama', city: 'Adama', lat: 8.5400, lng: 39.2675 } }); - const feto = await prisma.station.upsert({ where: { code: 'FTO' }, update: {}, create: { code: 'FTO', name: 'Feto', city: 'Feto', lat: 8.7167, lng: 39.5833 } }); - const metahara = await prisma.station.upsert({ where: { code: 'MTH' }, update: {}, create: { code: 'MTH', name: 'Metahara', city: 'Metahara', lat: 8.9000, lng: 39.9167 } }); - const awash = await prisma.station.upsert({ where: { code: 'AWS' }, update: {}, create: { code: 'AWS', name: 'Awash', city: 'Awash', lat: 8.9833, lng: 40.1667 } }); - const mieso = await prisma.station.upsert({ where: { code: 'MSO' }, update: {}, create: { code: 'MSO', name: 'Mieso', city: 'Mieso', lat: 9.2333, lng: 40.7500 } }); - const bike = await prisma.station.upsert({ where: { code: 'BKE' }, update: {}, create: { code: 'BKE', name: 'Bike', city: 'Bike', lat: 9.4167, lng: 41.2500 } }); - const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } }); - const arawa = await prisma.station.upsert({ where: { code: 'ARW' }, update: {}, create: { code: 'ARW', name: 'Arawa', city: 'Arawa', lat: 10.0833, lng: 42.2500 } }); - const adigala = await prisma.station.upsert({ where: { code: 'ADG' }, update: {}, create: { code: 'ADG', name: 'Adigala', city: 'Adigala', lat: 10.5000, lng: 42.5833 } }); - const aysha = await prisma.station.upsert({ where: { code: 'AYS' }, update: {}, create: { code: 'AYS', name: 'Aysha', city: 'Aysha', lat: 11.5500, lng: 42.7167 } }); - const dawanle = await prisma.station.upsert({ where: { code: 'DWN' }, update: {}, create: { code: 'DWN', name: 'Dawanle', city: 'Dawanle', timezone: 'Africa/Djibouti', lat: 11.3833, lng: 42.8500 } }); - const alisabieh = await prisma.station.upsert({ where: { code: 'ALI' }, update: {}, create: { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', timezone: 'Africa/Djibouti', lat: 11.1667, lng: 42.7167 } }); - const holhol = await prisma.station.upsert({ where: { code: 'HLH' }, update: {}, create: { code: 'HLH', name: 'Holhol', city: 'Holhol', timezone: 'Africa/Djibouti', lat: 11.4167, lng: 43.0000 } }); - const nagad = await prisma.station.upsert({ where: { code: 'NGD' }, update: {}, create: { code: 'NGD', name: 'Nagad', city: 'Nagad', timezone: 'Africa/Djibouti', lat: 11.5167, lng: 43.1000 } }); - const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } }); + // All 21 Stations (Ethiopian-Djibouti Railway) + 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 labu = await prisma.station.upsert({ where: { code: 'LBU' }, update: {}, create: { code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.8500 } }); + const indode = await prisma.station.upsert({ where: { code: 'IND' }, update: {}, create: { code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7833, lng: 39.0167 } }); + const bishoftu = await prisma.station.upsert({ where: { code: 'BSH' }, update: {}, create: { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 } }); + const mojo = await prisma.station.upsert({ where: { code: 'MJO' }, update: {}, create: { code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.5833, lng: 39.1167 } }); + 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 feto = await prisma.station.upsert({ where: { code: 'FTO' }, update: {}, create: { code: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.7167, lng: 39.5833 } }); + const metahara = await prisma.station.upsert({ where: { code: 'MTH' }, update: {}, create: { code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 } }); + 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 mieso = await prisma.station.upsert({ where: { code: 'MSO' }, update: {}, create: { code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2333, lng: 40.7500 } }); + const bike = await prisma.station.upsert({ where: { code: 'BKE' }, update: {}, create: { code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4167, lng: 41.2500 } }); + 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 arawa = await prisma.station.upsert({ where: { code: 'ARW' }, update: {}, create: { code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.0833, lng: 42.2500 } }); + const adigala = await prisma.station.upsert({ where: { code: 'ADG' }, update: {}, create: { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.5000, lng: 42.5833 } }); + 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 dawanle = await prisma.station.upsert({ where: { code: 'DWN' }, update: {}, create: { code: 'DWN', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.3833, lng: 42.8500 } }); + const alisabieh = await prisma.station.upsert({ where: { code: 'ALI' }, update: {}, create: { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.1667, lng: 42.7167 } }); + const holhol = await prisma.station.upsert({ where: { code: 'HLH' }, update: {}, create: { code: 'HLH', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.4167, lng: 43.0000 } }); + const nagad = await prisma.station.upsert({ where: { code: 'NGD' }, update: {}, create: { code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5167, lng: 43.1000 } }); + 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 } }); // Routes const route1 = await prisma.route.upsert({ @@ -62,12 +62,15 @@ async function main() { { routeId: route1.id, stationId: djibouti.id, sequence: 21, distanceKm: 756 }, ]}); - // Route Fare Rules + // Route Fare Rules (with passenger categories) await prisma.routeFareRule.deleteMany({ where: { routeId: route1.id } }); await prisma.routeFareRule.createMany({ data: [ - { routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, - { routeId: route1.id, serviceClass: 'ECONOMY_BED_LOWER', baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: route1.id, serviceClass: 'VIP_BED_LOWER', baseFareMinor: 95000, validFrom: new Date('2026-01-01') }, + { routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', passengerCategory: 'ADULT', baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, + { routeId: route1.id, serviceClass: 'ECONOMY_BED_LOWER', passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, + { routeId: route1.id, serviceClass: 'VIP_BED_LOWER', passengerCategory: 'ADULT', baseFareMinor: 95000, validFrom: new Date('2026-01-01') }, + { routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', passengerCategory: 'CHILD', baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, + { routeId: route1.id, serviceClass: 'ECONOMY_BED_LOWER', passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, + { routeId: route1.id, serviceClass: 'VIP_BED_LOWER', passengerCategory: 'CHILD', baseFareMinor: 95000, validFrom: new Date('2026-01-01') }, ]}); // Train Services @@ -217,12 +220,24 @@ async function main() { } } + // 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: 'DJF', rate: 3.25, effectiveDate: new Date() }, + { fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() }, + { fromCurrency: 'DJF', toCurrency: 'ETB', rate: 0.3077, effectiveDate: new Date() }, + { fromCurrency: 'USD', toCurrency: 'ETB', rate: 55.56, effectiveDate: new Date() }, + ]}); + console.log('✅ Comprehensive seed complete'); console.log('\n📋 Seed Summary:'); - console.log(' - 18 Stations (Complete Ethiopian-Djibouti Railway)'); + console.log(' - 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)'); console.log(' - 1 Route with 21 stops'); console.log(' - 2 Train services, 4 trips'); console.log(' - 3 Coaches per trip (Economy, Bed, VIP)'); + console.log(' - Fare rules for ADULT and CHILD categories'); + console.log(' - Currency exchange rates (ETB ↔ DJF, USD)'); console.log(' - 3 Users: Admin, Passenger (Silver tier + wallet), Agent'); console.log(' - 3 Fraud detection rules'); console.log(' - 3 Loyalty rewards'); @@ -232,6 +247,9 @@ async function main() { console.log(' Passenger: kelemu@email.com / password123'); console.log(' Agent: agent@edr-platform.com / agent123'); console.log('\n🚉 Stations: Addis Ababa → Sebeta → Labu → Indode → Bishoftu → Mojo → Adama → Feto → Metahara → Awash → Mieso → Bike → Dire Dawa → Arawa → Adigala → Aysha → Dawanle → Alisabieh → Holhol → Nagad → Djibouti'); + console.log('\n💰 Pricing: ADULT (≥5 years) = 100% fare | CHILD (<5 years) = First free, subsequent 100%'); + console.log('\n💱 Currencies: ETB (transaction) | DJF, USD (display) | Rates: ETB→DJF=3.25, ETB→USD=0.018'); + console.log('\n🔐 Verifayda: DISABLED (set VERIFAYDA_ENABLED=true in production)'); } main().catch(console.error).finally(() => prisma.$disconnect()); diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 37f4fc433..f17289de4 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; +import { IdDocumentType } from '@prisma/client'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -35,9 +36,9 @@ export class AgentsService { totalMinor, seats: { create: dto.passengers.map(p => ({ - seatId: p.seatId, + seat: { connect: { id: p.seatId } }, passengerName: p.fullName, - idDocumentType: p.idDocumentType, + idDocumentType: p.idDocumentType as IdDocumentType | undefined, idDocumentNumber: p.idDocumentNumber })) } 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 b7363ec70..a0fb8afba 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -12,25 +12,51 @@ export class BookingsController { constructor(private service: BookingsService) {} @Post() - @ApiOperation({ summary: 'Create booking from seat hold' }) + @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` + }) + @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) + @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) + @ApiResponse({ status: 404, description: 'Trip or seat hold not found' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); } @Get(':bookingRef') - @ApiOperation({ summary: 'Get booking by reference' }) + @ApiOperation({ + summary: 'Get booking details by reference', + description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts' + }) + @ApiResponse({ status: 200, description: 'Booking details with adult/child counts and currency conversion' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } @Patch(':bookingRef/modify') - @ApiOperation({ summary: 'Modify booking seats or trip' }) + @ApiOperation({ + summary: 'Modify booking seats or trip', + description: 'Allows modification of confirmed bookings before departure' + }) + @ApiResponse({ status: 200, description: 'Booking modified successfully' }) + @ApiResponse({ status: 400, description: 'Cannot modify cancelled or past bookings' }) modify(@Body() dto: ModifyBookingDto) { return this.service.modify(dto); } @Delete(':bookingRef') - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ + summary: 'Cancel booking with refund', + description: 'Cancels booking and processes refund (80% for confirmed bookings)' + }) + @ApiResponse({ status: 200, description: 'Booking cancelled with refund amount' }) + @ApiResponse({ status: 400, description: 'Booking already cancelled' }) cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) { return this.service.cancel(ref, dto.reason); } 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 eae26111e..0ef1debd0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,14 +1,16 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Currency, IdDocumentType } from '@prisma/client'; export class PassengerInputDto { - @ApiProperty() @IsString() fullName: string; - @ApiProperty() @IsString() phone: string; - @ApiProperty() @IsString() email: string; @ApiProperty() @IsString() seatId: string; - @ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string; - @ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: 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; } export class CreateBookingDto { @@ -16,15 +18,15 @@ export class CreateBookingDto { @ApiProperty() @IsString() tripId: string; @ApiProperty() @IsString() holdId: string; @ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; - @ApiPropertyOptional({ + @ApiProperty({ example: 'ECONOMY_REGULAR', enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER'] }) - @IsOptional() @IsString() serviceClass?: string; + @IsString() serviceClass: string; @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; @ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; - @ApiPropertyOptional({ description: 'Auto-assign seats instead of manual selection' }) @IsOptional() autoAssign?: boolean; + @ApiPropertyOptional({ example: 'ETB', enum: Currency }) @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 5632af5d7..a73d16796 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -2,7 +2,13 @@ import { Module } from '@nestjs/common'; import { BookingsController } from './bookings.controller'; import { BookingsService } from './bookings.service'; import { SeatsModule } from '../seats/seats.module'; -import { SearchModule } from '../search/search.module'; +import { VerifaydaModule } from '../verifayda/verifayda.module'; +import { CurrencyModule } from '../currency/currency.module'; -@Module({ imports: [SeatsModule, SearchModule], controllers: [BookingsController], providers: [BookingsService], exports: [BookingsService] }) +@Module({ + imports: [SeatsModule, VerifaydaModule, CurrencyModule], + controllers: [BookingsController], + providers: [BookingsService], + exports: [BookingsService] +}) 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 9532531f3..5d99717d4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -4,16 +4,34 @@ import { SeatsService } from '../seats/seats.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { SearchService } from '../search/search.service'; +import { VerifaydaService } from '../verifayda/verifayda.service'; +import { CurrencyService } from '../currency/currency.service'; +import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; return 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 BookingsService { - constructor(private prisma: PrismaService, private seatsService: SeatsService, private eventEmitter: EventEmitter2, private searchService: SearchService) {} + constructor( + private prisma: PrismaService, + private seatsService: SeatsService, + private eventEmitter: EventEmitter2, + private verifaydaService: VerifaydaService, + private currencyService: CurrencyService, + ) {} async create(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); @@ -21,45 +39,150 @@ export class BookingsService { const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } }); if (!trip) throw new NotFoundException('Trip not found'); - let seatIds: string[]; - if (dto.autoAssign) { - seatIds = await this.seatsService.autoAssignSeats( - dto.tripId, - dto.passengers.length, - dto.serviceClass ?? 'ECONOMY_REGULAR', - ); - await this.seatsService.confirmSeats(seatIds); - } else { - seatIds = dto.passengers.map((p) => p.seatId); + const seatIds = dto.passengers.map((p) => p.seatId); + + // Calculate passenger categories and verify Ethiopian nationals + const passengersData = []; + let adultCount = 0; + let 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 = undefined; + + // Verify Ethiopian nationals via Verifayda + 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 ${passenger.passengerName}: ${verification.failureReason}`, + ); + } + + // Use verified data from Verifayda + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + // Non-Ethiopian: require passport details + if (!passenger.passportNumber || !passenger.passportCountry) { + throw new BadRequestException( + `Passport number and country required for non-Ethiopian passenger ${passenger.passengerName}`, + ); + } + } + + passengersData.push({ + ...passenger, + passengerName, + dateOfBirth, + category, + verifaydaVerified, + verifaydaData, + }); } - - const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY_REGULAR', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints }); + + // Calculate fare with age-based pricing + const baseFareMinor = await this.getBaseFare(dto.tripId, dto.serviceClass); + 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 loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const taxesMinor = Math.round(totalBaseFareMinor * 0.05); + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = totalMinor; + + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } + const booking = await this.prisma.booking.create({ - data: { - bookingRef: generateRef(), - passengerId: dto.passengerId, - tripId: dto.tripId, - status: 'PENDING_PAYMENT', - totalMinor: fareQuote.totalMinor, + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + tripId: dto.tripId, + status: 'PENDING_PAYMENT', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, bookingType: dto.bookingType ?? 'ONE_WAY', - seats: { create: dto.passengers.map((p, i) => ({ seatId: seatIds[i], passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } + seats: { + create: passengersData.map((p) => ({ + seat: { connect: { id: p.seatId } }, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), + displayCurrency, + })), + }, }, include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } }, }); + + await this.seatsService.confirmSeats(seatIds); this.eventEmitter.emit('booking.created', { booking }); - return { - ...booking, + + return { + ...booking, fareBreakdown: { - baseFare: fareQuote.baseFareMinor / 100, - discount: fareQuote.discountMinor / 100, - loyaltyRedemption: fareQuote.loyaltyRedemptionMinor / 100, - taxesFees: fareQuote.taxesFeesMinor / 100, - total: fareQuote.totalMinor / 100, - currency: fareQuote.currency - } + baseFareMinor, + adultCount, + adultFareMinor, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + childFareMinor, + totalBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }, }; } + private async getBaseFare(tripId: string, serviceClass: string): Promise { + const fareRule = await this.prisma.fareRule.findFirst({ + where: { tripId, serviceClass: serviceClass as any }, + }); + return fareRule?.baseFareMinor ?? 35000; + } + async getByRef(bookingRef: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true } }); if (!booking) throw new NotFoundException('Booking not found'); @@ -68,6 +191,10 @@ export class BookingsService { bookingRef: booking.bookingRef, status: booking.status, totalFare: booking.totalMinor / 100, + adultCount: booking.adultCount, + childCount: booking.childCount, + displayCurrency: booking.displayCurrency, + displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined, bookingType: booking.bookingType, createdAt: booking.createdAt, trip: { @@ -79,6 +206,8 @@ export class BookingsService { }, passengers: booking.seats.map((bs) => ({ fullName: bs.passengerName, + category: bs.passengerCategory, + verifaydaVerified: bs.verifaydaVerified, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass }, })), payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined, diff --git a/apps/edr-passenger-api/src/modules/currency/currency.module.ts b/apps/edr-passenger-api/src/modules/currency/currency.module.ts new file mode 100644 index 000000000..445f31e05 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currency/currency.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CurrencyService } from './currency.service'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [CurrencyService], + exports: [CurrencyService], +}) +export class CurrencyModule {} diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts new file mode 100644 index 000000000..3fcfce179 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { Currency } from '@prisma/client'; + +@Injectable() +export class CurrencyService { + private readonly logger = new Logger(CurrencyService.name); + + constructor(private readonly prisma: PrismaService) {} + + async convertAmount( + amountMinor: number, + fromCurrency: Currency, + toCurrency: Currency, + ): Promise { + if (fromCurrency === toCurrency) { + return amountMinor; + } + + const rate = await this.getExchangeRate(fromCurrency, toCurrency); + return Math.round(amountMinor * rate); + } + + async getExchangeRate( + fromCurrency: Currency, + toCurrency: Currency, + ): Promise { + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { + fromCurrency, + toCurrency, + }, + orderBy: { + effectiveDate: 'desc', + }, + }); + + if (!exchangeRate) { + this.logger.warn( + `No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`, + ); + return 1.0; + } + + return Number(exchangeRate.rate); + } + + async syncExchangeRates(): Promise { + this.logger.log('Syncing exchange rates from external provider'); + + // In production, fetch from external API + // For now, using static rates + const rates = [ + { from: 'ETB', to: 'ETB', rate: 1.0 }, + { from: 'ETB', to: 'DJF', rate: 3.25 }, + { from: 'ETB', to: 'USD', rate: 0.018 }, + { from: 'DJF', to: 'ETB', rate: 0.3077 }, + { from: 'USD', to: 'ETB', rate: 55.56 }, + ]; + + for (const { from, to, rate } of rates) { + await this.prisma.currencyExchangeRate.upsert({ + where: { + fromCurrency_toCurrency_effectiveDate: { + fromCurrency: from as Currency, + toCurrency: to as Currency, + effectiveDate: new Date(), + }, + }, + update: { rate }, + create: { + fromCurrency: from as Currency, + toCurrency: to as Currency, + rate, + effectiveDate: new Date(), + source: 'EXTERNAL_API', + }, + }); + } + + this.logger.log('Exchange rates synced successfully'); + } +} 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 9409873ed..2158f9cd4 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { SearchService } from './search.service'; import { SearchTripsDto, FareQuoteDto } from './search.dto'; @@ -7,6 +7,26 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto'; @Controller('search') export class SearchController { constructor(private service: SearchService) {} - @Post() @ApiOperation({ summary: 'Search trips' }) searchTrips(@Body() dto: SearchTripsDto) { return this.service.searchTrips(dto); } - @Post('fare-quote')@ApiOperation({ summary: 'Get fare quote' }) getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); } + + @Post() + @ApiOperation({ + summary: 'Search trips by origin, destination, and passenger counts', + description: 'Returns available trips WITHOUT pricing. Requires adult count (mandatory) and optional child count. Pricing is shown only in fare quote endpoint.' + }) + @ApiResponse({ status: 200, description: 'List of available trips with seat availability' }) + @ApiResponse({ status: 400, description: 'Invalid search parameters' }) + searchTrips(@Body() dto: SearchTripsDto) { + return this.service.searchTrips(dto); + } + + @Post('fare-quote') + @ApiOperation({ + summary: 'Get detailed fare quote with age-based pricing', + description: 'Calculates fare based on adult/child counts. First child travels free, subsequent children pay full fare. Supports multi-currency display (ETB, DJF, USD).' + }) + @ApiResponse({ status: 200, description: 'Detailed fare breakdown with adult/child pricing and currency conversion' }) + @ApiResponse({ status: 404, description: 'Trip not found' }) + getFareQuote(@Body() dto: FareQuoteDto) { + return this.service.getFareQuote(dto); + } } 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 3d8344218..7a85fe816 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -1,12 +1,14 @@ -import { IsString, IsDateString, IsInt, IsOptional, Min } from 'class-validator'; +import { IsString, IsDateString, IsInt, IsOptional, Min, IsEnum } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; +import { Currency } from '@prisma/client'; export class SearchTripsDto { @ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string; @ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string; @ApiProperty({ example: '2026-05-11' }) @IsDateString() date: string; - @ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengers?: number; + @ApiProperty({ example: 2, description: 'Number of adults (5 years and above)' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number; + @ApiPropertyOptional({ example: 1, description: 'Number of children (below 5 years)' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number; } export class FareQuoteDto { @@ -16,7 +18,9 @@ export class FareQuoteDto { enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER'] }) @IsString() serviceClass: string; - @ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number; + @ApiProperty({ example: 2, description: 'Number of adults' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number; + @ApiPropertyOptional({ example: 1, description: 'Number of children' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number; @ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string; @ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number; + @ApiPropertyOptional({ example: 'ETB', enum: ['ETB', 'DJF', 'USD'] }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; } diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts index e156d3cde..5228590fb 100644 --- a/apps/edr-passenger-api/src/modules/search/search.module.ts +++ b/apps/edr-passenger-api/src/modules/search/search.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; +import { CurrencyModule } from '../currency/currency.module'; -@Module({ controllers: [SearchController], providers: [SearchService], exports: [SearchService] }) +@Module({ + imports: [CurrencyModule], + controllers: [SearchController], + providers: [SearchService], + exports: [SearchService] +}) export class SearchModule {} 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 bf709b6d8..7a6bef602 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -1,12 +1,17 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { CurrencyService } from '../currency/currency.service'; +import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; @Injectable() export class SearchService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private currencyService: CurrencyService, + ) {} async searchTrips(dto: SearchTripsDto) { const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000); @@ -14,6 +19,9 @@ export class SearchService { where: { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: { gte: date, lt: nextDay }, status: { in: ['SCHEDULED', 'BOARDING'] } }, include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } }, }); + + const totalPassengers = dto.adultCount + (dto.childCount || 0); + return trips.map((trip) => { const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats); const avail = (cls: string) => seatsByClass(cls).filter((s) => s.status === 'AVAILABLE').length; @@ -24,20 +32,12 @@ export class SearchService { destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city }, departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status, availability: { - ECONOMY_REGULAR: avail('ECONOMY_REGULAR'), - ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER'), - ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE'), - ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER'), - VIP_BED_LOWER: avail('VIP_BED_LOWER'), - VIP_BED_UPPER: avail('VIP_BED_UPPER') - }, - fares: { - ECONOMY_REGULAR: this.defaultFare('ECONOMY_REGULAR') / 100, - ECONOMY_BED_LOWER: this.defaultFare('ECONOMY_BED_LOWER') / 100, - ECONOMY_BED_MIDDLE: this.defaultFare('ECONOMY_BED_MIDDLE') / 100, - ECONOMY_BED_UPPER: this.defaultFare('ECONOMY_BED_UPPER') / 100, - VIP_BED_LOWER: this.defaultFare('VIP_BED_LOWER') / 100, - VIP_BED_UPPER: this.defaultFare('VIP_BED_UPPER') / 100 + ECONOMY_REGULAR: avail('ECONOMY_REGULAR') >= totalPassengers, + ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER') >= totalPassengers, + ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE') >= totalPassengers, + ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER') >= totalPassengers, + VIP_BED_LOWER: avail('VIP_BED_LOWER') >= totalPassengers, + VIP_BED_UPPER: avail('VIP_BED_UPPER') >= totalPassengers }, }; }); @@ -46,26 +46,69 @@ export class SearchService { async getFareQuote(dto: FareQuoteDto) { const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } }); if (!trip) throw new NotFoundException('Trip not found'); - const count = dto.passengerCount ?? 1; - const baseFareMinor = this.defaultFare(dto.serviceClass) * count; + + const adultCount = dto.adultCount; + const childCount = dto.childCount || 0; + + const baseFareMinor = this.defaultFare(dto.serviceClass); + + // Adult fare: 100% of base fare + const adultFareMinor = baseFareMinor * adultCount; + + // Child fare: First child free, subsequent children pay full fare + 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(baseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } } + const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = Math.round(baseFareMinor * 0.05); - return { tripId: dto.tripId, serviceClass: dto.serviceClass, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' }; + const taxesMinor = Math.round(totalBaseFareMinor * 0.05); + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = totalMinor; + + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } + + return { + tripId: dto.tripId, + serviceClass: dto.serviceClass, + adultCount, + childCount, + baseFareMinor, + adultFareMinor, + childFareMinor, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + totalBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }; } private defaultFare(serviceClass: string): number { const fares: Record = { - ECONOMY_REGULAR: 35000, // 350 ETB - ECONOMY_BED_LOWER: 55000, // 550 ETB - ECONOMY_BED_MIDDLE: 50000, // 500 ETB - ECONOMY_BED_UPPER: 45000, // 450 ETB - VIP_BED_LOWER: 85000, // 850 ETB - VIP_BED_UPPER: 80000 // 800 ETB + ECONOMY_REGULAR: 35000, + ECONOMY_BED_LOWER: 55000, + ECONOMY_BED_MIDDLE: 50000, + ECONOMY_BED_UPPER: 45000, + VIP_BED_LOWER: 85000, + VIP_BED_UPPER: 80000 }; return fares[serviceClass] ?? 35000; } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts new file mode 100644 index 000000000..c14ba3f1f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { VerifaydaService } from './verifayda.service'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [VerifaydaService], + exports: [VerifaydaService], +}) +export class VerifaydaModule {} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts new file mode 100644 index 000000000..d86ad98a2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -0,0 +1,142 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../common/prisma.service'; +import axios, { AxiosInstance } from 'axios'; + +export interface VerifaydaPassengerData { + fullName: string; + dateOfBirth: Date; + gender?: string; + nationality?: string; + profileData?: Record; +} + +export interface VerifaydaVerificationResult { + verified: boolean; + passengerData?: VerifaydaPassengerData; + failureReason?: string; +} + +@Injectable() +export class VerifaydaService { + private readonly logger = new Logger(VerifaydaService.name); + private readonly httpClient: AxiosInstance; + private readonly enabled: boolean; + private readonly apiUrl: string; + private readonly apiKey: string; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) { + this.enabled = this.config.get('VERIFAYDA_ENABLED', false); + this.apiUrl = this.config.get('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'); + this.apiKey = this.config.get('VERIFAYDA_API_KEY', ''); + + this.httpClient = axios.create({ + baseURL: this.apiUrl, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey, + }, + }); + } + + async verifyNationalId( + nationalId: string, + bookingId?: string, + ): Promise { + if (!this.enabled) { + this.logger.warn('Verifayda is disabled - skipping verification'); + return { + verified: false, + failureReason: 'Verifayda integration is disabled', + }; + } + + const requestPayload = { + nationalId, + requestedFields: ['fullName', 'dateOfBirth', 'gender', 'nationality'], + timestamp: new Date().toISOString(), + }; + + try { + this.logger.log(`Verifying national ID via Verifayda 2.0`); + + const response = await this.httpClient.post('/verify', requestPayload); + + const { data } = response; + + if (data.status === 'verified' && data.citizen) { + const passengerData: VerifaydaPassengerData = { + fullName: data.citizen.fullName, + dateOfBirth: new Date(data.citizen.dateOfBirth), + gender: data.citizen.gender, + nationality: data.citizen.nationality || 'Ethiopian', + profileData: data.citizen, + }; + + await this.prisma.verifaydaVerification.create({ + data: { + bookingId, + nationalId, + requestPayload, + responsePayload: data, + verified: true, + verifiedAt: new Date(), + }, + }); + + this.logger.log('Verifayda verification successful'); + + return { + verified: true, + passengerData, + }; + } else { + const failureReason = data.message || 'Verification failed'; + + await this.prisma.verifaydaVerification.create({ + data: { + bookingId, + nationalId, + requestPayload, + responsePayload: data, + verified: false, + failureReason, + }, + }); + + this.logger.warn(`Verifayda verification failed: ${failureReason}`); + + return { + verified: false, + failureReason, + }; + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Unknown error'; + + await this.prisma.verifaydaVerification.create({ + data: { + bookingId, + nationalId, + requestPayload, + verified: false, + failureReason: errorMessage, + }, + }); + + this.logger.error(`Verifayda API error: ${errorMessage}`); + + throw new BadRequestException( + `National ID verification failed: ${errorMessage}`, + ); + } + } + + isEnabled(): boolean { + return this.enabled; + } +}