Fix price inconsistency

This commit is contained in:
Roba Boru
2026-07-19 22:46:36 +03:00
parent 996c0d8896
commit d43229528e
6 changed files with 153 additions and 108 deletions

View File

@@ -834,53 +834,63 @@ export class BookingsService {
// 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 = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else if (dto.packageId) {
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? fareCalculation.baseFareMinor) : 0;
}
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.
// Determine the authoritative total.
// Priority (one-way, non-package):
// 1. Server-computed sum of per-seat fares when every seated passenger supplied
// seatFareMinor — this captures berth-specific pricing (Upper/Middle/Lower)
// exactly as shown to the user and cannot be corrupted by a frontend race
// condition that sends reviewedTotalMinor before all fares are resolved.
// 2. reviewedTotalMinor from the frontend — fallback when the server doesn't
// have complete per-seat data (e.g. auto-assign with no seat map loaded).
// 3. Fare engine total — last resort when neither is available.
// For package bookings reviewedTotalMinor always wins because the tier price
// may include berth-specific adjustments the server cannot derive alone.
// Free children have no seatId and no seatFareMinor — exclude from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
// seatFareMinor values from the client are in display-currency minor units (matching
// displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
// In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
let resolvedTotalMinor: number;
let displayTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
if (allFaresProvided && !dto.packageId) {
// Server has every passenger's berth fare — sum is the authoritative display total.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
// Package booking: client-supplied tier-adjusted total.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
// For package bookings where per-seat fares weren't supplied, back-derive the
// per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the
// actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
if (dto.packageId && seatedPassengers.length > 0) {
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (allFaresProvided) {
// seatFareMinor is in display currency — sum is already the display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
// Partial data on server: use client's total as best available.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
@@ -1028,8 +1038,6 @@ export class BookingsService {
// 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 => {
let outboundFareMinor: number;
let returnFareMinor: number;
@@ -1037,14 +1045,12 @@ export class BookingsService {
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else if (dto.packageId) {
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
// Free children have no outbound seat (frontend excludes them).
// Guard by outboundSeatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.outboundSeatId ? (p.seatFareMinor ?? outboundFare.baseFareMinor) : 0;
returnFareMinor = p.outboundSeatId ? (p.returnSeatFareMinor ?? returnFare.baseFareMinor) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
@@ -1052,33 +1058,39 @@ export class BookingsService {
// 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.
// Same priority logic as one-way: server-computed sum wins when all per-seat fares
// are present; reviewedTotalMinor is used only as fallback to avoid a frontend
// race condition from under-counting passengers.
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
// For package bookings where per-seat fares weren't supplied, back-derive the
// per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects
// the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
if (dto.packageId) {
const seatedCount = passengersData.filter(p => p.outboundSeatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
}
} else if (allRTFaresProvided && !dto.packageId) {
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
if (allRTFaresProvided && !dto.packageId) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createRoundTripBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
const seatedCount = rtSeatedPassengers.length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
}
const booking = await this.prisma.booking.create({

View File

@@ -204,43 +204,41 @@ export class GuestBookingService {
const taxesMinor = 0;
// 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;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? childUnitFare) : 0;
}
return { ...p, fareMinor };
});
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
// Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
// Server-computed sum from per-seat fares is the authoritative total when all
// seated passengers supplied seatFareMinor. This prevents a frontend race
// condition (fareBreakdown not yet loaded → only partial fares summed →
// reviewedTotalMinor reflects one passenger's fare instead of all).
const displayCurrency = dto.displayCurrency || Currency.ETB;
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
let displayTotalMinor: number;
let resolvedTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
if (allFaresProvided && !isPackageOneway) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
// For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor
// so BookingSeat records store the actual berth price, not the tier minimum.
if (isPackageOneway && seatedPassengers.length > 0) {
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (allFaresProvided) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
} else {
// fare engine returns ETB — convert forward to display currency
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
@@ -494,22 +492,18 @@ export class GuestBookingService {
: 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;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.seatId ? (p.seatFareMinor ?? outboundChildUnitFare) : 0;
returnFareMinor = p.seatId ? (p.returnSeatFareMinor ?? returnChildUnitFare) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
@@ -519,25 +513,28 @@ export class GuestBookingService {
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
if (allRTFaresProvided && !isPackageRoundTrip) {
// Server-computed sum is authoritative — prevents race-condition under-count.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor.
if (isPackageRoundTrip) {
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (allRTFaresProvided && !isPackageRoundTrip) {
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;

View File

@@ -236,18 +236,27 @@ export class PackagesService {
});
if (!pkg) throw new NotFoundException('Package not found');
// Fetch live route stops so the departure station dropdown always reflects
// the current route definition, not stale TripStopTime snapshots.
// Build departure station list from live route stops when a routeId exists,
// falling back to the schedule's own stopTimes (already included in the query).
let routeStops: { sequence: number; station: any }[] = [];
if (pkg.outboundSchedule.routeId) {
const stops = await this.prisma.routeStop.findMany({
where: { routeId: pkg.outboundSchedule.routeId },
orderBy: { sequence: 'asc' },
});
const stationIds = stops.map((s) => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
if (stops.length > 0) {
const stationIds = stops.map((s) => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
}
}
// Fall back to the schedule's own TripStopTimes when RouteStop table has no rows
// for this route (e.g. route exists but stops were never seeded).
if (routeStops.length === 0) {
routeStops = (pkg.outboundSchedule.stopTimes ?? [])
.filter((st: any) => st.station)
.map((st: any) => ({ sequence: st.sequence, station: st.station }));
}
return {

View File

@@ -595,10 +595,20 @@ export class SearchService {
});
const freeChildrenAllowed = groupFare.freeChildrenCount;
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
// Pre-compute isFree per passenger index synchronously so the race-free
// counter assignment isn't corrupted by concurrent Promise.all resolution.
let freeChildrenUsed = 0;
const isFreeByIndex = categorised.map(p => {
if (p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed) {
freeChildrenUsed++;
return true;
}
return false;
});
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
const passengerLines = await Promise.all(
categorised.map(async (p) => {
categorised.map(async (p, idx) => {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
@@ -610,8 +620,7 @@ export class SearchService {
childCount: 0,
});
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
if (isFree) freeChildrenUsed++;
const isFree = isFreeByIndex[idx];
const fareMinor = isFree
? fare.premiumPerPassenger + fare.insurancePerPassenger