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');