Refactor booking creation to support one-way and round-trip bookings with enhanced DTOs and service methods

This commit is contained in:
Stephanos A
2026-06-16 18:44:09 +03:00
parent 421e30dc6c
commit eb60fc340d
4 changed files with 414 additions and 127 deletions

View File

@@ -494,38 +494,44 @@ model FareRule {
}
model Booking {
id String @id @default(uuid())
bookingRef String @unique
passengerId String
scheduleId String
status BookingStatus @default(DRAFT)
currency String @default("ETB")
totalMinor Int
adultCount Int @default(1)
childCount Int @default(0)
displayCurrency Currency?
displayTotalMinor Int?
bookingType String @default("ONE_WAY")
contactEmail String?
contactPhone String?
userAgent String?
source String @default("WEB")
promoCode String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
foodOrders FoodOrder[]
agentBooking AgentBooking?
modifications BookingModification[]
cancellation BookingCancellation?
baggage BaggageBooking[]
id String @id @default(uuid())
bookingRef String @unique
passengerId String
scheduleId String
bookingType String @default("ONE_WAY")
status BookingStatus @default(DRAFT)
currency String @default("ETB")
totalMinor Int
adultCount Int @default(1)
childCount Int @default(0)
displayCurrency Currency?
displayTotalMinor Int?
returnScheduleId String?
returnOriginStationId String?
returnDestinationStationId String?
returnHoldId String?
returnSeatClassId String?
contactEmail String?
contactPhone String?
userAgent String?
source String @default("WEB")
promoCode String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
foodOrders FoodOrder[]
agentBooking AgentBooking?
modifications BookingModification[]
cancellation BookingCancellation?
baggage BaggageBooking[]
@@index([passengerId, status])
@@index([bookingType])
@@schema("passenger")
}

View File

@@ -144,9 +144,20 @@ export class BookingsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create booking (requires login)',
description: `Creates a booking for logged-in users with saved passenger profiles.
Use POST /bookings/guest for guest checkout without login.`
summary: 'Create booking (one-way or round-trip)',
description: `Creates a one-way or round-trip booking for logged-in users.
ONE_WAY booking:
- scheduleId, holdId, originStationId, destinationStationId
- passengers: array of PassengerInputDto with seatId
- Single PNR, single payment
ROUND_TRIP booking:
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
- Combined PNR, single payment for both legs
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
})
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })

View File

@@ -15,53 +15,130 @@ export class PassengerInputDto {
}
export class RoundTripPassengerDto {
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
@ApiProperty({
description: 'Outbound journey seat ID',
example: 'seat-uuid-outbound'
})
@IsString() outboundSeatId: string;
@ApiProperty({
description: 'Return journey seat ID',
example: 'seat-uuid-return'
})
@IsString() returnSeatId: string;
@ApiProperty({
example: 'Abebe Kebede',
description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)'
})
@IsString() passengerName: string;
@ApiProperty({
example: '1990-05-15',
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
})
@IsDateString() dateOfBirth: string;
@ApiProperty({
example: 'NATIONAL_ID',
enum: IdDocumentType,
description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others'
})
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({
example: 'ET123456789',
description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)'
})
@IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({
example: 'P1234567',
description: 'Passport number for non-Ethiopian passengers (no verification)'
})
@IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({
example: 'Djibouti',
description: 'Passport issuing country for non-Ethiopians'
})
@IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({
example: 'Ethiopian',
description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)'
})
@IsOptional() @IsString() nationality?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() scheduleId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
@ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' })
@IsString() scheduleId: string;
@ApiProperty({ description: 'Outbound seat hold ID' })
@IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@IsString() seatClassId: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class CreateRoundTripBookingDto {
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
@ApiProperty({
example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP'],
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
default: 'ONE_WAY'
})
@IsOptional() @IsString() bookingType?: string;
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
@ApiProperty({
type: [PassengerInputDto],
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
@ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' })
@IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
// Round-trip specific fields
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
})
@IsOptional() @IsString() returnScheduleId?: string;
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
})
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
})
@IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
})
@IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
})
@IsOptional() @IsString() returnSeatClassId?: string;
}
export class ModifyBookingDto {

View File

@@ -246,15 +246,19 @@ export class BookingsService {
}
async create(dto: CreateBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') {
return this.createRoundTripBooking(dto);
}
return this.createOneWayBooking(dto);
}
private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
});
if (!schedule) throw new NotFoundException('Schedule not found');
@@ -262,18 +266,182 @@ export class BookingsService {
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const passengersData = await this.processPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = fareCalculation.totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
}
const seatIds = dto.passengers.map((p) => p.seatId);
const passengersData = [];
let adultCount = 0, childCount = 0;
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: fareCalculation.totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
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,
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
displayCurrency
}))
}
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
for (const passenger of dto.passengers) {
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
this.eventEmitter.emit('booking.created', { booking });
return { ...booking, fareBreakdown: fareCalculation };
}
private async createRoundTripBooking(dto: CreateBookingDto) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('Return trip details required for round-trip booking');
}
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');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
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 || !returnSchedule) throw new NotFoundException('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 || !returnOriginStop || !returnDestStop) {
throw new NotFoundException('Origin or destination stops not found');
}
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const [outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
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 loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
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: dto.returnSeatClassId,
seats: {
create: passengersData.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
displayCurrency
}))
}
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
const returnSeatIds = passengersData.map(p => p.returnSeatId);
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds)
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
outboundFare: outboundFare.baseFareMinor,
returnFare: returnFare.baseFareMinor,
combinedBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor
}
};
}
private async processPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of 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;
@@ -292,68 +460,93 @@ export class BookingsService {
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
return processedPassengers;
}
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
private async processRoundTripPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && 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 = nationality || 'Ethiopian';
} else if (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');
}
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
return processedPassengers;
}
private countPassengers(passengersData: any[]) {
let adultCount = 0, childCount = 0;
for (const passenger of passengersData) {
if (passenger.category === PassengerCategory.ADULT) adultCount++;
else childCount++;
}
return { adultCount, childCount };
}
private async calculateFare(
scheduleId: string,
seatClassId: string,
originStop: any,
destStop: any,
nationality?: string,
adultCount = 1,
childCount = 0,
promoCode?: string,
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
bookingType: dto.bookingType ?? 'ONE_WAY',
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
displayCurrency,
})),
},
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await this.seatsService.confirmSeats(seatIds);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
baseFareMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
childFareMinor,
totalBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor
};
}