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