This commit is contained in:
Roba Boru
2026-06-17 16:50:41 +03:00
26 changed files with 870 additions and 334 deletions

View File

@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
EMAIL_QUEUE=email_queue
SMS_QUEUE=sms_queue
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- AlterTable: add return leg tracking columns to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
-- Set NEITHER_USED for existing confirmed round-trip bookings
UPDATE "passenger"."Booking"
SET "returnLegStatus" = 'NEITHER_USED'
WHERE "bookingType" = 'ROUND_TRIP'
AND "status" IN ('CONFIRMED', 'COMPLETED');
-- AlterTable: add leg column to GateValidationLog
ALTER TABLE "passenger"."GateValidationLog"
ADD COLUMN "leg" TEXT;

View File

@@ -115,6 +115,16 @@ enum BookingStatus {
@@schema("passenger")
}
enum ReturnLegStatus {
NOT_APPLICABLE // one-way booking
BOTH_USED // passenger used both legs
OUTBOUND_ONLY // return leg not used (no-show on return)
INBOUND_ONLY // outbound leg not used, return leg used
NEITHER_USED // neither leg boarded yet
@@schema("passenger")
}
enum PaymentRegion {
ETHIOPIA
DJIBOUTI
@@ -364,7 +374,8 @@ model TrainSchedule {
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
bookings Booking[]
bookings Booking[] @relation("OutboundSchedule")
returnBookings Booking[] @relation("ReturnSchedule")
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
menuItems MenuItem[]
@@ -511,6 +522,9 @@ model Booking {
returnDestinationStationId String?
returnHoldId String?
returnSeatClassId String?
returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
outboundBoardedAt DateTime?
returnBoardedAt DateTime?
contactEmail String?
contactPhone String?
userAgent String?
@@ -520,7 +534,8 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
@@ -1159,6 +1174,7 @@ model GateValidationLog {
ticketId String
validatorId String
gateId String?
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
status String
reason String?
validatedAt DateTime @default(now())

View File

@@ -75,7 +75,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
PaymentsModule,
TicketsModule,
PassengersModule,
NotificationsModule,
NotificationsModule.register(),
LoyaltyModule,
WalletModule,
PromosModule,

View File

@@ -34,19 +34,23 @@ 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
## Latest Updates
- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN).
- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
- **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, outboundBoardedAt, returnBoardedAt for complete audit trail.
## Key Features
### 🎫 Booking Lifecycle
### Booking Lifecycle
- Search trips with real-time availability
- Age-based passenger categorization (Adult 5 years, Child <5 years)
- Age-based passenger categorization (Adult 5+ years, Child under 5)
- Nationality-based verification (Ethiopian Fayda, International Passport)
- Passenger information collection with verification
- Coach and seat selection with real-time availability
@@ -55,130 +59,129 @@ 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)
- Cross-border journeys via Dire Dawa transit (Ethiopia to 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
- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP)
- NEW: Display currency and converted pricing per booking
- NEW: returnLegStatus field tracks which legs of a round-trip were used
- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
### 👤 Passenger Verification
1. **Ethiopian Nationals:**
- Automatic Fayda verification for adults (5 years)
### Passenger Verification
1. Ethiopian Nationals:
- Automatic Fayda verification for adults (5+ years)
- Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant)
2. **International Passengers:**
2. International Passengers:
- Passport information collection
- Manual verification for Djiboutian and other nationals
- No government database verification required
### 💰 Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
### Age-Based Pricing
- ADULT (5+ years): Pay 100% of base fare
- CHILD (under 5): 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
- Example: 2 adults + 3 children = 4x 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
### Payment Integration
1. Ethiopian Payment Methods: Telebirr, CBE Birr
2. Djiboutian Payment Methods: Waafi
3. International Payment Methods: Card, Wallet
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
3. **International Payment Methods:**
- **Card** - International card payments (Visa, Mastercard)
- **Wallet** - Internal wallet system
### 🪑 Seat Management
### Seat Management
- Real-time seat availability by coach and class
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
- **NEW:** Sequence-based coach ordering for consistent display
- NEW: Sequence-based coach ordering for consistent display
### 🎟️ Ticketing
### Ticketing
- QR code and barcode generation
- PDF ticket generation
- 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
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- NEW: Complete audit trail per leg for compliance and reporting
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### Round-Trip Leg Tracking (NEW)
- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN)
- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
### 💰 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
### 👤 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)
### 🚌 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
### 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
- NEW: Booking type stored for analytics and reporting
### 🚐 Coach Type & Class Selection
- Browse available coach types per route (standard coaches, premium coaches)
### 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
### 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
### 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)
### Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia to 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
### Coach Type & Class Selection
- Browse available coach types per route
- 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
- 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
### 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
@@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5 years)
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years)
**For International Passengers:**
\`POST /passengers/register-international\` - Passport information collection
@@ -265,7 +268,7 @@ Payment providers send notifications to:
.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("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management")
.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")
@@ -291,7 +294,7 @@ Payment providers send notifications to:
.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("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), 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')

View File

@@ -75,21 +75,24 @@ export class BookingsController {
@Get()
@ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
description: 'Returns paginated list of bookings with search and status filters'
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('returnLegStatus') returnLegStatus?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
status,
status,
returnLegStatus,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});

View File

@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string {
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
interface BookingFilters {
search?: string;
status?: string;
returnLegStatus?: string;
page?: number;
pageSize?: number;
}
@@ -36,6 +38,7 @@ export class BookingsService {
private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -82,6 +85,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -159,6 +164,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -180,7 +187,7 @@ export class BookingsService {
}
async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -194,9 +201,8 @@ export class BookingsService {
];
}
if (status) {
where.status = status;
}
if (status) where.status = status;
if (returnLegStatus) where.returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
@@ -225,6 +231,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
@@ -391,6 +399,7 @@ export class BookingsService {
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
seats: {
create: passengersData.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
@@ -406,7 +415,7 @@ export class BookingsService {
displayCurrency
}))
}
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
@@ -560,76 +569,69 @@ export class BookingsService {
destStopSeq?: number,
): Promise<number> {
const now = new Date();
// Get schedule with route info
// 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { route: true },
select: { routeId: true, originStationId: true, destinationStationId: 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,
nationality: nationality ?? null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: 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;
}
}) ?? (nationality ? 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 } }],
},
}) : null);
if (segmentFare) return segmentFare.baseFareMinor;
}
// Fall back to fare rules if no segment fare found
// 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
const bestMatch = this.selectBestFareRule(
candidates,
scheduleId,
segmentRoute,
fullRoute,
nationality,
// 3. FareEngine — distance × rate-per-km from the schedule's route
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
return bestMatch?.baseFareMinor ?? 35000;
}
async getByRef(bookingRef: string) {
@@ -646,7 +648,11 @@ export class BookingsService {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
bookingType: booking.bookingType, createdAt: booking.createdAt,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
@@ -751,6 +757,37 @@ export class BookingsService {
}
}
// Mark round-trip bookings where the return train has departed but the return leg
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
@Cron(CronExpression.EVERY_MINUTE)
async markReturnLegNoShows() {
const now = new Date();
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
const candidates = await this.prisma.booking.findMany({
where: {
bookingType: 'ROUND_TRIP',
status: 'CONFIRMED',
returnLegStatus: 'NEITHER_USED' as any,
outboundBoardedAt: { not: null },
returnBoardedAt: null,
returnScheduleId: { not: null },
},
include: { returnSchedule: { select: { departureAt: true } } },
} as any);
for (const b of candidates) {
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
if (returnDep && returnDep < graceCutoff) {
await this.prisma.booking.update({
where: { id: b.id },
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
});
}
}
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,

View File

@@ -4,9 +4,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
@ApiProperty({ example: 'seat-id-uuid' })
@ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' })
@IsString() seatId: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP only)' })
@IsOptional() @IsString() returnSeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' })
@IsString() passengerName: string;
@@ -36,24 +39,42 @@ export class GuestPassengerDto {
}
export class CreateGuestBookingDto {
@ApiProperty({ example: 'schedule-uuid' })
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], default: 'ONE_WAY' })
@IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP';
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound schedule UUID' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'hold-uuid' })
@ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
@IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
@IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP only: return schedule UUID' })
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP only: return seat hold UUID' })
@IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return origin station UUID' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return destination station UUID' })
@IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP only: return seat class UUID (defaults to outbound seatClassId)' })
@IsOptional() @IsString() returnSeatClassId?: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@@ -66,7 +87,7 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
@IsOptional() @IsString() password?: string;
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' })
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
@IsOptional() @IsBoolean() savePassengerDetails?: boolean;
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
@@ -28,10 +29,18 @@ export class GuestBookingService {
private seatsService: SeatsService,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
) {}
async createGuestBooking(dto: CreateGuestBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') {
return this.createGuestRoundTripBooking(dto);
}
return this.createGuestOneWayBooking(dto);
}
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
// Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) {
@@ -157,76 +166,7 @@ export class GuestBookingService {
// Create or get guest passenger
const firstPassenger = passengersData[0];
let guestPassenger = null;
let userId = null;
let createdAccount = false;
// Optional account creation
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
throw new BadRequestException('Email already registered. Please login instead.');
}
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash,
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
userId = user.id;
createdAccount = true;
} else {
// Create anonymous guest passenger with minimal data
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Check if email exists and use a unique guest email if it does
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
// Email exists, use guest email instead for anonymous booking
guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
}
// Use a guaranteed-unique guest phone to avoid constraint collisions
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existingPhone) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
}
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
// Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
@@ -302,6 +242,258 @@ export class GuestBookingService {
};
}
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
}
// Validate both holds
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
// Validate passengers have returnSeatId
for (const p of dto.passengers) {
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
}
// Load both schedules
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.returnScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
]);
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
// Process passengers (verify identity once — same person travels both legs)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
}
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
nationality = 'Ethiopian';
} else {
nationality = nationality || 'Other';
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Calculate fares for both legs
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff
? Math.round(combinedBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Create or resolve guest passenger (same as one-way)
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
// Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId);
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassenger.id,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
userAgent: dto.deviceId,
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT
? outboundBaseFare + returnBaseFare
: (paidChildrenCount > 0 ? outboundBaseFare + returnBaseFare : 0),
displayCurrency,
})),
},
} as any,
include: {
seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
createdAccount,
userId,
fareBreakdown: {
outboundBaseFareMinor: outboundBaseFare,
returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
},
};
}
private async resolveGuestPassenger(
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
firstPassenger: any,
): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash: await bcrypt.hash(dto.password, 10),
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
return { guestPassenger, userId: user.id, createdAccount: true };
}
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existing) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
});
const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
return { guestPassenger, userId: null, createdAccount: false };
}
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');
@@ -343,6 +535,8 @@ export class GuestBookingService {
nationality?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
@@ -368,14 +562,34 @@ export class GuestBookingService {
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
}
return 35000; // Default fallback
// 2. FareEngine — distance × rate-per-km from the schedule's route
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
}
}

View File

@@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap {
private readonly emailServiceClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
async onApplicationBootstrap() {
if (!this.enabled) return;
this.emailServiceClient
.connect()
.then(() => this.logger.log('Connected to Email service'))
@@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap {
}
async sendEmail(dto: SendEmail) {
this.emailServiceClient.emit('send-email', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
if (!this.enabled) return {};
this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' });
return {};
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { DynamicModule, Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
@@ -38,17 +38,37 @@ import { SmsClientService } from './sms-client.service';
queueOptions: { durable: true },
},
},
]),
],
controllers: [NotificationsController],
providers: [
NotificationsService,
EmailAdapter,
SmsAdapter,
PushAdapter,
EmailClientService,
SmsClientService,
],
exports: [NotificationsService, EmailClientService, SmsClientService],
})
export class NotificationsModule {}
}),
},
]);
@Module({})
export class NotificationsModule {
static register(): DynamicModule {
const rmqEnabled = process.env.RABBITMQ_ENABLED !== 'false';
return {
module: NotificationsModule,
imports: [
HttpModule.register({ timeout: 10_000 }),
...(rmqEnabled ? [rmqClientsModule] : []),
],
controllers: [NotificationsController],
providers: [
NotificationsService,
EmailAdapter,
SmsAdapter,
PushAdapter,
...(rmqEnabled
? [EmailClientService, SmsClientService]
: [
{ provide: 'EMAIL_SERVICE', useValue: null },
{ provide: 'SMS_SERVICE', useValue: null },
EmailClientService,
SmsClientService,
]),
],
exports: [NotificationsService, EmailClientService, SmsClientService],
};
}
}

View File

@@ -16,7 +16,10 @@ export class SmsClientService implements OnApplicationBootstrap {
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => {
@@ -28,18 +31,14 @@ export class SmsClientService implements OnApplicationBootstrap {
}
async sendSms(dto: SendMessage) {
this.smsClient.emit('send-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
if (!this.enabled) return {};
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',
});
if (!this.enabled) return {};
this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
return {};
}
}

View File

@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}
export class BulkSchedulesResponseDto {

View File

@@ -44,6 +44,15 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// Assign coaches if provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
}
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);

View File

@@ -244,7 +244,8 @@ export class SearchService {
nationality,
);
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
const baseFareMinor = bestMatch?.baseFareMinor
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
@@ -379,11 +380,8 @@ export class SearchService {
}
}
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
baseFareMinor: this.getDefaultFareForClass(sc.name),
}));
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
return [];
}
private async buildCoachTypeDetails(
@@ -420,11 +418,10 @@ export class SearchService {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return {
name: className,
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
if (!fareInfo) return null;
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
})
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
@@ -442,22 +439,28 @@ export class SearchService {
});
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,
'Economy Bed': 49000,
'VIP Bed': 63000,
};
return defaults[className] ?? 35000;
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
});
return fare.baseFarePerPassengerMinor;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
'Economy Regular': 45000,
'Economy Bed': 65000,
'VIP Bed': 95000,
};
return fares[seatClassName] ?? 45000;
private getDefaultFareForClass(_className: string): never {
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
}
private defaultFare(_seatClassName: string): never {
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule(

View File

@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
});

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -30,6 +30,10 @@ export class TicketsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
@@ -77,14 +81,26 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
})
@ApiBody({
schema: {
type: 'object',
required: ['validatorId'],
properties: {
validatorId: { type: 'string', example: 'agent-uuid' },
gateId: { type: 'string', example: 'gate-01' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' },
},
},
})
validate(
@Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string
@Body('gateId') gateId?: string,
@Body('leg') leg?: 'OUTBOUND' | 'RETURN',
) {
return this.service.validate(ref, validatorId, gateId);
return this.service.validate(ref, validatorId, gateId, leg);
}
@Get(':ticketId/validation-logs')
@@ -106,7 +122,31 @@ export class TicketsController {
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' })
@ApiOperation({
summary: 'Batch import offline validations',
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
})
@ApiBody({
schema: {
type: 'object',
properties: {
validations: {
type: 'array',
items: {
type: 'object',
required: ['bookingRef', 'validatorId', 'validatedAt'],
properties: {
bookingRef: { type: 'string' },
validatorId: { type: 'string' },
gateId: { type: 'string' },
validatedAt: { type: 'string', format: 'date-time' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] },
},
},
},
},
},
})
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}

View File

@@ -7,6 +7,7 @@ interface OfflineValidation {
validatorId: string;
gateId?: string;
validatedAt: string;
leg?: 'OUTBOUND' | 'RETURN';
}
@Injectable()
@@ -49,6 +50,10 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
@@ -213,22 +218,67 @@ export class TicketsService {
};
}
async validate(bookingRef: string, validatorId: string, gateId?: string) {
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') {
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) {
const isRoundTrip = booking.bookingType === 'ROUND_TRIP';
// For one-way bookings use the original single-validation guard
if (!isRoundTrip) {
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: 'REJECTED', reason: 'ALREADY_VALIDATED' }
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' },
});
throw new BadRequestException('Ticket already validated');
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
// Round-trip: track which leg is being boarded
const resolvedLeg = leg ?? 'OUTBOUND';
const now = new Date();
const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any,
});
throw new BadRequestException('Outbound leg already used');
}
bookingData.outboundBoardedAt = now;
// Stamp the ticket's first validation
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
}
} else {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any,
});
throw new BadRequestException('Return leg already used');
}
bookingData.returnBoardedAt = now;
}
// Derive the new composite status
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; // return pending/no-show
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any,
});
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
async getValidationLogs(ticketId: string) {
@@ -256,6 +306,8 @@ export class TicketsService {
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
}));
}
@@ -265,11 +317,13 @@ export class TicketsService {
const processedRefs = new Set<string>();
for (const v of validations) {
if (processedRefs.has(v.bookingRef)) {
const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
processedRefs.add(v.bookingRef);
processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +340,21 @@ export class TicketsService {
continue;
}
if (ticket.validatedAt) {
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') {
results.duplicate++;
continue;
}
// For round-trip, check per-leg duplication
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
const alreadyUsed =
offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt;
if (alreadyUsed) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +365,24 @@ export class TicketsService {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
},
} as any,
});
// update returnLegStatus for round-trip offline validations
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
const bookingData: Record<string, any> =
offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) };
const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = offlineLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
}
results.success++;
} catch (err) {
results.failed++;

View File

@@ -140,7 +140,7 @@ export default function BookingsPage() {
},
{
key: 'bookingType',
label: 'Class',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},

View File

@@ -145,7 +145,7 @@ export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const queryClient = useQueryClient();
// Coach Types Queries
@@ -252,12 +252,17 @@ export default function CoachesPage() {
};
const confirmDelete = async () => {
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
try {
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
}
setDeleteConfirm({ isOpen: false, item: null });
};
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
@@ -536,6 +541,8 @@ export default function CoachesPage() {
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
error={deleteConfirm.error}
warning={
deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'

View File

@@ -362,12 +362,16 @@ export default function RoutesPage() {
type="text"
name="code"
className="input"
value={generateRouteCode(originStationId, destinationStationId)}
readOnly
defaultValue={editingRoute ? editingRoute.code : undefined}
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
required
placeholder="Select stations to generate"
disabled={!!editingRoute}
/>
{!editingRoute && originStationId && destinationStationId && (
<p className="text-xs text-muted-foreground mt-1">
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
</p>
)}
</div>
<div>
<label className="label">Route Name *</label>
@@ -375,11 +379,16 @@ export default function RoutesPage() {
type="text"
name="name"
className="input"
value={generateRouteName(originStationId, destinationStationId)}
readOnly
defaultValue={editingRoute ? editingRoute.name : undefined}
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
required
placeholder="Select stations to generate"
/>
{!editingRoute && originStationId && destinationStationId && (
<p className="text-xs text-muted-foreground mt-1">
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
</p>
)}
</div>
</div>

View File

@@ -318,8 +318,8 @@ export default function SchedulesPage() {
label: 'Train',
sortable: true,
render: (schedule: Schedule) => (
<div className="font-medium">
{schedule.train?.name} ({schedule.train?.number})
<div className="font-medium font-mono">
{schedule.train?.number}
</div>
),
},

View File

@@ -548,8 +548,11 @@ export default function SeatsPage() {
{coachesWithSeats.map((coach: any, index: number) => {
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
const coachTypeName = coachData?.coachType?.type || 'Coach';
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
coachTypeName.toLowerCase().includes('bed') ||
(coach.seats || []).some((s: any) => s.bedPosition);
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const isExpanded = expandedCoaches.has(coach.id);
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';

View File

@@ -204,15 +204,31 @@ export default function TicketsPage() {
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.boardedAt ? (
ticket.validatedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Not boarded</span>
)
),
},
{
key: 'returnLegStatus',
label: 'Return Leg',
render: (ticket: any) => {
const status = ticket.booking?.returnLegStatus;
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground"></span>;
const map: Record<string, { label: string; cls: string }> = {
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
};
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
},
},
];
const actions = [
@@ -444,10 +460,30 @@ export default function TicketsPage() {
</div>
</div>
{selectedTicket.boardedAt && (
{selectedTicket.validatedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Boarded At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
<p className="text-sm text-muted-foreground">Validated At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
</div>
)}
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Leg Status</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Return Boarded</p>
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
</div>
</div>
</div>
)}

View File

@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
isLoading?: boolean;
isDanger?: boolean;
warning?: string;
error?: string;
}
export default function ConfirmDialog({
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
isLoading = false,
isDanger = false,
warning,
error,
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
</div>
</div>
)}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText}