Merge branch 'alpha' into dev

This commit is contained in:
Abubeker Yasin
2026-05-25 23:37:13 +03:00
37 changed files with 2518 additions and 492 deletions

View File

@@ -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

View File

@@ -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! 🎉**

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
-- AlterTable
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;

View File

@@ -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");

View File

@@ -0,0 +1,26 @@
/*
Warnings:
- You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- DropIndex
DROP INDEX "PaymentMethod_userId_isDefault_idx";
-- AlterTable
ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint",
DROP COLUMN "userId",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB',
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");

View File

@@ -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)';

View File

@@ -97,12 +97,22 @@ enum BookingStatus {
@@schema("passenger")
}
enum PaymentRegion {
ETHIOPIA
DJIBOUTI
INTERNATIONAL
GLOBAL
@@schema("passenger")
}
enum PaymentMethodType {
TELEBIRR
CBE_BIRR
EBIRR
CARD
WALLET
WAAFI
@@schema("passenger")
}
@@ -443,6 +453,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 +479,8 @@ model Booking {
displayCurrency Currency?
displayTotalMinor Int?
bookingType String @default("ONE_WAY")
contactEmail String?
contactPhone String?
userAgent String?
source String @default("WEB")
promoCode String?
@@ -514,14 +527,16 @@ model BookingSeat {
model PaymentMethod {
id String @id @default(uuid())
userId String
type PaymentMethodType
type PaymentMethodType @unique
displayName String
maskedHint String?
region PaymentRegion @default(GLOBAL)
currency String @default("ETB")
providerId String?
isDefault Boolean @default(false)
enabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@index([userId, isDefault])
updatedAt DateTime @updatedAt
@@schema("passenger")
}
@@ -1221,3 +1236,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")
}

View File

@@ -3,182 +3,635 @@ import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting comprehensive seed...');
// ============================================================================
// SECTION 1: STATIONS (18 STATIONS)
// ============================================================================
async function seedStations() {
console.log('📍 Seeding 18 stations...');
const stations = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 },
{ code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 },
{ code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 },
{ code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 },
{ code: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.4500, lng: 39.4000 },
{ code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 },
{ code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2400, lng: 40.7500 },
{ code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4200, lng: 41.2000 },
{ code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 },
{ code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.2000, lng: 42.1500 },
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.8500, lng: 42.4000 },
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 },
{ code: 'DWL', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', lat: 11.4000, lng: 42.9500 },
{ code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 11.1667, lng: 42.7167 },
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.3500, lng: 43.0500 },
{ code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 },
];
// Stations
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa Central', city: 'Addis Ababa', countryCode: 'ET', lat: 9.0054, lng: 38.7636 } });
const sebeta = await prisma.station.upsert({ where: { code: 'SBT' }, update: {}, create: { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 } });
const adama = await prisma.station.upsert({ where: { code: 'ADM' }, update: {}, create: { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 } });
const awash = await prisma.station.upsert({ where: { code: 'AWS' }, update: {}, create: { code: 'AWS', name: 'Awash', city: 'Awash', countryCode: 'ET', lat: 8.9833, lng: 40.1667 } });
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 } });
const aysha = await prisma.station.upsert({ where: { code: 'AYS' }, update: {}, create: { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 } });
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
const created = [];
for (const station of stations) {
const s = await prisma.station.upsert({
where: { code: station.code },
update: {},
create: station,
});
created.push(s);
}
// Seat Classes
const scEconomyRegular = await prisma.seatClass.upsert({ where: { name: 'Economy Regular' }, update: {}, create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true } });
const scEconomyBed = await prisma.seatClass.upsert({ where: { name: 'Economy Bed' }, update: {}, create: { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 65000, isActive: true } });
const scVipBed = await prisma.seatClass.upsert({ where: { name: 'VIP Bed' }, update: {}, create: { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 95000, isActive: true } });
console.log(` ✅ Created ${created.length} stations`);
return created;
}
// Trains (logical services)
const train301 = await prisma.train.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301', description: 'Addis-Djibouti Express' } });
const train302 = await prisma.train.upsert({ where: { number: '302' }, update: {}, create: { number: '302', name: 'Express 302', description: 'Djibouti-Addis Express' } });
// ============================================================================
// SECTION 2: SEAT CLASSES
// ============================================================================
async function seedSeatClasses() {
console.log('💺 Seeding seat classes...');
const classes = [
{ name: 'Economy Regular', description: 'Standard economy seating', basePrice: 25000 },
{ name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 35000 },
{ name: 'VIP Bed', description: 'First class VIP bed', basePrice: 55000 },
];
// Physical Coaches (reusable)
const coachA1 = await prisma.coach.upsert({ where: { coachNumber: 'C-A1' }, update: {}, create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } });
const coachB1 = await prisma.coach.upsert({ where: { coachNumber: 'C-B1' }, update: {}, create: { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } });
const coachC1 = await prisma.coach.upsert({ where: { coachNumber: 'C-C1' }, update: {}, create: { coachNumber: 'C-C1', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } });
const coachA2 = await prisma.coach.upsert({ where: { coachNumber: 'C-A2' }, update: {}, create: { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } });
const coachB2 = await prisma.coach.upsert({ where: { coachNumber: 'C-B2' }, update: {}, create: { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } });
const coachC2 = await prisma.coach.upsert({ where: { coachNumber: 'C-C2' }, update: {}, create: { coachNumber: 'C-C2', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } });
const created = [];
for (const cls of classes) {
const c = await prisma.seatClass.upsert({
where: { name: cls.name },
update: {},
create: { ...cls, isActive: true },
});
created.push(c);
}
// Create seats for each physical coach
for (const coach of [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2]) {
console.log(` ✅ Created ${created.length} seat classes`);
return created;
}
// ============================================================================
// SECTION 3: TRAINS
// ============================================================================
async function seedTrains() {
console.log('🚂 Seeding trains...');
const trains = [
{ number: '301', name: 'Express 301', description: 'Sebeta-Nagad Express' },
{ number: '302', name: 'Express 302', description: 'Nagad-Sebeta Express' },
{ number: '303', name: 'Local 303', description: 'Regional Service' },
];
const created = [];
for (const train of trains) {
const t = await prisma.train.upsert({
where: { number: train.number },
update: {},
create: train,
});
created.push(t);
}
console.log(` ✅ Created ${created.length} trains`);
return created;
}
// ============================================================================
// SECTION 4: COACHES & SEATS
// ============================================================================
async function seedCoachesAndSeats(seatClasses: any[]) {
console.log('🚃 Seeding coaches and seats...');
const [scEconomy, scEconomyBed, scVip] = seatClasses;
const coachConfigs = [
{ coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 },
{ coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 },
{ coachNumber: 'C-C1', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 },
{ coachNumber: 'C-A2', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 },
{ coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 },
{ coachNumber: 'C-C2', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 },
];
const coaches = [];
for (const config of coachConfigs) {
const coach = await prisma.coach.upsert({
where: { coachNumber: config.coachNumber },
update: {},
create: config,
});
coaches.push(coach);
// Create seats for this coach
const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } });
if (existingSeats === 0) {
const seats = [];
const rows = Math.ceil(coach.totalUnits / 4);
const rows = Math.ceil(config.totalUnits / 4);
for (let row = 1; row <= rows; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
if (seats.length >= coach.totalUnits) break;
seats.push({ coachId: coach.id, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}`, kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind });
if (seats.length >= config.totalUnits) break;
seats.push({
coachId: coach.id,
row,
col,
label: `${row}${col}`,
seatNumber: `${config.label}${row}${col}`,
kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind,
});
}
}
await prisma.seat.createMany({ data: seats });
}
}
// Train Schedules — delete dependents first to avoid FK violations
console.log(` ✅ Created ${coaches.length} coaches with seats`);
return coaches;
}
// ============================================================================
// SECTION 5: SCHEDULES (15+ SEGMENTS)
// ============================================================================
async function seedSchedules(trains: any[], stations: any[]) {
console.log('📅 Seeding schedules with 15+ segments...');
const [train301, train302, train303] = trains;
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
// Clean up existing schedules
const existingScheduleIds = (await prisma.trainSchedule.findMany({
where: { trainId: { in: [train301.id, train302.id] } },
where: { trainId: { in: [train301.id, train302.id, train303.id] } },
select: { id: true },
})).map((s) => s.id);
if (existingScheduleIds.length > 0) {
await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { id: { in: existingScheduleIds } } });
}
const schedule1 = await prisma.trainSchedule.create({
data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-15T08:00:00Z'), arrivalAt: new Date('2026-06-15T20:00:00Z'), durationMinutes: 720, stopsCount: 6 },
});
const schedule2 = await prisma.trainSchedule.create({
data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-16T09:00:00Z'), arrivalAt: new Date('2026-06-16T21:30:00Z'), durationMinutes: 750, stopsCount: 5 },
});
const schedule3 = await prisma.trainSchedule.create({
data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-17T07:30:00Z'), arrivalAt: new Date('2026-06-17T19:45:00Z'), durationMinutes: 735, stopsCount: 5 },
});
const schedule4 = await prisma.trainSchedule.create({
data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-18T08:30:00Z'), arrivalAt: new Date('2026-06-18T21:00:00Z'), durationMinutes: 750, stopsCount: 5 },
});
// Assign coaches to schedules
await prisma.coachAssignment.createMany({
data: [
{ scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 },
{ scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 },
{ scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 },
{ scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 },
{ scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 },
{ scheduleId: schedule4.id, coachId: coachA2.id, positionNumber: 1 },
{ scheduleId: schedule4.id, coachId: coachB2.id, positionNumber: 2 },
{ scheduleId: schedule4.id, coachId: coachC2.id, positionNumber: 3 },
],
skipDuplicates: true,
});
const schedules = [
// Full route: Sebeta to Nagad (18 stations)
{
trainId: train301.id,
originStationId: sebeta.id,
destinationStationId: nagad.id,
departureAt: new Date('2026-06-15T06:00:00Z'),
arrivalAt: new Date('2026-06-15T22:00:00Z'),
durationMinutes: 960,
stopsCount: 18,
},
// Return route: Nagad to Sebeta
{
trainId: train302.id,
originStationId: nagad.id,
destinationStationId: sebeta.id,
departureAt: new Date('2026-06-16T07:00:00Z'),
arrivalAt: new Date('2026-06-16T23:30:00Z'),
durationMinutes: 990,
stopsCount: 18,
},
// Regional service: Sebeta to Diredawa
{
trainId: train303.id,
originStationId: sebeta.id,
destinationStationId: diredawa.id,
departureAt: new Date('2026-06-17T08:00:00Z'),
arrivalAt: new Date('2026-06-17T18:00:00Z'),
durationMinutes: 600,
stopsCount: 11,
},
// Additional schedules for next day
{
trainId: train301.id,
originStationId: sebeta.id,
destinationStationId: nagad.id,
departureAt: new Date('2026-06-18T06:30:00Z'),
arrivalAt: new Date('2026-06-18T22:45:00Z'),
durationMinutes: 975,
stopsCount: 18,
},
{
trainId: train302.id,
originStationId: nagad.id,
destinationStationId: sebeta.id,
departureAt: new Date('2026-06-19T07:15:00Z'),
arrivalAt: new Date('2026-06-19T23:45:00Z'),
durationMinutes: 990,
stopsCount: 18,
},
];
// Stop Times
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule1.id, stationId: addis.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T08:00:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: adama.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T09:30:00Z'), plannedDepartureAt: new Date('2026-06-15T09:45:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: awash.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T11:30:00Z'), plannedDepartureAt: new Date('2026-06-15T11:45:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: direDawa.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: aysha.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T18:00:00Z'), plannedDepartureAt: new Date('2026-06-15T18:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: djibouti.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), status: 'UPCOMING' },
],
});
// Fare Rules
for (const schedule of [schedule1, schedule2, schedule3, schedule4]) {
await prisma.fareRule.createMany({
data: [
{ tripId: schedule.id, seatClassId: scEconomyRegular.id, baseFareMinor: 45000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: schedule.id, seatClassId: scEconomyBed.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: schedule.id, seatClassId: scVipBed.id, baseFareMinor: 95000, validFrom: new Date('2026-01-01'), refundable: true },
],
skipDuplicates: true,
});
const created = [];
for (const schedule of schedules) {
const s = await prisma.trainSchedule.create({ data: schedule });
created.push(s);
}
// Users
console.log(` ✅ Created ${created.length} schedules`);
return created;
}
// ============================================================================
// SECTION 6: COACH ASSIGNMENTS
// ============================================================================
async function seedCoachAssignments(schedules: any[], coaches: any[]) {
console.log('🔗 Seeding coach assignments...');
const [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2] = coaches;
const [schedule1, schedule2, schedule3] = schedules;
const assignments = [
{ scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 },
{ scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 },
{ scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 },
{ scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 },
{ scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 },
];
await prisma.coachAssignment.createMany({ data: assignments, skipDuplicates: true });
console.log(` ✅ Created ${assignments.length} coach assignments`);
}
// ============================================================================
// SECTION 7: STOP TIMES (ALL 18 STATIONS)
// ============================================================================
async function seedStopTimes(schedules: any[], stations: any[]) {
console.log('⏱️ Seeding stop times for all stations...');
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
const [schedule1, schedule2, schedule3] = schedules;
// Full route stop times (Sebeta to Nagad)
const fullRouteStops = [
{ scheduleId: schedule1.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T06:30:00Z'), plannedDepartureAt: new Date('2026-06-15T06:35:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T07:30:00Z'), plannedDepartureAt: new Date('2026-06-15T07:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T08:15:00Z'), plannedDepartureAt: new Date('2026-06-15T08:25:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:15:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:45:00Z'), plannedDepartureAt: new Date('2026-06-15T09:50:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T10:30:00Z'), plannedDepartureAt: new Date('2026-06-15T10:45:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-15T12:00:00Z'), plannedDepartureAt: new Date('2026-06-15T12:10:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T13:30:00Z'), plannedDepartureAt: new Date('2026-06-15T13:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: arawa.id, sequence: 12, plannedArrivalAt: new Date('2026-06-15T16:30:00Z'), plannedDepartureAt: new Date('2026-06-15T16:35:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: adigala.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T17:45:00Z'), plannedDepartureAt: new Date('2026-06-15T17:50:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: aysha.id, sequence: 14, plannedArrivalAt: new Date('2026-06-15T18:30:00Z'), plannedDepartureAt: new Date('2026-06-15T18:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: dawanle.id, sequence: 15, plannedArrivalAt: new Date('2026-06-15T19:15:00Z'), plannedDepartureAt: new Date('2026-06-15T19:20:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: alisabieh.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), plannedDepartureAt: new Date('2026-06-15T20:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: holhol.id, sequence: 17, plannedArrivalAt: new Date('2026-06-15T21:00:00Z'), plannedDepartureAt: new Date('2026-06-15T21:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: nagad.id, sequence: 18, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' as const },
];
// Regional route stop times (Sebeta to Diredawa)
const regionalStops = [
{ scheduleId: schedule3.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-17T08:00:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-17T08:30:00Z'), plannedDepartureAt: new Date('2026-06-17T08:35:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-17T09:00:00Z'), plannedDepartureAt: new Date('2026-06-17T09:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-17T09:30:00Z'), plannedDepartureAt: new Date('2026-06-17T09:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-17T10:15:00Z'), plannedDepartureAt: new Date('2026-06-17T10:25:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-17T11:00:00Z'), plannedDepartureAt: new Date('2026-06-17T11:15:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-17T11:45:00Z'), plannedDepartureAt: new Date('2026-06-17T11:50:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-17T12:30:00Z'), plannedDepartureAt: new Date('2026-06-17T12:45:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-17T14:00:00Z'), plannedDepartureAt: new Date('2026-06-17T14:10:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-17T15:30:00Z'), plannedDepartureAt: new Date('2026-06-17T15:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-17T18:00:00Z'), status: 'UPCOMING' as const },
];
const allStops = [...fullRouteStops, ...regionalStops];
await prisma.tripStopTime.createMany({ data: allStops });
console.log(` ✅ Created ${allStops.length} stop times`);
}
// ============================================================================
// SECTION 8: FARE RULES (COMPREHENSIVE SEGMENTS)
// ============================================================================
async function seedFareRules(schedules: any[], seatClasses: any[]) {
console.log('💰 Seeding comprehensive fare rules...');
const [scEconomy, scEconomyBed, scVip] = seatClasses;
// Segment-based fare rules (15+ segments)
const segmentRules = [
// Short segments (1-3 stations)
{ route: 'SBT-LBU', seatClassId: scEconomy.id, baseFareMinor: 5000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'LBU-IND', seatClassId: scEconomy.id, baseFareMinor: 4500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'IND-BSH', seatClassId: scEconomy.id, baseFareMinor: 5500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'BSH-MJO', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'MJO-ADM', seatClassId: scEconomy.id, baseFareMinor: 7000, validFrom: new Date('2026-01-01'), refundable: true },
// Medium segments (3-6 stations)
{ route: 'SBT-BSH', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-ADM', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'ADM-MTH', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'MTH-MSO', seatClassId: scEconomy.id, baseFareMinor: 9500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'MSO-BKE', seatClassId: scEconomy.id, baseFareMinor: 8000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'BKE-DDW', seatClassId: scEconomy.id, baseFareMinor: 7500, validFrom: new Date('2026-01-01'), refundable: true },
// Long segments (6+ stations)
{ route: 'SBT-DDW', seatClassId: scEconomy.id, baseFareMinor: 35000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'DDW-AYS', seatClassId: scEconomy.id, baseFareMinor: 15000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'AYS-NGD', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-NGD', seatClassId: scEconomy.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
// Cross-border segments
{ route: 'DDW-DWL', seatClassId: scEconomy.id, baseFareMinor: 22000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'DWL-ALI', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'ALI-HOL', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'HOL-NGD', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true },
];
// Add Economy Bed prices (40% higher)
const bedRules = segmentRules.map(rule => ({
...rule,
seatClassId: scEconomyBed.id,
baseFareMinor: Math.round(rule.baseFareMinor * 1.4),
}));
// Add VIP prices (80% higher)
const vipRules = segmentRules.map(rule => ({
...rule,
seatClassId: scVip.id,
baseFareMinor: Math.round(rule.baseFareMinor * 1.8),
}));
const allRules = [...segmentRules, ...bedRules, ...vipRules];
await prisma.fareRule.createMany({ data: allRules, skipDuplicates: true });
// Nationality-specific discounts
const nationalityRules = [
// Ethiopian nationals - 10% discount on domestic routes
{ route: 'SBT-DDW', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 31500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-ADM', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 16200, validFrom: new Date('2026-01-01'), refundable: true },
// Djiboutian nationals - 5% discount on cross-border routes
{ route: 'DDW-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 42750, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 61750, validFrom: new Date('2026-01-01'), refundable: true },
];
await prisma.fareRule.createMany({ data: nationalityRules, skipDuplicates: true });
console.log(` ✅ Created ${allRules.length + nationalityRules.length} fare rules`);
}
// ============================================================================
// SECTION 9: USERS & PASSENGERS
// ============================================================================
async function seedUsers() {
console.log('👥 Seeding users...');
const hash = await bcrypt.hash('password123', 10);
const adminHash = await bcrypt.hash('admin123', 10);
const agentHash = await bcrypt.hash('agent123', 10);
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: adminHash, role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' } });
const passengerUser = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash, nationality: 'Ethiopian', nationalId: 'ET123456789' } });
let passenger = await prisma.passenger.findUnique({ where: { userId: passengerUser.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: passengerUser.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
// Admin
await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash: adminHash, role: 'ADMIN' },
create: {
fullName: 'EDR Admin',
email: 'admin@edr-platform.com',
phone: '+251900000000',
passwordHash: adminHash,
role: 'ADMIN',
},
});
// Ethiopian Passenger
const ethiopianUser = await prisma.user.upsert({
where: { email: 'abebe@email.com' },
update: {},
create: {
fullName: 'Abebe Kebede',
email: 'abebe@email.com',
phone: '+251912345678',
passwordHash: hash,
nationality: 'Ethiopian',
nationalId: 'ET123456789',
},
});
let ethiopianPassenger = await prisma.passenger.findUnique({ where: { userId: ethiopianUser.id } });
if (!ethiopianPassenger) {
ethiopianPassenger = await prisma.passenger.create({ data: { userId: ethiopianUser.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: ethiopianPassenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: ethiopianPassenger.id, balanceMinor: 125000 } });
}
await prisma.userPreferences.upsert({ where: { userId: passengerUser.id }, update: {}, create: { userId: passengerUser.id, language: 'en' } });
await prisma.userPreferences.upsert({
where: { userId: ethiopianUser.id },
update: {},
create: { userId: ethiopianUser.id, language: 'en' },
});
const agentUser = await prisma.user.upsert({ where: { email: 'agent@edr-platform.com' }, update: { passwordHash: agentHash, role: 'AGENT' }, create: { fullName: 'Agent Abebe', email: 'agent@edr-platform.com', phone: '+251911111111', passwordHash: agentHash, role: 'AGENT' } });
await prisma.agent.upsert({ where: { userId: agentUser.id }, update: {}, create: { userId: agentUser.id, agentCode: 'AG001', stationId: addis.id, commissionRate: 5, active: true } });
// Djiboutian Passenger
const djiboutianUser = await prisma.user.upsert({
where: { email: 'ahmed@email.com' },
update: {},
create: {
fullName: 'Ahmed Hassan',
email: 'ahmed@email.com',
phone: '+25377123456',
passwordHash: hash,
nationality: 'Djiboutian',
passportNumber: 'DJ1234567',
},
});
let djiboutianPassenger = await prisma.passenger.findUnique({ where: { userId: djiboutianUser.id } });
if (!djiboutianPassenger) {
djiboutianPassenger = await prisma.passenger.create({ data: { userId: djiboutianUser.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: djiboutianPassenger.id, pointsBalance: 1200, tier: 'BRONZE' } });
await prisma.walletAccount.create({ data: { passengerId: djiboutianPassenger.id, balanceMinor: 85000 } });
}
await prisma.userPreferences.upsert({
where: { userId: djiboutianUser.id },
update: {},
create: { userId: djiboutianUser.id, language: 'fr' },
});
// Agent
const agentUser = await prisma.user.upsert({
where: { email: 'agent@edr-platform.com' },
update: { passwordHash: agentHash, role: 'AGENT' },
create: {
fullName: 'Agent Abebe',
email: 'agent@edr-platform.com',
phone: '+251911111111',
passwordHash: agentHash,
role: 'AGENT',
},
});
const stations = await prisma.station.findMany();
await prisma.agent.upsert({
where: { userId: agentUser.id },
update: {},
create: {
userId: agentUser.id,
agentCode: 'AG001',
stationId: stations[0].id,
commissionRate: 5,
active: true,
},
});
console.log(` ✅ Created 4 users (Admin, Ethiopian, Djiboutian, Agent)`);
}
// ============================================================================
// SECTION 10: SUPPORTING DATA
// ============================================================================
async function seedSupportingData(seatClasses: any[]) {
console.log('📦 Seeding supporting data...');
// Baggage Allowance
await prisma.baggageAllowance.deleteMany({});
await prisma.baggageAllowance.createMany({
data: [
{ seatClassId: scEconomyRegular.id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
{ seatClassId: scEconomyBed.id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
{ seatClassId: scVipBed.id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
{ seatClassId: seatClasses[0].id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
{ seatClassId: seatClasses[1].id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
{ seatClassId: seatClasses[2].id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
],
});
// Supported Payment Methods (platform-wide catalog)
const paymentMethods = [
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 1, isDefault: true },
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 2 },
{ type: 'EBIRR', displayName: 'E-Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 3 },
{ type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI', currency: 'DJF', sortOrder: 4 },
{ type: 'CARD', displayName: 'Credit / Debit Card', region: 'INTERNATIONAL', currency: 'USD', sortOrder: 5 },
{ type: 'WALLET', displayName: 'EDR Wallet', region: 'GLOBAL', currency: 'ETB', sortOrder: 6 },
] as const;
for (const pm of paymentMethods) {
await prisma.paymentMethod.upsert({
where: { type: pm.type as any },
update: { displayName: pm.displayName, region: pm.region as any, currency: pm.currency, sortOrder: pm.sortOrder, enabled: true },
create: { ...pm, region: pm.region as any, type: pm.type as any },
});
}
// 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();
});

View File

@@ -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(),

View 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 ?? '',
}));

View File

@@ -27,30 +27,213 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle("EDR Passenger API")
.setDescription(
"Ethio-Djibouti Railway Passenger API — booking lifecycle, seat inventory, payment (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, and support.",
`# Ethio-Djibouti Railway Passenger Booking API
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Key Features
### 🎫 Booking Lifecycle
- Search trips with real-time availability
- 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
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 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
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### 💰 Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
### 📍 Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
### 🔒 Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- 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
- Webhook endpoints: No limit
## Error Handling
All errors follow standard format:
\`\`\`json
{
"statusCode": 400,
"message": "Validation failed",
"error": "Bad Request",
"timestamp": "2026-05-20T14:30:00.000Z",
"path": "/bookings"
}
\`\`\`
## Pagination
List endpoints support pagination:
- \`limit\`: Number of items (default: 20, max: 100)
- \`offset\`: Skip items (default: 0)
## Webhooks
Payment providers send notifications to:
- \`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
- **Documentation:** https://docs.edr-platform.com
- **Status Page:** https://status.edr-platform.com
`,
)
.setVersion("1.0.0")
.addBearerAuth(
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking and management")
.addTag("Auth", "Registration and login")
.addTag("Stations", "Station directory")
.addTag("Booking", "Booking lifecycle")
.addTag("Dashboard", "Home dashboard aggregate")
.addTag("Fleet", "Train services and coaches")
.addTag("Fraud Detection", "Fraud detection and monitoring")
.addTag("Live Tracking", "Real-time trip status and crowd signals")
.addTag("Loyalty", "Points, tiers, and rewards")
.addTag("Notifications", "Push and email notifications")
.addTag("Passenger", "Profiles, traveler profiles, saved routes")
.addTag("Payment", "Payment intents and refunds")
.addTag("Payment Webhooks", "Endpoints for payment provider notifications")
.addTag("Promotions", "Promo codes and campaigns")
.addTag("Reports", "Sales and operational reports")
.addTag("Routes", "Route information and management")
.addTag("Schedule", "Trips and fare rules")
.addTag("Search", "Trip search and fare quotes")
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed")
.addTag("Seats", "Seat maps and holds")
.addTag("Booking", "Booking lifecycle")
.addTag("Payment", "Payment intents and refunds")
.addTag("Tickets", "QR ticket generation and validation")
.addTag("Passenger", "Profiles, traveler profiles, saved routes")
.addTag("Notifications", "Push and email notifications")
.addTag("Loyalty", "Points, tiers, and rewards")
.addTag("Wallet", "Wallet balance and ledger")
.addTag("Promotions", "Promo codes and campaigns")
.addTag("Live Tracking", "Real-time trip status and crowd signals")
.addTag("Segment-based Seats", "Seats assigned and released by trip segments")
.addTag("Stations", "Station directory")
.addTag("Support", "FAQ and chat support")
.addTag("Dashboard", "Home dashboard aggregate")
.addTag("Tickets", "QR ticket generation and validation")
.addTag("Wallet", "Wallet balance and ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View File

@@ -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)'

View File

@@ -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 {

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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 } });
}

View File

@@ -1,18 +1,65 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Payment')
@Controller('payments')
@UseGuards(JwtGuard)
@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')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' })
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' })
addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods')
@ApiOperation({
summary: 'List payment systems supported by the platform',
description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.',
})
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
}

View File

@@ -1,16 +1,34 @@
import { IsString, IsEnum, IsOptional, IsIn } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } 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 PaymentRegionEnum {
ETHIOPIA = 'ETHIOPIA',
DJIBOUTI = 'DJIBOUTI',
INTERNATIONAL = 'INTERNATIONAL',
GLOBAL = 'GLOBAL',
}
export enum PaymentMethodTypeEnum {
TELEBIRR = 'TELEBIRR', // Ethiopia
CBE_BIRR = 'CBE_BIRR', // Ethiopia
EBIRR = 'EBIRR', // Ethiopia
WAAFI = 'WAAFI', // Djibouti
CARD = 'CARD', // International
WALLET = 'WALLET' // Internal
}
export type PaymentPlatformDto = 'web' | 'mobile';
export class InitiatePaymentDto {
@ApiProperty() @IsString() bookingId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional() @IsOptional() @IsString() paymentMethodId?: string;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' })
@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;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' })
@IsOptional()
@IsIn(['web', 'mobile'])
platform?: PaymentPlatformDto;
@@ -22,10 +40,21 @@ export class RefundDto {
}
export class AddPaymentMethodDto {
@ApiProperty() @IsString() userId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
@ApiProperty() @IsString() displayName: string;
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
@ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum;
@ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
@ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean;
@ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number;
}
export class SupportedPaymentMethodDto {
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
@ApiProperty({ example: 'Telebirr' }) displayName: string;
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
@ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string;
@ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean;
}
export class ClientActionDto {

View File

@@ -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 {}

View File

@@ -3,13 +3,14 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto';
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types';
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],
]);
}
@@ -279,9 +282,34 @@ export class PaymentsService {
return { refunded: true, bookingRef: booking?.bookingRef };
}
addPaymentMethod(dto: AddPaymentMethodDto) { return this.prisma.paymentMethod.create({ data: dto }); }
addPaymentMethod(dto: AddPaymentMethodDto) {
const data = {
type: dto.type as unknown as PaymentMethodType,
displayName: dto.displayName,
region: dto.region as unknown as PaymentRegion,
currency: dto.currency ?? 'ETB',
providerId: dto.providerId,
enabled: dto.enabled ?? true,
sortOrder: dto.sortOrder ?? 0,
};
return this.prisma.paymentMethod.upsert({
where: { type: data.type },
update: data,
create: data,
});
}
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
...(region
? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } }
: {}),
},
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
});
}
async finalizePaymentSuccess(input: {
intentId: string;

View File

@@ -0,0 +1,274 @@
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 || `${this.baseUrl}/checkout?ref=${transactionId}`;
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: { type: 'REDIRECT', url: checkoutUrl },
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') ?? '';
}
}

View File

@@ -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 };
}
}

View File

@@ -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' };
}
}

View File

@@ -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;

View File

@@ -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,
},

View File

@@ -10,14 +10,16 @@ export class SearchController {
@Post()
@ApiOperation({
summary: 'Search schedules by any origindestination 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' })

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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); }

View File

@@ -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); }
}

View File

@@ -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,