mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Implemened verifayda and currency modules
This commit is contained in:
125
README.md
125
README.md
@@ -4,11 +4,47 @@ Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger bookin
|
|||||||
|
|
||||||
## 🚀 Features
|
## 🚀 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
|
### Core Modules
|
||||||
- **Authentication & Authorization** - Dual authentication system:
|
- **Authentication & Authorization** - Dual authentication system:
|
||||||
- **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout
|
- **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)
|
- **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins)
|
||||||
- Role-based access control (RBAC) with granular permissions
|
- 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
|
- **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
|
- **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, 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_API_KEY` | SendGrid API key (optional) | `SG.xxx` |
|
||||||
| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` |
|
| `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)
|
#### Corporate IAM Configuration (Back-office Authentication)
|
||||||
|
|
||||||
| Variable | Description | Example |
|
| Variable | Description | Example |
|
||||||
@@ -124,15 +170,18 @@ pnpm --filter @edr/passenger-api run prisma:seed
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Seed Data Includes:**
|
**Seed Data Includes:**
|
||||||
- 5 Stations (Addis Ababa, Adama, Awash, Dire Dawa, Djibouti)
|
- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)
|
||||||
- 1 Route with 5 stops and fare rules
|
- 1 Route with 21 stops and fare rules
|
||||||
- 2 Train services with 2 trips
|
- 2 Train services with 4 trips
|
||||||
- 360 seats across 6 coaches (Economy, Bed, VIP classes)
|
- 360 seats across 12 coaches (Economy, Bed, VIP classes)
|
||||||
- 3 User accounts (Admin, Passenger, Agent)
|
- 3 User accounts (Admin, Passenger, Agent)
|
||||||
|
- Fare rules for ADULT and CHILD passenger categories
|
||||||
|
- Currency exchange rates (ETB, DJF, USD)
|
||||||
- Baggage allowance rules
|
- Baggage allowance rules
|
||||||
- Notification templates
|
- Notification templates
|
||||||
- Promotions and FAQ content
|
- Promotions and FAQ content
|
||||||
- Menu items and station crowd signals
|
- Menu items and station crowd signals
|
||||||
|
- Fraud detection rules
|
||||||
|
|
||||||
### 5. Start Development Server
|
### 5. Start Development Server
|
||||||
```bash
|
```bash
|
||||||
@@ -247,11 +296,41 @@ Content-Type: application/json
|
|||||||
|
|
||||||
#### 3. Search Trips
|
#### 3. Search Trips
|
||||||
```bash
|
```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}
|
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
|
```bash
|
||||||
POST /bookings
|
POST /bookings
|
||||||
Authorization: Bearer {token}
|
Authorization: Bearer {token}
|
||||||
@@ -259,12 +338,31 @@ Content-Type: application/json
|
|||||||
|
|
||||||
{
|
{
|
||||||
"tripId": "uuid",
|
"tripId": "uuid",
|
||||||
"seats": [
|
"holdId": "uuid",
|
||||||
|
"serviceClass": "ECONOMY_REGULAR",
|
||||||
|
"displayCurrency": "DJF",
|
||||||
|
"passengers": [
|
||||||
{
|
{
|
||||||
"seatId": "uuid",
|
"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",
|
"idDocumentType": "PASSPORT",
|
||||||
"idDocumentNumber": "ET123456"
|
"passportNumber": "P1234567",
|
||||||
|
"passportCountry": "Kenya"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -300,6 +398,7 @@ apps/edr-passenger-api/
|
|||||||
│ │ ├── auth/ # Authentication & authorization (JWT)
|
│ │ ├── auth/ # Authentication & authorization (JWT)
|
||||||
│ │ ├── agents/ # Agent operations (IAM-protected)
|
│ │ ├── agents/ # Agent operations (IAM-protected)
|
||||||
│ │ ├── bookings/ # Booking management (JWT)
|
│ │ ├── bookings/ # Booking management (JWT)
|
||||||
|
│ │ ├── currency/ # Currency conversion service
|
||||||
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
|
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
|
||||||
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
|
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
|
||||||
│ │ ├── fraud/ # Fraud detection (IAM-protected)
|
│ │ ├── fraud/ # Fraud detection (IAM-protected)
|
||||||
@@ -317,6 +416,7 @@ apps/edr-passenger-api/
|
|||||||
│ │ ├── stations/ # Station management (JWT)
|
│ │ ├── stations/ # Station management (JWT)
|
||||||
│ │ ├── support/ # Customer support (JWT)
|
│ │ ├── support/ # Customer support (JWT)
|
||||||
│ │ ├── tickets/ # Ticketing (JWT/IAM)
|
│ │ ├── tickets/ # Ticketing (JWT/IAM)
|
||||||
|
│ │ ├── verifayda/ # Verifayda 2.0 integration
|
||||||
│ │ └── wallet/ # Wallet system (JWT)
|
│ │ └── wallet/ # Wallet system (JWT)
|
||||||
│ ├── app.module.ts # Root application module
|
│ ├── app.module.ts # Root application module
|
||||||
│ └── main.ts # Application entry point
|
│ └── main.ts # Application entry point
|
||||||
@@ -351,6 +451,8 @@ apps/edr-passenger-api/
|
|||||||
- `Notification`, `NotificationTemplate`
|
- `Notification`, `NotificationTemplate`
|
||||||
- `AuditLog`, `OperationalReport`
|
- `AuditLog`, `OperationalReport`
|
||||||
- `SeatBlock`, `SeatHold`
|
- `SeatBlock`, `SeatHold`
|
||||||
|
- `CurrencyExchangeRate` (Multi-currency)
|
||||||
|
- `VerifaydaVerification` (National ID verification)
|
||||||
|
|
||||||
## 🔧 Available Scripts
|
## 🔧 Available Scripts
|
||||||
|
|
||||||
@@ -467,6 +569,8 @@ pnpm --filter @edr/passenger-api run test:cov
|
|||||||
### Pre-deployment Checklist
|
### Pre-deployment Checklist
|
||||||
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
|
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
|
||||||
- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY)
|
- [ ] 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
|
- [ ] Set NODE_ENV=production
|
||||||
- [ ] Configure CORS origins (FRONTEND_URL, PORTAL_URL)
|
- [ ] Configure CORS origins (FRONTEND_URL, PORTAL_URL)
|
||||||
- [ ] Set up SSL/TLS certificates
|
- [ ] Set up SSL/TLS certificates
|
||||||
@@ -475,6 +579,9 @@ pnpm --filter @edr/passenger-api run test:cov
|
|||||||
- [ ] Configure backup strategy
|
- [ ] Configure backup strategy
|
||||||
- [ ] Test payment provider integrations
|
- [ ] Test payment provider integrations
|
||||||
- [ ] Verify IAM token validation endpoint
|
- [ ] 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
|
- [ ] Review security settings and audit logs
|
||||||
- [ ] Test both JWT and IAM authentication flows
|
- [ ] Test both JWT and IAM authentication flows
|
||||||
|
|
||||||
|
|||||||
@@ -79,4 +79,10 @@ SUPPORTED_LOCALES=en,am,fr,om
|
|||||||
IAM_ENABLED=false
|
IAM_ENABLED=false
|
||||||
IAM_API_URL=https://iam.tria-plc.com/api
|
IAM_API_URL=https://iam.tria-plc.com/api
|
||||||
IAM_API_KEY=
|
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=
|
GITHUB_PACKAGE_TOKEN=
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"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": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Language field already exists in UserPreferences table
|
|
||||||
-- No migration needed
|
|
||||||
@@ -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';
|
|
||||||
@@ -13,6 +13,15 @@ CREATE TYPE "SeatStatus" AS ENUM ('AVAILABLE', 'HELD', 'BOOKED', 'BLOCKED');
|
|||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "ServiceClass" AS ENUM ('ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER');
|
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
|
-- CreateEnum
|
||||||
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
|
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,
|
"passwordHash" TEXT NOT NULL,
|
||||||
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
|
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
|
||||||
"nationality" TEXT,
|
"nationality" TEXT,
|
||||||
|
"nationalityCode" TEXT,
|
||||||
"passportNumber" TEXT,
|
"passportNumber" TEXT,
|
||||||
"nationalId" TEXT,
|
"nationalId" TEXT,
|
||||||
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
"lockedUntil" TIMESTAMP(3),
|
"lockedUntil" TIMESTAMP(3),
|
||||||
|
"blockedUntil" TIMESTAMP(3),
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
@@ -86,6 +98,8 @@ CREATE TABLE "Session" (
|
|||||||
CREATE TABLE "Passenger" (
|
CREATE TABLE "Passenger" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
|
"defaultTravelerProfileId" TEXT,
|
||||||
|
"preferredLanguage" TEXT,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
|
||||||
@@ -111,6 +125,8 @@ CREATE TABLE "Station" (
|
|||||||
"code" TEXT NOT NULL,
|
"code" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"city" TEXT NOT NULL,
|
"city" TEXT NOT NULL,
|
||||||
|
"countryCode" TEXT,
|
||||||
|
"isOperational" BOOLEAN NOT NULL DEFAULT true,
|
||||||
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
|
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
|
||||||
"lat" DECIMAL(9,6) NOT NULL,
|
"lat" DECIMAL(9,6) NOT NULL,
|
||||||
"lng" DECIMAL(9,6) NOT NULL,
|
"lng" DECIMAL(9,6) NOT NULL,
|
||||||
@@ -124,6 +140,7 @@ CREATE TABLE "TrainService" (
|
|||||||
"number" TEXT NOT NULL,
|
"number" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"operatorId" TEXT NOT NULL DEFAULT 'op_edr',
|
"operatorId" TEXT NOT NULL DEFAULT 'op_edr',
|
||||||
|
"operatorName" TEXT,
|
||||||
|
|
||||||
CONSTRAINT "TrainService_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "TrainService_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -132,6 +149,7 @@ CREATE TABLE "TrainService" (
|
|||||||
CREATE TABLE "Trip" (
|
CREATE TABLE "Trip" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"serviceId" TEXT NOT NULL,
|
"serviceId" TEXT NOT NULL,
|
||||||
|
"routeId" TEXT,
|
||||||
"originStationId" TEXT NOT NULL,
|
"originStationId" TEXT NOT NULL,
|
||||||
"destinationStationId" TEXT NOT NULL,
|
"destinationStationId" TEXT NOT NULL,
|
||||||
"departureAt" TIMESTAMP(3) NOT NULL,
|
"departureAt" TIMESTAMP(3) NOT NULL,
|
||||||
@@ -139,8 +157,10 @@ CREATE TABLE "Trip" (
|
|||||||
"durationMinutes" INTEGER NOT NULL,
|
"durationMinutes" INTEGER NOT NULL,
|
||||||
"status" "TripStatus" NOT NULL DEFAULT 'SCHEDULED',
|
"status" "TripStatus" NOT NULL DEFAULT 'SCHEDULED',
|
||||||
"stopsCount" INTEGER NOT NULL DEFAULT 0,
|
"stopsCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"reservedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
"onTimePercent" INTEGER NOT NULL DEFAULT 100,
|
"onTimePercent" INTEGER NOT NULL DEFAULT 100,
|
||||||
"carbonRating" TEXT NOT NULL DEFAULT 'A',
|
"carbonRating" TEXT NOT NULL DEFAULT 'A',
|
||||||
|
"notes" TEXT,
|
||||||
|
|
||||||
CONSTRAINT "Trip_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Trip_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -180,6 +200,10 @@ CREATE TABLE "Coach" (
|
|||||||
"tripId" TEXT NOT NULL,
|
"tripId" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
"label" TEXT NOT NULL,
|
||||||
"serviceClass" "ServiceClass" NOT NULL,
|
"serviceClass" "ServiceClass" NOT NULL,
|
||||||
|
"capacity" INTEGER,
|
||||||
|
"sequence" INTEGER,
|
||||||
|
"coachType" TEXT,
|
||||||
|
"amenities" JSONB,
|
||||||
|
|
||||||
CONSTRAINT "Coach_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Coach_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -191,9 +215,12 @@ CREATE TABLE "Seat" (
|
|||||||
"row" INTEGER NOT NULL,
|
"row" INTEGER NOT NULL,
|
||||||
"col" TEXT NOT NULL,
|
"col" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
"label" TEXT NOT NULL,
|
||||||
|
"seatNumber" TEXT,
|
||||||
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
|
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
|
||||||
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
|
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
|
||||||
"heldUntil" TIMESTAMP(3),
|
"heldUntil" TIMESTAMP(3),
|
||||||
|
"isWindow" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isAisle" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
|
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
|
||||||
"eligibility" TEXT,
|
"eligibility" TEXT,
|
||||||
|
|
||||||
@@ -207,6 +234,7 @@ CREATE TABLE "SeatHold" (
|
|||||||
"seatIds" TEXT[],
|
"seatIds" TEXT[],
|
||||||
"fareQuoteId" TEXT,
|
"fareQuoteId" TEXT,
|
||||||
"passengerId" TEXT NOT NULL,
|
"passengerId" TEXT NOT NULL,
|
||||||
|
"createdBy" TEXT,
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
@@ -238,7 +266,15 @@ CREATE TABLE "Booking" (
|
|||||||
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
|
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
"totalMinor" INTEGER NOT NULL,
|
"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',
|
"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,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
@@ -251,8 +287,18 @@ CREATE TABLE "BookingSeat" (
|
|||||||
"bookingId" TEXT NOT NULL,
|
"bookingId" TEXT NOT NULL,
|
||||||
"seatId" TEXT NOT NULL,
|
"seatId" TEXT NOT NULL,
|
||||||
"passengerName" TEXT NOT NULL,
|
"passengerName" TEXT NOT NULL,
|
||||||
"idDocumentType" TEXT,
|
"dateOfBirth" TIMESTAMP(3),
|
||||||
|
"passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT',
|
||||||
|
"idDocumentType" "IdDocumentType",
|
||||||
"idDocumentNumber" TEXT,
|
"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")
|
CONSTRAINT "BookingSeat_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -264,6 +310,7 @@ CREATE TABLE "PaymentMethod" (
|
|||||||
"type" "PaymentMethodType" NOT NULL,
|
"type" "PaymentMethodType" NOT NULL,
|
||||||
"displayName" TEXT NOT NULL,
|
"displayName" TEXT NOT NULL,
|
||||||
"maskedHint" TEXT,
|
"maskedHint" TEXT,
|
||||||
|
"providerId" TEXT,
|
||||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
@@ -277,6 +324,7 @@ CREATE TABLE "PaymentIntent" (
|
|||||||
"amountMinor" INTEGER NOT NULL,
|
"amountMinor" INTEGER NOT NULL,
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
"method" "PaymentMethodType" NOT NULL,
|
"method" "PaymentMethodType" NOT NULL,
|
||||||
|
"provider" TEXT,
|
||||||
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
|
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
|
||||||
"providerRef" TEXT,
|
"providerRef" TEXT,
|
||||||
"clientAction" JSONB,
|
"clientAction" JSONB,
|
||||||
@@ -285,6 +333,8 @@ CREATE TABLE "PaymentIntent" (
|
|||||||
"providerTxnId" TEXT,
|
"providerTxnId" TEXT,
|
||||||
"rawInitiation" JSONB,
|
"rawInitiation" JSONB,
|
||||||
"paidAt" TIMESTAMP(3),
|
"paidAt" TIMESTAMP(3),
|
||||||
|
"refundedAt" TIMESTAMP(3),
|
||||||
|
"captureMethod" TEXT,
|
||||||
"failureCode" TEXT,
|
"failureCode" TEXT,
|
||||||
"failureMessage" TEXT,
|
"failureMessage" TEXT,
|
||||||
"expiresAt" TIMESTAMP(3),
|
"expiresAt" TIMESTAMP(3),
|
||||||
@@ -346,7 +396,9 @@ CREATE TABLE "LoyaltyAccount" (
|
|||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"passengerId" TEXT NOT NULL,
|
"passengerId" TEXT NOT NULL,
|
||||||
"pointsBalance" INTEGER NOT NULL DEFAULT 0,
|
"pointsBalance" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lifetimePoints" INTEGER NOT NULL DEFAULT 0,
|
||||||
"tier" "LoyaltyTier" NOT NULL DEFAULT 'BRONZE',
|
"tier" "LoyaltyTier" NOT NULL DEFAULT 'BRONZE',
|
||||||
|
"tierUpdatedAt" TIMESTAMP(3),
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id")
|
||||||
@@ -382,6 +434,8 @@ CREATE TABLE "WalletAccount" (
|
|||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"passengerId" TEXT NOT NULL,
|
"passengerId" TEXT NOT NULL,
|
||||||
"balanceMinor" INTEGER NOT NULL DEFAULT 0,
|
"balanceMinor" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"holdMinor" INTEGER NOT NULL DEFAULT 0,
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
@@ -441,6 +495,8 @@ CREATE TABLE "StationCrowdSignal" (
|
|||||||
"level" TEXT NOT NULL,
|
"level" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
"label" TEXT NOT NULL,
|
||||||
"statusLabel" TEXT NOT NULL,
|
"statusLabel" TEXT NOT NULL,
|
||||||
|
"confidence" INTEGER,
|
||||||
|
"observedAt" TIMESTAMP(3),
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "StationCrowdSignal_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "StationCrowdSignal_pkey" PRIMARY KEY ("id")
|
||||||
@@ -476,6 +532,7 @@ CREATE TABLE "MenuItem" (
|
|||||||
"priceMinor" INTEGER NOT NULL,
|
"priceMinor" INTEGER NOT NULL,
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
"available" BOOLEAN NOT NULL DEFAULT true,
|
"available" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"availableUntil" TIMESTAMP(3),
|
||||||
|
|
||||||
CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -487,6 +544,8 @@ CREATE TABLE "FoodOrder" (
|
|||||||
"status" "FoodOrderStatus" NOT NULL DEFAULT 'PENDING',
|
"status" "FoodOrderStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
"totalMinor" INTEGER NOT NULL,
|
"totalMinor" INTEGER NOT NULL,
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
|
"specialInstructions" TEXT,
|
||||||
|
"estimatedReadyAt" TIMESTAMP(3),
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "FoodOrder_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "FoodOrder_pkey" PRIMARY KEY ("id")
|
||||||
@@ -499,6 +558,7 @@ CREATE TABLE "FoodOrderItem" (
|
|||||||
"menuItemId" TEXT NOT NULL,
|
"menuItemId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"quantity" INTEGER NOT NULL,
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"unitPriceMinor" INTEGER,
|
||||||
"lineTotalMinor" INTEGER NOT NULL,
|
"lineTotalMinor" INTEGER NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "FoodOrderItem_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "FoodOrderItem_pkey" PRIMARY KEY ("id")
|
||||||
@@ -528,6 +588,7 @@ CREATE TABLE "FaqArticle" (
|
|||||||
CREATE TABLE "SupportConversation" (
|
CREATE TABLE "SupportConversation" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
|
"assignedAgentId" TEXT,
|
||||||
"status" "SupportConversationStatus" NOT NULL DEFAULT 'OPEN',
|
"status" "SupportConversationStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
@@ -540,6 +601,7 @@ CREATE TABLE "SupportMessage" (
|
|||||||
"conversationId" TEXT NOT NULL,
|
"conversationId" TEXT NOT NULL,
|
||||||
"sender" "SupportSender" NOT NULL,
|
"sender" "SupportSender" NOT NULL,
|
||||||
"text" TEXT NOT NULL,
|
"text" TEXT NOT NULL,
|
||||||
|
"attachments" JSONB,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "SupportMessage_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "SupportMessage_pkey" PRIMARY KEY ("id")
|
||||||
@@ -676,9 +738,11 @@ CREATE TABLE "RouteFareRule" (
|
|||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"routeId" TEXT NOT NULL,
|
"routeId" TEXT NOT NULL,
|
||||||
"serviceClass" "ServiceClass" NOT NULL,
|
"serviceClass" "ServiceClass" NOT NULL,
|
||||||
"passengerCategory" TEXT NOT NULL DEFAULT 'ADULT',
|
"passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT',
|
||||||
"baseFareMinor" INTEGER NOT NULL,
|
"baseFareMinor" INTEGER NOT NULL,
|
||||||
"discountPercent" INTEGER,
|
"discountPercent" INTEGER,
|
||||||
|
"taxPercent" INTEGER,
|
||||||
|
"surchargeMinor" INTEGER,
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
"validFrom" TIMESTAMP(3) NOT NULL,
|
"validFrom" TIMESTAMP(3) NOT NULL,
|
||||||
"validUntil" TIMESTAMP(3),
|
"validUntil" TIMESTAMP(3),
|
||||||
@@ -865,6 +929,61 @@ CREATE TABLE "OperationalReport" (
|
|||||||
CONSTRAINT "OperationalReport_pkey" PRIMARY KEY ("id")
|
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
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
@@ -877,12 +996,21 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
|
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
|
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "TrainService_number_key" ON "TrainService"("number");
|
CREATE UNIQUE INDEX "TrainService_number_key" ON "TrainService"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Trip_departureAt_originStationId_idx" ON "Trip"("departureAt", "originStationId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "TripStopTime_tripId_sequence_key" ON "TripStopTime"("tripId", "sequence");
|
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
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
|
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
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
|
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
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
|
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
|
||||||
|
|
||||||
@@ -925,6 +1065,9 @@ CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passen
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "WalletAccount_passengerId_key" ON "WalletAccount"("passengerId");
|
CREATE UNIQUE INDEX "WalletAccount_passengerId_key" ON "WalletAccount"("passengerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
|
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
|
||||||
|
|
||||||
@@ -997,6 +1140,27 @@ CREATE INDEX "SeatBlock_seatId_idx" ON "SeatBlock"("seatId");
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"("reportType", "dateFrom");
|
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
|
-- AddForeignKey
|
||||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
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
|
-- AddForeignKey
|
||||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
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;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -46,6 +46,24 @@ enum ServiceClass {
|
|||||||
VIP_BED_UPPER
|
VIP_BED_UPPER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PassengerCategory {
|
||||||
|
ADULT
|
||||||
|
CHILD
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IdDocumentType {
|
||||||
|
NATIONAL_ID
|
||||||
|
PASSPORT
|
||||||
|
DRIVING_LICENSE
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Currency {
|
||||||
|
ETB
|
||||||
|
DJF
|
||||||
|
USD
|
||||||
|
}
|
||||||
|
|
||||||
enum BookingStatus {
|
enum BookingStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
PENDING_PAYMENT
|
PENDING_PAYMENT
|
||||||
@@ -142,11 +160,13 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
role UserRole @default(PASSENGER)
|
role UserRole @default(PASSENGER)
|
||||||
nationality String?
|
nationality String?
|
||||||
|
nationalityCode String?
|
||||||
passportNumber String?
|
passportNumber String?
|
||||||
nationalId String?
|
nationalId String?
|
||||||
failedLoginAttempts Int @default(0)
|
failedLoginAttempts Int @default(0)
|
||||||
lockedUntil DateTime?
|
lockedUntil DateTime?
|
||||||
blockedUntil DateTime?
|
blockedUntil DateTime?
|
||||||
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger?
|
passenger Passenger?
|
||||||
@@ -173,6 +193,8 @@ model Session {
|
|||||||
model Passenger {
|
model Passenger {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
|
defaultTravelerProfileId String?
|
||||||
|
preferredLanguage String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
@@ -181,6 +203,7 @@ model Passenger {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
travelerProfiles TravelerProfile[]
|
travelerProfiles TravelerProfile[]
|
||||||
savedRoutes SavedRoute[]
|
savedRoutes SavedRoute[]
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model TravelerProfile {
|
model TravelerProfile {
|
||||||
@@ -200,6 +223,8 @@ model Station {
|
|||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
city String
|
city String
|
||||||
|
countryCode String?
|
||||||
|
isOperational Boolean @default(true)
|
||||||
timezone String @default("Africa/Addis_Ababa")
|
timezone String @default("Africa/Addis_Ababa")
|
||||||
lat Decimal @db.Decimal(9, 6)
|
lat Decimal @db.Decimal(9, 6)
|
||||||
lng Decimal @db.Decimal(9, 6)
|
lng Decimal @db.Decimal(9, 6)
|
||||||
@@ -207,6 +232,7 @@ model Station {
|
|||||||
destinationTrips Trip[] @relation("DestinationTrips")
|
destinationTrips Trip[] @relation("DestinationTrips")
|
||||||
stopTimes TripStopTime[]
|
stopTimes TripStopTime[]
|
||||||
crowdSignals StationCrowdSignal[]
|
crowdSignals StationCrowdSignal[]
|
||||||
|
@@index([city, countryCode])
|
||||||
}
|
}
|
||||||
|
|
||||||
model TrainService {
|
model TrainService {
|
||||||
@@ -214,12 +240,14 @@ model TrainService {
|
|||||||
number String @unique
|
number String @unique
|
||||||
name String
|
name String
|
||||||
operatorId String @default("op_edr")
|
operatorId String @default("op_edr")
|
||||||
|
operatorName String?
|
||||||
trips Trip[]
|
trips Trip[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Trip {
|
model Trip {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
serviceId String
|
serviceId String
|
||||||
|
routeId String?
|
||||||
originStationId String
|
originStationId String
|
||||||
destinationStationId String
|
destinationStationId String
|
||||||
departureAt DateTime
|
departureAt DateTime
|
||||||
@@ -227,8 +255,10 @@ model Trip {
|
|||||||
durationMinutes Int
|
durationMinutes Int
|
||||||
status TripStatus @default(SCHEDULED)
|
status TripStatus @default(SCHEDULED)
|
||||||
stopsCount Int @default(0)
|
stopsCount Int @default(0)
|
||||||
|
reservedCount Int @default(0)
|
||||||
onTimePercent Int @default(100)
|
onTimePercent Int @default(100)
|
||||||
carbonRating String @default("A")
|
carbonRating String @default("A")
|
||||||
|
notes String?
|
||||||
service TrainService @relation(fields: [serviceId], references: [id])
|
service TrainService @relation(fields: [serviceId], references: [id])
|
||||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||||
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
|
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
|
||||||
@@ -238,6 +268,7 @@ model Trip {
|
|||||||
liveStatus TripLiveStatus?
|
liveStatus TripLiveStatus?
|
||||||
menuItems MenuItem[]
|
menuItems MenuItem[]
|
||||||
journeySegments JourneySegment[]
|
journeySegments JourneySegment[]
|
||||||
|
@@index([departureAt, originStationId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model TripStopTime {
|
model TripStopTime {
|
||||||
@@ -272,6 +303,10 @@ model Coach {
|
|||||||
tripId String
|
tripId String
|
||||||
label String
|
label String
|
||||||
serviceClass ServiceClass
|
serviceClass ServiceClass
|
||||||
|
capacity Int?
|
||||||
|
sequence Int?
|
||||||
|
coachType String?
|
||||||
|
amenities Json?
|
||||||
trip Trip @relation(fields: [tripId], references: [id])
|
trip Trip @relation(fields: [tripId], references: [id])
|
||||||
seats Seat[]
|
seats Seat[]
|
||||||
@@unique([tripId, label])
|
@@unique([tripId, label])
|
||||||
@@ -283,15 +318,19 @@ model Seat {
|
|||||||
row Int
|
row Int
|
||||||
col String
|
col String
|
||||||
label String
|
label String
|
||||||
|
seatNumber String?
|
||||||
kind SeatKind @default(STANDARD)
|
kind SeatKind @default(STANDARD)
|
||||||
status SeatStatus @default(AVAILABLE)
|
status SeatStatus @default(AVAILABLE)
|
||||||
heldUntil DateTime?
|
heldUntil DateTime?
|
||||||
|
isWindow Boolean @default(false)
|
||||||
|
isAisle Boolean @default(false)
|
||||||
premiumFeeMinor Int @default(0)
|
premiumFeeMinor Int @default(0)
|
||||||
eligibility String?
|
eligibility String?
|
||||||
coach Coach @relation(fields: [coachId], references: [id])
|
coach Coach @relation(fields: [coachId], references: [id])
|
||||||
bookingSeats BookingSeat[]
|
bookingSeats BookingSeat[]
|
||||||
blocks SeatBlock[]
|
blocks SeatBlock[]
|
||||||
@@unique([coachId, row, col])
|
@@unique([coachId, row, col])
|
||||||
|
@@unique([coachId, seatNumber])
|
||||||
}
|
}
|
||||||
|
|
||||||
model SeatHold {
|
model SeatHold {
|
||||||
@@ -300,8 +339,10 @@ model SeatHold {
|
|||||||
seatIds String[]
|
seatIds String[]
|
||||||
fareQuoteId String?
|
fareQuoteId String?
|
||||||
passengerId String
|
passengerId String
|
||||||
|
createdBy String?
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model FareRule {
|
model FareRule {
|
||||||
@@ -318,16 +359,24 @@ model FareRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Booking {
|
model Booking {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bookingRef String @unique
|
bookingRef String @unique
|
||||||
passengerId String
|
passengerId String
|
||||||
tripId String
|
tripId String
|
||||||
status BookingStatus @default(DRAFT)
|
status BookingStatus @default(DRAFT)
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
totalMinor Int
|
totalMinor Int
|
||||||
bookingType String @default("ONE_WAY")
|
adultCount Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
childCount Int @default(0)
|
||||||
updatedAt DateTime @updatedAt
|
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])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
trip Trip @relation(fields: [tripId], references: [id])
|
trip Trip @relation(fields: [tripId], references: [id])
|
||||||
seats BookingSeat[]
|
seats BookingSeat[]
|
||||||
@@ -338,15 +387,26 @@ model Booking {
|
|||||||
modifications BookingModification[]
|
modifications BookingModification[]
|
||||||
cancellation BookingCancellation?
|
cancellation BookingCancellation?
|
||||||
baggage BaggageBooking[]
|
baggage BaggageBooking[]
|
||||||
|
@@index([passengerId, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BookingSeat {
|
model BookingSeat {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bookingId String
|
bookingId String
|
||||||
seatId String
|
seatId String
|
||||||
passengerName String
|
passengerName String
|
||||||
idDocumentType String?
|
dateOfBirth DateTime?
|
||||||
idDocumentNumber String?
|
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])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
seat Seat @relation(fields: [seatId], references: [id])
|
seat Seat @relation(fields: [seatId], references: [id])
|
||||||
}
|
}
|
||||||
@@ -357,8 +417,10 @@ model PaymentMethod {
|
|||||||
type PaymentMethodType
|
type PaymentMethodType
|
||||||
displayName String
|
displayName String
|
||||||
maskedHint String?
|
maskedHint String?
|
||||||
|
providerId String?
|
||||||
isDefault Boolean @default(false)
|
isDefault Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
@@index([userId, isDefault])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PaymentIntent {
|
model PaymentIntent {
|
||||||
@@ -367,6 +429,7 @@ model PaymentIntent {
|
|||||||
amountMinor Int
|
amountMinor Int
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
method PaymentMethodType
|
method PaymentMethodType
|
||||||
|
provider String?
|
||||||
status PaymentIntentStatus @default(REQUIRES_ACTION)
|
status PaymentIntentStatus @default(REQUIRES_ACTION)
|
||||||
providerRef String?
|
providerRef String?
|
||||||
clientAction Json?
|
clientAction Json?
|
||||||
@@ -375,6 +438,8 @@ model PaymentIntent {
|
|||||||
providerTxnId String?
|
providerTxnId String?
|
||||||
rawInitiation Json?
|
rawInitiation Json?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
|
refundedAt DateTime?
|
||||||
|
captureMethod String?
|
||||||
failureCode String?
|
failureCode String?
|
||||||
failureMessage String?
|
failureMessage String?
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
@@ -433,7 +498,9 @@ model LoyaltyAccount {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
passengerId String @unique
|
passengerId String @unique
|
||||||
pointsBalance Int @default(0)
|
pointsBalance Int @default(0)
|
||||||
|
lifetimePoints Int @default(0)
|
||||||
tier LoyaltyTier @default(BRONZE)
|
tier LoyaltyTier @default(BRONZE)
|
||||||
|
tierUpdatedAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
ledger LoyaltyLedgerEntry[]
|
ledger LoyaltyLedgerEntry[]
|
||||||
@@ -465,10 +532,13 @@ model WalletAccount {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
passengerId String @unique
|
passengerId String @unique
|
||||||
balanceMinor Int @default(0)
|
balanceMinor Int @default(0)
|
||||||
|
status String @default("ACTIVE")
|
||||||
|
holdMinor Int @default(0)
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
ledger WalletLedgerEntry[]
|
ledger WalletLedgerEntry[]
|
||||||
|
@@index([passengerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model WalletLedgerEntry {
|
model WalletLedgerEntry {
|
||||||
@@ -516,6 +586,8 @@ model StationCrowdSignal {
|
|||||||
level String
|
level String
|
||||||
label String
|
label String
|
||||||
statusLabel String
|
statusLabel String
|
||||||
|
confidence Int?
|
||||||
|
observedAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
station Station @relation(fields: [stationId], references: [id])
|
station Station @relation(fields: [stationId], references: [id])
|
||||||
}
|
}
|
||||||
@@ -544,6 +616,7 @@ model MenuItem {
|
|||||||
priceMinor Int
|
priceMinor Int
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
available Boolean @default(true)
|
available Boolean @default(true)
|
||||||
|
availableUntil DateTime?
|
||||||
trip Trip @relation(fields: [tripId], references: [id])
|
trip Trip @relation(fields: [tripId], references: [id])
|
||||||
category MenuCategory @relation(fields: [categoryId], references: [id])
|
category MenuCategory @relation(fields: [categoryId], references: [id])
|
||||||
}
|
}
|
||||||
@@ -554,6 +627,8 @@ model FoodOrder {
|
|||||||
status FoodOrderStatus @default(PENDING)
|
status FoodOrderStatus @default(PENDING)
|
||||||
totalMinor Int
|
totalMinor Int
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
|
specialInstructions String?
|
||||||
|
estimatedReadyAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
items FoodOrderItem[]
|
items FoodOrderItem[]
|
||||||
@@ -565,6 +640,7 @@ model FoodOrderItem {
|
|||||||
menuItemId String
|
menuItemId String
|
||||||
name String
|
name String
|
||||||
quantity Int
|
quantity Int
|
||||||
|
unitPriceMinor Int?
|
||||||
lineTotalMinor Int
|
lineTotalMinor Int
|
||||||
order FoodOrder @relation(fields: [orderId], references: [id])
|
order FoodOrder @relation(fields: [orderId], references: [id])
|
||||||
}
|
}
|
||||||
@@ -588,6 +664,7 @@ model FaqArticle {
|
|||||||
model SupportConversation {
|
model SupportConversation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
assignedAgentId String?
|
||||||
status SupportConversationStatus @default(OPEN)
|
status SupportConversationStatus @default(OPEN)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
messages SupportMessage[]
|
messages SupportMessage[]
|
||||||
@@ -598,6 +675,7 @@ model SupportMessage {
|
|||||||
conversationId String
|
conversationId String
|
||||||
sender SupportSender
|
sender SupportSender
|
||||||
text String
|
text String
|
||||||
|
attachments Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
||||||
}
|
}
|
||||||
@@ -715,17 +793,19 @@ model RouteStop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RouteFareRule {
|
model RouteFareRule {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
routeId String
|
routeId String
|
||||||
serviceClass ServiceClass
|
serviceClass ServiceClass
|
||||||
passengerCategory String @default("ADULT")
|
passengerCategory PassengerCategory @default(ADULT)
|
||||||
baseFareMinor Int
|
baseFareMinor Int
|
||||||
discountPercent Int?
|
discountPercent Int?
|
||||||
currency String @default("ETB")
|
taxPercent Int?
|
||||||
|
surchargeMinor Int?
|
||||||
|
currency String @default("ETB")
|
||||||
validFrom DateTime
|
validFrom DateTime
|
||||||
validUntil DateTime?
|
validUntil DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||||
@@index([routeId, serviceClass])
|
@@index([routeId, serviceClass])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,3 +995,29 @@ model FraudAlert {
|
|||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@index([acknowledged])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -6,28 +6,28 @@ const prisma = new PrismaClient();
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Starting comprehensive seed...');
|
console.log('🌱 Starting comprehensive seed...');
|
||||||
|
|
||||||
// All 18 Stations (Ethiopian-Djibouti Railway)
|
// 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', lat: 9.0054, lng: 38.7636 } });
|
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', lat: 8.9167, lng: 38.6167 } });
|
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', lat: 8.8500, lng: 38.8500 } });
|
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', lat: 8.7833, lng: 39.0167 } });
|
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', lat: 8.7500, lng: 38.9833 } });
|
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', lat: 8.5833, lng: 39.1167 } });
|
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', lat: 8.5400, lng: 39.2675 } });
|
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', lat: 8.7167, lng: 39.5833 } });
|
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', lat: 8.9000, lng: 39.9167 } });
|
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', lat: 8.9833, lng: 40.1667 } });
|
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', lat: 9.2333, lng: 40.7500 } });
|
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', lat: 9.4167, lng: 41.2500 } });
|
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', lat: 9.5931, lng: 41.8661 } });
|
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', lat: 10.0833, lng: 42.2500 } });
|
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', lat: 10.5000, lng: 42.5833 } });
|
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', lat: 11.5500, lng: 42.7167 } });
|
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', timezone: 'Africa/Djibouti', lat: 11.3833, lng: 42.8500 } });
|
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', timezone: 'Africa/Djibouti', lat: 11.1667, lng: 42.7167 } });
|
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', timezone: 'Africa/Djibouti', lat: 11.4167, lng: 43.0000 } });
|
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', timezone: 'Africa/Djibouti', lat: 11.5167, lng: 43.1000 } });
|
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', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
|
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
|
// Routes
|
||||||
const route1 = await prisma.route.upsert({
|
const route1 = await prisma.route.upsert({
|
||||||
@@ -62,12 +62,15 @@ async function main() {
|
|||||||
{ routeId: route1.id, stationId: djibouti.id, sequence: 21, distanceKm: 756 },
|
{ 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.deleteMany({ where: { routeId: route1.id } });
|
||||||
await prisma.routeFareRule.createMany({ data: [
|
await prisma.routeFareRule.createMany({ data: [
|
||||||
{ routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', baseFareMinor: 45000, 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', baseFareMinor: 65000, 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', baseFareMinor: 95000, 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
|
// 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('✅ Comprehensive seed complete');
|
||||||
console.log('\n📋 Seed Summary:');
|
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(' - 1 Route with 21 stops');
|
||||||
console.log(' - 2 Train services, 4 trips');
|
console.log(' - 2 Train services, 4 trips');
|
||||||
console.log(' - 3 Coaches per trip (Economy, Bed, VIP)');
|
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 Users: Admin, Passenger (Silver tier + wallet), Agent');
|
||||||
console.log(' - 3 Fraud detection rules');
|
console.log(' - 3 Fraud detection rules');
|
||||||
console.log(' - 3 Loyalty rewards');
|
console.log(' - 3 Loyalty rewards');
|
||||||
@@ -232,6 +247,9 @@ async function main() {
|
|||||||
console.log(' Passenger: kelemu@email.com / password123');
|
console.log(' Passenger: kelemu@email.com / password123');
|
||||||
console.log(' Agent: agent@edr-platform.com / agent123');
|
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🚉 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());
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||||
|
import { IdDocumentType } from '@prisma/client';
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
@@ -35,9 +36,9 @@ export class AgentsService {
|
|||||||
totalMinor,
|
totalMinor,
|
||||||
seats: {
|
seats: {
|
||||||
create: dto.passengers.map(p => ({
|
create: dto.passengers.map(p => ({
|
||||||
seatId: p.seatId,
|
seat: { connect: { id: p.seatId } },
|
||||||
passengerName: p.fullName,
|
passengerName: p.fullName,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
|
||||||
idDocumentNumber: p.idDocumentNumber
|
idDocumentNumber: p.idDocumentNumber
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common';
|
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 { BookingsService } from './bookings.service';
|
||||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
@@ -12,25 +12,51 @@ export class BookingsController {
|
|||||||
constructor(private service: BookingsService) {}
|
constructor(private service: BookingsService) {}
|
||||||
|
|
||||||
@Post()
|
@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) {
|
create(@Body() dto: CreateBookingDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':bookingRef')
|
@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) {
|
getByRef(@Param('bookingRef') ref: string) {
|
||||||
return this.service.getByRef(ref);
|
return this.service.getByRef(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':bookingRef/modify')
|
@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) {
|
modify(@Body() dto: ModifyBookingDto) {
|
||||||
return this.service.modify(dto);
|
return this.service.modify(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':bookingRef')
|
@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) {
|
cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) {
|
||||||
return this.service.cancel(ref, dto.reason);
|
return this.service.cancel(ref, dto.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Currency, IdDocumentType } from '@prisma/client';
|
||||||
|
|
||||||
export class PassengerInputDto {
|
export class PassengerInputDto {
|
||||||
@ApiProperty() @IsString() fullName: string;
|
|
||||||
@ApiProperty() @IsString() phone: string;
|
|
||||||
@ApiProperty() @IsString() email: string;
|
|
||||||
@ApiProperty() @IsString() seatId: string;
|
@ApiProperty() @IsString() seatId: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
|
@ApiProperty({ example: 'John Doe' }) @IsString() passengerName: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: 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 {
|
export class CreateBookingDto {
|
||||||
@@ -16,15 +18,15 @@ export class CreateBookingDto {
|
|||||||
@ApiProperty() @IsString() tripId: string;
|
@ApiProperty() @IsString() tripId: string;
|
||||||
@ApiProperty() @IsString() holdId: string;
|
@ApiProperty() @IsString() holdId: string;
|
||||||
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
||||||
@ApiPropertyOptional({
|
@ApiProperty({
|
||||||
example: 'ECONOMY_REGULAR',
|
example: 'ECONOMY_REGULAR',
|
||||||
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
|
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() @IsString() promoCode?: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||||
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
|
@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 {
|
export class ModifyBookingDto {
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { SeatsModule } from '../seats/seats.module';
|
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 {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -4,16 +4,34 @@ import { SeatsService } from '../seats/seats.service';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
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 {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
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()
|
@Injectable()
|
||||||
export class BookingsService {
|
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) {
|
async create(dto: CreateBookingDto) {
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
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 } });
|
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
|
||||||
if (!trip) throw new NotFoundException('Trip not found');
|
if (!trip) throw new NotFoundException('Trip not found');
|
||||||
|
|
||||||
let seatIds: string[];
|
const seatIds = dto.passengers.map((p) => p.seatId);
|
||||||
if (dto.autoAssign) {
|
|
||||||
seatIds = await this.seatsService.autoAssignSeats(
|
// Calculate passenger categories and verify Ethiopian nationals
|
||||||
dto.tripId,
|
const passengersData = [];
|
||||||
dto.passengers.length,
|
let adultCount = 0;
|
||||||
dto.serviceClass ?? 'ECONOMY_REGULAR',
|
let childCount = 0;
|
||||||
);
|
|
||||||
await this.seatsService.confirmSeats(seatIds);
|
for (const passenger of dto.passengers) {
|
||||||
} else {
|
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||||
seatIds = dto.passengers.map((p) => p.seatId);
|
const age = calculateAge(dateOfBirth);
|
||||||
|
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||||
|
|
||||||
|
if (category === PassengerCategory.ADULT) adultCount++;
|
||||||
|
else childCount++;
|
||||||
|
|
||||||
|
let passengerName = passenger.passengerName;
|
||||||
|
let verifaydaVerified = false;
|
||||||
|
let verifaydaData: Record<string, any> | undefined = 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({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: dto.passengerId,
|
passengerId: dto.passengerId,
|
||||||
tripId: dto.tripId,
|
tripId: dto.tripId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
totalMinor: fareQuote.totalMinor,
|
totalMinor,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
bookingType: dto.bookingType ?? 'ONE_WAY',
|
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 } } },
|
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.seatsService.confirmSeats(seatIds);
|
||||||
this.eventEmitter.emit('booking.created', { booking });
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
return {
|
|
||||||
...booking,
|
return {
|
||||||
|
...booking,
|
||||||
fareBreakdown: {
|
fareBreakdown: {
|
||||||
baseFare: fareQuote.baseFareMinor / 100,
|
baseFareMinor,
|
||||||
discount: fareQuote.discountMinor / 100,
|
adultCount,
|
||||||
loyaltyRedemption: fareQuote.loyaltyRedemptionMinor / 100,
|
adultFareMinor,
|
||||||
taxesFees: fareQuote.taxesFeesMinor / 100,
|
childCount,
|
||||||
total: fareQuote.totalMinor / 100,
|
freeChildrenCount: Math.min(childCount, 1),
|
||||||
currency: fareQuote.currency
|
paidChildrenCount,
|
||||||
}
|
childFareMinor,
|
||||||
|
totalBaseFareMinor,
|
||||||
|
discountMinor,
|
||||||
|
loyaltyRedemptionMinor: loyaltyMinor,
|
||||||
|
taxesFeesMinor: taxesMinor,
|
||||||
|
totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getBaseFare(tripId: string, serviceClass: string): Promise<number> {
|
||||||
|
const fareRule = await this.prisma.fareRule.findFirst({
|
||||||
|
where: { tripId, serviceClass: serviceClass as any },
|
||||||
|
});
|
||||||
|
return fareRule?.baseFareMinor ?? 35000;
|
||||||
|
}
|
||||||
|
|
||||||
async getByRef(bookingRef: string) {
|
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 } });
|
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');
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
@@ -68,6 +191,10 @@ export class BookingsService {
|
|||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
totalFare: booking.totalMinor / 100,
|
totalFare: booking.totalMinor / 100,
|
||||||
|
adultCount: booking.adultCount,
|
||||||
|
childCount: booking.childCount,
|
||||||
|
displayCurrency: booking.displayCurrency,
|
||||||
|
displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
||||||
bookingType: booking.bookingType,
|
bookingType: booking.bookingType,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
trip: {
|
trip: {
|
||||||
@@ -79,6 +206,8 @@ export class BookingsService {
|
|||||||
},
|
},
|
||||||
passengers: booking.seats.map((bs) => ({
|
passengers: booking.seats.map((bs) => ({
|
||||||
fullName: bs.passengerName,
|
fullName: bs.passengerName,
|
||||||
|
category: bs.passengerCategory,
|
||||||
|
verifaydaVerified: bs.verifaydaVerified,
|
||||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass },
|
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,
|
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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<number> {
|
||||||
|
if (fromCurrency === toCurrency) {
|
||||||
|
return amountMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
|
||||||
|
return Math.round(amountMinor * rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExchangeRate(
|
||||||
|
fromCurrency: Currency,
|
||||||
|
toCurrency: Currency,
|
||||||
|
): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
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 { SearchService } from './search.service';
|
||||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||||
|
|
||||||
@@ -7,6 +7,26 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
|||||||
@Controller('search')
|
@Controller('search')
|
||||||
export class SearchController {
|
export class SearchController {
|
||||||
constructor(private service: SearchService) {}
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
export class SearchTripsDto {
|
export class SearchTripsDto {
|
||||||
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
|
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
|
||||||
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
|
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
|
||||||
@ApiProperty({ example: '2026-05-11' }) @IsDateString() date: 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 {
|
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']
|
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
|
||||||
})
|
})
|
||||||
@IsString() serviceClass: string;
|
@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: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
|
||||||
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
|
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
|
||||||
|
@ApiPropertyOptional({ example: 'ETB', enum: ['ETB', 'DJF', 'USD'] }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SearchController } from './search.controller';
|
import { SearchController } from './search.controller';
|
||||||
import { SearchService } from './search.service';
|
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 {}
|
export class SearchModule {}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||||
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private currencyService: CurrencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async searchTrips(dto: SearchTripsDto) {
|
async searchTrips(dto: SearchTripsDto) {
|
||||||
const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000);
|
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'] } },
|
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 } } },
|
include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalPassengers = dto.adultCount + (dto.childCount || 0);
|
||||||
|
|
||||||
return trips.map((trip) => {
|
return trips.map((trip) => {
|
||||||
const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats);
|
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;
|
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 },
|
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,
|
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
|
||||||
availability: {
|
availability: {
|
||||||
ECONOMY_REGULAR: avail('ECONOMY_REGULAR'),
|
ECONOMY_REGULAR: avail('ECONOMY_REGULAR') >= totalPassengers,
|
||||||
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER'),
|
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER') >= totalPassengers,
|
||||||
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE'),
|
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE') >= totalPassengers,
|
||||||
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER'),
|
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER') >= totalPassengers,
|
||||||
VIP_BED_LOWER: avail('VIP_BED_LOWER'),
|
VIP_BED_LOWER: avail('VIP_BED_LOWER') >= totalPassengers,
|
||||||
VIP_BED_UPPER: avail('VIP_BED_UPPER')
|
VIP_BED_UPPER: avail('VIP_BED_UPPER') >= totalPassengers
|
||||||
},
|
|
||||||
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
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -46,26 +46,69 @@ export class SearchService {
|
|||||||
async getFareQuote(dto: FareQuoteDto) {
|
async getFareQuote(dto: FareQuoteDto) {
|
||||||
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
|
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
|
||||||
if (!trip) throw new NotFoundException('Trip not found');
|
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;
|
let discountMinor = 0;
|
||||||
if (dto.promoCode) {
|
if (dto.promoCode) {
|
||||||
const promo = await this.prisma.promotion.findUnique({ where: { code: 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 loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
||||||
const taxesMinor = Math.round(baseFareMinor * 0.05);
|
const taxesMinor = Math.round(totalBaseFareMinor * 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 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 {
|
private defaultFare(serviceClass: string): number {
|
||||||
const fares: Record<string, number> = {
|
const fares: Record<string, number> = {
|
||||||
ECONOMY_REGULAR: 35000, // 350 ETB
|
ECONOMY_REGULAR: 35000,
|
||||||
ECONOMY_BED_LOWER: 55000, // 550 ETB
|
ECONOMY_BED_LOWER: 55000,
|
||||||
ECONOMY_BED_MIDDLE: 50000, // 500 ETB
|
ECONOMY_BED_MIDDLE: 50000,
|
||||||
ECONOMY_BED_UPPER: 45000, // 450 ETB
|
ECONOMY_BED_UPPER: 45000,
|
||||||
VIP_BED_LOWER: 85000, // 850 ETB
|
VIP_BED_LOWER: 85000,
|
||||||
VIP_BED_UPPER: 80000 // 800 ETB
|
VIP_BED_UPPER: 80000
|
||||||
};
|
};
|
||||||
return fares[serviceClass] ?? 35000;
|
return fares[serviceClass] ?? 35000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean>('VERIFAYDA_ENABLED', false);
|
||||||
|
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
|
||||||
|
this.apiKey = this.config.get<string>('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<VerifaydaVerificationResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user