Merge branch 'dev' into feat/payment-microservice

This commit is contained in:
Abubeker Yasin
2026-06-16 15:24:33 +03:00
86 changed files with 10812 additions and 2009 deletions

View File

@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { AuditModule } from './common/audit.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
@@ -39,6 +40,8 @@ import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { AuditModuleFeature } from './modules/audit/audit.module';
import { CurrenciesModule } from './modules/currencies/currencies.module';
@Module({
imports: [
@@ -59,6 +62,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
AuditModule,
I18nModule,
IamModule,
AuthModule,
@@ -85,6 +89,8 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
SeatClassesModule,
FareEngineModule,
VerifaydaModule,
AuditModuleFeature,
CurrenciesModule,
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
import { AuditService } from './audit.service';
@Module({
imports: [PrismaModule],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,92 @@
import { Injectable, Inject, Optional } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaService } from './prisma.service';
@Injectable()
export class AuditService {
constructor(
private prisma: PrismaService,
@Optional() @Inject(REQUEST) private request?: any,
) {}
async log(input: {
userId?: string;
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
entityType: string;
entityId?: string;
oldData?: any;
newData?: any;
}) {
try {
const ipAddress = this.getIpAddress();
const userAgent = this.getUserAgent();
await this.prisma.auditLog.create({
data: {
userId: input.userId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
oldData: input.oldData,
newData: input.newData,
ipAddress,
userAgent,
},
});
} catch (error) {
console.error('Failed to log audit event:', error);
// Don't throw - audit logging should not break main operations
}
}
private getIpAddress(): string {
if (!this.request) return '';
return (
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
this.request.headers['x-real-ip'] ||
this.request.connection?.remoteAddress ||
this.request.socket?.remoteAddress ||
this.request.ip ||
''
);
}
private getUserAgent(): string {
return this.request?.headers?.['user-agent'] || '';
}
async getLogs(filters: any = {}) {
const where: any = {};
if (filters.search) {
where.OR = [
{ entityId: { contains: filters.search, mode: 'insensitive' } },
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.action) {
where.action = filters.action;
}
if (filters.entityType) {
where.entityType = filters.entityType;
}
return this.prisma.auditLog.findMany({
where,
include: { user: true },
orderBy: { createdAt: 'desc' },
take: 500, // Limit to last 500 logs
});
}
async getLog(id: string) {
return this.prisma.auditLog.findUnique({
where: { id },
include: { user: true },
});
}
}

View File

@@ -6,7 +6,7 @@ import { registerAs } from '@nestjs/config';
* never interfere. Points at the dedicated `payment` vhost on the shared broker.
*/
export default registerAs('rabbitmq', () => ({
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
url: process.env.PAYMENT_RABBITMQ_URL,
/** Max unacked payment events held by this consumer at once. */
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
}));

View File

@@ -34,6 +34,14 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 🆕 Latest Updates
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
- **Multi-Currency Display:** Bookings track display currency and converted amounts
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
## Key Features
### 🎫 Booking Lifecycle
@@ -47,6 +55,11 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
- Round-trip booking with return journey scheduling
- Coach type selection with seat class and pricing options
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
- **NEW:** Display currency and converted pricing per booking
### 👤 Passenger Verification
1. **Ethiopian Nationals:**
@@ -65,12 +78,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
- **NEW:** Premium charges and insurance fees per seat class
- **NEW:** Transparent fee breakdown in pricing calculations
### 💳 Payment Integration
1. **Ethiopian Payment Methods:**
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
- **eBirr** - Electronic payment gateway
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
@@ -84,8 +98,9 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
- **NEW:** Sequence-based coach ordering for consistent display
### 🎟️ Ticketing
- QR code and barcode generation
@@ -93,6 +108,8 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
- **NEW:** Complete audit trail for compliance and reporting
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
@@ -118,16 +135,50 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Failed payment pattern detection
- Automatic user blocking
### 👤 Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
- **NEW:** Complete demographic data for personalized services
- **NEW:** Improved user targeting and communications
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- Commission tracking
- Cash reconciliation
### 🚌 Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
- Transit stop optimization and route planning
### 🔄 Round-Trip Booking
- One-way and round-trip journey options
- Flexible return date selection
- Combined pricing for outbound + return legs
- Separate seat management per leg
- Independent modification/cancellation per leg
- Return journey tracking and notifications
- **NEW:** Booking type stored for analytics and reporting
### 🚐 Coach Type & Class Selection
- Browse available coach types per route (standard coaches, premium coaches)
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
- Deferred pricing at seat selection stage
- Coach amenities and features display
- **NEW:** Sequence-based coach ordering for consistent UI
- **NEW:** Premium and insurance fee transparency per class
### 📊 Data Organization
- **Stations:** Ordered by sequence (1-15) for consistent route display
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
- **Booking History:** Sorted chronologically with filtering options
## Authentication
@@ -197,7 +248,6 @@ List endpoints support pagination:
Payment providers send notifications to:
- \`POST /payments/webhooks/telebirr\` (Ethiopia)
- \`POST /payments/webhooks/cbe-birr\` (Ethiopia)
- \`POST /payments/webhooks/ebirr\` (Ethiopia)
- \`POST /payments/webhooks/waafi\` (Djibouti)
- \`POST /payments/webhooks/card\` (International)
@@ -212,33 +262,38 @@ Payment providers send notifications to:
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, and commission tracking")
.addTag("Auth", "User registration, login, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
.addTag("Dashboard", "Aggregated dashboard data for home screen")
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
.addTag("Fleet", "Train services, coaches, and seat configurations")
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
.addTag("Passengers", "Passenger registration, verification, and profiles")
.addTag("Payment", "Payment processing, intents, and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers")
.addTag("Promotions", "Promo codes, campaigns, and discount management")
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
.addTag("Routes", "Route templates with stops and fare rules")
.addTag("Schedule", "Trip schedules, availability, and status updates")
.addTag("Search", "Trip search, availability checks, and fare quotes")
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
.addTag("Seats", "Seat maps, holds, releases, and blocking")
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
.addTag("Stations", "Station directory and information")
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
.addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency")
.addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API")
.addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations")
.addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking")
.addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management")
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management")
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles")
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed class configuration and pricing")
.addTag("Seats", "Seat maps, holds (15-min expiry), releases, blocking, and inventory")
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View File

@@ -0,0 +1,41 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AuditService } from '../../common/audit.service';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Audit')
@Controller('audit')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class AuditController {
constructor(private auditService: AuditService) {}
@Get('logs')
@ApiOperation({
summary: 'Get audit logs',
description: 'Retrieve system audit logs with optional filtering',
})
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
async getLogs(
@Query('search') search?: string,
@Query('action') action?: string,
@Query('entityType') entityType?: string,
) {
const filters = {
search: search || undefined,
action: action || undefined,
entityType: entityType || undefined,
};
const items = await this.auditService.getLogs(filters);
return { items };
}
@Get('logs/:id')
@ApiOperation({ summary: 'Get audit log by ID' })
async getLog(@Param('id') id: string) {
return this.auditService.getLog(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
import { AuditController } from './audit.controller';
@Module({
imports: [AuditModule, HttpModule],
controllers: [AuditController],
})
export class AuditModuleFeature {}

View File

@@ -14,6 +14,18 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
}
export class RoundTripPassengerDto {
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() scheduleId: string;
@@ -29,6 +41,29 @@ export class CreateBookingDto {
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class CreateRoundTripBookingDto {
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
import { BookingsController } from './bookings.controller';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
@@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -296,7 +296,7 @@ export class BookingsService {
}
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
@@ -363,8 +363,60 @@ export class BookingsService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
): Promise<number> {
const now = new Date();
// Get schedule with route info
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { route: true },
});
// Try segment fare rule first (most specific) if route info available
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
// Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: nationality || null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
if (segmentFare) {
return segmentFare.baseFareMinor;
}
// If no segment fare with nationality, try without nationality filter
if (nationality) {
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
if (segmentFareAny) return segmentFareAny.baseFareMinor;
}
}
// Fall back to fare rules if no segment fare found
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,

View File

@@ -0,0 +1,50 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { CurrenciesService } from './currencies.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
@ApiTags('Currencies')
@Controller('currencies')
export class CurrenciesController {
constructor(private currenciesService: CurrenciesService) {}
@Get()
getAllCurrencies() {
return this.currenciesService.getAllCurrencies();
}
@Post()
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
@HttpCode(201)
createCurrency(@Body() dto: CreateCurrencyDto) {
return this.currenciesService.createCurrency(dto);
}
@Patch(':id')
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
return this.currenciesService.updateCurrency(id, dto);
}
@Delete(':id')
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
deleteCurrency(@Param('id') id: string) {
return this.currenciesService.deleteCurrency(id);
}
@Post('sync-rates')
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
@HttpCode(200)
syncRates() {
return this.currenciesService.syncExchangeRates();
}
}

View File

@@ -0,0 +1,47 @@
import { IsString, IsNumber, IsOptional, Min } from 'class-validator';
export class CreateCurrencyDto {
@IsString()
code: string;
@IsString()
name: string;
@IsString()
symbol: string;
@IsString()
@IsOptional()
baseCurrencyCode?: string;
@IsNumber()
@Min(0.0001)
exchangeRate: number;
}
export class UpdateCurrencyDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
symbol?: string;
@IsNumber()
@IsOptional()
@Min(0.0001)
exchangeRate?: number;
}
export class CurrencyResponseDto {
id: string;
code: string;
name: string;
symbol: string;
baseCurrencyCode: string;
exchangeRate: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

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

View File

@@ -0,0 +1,131 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
@Injectable()
export class CurrenciesService {
constructor(private prisma: PrismaService) {}
async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({
distinct: ['toCurrency'],
orderBy: { toCurrency: 'asc' },
});
return rates.map(rate => ({
id: rate.id,
code: rate.toCurrency,
name: this.getCurrencyName(rate.toCurrency),
symbol: this.getCurrencySymbol(rate.toCurrency),
baseCurrencyCode: rate.fromCurrency,
exchangeRate: Number(rate.rate),
isActive: true,
createdAt: rate.createdAt,
updatedAt: rate.createdAt,
}));
}
async createCurrency(dto: CreateCurrencyDto) {
const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto;
if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) {
throw new BadRequestException('Unsupported currency code');
}
if (exchangeRate <= 0) {
throw new BadRequestException('Exchange rate must be positive');
}
const rate = await this.prisma.currencyExchangeRate.create({
data: {
fromCurrency: baseCurrencyCode as any,
toCurrency: code.toUpperCase() as any,
rate: exchangeRate,
source: 'MANUAL',
},
});
return {
id: rate.id,
code: rate.toCurrency,
name,
symbol,
baseCurrencyCode: rate.fromCurrency,
exchangeRate: Number(rate.rate),
isActive: true,
createdAt: rate.createdAt,
updatedAt: rate.createdAt,
};
}
async updateCurrency(id: string, dto: UpdateCurrencyDto) {
const existing = await this.prisma.currencyExchangeRate.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException('Currency not found');
}
if (dto.exchangeRate !== undefined && dto.exchangeRate <= 0) {
throw new BadRequestException('Exchange rate must be positive');
}
const updated = await this.prisma.currencyExchangeRate.update({
where: { id },
data: {
rate: dto.exchangeRate,
},
});
return {
id: updated.id,
code: updated.toCurrency,
name: dto.name || this.getCurrencyName(updated.toCurrency),
symbol: dto.symbol || this.getCurrencySymbol(updated.toCurrency),
baseCurrencyCode: updated.fromCurrency,
exchangeRate: Number(updated.rate),
isActive: true,
createdAt: updated.createdAt,
updatedAt: updated.createdAt,
};
}
async deleteCurrency(id: string) {
const existing = await this.prisma.currencyExchangeRate.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException('Currency not found');
}
await this.prisma.currencyExchangeRate.delete({
where: { id },
});
return { message: 'Currency deleted successfully' };
}
async syncExchangeRates() {
return { message: 'Exchange rates synced successfully', synced: 0 };
}
private getCurrencyName(code: string): string {
const names: Record<string, string> = {
ETB: 'Ethiopian Birr',
USD: 'US Dollar',
DJF: 'Djiboutian Franc',
};
return names[code] || code;
}
private getCurrencySymbol(code: string): string {
const symbols: Record<string, string> = {
ETB: 'Br',
USD: '$',
DJF: 'Fdj',
};
return symbols[code] || code;
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { Body, Controller, Post, Get, Query, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { FareEngineService } from './fare-engine.service';
@@ -16,23 +16,10 @@ export class FareEngineController {
@Post('calculate')
@ApiOperation({
summary: 'Calculate fare for a journey leg',
description: `Computes fare using the formula:
**Fare = totalKm × ratePerKm × exchangeRate**
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
- \`ratePerKm\`\`SeatClass.basePrice\` (stored in ETB minor units per km)
- \`exchangeRate\` — derived from passenger nationality:
- **Ethiopian** → ETB (rate = 1.0)
- **Djiboutian** → DJF (rate ≈ 3.25)
- **Other / unspecified** → USD (rate ≈ 0.018)
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
5% tax applied after promo discount.
Returns a full breakdown including a human-readable calculation trace.`,
description: `Computes fare using the formula:\n\n**Fare = totalKm × ratePerKm × exchangeRate**`,
})
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination' })
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
calculate(@Body() dto: FareCalculateDto) {
return this.service.calculate(dto);
@@ -41,15 +28,14 @@ Returns a full breakdown including a human-readable calculation trace.`,
@Get('compare')
@ApiOperation({
summary: 'Compare fares across all seat classes for a route leg',
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
})
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
@ApiQuery({ name: 'nationality', required: false })
@ApiQuery({ name: 'adultCount', required: false, type: Number })
@ApiQuery({ name: 'childCount', required: false, type: Number })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns' })
compareClasses(
@Query('routeId') routeId: string,
@Query('originStationId') originStationId: string,
@@ -67,6 +53,8 @@ Returns a full breakdown including a human-readable calculation trace.`,
childCount ? parseInt(childCount) : 0,
);
}
}
@ApiTags('Config')
@@ -77,18 +65,10 @@ export class ConfigController {
@Get('fayda-status')
@ApiOperation({
summary: 'Check Verifayda 2.0 configuration status',
description: 'Returns whether Verifayda integration is enabled and ready to use'
})
@ApiResponse({
status: 200,
description: 'Verifayda status retrieved successfully',
schema: {
example: {
enabled: true,
mode: 'production',
apiUrl: 'https://api.verifayda.gov.et/v2'
}
}
})
getFaydaStatus() {
const faydaConfig = this.configService.get<FaydaConfig>('fayda');

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FareEngineController, ConfigController } from './fare-engine.controller';
import { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [CurrencyModule],
imports: [HttpModule, CurrencyModule],
controllers: [FareEngineController, CurrencyController, ConfigController],
providers: [FareEngineService],
exports: [FareEngineService],

View File

@@ -47,14 +47,22 @@ export class FareEngineService {
const ratePerKmMinor = seatClass.baseFareMinor;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
// Premium and insurance fees applied per passenger
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0;
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const paidChildrenCount = Math.max(0, childCount - 1);
const subtotalMinor =
baseFarePerPassengerMinor * adultCount +
baseFarePerPassengerMinor * paidChildrenCount;
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
// First child is free, but pays premium and insurance
const adultSubtotal = farePerPassengerMinor * adultCount;
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
let discountMinor = 0;
let promoLabel = 'none';
@@ -85,12 +93,20 @@ export class FareEngineService {
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
@@ -104,6 +120,9 @@ export class FareEngineService {
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
premiumPerPassenger,
insurancePerPassenger,
farePerPassengerMinor,
adultCount,
childCount,
freeChildrenCount,
@@ -142,7 +161,6 @@ export class FareEngineService {
return results.filter(Boolean);
}
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -160,7 +178,6 @@ export class FareEngineService {
});
}
/** Calculate fares for all active seat classes on a schedule. */
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -168,7 +185,6 @@ export class FareEngineService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
// ── Route-based calculation (fare engine) ────────────────────────────────
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
@@ -190,7 +206,6 @@ export class FareEngineService {
return results.filter(Boolean);
}
// ── Fallback: FareRule records scoped to this schedule ───────────────────
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {

View File

@@ -158,7 +158,34 @@ export class FleetController {
@ApiOperation({ summary: 'List coaches with seat status summary' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
@ApiResponse({ status: 200, description: 'Array of coaches' })
@ApiResponse({
status: 200,
description: 'Array of coaches',
schema: {
example: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
},
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
totalSeats: 60,
availableSeats: 45,
occupiedSeats: 15,
blockedSeats: 0,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
]
}
})
listCoaches(
@Query('status') status?: string,
@Query('scheduleId') scheduleId?: string,
@@ -173,7 +200,40 @@ export class FleetController {
@Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
@ApiResponse({
status: 200,
description: 'Coach detail with seats by row',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
},
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
seats: [
{
id: 'seat-uuid-1',
seatNumber: '1A',
status: 'AVAILABLE',
class: {
id: 'class-uuid',
name: 'Economy',
baseFareMinor: 5000
}
}
],
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) {
return this.service.getCoach(id);
@@ -182,7 +242,23 @@ export class FleetController {
@Post('coaches')
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@ApiResponse({
status: 201,
description: 'Coach created',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) {
return this.service.createCoach(dto);
@@ -192,7 +268,23 @@ export class FleetController {
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({
status: 200,
description: 'Coach updated',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
return this.service.updateCoach(id, dto);
@@ -201,7 +293,7 @@ export class FleetController {
@Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach deleted' })
@ApiResponse({ status: 200, description: 'Coach deleted successfully' })
@ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) {
return this.service.deleteCoach(id);

View File

@@ -48,19 +48,16 @@ function buildSeats(coachId: string, coachNumber: string, arrangement: string, c
const col = cols[ci];
let bedPosition = null;
// Set bedPosition for bed coaches based on seat number cycling
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
if (isBedCoach) {
if (totalCols === 3) {
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
const posMod = ((seatNumber - 1) % 3);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'middle';
else if (posMod === 2) bedPosition = 'upper';
// Economy bed (3-row cycle): upper, middle, lower
if (row % 3 === 1) bedPosition = 'upper';
else if (row % 3 === 2) bedPosition = 'middle';
else bedPosition = 'lower';
} else if (totalCols === 2) {
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
const posMod = ((seatNumber - 1) % 2);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'upper';
// VIP bed (2-row cycle): upper, lower
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
}
}
@@ -253,7 +250,7 @@ export class FleetService {
return this.prisma.coach.findMany({
where,
include: { coachType: true },
orderBy: { number: 'asc' },
orderBy: { sequence: 'asc' },
});
}
@@ -263,10 +260,18 @@ export class FleetService {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
// Get the next sequence number for this coach type
const lastCoach = await this.prisma.coach.findFirst({
where: { coachTypeId: dto.coachTypeId },
orderBy: { sequence: 'desc' },
});
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
sequence: nextSequence,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',
@@ -301,33 +306,6 @@ export class FleetService {
async deleteCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
// Get all seat IDs for this coach
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
const seatIds = seats.map(s => s.id);
// Delete in order of foreign key dependencies
if (seatIds.length > 0) {
// 1. Delete seat blocks (references seats)
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
// 2. Delete ticket seats (references seats)
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 3. Delete booking seats (references seats)
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 4. Delete journey segments with these seats
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
}
// 5. Delete all associated seats
await this.prisma.seat.deleteMany({ where: { coachId: id } });
// 6. Delete coach assignments
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
// 7. Finally delete the coach
return this.prisma.coach.delete({ where: { id } });
}

View File

@@ -0,0 +1,8 @@
export class SendEmail {
to: string;
subject: string;
body: string;
html?: string;
templateKey?: string;
context?: Record<string, unknown>;
}

View File

@@ -0,0 +1,9 @@
export class SendMessage {
to: string;
message: string;
from?: string;
}
export class BulkMessagesDto {
messages: SendMessage[];
}

View File

@@ -0,0 +1,28 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { SendEmail } from './dtos/email.dto';
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailServiceClient: ClientProxy,
) {}
async onApplicationBootstrap() {
this.emailServiceClient
.connect()
.then(() => this.logger.log('Connected to Email service'))
.catch((err) => this.logger.error('Error connecting to Email service', err));
}
async sendEmail(dto: SendEmail) {
this.emailServiceClient.emit('send-email', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
return {};
}
}

View File

@@ -1,16 +1,24 @@
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } 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';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
import { SendMessage } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class NotificationsController {
constructor(private service: NotificationsService) {}
constructor(
private service: NotificationsService,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
) {}
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
@@ -30,6 +38,24 @@ export class NotificationsController {
return this.service.markAllRead(id);
}
@Post('send/email')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
@ApiBody({ type: SendEmail })
sendEmail(@Body() dto: SendEmail) {
return this.emailClient.sendEmail(dto);
}
@Post('send/sms')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SendMessage })
sendSms(@Body() dto: SendMessage) {
return this.smsClient.sendSms(dto);
}
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')

View File

@@ -1,13 +1,56 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
@Module({
imports: [HttpModule.register({ timeout: 10_000 })],
imports: [
HttpModule.register({ timeout: 10_000 }),
ClientsModule.registerAsync([
{
name: 'EMAIL_SERVICE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
},
{
name: 'SMS_SERVICE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
},
]),
],
controllers: [NotificationsController],
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
exports: [NotificationsService],
providers: [
NotificationsService,
EmailAdapter,
SmsAdapter,
PushAdapter,
EmailClientService,
SmsClientService,
],
exports: [NotificationsService, EmailClientService, SmsClientService],
})
export class NotificationsModule {}

View File

@@ -1,8 +1,10 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@@ -13,13 +15,13 @@ export class NotificationsService {
constructor(
private prisma: PrismaService,
private emailAdapter: EmailAdapter,
private smsAdapter: SmsAdapter,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', this.emailAdapter as NotificationChannel],
['SMS', this.smsAdapter as NotificationChannel],
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
@@ -102,11 +104,11 @@ export class NotificationsService {
});
if (passenger?.user) {
await this.emailAdapter.send(
passenger.user.email,
this.sanitize(dto.title),
this.sanitize(dto.body),
);
await this.emailClient.sendEmail({
to: passenger.user.email,
subject: this.sanitize(dto.title),
body: this.sanitize(dto.body),
});
}
return notification;

View File

@@ -0,0 +1,36 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject('SMS_SERVICE')
private readonly smsClient: ClientProxy,
) {}
async onApplicationBootstrap() {
this.smsClient
.connect()
.then(() => this.logger.log('Connected to SMS service'))
.catch((err) => this.logger.error('Error connecting to SMS service', err));
}
async sendSms(dto: SendMessage) {
this.smsClient.emit('send-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
return {};
}
async sendBulkMessages(dto: BulkMessagesDto) {
this.smsClient.emit('ozeking-bulk-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
return {};
}
}

View File

@@ -47,17 +47,9 @@ export class PassengersService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
fullName: true,
email: true,
phone: true,
nationalId: true,
nationality: true,
},
},
user: true,
loyalty: true,
wallet: true,
_count: {
select: {
bookings: true,
@@ -69,19 +61,30 @@ export class PassengersService {
]);
return {
items: items.map(passenger => ({
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
nationalId: passenger.user.nationalId,
nationality: passenger.user.nationality,
verified: !!passenger.user.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
})),
items: items.map(passenger => {
const user = passenger.user as any;
return {
id: passenger.id,
userId: passenger.userId,
fullName: user.fullName,
email: user.email,
phone: user.phone,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,
gender: user.gender ?? null,
passportNumber: user.passportNumber,
passportCountry: user.passportCountry ?? null,
verified: !!user.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
updatedAt: user.updatedAt,
loyalty: passenger.loyalty,
wallet: passenger.wallet,
};
}),
meta: {
page,
pageSize,
@@ -95,9 +98,19 @@ export class PassengersService {
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
user: true,
bookings: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
}
},
loyalty: true,
wallet: true,
travelerProfiles: true,
savedRoutes: true,
},
});
if (!passenger) throw new NotFoundException('Passenger not found');
@@ -108,14 +121,35 @@ export class PassengersService {
phone: passenger.user.phone,
createdAt: passenger.createdAt,
bookings: passenger.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalFare: b.totalMinor / 100,
createdAt: b.createdAt,
trip: {
number: b.schedule.train.number,
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
origin: {
id: b.schedule.originStation.id,
name: b.schedule.originStation.name,
code: b.schedule.originStation.code,
city: b.schedule.originStation.city
},
destination: {
id: b.schedule.destinationStation.id,
name: b.schedule.destinationStation.name,
code: b.schedule.destinationStation.code,
city: b.schedule.destinationStation.city
},
departureAt: b.schedule.departureAt,
},
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
passengers: b.seats.map((bs) => ({
fullName: bs.passengerName,
seat: {
number: bs.seat.seatNumber,
coach: bs.seat.coach.number,
class: 'N/A'
}
})),
})),
};
}
@@ -171,14 +205,25 @@ export class PassengersService {
}
createTravelerProfile(dto: CreateTravelerProfileDto) {
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
return this.prisma.travelerProfile.create({
data: {
...dto,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
}
});
}
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
getTravelerProfiles(passengerId: string) {
return this.prisma.travelerProfile.findMany({ where: { passengerId } });
}
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
createSavedRoute(dto: CreateSavedRouteDto) {
return this.prisma.savedRoute.create({ data: dto });
}
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
getSavedRoutes(passengerId: string) {
return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } });
}
async updatePassenger(id: string, dto: any) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
@@ -196,7 +241,7 @@ export class PassengersService {
},
},
include: {
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
user: true,
loyalty: true,
},
});
@@ -290,9 +335,7 @@ export class PassengersService {
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found');
await this.prisma.passenger.delete({ where: { id } });
return { deleted: true, passengerId: id };
return this.prisma.passenger.delete({ where: { id } });
}
async checkPassengerUsage(id: string) {

View File

@@ -1,60 +1,23 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentService,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@Module({
imports: [
SeatsModule,
TicketsModule,
HttpModule.register({ timeout: 10_000 }),
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>("rabbitmq.url") as string,
exchanges: [
{
name: PAYMENT_EVENTS_EXCHANGE,
type: "topic",
options: { durable: true },
},
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
queues: [
{
name: PASSENGER_QUEUE.dlq,
exchange: PAYMENT_EVENTS_DLX,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER),
options: { durable: true },
},
],
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
connectionInitOptions: { wait: false },
}),
}),
],
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
})

View File

@@ -8,7 +8,10 @@ export class ReportsService {
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
dateFrom.setHours(0, 0, 0, 0);
const dateTo = new Date(dto.dateTo);
dateTo.setHours(23, 59, 59, 999);
let data: any;
switch (dto.reportType) {
@@ -44,14 +47,16 @@ export class ReportsService {
}
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
// Fetch all bookings in date range, regardless of status
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: { in: ['CONFIRMED', 'COMPLETED'] }
createdAt: { gte: dateFrom, lte: dateTo }
},
include: { paymentIntent: true }
});
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
@@ -59,12 +64,25 @@ export class ReportsService {
return acc;
}, {} as Record<string, number>);
// Group by date for charts
const byDate = bookings.reduce((acc, b) => {
const date = b.createdAt.toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
byPaymentMethod
byPaymentMethod,
byDate,
cancellationRate: 0
};
}
@@ -73,7 +91,7 @@ export class ReportsService {
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coachAssignments: { include: { coach: { include: { seats: true } } } },
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
bookings: { include: { seats: true } },
},
});

View File

@@ -142,6 +142,14 @@ export class SchedulesController {
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
@Get(':scheduleId/fares/stored')
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
getStoredFares(@Param('scheduleId') scheduleId: string) {
return this.service.getFareRules(scheduleId);
}
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })

View File

@@ -1,7 +1,7 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { TripStatus, StopStatus } from '@prisma/client';
import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client';
export class PlannedStopTimeDto {
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
@@ -51,6 +51,7 @@ export class CreateFareRuleDto {
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@@ -64,6 +65,7 @@ export class CreateSegmentFareRuleDto {
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}

View File

@@ -329,50 +329,6 @@ export class SchedulesService {
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: id },
select: { id: true },
});
const bookingIds = bookings.map(b => b.id);
if (bookingIds.length > 0) {
const paymentIntents = await this.prisma.paymentIntent.findMany({
where: { bookingId: { in: bookingIds } },
select: { id: true },
});
const paymentIntentIds = paymentIntents.map(pi => pi.id);
if (paymentIntentIds.length > 0) {
await this.prisma.paymentRefund.deleteMany({
where: { paymentIntentId: { in: paymentIntentIds } },
});
}
await this.prisma.ticket.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingSeat.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingModification.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingCancellation.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.paymentIntent.deleteMany({
where: { bookingId: { in: bookingIds } },
});
}
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}
@@ -402,7 +358,7 @@ export class SchedulesService {
}
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
return this.prisma.fareRule.create({
data: {
...rest,
@@ -415,7 +371,7 @@ export class SchedulesService {
}
createSegmentFareRule(dto: any) {
const { validFrom, validUntil, ...rest } = dto;
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.create({
data: {
...rest,
@@ -439,7 +395,7 @@ export class SchedulesService {
}
updateSegmentFareRule(id: string, dto: any) {
const { validFrom, validUntil, ...rest } = dto;
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.update({
where: { id },
data: {
@@ -451,12 +407,36 @@ export class SchedulesService {
});
}
async getFareRules(scheduleId?: string) {
const where: any = {};
if (scheduleId) where.tripId = scheduleId;
return this.prisma.fareRule.findMany({
where,
include: { seatClass: true },
orderBy: { createdAt: 'desc' },
});
}
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
}
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
async getAllFaresFromEngine(scheduleId: string, nationality?: string) {
try {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
} catch (error) {
throw new BadRequestException(
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
);
}
}
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {

View File

@@ -11,17 +11,24 @@ export class SearchController {
@Post()
@ApiOperation({
summary: 'Search trips by origin, destination, date, passengers, and nationality',
description: `Finds all train schedules matching search criteria with real-time seat availability.
description: `Finds all train schedules matching search criteria with real-time seat availability and coach type options.
**Coach Type Selection Flow:**
- Users browse available coach types (Economy, VIP, etc.)
- Each coach type displays available seat classes and base fares
- Users select a coach type to proceed to seat selection
- At seat selection, users choose specific seat and class (actual price confirmed here)
- Final fare may adjust based on seat position/amenities selected
**Features:**
- Any origin→destination stop pair (not just terminals)
- Age-based passenger counts (adults ≥5 years, children <5 years)
- Nationality filtering (Ethiopian, Djiboutian, Other)
- Real-time seat availability per class
- Multi-currency fare display
- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D
- Availability: Segment-based (seat booked A→B is still available B→D)`
- Segment-based availability (seat booked A→B still available B→D)`
})
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
@ApiResponse({ status: 200, description: 'Matching schedules with coachTypes array showing available coach types with seat classes and base fares' })
searchTrips(@Body() dto: SearchTripsDto) {
return this.service.searchTrips(dto);
}

View File

@@ -21,6 +21,12 @@ export class SearchTripsDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], description: 'Journey type: ONE_WAY or ROUND_TRIP' })
@IsOptional() @IsEnum(['ONE_WAY', 'ROUND_TRIP']) journeyType?: string;
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
@IsOptional() @IsDateString() returnDate?: string;
}
export class FareQuoteDto {
@@ -53,4 +59,39 @@ export class FareQuoteDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Return schedule UUID (required for ROUND_TRIP journeys)' })
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return origin station ID (required for ROUND_TRIP)' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return destination station ID (required for ROUND_TRIP)' })
@IsOptional() @IsString() returnDestinationStationId?: string;
}
export class CoachTypeOptionClass {
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
name: string;
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
baseFareMinor: number;
}
export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
coachTypeId: string;
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
coachTypeName: string;
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
coachTypeCode: string;
@ApiProperty({
type: 'array',
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
})
classes: CoachTypeOptionClass[];
}

View File

@@ -18,15 +18,57 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
const outbound = await this.searchSchedules(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
if (dto.journeyType === 'ROUND_TRIP') {
const allInbound = await this.searchSchedules(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
const latestOutboundArrival = outbound.length > 0
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
: Date.now();
const inbound = allInbound.filter((schedule) =>
new Date(schedule.departureAt).getTime() > latestOutboundArrival
);
return { journeyType: 'ROUND_TRIP', outbound, inbound };
}
return { journeyType: 'ONE_WAY', outbound };
}
private async searchSchedules(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: dto.originStationId } },
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
@@ -42,8 +84,8 @@ export class SearchService {
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
@@ -61,14 +103,14 @@ export class SearchService {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
@@ -89,14 +131,14 @@ export class SearchService {
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
@@ -109,11 +151,13 @@ export class SearchService {
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
dto.destinationStationId,
dto.nationality,
originStationId,
destinationStationId,
nationality,
);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
results.push({
scheduleId: schedule.id,
trainNumber: schedule.train.number,
@@ -150,6 +194,7 @@ export class SearchService {
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
coachTypes,
});
}
@@ -258,14 +303,14 @@ export class SearchService {
.filter((id: any) => id)
)
);
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
const seatClasses = await this.prisma.seatClass.findMany({
where: {
where: {
isActive: true,
id: { in: seatClassIds }
},
@@ -307,7 +352,7 @@ export class SearchService {
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
if (originStation && destStation) {
const segmentRoute = `${originStation.code}-${destStation.code}`;
const now = new Date();
@@ -341,6 +386,62 @@ export class SearchService {
}));
}
private async buildCoachTypeDetails(
schedule: any,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
): Promise<Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string> }
>();
for (const assignment of schedule.coachAssignments) {
const coachType = assignment.coach.coachType;
if (!coachType) continue;
if (!coachTypeMap.has(coachType.id)) {
coachTypeMap.set(coachType.id, {
coachType,
classNames: new Set(),
});
}
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
}
const result = [];
for (const [, { coachType, classNames }] of coachTypeMap) {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return {
name: className,
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
})
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,
coachTypeCode: coachType.code,
classes,
});
}
return result.sort((a, b) => {
const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
return minPriceA - minPriceB;
});
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard';
@@ -17,6 +17,28 @@ export class StationsController {
@ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' })
@ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' })
@ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' })
@ApiResponse({
status: 200,
description: 'Array of stations',
schema: {
example: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
]
}
})
findAll(
@Query('search') search?: string,
@Query('country') country?: string,
@@ -30,18 +52,79 @@ export class StationsController {
summary: 'Get station details by ID',
description: 'Returns station information including name, code, country, coordinates, and facilities'
})
@ApiResponse({
status: 200,
description: 'Station details',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new station' })
@ApiResponse({
status: 201,
description: 'Station created',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update station' })
@ApiResponse({
status: 200,
description: 'Station updated',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 404, description: 'Station not found' })
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
return this.service.update(id, dto);
}
@@ -50,6 +133,8 @@ export class StationsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete station' })
@ApiResponse({ status: 200, description: 'Station deleted successfully' })
@ApiResponse({ status: 404, description: 'Station not found' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}

View File

@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../../common/audit.module';
import { StationsController } from './stations.controller';
import { StationsService } from './stations.service';
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
@Module({
imports: [AuditModule],
controllers: [StationsController],
providers: [StationsService],
exports: [StationsService],
})
export class StationsModule {}

View File

@@ -1,5 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { CreateStationDto } from './stations.dto';
interface StationFilters {
@@ -10,7 +12,11 @@ interface StationFilters {
@Injectable()
export class StationsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private auditService: AuditService,
@Optional() @Inject(REQUEST) private request?: any,
) {}
findAll(filters: StationFilters = {}) {
const where: any = {};
@@ -33,7 +39,7 @@ export class StationsService {
return this.prisma.station.findMany({
where,
orderBy: { name: 'asc' }
orderBy: { sequence: 'asc' }
});
}
@@ -43,20 +49,51 @@ export class StationsService {
return s;
}
create(dto: CreateStationDto) {
return this.prisma.station.create({ data: dto });
async create(dto: CreateStationDto) {
const station = await this.prisma.station.create({ data: dto });
await this.auditService.log({
userId: this.request?.user?.id,
action: 'CREATE',
entityType: 'Station',
entityId: station.id,
newData: station,
});
return station;
}
async update(id: string, dto: Partial<CreateStationDto>) {
await this.findOne(id); // Check if exists
return this.prisma.station.update({
where: { id },
data: dto
const oldStation = await this.findOne(id);
const updatedStation = await this.prisma.station.update({
where: { id },
data: dto,
});
await this.auditService.log({
userId: this.request?.user?.id,
action: 'UPDATE',
entityType: 'Station',
entityId: id,
oldData: oldStation,
newData: updatedStation,
});
return updatedStation;
}
async remove(id: string) {
await this.findOne(id); // Check if exists
return this.prisma.station.delete({ where: { id } });
const station = await this.findOne(id);
const deleted = await this.prisma.station.delete({ where: { id } });
await this.auditService.log({
userId: this.request?.user?.id,
action: 'DELETE',
entityType: 'Station',
entityId: id,
oldData: station,
});
return deleted;
}
}

View File

@@ -44,6 +44,17 @@ export class TicketsController {
});
}
@Get('by-order/:merchantOrderId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket by merchant order ID',
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
})
getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) {
return this.service.getByMerchantOrderId(merchantOrderId);
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -49,12 +49,16 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.booking.status,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
})),
@@ -157,9 +161,14 @@ export class TicketsService {
return { success: true, updatedSeats: newSeatIds.length };
}
async getByRef(bookingRef: string) {
async getByMerchantOrderId(merchantOrderId: string) {
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
select: { bookingId: true },
});
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
where: { id: intent.bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
@@ -170,6 +179,36 @@ export class TicketsService {
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload,
};
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
ticket: true
},
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id,
bookingId: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
fromStationName: booking.schedule.originStation.name,
toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt,
trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number,
seatLabel: seat?.seat.seatNumber,
passengerName: seat?.passengerName,
priceMinor: booking.totalMinor,
currency: booking.currency,
qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
}