Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

View File

@@ -863,6 +863,9 @@ export class BookingsService {
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
let resolvedTotalMinor: number;
let displayTotalMinor: number;
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
let usedClientSubtotal = false;
if (allFaresProvided && !dto.packageId) {
// Server has every passenger's berth fare — sum is the authoritative display total.
@@ -870,6 +873,7 @@ export class BookingsService {
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
usedClientSubtotal = true;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
@@ -891,13 +895,35 @@ export class BookingsService {
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
usedClientSubtotal = true;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
: resolvedTotalMinor;
}
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
// H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently
// dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown).
// When the total came from that client subtotal, apply the authoritative promo discount so the
// customer is charged the discounted price. No-op when no promo applies (discountMinor === 0).
// The fallback branch above already books fareCalculation.totalMinor (discount included), so it is
// excluded via usedClientSubtotal to avoid double-subtracting.
if (usedClientSubtotal && fareCalculation.discountMinor > 0) {
resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor);
const discountDisplayMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency)
: fareCalculation.discountMinor;
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
}
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`);
// C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor
// is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net
// of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it
// is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total —
// still pass; the tolerance absorbs FX-conversion rounding.
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
const booking = await this.prisma.booking.create({
data: {
@@ -1028,6 +1054,9 @@ export class BookingsService {
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
}
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
const authoritativeTotalMinor = totalMinor;
const taxesMinor = 0;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
@@ -1093,6 +1122,9 @@ export class BookingsService {
: dto.reviewedTotalMinor;
}
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -1675,6 +1707,21 @@ export class BookingsService {
};
}
/**
* C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed
* authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise
* the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor /
* reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is
* persisted.
*/
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
throw new BadRequestException('Booking total does not match the authoritative fare');
}
}
private async calculateFare(
scheduleId: string,
seatClassId: string,

View File

@@ -1,4 +1,4 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service';
@@ -42,6 +42,8 @@ function calculateAge(dateOfBirth: Date): number {
@Injectable()
export class GuestBookingService {
private readonly logger = new Logger(GuestBookingService.name);
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
@@ -52,6 +54,21 @@ export class GuestBookingService {
private eventEmitter: EventEmitter2,
) { }
/**
* C-1 protection (guest path): reject a booking whose ETB charge basis is below the
* server-recomputed authoritative fare. A floor (not equality) so legitimate berth surcharges —
* which only raise the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
* nothing is persisted.
*/
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
throw new BadRequestException('Booking total does not match the authoritative fare');
}
}
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
// The portal calls /passengers/save-details before booking but doesn't re-send contact
@@ -250,6 +267,9 @@ export class GuestBookingService {
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking');
// Resolve or create the guest Passenger record
const firstPassenger = passengersData[0];
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
@@ -485,6 +505,9 @@ export class GuestBookingService {
const taxesMinor = 0;
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
const authoritativeTotalMinor = totalMinor;
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = displayCurrency !== Currency.ETB
@@ -540,6 +563,9 @@ export class GuestBookingService {
: displayTotalMinor;
}
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
// Create or resolve guest passenger (same as one-way)
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);