Boarding, payment methods, journey direction on seat hold, and more updates

This commit is contained in:
Stephanos A
2026-06-29 08:44:38 +03:00
parent 81ae99cee3
commit c6e56d1c4f
65 changed files with 6437 additions and 1425 deletions

View File

@@ -10,6 +10,7 @@ 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';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -277,6 +278,16 @@ export class BookingsService {
return {
items: items.map(booking => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
// Build passenger list with categories
const passengerDetails = booking.seats.map((s: any) => ({
name: s.passengerName,
category: s.passengerCategory // 'ADULT' or 'CHILD'
}));
// Get unique names with their categories
const uniquePassengers = Array.from(
new Map(passengerDetails.map(p => [p.name, p])).values()
);
return {
id: booking.id,
bookingRef: booking.bookingRef,
@@ -296,6 +307,7 @@ export class BookingsService {
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers, // Include category info
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
@@ -359,6 +371,24 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
}
// Track which child gets free fare (first child encountered)
let freeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor;
} else {
// Child: first child is free, subsequent children pay full fare
if (!freeChildUsed) {
fareMinor = 0;
freeChildUsed = true;
} else {
fareMinor = fareCalculation.baseFareMinor;
}
}
return { ...p, fareMinor };
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -372,7 +402,7 @@ export class BookingsService {
displayCurrency,
displayTotalMinor,
seats: {
create: passengersData.map(p => ({
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
@@ -382,7 +412,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
fareMinor: p.fareMinor,
displayCurrency
}))
}
@@ -462,6 +492,37 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
// Track which child gets free fare for outbound and return legs
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor;
} else {
// Child fare for outbound
if (!outboundFreeChildUsed) {
outboundFareMinor = 0;
outboundFreeChildUsed = true;
} else {
outboundFareMinor = outboundFare.baseFareMinor;
}
// Child fare for return
if (!returnFreeChildUsed) {
returnFareMinor = 0;
returnFreeChildUsed = true;
} else {
returnFareMinor = returnFare.baseFareMinor;
}
}
return { ...p, outboundFareMinor, returnFareMinor };
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -482,7 +543,7 @@ export class BookingsService {
returnLegStatus: 'NEITHER_USED',
seats: {
create: [
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -494,10 +555,10 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
fareMinor: p.outboundFareMinor,
displayCurrency,
})),
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
@@ -509,7 +570,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
@@ -606,6 +667,37 @@ export class BookingsService {
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Track which child gets free fare for leg1 and leg2
let leg1FreeChildUsed = false;
let leg2FreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let leg1FareMinor: number;
let leg2FareMinor: number;
if (p.category === PassengerCategory.ADULT) {
leg1FareMinor = leg1Fare.baseFareMinor;
leg2FareMinor = leg2Fare.baseFareMinor;
} else {
// Child fare for leg1
if (!leg1FreeChildUsed) {
leg1FareMinor = 0;
leg1FreeChildUsed = true;
} else {
leg1FareMinor = leg1Fare.baseFareMinor;
}
// Child fare for leg2
if (!leg2FreeChildUsed) {
leg2FareMinor = 0;
leg2FreeChildUsed = true;
} else {
leg2FareMinor = leg2Fare.baseFareMinor;
}
}
return { ...p, leg1FareMinor, leg2FareMinor };
});
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
@@ -625,7 +717,7 @@ export class BookingsService {
leg2SeatClassId,
seats: {
create: [
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -637,10 +729,10 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
fareMinor: p.leg1FareMinor,
displayCurrency,
})),
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
leg: 2,
scheduleId: dto.leg2ScheduleId,
@@ -652,7 +744,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
fareMinor: p.leg2FareMinor,
displayCurrency,
})),
],
@@ -769,7 +861,32 @@ export class BookingsService {
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited<ReturnType<BookingsService['calculateFare']>>) => ({
// Track which child gets free fare for all 4 legs
let obL1FreeChildUsed = false;
let obL2FreeChildUsed = false;
let retL1FreeChildUsed = false;
let retL2FreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let obL1FareMinor: number, obL2FareMinor: number, retL1FareMinor: number, retL2FareMinor: number;
if (p.category === PassengerCategory.ADULT) {
obL1FareMinor = obL1Fare.baseFareMinor;
obL2FareMinor = obL2Fare.baseFareMinor;
retL1FareMinor = retL1Fare.baseFareMinor;
retL2FareMinor = retL2Fare.baseFareMinor;
} else {
// Child fares for each leg
obL1FareMinor = !obL1FreeChildUsed ? (obL1FreeChildUsed = true, 0) : obL1Fare.baseFareMinor;
obL2FareMinor = !obL2FreeChildUsed ? (obL2FreeChildUsed = true, 0) : obL2Fare.baseFareMinor;
retL1FareMinor = !retL1FreeChildUsed ? (retL1FreeChildUsed = true, 0) : retL1Fare.baseFareMinor;
retL2FareMinor = !retL2FreeChildUsed ? (retL2FreeChildUsed = true, 0) : retL2Fare.baseFareMinor;
}
return { ...p, obL1FareMinor, obL2FareMinor, retL1FareMinor, retL2FareMinor };
});
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fareMinor: number) => ({
seat: { connect: { id: seatId } },
leg,
scheduleId,
@@ -781,7 +898,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
fareMinor,
displayCurrency,
});
@@ -811,13 +928,13 @@ export class BookingsService {
seats: {
create: [
// Outbound leg-1 (sequence 1)
...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
...passengersWithFares.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, p.obL1FareMinor)),
// Outbound leg-2 (sequence 2)
...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
...passengersWithFares.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, p.obL2FareMinor)),
// Return leg-1 (sequence 3)
...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
...passengersWithFares.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, p.retL1FareMinor)),
// Return leg-2 (sequence 4)
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
...passengersWithFares.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, p.retL2FareMinor)),
],
},
} as any,
@@ -1060,7 +1177,7 @@ export class BookingsService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, ticket: true,
paymentIntent: true, tickets: { take: 1 },
},
});
if (!booking) throw new NotFoundException('Booking not found');
@@ -1077,14 +1194,14 @@ export class BookingsService {
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
schedule: {
id: booking.schedule.id,
trainNumber: booking.schedule.train.number,
trainName: booking.schedule.train.name,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
passengers: booking.seats?.map((bs: any) => ({
passengers: (booking as any).seats?.map((bs: any) => ({
fullName: bs.passengerName,
category: bs.passengerCategory,
leg: bs.leg ?? 1,
@@ -1098,8 +1215,8 @@ export class BookingsService {
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
},
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
};
}
@@ -1153,6 +1270,12 @@ export class BookingsService {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
// Check usage before allowing deletion
const usage = await this.checkBookingUsage(id);
if (usage.isInUse && usage.constraints) {
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
}
await this.seatsService.releaseSeats(booking.id);
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
@@ -1172,15 +1295,16 @@ export class BookingsService {
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
]);
const usage = [];
if (ticketCount > 0) usage.push('Ticket(s)');
if (paymentIntentCount > 0) usage.push('Payment record(s)');
if (modificationsCount > 0) usage.push('Modification history');
if (cancellationCount > 0) usage.push('Cancellation record(s)');
const constraints = [];
if (ticketCount > 0) constraints.push({ entityName: 'ticket', count: ticketCount, action: 'complete' as const });
if (paymentIntentCount > 0) constraints.push({ entityName: 'payment record', count: paymentIntentCount, action: 'complete' as const });
if (modificationsCount > 0) constraints.push({ entityName: 'modification record', count: modificationsCount, action: 'complete' as const });
if (cancellationCount > 0) constraints.push({ entityName: 'cancellation record', count: cancellationCount, action: 'complete' as const });
return {
isInUse: usage.length > 0,
affectedModules: usage,
isInUse: constraints.length > 0,
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
constraints
};
}

View File

@@ -231,6 +231,9 @@ export class GuestBookingService {
},
});
// Save passenger details as traveler profiles
await this.createTravelerProfiles(guestPassengerId, passengersData);
// Confirm seats
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId));
this.eventEmitter.emit('booking.created', { booking });
@@ -443,6 +446,8 @@ export class GuestBookingService {
},
});
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds),
@@ -635,6 +640,8 @@ export class GuestBookingService {
},
});
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
@@ -822,6 +829,8 @@ export class GuestBookingService {
},
});
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
@@ -870,12 +879,44 @@ export class GuestBookingService {
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
}
// Create guest passenger with basic profile
const guestPassenger = await this.prisma.passenger.create({ data: {} });
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 { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
}
private async createTravelerProfiles(passengerId: string, passengersData: any[]): Promise<void> {
for (const passenger of passengersData) {
let gender: string | null = null;
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
}
await this.prisma.travelerProfile.create({
data: {
passengerId,
fullName: passenger.passengerName,
gender,
dateOfBirth: passenger.dateOfBirth,
nationalId: passenger.idDocumentType === IdDocumentType.NATIONAL_ID ? passenger.idDocumentNumber : null,
relationship: 'self',
notes: JSON.stringify({
idDocumentType: passenger.idDocumentType,
idDocumentNumber: passenger.idDocumentNumber,
passportNumber: passenger.passportNumber,
passportCountry: passenger.passportCountry,
nationality: passenger.nationality,
phone: passenger.phone,
email: passenger.email,
verifaydaVerified: passenger.verifaydaVerified,
}),
},
});
}
}
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');

View File

@@ -0,0 +1,235 @@
import { Body, Controller, Get, Post, Put, Delete, Param, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { ConfigurableFareService } from './configurable-fare.service';
import {
CreateFareConfigurationDto,
UpdateFareConfigurationDto,
FareTestScenarioDto,
FareCalculationResultDto,
MigrateLegacyDto,
CreateNewFormulaDto,
ToggleFeatureDto
} from './configurable-fare.dto';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
@ApiTags('Configurable Fares')
@Controller('admin/fare-configurations')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class ConfigurableFareController {
constructor(private service: ConfigurableFareService) {}
@Get()
@ApiOperation({ summary: 'List all fare configurations' })
@ApiResponse({ status: 200, description: 'List of all configurations with summary counts' })
async getAllConfigurations() {
return this.service.getAllConfigurations();
}
@Post()
@ApiOperation({ summary: 'Create new fare configuration' })
@ApiResponse({ status: 201, description: 'Configuration created successfully' })
@ApiResponse({ status: 400, description: 'Invalid configuration data' })
async createConfiguration(
@Body() dto: CreateFareConfigurationDto,
@Request() req: any
) {
const createdBy = req.user?.id || req.user?.sub;
return this.service.createConfiguration(dto, createdBy);
}
@Get(':id')
@ApiOperation({ summary: 'Get configuration details' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration details with all rules' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async getConfigurationById(@Param('id') id: string) {
return this.service.getConfigurationById(id);
}
@Put(':id')
@ApiOperation({ summary: 'Update configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration updated successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async updateConfiguration(
@Param('id') id: string,
@Body() dto: UpdateFareConfigurationDto,
@Request() req: any
) {
const updatedBy = req.user?.id || req.user?.sub;
return this.service.updateConfiguration(id, dto, updatedBy);
}
@Post(':id/activate')
@ApiOperation({ summary: 'Activate configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration activated successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async activateConfiguration(@Param('id') id: string, @Request() req: any) {
const activatedBy = req.user?.id || req.user?.sub;
return this.service.activateConfiguration(id, activatedBy);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration deleted successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
@ApiResponse({ status: 409, description: 'Cannot delete active configuration' })
async deleteConfiguration(@Param('id') id: string, @Request() req: any) {
const deletedBy = req.user?.id || req.user?.sub;
return this.service.deleteConfiguration(id, deletedBy);
}
@Post(':id/test')
@ApiOperation({ summary: 'Test fare calculation with configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, type: FareCalculationResultDto, description: 'Fare calculation result' })
@ApiResponse({ status: 400, description: 'Invalid test scenario or missing rate rules' })
async testConfiguration(
@Param('id') id: string,
@Body() scenario: FareTestScenarioDto
): Promise<FareCalculationResultDto> {
return this.service.testConfiguration(id, scenario);
}
@Get(':id/audit')
@ApiOperation({ summary: 'Get configuration audit trail' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Audit trail entries' })
async getAuditTrail(@Param('id') id: string) {
return this.service.getAuditTrail(id);
}
}
@ApiTags('Configurable Fares')
@Controller('admin/fare-migration')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class FareMigrationController {
constructor(private service: ConfigurableFareService) {}
@Post('migrate-legacy')
@ApiOperation({ summary: 'Migrate existing fare rules to configurable system' })
@ApiResponse({ status: 200, description: 'Migration completed successfully' })
@ApiResponse({ status: 400, description: 'Migration failed' })
async migrateLegacySystem(@Body() dto: MigrateLegacyDto) {
return this.service.migrateLegacySystem(dto);
}
@Post('create-new-formula')
@ApiOperation({ summary: 'Create new formula configuration with defaults' })
@ApiResponse({ status: 201, description: 'New formula configuration created' })
async createNewFormulaConfiguration(@Body() dto: CreateNewFormulaDto) {
return this.service.createNewFormulaConfiguration(dto);
}
@Post('complete-setup')
@ApiOperation({
summary: 'Complete system setup (migrate + create + activate)',
description: 'Performs full system migration and setup in one operation'
})
@ApiResponse({ status: 200, description: 'System setup completed successfully' })
async completeSetup(@Body() body: { activateNewFormula?: boolean; enableFeature?: boolean }) {
// Step 1: Migrate legacy system
const migrationResult = await this.service.migrateLegacySystem({ dryRun: false });
// Step 2: Create new formula configuration
const newConfig = await this.service.createNewFormulaConfiguration({
name: 'Default System Configuration',
description: 'System-generated configuration with optimal defaults',
activateImmediately: body.activateNewFormula !== false
});
// Step 3: Enable feature flag if requested
if (body.enableFeature) {
await this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: true,
config: { rollout_percentage: 100 }
});
}
return {
migration: migrationResult,
newConfiguration: newConfig,
featureEnabled: body.enableFeature || false,
message: 'System setup completed successfully'
};
}
@Get('status')
@ApiOperation({ summary: 'Get migration and setup status' })
@ApiResponse({ status: 200, description: 'Current system status' })
async getStatus() {
const featureStatus = await this.service.getFeatureStatus('USE_CONFIGURABLE_FARES');
const configurations = await this.service.getAllConfigurations();
const activeConfig = (configurations as any[]).find(config => config.is_active);
return {
configurableFaresEnabled: featureStatus.enabled,
rolloutPercentage: featureStatus.config?.rollout_percentage || 0,
totalConfigurations: (configurations as any[]).length,
activeConfiguration: activeConfig?.id || null,
activeConfigurationName: activeConfig?.name || null,
systemReady: featureStatus.enabled && !!activeConfig
};
}
}
@ApiTags('Configurable Fares')
@Controller('admin/fare-configurations/system')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class FareSystemController {
constructor(private service: ConfigurableFareService) {}
@Get('feature-status')
@ApiOperation({ summary: 'Check configurable fares feature status' })
@ApiQuery({ name: 'feature', required: false, description: 'Feature name (defaults to USE_CONFIGURABLE_FARES)' })
@ApiResponse({ status: 200, description: 'Feature status retrieved' })
async getFeatureStatus(@Query('feature') featureName = 'USE_CONFIGURABLE_FARES') {
return this.service.getFeatureStatus(featureName);
}
@Post('toggle-feature')
@ApiOperation({ summary: 'Enable or disable configurable fares system' })
@ApiResponse({ status: 200, description: 'Feature toggled successfully' })
async toggleFeature(@Body() dto: ToggleFeatureDto) {
return this.service.toggleFeature(dto);
}
@Post('enable-configurable-fares')
@ApiOperation({
summary: 'Enable configurable fares with rollout percentage',
description: 'Quick endpoint to enable the configurable fares feature'
})
@ApiResponse({ status: 200, description: 'Configurable fares enabled successfully' })
async enableConfigurableFares(@Body() body: { rolloutPercentage?: number }) {
return this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: true,
config: { rollout_percentage: body.rolloutPercentage || 100 }
});
}
@Post('disable-configurable-fares')
@ApiOperation({
summary: 'Disable configurable fares (fallback to legacy system)',
description: 'Disables the configurable fares feature and falls back to legacy fare calculation'
})
@ApiResponse({ status: 200, description: 'Configurable fares disabled successfully' })
async disableConfigurableFares() {
return this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: false,
config: { rollout_percentage: 0 }
});
}
}

View File

@@ -0,0 +1,362 @@
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, ValidateNested, IsDateString, IsEnum, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NationalityType {
LOCAL = 'LOCAL',
INTERNATIONAL = 'INTERNATIONAL'
}
export enum CoachType {
REGULAR_SEAT = 'REGULAR_SEAT',
ECONOMY_BED = 'ECONOMY_BED',
VIP_BED = 'VIP_BED'
}
export enum BedPosition {
UPPER = 'UPPER',
MIDDLE = 'MIDDLE',
LOWER = 'LOWER'
}
export enum ComponentType {
INSURANCE = 'INSURANCE',
PREMIUM = 'PREMIUM',
SERVICE_CHARGE = 'SERVICE_CHARGE',
TAX = 'TAX',
DEMAND = 'DEMAND'
}
export enum CalculationMethod {
MULTIPLIER = 'MULTIPLIER',
PERCENTAGE = 'PERCENTAGE',
FIXED_AMOUNT = 'FIXED_AMOUNT'
}
export enum AppliesTo {
BASE_FARE = 'BASE_FARE',
SUBTOTAL = 'SUBTOTAL',
TOTAL = 'TOTAL'
}
export enum PricingType {
FREE = 'FREE',
FULL_FARE = 'FULL_FARE',
DISCOUNTED = 'DISCOUNTED'
}
export class FareRateRuleDto {
@ApiProperty({ enum: NationalityType })
@IsEnum(NationalityType)
nationalityType: NationalityType;
@ApiProperty({ enum: CoachType })
@IsEnum(CoachType)
coachType: CoachType;
@ApiPropertyOptional({ enum: BedPosition })
@IsOptional()
@IsEnum(BedPosition)
bedPosition?: BedPosition;
@ApiProperty({ example: 3000, description: 'Rate per km in minor units (e.g., 30.00 ETB = 3000)' })
@IsInt()
@Min(0)
ratePerKmMinor: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class FareComponentDto {
@ApiProperty({ enum: ComponentType })
@IsEnum(ComponentType)
componentType: ComponentType;
@ApiProperty({ example: 'Travel Insurance' })
@IsString()
componentName: string;
@ApiProperty({ enum: CalculationMethod })
@IsEnum(CalculationMethod)
calculationMethod: CalculationMethod;
@ApiPropertyOptional({ example: 500, description: 'Fixed amount in minor units' })
@IsOptional()
@IsInt()
@Min(0)
valueMinor?: number;
@ApiPropertyOptional({ example: 0.02, description: 'Percentage value (e.g., 0.02 for 2%)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
percentageValue?: number;
@ApiProperty({ enum: AppliesTo, default: AppliesTo.SUBTOTAL })
@IsEnum(AppliesTo)
appliesTo: AppliesTo;
@ApiProperty({ example: 1, description: 'Order of application' })
@IsInt()
@Min(1)
applyOrder: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class AgePricingRuleDto {
@ApiProperty({ example: 'Adult Passengers' })
@IsString()
ruleName: string;
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
minAge: number;
@ApiPropertyOptional({ example: 120 })
@IsOptional()
@IsInt()
@Min(0)
maxAge?: number;
@ApiProperty({ enum: PricingType })
@IsEnum(PricingType)
pricingType: PricingType;
@ApiPropertyOptional({ example: 0.5, description: 'Discount percentage for DISCOUNTED type' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
discountPercentage?: number;
@ApiPropertyOptional({ example: 1, description: 'Max free passengers for FREE type' })
@IsOptional()
@IsInt()
@Min(0)
maxFreePassengers?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
appliesToComponents?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateFareConfigurationDto {
@ApiProperty({ example: 'Summer 2024 Rates' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'Updated rates for summer season' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: '2024-06-01T00:00:00.000Z' })
@IsDateString()
effectiveDate: string;
@ApiPropertyOptional({ example: '2024-08-31T23:59:59.000Z' })
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiProperty({ type: [FareRateRuleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareRateRuleDto)
rateRules: FareRateRuleDto[];
@ApiProperty({ type: [FareComponentDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareComponentDto)
components: FareComponentDto[];
@ApiProperty({ type: [AgePricingRuleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AgePricingRuleDto)
ageRules: AgePricingRuleDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
export class UpdateFareConfigurationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
effectiveDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiPropertyOptional({ type: [FareRateRuleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareRateRuleDto)
rateRules?: FareRateRuleDto[];
@ApiPropertyOptional({ type: [FareComponentDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareComponentDto)
components?: FareComponentDto[];
@ApiPropertyOptional({ type: [AgePricingRuleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AgePricingRuleDto)
ageRules?: AgePricingRuleDto[];
}
export class FareTestScenarioDto {
@ApiProperty({ example: 100 })
@IsInt()
@Min(1)
distanceKm: number;
@ApiProperty({ example: 'Ethiopian' })
@IsString()
nationality: string;
@ApiProperty({ enum: CoachType })
@IsEnum(CoachType)
coachType: CoachType;
@ApiPropertyOptional({ enum: BedPosition })
@IsOptional()
@IsEnum(BedPosition)
bedPosition?: BedPosition;
@ApiProperty({ example: 2, default: 1 })
@IsInt()
@Min(1)
adultCount: number;
@ApiPropertyOptional({ example: 1, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
childCount?: number;
@ApiPropertyOptional({ example: 'SUMMER20' })
@IsOptional()
@IsString()
promoCode?: string;
@ApiPropertyOptional({ example: 500 })
@IsOptional()
@IsInt()
@Min(0)
loyaltyPoints?: number;
}
export class FareCalculationResultDto {
@ApiProperty()
baseFareMinor: number;
@ApiProperty()
componentsTotal: number;
@ApiProperty()
totalBeforeDiscounts: number;
@ApiProperty()
discountsTotal: number;
@ApiProperty()
finalTotalMinor: number;
@ApiProperty()
breakdown: Array<{
step: string;
description: string;
amount: number;
runningTotal: number;
}>;
@ApiProperty()
currency: string;
@ApiProperty()
calculationTimestamp: Date;
}
export class MigrateLegacyDto {
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
dryRun?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
migrateScheduleFares?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
migrateSegmentFares?: boolean;
}
export class CreateNewFormulaDto {
@ApiProperty({ example: 'Default Formula Configuration' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'System-generated default configuration' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
activateImmediately?: boolean;
}
export class ToggleFeatureDto {
@ApiProperty({ example: 'USE_CONFIGURABLE_FARES' })
@IsString()
featureName: string;
@ApiProperty()
@IsBoolean()
enabled: boolean;
@ApiPropertyOptional({ example: { rollout_percentage: 50 } })
@IsOptional()
config?: Record<string, any>;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigurableFareService } from './configurable-fare.service';
import { ConfigurableFareController, FareMigrationController, FareSystemController } from './configurable-fare.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ConfigurableFareController, FareMigrationController, FareSystemController],
providers: [ConfigurableFareService],
exports: [ConfigurableFareService],
})
export class ConfigurableFareModule {}

View File

@@ -0,0 +1,623 @@
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import {
CreateFareConfigurationDto,
UpdateFareConfigurationDto,
FareTestScenarioDto,
FareCalculationResultDto,
MigrateLegacyDto,
CreateNewFormulaDto,
ToggleFeatureDto,
NationalityType,
CoachType,
BedPosition,
ComponentType,
CalculationMethod,
AppliesTo,
PricingType
} from './configurable-fare.dto';
@Injectable()
export class ConfigurableFareService {
constructor(private prisma: PrismaService) {}
// Helper method to map nationality to type
private mapNationalityToType(nationality: string): NationalityType {
const upperNationality = nationality.toUpperCase();
if (upperNationality === 'ETHIOPIAN' || upperNationality === 'DJIBOUTIAN') {
return NationalityType.LOCAL;
}
return NationalityType.INTERNATIONAL;
}
// Generate unique IDs
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Configuration Management
async getAllConfigurations() {
return this.prisma.$queryRaw`
SELECT
fc.*,
COUNT(DISTINCT frr.id) as rate_rules_count,
COUNT(DISTINCT fcmp.id) as components_count,
COUNT(DISTINCT apr.id) as age_rules_count
FROM fare_configurations fc
LEFT JOIN fare_rate_rules frr ON fc.id = frr.fare_config_id AND frr.is_active = true
LEFT JOIN fare_components fcmp ON fc.id = fcmp.fare_config_id AND fcmp.is_active = true
LEFT JOIN age_pricing_rules apr ON fc.id = apr.fare_config_id AND apr.is_active = true
GROUP BY fc.id, fc.name, fc.description, fc.effective_date, fc.expiry_date,
fc.is_active, fc.is_default, fc.created_by, fc.approved_by,
fc.approved_at, fc.created_at, fc.updated_at
ORDER BY fc.created_at DESC
`;
}
async getConfigurationById(id: string) {
const config = await this.prisma.$queryRaw`
SELECT * FROM fare_configurations WHERE id = ${id}
`;
if (!Array.isArray(config) || config.length === 0) {
throw new NotFoundException(`Fare configuration ${id} not found`);
}
const rateRules = await this.prisma.$queryRaw`
SELECT * FROM fare_rate_rules WHERE fare_config_id = ${id} ORDER BY nationality_type, coach_type, bed_position
`;
const components = await this.prisma.$queryRaw`
SELECT * FROM fare_components WHERE fare_config_id = ${id} ORDER BY apply_order
`;
const ageRules = await this.prisma.$queryRaw`
SELECT * FROM age_pricing_rules WHERE fare_config_id = ${id} ORDER BY min_age
`;
return {
...config[0],
rateRules,
components,
ageRules
};
}
async createConfiguration(dto: CreateFareConfigurationDto, createdBy?: string) {
return this.prisma.$transaction(async (tx) => {
const configId = this.generateId('fc');
// Validate no overlapping active configurations
if (dto.isDefault) {
await tx.$executeRaw`
UPDATE fare_configurations SET is_default = false WHERE is_default = true
`;
}
// Create main configuration
await tx.$executeRaw`
INSERT INTO fare_configurations (
id, name, description, effective_date, expiry_date,
is_active, is_default, created_by, created_at, updated_at
) VALUES (
${configId}, ${dto.name}, ${dto.description}, ${dto.effectiveDate},
${dto.expiryDate}, false, ${dto.isDefault || false}, ${createdBy},
NOW(), NOW()
)
`;
// Create rate rules
for (const rule of dto.rateRules) {
const ruleId = this.generateId('frr');
await tx.$executeRaw`
INSERT INTO fare_rate_rules (
id, fare_config_id, nationality_type, coach_type, bed_position,
rate_per_km_minor, is_active, created_at, updated_at
) VALUES (
${ruleId}, ${configId}, ${rule.nationalityType}, ${rule.coachType},
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
NOW(), NOW()
)
`;
}
// Create components
for (const component of dto.components) {
const componentId = this.generateId('fcmp');
await tx.$executeRaw`
INSERT INTO fare_components (
id, fare_config_id, component_type, component_name, calculation_method,
value_minor, percentage_value, applies_to, apply_order, is_active,
created_at, updated_at
) VALUES (
${componentId}, ${configId}, ${component.componentType}, ${component.componentName},
${component.calculationMethod}, ${component.valueMinor || null},
${component.percentageValue || null}, ${component.appliesTo},
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
)
`;
}
// Create age rules
for (const ageRule of dto.ageRules) {
const ageRuleId = this.generateId('apr');
await tx.$executeRaw`
INSERT INTO age_pricing_rules (
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
discount_percentage, max_free_passengers, applies_to_components,
is_active, created_at, updated_at
) VALUES (
${ageRuleId}, ${configId}, ${ageRule.ruleName}, ${ageRule.minAge},
${ageRule.maxAge || null}, ${ageRule.pricingType},
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
NOW(), NOW()
)
`;
}
// Log audit entry
await this.createAuditEntry(tx, configId, 'CREATED', createdBy, { action: 'Configuration created' });
return this.getConfigurationById(configId);
});
}
async updateConfiguration(id: string, dto: UpdateFareConfigurationDto, updatedBy?: string) {
await this.getConfigurationById(id); // Validate exists
return this.prisma.$transaction(async (tx) => {
// Update main configuration
if (dto.name || dto.description !== undefined || dto.effectiveDate || dto.expiryDate !== undefined) {
await tx.$executeRaw`
UPDATE fare_configurations
SET
name = COALESCE(${dto.name}, name),
description = COALESCE(${dto.description}, description),
effective_date = COALESCE(${dto.effectiveDate}, effective_date),
expiry_date = COALESCE(${dto.expiryDate}, expiry_date),
updated_at = NOW()
WHERE id = ${id}
`;
}
// Update rate rules if provided
if (dto.rateRules) {
await tx.$executeRaw`DELETE FROM fare_rate_rules WHERE fare_config_id = ${id}`;
for (const rule of dto.rateRules) {
const ruleId = this.generateId('frr');
await tx.$executeRaw`
INSERT INTO fare_rate_rules (
id, fare_config_id, nationality_type, coach_type, bed_position,
rate_per_km_minor, is_active, created_at, updated_at
) VALUES (
${ruleId}, ${id}, ${rule.nationalityType}, ${rule.coachType},
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
NOW(), NOW()
)
`;
}
}
// Update components if provided
if (dto.components) {
await tx.$executeRaw`DELETE FROM fare_components WHERE fare_config_id = ${id}`;
for (const component of dto.components) {
const componentId = this.generateId('fcmp');
await tx.$executeRaw`
INSERT INTO fare_components (
id, fare_config_id, component_type, component_name, calculation_method,
value_minor, percentage_value, applies_to, apply_order, is_active,
created_at, updated_at
) VALUES (
${componentId}, ${id}, ${component.componentType}, ${component.componentName},
${component.calculationMethod}, ${component.valueMinor || null},
${component.percentageValue || null}, ${component.appliesTo},
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
)
`;
}
}
// Update age rules if provided
if (dto.ageRules) {
await tx.$executeRaw`DELETE FROM age_pricing_rules WHERE fare_config_id = ${id}`;
for (const ageRule of dto.ageRules) {
const ageRuleId = this.generateId('apr');
await tx.$executeRaw`
INSERT INTO age_pricing_rules (
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
discount_percentage, max_free_passengers, applies_to_components,
is_active, created_at, updated_at
) VALUES (
${ageRuleId}, ${id}, ${ageRule.ruleName}, ${ageRule.minAge},
${ageRule.maxAge || null}, ${ageRule.pricingType},
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
NOW(), NOW()
)
`;
}
}
await this.createAuditEntry(tx, id, 'UPDATED', updatedBy, { changes: dto });
return this.getConfigurationById(id);
});
}
async activateConfiguration(id: string, activatedBy?: string) {
return this.prisma.$transaction(async (tx) => {
// Deactivate all other configurations
await tx.$executeRaw`UPDATE fare_configurations SET is_active = false`;
// Activate this one
await tx.$executeRaw`
UPDATE fare_configurations
SET is_active = true, approved_by = ${activatedBy}, approved_at = NOW()
WHERE id = ${id}
`;
await this.createAuditEntry(tx, id, 'ACTIVATED', activatedBy, {});
return { success: true, message: `Configuration ${id} activated successfully` };
});
}
async deleteConfiguration(id: string, deletedBy?: string) {
const config = await this.getConfigurationById(id);
if ((config as any).is_active) {
throw new ConflictException('Cannot delete active configuration. Deactivate first.');
}
await this.prisma.$executeRaw`DELETE FROM fare_configurations WHERE id = ${id}`;
return { success: true, message: `Configuration ${id} deleted successfully` };
}
// Fare Calculation
async testConfiguration(id: string, scenario: FareTestScenarioDto): Promise<FareCalculationResultDto> {
const config = await this.getConfigurationById(id);
// Find matching rate rule
const nationalityType = this.mapNationalityToType(scenario.nationality);
const rateRule = (config.rateRules as any[]).find((rule: any) =>
rule.nationality_type === nationalityType &&
rule.coach_type === scenario.coachType &&
(scenario.bedPosition ? rule.bed_position === scenario.bedPosition : !rule.bed_position)
);
if (!rateRule) {
throw new BadRequestException(`No rate rule found for ${nationalityType}/${scenario.coachType}${scenario.bedPosition ? `/${scenario.bedPosition}` : ''}`);
}
// Calculate base fare
const baseFareMinor = scenario.distanceKm * rateRule.rate_per_km_minor;
const breakdown = [
{
step: '1',
description: `Base fare: ${scenario.distanceKm}km × ${rateRule.rate_per_km_minor} minor units/km`,
amount: baseFareMinor,
runningTotal: baseFareMinor
}
];
let runningTotal = baseFareMinor;
// Apply age-based pricing
const { adultCount = 1, childCount = 0 } = scenario;
let totalPassengerFare = 0;
// Process adults
totalPassengerFare += adultCount * baseFareMinor;
breakdown.push({
step: '2a',
description: `Adult passengers: ${adultCount} × ${baseFareMinor}`,
amount: adultCount * baseFareMinor,
runningTotal: adultCount * baseFareMinor
});
// Process children with age rules
if (childCount > 0) {
const childRule = (config.ageRules as any[]).find((rule: any) =>
rule.pricing_type === PricingType.FREE && rule.max_free_passengers > 0
);
if (childRule) {
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
const paidChildren = Math.max(0, childCount - freeChildren);
if (freeChildren > 0) {
breakdown.push({
step: '2b',
description: `Free children: ${freeChildren} × 0 (first ${childRule.max_free_passengers} free)`,
amount: 0,
runningTotal: totalPassengerFare
});
}
if (paidChildren > 0) {
const paidChildrenFare = paidChildren * baseFareMinor;
totalPassengerFare += paidChildrenFare;
breakdown.push({
step: '2c',
description: `Paid children: ${paidChildren} × ${baseFareMinor}`,
amount: paidChildrenFare,
runningTotal: totalPassengerFare
});
}
} else {
// All children pay
const childrenFare = childCount * baseFareMinor;
totalPassengerFare += childrenFare;
breakdown.push({
step: '2b',
description: `Child passengers: ${childCount} × ${baseFareMinor}`,
amount: childrenFare,
runningTotal: totalPassengerFare
});
}
}
runningTotal = totalPassengerFare;
// Apply components in order
let componentsTotal = 0;
const components = (config.components as any[])
.filter((c: any) => c.is_active)
.sort((a: any, b: any) => a.apply_order - b.apply_order);
for (const component of components) {
let componentAmount = 0;
let baseAmount = runningTotal;
if (component.applies_to === 'BASE_FARE') {
baseAmount = baseFareMinor;
} else if (component.applies_to === 'SUBTOTAL') {
baseAmount = runningTotal;
}
switch (component.calculation_method) {
case 'PERCENTAGE':
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
break;
case 'MULTIPLIER':
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
break;
case 'FIXED_AMOUNT':
componentAmount = component.value_minor || 0;
break;
}
componentsTotal += componentAmount;
runningTotal += componentAmount;
breakdown.push({
step: `3${String.fromCharCode(97 + component.apply_order - 1)}`,
description: `${component.component_name}: ${component.calculation_method} on ${component.applies_to}`,
amount: componentAmount,
runningTotal
});
}
return {
baseFareMinor,
componentsTotal,
totalBeforeDiscounts: runningTotal,
discountsTotal: 0, // TODO: Implement promo code discounts
finalTotalMinor: runningTotal,
breakdown,
currency: 'ETB',
calculationTimestamp: new Date()
};
}
// Migration Methods
async migrateLegacySystem(dto: MigrateLegacyDto) {
const results = {
scheduleFareRules: 0,
segmentFareRules: 0,
configurationsCreated: 0,
dryRun: dto.dryRun || false
};
if (dto.dryRun) {
// Count what would be migrated
const scheduleFares = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM "FareRule" WHERE migrated_to_config_id IS NULL
`;
const segmentFares = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM "SegmentFareRule" WHERE migrated_to_config_id IS NULL
`;
results.scheduleFareRules = Number((scheduleFares as any[])[0]?.count || 0);
results.segmentFareRules = Number((segmentFares as any[])[0]?.count || 0);
return results;
}
// Create a migration configuration based on existing rules
const migrationConfig: CreateFareConfigurationDto = {
name: 'Legacy Migration Configuration',
description: 'Automatically migrated from existing fare rules',
effectiveDate: new Date().toISOString(),
rateRules: [
// Default rates based on current system
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
],
components: [
{
componentType: ComponentType.INSURANCE,
componentName: 'Travel Insurance',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.02,
appliesTo: AppliesTo.BASE_FARE,
applyOrder: 1
},
{
componentType: ComponentType.TAX,
componentName: 'Government Tax',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.05,
appliesTo: AppliesTo.SUBTOTAL,
applyOrder: 2
}
],
ageRules: [
{
ruleName: 'Adult Passengers',
minAge: 5,
pricingType: PricingType.FULL_FARE
},
{
ruleName: 'Child Passengers (First Free)',
minAge: 0,
maxAge: 4,
pricingType: PricingType.FREE,
maxFreePassengers: 1
}
]
};
const newConfig = await this.createConfiguration(migrationConfig, 'system-migration');
results.configurationsCreated = 1;
return results;
}
async createNewFormulaConfiguration(dto: CreateNewFormulaDto) {
const config = await this.createConfiguration({
name: dto.name,
description: dto.description || 'System-generated default configuration',
effectiveDate: new Date().toISOString(),
rateRules: [
// Default rates for all combinations
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 8000 },
// International rates (2x local)
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 16000 },
],
components: [
{
componentType: ComponentType.INSURANCE,
componentName: 'Travel Insurance',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.02,
appliesTo: AppliesTo.BASE_FARE,
applyOrder: 1
},
{
componentType: ComponentType.SERVICE_CHARGE,
componentName: 'Service Charge',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.03,
appliesTo: AppliesTo.SUBTOTAL,
applyOrder: 2
},
{
componentType: ComponentType.TAX,
componentName: 'Government Tax',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.05,
appliesTo: AppliesTo.TOTAL,
applyOrder: 3
}
],
ageRules: [
{
ruleName: 'Adult Passengers',
minAge: 5,
pricingType: PricingType.FULL_FARE
},
{
ruleName: 'Child Passengers (First Free)',
minAge: 0,
maxAge: 4,
pricingType: PricingType.FREE,
maxFreePassengers: 1
}
],
isDefault: true
}, 'system');
if (dto.activateImmediately) {
await this.activateConfiguration((config as any).id, 'system');
}
return config;
}
// Feature Management
async toggleFeature(dto: ToggleFeatureDto) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
INSERT INTO system_features (id, feature_name, is_enabled, config, created_at, updated_at)
VALUES (${this.generateId('sf')}, ${dto.featureName}, ${dto.enabled},
${JSON.stringify(dto.config || {})}, NOW(), NOW())
ON CONFLICT (feature_name) DO UPDATE SET
is_enabled = ${dto.enabled},
config = ${JSON.stringify(dto.config || {})},
updated_at = NOW()
`;
return { success: true, message: `Feature ${dto.featureName} ${dto.enabled ? 'enabled' : 'disabled'}` };
});
}
async getFeatureStatus(featureName: string) {
const result = await this.prisma.$queryRaw`
SELECT * FROM system_features WHERE feature_name = ${featureName}
`;
if (!Array.isArray(result) || result.length === 0) {
return { enabled: false, config: {} };
}
const feature = result[0] as any;
return {
enabled: feature.is_enabled,
config: feature.config || {}
};
}
async getAuditTrail(configId: string) {
return this.prisma.$queryRaw`
SELECT * FROM fare_configuration_audit
WHERE fare_config_id = ${configId}
ORDER BY timestamp DESC
`;
}
// Private helper methods
private async createAuditEntry(tx: any, configId: string, action: string, changedBy?: string, changes?: any) {
const auditId = this.generateId('fca');
await tx.$executeRaw`
INSERT INTO fare_configuration_audit (
id, fare_config_id, action, changed_by, changes, timestamp
) VALUES (
${auditId}, ${configId}, ${action}, ${changedBy || 'system'},
${JSON.stringify(changes || {})}, NOW()
)
`;
}
}

View File

@@ -19,7 +19,7 @@ export class DashboardService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, liveStatus: true } },
seats: { include: { seat: { include: { coach: true } } }, take: 1 },
ticket: true,
tickets: { take: 1 },
},
orderBy: { createdAt: 'asc' },
}),
@@ -42,16 +42,16 @@ export class DashboardService {
const name = iamRows[0]?.name;
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
}
const seat = upcomingBooking?.seats[0];
const seat = (upcomingBooking as any)?.seats?.[0];
return {
user: { firstName, greetingKey },
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: upcomingBooking.schedule.departureAt,
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
ticketId: (upcomingBooking as any).tickets?.[0]?.id, bookingRef: upcomingBooking.bookingRef,
from: (upcomingBooking as any).schedule.originStation.name, to: (upcomingBooking as any).schedule.destinationStation.name,
trainName: (upcomingBooking as any).schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: (upcomingBooking as any).schedule.departureAt,
punctualityLabel: ((upcomingBooking as any).schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
activePromotionsCount: promos,

View File

@@ -91,6 +91,12 @@ export class ExcessBaggageAgentController {
deleteAllowance(@Param('id') id: string) {
return this.service.deleteAllowance(id);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
deleteCharge(@Param('id') id: string) {
return this.service.deleteCharge(id);
}
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────

View File

@@ -252,6 +252,15 @@ export class ExcessBaggageService {
};
}
// Mark expired charges before fetching
await this.prisma.excessBaggageCharge.updateMany({
where: {
status: 'PENDING',
expiresAt: { lt: new Date() },
},
data: { status: 'EXPIRED' },
});
const [items, total] = await Promise.all([
this.prisma.excessBaggageCharge.findMany({
where,
@@ -291,4 +300,12 @@ export class ExcessBaggageService {
await this.prisma.baggageAllowance.delete({ where: { id } });
return { deleted: true };
}
async deleteCharge(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
await this.prisma.excessBaggageCharge.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -28,14 +28,16 @@ export class FareEngineService {
if (originStop.sequence >= destStop.sequence)
throw new BadRequestException('Origin must come before destination in the route sequence');
const legStops = route.stops.filter(
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
);
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
// Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
throw new BadRequestException('Invalid distance calculation - check route stop distances');
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
const now = new Date();
const [originStation, destStation] = await Promise.all([
@@ -64,23 +66,15 @@ export class FareEngineService {
let baseFarePerPassengerMinor: number;
let ratePerKmMinor: number;
let totalDistanceKm: number;
let fareSource: string;
if (fareRule) {
// Flat fare from FareRule — distance is informational only
baseFarePerPassengerMinor = fareRule.baseFareMinor;
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
} else {
// Distance × rate fallback
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
if (missingDistance.length > 0)
throw new BadRequestException(
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
);
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
ratePerKmMinor = seatClass.baseFareMinor;
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
fareSource = 'DISTANCE_RATE';

View File

@@ -174,13 +174,13 @@ export class FleetController {
example: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
code: 'HSC',
name: 'Hard Seat Coach'
},
arrangement: '2+2',
capacity: 60,
@@ -215,13 +215,13 @@ export class FleetController {
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
code: 'HSC',
name: 'Hard Seat Coach'
},
arrangement: '2+2',
capacity: 60,
@@ -257,7 +257,7 @@ export class FleetController {
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',
@@ -283,7 +283,7 @@ export class FleetController {
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',

View File

@@ -11,7 +11,7 @@ export class CreateTrainDto {
}
export class CreateCoachDto {
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'HSC-0001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
@@ -53,22 +53,22 @@ export class ListCoachesDto {
// Legacy DTO types for backward compatibility
export class CreateCoachTypeDto {
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
@ApiProperty({ example: 'HSC' }) @IsString() code: string;
@ApiProperty({ example: 'Hard Seat Coach' }) @IsString() name: string;
@IsOptional() @IsString() type?: string;
}
export class UpdateCoachTypeDto {
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
@ApiPropertyOptional({ example: 'HSC' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Hard Seat Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'Regular Seat' }) @IsOptional() @IsString() type?: string;
}
export class CreateClassDto {
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
@ApiProperty({ example: 500 }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
@@ -78,7 +78,7 @@ export class UpdateClassDto {
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
function parseArrangement(arrangement: string): number[] {
@@ -41,7 +42,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
ECONOMY_BED: 6,
};
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
// Name-based fallback: checks if 'vip' is present for any bed coach type
function detectBedCategory(coachTypeName: string): BedCategory {
const name = coachTypeName.toLowerCase();
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
@@ -201,17 +202,25 @@ export class FleetService {
});
if (!coachType) throw new NotFoundException('Coach type not found');
// Check for related records
const constraints = [];
if (coachType.coaches.length > 0) {
throw new BadRequestException(
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
);
constraints.push({
entityName: 'coach',
count: coachType.coaches.length,
action: 'reassign' as const
});
}
if (coachType.seatClasses.length > 0) {
throw new BadRequestException(
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
);
constraints.push({
entityName: 'seat class',
count: coachType.seatClasses.length,
action: 'reassign' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
}
return this.prisma.coachType.delete({ where: { id } });
@@ -273,17 +282,19 @@ export class FleetService {
});
if (!seatClass) throw new NotFoundException('Seat class not found');
// Check for related records
const relatedRecords = [
...seatClass.fareRules,
...seatClass.routeFareRules,
...seatClass.segmentFares,
];
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
const constraints = [];
if (relatedRecords.length > 0) {
throw new BadRequestException(
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
);
if (totalFareRules > 0) {
constraints.push({
entityName: 'fare rule',
count: totalFareRules,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
}
return this.prisma.seatClass.delete({ where: { id } });
@@ -344,11 +355,20 @@ export class FleetService {
include: { schedules: true },
});
if (!train) throw new NotFoundException('Train not found');
const constraints = [];
if (train.schedules.length > 0) {
throw new BadRequestException(
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
);
constraints.push({
entityName: 'schedule',
count: train.schedules.length,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
}
return this.prisma.train.delete({ where: { id } });
}
@@ -460,45 +480,54 @@ export class FleetService {
include: {
bookingSeats: true,
blocks: true,
ticketSeats: true,
tickets: true,
},
},
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Check for active assignments
if (coach.assignments.length > 0) {
throw new BadRequestException(
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
);
const constraints = [];
if ((coach as any).assignments.length > 0) {
constraints.push({
entityName: 'schedule assignment',
count: (coach as any).assignments.length,
action: 'reassign' as const
});
}
// Check for booked seats
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
if (bookedSeats.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
);
constraints.push({
entityName: 'booked seat',
count: bookedSeats.length,
action: 'complete' as const
});
}
// Check for blocked seats
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
);
constraints.push({
entityName: 'blocked seat',
count: blockedSeats.length,
action: 'delete' as const
});
}
// Check for tickets
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
if (seatsWithTickets.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
);
constraints.push({
entityName: 'seat with issued ticket',
count: seatsWithTickets.length,
action: 'complete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach', coach.number, constraints);
}
// Delete related seats first (now safe to do)
await this.prisma.seat.deleteMany({ where: { coachId: id } });
return this.prisma.coach.delete({ where: { id } });

View File

@@ -280,7 +280,7 @@ export class NotificationsService {
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
});
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId } });
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const amount = this.formatAmount(booking ?? payload.booking);

View File

@@ -21,22 +21,152 @@ export class PassengersController {
@Get()
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'List all passengers with filters (Admin/Agent)',
description: 'Returns paginated list of passengers with search filters'
summary: 'List all travelers with filters (Admin/Agent)',
description: `**Returns paginated list of all travelers in the system**
---
### Data Source
- Fetches from **TravelerProfile** table (created during booking)
- Shows ALL passengers from ALL bookings (including guest bookings)
- Each row represents a unique traveler, not a user account
---
### Features
- Search by name, email, phone
- Filter by gender
- Date range filtering (createdAt)
- Pagination support (page, pageSize)
- Includes loyalty and wallet info if linked to user account
- Shows booking count per traveler
---
### Response Fields
- **id**: TravelerProfile ID
- **fullName**: Passenger name
- **email/phone**: Contact info (from linked user or booking)
- **gender**: Male/Female/Other (from Verifayda or manual entry)
- **dateOfBirth**: Birth date in YYYY-MM-DD format
- **nationality**: Passenger nationality
- **faydaVerified**: Whether verified via Verifayda
- **loyaltyTier/loyaltyPoints**: If linked to user account
- **totalBookings**: Number of bookings
- **createdAt**: When traveler was first added to system`
})
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
@ApiQuery({ name: 'gender', required: false, description: 'Filter by gender (Male, Female, Other)' })
@ApiQuery({ name: 'dateFrom', required: false, description: 'Filter by creation date from (YYYY-MM-DD)' })
@ApiQuery({ name: 'dateTo', required: false, description: 'Filter by creation date to (YYYY-MM-DD)' })
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page (default: 20)' })
@ApiResponse({
status: 200,
description: 'Travelers retrieved successfully',
schema: {
example: {
items: [
{
id: 'uuid-123',
fullName: 'Abebe Kebede',
email: 'abebe@example.com',
phone: '+251911234567',
gender: 'Male',
dateOfBirth: '1985-03-15',
nationality: 'Ethiopian',
faydaVerified: true,
loyaltyTier: 'SILVER',
loyaltyPoints: 1500,
totalBookings: 5,
createdAt: '2024-01-10T12:00:00.000Z'
}
],
meta: {
page: 1,
pageSize: 20,
total: 150,
totalPages: 8
}
}
}
})
@ApiResponse({
status: 200,
description: 'Travelers retrieved successfully',
schema: {
example: {
items: [
{
id: 'uuid-123',
fullName: 'Abebe Kebede',
email: 'abebe@example.com',
phone: '+251911234567',
gender: 'Male',
dateOfBirth: '1985-03-15',
nationality: 'Ethiopian',
nationalityCode: 'ET',
faydaVerified: true,
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
passportNumber: null,
passportCountry: null,
passportExpiryDate: null,
idDocumentType: 'NATIONAL_ID',
verified: true,
lastLoginAt: '2024-01-20T08:15:00.000Z',
role: 'PASSENGER',
loyalty: {
tier: 'SILVER',
pointsBalance: 1500,
lifetimePoints: 3000
},
wallet: {
balanceMinor: 50000,
currency: 'ETB'
},
loyaltyTier: 'SILVER',
loyaltyPoints: 1500,
totalBookings: 5,
createdAt: '2024-01-10T12:00:00.000Z'
},
{
id: 'uuid-456',
fullName: 'Sara Ketsela',
email: null,
phone: null,
gender: 'Female',
dateOfBirth: '1990-08-22',
nationality: 'Ethiopian',
nationalityCode: null,
faydaVerified: false,
faydaVerifiedAt: null,
passportNumber: null,
passportCountry: null,
passportExpiryDate: null,
idDocumentType: null,
verified: false,
lastLoginAt: null,
role: null,
loyalty: null,
wallet: null,
loyaltyTier: 'BRONZE',
loyaltyPoints: 0,
totalBookings: 1,
createdAt: '2024-01-18T14:30:00.000Z'
}
],
meta: {
page: 1,
pageSize: 20,
total: 150,
totalPages: 8
}
}
}
})
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'verified', required: false })
@ApiQuery({ name: 'gender', required: false })
@ApiQuery({ name: 'nationality', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
findAll(
@Query('search') search?: string,
@Query('verified') verified?: string,
@Query('gender') gender?: string,
@Query('nationality') nationality?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('page') page?: string,
@@ -44,9 +174,7 @@ export class PassengersController {
) {
return this.service.findAll({
search,
verified: verified ? verified === 'true' : undefined,
gender,
nationality,
dateFrom,
dateTo,
page: page ? parseInt(page) : 1,

View File

@@ -8,6 +8,7 @@ export class CreateTravelerProfileDto {
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
@ApiPropertyOptional({ example: 'Female' }) @IsOptional() @IsString() gender?: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}

View File

@@ -4,12 +4,11 @@ import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
interface PassengerFilters {
search?: string;
verified?: boolean;
gender?: string;
nationality?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
@@ -33,31 +32,21 @@ export class PassengersService {
) {}
async findAll(filters: PassengerFilters = {}) {
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const { search, gender, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.user = {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
{ fullName: { contains: search, mode: 'insensitive' } },
],
};
}
if (verified !== undefined) {
where.user = { ...(where.user ?? {}), faydaVerified: verified };
where.OR = [
{ fullName: { contains: search, mode: 'insensitive' } },
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
];
}
if (gender) {
where.user = { ...(where.user ?? {}), gender };
}
if (nationality) {
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
where.gender = gender;
}
if (dateFrom || dateTo) {
@@ -68,34 +57,43 @@ export class PassengersService {
}
const [items, total] = await Promise.all([
this.prisma.passenger.findMany({
this.prisma.travelerProfile.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: true,
loyalty: true,
wallet: true,
_count: { select: { bookings: true } },
bookings: {
orderBy: { createdAt: 'desc' },
take: 1,
select: {
contactEmail: true,
contactPhone: true,
seats: { take: 1, orderBy: { id: 'asc' }, select: {
passengerName: true, dateOfBirth: true, passportNumber: true,
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
}},
passenger: {
include: {
user: true,
loyalty: true,
wallet: true,
_count: { select: { bookings: true } },
bookings: {
take: 1,
orderBy: { createdAt: 'desc' },
select: {
contactPhone: true,
contactEmail: true,
seats: {
take: 1,
orderBy: { id: 'asc' },
select: {
passengerName: true,
passportNumber: true,
passportCountry: true,
},
},
},
},
},
},
},
}),
this.prisma.passenger.count({ where }),
this.prisma.travelerProfile.count({ where }),
]);
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
const iamUserIds = items.map(p => p.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
@@ -104,72 +102,51 @@ export class PassengersService {
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
// Collect guest contact details for bulk SavedPassengerProfile lookup
const guestContacts = items
.filter(p => !(p as any).user && !p.iamUserId)
.map(p => (p as any).bookings?.[0])
.filter(Boolean);
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
const savedProfiles = (guestEmails.length || guestPhones.length)
? await this.prisma.savedPassengerProfile.findMany({
where: { OR: [
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
]},
orderBy: { createdAt: 'desc' },
})
: [];
// Index by email then phone for O(1) lookup
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
return {
items: items.map(passenger => {
const localUser = (passenger as any).user ?? null;
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
items: items.map(profile => {
const passenger = profile.passenger;
const localUser = (passenger as any)?.user ?? null;
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
const faydaVerified = localUser?.faydaVerified === true
|| iam?.metadata?.faydaVerified === true
|| iam?.metadata?.faydaVerified === 'true';
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
// Get additional data from bookings for guest passengers
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
const guestSeat = guestBooking?.seats?.[0] ?? null;
const savedProfile = guestBooking
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
: null;
return {
id: passenger.id,
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
dateOfBirth: localUser?.dateOfBirth
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
id: profile.id,
fullName: profile.fullName,
email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null,
phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null,
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
dateOfBirth: profile.dateOfBirth
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
: (localUser?.dateOfBirth
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
: iam?.metadata?.dateOfBirth ?? null),
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
faydaVerified,
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null,
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null,
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null,
verified: faydaVerified,
lastLoginAt: localUser?.lastLoginAt ?? null,
role: localUser?.role ?? null,
loyalty: passenger.loyalty
loyalty: passenger?.loyalty
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
: null,
wallet: (passenger as any).wallet
wallet: (passenger as any)?.wallet
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
: null,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
loyaltyTier: passenger?.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger?.loyalty?.pointsBalance || 0,
totalBookings: passenger?._count?.bookings || 0,
createdAt: profile.createdAt,
};
}),
meta: {
@@ -299,8 +276,13 @@ export class PassengersService {
createTravelerProfile(dto: CreateTravelerProfileDto) {
return this.prisma.travelerProfile.create({
data: {
...dto,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
passengerId: dto.passengerId,
fullName: dto.fullName,
relationship: dto.relationship,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null,
nationalId: dto.nationalId || null,
gender: dto.gender || null,
notes: dto.notes || null,
}
});
}
@@ -440,9 +422,21 @@ export class PassengersService {
}
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
const passenger = await this.prisma.passenger.findUnique({
where: { id },
include: {
user: true
}
});
if (!passenger) throw new NotFoundException('Passenger not found');
// Check usage before allowing deletion
const usage = await this.checkPassengerUsage(id);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
@@ -470,14 +464,15 @@ export class PassengersService {
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
]);
const usage = [];
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
if (loyaltyAccount) usage.push('Loyalty account');
if (walletAccount) usage.push('Wallet account');
const constraints = [];
if (bookingCount > 0) constraints.push({ entityName: 'booking', count: bookingCount, action: 'complete' as const });
if (loyaltyAccount) constraints.push({ entityName: 'loyalty account', count: 1, action: 'delete' as const });
if (walletAccount) constraints.push({ entityName: 'wallet account', count: 1, action: 'delete' as const });
return {
isInUse: usage.length > 0,
affectedModules: usage,
isInUse: constraints.length > 0,
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
constraints
};
}
}

View File

@@ -4,6 +4,7 @@ import {
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
Res,
@@ -123,6 +124,16 @@ export class PaymentsController {
return this.service.addPaymentMethod(dto);
}
@Patch("methods/:id")
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Update a payment method configuration (admin only)",
})
updateMethod(@Param("id") id: string, @Body() dto: Partial<AddPaymentMethodDto>) {
return this.service.updatePaymentMethod(id, dto);
}
@Get("methods")
@SetMetadata('isPublic', true)
@ApiOperation({

View File

@@ -473,6 +473,24 @@ export class PaymentsService {
});
}
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Payment method not found');
const updateData: any = {};
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
if (dto.currency !== undefined) updateData.currency = dto.currency;
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
return this.prisma.paymentMethod.update({
where: { id },
data: updateData,
});
}
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
return this.prisma.paymentMethod.findMany({
where: {

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
export class RoutesService {
@@ -93,8 +94,28 @@ export class RoutesService {
}
async deleteRoute(id: string) {
const route = await this.prisma.route.findUnique({ where: { id } });
const route = await this.prisma.route.findUnique({
where: { id },
include: {
schedules: true,
stops: true
}
});
if (!route) throw new NotFoundException('Route not found');
const constraints = [];
if (route.schedules.length > 0) {
constraints.push({
entityName: 'schedule',
count: route.schedules.length,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
}
await this.prisma.route.delete({ where: { id } });
return { deleted: true, id };
}

View File

@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
@Injectable()
export class SchedulesService {
@@ -13,7 +15,7 @@ export class SchedulesService {
) { }
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = new Date(dto.startDateTime);
const startDate = parseEthiopianTime(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
const errors: string[] = [];
const scheduleIds: string[] = [];
@@ -66,8 +68,8 @@ export class SchedulesService {
const where: any = {};
if (dto.date) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
const date = parseEthiopianTime(dto.date);
const nextDay = startOfNextDayEAT(date);
where.departureAt = { gte: date, lt: nextDay };
}
if (dto.routeId) where.routeId = dto.routeId;
@@ -93,8 +95,9 @@ export class SchedulesService {
}
async createSchedule(dto: CreateScheduleDto) {
const dep = new Date(dto.departureAt);
const arr = new Date(dto.arrivalAt);
// Parse dates in local Ethiopian time (EAT - UTC+3)
const dep = parseEthiopianTime(dto.departureAt);
const arr = parseEthiopianTime(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
const route = await this.prisma.route.findUnique({
@@ -105,10 +108,9 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
const depDate = new Date(dep);
depDate.setHours(0, 0, 0, 0);
const nextDay = new Date(depDate);
nextDay.setDate(nextDay.getDate() + 1);
// Check for existing schedule on the same day (local Ethiopian time)
const depDate = startOfDayEAT(dep);
const nextDay = startOfNextDayEAT(dep);
const existingSchedule = await this.prisma.trainSchedule.findFirst({
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
@@ -240,8 +242,9 @@ export class SchedulesService {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const dep = new Date(dto.departureAt);
const arr = new Date(dto.arrivalAt);
// Parse dates in local Ethiopian time (EAT - UTC+3)
const dep = parseEthiopianTime(dto.departureAt);
const arr = parseEthiopianTime(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
const route = await this.prisma.route.findUnique({
@@ -308,16 +311,59 @@ export class SchedulesService {
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: { _count: { select: { bookings: true } } },
include: {
_count: { select: { bookings: true } },
train: true,
originStation: true,
destinationStation: true
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
const constraints = [];
if ((schedule as any)._count.bookings > 0) {
throw new BadRequestException(
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
);
constraints.push({
entityName: 'booking',
count: (schedule as any)._count.bookings,
action: 'cancel' as const
});
}
if (constraints.length > 0) {
const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`;
throw new DeleteOperationException('Schedule', scheduleName, constraints);
}
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
// Delete travel packages that reference this schedule (required fields cannot be nulled)
// First get packages that reference this schedule
const packagesToDelete = await this.prisma.travelPackage.findMany({
where: {
OR: [
{ outboundScheduleId: id },
{ returnScheduleId: id }
]
},
select: { id: true }
});
// Delete price tiers first (they have foreign key to packages)
if (packagesToDelete.length > 0) {
const packageIds = packagesToDelete.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({
where: { packageId: { in: packageIds } }
});
// Now delete the packages
await this.prisma.travelPackage.deleteMany({
where: { id: { in: packageIds } }
});
}
return this.prisma.trainSchedule.delete({ where: { id } });
}
@@ -338,8 +384,8 @@ export class SchedulesService {
return this.prisma.tripStopTime.update({
where: { scheduleId_sequence: { scheduleId, sequence } },
data: {
plannedArrivalAt: dto.plannedArrivalAt ? new Date(dto.plannedArrivalAt) : undefined,
plannedDepartureAt: dto.plannedDepartureAt ? new Date(dto.plannedDepartureAt) : undefined,
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
plannedDepartureAt: dto.plannedDepartureAt ? parseEthiopianTime(dto.plannedDepartureAt) : undefined,
status: dto.status,
},
include: { station: true },
@@ -353,8 +399,8 @@ export class SchedulesService {
...rest,
tripId: scheduleId,
nationality,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
validFrom: parseEthiopianTime(validFrom),
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true },
});
@@ -371,8 +417,8 @@ export class SchedulesService {
...rest,
...(scheduleId !== undefined && { tripId: scheduleId }),
...(nationality !== undefined && { nationality }),
...(validFrom && { validFrom: new Date(validFrom) }),
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
...(validFrom && { validFrom: parseEthiopianTime(validFrom) }),
...(validUntil !== undefined && { validUntil: validUntil ? parseEthiopianTime(validUntil) : null }),
},
include: { seatClass: true },
});
@@ -390,8 +436,8 @@ export class SchedulesService {
return this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
validFrom: parseEthiopianTime(validFrom),
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true, route: true },
});
@@ -415,8 +461,8 @@ export class SchedulesService {
where: { id },
data: {
...rest,
validFrom: validFrom ? new Date(validFrom) : undefined,
validUntil: validUntil ? new Date(validUntil) : null,
validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined,
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true, route: true },
});
@@ -523,8 +569,8 @@ export class SchedulesService {
const updateData: any = {};
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
updateData.departureAt = dep;
updateData.arrivalAt = arr;

View File

@@ -33,7 +33,7 @@ export class SeatsController {
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Get seat map filtered by coach type",
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
})
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
@ApiQuery({
@@ -42,6 +42,23 @@ export class SeatsController {
description:
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
})
@ApiQuery({
name: "journeyDirection",
required: false,
enum: ['ONE_WAY', 'OUTBOUND', 'RETURN'],
description:
"Journey direction for round-trip bookings. Filters seat holds to show only conflicting holds. Use OUTBOUND for outbound leg, RETURN for return leg. Defaults to ONE_WAY (shows all holds).",
})
@ApiQuery({
name: "originStationId",
required: false,
description: "Origin station UUID for segment-specific seat availability",
})
@ApiQuery({
name: "destinationStationId",
required: false,
description: "Destination station UUID for segment-specific seat availability",
})
@ApiResponse({
status: 200,
description:
@@ -50,8 +67,17 @@ export class SeatsController {
getSeatMap(
@Param("scheduleId") scheduleId: string,
@Query("coachTypeId") coachTypeId?: string,
@Query("journeyDirection") journeyDirection?: string,
@Query("originStationId") originStationId?: string,
@Query("destinationStationId") destinationStationId?: string,
) {
return this.service.getSeatMap(scheduleId, coachTypeId);
return this.service.getSeatMap(
scheduleId,
coachTypeId,
journeyDirection as any,
originStationId,
destinationStationId
);
}
// ── Hold / Release ────────────────────────────────────────────────────────

View File

@@ -1,7 +1,13 @@
import { IsString, IsArray, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export enum JourneyDirection {
ONE_WAY = 'ONE_WAY',
OUTBOUND = 'OUTBOUND',
RETURN = 'RETURN'
}
export class PassengerSeatDto {
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
@IsString() passengerId: string;
@@ -20,6 +26,15 @@ export class HoldSeatsDto {
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@ApiPropertyOptional({
enum: JourneyDirection,
example: JourneyDirection.OUTBOUND,
description: 'Journey direction for round-trip bookings. ONE_WAY for single journeys, OUTBOUND/RETURN for round-trip legs. Allows same seats to be held for different directions.'
})
@IsOptional()
@IsEnum(JourneyDirection)
journeyDirection?: JourneyDirection;
@ApiProperty({
type: [PassengerSeatDto],
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',

View File

@@ -1,6 +1,6 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@@ -13,7 +13,7 @@ export class SeatsService {
private systemConfig: SystemConfigService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string) {
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
@@ -37,7 +37,13 @@ export class SeatsService {
});
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
const effectiveStatuses = await this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
journeyDirection
);
return {
coaches: assignments.map((a) => {
@@ -179,6 +185,7 @@ export class SeatsService {
seatIds: string[],
originStationId?: string,
destinationStationId?: string,
journeyDirection?: JourneyDirection,
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
@@ -211,9 +218,13 @@ export class SeatsService {
select: { seatIds: true, createdBy: true },
});
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
for (const hold of activeHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
let holdDirection = JourneyDirection.ONE_WAY;
try {
if (hold.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(hold.createdBy);
@@ -221,16 +232,26 @@ export class SeatsService {
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
}
} catch { /* ignore */ }
for (const seatId of hold.seatIds) {
if (!seatIds.includes(seatId)) continue;
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
} else {
statusMap.set(seatId, 'HELD');
}
// Check leg overlap
const legsOverlap =
reqFrom === undefined || reqTo === undefined ||
holdFrom === undefined || holdTo === undefined ||
(holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
// Check direction conflict
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
if (!directionsConflict) continue;
statusMap.set(seatId, 'HELD');
}
}
@@ -266,6 +287,36 @@ export class SeatsService {
return statusMap;
}
/**
* Check if two journey directions conflict (should not be allowed simultaneously)
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
*/
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
return false;
}
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
if (current === existing) {
return true;
}
// ONE_WAY conflicts with other ONE_WAY bookings only
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
return true;
}
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
return true;
}
// Default: no conflict
return false;
}
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
@@ -307,19 +358,16 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) =>
stopTimes.find(s => s.stationId === stationId)?.sequence;
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
@@ -328,57 +376,46 @@ export class SeatsService {
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── Check existing holds for overlap ────────────────────────────────────
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
for (const h of activeHolds) {
const rawSeatIds = h.seatIds as string[];
let holdDirection = JourneyDirection.ONE_WAY;
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
let passengerIds: string[] = [];
let legUnknown = true;
try {
if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId);
parsedHolds.push({
seatIds: rawSeatIds,
from: holdFrom ?? 0,
to: holdTo ?? Number.MAX_SAFE_INTEGER,
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
legUnknown: holdFrom === undefined || holdTo === undefined,
});
} else {
// Legacy plain-string createdBy — can't determine leg; block conservatively.
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
holdFrom = seqOf(meta.originStationId) ?? 0;
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
legUnknown = !meta.originStationId || !meta.destinationStationId;
}
} catch {
// Malformed JSON — block conservatively.
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
}
}
} catch { /* ignore */ }
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
if (!legsOverlap) continue;
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg`,
);
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
if (!directionsConflict) continue;
for (const { passengerId, seatId } of dto.passengers) {
if (rawSeatIds.includes(seatId)) {
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
}
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg`,
);
if (!legUnknown && passengerIds.includes(passengerId)) {
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
}
}
}
// ── Check confirmed JourneySegments for overlap ──────────────────────────
const bookedSegments = await tx.journeySegment.findMany({
where: {
scheduleId: dto.scheduleId,
@@ -392,25 +429,19 @@ export class SeatsService {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
const overlaps = (segFrom === undefined || segTo === undefined)
? true
: segFrom < reqTo && reqFrom < segTo;
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
if (overlaps) {
throw new ConflictException(
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
);
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
}
}
const holdMeta = {
originStationId: dto.originStationId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: currentDirection,
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
// Mark seats as HELD so the status check catches them immediately on any
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
await tx.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'HELD' },
@@ -418,10 +449,10 @@ export class SeatsService {
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
scheduleId: dto.scheduleId,
passengerId: dto.passengers[0].passengerId,
seatIds,
createdBy: JSON.stringify(holdMeta),
createdBy: JSON.stringify(holdMeta),
expiresAt,
},
});

View File

@@ -5,7 +5,6 @@ export class CreateStationDto {
@ApiProperty({ example: 'ADD' }) @IsString() code: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;

View File

@@ -68,8 +68,8 @@ export class StationsService {
async update(id: string, dto: Partial<CreateStationDto>) {
const oldStation = await this.findOne(id);
const { code, name, city, timezone, lat, lng } = dto;
const data: any = { code, name, city, timezone, lat, lng };
const { code, name, city, lat, lng } = dto;
const data: any = { code, name, city, lat, lng };
if ('countryCode' in dto) data.countryCode = (dto as any).countryCode;
if ('sequence' in dto) data.sequence = (dto as any).sequence;
if ('isOperational' in dto) data.isOperational = (dto as any).isOperational;

View File

@@ -39,6 +39,7 @@ export class TicketsController {
@ApiQuery({ name: 'arrivalDate', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'coachId', required: false })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@@ -49,6 +50,7 @@ export class TicketsController {
@Query('arrivalDate') arrivalDate?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('coachId') coachId?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
@@ -60,6 +62,7 @@ export class TicketsController {
arrivalDate,
dateFrom,
dateTo,
coachId,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
@@ -83,6 +86,31 @@ export class TicketsController {
return this.service.getByRef(ref);
}
@Post('scan-board/:qrCodeOrRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Scan QR code or booking ref and automatically board ticket',
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
})
@ApiBody({
schema: {
type: 'object',
required: ['validatorId'],
properties: {
validatorId: { type: 'string', example: 'agent-uuid' },
gateId: { type: 'string', example: 'gate-01' },
},
},
})
scanAndBoard(
@Param('qrCodeOrRef') qrCodeOrRef: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string,
) {
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
}
@Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -23,12 +23,13 @@ export class TicketsService {
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ passengerName: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
@@ -53,6 +54,12 @@ export class TicketsService {
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
// if (filters.coachId) {
// where.seat = {
// coachId: filters.coachId
// };
// }
const [tickets, total] = await Promise.all([
this.prisma.ticket.findMany({
where,
@@ -61,19 +68,20 @@ export class TicketsService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
seats: { include: { seat: { include: { coach: true } } } },
},
},
},
seat: { include: { coach: { include: { coachType: true } } } },
} as any,
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
}),
}) as any,
this.prisma.ticket.count({ where }),
]);
const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
const iamUserIds = tickets.map((t: any) => t.booking?.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
@@ -83,34 +91,49 @@ export class TicketsService {
const iamMap = new Map(iamRows.map(r => [r.id, r]));
return {
items: tickets.map((t) => {
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
items: tickets.map((t: any) => {
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
const passengerInfo = iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
: { fullName: 'Guest', email: t.booking?.contactEmail, phone: null };
return {
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
passengerName: t.passengerName,
leg: t.leg,
booking: {
id: t.booking.id,
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,
displayTotalMinor: t.booking.displayTotalMinor,
id: t.booking?.id,
bookingRef: t.booking?.bookingRef,
status: t.booking?.status,
bookingType: t.booking?.bookingType,
returnLegStatus: t.booking?.returnLegStatus ?? null,
outboundBoardedAt: t.booking?.outboundBoardedAt ?? null,
returnBoardedAt: t.booking?.returnBoardedAt ?? null,
totalMinor: t.booking?.totalMinor,
currency: t.booking?.currency,
displayCurrency: t.booking?.displayCurrency,
displayTotalMinor: t.booking?.displayTotalMinor,
passenger: passengerInfo,
contactEmail: t.booking.contactEmail,
contactPhone: t.booking.contactPhone,
returnSchedule: (t.booking as any).returnSchedule ?? null,
contactEmail: t.booking?.contactEmail,
contactPhone: t.booking?.contactPhone,
returnSchedule: t.booking?.returnSchedule ?? null,
seats: t.booking?.seats ?? [],
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
schedule: t.booking?.schedule,
seat: t.seat ? {
id: t.seat.id,
seatNumber: t.seat.seatNumber,
coach: t.seat.coach ? {
id: t.seat.coach.id,
number: t.seat.coach.number,
coachType: t.seat.coach.coachType ? {
id: t.seat.coach.coachType.id,
name: t.seat.coach.coachType.name,
type: t.seat.coach.coachType.type,
} : null,
} : null,
} : null,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
@@ -136,7 +159,7 @@ export class TicketsService {
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// No payment intent record at all
if (!booking.paymentIntent) {
if (!(booking as any).paymentIntent) {
throw new HttpException(
{ status: 'error', message: 'Payment not completed', code: 400 },
HttpStatus.BAD_REQUEST,
@@ -144,21 +167,19 @@ export class TicketsService {
}
// Payment intent exists but not yet succeeded
if (booking.paymentIntent.status !== 'SUCCEEDED') {
if ((booking as any).paymentIntent.status !== 'SUCCEEDED') {
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Payment status: ${booking.paymentIntent.status}`,
detail: `Payment status: ${(booking as any).paymentIntent.status}`,
},
HttpStatus.BAD_REQUEST,
);
}
// Booking not in CONFIRMED state — could be a webhook delivery failure.
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
// self-heal here rather than rejecting a legitimately paid booking.
if (booking.status !== 'CONFIRMED') {
if (booking.status === 'PENDING_PAYMENT') {
this.logger.warn(
@@ -181,154 +202,282 @@ export class TicketsService {
}
}
// Build a compact multi-leg payload for the QR so gate scanners see all legs
const legSummary = this.buildLegSummary(booking);
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
legs: legSummary,
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
// Delete existing tickets if any
await this.prisma.ticket.deleteMany({ where: { bookingId } });
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
// Generate one ticket per unique passenger (grouped by passengerName)
const tickets = [];
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
// Group seats by passenger
const passengerSeatsMap = new Map<string, any[]>();
for (const bookingSeat of (booking as any).seats) {
const key = bookingSeat.passengerName;
if (!passengerSeatsMap.has(key)) {
passengerSeatsMap.set(key, []);
}
passengerSeatsMap.get(key)!.push(bookingSeat);
}
// Create one ticket per passenger
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
// Use first seat for primary data
const primarySeat = passengerSeats[0];
// Build passenger QR data with all legs included
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
seat: ps.seat.seatNumber,
coach: ps.seat.coach.number,
leg: ps.leg || 1,
scheduleId: ps.scheduleId || booking.scheduleId,
})),
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
const ticket = await this.prisma.ticket.create({
data: {
bookingId,
bookingRef: booking.bookingRef,
passengerName,
seatId: primarySeat.seatId,
leg: primarySeat.leg || 1,
scheduleId: primarySeat.scheduleId || booking.scheduleId,
qrPayload,
barcodePayload,
} as any,
});
tickets.push(ticket);
}
// Block all seats across all legs
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
}).catch(() => null);
}
return { ...ticket, legs: legSummary };
return { tickets, totalTickets: tickets.length };
}
private buildLegSummary(booking: any) {
const seatsByLeg = new Map<number, any[]>();
for (const bs of booking.seats) {
const leg = bs.leg ?? 1;
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
seatsByLeg.get(leg)!.push(bs);
}
return Array.from(seatsByLeg.entries())
.sort(([a], [b]) => a - b)
.map(([leg, seats]) => ({
leg,
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
passengers: seats.map(bs => ({
name: bs.passengerName,
category: bs.passengerCategory,
coach: bs.seat?.coach?.number,
seat: bs.seat?.seatNumber,
fareMinor: bs.fareMinor,
})),
}));
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
async updateSeats(bookingId: string, seatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
include: { tickets: true, seats: true } as any
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
for (const ticket of (booking as any).tickets) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
where: { reason: { contains: ticket.id } }
});
}
// Remove old booking seats
// Delete existing tickets
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Update booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
// Create new seat assignments (simplified)
for (let i = 0; i < seatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
seatId: seatIds[i],
passengerName: `Passenger ${i + 1}`,
}
leg: 1
} as any
});
}
return { success: true, updatedSeats: newSeatIds.length };
// Generate new tickets
return this.generate(bookingId);
}
async getByMerchantOrderId(merchantOrderId: string) {
const intent = await this.prisma.paymentIntent.findUnique({
const paymentIntent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
select: { bookingId: true },
include: { booking: { include: { tickets: true } as any } } as any
});
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
if (!paymentIntent) throw new NotFoundException('Payment not found');
const booking = (paymentIntent as any).booking;
if (!booking) throw new NotFoundException('Booking not found');
return this.getByRef(booking.bookingRef);
}
async getByRef(ref: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
where: { bookingRef: ref },
include: {
tickets: true,
schedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: true } } } }
} as any
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
if (!booking) throw new NotFoundException('Booking not found');
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,
booking: {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
bookingType: booking.bookingType,
totalMinor: booking.totalMinor,
currency: booking.currency
},
tickets: (booking as any).tickets,
schedule: (booking as any).schedule,
returnSchedule: (booking as any).returnSchedule,
seats: (booking as any).seats
};
}
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
};
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
try {
// Extract booking reference from QR code if it's JSON
let bookingRef = qrCodeOrRef;
try {
const qrData = JSON.parse(qrCodeOrRef);
if (qrData.ref) {
bookingRef = qrData.ref;
}
} catch {
// Not JSON, treat as booking reference
}
// Get booking and ticket info
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) {
throw new NotFoundException('Ticket not found');
}
if (booking.status !== 'CONFIRMED') {
throw new BadRequestException('Ticket is not confirmed');
}
const ticket = (booking as any).tickets[0];
if (!ticket) {
throw new NotFoundException('No ticket found for this booking');
}
// Check if ticket date matches today
const today = new Date();
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
if ((booking as any).schedule?.departureAt) {
const departureDate = new Date((booking as any).schedule.departureAt);
const departureDateStr = departureDate.toISOString().split('T')[0];
// Check if ticket is for today
if (departureDateStr !== todayDateStr) {
if (departureDateStr < todayDateStr) {
throw new BadRequestException('Ticket has expired - departure date has passed');
} else {
throw new BadRequestException('Ticket is for a future date - cannot board early');
}
}
// Additional check: ticket expires 4 hours after departure time
const departureTime = new Date((booking as any).schedule.departureAt);
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
if (today > expiryTime) {
throw new BadRequestException('Ticket has expired - boarding window closed');
}
}
// Use existing validation logic to handle round trips properly
const result = await this.validate(bookingRef, validatorId, gateId);
// Get seat information
const seatInfo = (booking as any).seats[0];
const seatNumber = seatInfo?.seat?.seatNumber || 'N/A';
const coachNumber = seatInfo?.seat?.coach?.number || 'N/A';
// Send notifications after successful boarding
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
return {
success: true,
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
boarding: {
ticketId: ticket.id,
bookingRef: booking.bookingRef,
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
route: `${(booking as any).schedule?.originStation?.name || 'N/A'}${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
seat: seatNumber,
coach: coachNumber,
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
departureTime: (booking as any).schedule?.departureAt,
boardedAt: result.validatedAt,
leg: result.leg || 'OUTBOUND',
bookingType: booking.bookingType,
isRoundTrip: booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'ROUND_TRIP_TRANSIT',
},
};
} catch (error) {
// Return structured error for the UI
const errorMessage = error instanceof Error ? error.message : 'Boarding failed';
const errorCode = error instanceof BadRequestException ? 'VALIDATION_ERROR'
: error instanceof NotFoundException ? 'NOT_FOUND'
: 'SYSTEM_ERROR';
return {
success: false,
error: errorMessage,
errorCode,
};
}
}
private async sendBoardingNotifications(booking: any, ticket: any, leg: string) {
try {
const passengerName = booking.seats?.[0]?.passengerName || ticket.passengerName || 'Passenger';
const contactEmail = booking.contactEmail;
const contactPhone = booking.contactPhone;
if (!contactEmail && !contactPhone) {
this.logger.warn(`No contact details found for booking ${booking.bookingRef}`);
return;
}
const routeInfo = `${booking.schedule?.originStation?.name}${booking.schedule?.destinationStation?.name}`;
const trainName = booking.schedule?.train?.name || booking.schedule?.train?.number;
const departureTime = booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : 'N/A';
const legText = leg === 'RETURN' ? 'Return' : 'Outbound';
// Use the existing sendBoardingPassNotification method
await this.notifications.sendBoardingPassNotification({
passengerId: booking.passengerId || null,
contactEmail,
contactPhone,
bookingRef: booking.bookingRef,
leg,
booking,
ticket,
});
} catch (error: any) {
this.logger.error('Error sending boarding notifications:', error);
}
}
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
@@ -343,11 +492,11 @@ export class TicketsService {
const resolvedValidatorId = validatorId || 'BACKOFFICE';
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 } });
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
const type = booking.bookingType;
const now = new Date();
const now = new Date();
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') {
@@ -389,92 +538,34 @@ export class TicketsService {
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
throw new BadRequestException('Outbound leg already used');
throw new BadRequestException('Outbound leg already validated');
}
bookingData.outboundBoardedAt = now;
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
} else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
throw new BadRequestException('Return leg already used');
throw new BadRequestException('Return leg already validated');
}
bookingData.returnBoardedAt = now;
} else {
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
}
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';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
if (type === 'ROUND_TRIP_TRANSIT') {
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
const resolvedLeg = (leg ?? '').toUpperCase();
if (!validLegs.includes(resolvedLeg)) {
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
const bookingData: Record<string, any> = {};
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
bookingData.outboundBoardedAt = now;
}
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
bookingData.returnBoardedAt = now;
}
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// Fallback for unknown booking types — single scan
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
throw new BadRequestException(`Unsupported booking type: ${type}`);
}
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
this.prisma.booking.findUnique({
where: { id: booking.id },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
}).then((enriched) => {
if (!enriched) return;
this.notifications.sendBoardingPassNotification({
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
contactEmail: (enriched as any).contactEmail ?? null,
contactPhone: (enriched as any).contactPhone ?? null,
bookingRef: enriched.bookingRef,
leg,
booking: enriched,
ticket,
}).catch(() => null);
}).catch(() => null);
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
// TODO: Implement notification logic
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
}
async getValidationLogs(ticketId: string) {
@@ -488,111 +579,57 @@ export class TicketsService {
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
passenger: { select: { id: true, iamUserId: true } },
},
} as any,
});
return bookings.map((b) => ({
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.seatNumber,
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
ticketId: (b as any).tickets?.[0]?.id,
passengerName: (b as any).seats[0]?.passengerName,
seatLabel: (b as any).seats[0]?.seat.seatNumber,
coachLabel: (b as any).seats[0]?.seat.coach.number,
qrPayload: (b as any).tickets?.[0]?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
validatedAt: (b as any).tickets?.[0]?.validatedAt,
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
const processedRefs = new Set<string>();
for (const v of validations) {
const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
processedRefs.add(dedupKey);
const results = [];
for (const validation of validations) {
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
if (!booking) {
results.failed++;
results.errors.push(`Booking ${v.bookingRef} not found`);
continue;
}
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) {
results.failed++;
results.errors.push(`Ticket for ${v.bookingRef} not found`);
continue;
}
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++;
continue;
}
// For multi-leg bookings, check per-leg duplication
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLeg && offlineLeg) {
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
if (existingLogs.some(l => l.leg === offlineLeg)) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
const result = await this.validate(
validation.bookingRef,
validation.validatorId,
validation.gateId,
validation.leg
);
results.push({
bookingRef: validation.bookingRef,
success: true,
result
});
await this.prisma.gateValidationLog.create({
data: {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
} as any,
} catch (error) {
results.push({
bookingRef: validation.bookingRef,
success: false,
error: error instanceof Error ? error.message : 'Validation failed'
});
// update boarding timestamps for multi-leg bookings
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLegBooking && offlineLeg) {
const bookingData: Record<string, any> = {};
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
if (Object.keys(bookingData).length) {
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
}
}
results.success++;
} catch (err) {
results.failed++;
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return results;
return {
processed: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results
};
}
async delete(id: string) {
@@ -618,4 +655,4 @@ export class TicketsService {
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
}
}
}