mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Adding all the tests and fixes to the passengers app
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user