Fare engine updates, seat class amount updates

This commit is contained in:
Stephanos A
2026-07-08 18:16:57 +03:00
parent 9e208861c7
commit 3760bcdb66
15 changed files with 243 additions and 99 deletions

View File

@@ -21,6 +21,8 @@ export class PassengerInputDto {
@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;
@ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
}
export class RoundTripPassengerDto {
@@ -143,6 +145,9 @@ export class CreateBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
@IsOptional() @IsInt() reviewedTotalMinor?: number;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@@ -60,6 +60,8 @@ interface BookingFilters {
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name);
constructor(
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
@@ -546,30 +548,42 @@ export class BookingsService {
: 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);
}
// Track per-seat fare. For package bookings children pay 10% of adult fare;
// for regular bookings the first child is free.
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
let freeChildUsed = false;
let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor;
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else if (dto.packageId) {
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
// passengersWithFares is built in adult-first order so we track paid children by count.
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = fareCalculation.baseFareMinor;
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
}
return { ...p, fareMinor };
});
// Use the sum of per-seat fares as the authoritative total when the client supplied
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
// Free children have no seatId and no seatFareMinor — exclude them from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
const resolvedTotalMinor = dto.reviewedTotalMinor ??
(allFaresProvided
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
: fareCalculation.totalMinor);
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
let displayTotalMinor = resolvedTotalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -577,7 +591,7 @@ export class BookingsService {
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: fareCalculation.totalMinor,
totalMinor: resolvedTotalMinor / 100,
adultCount,
childCount,
displayCurrency,
@@ -697,8 +711,8 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
// Track per-seat fare. For package bookings children pay 10% of adult fare;
// for regular bookings the first child is free per leg.
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
@@ -706,24 +720,40 @@ export class BookingsService {
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor;
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else if (dto.packageId) {
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
const isFreeChild = childIdx < adultCount;
outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = outboundFare.baseFareMinor;
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = returnFare.baseFareMinor;
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
// supplied their fares — free children (no seatId) are excluded from the check.
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor) {
totalMinor = dto.reviewedTotalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
} else if (allRTFaresProvided && !dto.packageId) {
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
} else {
displayTotalMinor = totalMinor;
}
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -42,6 +42,12 @@ export class GuestPassengerDto {
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
@IsOptional() @IsString() email?: string;
@ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). Overrides fare engine — use for berth-specific pricing (Upper/Middle/Lower).' })
@IsOptional() @IsInt() seatFareMinor?: number;
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' })
@IsOptional() @IsInt() returnSeatFareMinor?: number;
}
export class CreateGuestBookingDto {
@@ -150,6 +156,9 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
@IsOptional() @IsInt() reviewedTotalMinor?: number;
}
export class SavedPassengerProfileDto {

View File

@@ -185,12 +185,38 @@ export class GuestBookingService {
}
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
// Free children (first child, non-package) get fareMinor=0.
let freeChildUsed = false;
let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = p.seatFareMinor ?? baseFareMinor;
} else if (isPackageOneway) {
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = p.seatFareMinor ?? childUnitFare;
}
return { ...p, fareMinor };
});
// Use reviewedTotalMinor from frontend as authoritative total when provided.
// Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
const resolvedTotalMinor = dto.reviewedTotalMinor ??
(allFaresProvided
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
: Math.max(0, totalBaseFareMinor - discountMinor));
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
let displayTotalMinor = resolvedTotalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
}
// Resolve or create the guest Passenger record
@@ -224,7 +250,7 @@ export class GuestBookingService {
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
totalMinor,
totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
@@ -235,7 +261,7 @@ export class GuestBookingService {
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
seats: {
create: passengersData.map((p) => ({
create: passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
@@ -245,7 +271,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
fareMinor: p.fareMinor,
displayCurrency,
})),
},
@@ -278,7 +304,7 @@ export class GuestBookingService {
totalBaseFareMinor,
discountMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
totalMinor: resolvedTotalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
@@ -423,13 +449,51 @@ export class GuestBookingService {
}
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
let displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
} else if (isPackageRoundTrip) {
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
// Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares
// when all seated passengers supplied their fares.
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor) {
totalMinor = dto.reviewedTotalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
} else if (allRTFaresProvided && !isPackageRoundTrip) {
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
}
// Create or resolve guest passenger (same as one-way)
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
@@ -461,7 +525,7 @@ export class GuestBookingService {
contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersData.map((p) => ({
...passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -473,10 +537,10 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
fareMinor: p.outboundFareMinor,
displayCurrency,
})),
...passengersData.map((p) => ({
...passengersWithFares.map((p) => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
@@ -488,7 +552,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
fareMinor: p.returnFareMinor,
displayCurrency,
})),
],