fix(bookings): apply promo discount to client-subtotal totals in guest/authenticated booking

createGuestBooking (ONE_WAY) and createGuestRoundTripBooking overwrote the
authoritative discounted total with an undiscounted client-provided subtotal
(per-seat sum or reviewedTotalMinor) whenever one was present, silently
dropping a valid promos discount at booking-creation time, even though the
same promo was correctly applied in the fare-breakdown/quote step. Only the
no-client-subtotal fallback branch subtracted discountMinor.

Mirrors the existing usedClientSubtotal fix already present in
BookingsService.createOneWayBooking (H-13): subtract discountMinor from the
client subtotal before storing it, in ETB and display currency. TRANSIT and
ROUND_TRIP_TRANSIT were unaffected since neither overrides with a client
subtotal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Muluhabt
2026-07-28 09:42:27 +03:00
parent 1d6f89df29
commit acd4b37d59

View File

@@ -294,8 +294,12 @@ export class GuestBookingService {
let displayTotalMinor: number;
let resolvedTotalMinor: 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 && !isPackageOneway) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
usedClientSubtotal = true;
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
if (seatedPassengers.length > 0) {
@@ -306,6 +310,7 @@ export class GuestBookingService {
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
usedClientSubtotal = true;
} else {
// fare engine returns ETB — convert forward to display currency
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
@@ -313,6 +318,18 @@ export class GuestBookingService {
? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
: etbTotal;
}
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
// customer is charged the discounted price. The non-client-subtotal branch above already
// nets the discount out of etbTotal, so it's excluded here to avoid double-subtracting.
if (usedClientSubtotal && discountMinor > 0) {
const discountDisplayMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
: discountMinor;
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
}
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
@@ -834,17 +851,16 @@ export class GuestBookingService {
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
// 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 (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;
usedClientSubtotal = true;
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
@@ -855,11 +871,24 @@ export class GuestBookingService {
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
usedClientSubtotal = true;
}
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
// customer is charged the discounted price. The no-override case above already starts from a
// discounted displayTotalMinor, so it's excluded here to avoid double-subtracting.
if (usedClientSubtotal && discountMinor > 0) {
const discountDisplayMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
: discountMinor;
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
}
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');