Refactored the whole app based on the requirements shared

This commit is contained in:
Stephanos A
2026-05-21 08:48:28 +03:00
parent 2dc3da9e74
commit 51bc906792
84 changed files with 6880 additions and 12659 deletions

584
README.md
View File

@@ -1,141 +1,513 @@
# EDR Passenger API
# EDR Platform - Ethio-Djibouti Railway Passenger API
NestJS REST API for the Ethio-Djibouti Railway passenger platform. Handles booking lifecycle, seat inventory, payments (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, notifications, and support.
Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM.
## Tech Stack
## 🚀 Features
- **Runtime**: Node.js 20, TypeScript
- **Framework**: NestJS 11
- **Database**: PostgreSQL via Prisma ORM
- **Auth**: JWT (Passport)
- **Docs**: Swagger / OpenAPI (`/api-docs`)
- **Package manager**: pnpm 9
### Core Modules
- **Authentication & Authorization** - Dual authentication system:
- **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout
- **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins)
- Role-based access control (RBAC) with granular permissions
- **Booking Management** - Complete booking lifecycle with modification, cancellation, refunds, and fare breakdown
- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling
- **Seat Management** - Real-time seat inventory, holds, releases, and blocking with coach/class management
- **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs
- **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation
- **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences
- **Loyalty Program** - Points accumulation, tier management (Bronze/Silver/Gold/Platinum), and rewards
- **Wallet System** - Balance management, top-up, transaction ledger
- **Live Tracking** - Real-time trip status, location updates, delay notifications, crowd signals
- **Notifications** - Multi-channel (Email, SMS, Push) with templating engine
- **Support System** - FAQ management, live chat conversations
- **Reports & Analytics** - Revenue reports, occupancy analytics, agent sales tracking
- **Route Management** - Route configuration, stops, fare rules, baggage allowance
## Prerequisites
### Technical Features
- **Security** - Password hashing (bcrypt), JWT tokens, rate limiting, audit logging
- **Validation** - Request validation with class-validator, DTO transformation
- **Documentation** - Auto-generated Swagger/OpenAPI docs at `/api-docs`
- **Error Handling** - Global exception filters with standardized error responses
- **Database** - PostgreSQL with Prisma ORM, migrations, and comprehensive seeding
- **Scheduling** - Cron jobs for automated tasks (seat release, report generation)
- **Event System** - Event-driven architecture with @nestjs/event-emitter
- Node.js >= 20
- pnpm >= 9 (`npm i -g pnpm`)
- PostgreSQL 15+
## 📋 Prerequisites
## Quick Start
- **Node.js** >= 20.x
- **pnpm** >= 9.x (`npm install -g pnpm`)
- **PostgreSQL** >= 15.x
- **Git**
## 🛠️ Installation & Setup
### 1. Clone Repository
```bash
# 1. Install dependencies (from monorepo root)
pnpm install
git clone <repository-url>
cd edr-platform
```
# 2. Copy and fill environment variables
### 2. Install Dependencies
```bash
pnpm install
```
### 3. Environment Configuration
```bash
# Copy environment template
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
# 3. Generate Prisma client
# Edit .env file with your configuration
```
#### Required Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `NODE_ENV` | Environment mode | `development` |
| `PORT` | HTTP server port | `4000` |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
| `FRONTEND_URL` | Web app CORS origin | `http://localhost:3000` |
| `PORTAL_URL` | Admin portal CORS origin | `http://localhost:3001` |
| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` |
| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` |
#### Corporate IAM Configuration (Back-office Authentication)
| Variable | Description | Example |
|----------|-------------|---------|
| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` |
| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` |
| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` |
**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only).
#### Optional: Payment Provider Configuration
```bash
# Telebirr Configuration
TELEBIRR_BASE_URL=https://api.telebirr.com
TELEBIRR_MERCHANT_CODE=your-merchant-code
TELEBIRR_APP_SECRET=your-app-secret
# ... see .env.example for complete list
```
### 4. Database Setup
#### Start PostgreSQL
```bash
# Using Docker (recommended)
docker run --name edr-postgres \
-e POSTGRES_USER=edr \
-e POSTGRES_PASSWORD=edr_secret \
-e POSTGRES_DB=edr_passenger \
-p 5432:5432 \
-d postgres:15
# Or use your local PostgreSQL installation
```
#### Generate Prisma Client
```bash
pnpm --filter @edr/passenger-api run prisma:generate
```
# 4. Run database migrations
#### Run Migrations
```bash
pnpm --filter @edr/passenger-api run prisma:migrate
```
# 5. Seed the database
#### Seed Database
```bash
pnpm --filter @edr/passenger-api run prisma:seed
```
# 6. Start in development mode
**Seed Data Includes:**
- 5 Stations (Addis Ababa, Adama, Awash, Dire Dawa, Djibouti)
- 1 Route with 5 stops and fare rules
- 2 Train services with 2 trips
- 360 seats across 6 coaches (Economy, Bed, VIP classes)
- 3 User accounts (Admin, Passenger, Agent)
- Baggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals
### 5. Start Development Server
```bash
pnpm --filter @edr/passenger-api run dev
```
API runs at **http://localhost:4000**
Swagger UI at **http://localhost:4000/api-docs**
**API Server:** http://localhost:4000
**Swagger Docs:** http://localhost:4000/api-docs
## Environment Variables
## 🔑 Default Credentials
| Variable | Description | Default |
| --------------------- | ------------------------------------ | -------------------------------- |
| `PORT` | HTTP port | `4000` |
| `DATABASE_URL` | PostgreSQL connection string | — |
| `JWT_SECRET` | JWT signing secret | — |
| `JWT_EXPIRES_IN` | JWT expiry | `7d` |
| `FRONTEND_URL` | Allowed CORS origin (web app) | `http://localhost:3000` |
| `PORTAL_URL` | Allowed CORS origin (portal) | `http://localhost:3001` |
| `SENDGRID_API_KEY` | SendGrid key for email notifications | _(optional — logs if absent)_ |
| `SENDGRID_FROM_EMAIL` | Sender email address | `noreply@edr-platform.com` |
After seeding, use these credentials to test the API:
## API Modules
| Role | Email | Password | Description |
|------|-------|----------|-------------|
| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management |
| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet |
| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking |
| Tag | Base path | Description |
| -------------- | ----------------- | ---------------------------------------- |
| Auth | `/auth` | Register, login, JWT |
| Stations | `/stations` | Station directory |
| Fleet | `/fleet` | Train services, coaches, seat batches |
| Schedule | `/schedule` | Trips, fare rules, status updates |
| Search | `/search` | Trip search, fare quotes |
| Seats | `/seats` | Seat maps, holds, releases |
| Booking | `/bookings` | Create, retrieve, cancel bookings |
| Payment | `/payments` | Initiate payment, refunds, methods |
| Tickets | `/tickets` | QR ticket generation and validation |
| Passenger | `/passengers` | Profiles, traveler profiles, saved routes|
| Notifications | `/notifications` | In-app notifications |
| Loyalty | `/loyalty` | Points, tiers, rewards |
| Wallet | `/wallet` | Balance, top-up, ledger |
| Promotions | `/promos` | Active promos, promo code validation |
| Live Tracking | `/live` | Real-time trip status, crowd signals |
| Support | `/support` | FAQ, chat conversations |
| Dashboard | `/dashboard` | Home screen aggregate |
## 📚 API Documentation
## Seed Credentials
### Swagger UI
Interactive API documentation available at: **http://localhost:4000/api-docs**
After running `prisma:seed`:
### Authentication Methods
| Role | Email | Password |
| --------- | ------------------------ | ------------- |
| Passenger | `kelemu@email.com` | `password123` |
| Admin | `admin@edr-platform.com` | `admin123` |
The API uses two authentication schemes:
## Docker
#### 1. JWT Authentication (Passenger-facing)
- **Used for**: Passenger bookings, profile management, wallet, loyalty
- **Header**: `Authorization: Bearer <jwt-token>`
- **Obtain token**: `POST /auth/login` with passenger credentials
- **Swagger Security**: `JWT-auth`
#### 2. IAM Authentication (Back-office)
- **Used for**: Agent operations, fraud detection, reports, admin functions
- **Header**: `Authorization: Bearer <iam-token>`
- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com)
- **Swagger Security**: `IAM-auth`
- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF
### API Endpoints Overview
| Module | Base Path | Auth Type | Description |
|--------|-----------|-----------|-------------|
| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics |
| **Stations** | `/stations` | JWT | Station directory and information |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Search** | `/search` | JWT | Trip search, availability, fare quotes |
| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking |
| **Bookings** | `/bookings` | JWT | Create, modify, cancel bookings |
| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds |
| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation |
| **Passengers** | `/passengers` | JWT | Profile management, traveler profiles |
| **Notifications** | `/notifications` | JWT | In-app notifications, preferences |
| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption |
| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history |
| **Promotions** | `/promos` | JWT | Active promotions, promo code validation |
| **Live Tracking** | `/live` | JWT | Real-time trip status, crowd signals |
| **Support** | `/support` | JWT | FAQ, chat conversations |
| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data |
### Example API Calls
#### 1. Register Passenger
```bash
POST /auth/register
Content-Type: application/json
{
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"password": "SecurePass123"
}
```
#### 2. Login (Passenger)
```bash
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123"
}
# Response includes JWT token
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "id": "uuid", "role": "PASSENGER" }
}
```
#### 2b. Agent Booking (IAM Auth)
```bash
POST /agents/bookings
Authorization: Bearer <iam-token>
Content-Type: application/json
{
"tripId": "uuid",
"seats": [...],
"paymentMethod": "CASH",
"cashReceived": 50000
}
```
#### 3. Search Trips
```bash
GET /search/trips?originStationId={id}&destinationStationId={id}&date=2026-06-15
Authorization: Bearer {token}
```
#### 4. Create Booking
```bash
POST /bookings
Authorization: Bearer {token}
Content-Type: application/json
{
"tripId": "uuid",
"seats": [
{
"seatId": "uuid",
"passengerName": "John Doe",
"idDocumentType": "PASSPORT",
"idDocumentNumber": "ET123456"
}
]
}
```
## 🏗️ Project Structure
```
apps/edr-passenger-api/
├── prisma/
│ ├── schema.prisma # Database schema (40+ models)
│ ├── seed.ts # Comprehensive seed script
│ └── migrations/ # Database migrations
├── src/
│ ├── common/ # Shared utilities
│ │ ├── filters/ # Exception filters
│ │ ├── interceptors/ # Response interceptors
│ │ ├── pipes/ # Validation pipes
│ │ ├── i18n/ # Internationalization
│ │ ├── jwt.guard.ts # JWT authentication guard (passengers)
│ │ ├── jwt.strategy.ts # Passport JWT strategy
│ │ ├── iam-adapter.ts # Corporate IAM guard (back-office)
│ │ ├── iam.module.ts # IAM module
│ │ ├── roles.guard.ts # RBAC authorization guard
│ │ ├── roles.decorator.ts # Roles decorator
│ │ ├── prisma.service.ts # Prisma client service
│ │ └── prisma.module.ts # Prisma module
│ ├── config/ # Configuration files
│ │ ├── app.config.ts # App configuration
│ │ ├── database.config.ts # Database configuration
│ │ └── telebirr.config.ts # Payment provider config
│ ├── modules/ # Feature modules
│ │ ├── auth/ # Authentication & authorization (JWT)
│ │ ├── agents/ # Agent operations (IAM-protected)
│ │ ├── bookings/ # Booking management (JWT)
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
│ │ ├── fraud/ # Fraud detection (IAM-protected)
│ │ ├── live/ # Live tracking (JWT)
│ │ ├── loyalty/ # Loyalty program (JWT)
│ │ ├── notifications/ # Notification system (JWT)
│ │ ├── passengers/ # Passenger management (JWT)
│ │ ├── payments/ # Payment processing (JWT/Webhooks)
│ │ ├── promos/ # Promotions (JWT)
│ │ ├── reports/ # Reports & analytics (IAM-protected)
│ │ ├── schedules/ # Trip schedules (JWT/IAM)
│ │ ├── search/ # Trip search (JWT)
│ │ ├── seats/ # Seat management (JWT/IAM)
│ │ ├── segments/ # Journey segments (JWT)
│ │ ├── stations/ # Station management (JWT)
│ │ ├── support/ # Customer support (JWT)
│ │ ├── tickets/ # Ticketing (JWT/IAM)
│ │ └── wallet/ # Wallet system (JWT)
│ ├── app.module.ts # Root application module
│ └── main.ts # Application entry point
├── test/ # E2E tests
├── .env.example # Environment template
├── Dockerfile # Docker configuration
├── nest-cli.json # NestJS CLI configuration
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
└── tsconfig.build.json # Build configuration
```
## 🗄️ Database Schema
### Key Models (40+ total)
**Core Entities:**
- `User`, `Session`, `Passenger`, `Agent`
- `Station`, `Route`, `RouteStop`, `RouteFareRule`
- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat`
- `Booking`, `BookingSeat`, `Ticket`
- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent`
**Enhanced Features:**
- `OtpCode`, `PasswordResetToken` (Auth)
- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents)
- `BookingModification`, `BookingCancellation` (Booking lifecycle)
- `GateValidationLog` (Ticket validation)
- `BaggageAllowance`, `BaggageBooking` (Baggage)
- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward`
- `WalletAccount`, `WalletLedgerEntry`
- `Notification`, `NotificationTemplate`
- `AuditLog`, `OperationalReport`
- `SeatBlock`, `SeatHold`
## 🔧 Available Scripts
```bash
# Build image (run from monorepo root)
# Development
pnpm --filter @edr/passenger-api run dev # Start with hot-reload
# Build
pnpm --filter @edr/passenger-api run build # Compile TypeScript
# Production
pnpm --filter @edr/passenger-api run start # Run compiled code
# Testing
pnpm --filter @edr/passenger-api run test # Unit tests
pnpm --filter @edr/passenger-api run test:e2e # E2E tests
# Code Quality
pnpm --filter @edr/passenger-api run lint # ESLint
pnpm --filter @edr/passenger-api run type-check # TypeScript check
# Database
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
pnpm --filter @edr/passenger-api run prisma:migrate # Run migrations
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
```
## 🐳 Docker Deployment
### Build Image
```bash
# From monorepo root
docker build -f apps/edr-passenger-api/Dockerfile -t edr-passenger-api .
# Run
docker run -p 4000:4000 --env-file apps/edr-passenger-api/.env edr-passenger-api
```
## Scripts
| Command | Description |
| ---------------------- | ------------------------------ |
| `pnpm dev` | Start with hot-reload |
| `pnpm build` | Compile to `dist/` |
| `pnpm start` | Run compiled output |
| `pnpm test` | Run unit tests |
| `pnpm lint` | ESLint |
| `pnpm type-check` | TypeScript type check |
| `pnpm prisma:generate` | Regenerate Prisma client |
| `pnpm prisma:migrate` | Run pending migrations |
| `pnpm prisma:seed` | Seed the database |
## Project Structure
### Run Container
```bash
docker run -d \
--name edr-api \
-p 4000:4000 \
--env-file apps/edr-passenger-api/.env \
edr-passenger-api
```
src/
├── common/ # PrismaService, JwtGuard, JwtStrategy, filters, interceptors
├── config/ # app.config.ts, database.config.ts
└── modules/
├── auth/
├── stations/
├── fleet/
├── schedules/
├── search/
├── seats/
├── bookings/
├── payments/
├── tickets/
├── passengers/
├── notifications/
├── loyalty/
├── wallet/
├── promos/
├── live/
├── support/
└── dashboard/
prisma/
├── schema.prisma
├── seed.ts
└── migrations/
### Docker Compose (Recommended)
```yaml
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: edr
POSTGRES_PASSWORD: edr_secret
POSTGRES_DB: edr_passenger
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
api:
build:
context: .
dockerfile: apps/edr-passenger-api/Dockerfile
ports:
- "4000:4000"
environment:
DATABASE_URL: postgresql://edr:edr_secret@postgres:5432/edr_passenger
JWT_SECRET: your-secret-key
PORT: 4000
depends_on:
- postgres
volumes:
postgres_data:
```
## 🔒 Security Best Practices
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters).
3. **Password Hashing** - Bcrypt with salt rounds (default: 10).
4. **Rate Limiting** - Implement rate limiting for auth endpoints.
5. **CORS** - Configure allowed origins in production.
6. **HTTPS** - Always use HTTPS in production.
7. **Database** - Use connection pooling and prepared statements (Prisma handles this).
8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table.
9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM.
10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service.
11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN).
12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout.
## 📊 Monitoring & Logging
- **Application Logs** - NestJS built-in logger
- **Database Queries** - Prisma query logging (enable in development)
- **Audit Trail** - All user actions logged in `AuditLog` table
- **Error Tracking** - Global exception filters with detailed error responses
## 🧪 Testing
```bash
# Unit tests
pnpm --filter @edr/passenger-api run test
# E2E tests
pnpm --filter @edr/passenger-api run test:e2e
# Test coverage
pnpm --filter @edr/passenger-api run test:cov
```
## 🚀 Production Deployment
### Pre-deployment Checklist
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY)
- [ ] Set NODE_ENV=production
- [ ] Configure CORS origins (FRONTEND_URL, PORTAL_URL)
- [ ] Set up SSL/TLS certificates
- [ ] Configure database connection pooling
- [ ] Set up monitoring and logging
- [ ] Configure backup strategy
- [ ] Test payment provider integrations
- [ ] Verify IAM token validation endpoint
- [ ] Review security settings and audit logs
- [ ] Test both JWT and IAM authentication flows
### Deployment Steps
```bash
# 1. Build application
pnpm --filter @edr/passenger-api run build
# 2. Run migrations
pnpm --filter @edr/passenger-api run prisma:migrate
# 3. Start production server
NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod
```
## 🤝 Contributing
1. Fork the repository
2. Create feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open Pull Request
## 📝 License
This project is proprietary and confidential.
## 📧 Support
For technical support or questions:
- Email: support@edr-platform.com
- Documentation: http://localhost:4000/api-docs
---
**Built with ❤️ for Ethio-Djibouti Railway**

View File

@@ -17,6 +17,19 @@ JWT_EXPIRES_IN=7d
SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
# SMS Configuration
SMS_PROVIDER=twilio
SMS_API_KEY=
# Twilio (if SMS_PROVIDER=twilio)
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
# Africa's Talking (if SMS_PROVIDER=africastalking)
AFRICASTALKING_USERNAME=
AFRICASTALKING_FROM=
# Telebirr
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
@@ -29,4 +42,41 @@ TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
TELEBIRR_INSECURE_TLS=false
# CBE Birr
CBE_BASE_URL=
CBE_MERCHANT_ID=
CBE_SECRET_KEY=
CBE_NOTIFY_URL=
CBE_RETURN_URL=
# eBirr
EBIRR_BASE_URL=
EBIRR_MERCHANT_CODE=
EBIRR_SECRET_KEY=
EBIRR_NOTIFY_URL=
EBIRR_RETURN_URL=
# Card Gateway (Stripe-like)
CARD_BASE_URL=
CARD_API_KEY=
CARD_WEBHOOK_SECRET=
CARD_WEBHOOK_URL=
CARD_RETURN_URL=
# Payment Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET
# Session Configuration
SESSION_INACTIVITY_MINUTES=30
# i18n Configuration
DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,am,fr,om
# Corporate IAM Configuration (for back-office authentication)
IAM_ENABLED=false
IAM_API_URL=https://iam.tria-plc.com/api
IAM_API_KEY=
GITHUB_PACKAGE_TOKEN=

View File

@@ -0,0 +1,6 @@
# GitHub Packages configuration for @tria-plc scope
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
# Default registry for other packages
registry=https://registry.npmjs.org/

View File

@@ -1,364 +0,0 @@
# Segment-Based Seat Reservation
## Overview
This implementation introduces segment-based seat reservation and release logic for the Ethio-Djibouti Railway passenger booking system. It allows passengers to book partial journeys while ensuring optimal seat utilization through automatic release when passengers reach their destinations.
## Key Features
- **Segment-based reservations**: Book seats for specific route segments (e.g., Addis Ababa → Dire Dawa)
- **Automatic seat release**: Seats are released when passengers reach their destination
- **Concurrency control**: Database transactions ensure consistency
- **Real-time updates**: Event-driven notifications for seat availability changes
- **Hold expiration**: Automatic cleanup of expired seat holds
## Route Example
**Full Route**: Addis Ababa → Adama → Awash → Dire Dawa → Djibouti
**Passenger Journey**: Addis Ababa → Dire Dawa
- **Segments**: [Addis→Adama, Adama→Awash, Awash→Dire Dawa]
- **Seat Status**: HELD → BOOKED → AVAILABLE (when reaching Dire Dawa)
## Database Schema Integration
### Core Tables Used
```sql
-- Trip and route structure
Trip, TripStopTime, Station
-- Seat management
Seat, SeatHold, BookingSeat, Booking
-- Journey tracking
JourneySegment (stores segment-to-seat mapping)
-- Real-time progress
TripLiveStatus (triggers seat releases)
```
### Key Enums
```typescript
enum SeatStatus {
AVAILABLE = 'AVAILABLE',
HELD = 'HELD',
BOOKED = 'BOOKED',
BLOCKED = 'BLOCKED'
}
```
## API Endpoints
### 1. Check Seat Availability
```http
GET /segments/seats/availability?tripId=trip_001&originStationId=st_ADD&destinationStationId=st_DRE
```
**Response:**
```json
{
"segments": [
{ "fromName": "Addis Ababa", "toName": "Adama", "fromSequence": 0, "toSequence": 1 },
{ "fromName": "Adama", "toName": "Awash", "fromSequence": 1, "toSequence": 2 },
{ "fromName": "Awash", "toName": "Dire Dawa", "fromSequence": 2, "toSequence": 3 }
],
"availableSeats": [
{ "id": "seat_1", "label": "1A", "coach": "A", "serviceClass": "ECONOMY" }
],
"totalAvailable": 1
}
```
### 2. Hold Seats
```http
POST /segments/seats/hold
```
**Request:**
```json
{
"tripId": "trip_001",
"seatIds": ["seat_1", "seat_2"],
"passengerId": "passenger_123",
"originStationId": "st_ADD",
"destinationStationId": "st_DRE",
"fareQuoteId": "quote_456"
}
```
**Response:**
```json
{
"holdId": "hold_789",
"expiresAt": "2024-01-15T10:10:00Z",
"segments": [
{ "fromName": "Addis Ababa", "toName": "Adama" },
{ "fromName": "Adama", "toName": "Awash" },
{ "fromName": "Awash", "toName": "Dire Dawa" }
],
"seats": ["seat_1", "seat_2"]
}
```
### 3. Confirm Booking
```http
POST /segments/seats/confirm
```
**Request:**
```json
{
"holdId": "hold_789",
"bookingId": "booking_123"
}
```
### 4. Release Seats (Automatic)
```http
POST /segments/seats/release
```
**Request:**
```json
{
"tripId": "trip_001",
"currentStationId": "st_DRE"
}
```
## Database Transaction Flow
### 1. Seat Hold Transaction
```typescript
async function holdSeatsTransaction(request: SeatHoldRequest) {
return prisma.$transaction(async (tx) => {
// 1. Validate seat availability
const seats = await tx.seat.findMany({
where: { id: { in: request.seatIds } }
});
// 2. Check for overlapping reservations
for (const seatId of request.seatIds) {
const overlaps = await checkOverlaps(tx, tripId, seatId, segments);
if (overlaps.length > 0) throw new ConflictException();
}
// 3. Create hold record
const hold = await tx.seatHold.create({
data: {
tripId: request.tripId,
seatIds: request.seatIds,
passengerId: request.passengerId,
expiresAt: new Date(Date.now() + 10 * 60 * 1000)
}
});
// 4. Update seat status
await tx.seat.updateMany({
where: { id: { in: request.seatIds } },
data: { status: 'HELD', heldUntil: hold.expiresAt }
});
return hold;
});
}
```
### 2. Booking Confirmation Transaction
```typescript
async function confirmBookingTransaction(holdId: string, bookingId: string) {
return prisma.$transaction(async (tx) => {
// 1. Validate hold
const hold = await tx.seatHold.findUnique({ where: { id: holdId } });
if (!hold || hold.expiresAt < new Date()) {
throw new BadRequestException('Hold expired');
}
// 2. Create journey segments
for (const seatId of hold.seatIds) {
for (let i = 0; i < segments.length; i++) {
await tx.journeySegment.create({
data: {
journeyId: bookingId,
tripId: hold.tripId,
segmentOrder: i + 1,
seatId,
departureStationId: segments[i].fromStationId,
arrivalStationId: segments[i].toStationId
}
});
}
}
// 3. Update seat status to BOOKED
await tx.seat.updateMany({
where: { id: { in: hold.seatIds } },
data: { status: 'BOOKED', heldUntil: null }
});
// 4. Delete hold
await tx.seatHold.delete({ where: { id: holdId } });
return { bookingId, confirmedSeats: hold.seatIds };
});
}
```
### 3. Seat Release Transaction
```typescript
async function releaseSeatsTransaction(tripId: string, currentStationId: string) {
return prisma.$transaction(async (tx) => {
// 1. Find completed journey segments
const completedSegments = await tx.journeySegment.findMany({
where: { tripId, arrivalStationId: currentStationId },
include: { journey: { include: { journeySegments: true } } }
});
const seatsToRelease = [];
// 2. Check if passenger's entire journey is complete
for (const segment of completedSegments) {
const allSegments = segment.journey.journeySegments
.filter(js => js.seatId === segment.seatId);
const maxOrder = Math.max(...allSegments.map(js => js.segmentOrder));
if (segment.segmentOrder === maxOrder) {
seatsToRelease.push(segment.seatId);
}
}
// 3. Release seats
if (seatsToRelease.length > 0) {
await tx.seat.updateMany({
where: { id: { in: seatsToRelease } },
data: { status: 'AVAILABLE' }
});
}
return { releasedSeats: seatsToRelease };
});
}
```
## Real-Time Integration
### Trip Progress Updates
```typescript
// When train reaches a station
await tripProgressService.updateTripProgress(tripId, stationId, progressPercent);
// Automatically triggers seat release
this.eventEmitter.emit('trip.station.arrived', {
tripId,
stationId,
stationName: 'Dire Dawa'
});
```
### Event Listeners
```typescript
@OnEvent('trip.station.arrived')
async handleStationArrival(payload: { tripId: string, stationId: string }) {
await this.enhancedSeatsService.releaseSeats(payload.tripId, payload.stationId);
}
@OnEvent('seats.released')
async handleSeatsReleased(payload: { releasedSeats: string[] }) {
// Notify waiting passengers about newly available seats
this.notificationService.notifyAvailability(payload.releasedSeats);
}
```
## Background Jobs
### Hold Expiration (Every Minute)
```typescript
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({
where: { expiresAt: { lt: new Date() } }
});
// Release expired seats
await this.prisma.seat.updateMany({
where: { id: { in: expiredSeatIds } },
data: { status: 'AVAILABLE', heldUntil: null }
});
}
```
## Usage Examples
### Complete Booking Flow
```typescript
// 1. Check availability
const availability = await segmentSeatsService.getSeatAvailability(
'trip_001', 'st_ADD', 'st_DRE'
);
// 2. Hold seats (10-minute expiry)
const hold = await segmentSeatsService.holdSeats({
tripId: 'trip_001',
seatIds: ['seat_1'],
passengerId: 'passenger_123',
originStationId: 'st_ADD',
destinationStationId: 'st_DRE'
});
// 3. Process payment...
await paymentService.processPayment(bookingId);
// 4. Confirm booking
const booking = await segmentSeatsService.confirmBooking({
holdId: hold.holdId,
bookingId: 'booking_456'
});
// 5. Seats automatically released when train reaches Dire Dawa
```
## Error Handling
- **Seat Conflicts**: `ConflictException` when seats overlap with existing reservations
- **Expired Holds**: `BadRequestException` when trying to confirm expired holds
- **Invalid Segments**: `BadRequestException` for invalid origin/destination combinations
- **Transaction Rollback**: Automatic rollback on any failure within transactions
## Performance Considerations
- **Indexing**: Ensure indexes on `tripId`, `seatId`, `stationId`, `expiresAt`
- **Batch Operations**: Use `updateMany` for bulk seat status updates
- **Event Queuing**: Consider message queues for high-volume seat release events
- **Caching**: Cache frequently accessed route/station data
## Integration Notes
1. **Existing Booking System**: Extends current booking flow with segment awareness
2. **Payment Integration**: Hold expiry provides payment processing window
3. **Real-time Updates**: WebSocket notifications for seat availability changes
4. **Mobile Apps**: Push notifications when seats become available on preferred routes
5. **Analytics**: Track seat utilization patterns by segment for route optimization
## Testing
Run the example booking flow:
```bash
cd apps/edr-passenger-api
npx ts-node src/modules/segments/booking-flow-example.ts
```
This demonstrates the complete segment-based reservation lifecycle with database transactions and real-time seat releases.

View File

@@ -29,7 +29,6 @@
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^5.8.0",
"@sendgrid/mail": "^8.1.0",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
@@ -49,6 +48,7 @@
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@prisma/client": "^6.19.3",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
@@ -56,7 +56,7 @@
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"prisma": "^5.8.0",
"prisma": "^6.19.3",
"supertest": "^7.0.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",

View File

@@ -1,31 +0,0 @@
-- CreateTable
CREATE TABLE "Journey" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Journey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JourneySegment" (
"id" TEXT NOT NULL,
"journeyId" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"segmentOrder" INTEGER NOT NULL,
"seatId" TEXT,
"coachId" TEXT,
"departureStationId" TEXT NOT NULL,
"arrivalStationId" TEXT NOT NULL,
CONSTRAINT "JourneySegment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,63 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[merchantOrderId]` on the table `PaymentIntent` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "PaymentIntent" ADD COLUMN "expiresAt" TIMESTAMP(3),
ADD COLUMN "failureCode" TEXT,
ADD COLUMN "failureMessage" TEXT,
ADD COLUMN "merchantOrderId" TEXT,
ADD COLUMN "paidAt" TIMESTAMP(3),
ADD COLUMN "providerOrderId" TEXT,
ADD COLUMN "providerTxnId" TEXT,
ADD COLUMN "rawInitiation" JSONB;
-- CreateTable
CREATE TABLE "PaymentWebhookEvent" (
"id" TEXT NOT NULL,
"provider" "PaymentMethodType" NOT NULL,
"externalEventId" TEXT NOT NULL,
"merchantOrderId" TEXT,
"providerTxnId" TEXT,
"signatureValid" BOOLEAN NOT NULL,
"status" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
"processingError" TEXT,
CONSTRAINT "PaymentWebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentRefund" (
"id" TEXT NOT NULL,
"paymentIntentId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"reason" TEXT,
"providerRefundId" TEXT,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PaymentRefund_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"("merchantOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_merchantOrderId_key" ON "PaymentIntent"("merchantOrderId");
-- CreateIndex
CREATE INDEX "PaymentIntent_providerOrderId_idx" ON "PaymentIntent"("providerOrderId");
-- CreateIndex
CREATE INDEX "PaymentIntent_providerTxnId_idx" ON "PaymentIntent"("providerTxnId");
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,5 +1,5 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('PASSENGER', 'ADMIN', 'STAFF');
CREATE TYPE "UserRole" AS ENUM ('PASSENGER', 'AGENT', 'SUPERVISOR', 'ADMIN', 'STAFF');
-- CreateEnum
CREATE TYPE "TripStatus" AS ENUM ('SCHEDULED', 'BOARDING', 'EN_ROUTE', 'ARRIVED', 'CANCELLED', 'DELAYED');
@@ -11,16 +11,16 @@ CREATE TYPE "SeatKind" AS ENUM ('STANDARD', 'PREMIUM', 'ACCESSIBLE');
CREATE TYPE "SeatStatus" AS ENUM ('AVAILABLE', 'HELD', 'BOOKED', 'BLOCKED');
-- CreateEnum
CREATE TYPE "ServiceClass" AS ENUM ('ECONOMY', 'BUSINESS', 'FIRST');
CREATE TYPE "ServiceClass" AS ENUM ('ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW');
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET');
-- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
-- CreateEnum
CREATE TYPE "WalletLedgerType" AS ENUM ('CREDIT', 'DEBIT');
@@ -57,6 +57,11 @@ CREATE TABLE "User" (
"fullName" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
"nationality" TEXT,
"passportNumber" TEXT,
"nationalId" TEXT,
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -69,6 +74,9 @@ CREATE TABLE "Session" (
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
@@ -186,6 +194,8 @@ CREATE TABLE "Seat" (
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
"heldUntil" TIMESTAMP(3),
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
"eligibility" TEXT,
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
);
@@ -228,6 +238,7 @@ CREATE TABLE "Booking" (
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"totalMinor" INTEGER NOT NULL,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -269,12 +280,50 @@ CREATE TABLE "PaymentIntent" (
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"clientAction" JSONB,
"merchantOrderId" TEXT,
"providerOrderId" TEXT,
"providerTxnId" TEXT,
"rawInitiation" JSONB,
"paidAt" TIMESTAMP(3),
"failureCode" TEXT,
"failureMessage" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentIntent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentWebhookEvent" (
"id" TEXT NOT NULL,
"provider" "PaymentMethodType" NOT NULL,
"externalEventId" TEXT NOT NULL,
"merchantOrderId" TEXT,
"providerTxnId" TEXT,
"signatureValid" BOOLEAN NOT NULL,
"status" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
"processingError" TEXT,
CONSTRAINT "PaymentWebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentRefund" (
"id" TEXT NOT NULL,
"paymentIntentId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"reason" TEXT,
"providerRefundId" TEXT,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PaymentRefund_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
@@ -282,6 +331,9 @@ CREATE TABLE "Ticket" (
"bookingRef" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
"qrPayload" TEXT NOT NULL,
"barcodePayload" TEXT,
"pdfUrl" TEXT,
"deliveryChannel" TEXT NOT NULL DEFAULT 'EMAIL',
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"validatedAt" TIMESTAMP(3),
"validatorId" TEXT,
@@ -508,6 +560,7 @@ CREATE TABLE "UserPreferences" (
"dataSharing" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT NOT NULL DEFAULT 'en',
"darkMode" BOOLEAN NOT NULL DEFAULT false,
"language" TEXT NOT NULL DEFAULT 'en',
CONSTRAINT "UserPreferences_pkey" PRIMARY KEY ("id")
);
@@ -539,6 +592,279 @@ CREATE TABLE "SavedRoute" (
CONSTRAINT "SavedRoute_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Journey" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Journey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JourneySegment" (
"id" TEXT NOT NULL,
"journeyId" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"segmentOrder" INTEGER NOT NULL,
"seatId" TEXT,
"coachId" TEXT,
"departureStationId" TEXT NOT NULL,
"arrivalStationId" TEXT NOT NULL,
CONSTRAINT "JourneySegment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OtpCode" (
"id" TEXT NOT NULL,
"userId" TEXT,
"email" TEXT,
"phone" TEXT,
"code" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OtpCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Route" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"effectiveFrom" TIMESTAMP(3) NOT NULL,
"effectiveUntil" TIMESTAMP(3),
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Route_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RouteStop" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"distanceKm" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RouteStop_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RouteFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"serviceClass" "ServiceClass" NOT NULL,
"passengerCategory" TEXT NOT NULL DEFAULT 'ADULT',
"baseFareMinor" INTEGER NOT NULL,
"discountPercent" INTEGER,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RouteFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"agentCode" TEXT NOT NULL,
"stationId" TEXT,
"commissionRate" INTEGER NOT NULL DEFAULT 5,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Agent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentBooking" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"paymentMethod" TEXT NOT NULL,
"cashReceived" INTEGER,
"changeGiven" INTEGER,
"paperTicket" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentBooking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentShift" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" TIMESTAMP(3),
"openingBalance" INTEGER NOT NULL DEFAULT 0,
"closingBalance" INTEGER,
"reconciled" BOOLEAN NOT NULL DEFAULT false,
"notes" TEXT,
CONSTRAINT "AgentShift_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentCommission" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"rate" INTEGER NOT NULL,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentCommission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingModification" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"modifiedBy" TEXT NOT NULL,
"modificationType" TEXT NOT NULL,
"oldData" JSONB NOT NULL,
"newData" JSONB NOT NULL,
"fareAdjustment" INTEGER NOT NULL DEFAULT 0,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingModification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingCancellation" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"cancelledBy" TEXT NOT NULL,
"reason" TEXT,
"refundAmount" INTEGER NOT NULL,
"refundMethod" TEXT NOT NULL,
"refundStatus" TEXT NOT NULL,
"processedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingCancellation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GateValidationLog" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"validatorId" TEXT NOT NULL,
"gateId" TEXT,
"status" TEXT NOT NULL,
"reason" TEXT,
"validatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GateValidationLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BaggageAllowance" (
"id" TEXT NOT NULL,
"serviceClass" "ServiceClass" NOT NULL,
"maxWeightKg" INTEGER NOT NULL,
"maxPiecesCount" INTEGER NOT NULL,
"excessFeePerKg" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BaggageAllowance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BaggageBooking" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"weightKg" INTEGER NOT NULL,
"piecesCount" INTEGER NOT NULL,
"excessFeeMinor" INTEGER NOT NULL DEFAULT 0,
"paid" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BaggageBooking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"oldData" JSONB,
"newData" JSONB,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationTemplate" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"subject" TEXT,
"bodyTemplate" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeatBlock" (
"id" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"blockedBy" TEXT NOT NULL,
"approvedBy" TEXT,
"blockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"unblockAt" TIMESTAMP(3),
CONSTRAINT "SeatBlock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OperationalReport" (
"id" TEXT NOT NULL,
"reportType" TEXT NOT NULL,
"dateFrom" TIMESTAMP(3) NOT NULL,
"dateTo" TIMESTAMP(3) NOT NULL,
"data" JSONB NOT NULL,
"generatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OperationalReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -575,6 +901,21 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_merchantOrderId_key" ON "PaymentIntent"("merchantOrderId");
-- CreateIndex
CREATE INDEX "PaymentIntent_providerOrderId_idx" ON "PaymentIntent"("providerOrderId");
-- CreateIndex
CREATE INDEX "PaymentIntent_providerTxnId_idx" ON "PaymentIntent"("providerTxnId");
-- CreateIndex
CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"("merchantOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
@@ -590,6 +931,72 @@ CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
-- CreateIndex
CREATE INDEX "OtpCode_email_phone_idx" ON "OtpCode"("email", "phone");
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- CreateIndex
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Route_code_key" ON "Route"("code");
-- CreateIndex
CREATE INDEX "RouteStop_routeId_stationId_idx" ON "RouteStop"("routeId", "stationId");
-- CreateIndex
CREATE UNIQUE INDEX "RouteStop_routeId_sequence_key" ON "RouteStop"("routeId", "sequence");
-- CreateIndex
CREATE INDEX "RouteFareRule_routeId_serviceClass_idx" ON "RouteFareRule"("routeId", "serviceClass");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_agentCode_key" ON "Agent"("agentCode");
-- CreateIndex
CREATE UNIQUE INDEX "AgentBooking_bookingId_key" ON "AgentBooking"("bookingId");
-- CreateIndex
CREATE INDEX "AgentShift_agentId_openedAt_idx" ON "AgentShift"("agentId", "openedAt");
-- CreateIndex
CREATE INDEX "AgentCommission_agentId_paidAt_idx" ON "AgentCommission"("agentId", "paidAt");
-- CreateIndex
CREATE INDEX "BookingModification_bookingId_idx" ON "BookingModification"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "BookingCancellation_bookingId_key" ON "BookingCancellation"("bookingId");
-- CreateIndex
CREATE INDEX "GateValidationLog_ticketId_idx" ON "GateValidationLog"("ticketId");
-- CreateIndex
CREATE INDEX "GateValidationLog_validatorId_idx" ON "GateValidationLog"("validatorId");
-- CreateIndex
CREATE INDEX "BaggageBooking_bookingId_idx" ON "BaggageBooking"("bookingId");
-- CreateIndex
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationTemplate_code_key" ON "NotificationTemplate"("code");
-- CreateIndex
CREATE INDEX "SeatBlock_seatId_idx" ON "SeatBlock"("seatId");
-- CreateIndex
CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"("reportType", "dateFrom");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -638,6 +1045,9 @@ ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY (
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -688,3 +1098,48 @@ ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId")
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteStop" ADD CONSTRAINT "RouteStop_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- Language field already exists in UserPreferences table
-- No migration needed

View File

@@ -0,0 +1,74 @@
-- Migration: Add Fraud Detection Tables and User.blockedUntil field
-- Date: 2026-05-21
-- Description: Adds FraudRule and FraudAlert tables, and blockedUntil field to User table
-- Add blockedUntil field to User table if not exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'User' AND column_name = 'blockedUntil'
) THEN
ALTER TABLE "User" ADD COLUMN "blockedUntil" TIMESTAMP(3);
END IF;
END $$;
-- CreateTable FraudRule
CREATE TABLE IF NOT EXISTS "FraudRule" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"threshold" DOUBLE PRECISION NOT NULL,
"config" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FraudRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable FraudAlert
CREATE TABLE IF NOT EXISTS "FraudAlert" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"triggeredRules" TEXT[],
"context" JSONB NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'MEDIUM',
"acknowledged" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FraudAlert_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "FraudRule_type_key" ON "FraudRule"("type");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FraudAlert_userId_fkey'
) THEN
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Insert default fraud rules
INSERT INTO "FraudRule" ("id", "type", "enabled", "threshold", "config", "createdAt", "updatedAt")
VALUES
(gen_random_uuid(), 'VELOCITY', true, 5, '{"timeWindowMinutes": 30, "blockDurationMinutes": 30}'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'HIGH_VALUE', true, 10000, '{"blockDurationMinutes": 60}'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'FAILED_PAYMENTS', true, 3, '{"timeWindowMinutes": 60, "blockDurationMinutes": 30}'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (type) DO NOTHING;
-- Add comments
COMMENT ON TABLE "FraudRule" IS 'Fraud detection rules for monitoring suspicious activities';
COMMENT ON TABLE "FraudAlert" IS 'Fraud alerts triggered by rule violations';
COMMENT ON COLUMN "User"."blockedUntil" IS 'Timestamp until which the user is blocked due to fraud or security reasons';

View File

@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=reset-admin.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"reset-admin.d.ts","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":""}

View File

@@ -1,49 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcrypt"));
const prisma = new client_1.PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash('admin123', 10);
const user = await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash, role: 'ADMIN' },
create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash, role: 'ADMIN' },
});
console.log('✅ Admin ready:', user.email);
}
main().catch(console.error).finally(() => prisma.$disconnect());
//# sourceMappingURL=reset-admin.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"reset-admin.js","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,+CAAiC;AAEjC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACvD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACpC,KAAK,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE;QAC1C,MAAM,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE;QACvC,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,wBAAwB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE;KACxH,CAAC,CAAC;IACH,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5C,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC"}

View File

@@ -1,16 +0,0 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash('admin123', 10);
const user = await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash, role: 'ADMIN' },
create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash, role: 'ADMIN' },
});
console.log('✅ Admin ready:', user.email);
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -9,6 +9,8 @@ datasource db {
enum UserRole {
PASSENGER
AGENT
SUPERVISOR
ADMIN
STAFF
}
@@ -36,9 +38,12 @@ enum SeatStatus {
}
enum ServiceClass {
ECONOMY
BUSINESS
FIRST
ECONOMY_REGULAR
ECONOMY_BED_LOWER
ECONOMY_BED_MIDDLE
ECONOMY_BED_UPPER
VIP_BED_LOWER
VIP_BED_UPPER
}
enum BookingStatus {
@@ -48,6 +53,7 @@ enum BookingStatus {
CANCELLED
COMPLETED
NO_SHOW
REFUNDED
}
enum PaymentMethodType {
@@ -64,6 +70,7 @@ enum PaymentIntentStatus {
SUCCEEDED
FAILED
CANCELLED
REFUNDED
}
enum WalletLedgerType {
@@ -134,12 +141,21 @@ model User {
fullName String
passwordHash String
role UserRole @default(PASSENGER)
nationality String?
passportNumber String?
nationalId String?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
blockedUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger?
agent Agent?
sessions Session[]
devices Device[]
preferences UserPreferences?
auditLogs AuditLog[]
fraudAlerts FraudAlert[]
}
model Session {
@@ -147,6 +163,9 @@ model Session {
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@@ -267,8 +286,11 @@ model Seat {
kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE)
heldUntil DateTime?
premiumFeeMinor Int @default(0)
eligibility String?
coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[]
blocks SeatBlock[]
@@unique([coachId, row, col])
}
@@ -303,6 +325,7 @@ model Booking {
status BookingStatus @default(DRAFT)
currency String @default("ETB")
totalMinor Int
bookingType String @default("ONE_WAY")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
@@ -311,6 +334,10 @@ model Booking {
paymentIntent PaymentIntent?
ticket Ticket?
foodOrders FoodOrder[]
agentBooking AgentBooking?
modifications BookingModification[]
cancellation BookingCancellation?
baggage BaggageBooking[]
}
model BookingSeat {
@@ -392,10 +419,14 @@ model Ticket {
bookingRef String
status String @default("CONFIRMED")
qrPayload String
barcodePayload String?
pdfUrl String?
deliveryChannel String @default("EMAIL")
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
}
model LoyaltyAccount {
@@ -585,6 +616,7 @@ model UserPreferences {
dataSharing Boolean @default(false)
locale String @default("en")
darkMode Boolean @default(false)
language String @default("en")
user User @relation(fields: [userId], references: [id])
}
@@ -633,3 +665,253 @@ model JourneySegment {
journey Journey @relation(fields: [journeyId], references: [id])
trip Trip @relation(fields: [tripId], references: [id])
}
model OtpCode {
id String @id @default(uuid())
userId String?
email String?
phone String?
code String
purpose String
expiresAt DateTime
verified Boolean @default(false)
createdAt DateTime @default(now())
@@index([email, phone])
}
model PasswordResetToken {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId])
}
model Route {
id String @id @default(uuid())
code String @unique
name String
description String?
effectiveFrom DateTime
effectiveUntil DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
stops RouteStop[]
fareRules RouteFareRule[]
}
model RouteStop {
id String @id @default(uuid())
routeId String
stationId String
sequence Int
distanceKm Int?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
@@unique([routeId, sequence])
@@index([routeId, stationId])
}
model RouteFareRule {
id String @id @default(uuid())
routeId String
serviceClass ServiceClass
passengerCategory String @default("ADULT")
baseFareMinor Int
discountPercent Int?
currency String @default("ETB")
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
@@index([routeId, serviceClass])
}
model Agent {
id String @id @default(uuid())
userId String @unique
agentCode String @unique
stationId String?
commissionRate Int @default(5)
active Boolean @default(true)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
bookings AgentBooking[]
shifts AgentShift[]
commissions AgentCommission[]
}
model AgentBooking {
id String @id @default(uuid())
agentId String
bookingId String @unique
paymentMethod String
cashReceived Int?
changeGiven Int?
paperTicket Boolean @default(false)
createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id])
booking Booking @relation(fields: [bookingId], references: [id])
}
model AgentShift {
id String @id @default(uuid())
agentId String
openedAt DateTime @default(now())
closedAt DateTime?
openingBalance Int @default(0)
closingBalance Int?
reconciled Boolean @default(false)
notes String?
agent Agent @relation(fields: [agentId], references: [id])
@@index([agentId, openedAt])
}
model AgentCommission {
id String @id @default(uuid())
agentId String
bookingId String
amountMinor Int
rate Int
paidAt DateTime?
createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id])
@@index([agentId, paidAt])
}
model BookingModification {
id String @id @default(uuid())
bookingId String
modifiedBy String
modificationType String
oldData Json
newData Json
fareAdjustment Int @default(0)
reason String?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
}
model BookingCancellation {
id String @id @default(uuid())
bookingId String @unique
cancelledBy String
reason String?
refundAmount Int
refundMethod String
refundStatus String
processedAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
}
model GateValidationLog {
id String @id @default(uuid())
ticketId String
validatorId String
gateId String?
status String
reason String?
validatedAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id])
@@index([ticketId])
@@index([validatorId])
}
model BaggageAllowance {
id String @id @default(uuid())
serviceClass ServiceClass
maxWeightKg Int
maxPiecesCount Int
excessFeePerKg Int
currency String @default("ETB")
createdAt DateTime @default(now())
}
model BaggageBooking {
id String @id @default(uuid())
bookingId String
weightKg Int
piecesCount Int
excessFeeMinor Int @default(0)
paid Boolean @default(false)
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
}
model AuditLog {
id String @id @default(uuid())
userId String?
action String
entityType String
entityId String?
oldData Json?
newData Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
@@index([userId, createdAt])
@@index([entityType, entityId])
}
model NotificationTemplate {
id String @id @default(uuid())
code String @unique
channel String
subject String?
bodyTemplate String
active Boolean @default(true)
createdAt DateTime @default(now())
}
model SeatBlock {
id String @id @default(uuid())
seatId String
reason String
blockedBy String
approvedBy String?
blockedAt DateTime @default(now())
unblockAt DateTime?
seat Seat @relation(fields: [seatId], references: [id])
@@index([seatId])
}
model OperationalReport {
id String @id @default(uuid())
reportType String
dateFrom DateTime
dateTo DateTime
data Json
generatedBy String?
createdAt DateTime @default(now())
@@index([reportType, dateFrom])
}
model FraudRule {
id String @id @default(uuid())
type String @unique
enabled Boolean @default(true)
threshold Float
config Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FraudAlert {
id String @id @default(uuid())
userId String
eventType String
triggeredRules String[]
context Json
severity String @default("MEDIUM")
acknowledged Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([acknowledged])
}

View File

@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=seed.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":""}

View File

@@ -1,73 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcrypt"));
const prisma = new client_1.PrismaClient();
async function main() {
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } });
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
const service = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
const trip = await prisma.trip.create({
data: { serviceId: service.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-05-11T08:30:00Z'), arrivalAt: new Date('2026-05-11T20:00:00Z'), durationMinutes: 690, stopsCount: 1 },
});
for (const [label, cls] of [['A', 'ECONOMY'], ['B', 'BUSINESS']]) {
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass: cls } });
const seats = [];
for (let row = 1; row <= 10; row++) {
for (const col of ['A', 'B', 'C', 'D'])
seats.push({ coachId: coach.id, row, col, label: `${row}${col}` });
}
await prisma.seat.createMany({ data: seats });
}
await prisma.fareRule.create({ data: { tripId: trip.id, serviceClass: 'ECONOMY', baseFareMinor: 45000, validFrom: new Date('2026-01-01') } });
const hash = await bcrypt.hash('password123', 10);
const user = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash } });
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
}
await prisma.userPreferences.upsert({ where: { userId: user.id }, update: {}, create: { userId: user.id } });
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' } });
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31') } });
const faqCat = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'description_outlined' } });
await prisma.faqArticle.create({ data: { categoryId: faqCat.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, pick stations and date, select seats, and proceed to payment.', rank: 1 } });
console.log('✅ Seed complete');
}
main().catch(console.error).finally(() => prisma.$disconnect());
//# sourceMappingURL=seed.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"seed.js","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,+CAAiC;AAEjC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAChL,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAC/K,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,iBAAiB,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAE3M,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,EAAE,CAAC,CAAC;IAE3I,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACpC,IAAI,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,EAAE,eAAe,EAAE,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,IAAI,CAAC,sBAAsB,CAAC,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,sBAAsB,CAAC,EAAE,eAAe,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE;KAC/N,CAAC,CAAC;IAEH,KAAK,MAAM,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,CAAC,CAAU,EAAE,CAAC;QAC1E,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QACjG,MAAM,KAAK,GAAG,EAAE,CAAC;QACjB,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC;YACnC,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,EAAE,EAAE,CAAC,CAAC;QAC7G,CAAC;QACD,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAE9I,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,gBAAgB,EAAE,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;IAC3M,IAAI,SAAS,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAClF,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,SAAS,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACzE,MAAM,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;QACjH,MAAM,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;IACnG,CAAC;IACD,MAAM,MAAM,CAAC,eAAe,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAE7G,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,wBAAwB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAEjT,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,IAAI,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAEtL,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,mBAAmB,EAAE,OAAO,EAAE,sBAAsB,EAAE,EAAE,CAAC,CAAC;IAC1H,MAAM,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,+BAA+B,EAAE,cAAc,EAAE,4EAA4E,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAEtN,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;AACjC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC"}

View File

@@ -1,48 +1,237 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient, ServiceClass, UserRole, LoyaltyTier, SeatKind } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
console.log('🌱 Starting comprehensive seed...');
// All 18 Stations (Ethiopian-Djibouti Railway)
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa Central', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
const sebeta = await prisma.station.upsert({ where: { code: 'SBT' }, update: {}, create: { code: 'SBT', name: 'Sebeta', city: 'Sebeta', lat: 8.9167, lng: 38.6167 } });
const labu = await prisma.station.upsert({ where: { code: 'LBU' }, update: {}, create: { code: 'LBU', name: 'Labu', city: 'Labu', lat: 8.8500, lng: 38.8500 } });
const indode = await prisma.station.upsert({ where: { code: 'IND' }, update: {}, create: { code: 'IND', name: 'Indode', city: 'Indode', lat: 8.7833, lng: 39.0167 } });
const bishoftu = await prisma.station.upsert({ where: { code: 'BSH' }, update: {}, create: { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', lat: 8.7500, lng: 38.9833 } });
const mojo = await prisma.station.upsert({ where: { code: 'MJO' }, update: {}, create: { code: 'MJO', name: 'Mojo', city: 'Mojo', lat: 8.5833, lng: 39.1167 } });
const adama = await prisma.station.upsert({ where: { code: 'ADM' }, update: {}, create: { code: 'ADM', name: 'Adama', city: 'Adama', lat: 8.5400, lng: 39.2675 } });
const feto = await prisma.station.upsert({ where: { code: 'FTO' }, update: {}, create: { code: 'FTO', name: 'Feto', city: 'Feto', lat: 8.7167, lng: 39.5833 } });
const metahara = await prisma.station.upsert({ where: { code: 'MTH' }, update: {}, create: { code: 'MTH', name: 'Metahara', city: 'Metahara', lat: 8.9000, lng: 39.9167 } });
const awash = await prisma.station.upsert({ where: { code: 'AWS' }, update: {}, create: { code: 'AWS', name: 'Awash', city: 'Awash', lat: 8.9833, lng: 40.1667 } });
const mieso = await prisma.station.upsert({ where: { code: 'MSO' }, update: {}, create: { code: 'MSO', name: 'Mieso', city: 'Mieso', lat: 9.2333, lng: 40.7500 } });
const bike = await prisma.station.upsert({ where: { code: 'BKE' }, update: {}, create: { code: 'BKE', name: 'Bike', city: 'Bike', lat: 9.4167, lng: 41.2500 } });
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } });
const arawa = await prisma.station.upsert({ where: { code: 'ARW' }, update: {}, create: { code: 'ARW', name: 'Arawa', city: 'Arawa', lat: 10.0833, lng: 42.2500 } });
const adigala = await prisma.station.upsert({ where: { code: 'ADG' }, update: {}, create: { code: 'ADG', name: 'Adigala', city: 'Adigala', lat: 10.5000, lng: 42.5833 } });
const aysha = await prisma.station.upsert({ where: { code: 'AYS' }, update: {}, create: { code: 'AYS', name: 'Aysha', city: 'Aysha', lat: 11.5500, lng: 42.7167 } });
const dawanle = await prisma.station.upsert({ where: { code: 'DWN' }, update: {}, create: { code: 'DWN', name: 'Dawanle', city: 'Dawanle', timezone: 'Africa/Djibouti', lat: 11.3833, lng: 42.8500 } });
const alisabieh = await prisma.station.upsert({ where: { code: 'ALI' }, update: {}, create: { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', timezone: 'Africa/Djibouti', lat: 11.1667, lng: 42.7167 } });
const holhol = await prisma.station.upsert({ where: { code: 'HLH' }, update: {}, create: { code: 'HLH', name: 'Holhol', city: 'Holhol', timezone: 'Africa/Djibouti', lat: 11.4167, lng: 43.0000 } });
const nagad = await prisma.station.upsert({ where: { code: 'NGD' }, update: {}, create: { code: 'NGD', name: 'Nagad', city: 'Nagad', timezone: 'Africa/Djibouti', lat: 11.5167, lng: 43.1000 } });
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
const service = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
// Routes
const route1 = await prisma.route.upsert({
where: { code: 'R001' },
update: {},
create: { code: 'R001', name: 'Addis Ababa - Djibouti Express', effectiveFrom: new Date('2026-01-01'), active: true }
});
// Delete existing route stops and recreate
await prisma.routeStop.deleteMany({ where: { routeId: route1.id } });
await prisma.routeStop.createMany({ data: [
{ routeId: route1.id, stationId: addis.id, sequence: 1, distanceKm: 0 },
{ routeId: route1.id, stationId: sebeta.id, sequence: 2, distanceKm: 23 },
{ routeId: route1.id, stationId: labu.id, sequence: 3, distanceKm: 45 },
{ routeId: route1.id, stationId: indode.id, sequence: 4, distanceKm: 62 },
{ routeId: route1.id, stationId: bishoftu.id, sequence: 5, distanceKm: 47 },
{ routeId: route1.id, stationId: mojo.id, sequence: 6, distanceKm: 73 },
{ routeId: route1.id, stationId: adama.id, sequence: 7, distanceKm: 99 },
{ routeId: route1.id, stationId: feto.id, sequence: 8, distanceKm: 145 },
{ routeId: route1.id, stationId: metahara.id, sequence: 9, distanceKm: 198 },
{ routeId: route1.id, stationId: awash.id, sequence: 10, distanceKm: 225 },
{ routeId: route1.id, stationId: mieso.id, sequence: 11, distanceKm: 305 },
{ routeId: route1.id, stationId: bike.id, sequence: 12, distanceKm: 375 },
{ routeId: route1.id, stationId: direDawa.id, sequence: 13, distanceKm: 453 },
{ routeId: route1.id, stationId: arawa.id, sequence: 14, distanceKm: 520 },
{ routeId: route1.id, stationId: adigala.id, sequence: 15, distanceKm: 580 },
{ routeId: route1.id, stationId: aysha.id, sequence: 16, distanceKm: 656 },
{ routeId: route1.id, stationId: dawanle.id, sequence: 17, distanceKm: 680 },
{ routeId: route1.id, stationId: alisabieh.id, sequence: 18, distanceKm: 700 },
{ routeId: route1.id, stationId: holhol.id, sequence: 19, distanceKm: 730 },
{ routeId: route1.id, stationId: nagad.id, sequence: 20, distanceKm: 750 },
{ routeId: route1.id, stationId: djibouti.id, sequence: 21, distanceKm: 756 },
]});
const trip = await prisma.trip.create({
data: { serviceId: service.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-05-11T08:30:00Z'), arrivalAt: new Date('2026-05-11T20:00:00Z'), durationMinutes: 690, stopsCount: 1 },
// Route Fare Rules
await prisma.routeFareRule.deleteMany({ where: { routeId: route1.id } });
await prisma.routeFareRule.createMany({ data: [
{ routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'ECONOMY_BED_LOWER', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'VIP_BED_LOWER', baseFareMinor: 95000, validFrom: new Date('2026-01-01') },
]});
// Train Services
const service301 = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
const service302 = await prisma.trainService.upsert({ where: { number: '302' }, update: {}, create: { number: '302', name: 'Express 302' } });
// Trips (Multiple schedules) - Delete existing trips for clean seed
await prisma.trip.deleteMany({ where: { serviceId: { in: [service301.id, service302.id] } } });
const trip1 = await prisma.trip.create({
data: { serviceId: service301.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: 19 },
});
const trip2 = await prisma.trip.create({
data: { serviceId: service302.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: 19 },
});
const trip3 = await prisma.trip.create({
data: { serviceId: service301.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: 19 },
});
const trip4 = await prisma.trip.create({
data: { serviceId: service302.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: 19 },
});
for (const [label, cls] of [['A', 'ECONOMY'], ['B', 'BUSINESS']] as const) {
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass: cls } });
const seats = [];
for (let row = 1; row <= 10; row++) {
for (const col of ['A', 'B', 'C', 'D']) seats.push({ coachId: coach.id, row, col, label: `${row}${col}` });
// Trip Stop Times (Major stops only for brevity)
await prisma.tripStopTime.createMany({ data: [
{ tripId: trip1.id, stationId: addis.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T08:00:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: adama.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:30:00Z'), plannedDepartureAt: new Date('2026-06-15T09:45:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: awash.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T11:30:00Z'), plannedDepartureAt: new Date('2026-06-15T11:45:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: direDawa.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: aysha.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T18:00:00Z'), plannedDepartureAt: new Date('2026-06-15T18:10:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: djibouti.id, sequence: 21, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), status: 'UPCOMING' },
]});
// Coaches & Seats
for (const trip of [trip1, trip2, trip3, trip4]) {
const coaches = [
{ label: 'A', serviceClass: 'ECONOMY_REGULAR' as ServiceClass, seatCount: 60 },
{ label: 'B', serviceClass: 'ECONOMY_BED_LOWER' as ServiceClass, seatCount: 40 },
{ label: 'C', serviceClass: 'VIP_BED_LOWER' as ServiceClass, seatCount: 20 },
];
for (const { label, serviceClass, seatCount } of coaches) {
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass } });
const seats = [];
const rows = Math.ceil(seatCount / 4);
for (let row = 1; row <= rows; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
if (seats.length >= seatCount) break;
seats.push({ coachId: coach.id, row, col, label: `${row}${col}`, kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind });
}
}
await prisma.seat.createMany({ data: seats });
}
await prisma.seat.createMany({ data: seats });
}
await prisma.fareRule.create({ data: { tripId: trip.id, serviceClass: 'ECONOMY', baseFareMinor: 45000, validFrom: new Date('2026-01-01') } });
// Fare Rules (All trips)
for (const trip of [trip1, trip2, trip3, trip4]) {
await prisma.fareRule.createMany({ data: [
{ tripId: trip.id, serviceClass: 'ECONOMY_REGULAR', baseFareMinor: 45000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: trip.id, serviceClass: 'ECONOMY_BED_LOWER', baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: trip.id, serviceClass: 'VIP_BED_LOWER', baseFareMinor: 95000, validFrom: new Date('2026-01-01'), refundable: true },
]});
}
// Users
const hash = await bcrypt.hash('password123', 10);
const user = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash } });
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
const adminHash = await bcrypt.hash('admin123', 10);
const agentHash = await bcrypt.hash('agent123', 10);
const adminUser = 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: user.id } });
passenger = await prisma.passenger.create({ data: { userId: passengerUser.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
}
await prisma.userPreferences.upsert({ where: { userId: user.id }, update: {}, create: { userId: user.id } });
await prisma.userPreferences.upsert({ where: { userId: passengerUser.id }, update: {}, create: { userId: passengerUser.id, language: 'en' } });
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' } });
const agentUser = await prisma.user.upsert({ where: { email: 'agent@edr-platform.com' }, update: { passwordHash: agentHash, role: 'AGENT' }, create: { fullName: 'Agent Abebe', email: 'agent@edr-platform.com', phone: '+251911111111', passwordHash: agentHash, role: 'AGENT' } });
await prisma.agent.upsert({ where: { userId: agentUser.id }, update: {}, create: { userId: agentUser.id, agentCode: 'AG001', stationId: addis.id, commissionRate: 5, active: true } });
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31') } });
// Baggage Allowance - Delete and recreate
await prisma.baggageAllowance.deleteMany({});
await prisma.baggageAllowance.createMany({ data: [
{ serviceClass: 'ECONOMY_REGULAR', maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
{ serviceClass: 'ECONOMY_BED_LOWER', maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
{ serviceClass: 'VIP_BED_LOWER', maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
]});
const faqCat = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'description_outlined' } });
await prisma.faqArticle.create({ data: { categoryId: faqCat.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, pick stations and date, select seats, and proceed to payment.', rank: 1 } });
// 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: 'TRIP_REMINDER' }, update: {}, create: { code: 'TRIP_REMINDER', channel: 'PUSH', subject: 'Trip Reminder', bodyTemplate: 'Your trip departs in {{hours}} hours from {{station}}.', active: true } });
console.log('✅ Seed complete');
// 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 } });
await prisma.promotion.upsert({ where: { code: 'NEWUSER20' }, update: {}, create: { title: 'New User Bonus', code: 'NEWUSER20', percentOff: 20, validUntil: new Date('2026-12-31'), active: true } });
// FAQ - Delete and recreate for clean seed
await prisma.faqArticle.deleteMany({});
await prisma.faqCategory.deleteMany({});
const faqBooking = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'confirmation_number' } });
const faqPayment = await prisma.faqCategory.create({ data: { title: 'Payment & Refunds', iconKey: 'payment' } });
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 },
{ categoryId: faqBooking.id, question: 'Can I modify my booking?', answerMarkdown: 'Yes, you can modify your booking up to 24 hours before departure through the Bookings section.', rank: 2 },
{ categoryId: faqPayment.id, question: 'What payment methods are accepted?', answerMarkdown: 'We accept Telebirr, CBE Birr, eBirr, Card, and Wallet payments.', rank: 1 },
{ categoryId: faqPayment.id, question: 'How do refunds work?', answerMarkdown: 'Refunds are processed within 5-7 business days to your original payment method.', rank: 2 },
]});
// Menu Categories & Items - Delete and recreate
await prisma.menuItem.deleteMany({});
await prisma.menuCategory.deleteMany({});
const menuBeverages = await prisma.menuCategory.create({ data: { name: 'Beverages' } });
const menuSnacks = await prisma.menuCategory.create({ data: { name: 'Snacks' } });
await prisma.menuItem.createMany({ data: [
{ tripId: trip1.id, categoryId: menuBeverages.id, name: 'Coffee', priceMinor: 2500, available: true },
{ tripId: trip1.id, categoryId: menuBeverages.id, name: 'Tea', priceMinor: 2000, available: true },
{ tripId: trip1.id, categoryId: menuSnacks.id, name: 'Sandwich', priceMinor: 5000, available: true },
]});
// Station Crowd Signals - Delete and recreate
await prisma.stationCrowdSignal.deleteMany({});
await prisma.stationCrowdSignal.createMany({ data: [
{ stationId: addis.id, level: 'MODERATE', label: 'Moderate', statusLabel: 'Normal operations' },
{ stationId: adama.id, level: 'LOW', label: 'Low', statusLabel: 'Quiet' },
{ stationId: direDawa.id, level: 'LOW', label: 'Low', statusLabel: 'Quiet' },
{ 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.fraudRule.upsert({ where: { type: 'HIGH_VALUE' }, update: {}, create: { type: 'HIGH_VALUE', enabled: true, threshold: 500000, config: { action: 'REVIEW' } } });
await prisma.fraudRule.upsert({ where: { type: 'FAILED_PAYMENTS' }, update: {}, create: { type: 'FAILED_PAYMENTS', enabled: true, threshold: 5, config: { windowMinutes: 1440, action: 'BLOCK' } } });
// Loyalty Rewards (linked to loyalty account) - Delete and recreate
if (passenger) {
const loyaltyAccount = await prisma.loyaltyAccount.findUnique({ where: { passengerId: passenger.id } });
if (loyaltyAccount) {
await prisma.loyaltyReward.deleteMany({ where: { accountId: loyaltyAccount.id } });
await prisma.loyaltyReward.createMany({ data: [
{ accountId: loyaltyAccount.id, title: '10% Discount Voucher', costPoints: 1000, available: true, description: 'Get 10% off your next booking' },
{ accountId: loyaltyAccount.id, title: 'Free Upgrade to VIP', costPoints: 2500, available: true, description: 'Upgrade to VIP class on any trip' },
{ accountId: loyaltyAccount.id, title: '500 ETB Wallet Credit', costPoints: 5000, available: true, description: 'Add 500 ETB to your wallet' },
]});
}
}
console.log('✅ Comprehensive seed complete');
console.log('\n📋 Seed Summary:');
console.log(' - 18 Stations (Complete Ethiopian-Djibouti Railway)');
console.log(' - 1 Route with 21 stops');
console.log(' - 2 Train services, 4 trips');
console.log(' - 3 Coaches per trip (Economy, Bed, VIP)');
console.log(' - 3 Users: Admin, Passenger (Silver tier + wallet), Agent');
console.log(' - 3 Fraud detection rules');
console.log(' - 3 Loyalty rewards');
console.log(' - Baggage rules, Notification templates, Promotions, FAQ');
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🚉 Stations: Addis Ababa → Sebeta → Labu → Indode → Bishoftu → Mojo → Adama → Feto → Metahara → Awash → Mieso → Bike → Dire Dawa → Arawa → Adigala → Aysha → Dawanle → Alisabieh → Holhol → Nagad → Djibouti');
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -1,11 +1,17 @@
import { Module } from '@nestjs/common';
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
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 { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
@@ -24,13 +30,21 @@ import { LiveModule } from './modules/live/live.module';
import { SupportModule } from './modules/support/support.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { SegmentsModule } from './modules/segments/segments.module';
import { AgentsModule } from './modules/agents/agents.module';
import { ReportsModule } from './modules/reports/reports.module';
import { FraudModule } from './modules/fraud/fraud.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig, telebirrConfig] }),
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
I18nModule,
IamModule,
AuthModule,
StationsModule,
FleetModule,
@@ -49,6 +63,13 @@ import { SegmentsModule } from './modules/segments/segments.module';
SupportModule,
DashboardModule,
SegmentsModule,
AgentsModule,
ReportsModule,
FraudModule,
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LocaleMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}

View File

@@ -0,0 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing';
import { I18nService } from './i18n.service';
describe('I18nService', () => {
let service: I18nService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [I18nService],
}).compile();
service = module.get<I18nService>(I18nService);
});
it('should translate English keys', () => {
expect(service.translate('common.welcome', 'en')).toBe('Welcome');
expect(service.translate('booking.created', 'en')).toBe('Booking created successfully');
});
it('should translate Amharic keys', () => {
expect(service.translate('common.welcome', 'am')).toBe('እንኳን ደህና መጡ');
});
it('should translate French keys', () => {
expect(service.translate('common.welcome', 'fr')).toBe('Bienvenue');
});
it('should translate Oromo keys', () => {
expect(service.translate('common.welcome', 'om')).toBe('Baga nagaan dhuftan');
});
it('should fallback to English for unsupported locale', () => {
expect(service.translate('common.welcome', 'de')).toBe('Welcome');
});
it('should return key if translation not found', () => {
expect(service.translate('nonexistent.key', 'en')).toBe('nonexistent.key');
});
it('should interpolate parameters', () => {
const result = service.translate('common.welcome', 'en', { name: 'John' });
expect(result).toBeDefined();
});
it('should return supported locales', () => {
const locales = service.getSupportedLocales();
expect(locales).toEqual(['en', 'am', 'fr', 'om']);
});
});

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
type TranslationMap = Record<string, any>;
@Injectable()
export class I18nService {
private translations: Map<string, TranslationMap> = new Map();
private readonly supportedLocales = ['en', 'am', 'fr', 'om'];
private readonly defaultLocale = 'en';
constructor() {
this.loadTranslations();
}
private loadTranslations() {
for (const locale of this.supportedLocales) {
const filePath = path.join(__dirname, 'translations', `${locale}.json`);
try {
const content = fs.readFileSync(filePath, 'utf-8');
this.translations.set(locale, JSON.parse(content));
} catch (err) {
console.warn(`Failed to load translation file for locale: ${locale}`);
}
}
}
translate(key: string, locale: string = this.defaultLocale, params?: Record<string, string>): string {
const normalizedLocale = this.normalizeLocale(locale);
const translations = this.translations.get(normalizedLocale) || this.translations.get(this.defaultLocale);
if (!translations) return key;
const keys = key.split('.');
let value: any = translations;
for (const k of keys) {
value = value?.[k];
if (value === undefined) return key;
}
if (typeof value !== 'string') return key;
if (params) {
return Object.entries(params).reduce(
(text, [param, val]) => text.replace(new RegExp(`{{${param}}}`, 'g'), val),
value
);
}
return value;
}
private normalizeLocale(locale: string): string {
const normalized = locale.toLowerCase().split('-')[0];
return this.supportedLocales.includes(normalized) ? normalized : this.defaultLocale;
}
getSupportedLocales(): string[] {
return this.supportedLocales;
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { LOCALE_KEY } from './locale.middleware';
export const Locale = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request[LOCALE_KEY] || 'en';
},
);

View File

@@ -0,0 +1,16 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
export const LOCALE_KEY = 'locale';
@Injectable()
export class LocaleMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const locale =
req.query.lang as string ||
req.headers['accept-language']?.split(',')[0]?.split('-')[0] ||
'en';
req[LOCALE_KEY] = locale;
next();
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "እንኳን ደህና መጡ",
"error": "ስህተት ተከስቷል",
"success": "ተሳክቷል"
},
"booking": {
"created": "ቦታ ማስያዝ በተሳካ ሁኔታ ተፈጥሯል",
"notFound": "ቦታ ማስያዝ አልተገኘም",
"cancelled": "ቦታ ማስያዝ ተሰርዟል",
"confirmed": "ቦታ ማስያዝ ተረጋግጧል"
},
"payment": {
"succeeded": "ክፍያ ተሳክቷል",
"failed": "ክፍያ አልተሳካም",
"pending": "ክፍያ በመጠባበቅ ላይ"
},
"ticket": {
"issued": "ትኬት ተሰጥቷል",
"validated": "ትኬት ተረጋግጧል",
"alreadyValidated": "ትኬት ቀድሞውኑ ተረጋግጧል"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Welcome",
"error": "An error occurred",
"success": "Success"
},
"booking": {
"created": "Booking created successfully",
"notFound": "Booking not found",
"cancelled": "Booking cancelled",
"confirmed": "Booking confirmed"
},
"payment": {
"succeeded": "Payment successful",
"failed": "Payment failed",
"pending": "Payment pending"
},
"ticket": {
"issued": "Ticket issued",
"validated": "Ticket validated",
"alreadyValidated": "Ticket already validated"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Bienvenue",
"error": "Une erreur s'est produite",
"success": "Succès"
},
"booking": {
"created": "Réservation créée avec succès",
"notFound": "Réservation introuvable",
"cancelled": "Réservation annulée",
"confirmed": "Réservation confirmée"
},
"payment": {
"succeeded": "Paiement réussi",
"failed": "Échec du paiement",
"pending": "Paiement en attente"
},
"ticket": {
"issued": "Billet émis",
"validated": "Billet validé",
"alreadyValidated": "Billet déjà validé"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Baga nagaan dhuftan",
"error": "Dogongora uumame",
"success": "Milkaa'ina"
},
"booking": {
"created": "Bakka qabachuu milkaa'inaan uumame",
"notFound": "Bakka qabachuu hin argamne",
"cancelled": "Bakka qabachuu haqame",
"confirmed": "Bakka qabachuu mirkaneeffame"
},
"payment": {
"succeeded": "Kaffaltiin milkaa'e",
"failed": "Kaffaltiin hin milkoofne",
"pending": "Kaffaltiin eegaa jira"
},
"ticket": {
"issued": "Tiikeetiin kenname",
"validated": "Tiikeetiin mirkaneeffame",
"alreadyValidated": "Tiikeetiin duraan mirkaneeffame"
}
}

View File

@@ -0,0 +1,264 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
import { of, throwError } from 'rxjs';
describe('IamGuard', () => {
let guard: IamGuard;
let httpService: HttpService;
let configService: ConfigService;
let reflector: Reflector;
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
IAM_API_URL: 'https://iam.test.com/api',
IAM_ENABLED: 'true',
IAM_API_KEY: 'test-api-key',
};
return config[key];
}),
};
const mockHttpService = {
post: jest.fn(),
};
const mockReflector = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IamGuard,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: Reflector, useValue: mockReflector },
],
}).compile();
guard = module.get<IamGuard>(IamGuard);
httpService = module.get<HttpService>(HttpService);
configService = module.get<ConfigService>(ConfigService);
reflector = module.get<Reflector>(Reflector);
jest.clearAllMocks();
});
const createMockContext = (token?: string, roles?: string[]): ExecutionContext => {
const request = {
headers: token ? { authorization: `Bearer ${token}` } : {},
user: undefined,
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
};
describe('canActivate', () => {
it('should allow access when IAM is disabled', async () => {
mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED
const context = createMockContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw UnauthorizedException when no token provided', async () => {
const context = createMockContext();
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should validate token and allow access', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockHttpService.post).toHaveBeenCalledWith(
'https://iam.test.com/api/v1/auth/validate',
{ token: 'valid-token' },
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key',
}),
}),
);
});
it('should throw UnauthorizedException for invalid token', async () => {
const mockValidationResponse = {
data: {
valid: false,
error: 'Token expired',
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
const context = createMockContext('invalid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should check required roles', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'agent@test.com',
roles: ['AGENT'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it('should allow access when user has required role', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should handle HTTP errors gracefully', async () => {
mockHttpService.post.mockReturnValue(
throwError(() => new Error('Network error')),
);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should attach user to request', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
await guard.canActivate(context);
const request = context.switchToHttp().getRequest();
expect(request.user).toEqual({
userId: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
});
});
});
describe('token extraction', () => {
it('should extract token from Bearer header', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'test@test.com',
roles: [],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('my-token-123');
await guard.canActivate(context);
expect(mockHttpService.post).toHaveBeenCalledWith(
expect.any(String),
{ token: 'my-token-123' },
expect.any(Object),
);
});
it('should reject malformed authorization header', async () => {
const request = {
headers: { authorization: 'InvalidFormat token' },
};
const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
});
});

View File

@@ -0,0 +1,144 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
/**
* IAM Adapter for @tria-plc corporate identity integration
*
* This adapter wraps the corporate IAM guards and provides a bridge
* between the corporate identity system and the EDR passenger API.
*
* For back-office roles (agent, supervisor, admin, staff), this guard
* validates tokens against the corporate IAM service.
*
* For passenger-facing routes, the existing JWT guard is used.
*/
export interface IamTokenPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
organizationId?: string;
exp: number;
iat: number;
}
export interface IamValidationResponse {
valid: boolean;
payload?: IamTokenPayload;
error?: string;
}
@Injectable()
export class IamGuard implements CanActivate {
private readonly iamApiUrl: string;
private readonly iamEnabled: boolean;
constructor(
private readonly reflector: Reflector,
private readonly config: ConfigService,
private readonly http: HttpService,
) {
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.iamEnabled) {
// IAM disabled - allow access (for development)
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No authentication token provided');
}
const validation = await this.validateToken(token);
if (!validation.valid || !validation.payload) {
throw new UnauthorizedException(validation.error || 'Invalid token');
}
// Check required roles
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
}
// Attach user to request
request.user = {
userId: validation.payload.sub,
email: validation.payload.email,
roles: validation.payload.roles,
permissions: validation.payload.permissions,
organizationId: validation.payload.organizationId,
};
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
return parts[1];
}
private async validateToken(token: string): Promise<IamValidationResponse> {
try {
const response = await firstValueFrom(
this.http.post<IamValidationResponse>(
`${this.iamApiUrl}/v1/auth/validate`,
{ token },
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
},
timeout: 5000,
},
),
);
return response.data;
} catch (err) {
return {
valid: false,
error: err instanceof Error ? err.message : 'Token validation failed',
};
}
}
}
/**
* Decorator to mark routes as requiring IAM authentication
*/
export const UseIamAuth = () => {
// This is a marker decorator that can be used with @UseGuards(IamGuard)
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
// Marker only - actual guard is applied via @UseGuards
};
};
/**
* Decorator to specify required roles for IAM-protected routes
*/
export const IamRoles = (...roles: string[]) => {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
if (descriptor) {
Reflect.defineMetadata('roles', roles, descriptor.value);
}
};
};

View File

@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
@Global()
@Module({
imports: [HttpModule.register({ timeout: 5000 })],
providers: [IamGuard],
exports: [IamGuard],
})
export class IamModule {}

View File

@@ -0,0 +1,49 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PrismaService } from '../prisma.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SessionActivityInterceptor implements NestInterceptor {
private readonly inactivityMinutes: number;
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const user = request.user;
if (user?.userId) {
const session = await this.prisma.session.findFirst({
where: { userId: user.userId },
orderBy: { lastActivityAt: 'desc' },
});
if (session) {
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
if (inactiveMinutes > this.inactivityMinutes) {
await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException('Session expired due to inactivity');
}
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes);
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
await this.prisma.session.update({
where: { id: session.id },
data: { lastActivityAt: new Date() },
});
}
}
return next.handle().pipe(tap(() => {}));
}
}

View File

@@ -1,6 +1,7 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { SessionActivityInterceptor } from './interceptors/session-activity.interceptor';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
@Module({ providers: [PrismaService, SessionActivityInterceptor], exports: [PrismaService, SessionActivityInterceptor] })
export class PrismaModule {}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,19 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('card', () => ({
baseUrl: process.env.CARD_BASE_URL || '',
apiKey: process.env.CARD_API_KEY || '',
webhookSecret: process.env.CARD_WEBHOOK_SECRET || '',
webhookUrl: process.env.CARD_WEBHOOK_URL || '',
returnUrl: process.env.CARD_RETURN_URL || '',
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('cbe', () => ({
baseUrl: process.env.CBE_BASE_URL || '',
merchantId: process.env.CBE_MERCHANT_ID || '',
secretKey: process.env.CBE_SECRET_KEY || '',
notifyUrl: process.env.CBE_NOTIFY_URL || '',
returnUrl: process.env.CBE_RETURN_URL || '',
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('ebirr', () => ({
baseUrl: process.env.EBIRR_BASE_URL || '',
merchantCode: process.env.EBIRR_MERCHANT_CODE || '',
secretKey: process.env.EBIRR_SECRET_KEY || '',
notifyUrl: process.env.EBIRR_NOTIFY_URL || '',
returnUrl: process.env.EBIRR_RETURN_URL || '',
}));

View File

@@ -5,6 +5,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor';
import { SessionActivityInterceptor } from './common/interceptors/session-activity.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -17,41 +18,202 @@ async function bootstrap() {
});
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
app.useGlobalInterceptors(
new ResponseTransformInterceptor(),
app.get(SessionActivityInterceptor),
);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
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.
## Authentication
### Passenger Authentication (JWT-auth)
Used for passenger-facing endpoints. Obtain token via \`POST /auth/login\`.
**Usage:** Add header \`Authorization: Bearer <token>\`
### Back-office Authentication (IAM-auth)
Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
**Usage:** Add header \`Authorization: Bearer <iam-token>\`
## Key Features
### 🎫 Booking Lifecycle
- Search trips with real-time availability
- Create bookings with seat selection
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
### 💳 Payment Integration
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
- **eBirr** - Electronic payment gateway
- **Card** - International card payments
- **Wallet** - Internal wallet system
### 🪑 Seat Management
- Real-time seat availability
- Seat holds (15-minute expiry)
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps
### 🎟️ Ticketing
- QR code and barcode generation
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
### 🏆 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
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- Commission tracking
- Cash reconciliation
## 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\`
- \`POST /payments/webhooks/cbe-birr\`
- \`POST /payments/webhooks/ebirr\`
- \`POST /payments/webhooks/card\`
## 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('Auth', 'Registration and login')
.addTag('Stations', 'Station directory')
.addTag('Fleet', 'Train services and coaches')
.addTag('Schedule', 'Trips and fare rules')
.addTag('Search', 'Trip search and fare quotes')
.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('Support', 'FAQ and chat support')
.addTag('Dashboard', 'Home dashboard aggregate')
.addServer('http://localhost:4000', 'Development')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
in: 'header',
description: 'JWT token for passenger authentication. Obtain via POST /auth/login'
},
'JWT-auth'
)
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
in: 'header',
description: 'Corporate IAM token for back-office operations (agents, fraud, reports)'
},
'IAM-auth'
)
.addTag('Auth', '🔐 Registration, login, OTP verification, password reset')
.addTag('Agents', '👨‍💼 Agent booking, shifts, commissions, reconciliation')
.addTag('Booking', '🎫 Booking lifecycle, modification, cancellation, refunds')
.addTag('Dashboard', '📊 Home dashboard aggregated data')
.addTag('Fleet', '🚂 Train services, coaches, seat configurations')
.addTag('Fraud Detection', '🔒 Fraud detection, risk scoring, user blocking')
.addTag('Live Tracking', '📍 Real-time trip status, location updates, crowd signals')
.addTag('Loyalty', '🏆 Points accumulation, tier management, rewards redemption')
.addTag('Notifications', '🔔 Push, email, SMS notifications, preferences')
.addTag('Passenger', '👤 Profiles, traveler profiles, saved routes, preferences')
.addTag('Payment', '💳 Payment intents, status queries, refunds')
.addTag('Payment Webhooks', '🔗 Payment provider callback endpoints')
.addTag('Promotions', '🎁 Promo codes, campaigns, discount validation')
.addTag('Reports', '📈 Revenue reports, occupancy analytics, agent sales')
.addTag('Schedule', '🗓️ Trip schedules, fare rules, status updates')
.addTag('Search', '🔍 Trip search, availability, fare quotes')
.addTag('Seats', '🪑 Seat maps, holds, releases, blocking, auto-assign')
.addTag('Segment-based Seats', '🎯 Segment-based seat availability and booking')
.addTag('Stations', '🚉 Station directory, information, crowd signals')
.addTag('Support', '💬 FAQ management, live chat conversations')
.addTag('Tickets', '🎟️ QR/barcode generation, PDF tickets, gate validation')
.addTag('Wallet', '💰 Wallet balance, top-up, transaction ledger')
.addServer('http://localhost:4000', 'Local Development')
.addServer('https://api-staging.edr-platform.com', 'Staging Environment')
.addServer('https://api.edr-platform.com', 'Production')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document, {
customSiteTitle: 'EDR Passenger API',
swaggerOptions: { persistAuthorization: true, docExpansion: 'none', filter: true },
customSiteTitle: 'EDR Passenger API Documentation',
customfavIcon: 'https://edr-platform.com/favicon.ico',
customCss: `
.swagger-ui .topbar { display: none }
.swagger-ui .info { margin: 20px 0 }
.swagger-ui .info .title { font-size: 36px; font-weight: bold }
.swagger-ui .scheme-container { background: #fafafa; padding: 15px; border-radius: 4px }
`,
swaggerOptions: {
persistAuthorization: true,
docExpansion: 'none',
filter: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
displayRequestDuration: true,
tryItOutEnabled: true,
syntaxHighlight: {
activate: true,
theme: 'monokai'
}
},
});
const port = process.env.PORT ?? 4000;

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Agents')
@Controller('agents')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class AgentsController {
constructor(private service: AgentsService) {}
@Post('bookings')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {
return this.service.createAgentBooking(dto);
}
@Post('shifts/open')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Open agent shift' })
openShift(@Body() dto: OpenShiftDto) {
return this.service.openShift(dto);
}
@Post('shifts/close')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Close agent shift' })
closeShift(@Body() dto: CloseShiftDto) {
return this.service.closeShift(dto);
}
@Get(':agentId/commissions')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent commissions' })
getCommissions(
@Param('agentId') agentId: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string
) {
return this.service.getCommissions(
agentId,
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined
);
}
@Get(':agentId/shifts')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent shifts' })
getShifts(@Param('agentId') agentId: string) {
return this.service.getShifts(agentId);
}
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AgentPassengerDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;
@ApiProperty() @IsString() email: string;
@ApiProperty() @IsString() seatId: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
}
export class CreateAgentBookingDto {
@ApiProperty() @IsString() agentId: string;
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ type: [AgentPassengerDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => AgentPassengerDto) passengers: AgentPassengerDto[];
@ApiProperty() @IsString() paymentMethod: string;
@ApiPropertyOptional() @IsOptional() @IsInt() cashReceived?: number;
@ApiPropertyOptional() @IsOptional() @IsBoolean() paperTicket?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() serviceClass?: string;
}
export class OpenShiftDto {
@ApiProperty() @IsString() agentId: string;
@ApiPropertyOptional() @IsOptional() @IsInt() openingBalance?: number;
}
export class CloseShiftDto {
@ApiProperty() @IsString() shiftId: string;
@ApiProperty() @IsInt() closingBalance: number;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AgentsController } from './agents.controller';
import { AgentsService } from './agents.service';
@Module({
imports: [HttpModule],
controllers: [AgentsController],
providers: [AgentsService],
exports: [AgentsService]
})
export class AgentsModule {}

View File

@@ -0,0 +1,131 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
@Injectable()
export class AgentsService {
constructor(private prisma: PrismaService) {}
async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } });
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const seatIds = dto.passengers.map(p => p.seatId);
const seats = await this.prisma.seat.findMany({ where: { id: { in: seatIds } } });
if (seats.length !== seatIds.length) throw new BadRequestException('Invalid seat selection');
const baseFare = 45000 * dto.passengers.length;
const totalMinor = baseFare;
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: agent.user.passenger.id,
tripId: dto.tripId,
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
totalMinor,
seats: {
create: dto.passengers.map(p => ({
seatId: p.seatId,
passengerName: p.fullName,
idDocumentType: p.idDocumentType,
idDocumentNumber: p.idDocumentNumber
}))
}
},
include: { seats: true }
});
await this.prisma.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'BOOKED' }
});
const changeGiven = dto.cashReceived ? dto.cashReceived - totalMinor : 0;
await this.prisma.agentBooking.create({
data: {
agentId: dto.agentId,
bookingId: booking.id,
paymentMethod: dto.paymentMethod,
cashReceived: dto.cashReceived,
changeGiven,
paperTicket: dto.paperTicket ?? false
}
});
const commissionAmount = Math.floor(totalMinor * agent.commissionRate / 100);
await this.prisma.agentCommission.create({
data: {
agentId: dto.agentId,
bookingId: booking.id,
amountMinor: commissionAmount,
rate: agent.commissionRate
}
});
return { booking, commission: commissionAmount };
}
async openShift(dto: OpenShiftDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
if (!agent) throw new NotFoundException('Agent not found');
const openShift = await this.prisma.agentShift.findFirst({
where: { agentId: dto.agentId, closedAt: null }
});
if (openShift) throw new BadRequestException('Shift already open');
return this.prisma.agentShift.create({
data: {
agentId: dto.agentId,
openingBalance: dto.openingBalance ?? 0
}
});
}
async closeShift(dto: CloseShiftDto) {
const shift = await this.prisma.agentShift.findUnique({ where: { id: dto.shiftId } });
if (!shift) throw new NotFoundException('Shift not found');
if (shift.closedAt) throw new BadRequestException('Shift already closed');
return this.prisma.agentShift.update({
where: { id: dto.shiftId },
data: {
closedAt: new Date(),
closingBalance: dto.closingBalance,
notes: dto.notes,
reconciled: true
}
});
}
async getCommissions(agentId: string, dateFrom?: Date, dateTo?: Date) {
return this.prisma.agentCommission.findMany({
where: {
agentId,
createdAt: {
gte: dateFrom,
lte: dateTo
}
},
orderBy: { createdAt: 'desc' }
});
}
async getShifts(agentId: string) {
return this.prisma.agentShift.findMany({
where: { agentId },
orderBy: { openedAt: 'desc' },
take: 20
});
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@ApiTags('Auth')
@Controller('auth')
@@ -9,10 +9,73 @@ export class AuthController {
constructor(private service: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register new user' })
@ApiOperation({
summary: 'Register new passenger account',
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
})
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
@Post('login')
@ApiOperation({ summary: 'Login and get JWT' })
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Login with email and password',
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
})
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
@Post('otp/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request OTP verification code',
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
})
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
@ApiBody({ type: RequestOtpDto })
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
@Post('otp/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Verify OTP code',
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
})
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
@ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
@Post('password/reset-request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request password reset link',
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Email not found' })
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
@ApiBody({ type: RequestPasswordResetDto })
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
@Post('password/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reset password with token',
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
}

View File

@@ -1,14 +1,152 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: '+251912345678' }) @IsString() phone: string;
@ApiProperty({ example: 'password123', minLength: 8 }) @IsString() @MinLength(8) password: string;
@ApiProperty({
description: 'Full name of the passenger',
example: 'Kelemu Ketsela',
minLength: 2,
maxLength: 100
})
@IsString()
fullName: string;
@ApiProperty({
description: 'Email address (must be unique)',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@IsString()
phone: string;
@ApiProperty({
description: 'Password (minimum 8 characters)',
example: 'SecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
description: 'National ID number',
example: 'ET123456789'
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
description: 'Passport number for international travelers',
example: 'P1234567'
})
@IsOptional()
@IsString()
passportNumber?: string;
}
export class LoginDto {
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: 'password123' }) @IsString() password: string;
@ApiProperty({
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Account password',
example: 'password123',
format: 'password'
})
@IsString()
password: string;
}
export class RequestOtpDto {
@ApiProperty({
description: 'Email address to send OTP',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class VerifyOtpDto {
@ApiProperty({
description: 'Email address',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: '6-digit OTP code',
example: '123456',
minLength: 6,
maxLength: 6
})
@IsString()
code: string;
@ApiProperty({
description: 'Purpose of OTP verification',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the account',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
description: 'Password reset token received via email',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
token: string;
@ApiProperty({
description: 'New password (minimum 8 characters)',
example: 'NewSecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -1,8 +1,9 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@Injectable()
export class AuthService {
@@ -15,28 +16,115 @@ export class AuthService {
if (exists) throw new ConflictException('Email or phone already registered');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: { fullName: dto.fullName, email: dto.email, phone: dto.phone, passwordHash },
data: {
fullName: dto.fullName,
email: dto.email,
phone: dto.phone,
passwordHash,
nationality: dto.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber
},
});
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
return this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: { passenger: true },
include: { passenger: true, agent: true },
});
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
if (!user) throw new UnauthorizedException('Invalid credentials');
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: { increment: 1 },
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
}
});
throw new UnauthorizedException('Invalid credentials');
}
return this.signToken(user.id, user.email, user.role, user.passenger?.id);
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
return this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
}
private signToken(userId: string, email: string, role: string, passengerId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId });
return { token, user: { id: userId, email, role, passengerId } };
async requestOtp(dto: RequestOtpDto) {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.prisma.otpCode.create({
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
});
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
return { sent: true, expiresIn: 600 };
}
async verifyOtp(dto: VerifyOtpDto) {
const otp = await this.prisma.otpCode.findFirst({
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' }
});
if (!otp) throw new BadRequestException('Invalid or expired OTP');
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
return { verified: true };
}
async requestPasswordReset(dto: RequestPasswordResetDto) {
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (!user) return { sent: true };
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await this.prisma.passwordResetToken.create({
data: { userId: user.id, token, expiresAt }
});
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
return { sent: true };
}
async resetPassword(dto: ResetPasswordDto) {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token: dto.token }
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
});
await this.prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true }
});
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
return { reset: true };
}
private signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
return { token, user: { id: userId, email, role, passengerId, agentId } };
}
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
await this.prisma.auditLog.create({
data: { userId, action, entityType, entityId, oldData, newData }
});
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './bookings.dto';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Booking')
@@ -10,7 +10,28 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class BookingsController {
constructor(private service: BookingsService) {}
@Post() @ApiOperation({ summary: 'Create booking from seat hold' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); }
@Get(':bookingRef') @ApiOperation({ summary: 'Get booking by reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Delete(':bookingRef')@ApiOperation({ summary: 'Cancel booking' }) cancel(@Param('bookingRef') ref: string) { return this.service.cancel(ref); }
@Post()
@ApiOperation({ summary: 'Create booking from seat hold' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}
@Get(':bookingRef')
@ApiOperation({ summary: 'Get booking by reference' })
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
}
@Patch(':bookingRef/modify')
@ApiOperation({ summary: 'Modify booking seats or trip' })
modify(@Body() dto: ModifyBookingDto) {
return this.service.modify(dto);
}
@Delete(':bookingRef')
@ApiOperation({ summary: 'Cancel booking' })
cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) {
return this.service.cancel(ref, dto.reason);
}
}

View File

@@ -16,7 +16,25 @@ export class CreateBookingDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiPropertyOptional({ example: 'ECONOMY', enum: ['ECONOMY', 'BUSINESS', 'FIRST'] }) @IsOptional() @IsString() serviceClass?: string;
@ApiPropertyOptional({
example: 'ECONOMY_REGULAR',
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsOptional() @IsString() serviceClass?: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
@ApiPropertyOptional({ description: 'Auto-assign seats instead of manual selection' }) @IsOptional() autoAssign?: boolean;
}
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty() @IsString() newTripId: string;
@ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class CancelBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto } from './bookings.dto';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SearchService } from '../search/search.service';
@@ -20,13 +20,44 @@ export class BookingsService {
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
let seatIds: string[];
if (dto.autoAssign) {
seatIds = await this.seatsService.autoAssignSeats(
dto.tripId,
dto.passengers.length,
dto.serviceClass ?? 'ECONOMY_REGULAR',
);
await this.seatsService.confirmSeats(seatIds);
} else {
seatIds = dto.passengers.map((p) => p.seatId);
}
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY_REGULAR', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
const booking = await this.prisma.booking.create({
data: { bookingRef: generateRef(), passengerId: dto.passengerId, tripId: dto.tripId, status: 'PENDING_PAYMENT', totalMinor: fareQuote.totalMinor, seats: { create: dto.passengers.map((p) => ({ seatId: p.seatId, passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } },
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
tripId: dto.tripId,
status: 'PENDING_PAYMENT',
totalMinor: fareQuote.totalMinor,
bookingType: dto.bookingType ?? 'ONE_WAY',
seats: { create: dto.passengers.map((p, i) => ({ seatId: seatIds[i], passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) }
},
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
});
this.eventEmitter.emit('booking.created', { booking });
return booking;
return {
...booking,
fareBreakdown: {
baseFare: fareQuote.baseFareMinor / 100,
discount: fareQuote.discountMinor / 100,
loyaltyRedemption: fareQuote.loyaltyRedemptionMinor / 100,
taxesFees: fareQuote.taxesFeesMinor / 100,
total: fareQuote.totalMinor / 100,
currency: fareQuote.currency
}
};
}
async getByRef(bookingRef: string) {
@@ -37,6 +68,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalFare: booking.totalMinor / 100,
bookingType: booking.bookingType,
createdAt: booking.createdAt,
trip: {
number: booking.trip.service.number,
@@ -53,12 +85,55 @@ export class BookingsService {
};
}
async cancel(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true } });
async modify(dto: ModifyBookingDto) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, trip: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CONFIRMED') throw new BadRequestException('Use refund for confirmed bookings');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
if (booking.trip.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
const oldSeats = booking.seats.map(s => s.seatId);
const fareAdjustment = 0;
await this.prisma.bookingModification.create({
data: {
bookingId: booking.id,
modifiedBy: booking.passengerId,
modificationType: 'SEAT_CHANGE',
oldData: { tripId: booking.tripId, seatIds: oldSeats },
newData: { tripId: dto.newTripId, seatIds: dto.newSeatIds },
fareAdjustment,
reason: dto.reason
}
});
await this.seatsService.releaseSeats(oldSeats);
await this.seatsService.confirmSeats(dto.newSeatIds);
return { modified: true, bookingRef: dto.bookingRef };
}
async cancel(bookingRef: string, reason?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({
data: {
bookingId: booking.id,
cancelledBy: booking.passengerId,
reason,
refundAmount,
refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL',
refundStatus: 'PENDING'
}
});
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
return this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
@Cron(CronExpression.EVERY_MINUTE)

View File

@@ -0,0 +1,74 @@
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Fraud Detection')
@Controller('fraud')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class FraudController {
private readonly logger = new Logger(FraudController.name);
constructor(private fraudService: FraudService) {}
/**
* Get fraud alerts
*/
@Get('alerts')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get fraud alerts' })
async getAlerts(
@Query('userId') userId?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
const alerts = await this.fraudService.getAlerts(userId, parseInt(limit || '100'), parseInt(offset || '0'));
return { data: alerts, total: alerts.length };
}
/**
* Get fraud rules
*/
@Get('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Get fraud detection rules' })
async getRules() {
const rules = await this.fraudService.getRules();
return { data: rules };
}
/**
* Create or update fraud rule
*/
@Post('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config);
return { data: rule, message: 'Rule updated successfully' };
}
/**
* Block user temporarily
*/
@Post('actions/block')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
return { message: `User blocked for ${body.durationMinutes} minutes` };
}
/**
* Unblock user
*/
@Post('actions/unblock')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { userId: string }) {
await this.fraudService.unblockUser(body.userId);
return { message: 'User unblocked' };
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FraudService } from './fraud.service';
import { FraudController } from './fraud.controller';
@Module({
imports: [HttpModule],
providers: [FraudService],
controllers: [FraudController],
exports: [FraudService],
})
export class FraudModule {}

View File

@@ -0,0 +1,252 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
export interface FraudRuleConfig {
type: 'VELOCITY' | 'HIGH_VALUE' | 'FAILED_PAYMENTS' | 'MULTIPLE_METHODS';
enabled: boolean;
threshold: number;
timeWindowMinutes?: number;
blockDurationMinutes?: number;
}
@Injectable()
export class FraudService {
private readonly logger = new Logger(FraudService.name);
constructor(private prisma: PrismaService) {}
/**
* Evaluate fraud rules and create alerts if triggered
*/
async evaluateRules(
userId: string,
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
context: Record<string, unknown>,
): Promise<{ triggered: boolean; rules: string[] }> {
const triggeredRules: string[] = [];
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) return { triggered: false, rules: [] };
// Check velocity rule (multiple bookings in short time)
if (eventType === 'booking.created') {
const velocityTriggered = await this.checkVelocityRule(userId);
if (velocityTriggered) {
triggeredRules.push('VELOCITY');
}
// Check high-value booking
const amount = (context.amountMinor as number) || 0;
const highValueTriggered = await this.checkHighValueRule(amount);
if (highValueTriggered) {
triggeredRules.push('HIGH_VALUE');
}
}
// Check repeated failed payments
if (eventType === 'payment.failed') {
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId);
if (failedPaymentTriggered) {
triggeredRules.push('FAILED_PAYMENTS');
}
}
// Create alert if rules triggered
if (triggeredRules.length > 0) {
await this.createFraudAlert(userId, eventType, triggeredRules, context);
return { triggered: true, rules: triggeredRules };
}
return { triggered: false, rules: [] };
}
/**
* Check velocity rule: X bookings in Y minutes
*/
private async checkVelocityRule(userId: string): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'VELOCITY', enabled: true },
});
if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
const threshold = rule.threshold;
const bookingCount = await this.prisma.booking.count({
where: {
passengerId: userId,
createdAt: {
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
},
},
});
return bookingCount > threshold;
}
/**
* Check high-value booking rule
*/
private async checkHighValueRule(amountMinor: number): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'HIGH_VALUE', enabled: true },
});
if (!rule) return false;
// threshold is in ETB (convert minor units to ETB)
const amountEtb = amountMinor / 100;
return amountEtb > rule.threshold;
}
/**
* Check failed payment rule: X failed attempts in Y minutes
*/
private async checkFailedPaymentRule(userId: string): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'FAILED_PAYMENTS', enabled: true },
});
if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
const threshold = rule.threshold;
const failedCount = await this.prisma.paymentIntent.count({
where: {
booking: { passengerId: userId },
status: 'FAILED',
updatedAt: {
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
},
},
});
return failedCount > threshold;
}
/**
* Create a fraud alert
*/
private async createFraudAlert(
userId: string,
eventType: string,
triggeredRules: string[],
context: Record<string, unknown>,
): Promise<void> {
const alert = await this.prisma.fraudAlert.create({
data: {
userId,
eventType,
triggeredRules,
context: context as any,
severity: triggeredRules.length > 1 ? 'HIGH' : 'MEDIUM',
},
});
this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`);
// Trigger blocking if needed
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
await this.blockUserTemporarily(userId, 30); // Block for 30 minutes
}
}
/**
* Block user temporarily
*/
async blockUserTemporarily(userId: string, durationMinutes: number): Promise<void> {
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
await this.prisma.user.update({
where: { id: userId },
data: { blockedUntil },
});
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`);
}
/**
* Unblock user
*/
async unblockUser(userId: string): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { blockedUntil: null },
});
this.logger.log(`User ${userId} unblocked`);
}
/**
* Get all fraud alerts
*/
async getAlerts(userId?: string, limit = 100, offset = 0) {
return this.prisma.fraudAlert.findMany({
where: userId ? { userId } : {},
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
});
}
/**
* Create or update a fraud rule
*/
async upsertRule(
type: string,
config: FraudRuleConfig,
) {
return this.prisma.fraudRule.upsert({
where: { type: type as any },
update: {
enabled: config.enabled,
threshold: config.threshold,
config: config as any,
},
create: {
type: type as any,
enabled: config.enabled,
threshold: config.threshold,
config: config as any,
},
});
}
/**
* Get all fraud rules
*/
async getRules() {
return this.prisma.fraudRule.findMany();
}
/**
* Event listener for booking created
*/
@OnEvent('booking.created')
async onBookingCreated(payload: { booking: any }) {
await this.evaluateRules(payload.booking.passengerId, 'booking.created', {
bookingId: payload.booking.id,
amountMinor: payload.booking.totalMinor,
});
}
/**
* Event listener for payment failed
*/
@OnEvent('payment.failed')
async onPaymentFailed(payload: { intentId: string; userId: string }) {
await this.evaluateRules(payload.userId, 'payment.failed', {
intentId: payload.intentId,
});
}
/**
* Event listener for auth login failed
*/
@OnEvent('auth.login.failed')
async onLoginFailed(payload: { userId: string; email: string }) {
await this.evaluateRules(payload.userId, 'auth.login.failed', {
email: payload.email,
});
}
}

View File

@@ -0,0 +1,216 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as sgMail from '@sendgrid/mail';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
export interface NotificationChannel {
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
}
@Injectable()
export class EmailAdapter implements NotificationChannel {
private readonly logger = new Logger(EmailAdapter.name);
constructor(private readonly config: ConfigService) {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
if (apiKey) {
sgMail.setApiKey(apiKey);
this.logger.log('SendGrid Email adapter initialized');
} else {
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
}
}
async send(
recipient: string,
subject: string,
body: string,
context?: Record<string, unknown>,
): Promise<boolean> {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
if (!apiKey) {
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
return true;
}
try {
const msg: sgMail.MailDataRequired = {
to: recipient,
from: fromEmail,
subject,
text: body,
html: this.formatHtml(body, context),
};
await sgMail.send(msg);
this.logger.log(`Email sent successfully to ${recipient}`);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
return false;
}
}
private formatHtml(body: string, context?: Record<string, unknown>): string {
const contextHtml = context
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<small>${JSON.stringify(context, null, 2)}</small>
</div>`
: '';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: white; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Ethio-Djibouti Railway</h2>
</div>
<div class="content">
${body.replace(/\n/g, '<br>')}
${contextHtml}
</div>
<div class="footer">
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
}
@Injectable()
export class SmsAdapter implements NotificationChannel {
private readonly logger = new Logger(SmsAdapter.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const provider = this.config.get<string>('SMS_PROVIDER');
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
}
async send(
recipient: string,
subject: string,
body: string,
_context?: Record<string, unknown>,
): Promise<boolean> {
const provider = this.config.get<string>('SMS_PROVIDER');
const apiKey = this.config.get<string>('SMS_API_KEY');
if (!provider || !apiKey) {
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
return true;
}
try {
switch (provider.toLowerCase()) {
case 'twilio':
return await this.sendViaTwilio(recipient, body);
case 'africastalking':
return await this.sendViaAfricasTalking(recipient, body);
default:
this.logger.warn(`Unknown SMS provider: ${provider}`);
return false;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
return false;
}
}
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
To: to,
From: fromNumber || '',
Body: body,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${auth}`,
},
},
),
);
return response.status === 201;
}
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
const apiKey = this.config.get<string>('SMS_API_KEY');
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
const from = this.config.get<string>('AFRICASTALKING_FROM');
const url = 'https://api.africastalking.com/version1/messaging';
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
username: username || '',
to,
message: body,
from: from || '',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'apiKey': apiKey || '',
},
},
),
);
return response.status === 201;
}
}
@Injectable()
export class PushAdapter implements NotificationChannel {
private readonly logger = new Logger(PushAdapter.name);
constructor(private readonly config: ConfigService) {
this.logger.log('Push notification adapter initialized');
}
async send(
recipient: string,
subject: string,
body: string,
context?: Record<string, unknown>,
): Promise<boolean> {
// Push notifications would typically use FCM/APNS
// For now, just log
this.logger.log(`[PUSH MOCK] To: ${recipient} | Title: ${subject} | Body: ${body.substring(0, 100)}`);
return true;
}
}

View File

@@ -1,7 +1,9 @@
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { TestNotificationDto } from './notifications.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -12,13 +14,32 @@ export class NotificationsController {
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
getForPassenger(@Param('passengerId') id: string) { return this.service.getForPassenger(id); }
getForPassenger(@Param('passengerId') id: string) {
return this.service.getForPassenger(id);
}
@Patch(':id/read')
@ApiOperation({ summary: 'Mark notification as read' })
markRead(@Param('id') id: string) { return this.service.markRead(id); }
markRead(@Param('id') id: string) {
return this.service.markRead(id);
}
@Patch(':passengerId/read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
markAllRead(@Param('passengerId') id: string) { return this.service.markAllRead(id); }
markAllRead(@Param('passengerId') id: string) {
return this.service.markAllRead(id);
}
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send(
dto.templateKey,
dto.recipient,
dto.context,
dto.channels as any,
);
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NotificationCategoryEnum {
@@ -17,3 +17,21 @@ export class SendNotificationDto {
@ApiPropertyOptional({ example: 'edr://tickets/tkt_01' }) @IsOptional() @IsString() deepLink?: string;
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
}
export class TestNotificationDto {
@ApiProperty({ example: 'booking.created' })
@IsString()
templateKey: string;
@ApiProperty({ example: 'user@example.com' })
@IsString()
recipient: string;
@ApiProperty({ example: { bookingRef: 'EDR123456', passengerName: 'John Doe' } })
context: Record<string, unknown>;
@ApiPropertyOptional({ example: ['EMAIL', 'SMS', 'IN_APP'] })
@IsOptional()
@IsArray()
channels?: string[];
}

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
@Module({ controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService] })
@Module({
imports: [HttpModule.register({ timeout: 10_000 })],
controllers: [NotificationsController],
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -1,45 +1,263 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import * as sgMail from '@sendgrid/mail';
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@Injectable()
export class NotificationsService {
constructor(private prisma: PrismaService) {
if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
private readonly logger = new Logger(NotificationsService.name);
private readonly channels: Map<NotificationChannelType, NotificationChannel>;
constructor(
private prisma: PrismaService,
private emailAdapter: EmailAdapter,
private smsAdapter: SmsAdapter,
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', this.emailAdapter as NotificationChannel],
['SMS', this.smsAdapter as NotificationChannel],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
private sanitize(value: string): string {
return value.replace(/[\r\n]/g, ' ').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
/**
* Send notification using template key and context
* @param templateKey - Template code from NotificationTemplate table
* @param recipient - User/Passenger ID or email/phone
* @param context - Variables to interpolate in template
* @param channels - Optional array of channels to use (defaults to user preferences)
*/
async send(
templateKey: string,
recipient: string,
context: Record<string, unknown>,
channels?: NotificationChannelType[],
): Promise<{ sent: boolean; channels: string[] }> {
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateKey },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateKey} not found or inactive`);
return { sent: false, channels: [] };
}
const { subject, body } = this.interpolate(template, context);
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
const sentChannels: string[] = [];
// Always create in-app notification
if (targetChannels.includes('IN_APP')) {
await this.createInAppNotification(recipient, subject, body, context);
sentChannels.push('IN_APP');
}
// Send via other channels
for (const channelType of targetChannels) {
if (channelType === 'IN_APP') continue;
const adapter = this.channels.get(channelType);
if (!adapter) {
this.logger.warn(`No adapter for channel: ${channelType}`);
continue;
}
const recipientAddress = await this.getRecipientAddress(recipient, channelType);
if (!recipientAddress) {
this.logger.warn(`No ${channelType} address for recipient: ${recipient}`);
continue;
}
const success = await adapter.send(recipientAddress, subject, body, context);
if (success) {
sentChannels.push(channelType);
}
}
return { sent: sentChannels.length > 0, channels: sentChannels };
}
async send(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata } });
const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true } });
if (passenger?.user) await this.sendEmail(passenger.user.email, this.sanitize(dto.title), this.sanitize(dto.body));
/**
* Legacy method for backward compatibility
*/
async sendDirect(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({
data: {
passengerId: dto.passengerId,
title: dto.title,
body: dto.body,
category: dto.category as any,
deepLink: dto.deepLink,
metadata: dto.metadata,
},
});
const passenger = await this.prisma.passenger.findUnique({
where: { id: dto.passengerId },
include: { user: true },
});
if (passenger?.user) {
await this.emailAdapter.send(
passenger.user.email,
this.sanitize(dto.title),
this.sanitize(dto.body),
);
}
return notification;
}
getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50 }); }
private async createInAppNotification(
recipient: string,
title: string,
body: string,
context: Record<string, unknown>,
): Promise<void> {
// Try to find passenger by ID or email
let passengerId = recipient;
markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); }
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: recipient }, { phone: recipient }],
},
include: { passenger: true },
});
if (user?.passenger) {
passengerId = user.passenger.id;
} else {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return;
}
}
async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true } }); return { updated: true }; }
await this.prisma.notification.create({
data: {
passengerId,
title,
body,
category: (context.category as any) || 'SYSTEM',
deepLink: context.deepLink as string,
metadata: context as any,
},
});
}
private interpolate(
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
): { subject: string; body: string } {
const subject = template.subject || 'Notification';
let body = template.bodyTemplate;
// Simple template interpolation: {{variable}}
for (const [key, value] of Object.entries(context)) {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
body = body.replace(regex, String(value));
}
return { subject, body };
}
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
},
include: { preferences: true },
});
if (!user?.preferences) {
return ['IN_APP', 'EMAIL'];
}
const channels: NotificationChannelType[] = ['IN_APP'];
if (user.preferences.emailEnabled) channels.push('EMAIL');
if (user.preferences.smsEnabled) channels.push('SMS');
if (user.preferences.pushEnabled) channels.push('PUSH');
return channels;
}
private async getRecipientAddress(
recipient: string,
channel: NotificationChannelType,
): Promise<string | null> {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
},
});
if (!user) return null;
switch (channel) {
case 'EMAIL':
return user.email;
case 'SMS':
return user.phone;
case 'PUSH':
// Would need to fetch device push token
return user.id;
default:
return null;
}
}
private sanitize(value: string): string {
return value
.replace(/[\r\n]/g, ' ')
.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({
where: { passengerId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}
markRead(id: string) {
return this.prisma.notification.update({ where: { id }, data: { read: true } });
}
async markAllRead(passengerId: string) {
await this.prisma.notification.updateMany({
where: { passengerId, read: false },
data: { read: true },
});
return { updated: true };
}
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Booking Created', body: `Booking ${payload.booking.bookingRef} created. Complete payment within 15 minutes.`, category: NotificationCategoryEnum.BOOKING, deepLink: `edr://bookings/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
await this.send(
'booking.created',
payload.booking.passengerId,
{
bookingRef: payload.booking.bookingRef,
category: 'BOOKING',
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
},
);
}
@OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Payment Successful', body: `Your ticket for ${payload.booking.bookingRef} is confirmed. Have a great journey!`, category: NotificationCategoryEnum.PAYMENT, deepLink: `edr://tickets/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
}
private async sendEmail(to: string, subject: string, text: string) {
if (!process.env.SENDGRID_API_KEY) { console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); return; }
try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL || 'noreply@edr-platform.com', subject, text }); }
catch (e) { console.error('[EMAIL] Send error:', String(e instanceof Error ? e.message : e).replace(/[\r\n<>&"']/g, ' ')); }
await this.send(
'payment.succeeded',
payload.booking.passengerId,
{
bookingRef: payload.booking.bookingRef,
category: 'PAYMENT',
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
},
);
}
}

View File

@@ -0,0 +1,270 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../../app.module';
import { PrismaService } from '../../common/prisma.service';
describe('Payments E2E', () => {
let app: INestApplication;
let prisma: PrismaService;
let authToken: string;
let bookingId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
await app.init();
prisma = app.get<PrismaService>(PrismaService);
// Create test user and authenticate
const testUser = await prisma.user.create({
data: {
email: 'payment-test@example.com',
phone: '+251911111111',
fullName: 'Payment Test User',
passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', // Mock hash
role: 'PASSENGER',
},
});
const passenger = await prisma.passenger.create({
data: {
userId: testUser.id,
},
});
// Create wallet for test user
await prisma.walletAccount.create({
data: {
passengerId: passenger.id,
balanceMinor: 100000, // 1000 ETB
currency: 'ETB',
},
});
// Mock JWT token (in real test, call /auth/login)
authToken = 'mock-jwt-token';
// Create test booking
const station1 = await prisma.station.create({
data: {
code: 'TEST1',
name: 'Test Station 1',
city: 'Test City',
lat: 9.0,
lng: 38.0,
},
});
const station2 = await prisma.station.create({
data: {
code: 'TEST2',
name: 'Test Station 2',
city: 'Test City 2',
lat: 9.5,
lng: 38.5,
},
});
const service = await prisma.trainService.create({
data: {
number: 'TEST-001',
name: 'Test Service',
},
});
const trip = await prisma.trip.create({
data: {
serviceId: service.id,
originStationId: station1.id,
destinationStationId: station2.id,
departureAt: new Date(Date.now() + 86400000),
arrivalAt: new Date(Date.now() + 90000000),
durationMinutes: 60,
},
});
const coach = await prisma.coach.create({
data: {
tripId: trip.id,
label: 'A',
serviceClass: 'ECONOMY_REGULAR',
},
});
const seat = await prisma.seat.create({
data: {
coachId: coach.id,
row: 1,
col: 'A',
label: '1A',
status: 'AVAILABLE',
},
});
const booking = await prisma.booking.create({
data: {
bookingRef: 'TEST-BOOK-001',
passengerId: passenger.id,
tripId: trip.id,
status: 'PENDING_PAYMENT',
totalMinor: 50000, // 500 ETB
currency: 'ETB',
},
});
await prisma.bookingSeat.create({
data: {
bookingId: booking.id,
seatId: seat.id,
passengerName: 'Test Passenger',
},
});
bookingId = booking.id;
});
afterAll(async () => {
await prisma.$transaction([
prisma.bookingSeat.deleteMany(),
prisma.paymentIntent.deleteMany(),
prisma.booking.deleteMany(),
prisma.seat.deleteMany(),
prisma.coach.deleteMany(),
prisma.trip.deleteMany(),
prisma.trainService.deleteMany(),
prisma.station.deleteMany(),
prisma.walletLedgerEntry.deleteMany(),
prisma.walletAccount.deleteMany(),
prisma.passenger.deleteMany(),
prisma.user.deleteMany(),
]);
await app.close();
});
describe('POST /payments/initiate', () => {
it('should initiate wallet payment successfully', async () => {
const response = await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId,
method: 'WALLET',
})
.expect(201);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBe('SUCCEEDED');
});
it('should return 400 for invalid payment method', async () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId,
method: 'INVALID_METHOD',
})
.expect(400);
});
it('should return 404 for non-existent booking', async () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId: 'non-existent-id',
method: 'WALLET',
})
.expect(404);
});
});
describe('GET /payments/intents/:bookingId', () => {
it('should get payment intent status', async () => {
const response = await request(app.getHttpServer())
.get(`/payments/intents/${bookingId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBeDefined();
});
it('should return 404 for non-existent intent', async () => {
await request(app.getHttpServer())
.get('/payments/intents/non-existent-booking')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
describe('Webhook endpoints', () => {
it('should handle Telebirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/telebirr')
.send({
merch_order_id: 'TEST-ORDER-123',
payment_order_id: 'PAY-123',
trade_status: 'Completed',
sign: 'mock-signature',
})
.expect(200);
});
it('should handle CBE Birr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/cbe-birr')
.send({
merchantId: 'TEST-MERCHANT',
merchantOrderId: 'TEST-ORDER-123',
orderId: 'CBE-ORDER-123',
status: 'SUCCESS',
signature: 'mock-signature',
})
.expect(200);
});
it('should handle eBirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/ebirr')
.send({
merchantCode: 'TEST-MERCHANT',
orderNo: 'TEST-ORDER-123',
tradeStatus: 'TRADE_SUCCESS',
timestamp: Date.now(),
sign: 'mock-signature',
})
.expect(200);
});
it('should handle Card webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/card')
.set('stripe-signature', 'mock-signature')
.send({
id: 'evt_123',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_123',
status: 'succeeded',
amount: 50000,
currency: 'ETB',
metadata: {
merchantOrderId: 'TEST-ORDER-123',
bookingRef: 'TEST-BOOK-001',
},
},
},
created: Math.floor(Date.now() / 1000),
})
.expect(200);
});
});
});

View File

@@ -5,12 +5,28 @@ import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
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 { 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';
@Module({
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
controllers: [PaymentsController, WebhooksController],
providers: [PaymentsService, TelebirrProvider, TelebirrWebhookService],
providers: [
PaymentsService,
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,328 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PaymentsService } from './payments.service';
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 { 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 { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { BadRequestException, NotFoundException } from '@nestjs/common';
describe('PaymentsService', () => {
let service: PaymentsService;
let prisma: PrismaService;
let seatsService: SeatsService;
let ticketsService: TicketsService;
let eventEmitter: EventEmitter2;
const mockPrisma = {
booking: {
findUnique: jest.fn(),
update: jest.fn(),
},
paymentIntent: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
walletAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
walletLedgerEntry: {
create: jest.fn(),
},
loyaltyAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
loyaltyLedgerEntry: {
create: jest.fn(),
},
$transaction: jest.fn((callback) => callback(mockPrisma)),
};
const mockSeatsService = {
confirmSeats: jest.fn(),
releaseSeats: jest.fn(),
};
const mockTicketsService = {
generate: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const mockTelebirrProvider = {
method: PaymentMethodType.TELEBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCbeBirrProvider = {
method: PaymentMethodType.CBE_BIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockEBirrProvider = {
method: PaymentMethodType.EBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCardProvider = {
method: PaymentMethodType.CARD,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SeatsService, useValue: mockSeatsService },
{ provide: TicketsService, useValue: mockTicketsService },
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
{ provide: EBirrProvider, useValue: mockEBirrProvider },
{ provide: CardProvider, useValue: mockCardProvider },
],
}).compile();
service = module.get<PaymentsService>(PaymentsService);
prisma = module.get<PrismaService>(PrismaService);
seatsService = module.get<SeatsService>(SeatsService);
ticketsService = module.get<TicketsService>(TicketsService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
jest.clearAllMocks();
});
describe('initiatePayment', () => {
const mockBooking = {
id: 'booking-1',
bookingRef: 'EDR123456',
passengerId: 'passenger-1',
totalMinor: 50000,
currency: 'ETB',
status: 'PENDING_PAYMENT',
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
};
it('should throw NotFoundException if booking not found', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(null);
await expect(
service.initiatePayment({
bookingId: 'invalid',
method: 'TELEBIRR' as any,
}),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException if booking not payable', async () => {
mockPrisma.booking.findUnique.mockResolvedValue({
...mockBooking,
status: 'CONFIRMED',
});
await expect(
service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
}),
).rejects.toThrow(BadRequestException);
});
it('should initiate Telebirr payment successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockTelebirrProvider.initiate.mockResolvedValue({
providerOrderId: 'TB-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockTelebirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate CBE Birr payment successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockCbeBirrProvider.initiate.mockResolvedValue({
providerOrderId: 'CBE-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'CBE_BIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockCbeBirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate wallet payment and debit successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 100000,
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.SUCCEEDED,
bookingId: 'booking-1',
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
pointsBalance: 100,
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
expect(mockTicketsService.generate).toHaveBeenCalled();
});
it('should fail wallet payment with insufficient balance', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 10000, // Less than booking total
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
});
expect(result.status).toBe(PaymentIntentStatus.FAILED);
});
});
describe('finalizePaymentSuccess', () => {
it('should finalize payment and issue ticket', async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
status: PaymentIntentStatus.PROCESSING,
};
const mockBooking = {
id: 'booking-1',
passengerId: 'passenger-1',
totalMinor: 50000,
seats: [{ seatId: 'seat-1' }],
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
pointsBalance: 100,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
providerTxnId: 'TXN-123',
});
expect(result.alreadyFinalized).toBe(false);
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
booking: mockBooking,
});
});
it('should return alreadyFinalized if payment already succeeded', async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.SUCCEEDED,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
});
expect(result.alreadyFinalized).toBe(true);
});
});
describe('getIntentByBookingId', () => {
it('should return intent status', async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
status: PaymentIntentStatus.SUCCEEDED,
method: PaymentMethodType.TELEBIRR,
paidAt: new Date(),
merchantOrderId: 'MERCH-123',
updatedAt: new Date(),
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
const result = await service.getIntentByBookingId('booking-1');
expect(result.intentId).toBe('intent-1');
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
});
it('should throw NotFoundException if intent not found', async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
await expect(service.getIntentByBookingId('invalid')).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

@@ -5,9 +5,11 @@ 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 { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
import { 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 { createMerchantOrderId } from './providers/telebirr.crypto';
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
@@ -27,9 +29,15 @@ export class PaymentsService {
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
private telebirrProvider: TelebirrProvider,
private cbeBirrProvider: CbeBirrProvider,
private eBirrProvider: EBirrProvider,
private cardProvider: CardProvider,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
[PaymentMethodType.EBIRR, this.eBirrProvider],
[PaymentMethodType.CARD, this.cardProvider],
]);
}
@@ -61,8 +69,7 @@ export class PaymentsService {
return this.initiateProviderPayment(booking, provider);
}
// TODO: convert CBE_BIRR, EBIRR, CARD into PaymentProvider implementations.
return this.initiateStubPayment(booking, method);
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
private async initiateWalletPayment(
@@ -170,41 +177,7 @@ export class PaymentsService {
return this.formatIntentResponse(intent);
}
private async initiateStubPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
method: PaymentMethodType,
): Promise<InitiateResponseDto> {
const adapters = {
[PaymentMethodType.CBE_BIRR]: cbeBirrAdapter,
[PaymentMethodType.EBIRR]: eBirrAdapter,
[PaymentMethodType.CARD]: cardAdapter,
} as Partial<Record<PaymentMethodType, (a: number, ref: string) => Promise<{ success: boolean; providerRef: string }>>>;
const adapter = adapters[method];
if (!adapter) {
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
const result = await adapter(booking.totalMinor, booking.bookingRef);
const status = result.success ? PaymentIntentStatus.PROCESSING : PaymentIntentStatus.FAILED;
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: { status, providerRef: result.providerRef },
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method,
status,
providerRef: result.providerRef,
},
});
if (result.success) {
await this.finalizePaymentSuccess({ intentId: intent.id });
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(refreshed);
}
return this.formatIntentResponse(intent);
}
private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,

View File

@@ -0,0 +1,218 @@
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 * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface CardInitiateRequest {
amount: number;
currency: string;
description: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
};
return_url: string;
webhook_url: string;
}
interface CardInitiateResponse {
id: string;
status: string;
client_secret: string;
checkout_url: string;
expires_at: number;
}
interface CardQueryResponse {
id: string;
status: string;
amount: number;
currency: string;
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
}
@Injectable()
export class CardProvider implements PaymentProvider {
readonly method = PaymentMethodType.CARD;
private readonly logger = new Logger(CardProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const requestBody: CardInitiateRequest = {
amount,
currency: input.currency,
description: `EDR Train Booking ${input.bookingRef}`,
metadata: {
merchantOrderId: input.merchantOrderId,
bookingRef: input.bookingRef,
},
return_url: this.returnUrl,
webhook_url: this.webhookUrl,
};
const response = await this.postJson<CardInitiateResponse>(
`${this.baseUrl}/v1/payment_intents`,
requestBody,
);
if (!response.id) {
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(response.expires_at * 1000);
return {
providerOrderId: response.id,
clientAction: { type: 'REDIRECT', url: response.checkout_url },
expiresAt,
rawInitiation: {
request: requestBody,
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
// For card payments, we need to find the payment intent by metadata
// In a real implementation, we'd store the provider order ID and use it directly
const response = await this.getJson<CardQueryResponse>(
`${this.baseUrl}/v1/payment_intents/search?metadata[merchantOrderId]=${merchantOrderId}`,
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transaction_id,
failureCode: response.failure_code,
failureMessage: response.failure_message,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
const payloadString = JSON.stringify(payload);
const expectedSignature = crypto
.createHmac('sha256', this.webhookSecret)
.update(payloadString)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch {
return false;
}
}
mapWebhookStatus(status: string): PaymentIntentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
switch (status?.toLowerCase()) {
case 'succeeded':
case 'paid':
return PaymentIntentStatus.SUCCEEDED;
case 'failed':
case 'canceled':
case 'expired':
return PaymentIntentStatus.FAILED;
case 'requires_payment_method':
case 'requires_confirmation':
case 'requires_action':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'processing':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private async getJson<T>(url: string): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.get<T>(url, config));
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private get baseUrl(): string {
return this.config.get<string>('card.baseUrl') ?? '';
}
private get apiKey(): string {
return this.config.get<string>('card.apiKey') ?? '';
}
private get webhookSecret(): string {
return this.config.get<string>('card.webhookSecret') ?? '';
}
private get webhookUrl(): string {
return this.config.get<string>('card.webhookUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('card.returnUrl') ?? '';
}
}

View File

@@ -0,0 +1,215 @@
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 * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface CbeBirrInitiateRequest {
merchantId: string;
merchantOrderId: string;
amount: string;
currency: string;
description: string;
returnUrl: string;
notifyUrl: string;
timestamp: string;
signature: string;
}
interface CbeBirrInitiateResponse {
success: boolean;
orderId: string;
paymentUrl: string;
expiresIn: number;
}
interface CbeBirrQueryResponse {
success: boolean;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
paidAt?: string;
}
@Injectable()
export class CbeBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.CBE_BIRR;
private readonly logger = new Logger(CbeBirrProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = (input.amountMinor / 100).toFixed(2);
const timestamp = new Date().toISOString();
const requestBody: CbeBirrInitiateRequest = {
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
currency: input.currency,
description: `EDR Booking ${input.bookingRef}`,
returnUrl: this.returnUrl,
notifyUrl: this.notifyUrl,
timestamp,
signature: this.signRequest({
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<CbeBirrInitiateResponse>(
`${this.baseUrl}/api/v1/payment/initiate`,
requestBody,
);
if (!response.success || !response.orderId) {
throw new Error(`CBE Birr initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
return {
providerOrderId: response.orderId,
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = new Date().toISOString();
const signature = this.signRequest({
merchantId: this.merchantId,
merchantOrderId,
timestamp,
});
const response = await this.postJson<CbeBirrQueryResponse>(
`${this.baseUrl}/api/v1/payment/query`,
{
merchantId: this.merchantId,
merchantOrderId,
timestamp,
signature,
},
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { signature, ...data } = payload;
if (!signature || typeof signature !== 'string') return false;
const expectedSignature = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}
mapWebhookStatus(status: string): PaymentIntentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
switch (status?.toUpperCase()) {
case 'SUCCESS':
case 'COMPLETED':
return PaymentIntentStatus.SUCCEEDED;
case 'FAILED':
case 'REJECTED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&');
return crypto
.createHmac('sha256', this.secretKey)
.update(signString)
.digest('hex');
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
'X-Merchant-Id': this.merchantId,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CbeBirrInitiateRequest): Record<string, unknown> {
const { signature: _signature, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>('cbe.baseUrl') ?? '';
}
private get merchantId(): string {
return this.config.get<string>('cbe.merchantId') ?? '';
}
private get secretKey(): string {
return this.config.get<string>('cbe.secretKey') ?? '';
}
private get notifyUrl(): string {
return this.config.get<string>('cbe.notifyUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('cbe.returnUrl') ?? '';
}
}

View File

@@ -0,0 +1,228 @@
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 * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface EBirrInitiateRequest {
merchantCode: string;
orderNo: string;
amount: number;
currency: string;
subject: string;
body: string;
notifyUrl: string;
returnUrl: string;
timestamp: number;
sign: string;
}
interface EBirrInitiateResponse {
code: string;
message: string;
data?: {
orderNo: string;
payUrl: string;
expireTime: number;
};
}
interface EBirrQueryResponse {
code: string;
message: string;
data?: {
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
payTime?: number;
};
}
@Injectable()
export class EBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.EBIRR;
private readonly logger = new Logger(EBirrProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const timestamp = Date.now();
const requestBody: EBirrInitiateRequest = {
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
currency: input.currency,
subject: `EDR Ticket`,
body: `Train booking ${input.bookingRef}`,
notifyUrl: this.notifyUrl,
returnUrl: this.returnUrl,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<EBirrInitiateResponse>(
`${this.baseUrl}/gateway/api/pay/create`,
requestBody,
);
if (response.code !== '0000' || !response.data?.orderNo) {
throw new Error(`eBirr initiate failed: ${response.message}`);
}
const expiresAt = new Date(response.data.expireTime);
return {
providerOrderId: response.data.orderNo,
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = Date.now();
const requestBody = {
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
}),
};
const response = await this.postJson<EBirrQueryResponse>(
`${this.baseUrl}/gateway/api/pay/query`,
requestBody,
);
if (response.code !== '0000' || !response.data) {
throw new Error(`eBirr query failed: ${response.message}`);
}
const mapped = this.mapStatus(response.data.tradeStatus);
return {
status: mapped,
providerTxnId: response.data.tradeNo,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { sign, ...data } = payload;
if (!sign || typeof sign !== 'string') return false;
const expectedSign = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(sign),
Buffer.from(expectedSign),
);
}
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
return this.mapStatus(tradeStatus);
}
private mapStatus(tradeStatus: string): PaymentIntentStatus {
switch (tradeStatus?.toUpperCase()) {
case 'TRADE_SUCCESS':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
case 'TRADE_CLOSED':
case 'TRADE_FAILED':
case 'FAILED':
return PaymentIntentStatus.FAILED;
case 'WAIT_BUYER_PAY':
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&') + `&key=${this.secretKey}`;
return crypto
.createHash('md5')
.update(signString)
.digest('hex')
.toUpperCase();
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>('ebirr.baseUrl') ?? '';
}
private get merchantCode(): string {
return this.config.get<string>('ebirr.merchantCode') ?? '';
}
private get secretKey(): string {
return this.config.get<string>('ebirr.secretKey') ?? '';
}
private get notifyUrl(): string {
return this.config.get<string>('ebirr.notifyUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('ebirr.returnUrl') ?? '';
}
}

View File

@@ -0,0 +1,147 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { CardProvider } from '../providers/card.provider';
export interface CardWebhookPayload {
id: string;
type: string;
data: {
object: {
id: string;
status: string;
amount: number;
currency: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
};
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
};
};
created: number;
}
@Injectable()
export class CardWebhookService {
private readonly logger = new Logger(CardWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: CardProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
const externalEventId = `${payload.id}_${payload.type}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
signature,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.data.object.transaction_id,
signatureValid,
status: payload.data.object.status,
payload,
});
if (!eventRow) {
this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.data.object.transaction_id,
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.data.object.failure_code,
failureMessage: payload.data.object.failure_message,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped,
providerTxnId: payload.data.object.transaction_id ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: CardWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.CARD,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -0,0 +1,133 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { CbeBirrProvider } from '../providers/cbe-birr.provider';
export interface CbeBirrWebhookPayload {
merchantId: string;
merchantOrderId: string;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
currency?: string;
paidAt?: string;
signature: string;
[key: string]: unknown;
}
@Injectable()
export class CbeBirrWebhookService {
private readonly logger = new Logger(CbeBirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: CbeBirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.merchantOrderId;
const externalEventId = `${payload.orderId}_${payload.status}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.transactionId ?? payload.orderId,
signatureValid,
status: payload.status,
payload,
});
if (!eventRow) {
this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.transactionId ?? payload.orderId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.status,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.transactionId ?? undefined },
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: CbeBirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.CBE_BIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -0,0 +1,133 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { EBirrProvider } from '../providers/ebirr.provider';
export interface EBirrWebhookPayload {
merchantCode: string;
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
currency?: string;
payTime?: number;
timestamp: number;
sign: string;
[key: string]: unknown;
}
@Injectable()
export class EBirrWebhookService {
private readonly logger = new Logger(EBirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: EBirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.orderNo;
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.tradeNo,
signatureValid,
status: payload.tradeStatus,
payload,
});
if (!eventRow) {
this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.tradeNo,
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.tradeStatus,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.tradeNo ?? undefined },
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: EBirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.EBIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -1,16 +1,33 @@
import { Body, Controller, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common';
import { Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TelebirrWebhookPayload,
TelebirrWebhookService,
} from './telebirr-webhook.service';
import {
CbeBirrWebhookPayload,
CbeBirrWebhookService,
} from './cbe-birr-webhook.service';
import {
EBirrWebhookPayload,
EBirrWebhookService,
} from './ebirr-webhook.service';
import {
CardWebhookPayload,
CardWebhookService,
} from './card-webhook.service';
@ApiTags('Payment Webhooks')
@Controller('payments/webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(private readonly telebirr: TelebirrWebhookService) {}
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
) {}
@Post('telebirr')
@HttpCode(HttpStatus.OK)
@@ -24,4 +41,46 @@ export class WebhooksController {
}
return { code: '0', message: 'OK' };
}
@Post('cbe-birr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'CBE Birr payment notification callback' })
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
try {
await this.cbeBirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
}
return { success: true };
}
@Post('ebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'eBirr payment notification callback' })
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`eBirr webhook handler threw: ${message}`);
}
return { code: '0000', message: 'success' };
}
@Post('card')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Card payment notification callback' })
async receiveCard(
@Body() payload: CardWebhookPayload,
@Headers('stripe-signature') signature: string,
) {
try {
await this.card.handle(payload, signature);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Card webhook handler threw: ${message}`);
}
return { received: true };
}
}

View File

@@ -0,0 +1,35 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Reports')
@Controller('reports')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class ReportsController {
constructor(private service: ReportsService) {}
@Post('generate')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Generate operational report' })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get(':reportId')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) {
return this.service.listReports(type);
}
}

View File

@@ -0,0 +1,29 @@
import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ReportType {
REVENUE = 'REVENUE',
OCCUPANCY = 'OCCUPANCY',
AGENT_SALES = 'AGENT_SALES',
CANCELLATIONS = 'CANCELLATIONS',
PAYMENT_METHODS = 'PAYMENT_METHODS'
}
export enum ExportFormat {
JSON = 'JSON',
CSV = 'CSV',
PDF = 'PDF'
}
export class GenerateReportDto {
@ApiProperty({ enum: ReportType }) @IsEnum(ReportType) reportType: ReportType;
@ApiProperty({ example: '2026-01-01' }) @IsDateString() dateFrom: string;
@ApiProperty({ example: '2026-01-31' }) @IsDateString() dateTo: string;
@ApiPropertyOptional() @IsOptional() @IsString() routeId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() agentId?: string;
}
export class ExportReportDto {
@ApiProperty() @IsString() reportId: string;
@ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [HttpModule],
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService]
})
export class ReportsModule {}

View File

@@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto';
@Injectable()
export class ReportsService {
constructor(private prisma: PrismaService) {}
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
const dateTo = new Date(dto.dateTo);
let data: any;
switch (dto.reportType) {
case ReportType.REVENUE:
data = await this.generateRevenueReport(dateFrom, dateTo);
break;
case ReportType.OCCUPANCY:
data = await this.generateOccupancyReport(dateFrom, dateTo);
break;
case ReportType.AGENT_SALES:
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
break;
case ReportType.CANCELLATIONS:
data = await this.generateCancellationsReport(dateFrom, dateTo);
break;
case ReportType.PAYMENT_METHODS:
data = await this.generatePaymentMethodsReport(dateFrom, dateTo);
break;
default:
data = {};
}
const report = await this.prisma.operationalReport.create({
data: {
reportType: dto.reportType,
dateFrom,
dateTo,
data
}
});
return { reportId: report.id, reportType: dto.reportType, data };
}
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: { in: ['CONFIRMED', 'COMPLETED'] }
},
include: { paymentIntent: true }
});
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
}, {} as Record<string, number>);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
byPaymentMethod
};
}
private async generateOccupancyReport(dateFrom: Date, dateTo: Date) {
const trips = await this.prisma.trip.findMany({
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coaches: { include: { seats: true } },
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } }
}
});
const tripData = trips.map(trip => {
const totalSeats = trip.coaches.reduce((sum, c) => sum + c.seats.length, 0);
const bookedSeats = trip.bookings.reduce((sum, b) => sum + b.seats.length, 0);
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return {
tripId: trip.id,
departureAt: trip.departureAt,
totalSeats,
bookedSeats,
occupancyRate: +occupancyRate.toFixed(2)
};
});
const avgOccupancy = tripData.length > 0
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length
: 0;
return {
totalTrips: trips.length,
averageOccupancyRate: +avgOccupancy.toFixed(2),
trips: tripData
};
}
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
const agentBookings = await this.prisma.agentBooking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
...(agentId ? { agentId } : {})
},
include: {
agent: { include: { user: true } },
booking: true
}
});
const byAgent = agentBookings.reduce((acc, ab) => {
const agentName = ab.agent.user.fullName;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}
acc[agentName].bookings += 1;
acc[agentName].revenueMinor += ab.booking.totalMinor;
acc[agentName].cashCollected += ab.cashReceived ?? 0;
return acc;
}, {} as Record<string, any>);
return {
totalAgentBookings: agentBookings.length,
byAgent
};
}
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
const cancellations = await this.prisma.bookingCancellation.findMany({
where: { createdAt: { gte: dateFrom, lte: dateTo } },
include: { booking: true }
});
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
return {
totalCancellations: cancellations.length,
totalRefundedMinor: totalRefunded,
totalRefunded: totalRefunded / 100,
currency: 'ETB'
};
}
private async generatePaymentMethodsReport(dateFrom: Date, dateTo: Date) {
const payments = await this.prisma.paymentIntent.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: 'SUCCEEDED'
}
});
const byMethod = payments.reduce((acc, p) => {
const method = p.method;
if (!acc[method]) {
acc[method] = { count: 0, totalMinor: 0 };
}
acc[method].count += 1;
acc[method].totalMinor += p.amountMinor;
return acc;
}, {} as Record<string, any>);
return {
totalPayments: payments.length,
byMethod
};
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
}
async listReports(reportType?: string) {
return this.prisma.operationalReport.findMany({
where: reportType ? { reportType } : {},
orderBy: { createdAt: 'desc' },
take: 50
});
}
}

View File

@@ -11,7 +11,11 @@ export class SearchTripsDto {
export class FareQuoteDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'ECONOMY' }) @IsString() serviceClass: string;
@ApiProperty({
example: 'ECONOMY_REGULAR',
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsString() serviceClass: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;

View File

@@ -23,8 +23,22 @@ export class SearchService {
origin: { id: trip.originStation.id, code: trip.originStation.code, name: trip.originStation.name, city: trip.originStation.city },
destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city },
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
availability: { ECONOMY: avail('ECONOMY'), BUSINESS: avail('BUSINESS'), FIRST: avail('FIRST') },
fares: { ECONOMY: this.defaultFare('ECONOMY') / 100, BUSINESS: this.defaultFare('BUSINESS') / 100, FIRST: this.defaultFare('FIRST') / 100 },
availability: {
ECONOMY_REGULAR: avail('ECONOMY_REGULAR'),
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER'),
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE'),
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER'),
VIP_BED_LOWER: avail('VIP_BED_LOWER'),
VIP_BED_UPPER: avail('VIP_BED_UPPER')
},
fares: {
ECONOMY_REGULAR: this.defaultFare('ECONOMY_REGULAR') / 100,
ECONOMY_BED_LOWER: this.defaultFare('ECONOMY_BED_LOWER') / 100,
ECONOMY_BED_MIDDLE: this.defaultFare('ECONOMY_BED_MIDDLE') / 100,
ECONOMY_BED_UPPER: this.defaultFare('ECONOMY_BED_UPPER') / 100,
VIP_BED_LOWER: this.defaultFare('VIP_BED_LOWER') / 100,
VIP_BED_UPPER: this.defaultFare('VIP_BED_UPPER') / 100
},
};
});
}
@@ -45,6 +59,14 @@ export class SearchService {
}
private defaultFare(serviceClass: string): number {
return ({ ECONOMY: 45000, BUSINESS: 90000, FIRST: 135000 } as any)[serviceClass] ?? 45000;
const fares: Record<string, number> = {
ECONOMY_REGULAR: 35000, // 350 ETB
ECONOMY_BED_LOWER: 55000, // 550 ETB
ECONOMY_BED_MIDDLE: 50000, // 500 ETB
ECONOMY_BED_UPPER: 45000, // 450 ETB
VIP_BED_LOWER: 85000, // 850 ETB
VIP_BED_UPPER: 80000 // 800 ETB
};
return fares[serviceClass] ?? 35000;
}
}

View File

@@ -14,4 +14,20 @@ export class SeatsController {
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
@Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
@Get('export/csv/:tripId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('tripId') tripId: string) {
const csv = await this.service.exportSeatsCSV(tripId);
return { csv, filename: `seats-${tripId}.csv` };
}
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
previewCSV(@Body() body: { csv: string }) {
return this.service.previewSeatsCSV(body.csv);
}
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' })
importCSV(@Body() body: { tripId: string; csv: string; commit: boolean }) {
return this.service.importSeatsCSV(body.tripId, body.csv, body.commit);
}
}

View File

@@ -0,0 +1,82 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SeatsService } from './seats.service';
import { PrismaService } from '../../common/prisma.service';
import { ConflictException } from '@nestjs/common';
describe('SeatsService - Auto Assign', () => {
let service: SeatsService;
let prisma: PrismaService;
const mockPrisma = {
seat: {
findMany: jest.fn(),
updateMany: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SeatsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<SeatsService>(SeatsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('autoAssignSeats', () => {
it('should assign contiguous seats in same row', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' },
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' },
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
expect(result).toEqual(['seat-1', 'seat-2']);
});
it('should throw error if not enough seats available', async () => {
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
]);
await expect(
service.autoAssignSeats('trip-1', 3, 'ECONOMY_REGULAR'),
).rejects.toThrow(ConflictException);
});
it('should respect eligibility filter', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
expect(result).toHaveLength(2);
});
it('should assign single seat', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 1, 'ECONOMY_REGULAR');
expect(result).toEqual(['seat-1']);
});
});
});

View File

@@ -42,6 +42,128 @@ export class SeatsService {
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
async autoAssignSeats(tripId: string, count: number, serviceClass: string, eligibility?: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {
coach: { tripId, serviceClass: serviceClass as any },
status: 'AVAILABLE',
...(eligibility ? { eligibility } : {}),
},
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
if (seats.length < count) {
throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`);
}
const assigned = this.findContiguousSeats(seats, count);
return assigned.map((s) => s.id);
}
private findContiguousSeats(seats: any[], count: number): any[] {
if (count === 1) return [seats[0]];
const grouped = new Map<string, any[]>();
for (const seat of seats) {
const key = `${seat.coachId}-${seat.row}`;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(seat);
}
for (const rowSeats of grouped.values()) {
if (rowSeats.length >= count) {
return rowSeats.slice(0, count);
}
}
return seats.slice(0, count);
}
async exportSeatsCSV(tripId: string): Promise<string> {
const coaches = await this.prisma.coach.findMany({
where: { tripId },
include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } },
});
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
for (const coach of coaches) {
for (const seat of coach.seats) {
rows.push(
`${coach.id},${coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`,
);
}
}
return rows.join('\n');
}
async previewSeatsCSV(csvContent: string): Promise<{ valid: number; invalid: number; errors: string[] }> {
const lines = csvContent.trim().split('\n').slice(1);
const errors: string[] = [];
let valid = 0;
let invalid = 0;
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split(',');
if (parts.length < 8) {
errors.push(`Line ${i + 2}: Invalid format`);
invalid++;
continue;
}
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !label) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
continue;
}
valid++;
}
return { valid, invalid, errors: errors.slice(0, 10) };
}
async importSeatsCSV(tripId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> {
const lines = csvContent.trim().split('\n').slice(1);
const errors: string[] = [];
let imported = 0;
if (!commit) {
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
}
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
update: {
label,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
create: {
coachId,
row: parseInt(row),
col,
label,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
});
imported++;
} catch (err) {
errors.push(`Line ${i + 2}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { imported, errors: errors.slice(0, 10) };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,6 +9,38 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class TicketsController {
constructor(private service: TicketsService) {}
@Get(':bookingRef') @ApiOperation({ summary: 'Get ticket by booking reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Post(':bookingRef/validate') @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) validate(@Param('bookingRef') ref: string, @Body('validatorId') validatorId: string) { return this.service.validate(ref, validatorId); }
@Get(':bookingRef')
@ApiOperation({ summary: 'Get ticket by booking reference' })
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
}
@Post(':bookingRef/validate')
@ApiOperation({ summary: 'Validate ticket at gate (staff)' })
validate(
@Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string
) {
return this.service.validate(ref, validatorId, gateId);
}
@Get(':ticketId/validation-logs')
@ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId);
}
@Get('offline/export')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('tripId') tripId: string) {
return this.service.exportOfflineData(tripId);
}
@Post('validate/offline')
@ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
}

View File

@@ -0,0 +1,126 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TicketsService } from './tickets.service';
import { PrismaService } from '../../common/prisma.service';
describe('TicketsService - Offline Validation', () => {
let service: TicketsService;
let prisma: PrismaService;
const mockPrisma = {
booking: {
findMany: jest.fn(),
findUnique: jest.fn(),
},
ticket: {
findUnique: jest.fn(),
update: jest.fn(),
},
gateValidationLog: {
create: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TicketsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<TicketsService>(TicketsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('exportOfflineData', () => {
it('should export tickets for offline validation', async () => {
const mockBookings = [
{
bookingRef: 'ABC123',
ticket: { id: 'ticket-1', qrPayload: 'qr-data', validatedAt: null },
seats: [{ passengerName: 'John Doe', seat: { label: '1A', coach: { label: 'A' } } }],
status: 'CONFIRMED',
},
];
mockPrisma.booking.findMany.mockResolvedValue(mockBookings);
const result = await service.exportOfflineData('trip-1');
expect(result).toHaveLength(1);
expect(result[0].bookingRef).toBe('ABC123');
expect(result[0].passengerName).toBe('John Doe');
});
});
describe('validateOfflineBatch', () => {
it('should process batch validations successfully', async () => {
const validations = [
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
gateId: 'gate-1',
validatedAt: new Date().toISOString(),
},
];
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
mockPrisma.ticket.findUnique.mockResolvedValue({ id: 'ticket-1', validatedAt: null });
mockPrisma.ticket.update.mockResolvedValue({});
mockPrisma.gateValidationLog.create.mockResolvedValue({});
const result = await service.validateOfflineBatch(validations);
expect(result.success).toBe(1);
expect(result.failed).toBe(0);
expect(result.duplicate).toBe(0);
});
it('should detect duplicate validations', async () => {
const validations = [
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
validatedAt: new Date().toISOString(),
},
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
validatedAt: new Date().toISOString(),
},
];
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
mockPrisma.ticket.findUnique.mockResolvedValue({ id: 'ticket-1', validatedAt: null });
mockPrisma.ticket.update.mockResolvedValue({});
mockPrisma.gateValidationLog.create.mockResolvedValue({});
const result = await service.validateOfflineBatch(validations);
expect(result.success).toBe(1);
expect(result.duplicate).toBe(1);
});
it('should handle already validated tickets', async () => {
const validations = [
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
validatedAt: new Date().toISOString(),
},
];
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
mockPrisma.ticket.findUnique.mockResolvedValue({
id: 'ticket-1',
validatedAt: new Date(),
});
const result = await service.validateOfflineBatch(validations);
expect(result.duplicate).toBe(1);
expect(result.success).toBe(0);
});
});
});

View File

@@ -2,6 +2,13 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
bookingRef: string;
validatorId: string;
gateId?: string;
validatedAt: string;
}
@Injectable()
export class TicketsService {
constructor(private prisma: PrismaService) {}
@@ -13,7 +20,12 @@ export class TicketsService {
});
if (!booking) throw new NotFoundException('Booking not found');
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
return this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload } });
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
});
}
async getByRef(bookingRef: string) {
@@ -29,15 +41,110 @@ export class TicketsService {
departureAt: booking.trip.departureAt, trainName: booking.trip.service.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
}
async validate(bookingRef: string, validatorId: string) {
async validate(bookingRef: string, validatorId: string, gateId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
if (ticket.validatedAt) throw new BadRequestException('Ticket already validated');
return this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
});
throw new BadRequestException('Ticket already validated');
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
});
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },
orderBy: { validatedAt: 'desc' }
});
}
async exportOfflineData(tripId: string) {
const bookings = await this.prisma.booking.findMany({
where: { tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: true } } } },
passenger: { include: { user: true } },
},
});
return bookings.map((b) => ({
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.label,
coachLabel: b.seats[0]?.seat.coach.label,
qrPayload: b.ticket?.qrPayload,
status: b.status,
validatedAt: b.ticket?.validatedAt,
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
const processedRefs = new Set<string>();
for (const v of validations) {
if (processedRefs.has(v.bookingRef)) {
results.duplicate++;
continue;
}
processedRefs.add(v.bookingRef);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
if (!booking) {
results.failed++;
results.errors.push(`Booking ${v.bookingRef} not found`);
continue;
}
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) {
results.failed++;
results.errors.push(`Ticket for ${v.bookingRef} not found`);
continue;
}
if (ticket.validatedAt) {
results.duplicate++;
continue;
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
});
await this.prisma.gateValidationLog.create({
data: {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
},
});
results.success++;
} catch (err) {
results.failed++;
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return results;
}
}

12058
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff