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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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