@ticketboothapp/booking 1.2.58 → 1.2.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -79,6 +79,16 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
|
79
79
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
80
80
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Change-booking diagnostics in the console on local machines only.
|
|
84
|
+
* Uses hostname (not NODE_ENV): bundled packages often inline production, so `development` was never true in the browser.
|
|
85
|
+
*/
|
|
86
|
+
function isLocalhostChangeBookingDebug(): boolean {
|
|
87
|
+
if (typeof window === 'undefined') return false;
|
|
88
|
+
const h = window.location.hostname;
|
|
89
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost');
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
/** Live selection snapshot for change-booking compare UI (parent dialog). */
|
|
83
93
|
export interface ChangeFlowSelectionPreview {
|
|
84
94
|
tourName: string;
|
|
@@ -143,6 +153,140 @@ function normalizeLineLabelForCompare(label: string): string {
|
|
|
143
153
|
.trim();
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
/** Mirrors BookingFlow protected ticket subtotal for a given order summary snapshot. */
|
|
157
|
+
function computeChangeFlowProtectedTicketSubtotalForOrderSummary(
|
|
158
|
+
sameItinerary: boolean,
|
|
159
|
+
ticketLineItems: OrderSummary['ticketLineItems'],
|
|
160
|
+
ticketFloors: Map<string, number>,
|
|
161
|
+
initialQtyByCat: Map<string, number>,
|
|
162
|
+
): number {
|
|
163
|
+
if (!sameItinerary) {
|
|
164
|
+
return ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0);
|
|
165
|
+
}
|
|
166
|
+
return ticketLineItems.reduce((sum, line) => {
|
|
167
|
+
const category = line.category?.trim().toUpperCase();
|
|
168
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
169
|
+
if (!category || qty <= 0) return sum;
|
|
170
|
+
const bookedUnitPrice = ticketFloors.get(category);
|
|
171
|
+
if (bookedUnitPrice == null) return sum + line.itemTotal;
|
|
172
|
+
const baselineQty = initialQtyByCat.get(category) ?? 0;
|
|
173
|
+
const protectedQty = Math.min(qty, baselineQty);
|
|
174
|
+
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
175
|
+
const liveUnit = qty > 0 ? Math.max(0, Number(line.itemTotal) || 0) / qty : 0;
|
|
176
|
+
return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnit;
|
|
177
|
+
}, 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function computeChangeFlowProtectedFeeSubtotalForOrderSummary(
|
|
181
|
+
sameItinerary: boolean,
|
|
182
|
+
totalQuantity: number,
|
|
183
|
+
feeLineItems: OrderSummary['feeLineItems'],
|
|
184
|
+
feeFloors: Map<string, number>,
|
|
185
|
+
initialTicketCount: number,
|
|
186
|
+
): number {
|
|
187
|
+
if (!sameItinerary || totalQuantity <= 0) {
|
|
188
|
+
return feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0);
|
|
189
|
+
}
|
|
190
|
+
const protectedP = Math.min(initialTicketCount, totalQuantity);
|
|
191
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
192
|
+
return feeLineItems.reduce((sum, line) => {
|
|
193
|
+
const key = normalizeLineLabelForCompare(line.name || '');
|
|
194
|
+
const bookedUnitPrice = key ? feeFloors.get(key) : undefined;
|
|
195
|
+
if (bookedUnitPrice == null) return sum + line.totalAmount;
|
|
196
|
+
const liveTotal = Math.max(0, Number(line.totalAmount) || 0);
|
|
197
|
+
const livePer = totalQuantity > 0 ? liveTotal / totalQuantity : 0;
|
|
198
|
+
return sum + protectedP * bookedUnitPrice + incrementalP * livePer;
|
|
199
|
+
}, 0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function computeChangeFlowProtectedReturnForOrderSummary(
|
|
203
|
+
sameItinerary: boolean,
|
|
204
|
+
totalQuantity: number,
|
|
205
|
+
returnPriceAdjustment: number,
|
|
206
|
+
returnFloorPerPerson: number | null,
|
|
207
|
+
initialTicketCount: number,
|
|
208
|
+
selectedReturnOption: ReturnOption | null,
|
|
209
|
+
currency: Currency,
|
|
210
|
+
): number {
|
|
211
|
+
if (!sameItinerary || totalQuantity <= 0) return returnPriceAdjustment;
|
|
212
|
+
if (returnFloorPerPerson == null) return returnPriceAdjustment;
|
|
213
|
+
const rawPerPerson = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
214
|
+
const protectedP = Math.min(initialTicketCount, totalQuantity);
|
|
215
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
216
|
+
return protectedP * returnFloorPerPerson + incrementalP * rawPerPerson;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function computeChangeFlowEffectiveSubtotalBeforeAddOnsForOrderSummary(
|
|
220
|
+
isChangeFlow: boolean,
|
|
221
|
+
sameItinerary: boolean,
|
|
222
|
+
os: OrderSummary,
|
|
223
|
+
ticketFloors: Map<string, number>,
|
|
224
|
+
initialQtyByCat: Map<string, number>,
|
|
225
|
+
feeFloors: Map<string, number>,
|
|
226
|
+
initialTicketCount: number,
|
|
227
|
+
returnFloorPerPerson: number | null,
|
|
228
|
+
selectedReturnOption: ReturnOption | null,
|
|
229
|
+
currency: Currency,
|
|
230
|
+
): number {
|
|
231
|
+
const { subtotal, ticketLineItems, feeLineItems, returnPriceAdjustment, totalQuantity } = os;
|
|
232
|
+
if (!isChangeFlow || !sameItinerary) return subtotal;
|
|
233
|
+
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
234
|
+
(s, l) => s + Math.max(0, Number(l.itemTotal) || 0),
|
|
235
|
+
0,
|
|
236
|
+
);
|
|
237
|
+
const currentFeeSubtotal = feeLineItems.reduce((s, l) => s + Math.max(0, Number(l.totalAmount) || 0), 0);
|
|
238
|
+
const protectedT = computeChangeFlowProtectedTicketSubtotalForOrderSummary(
|
|
239
|
+
sameItinerary,
|
|
240
|
+
ticketLineItems,
|
|
241
|
+
ticketFloors,
|
|
242
|
+
initialQtyByCat,
|
|
243
|
+
);
|
|
244
|
+
const protectedF = computeChangeFlowProtectedFeeSubtotalForOrderSummary(
|
|
245
|
+
sameItinerary,
|
|
246
|
+
totalQuantity,
|
|
247
|
+
feeLineItems,
|
|
248
|
+
feeFloors,
|
|
249
|
+
initialTicketCount,
|
|
250
|
+
);
|
|
251
|
+
const protectedR = computeChangeFlowProtectedReturnForOrderSummary(
|
|
252
|
+
sameItinerary,
|
|
253
|
+
totalQuantity,
|
|
254
|
+
returnPriceAdjustment,
|
|
255
|
+
returnFloorPerPerson,
|
|
256
|
+
initialTicketCount,
|
|
257
|
+
selectedReturnOption,
|
|
258
|
+
currency,
|
|
259
|
+
);
|
|
260
|
+
return (
|
|
261
|
+
subtotal -
|
|
262
|
+
currentTicketSubtotal -
|
|
263
|
+
currentFeeSubtotal -
|
|
264
|
+
returnPriceAdjustment +
|
|
265
|
+
protectedT +
|
|
266
|
+
protectedF +
|
|
267
|
+
protectedR
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Matches BookingFlow totalPrice from effectiveSubtotalBeforeAddOns + add-ons + tax − promo. */
|
|
272
|
+
function computeTotalPriceFromEffectiveSubtotalBeforeAddOns(
|
|
273
|
+
effectiveSubtotalBeforeAddOns: number,
|
|
274
|
+
addOnTotal: number,
|
|
275
|
+
effectivePromoDiscountAmount: number,
|
|
276
|
+
isGiftCard: boolean,
|
|
277
|
+
isVoucher: boolean,
|
|
278
|
+
isTaxIncludedInPrice: boolean,
|
|
279
|
+
pricingConfig: PricingConfig | null,
|
|
280
|
+
): number {
|
|
281
|
+
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
282
|
+
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
|
|
283
|
+
const effectiveTax =
|
|
284
|
+
effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
285
|
+
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
286
|
+
: taxOnSubtotal;
|
|
287
|
+
return effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
288
|
+
}
|
|
289
|
+
|
|
146
290
|
function deriveAddOnSelectionsFromReceiptLines(
|
|
147
291
|
addOns: AddOn[],
|
|
148
292
|
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
@@ -1779,7 +1923,55 @@ export function BookingFlow({
|
|
|
1779
1923
|
}
|
|
1780
1924
|
return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
|
|
1781
1925
|
}, [product.pickupLocations]);
|
|
1926
|
+
const changeFlowInitialTicketCountForSeatCredit = useMemo(() => {
|
|
1927
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return 0;
|
|
1928
|
+
return initialValues.bookingItems.reduce(
|
|
1929
|
+
(sum, item) => sum + Math.max(0, Number(item.count) || 0),
|
|
1930
|
+
0,
|
|
1931
|
+
);
|
|
1932
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
1782
1933
|
|
|
1934
|
+
const changeFlowSeatCreditForOutboundAvailability = useCallback(
|
|
1935
|
+
(availability: Availability): number => {
|
|
1936
|
+
if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1937
|
+
const initialAvailabilityId = initialValues?.availabilityId?.trim();
|
|
1938
|
+
const availabilityId = availability.availabilityId?.trim();
|
|
1939
|
+
if (initialAvailabilityId && availabilityId) {
|
|
1940
|
+
return initialAvailabilityId === availabilityId ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1941
|
+
}
|
|
1942
|
+
if (!initialValues?.dateTime) return 0;
|
|
1943
|
+
const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
|
|
1944
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
1945
|
+
if (selectedMs !== initialMs) return 0;
|
|
1946
|
+
const selectedOptionId = availability.productOptionId?.trim() || null;
|
|
1947
|
+
const initialOptionId = initialValues.productOptionId?.trim() || null;
|
|
1948
|
+
if (!selectedOptionId || !initialOptionId) return changeFlowInitialTicketCountForSeatCredit;
|
|
1949
|
+
return selectedOptionId === initialOptionId ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1950
|
+
},
|
|
1951
|
+
[
|
|
1952
|
+
isChangeFlow,
|
|
1953
|
+
changeFlowInitialTicketCountForSeatCredit,
|
|
1954
|
+
initialValues?.availabilityId,
|
|
1955
|
+
initialValues?.dateTime,
|
|
1956
|
+
initialValues?.productOptionId,
|
|
1957
|
+
],
|
|
1958
|
+
);
|
|
1959
|
+
const changeFlowSeatCreditForReturnAvailabilityId = useCallback(
|
|
1960
|
+
(returnAvailabilityId: string | null | undefined): number => {
|
|
1961
|
+
if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1962
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
1963
|
+
const selectedReturnAvailabilityId = returnAvailabilityId?.trim() || null;
|
|
1964
|
+
if (!selectedReturnAvailabilityId) {
|
|
1965
|
+
return initialReturnAvailabilityId == null ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1966
|
+
}
|
|
1967
|
+
if (!initialReturnAvailabilityId) return 0;
|
|
1968
|
+
return selectedReturnAvailabilityId === initialReturnAvailabilityId
|
|
1969
|
+
? changeFlowInitialTicketCountForSeatCredit
|
|
1970
|
+
: 0;
|
|
1971
|
+
},
|
|
1972
|
+
[isChangeFlow, changeFlowInitialTicketCountForSeatCredit, initialValues?.returnAvailabilityId],
|
|
1973
|
+
);
|
|
1974
|
+
|
|
1783
1975
|
// Calculate pickup times based on availability times + pickup location offset
|
|
1784
1976
|
interface PickupTimeInfo extends Availability {
|
|
1785
1977
|
pickupTime: string;
|
|
@@ -1802,6 +1994,8 @@ export function BookingFlow({
|
|
|
1802
1994
|
return timesForSelectedDate.map(avail => {
|
|
1803
1995
|
// Parse the dateTime (which should already be in company timezone from backend)
|
|
1804
1996
|
const availabilityTime = parseISO(avail.dateTime);
|
|
1997
|
+
const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
|
|
1998
|
+
const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
|
|
1805
1999
|
|
|
1806
2000
|
// Only apply offset if it's set and > 0 and location is selected
|
|
1807
2001
|
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
@@ -1822,13 +2016,22 @@ export function BookingFlow({
|
|
|
1822
2016
|
|
|
1823
2017
|
return {
|
|
1824
2018
|
...avail,
|
|
2019
|
+
vacancies: adjustedVacancies,
|
|
1825
2020
|
pickupTime: pickupTime.toISOString(),
|
|
1826
2021
|
displayTime,
|
|
1827
2022
|
originalTime,
|
|
1828
2023
|
displayTimeRange,
|
|
1829
2024
|
};
|
|
1830
2025
|
});
|
|
1831
|
-
}, [
|
|
2026
|
+
}, [
|
|
2027
|
+
selectedDate,
|
|
2028
|
+
selectedPickupLocation,
|
|
2029
|
+
timesForSelectedDate,
|
|
2030
|
+
pickupLocationSkipped,
|
|
2031
|
+
maxTimeOffsetMinutes,
|
|
2032
|
+
companyTimezone,
|
|
2033
|
+
changeFlowSeatCreditForOutboundAvailability,
|
|
2034
|
+
]);
|
|
1832
2035
|
|
|
1833
2036
|
// Check if any pickup time has "most popular" tag (memoized for performance)
|
|
1834
2037
|
const hasAnyMostPopular = useMemo(() => {
|
|
@@ -2134,25 +2337,121 @@ export function BookingFlow({
|
|
|
2134
2337
|
return feeUnitByLabel;
|
|
2135
2338
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2136
2339
|
|
|
2340
|
+
const changeFlowInitialTicketQtyByCategory = useMemo(() => {
|
|
2341
|
+
const qtyByCategory = new Map<string, number>();
|
|
2342
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
|
|
2343
|
+
for (const item of initialValues.bookingItems) {
|
|
2344
|
+
const category = item.category?.trim().toUpperCase();
|
|
2345
|
+
if (!category) continue;
|
|
2346
|
+
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2347
|
+
}
|
|
2348
|
+
return qtyByCategory;
|
|
2349
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
2350
|
+
const changeFlowInitialTicketCount = useMemo(() => {
|
|
2351
|
+
let sum = 0;
|
|
2352
|
+
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2353
|
+
return sum;
|
|
2354
|
+
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2355
|
+
const changeFlowOutboundMatchesOriginalSelection = useMemo(() => {
|
|
2356
|
+
if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
|
|
2357
|
+
const initialAvailabilityId = initialValues.availabilityId?.trim();
|
|
2358
|
+
const selectedAvailabilityId = selectedAvailability.availabilityId?.trim();
|
|
2359
|
+
const idsMatch =
|
|
2360
|
+
Boolean(initialAvailabilityId && selectedAvailabilityId) &&
|
|
2361
|
+
initialAvailabilityId === selectedAvailabilityId;
|
|
2362
|
+
if (idsMatch) return true;
|
|
2363
|
+
// Same wall time + option after API/list refreshes can carry a new availability row id; still "same" for price floors.
|
|
2364
|
+
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2365
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2366
|
+
if (selectedMs !== initialMs) return false;
|
|
2367
|
+
const selectedOptionId = selectedAvailability.productOptionId?.trim() || null;
|
|
2368
|
+
const initialOptionId = initialValues.productOptionId?.trim() || null;
|
|
2369
|
+
return !selectedOptionId || !initialOptionId || selectedOptionId === initialOptionId;
|
|
2370
|
+
}, [
|
|
2371
|
+
isChangeFlow,
|
|
2372
|
+
selectedAvailability,
|
|
2373
|
+
initialValues?.availabilityId,
|
|
2374
|
+
initialValues?.dateTime,
|
|
2375
|
+
initialValues?.productOptionId,
|
|
2376
|
+
]);
|
|
2377
|
+
const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
|
|
2378
|
+
if (!isChangeFlow) return false;
|
|
2379
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
2380
|
+
const selectedReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
|
|
2381
|
+
const initialReturnDt = initialValues?.returnDateTime?.trim() || null;
|
|
2382
|
+
const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
|
|
2383
|
+
|
|
2384
|
+
if (!selectedReturnAvailabilityId) {
|
|
2385
|
+
return initialReturnAvailabilityId == null;
|
|
2386
|
+
}
|
|
2387
|
+
if (initialReturnAvailabilityId && selectedReturnAvailabilityId) {
|
|
2388
|
+
if (initialReturnAvailabilityId === selectedReturnAvailabilityId) return true;
|
|
2389
|
+
if (initialReturnDt && selectedReturnDt) {
|
|
2390
|
+
return (
|
|
2391
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2392
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
return false;
|
|
2396
|
+
}
|
|
2397
|
+
// Bookings often store returnDateTime without returnAvailabilityId; still the same return if wall times match.
|
|
2398
|
+
if (!initialReturnAvailabilityId && initialReturnDt && selectedReturnDt) {
|
|
2399
|
+
return (
|
|
2400
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2401
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
return false;
|
|
2405
|
+
}, [
|
|
2406
|
+
isChangeFlow,
|
|
2407
|
+
initialValues?.returnAvailabilityId,
|
|
2408
|
+
initialValues?.returnDateTime,
|
|
2409
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
2410
|
+
selectedReturnOption?.dateTime,
|
|
2411
|
+
]);
|
|
2412
|
+
/** Same outbound + return as original booking: incremental seats use live pricing; receipt floors apply only after itinerary changes. */
|
|
2413
|
+
const changeFlowSameItineraryAsOriginalBooking = useMemo(
|
|
2414
|
+
() =>
|
|
2415
|
+
isChangeFlow &&
|
|
2416
|
+
changeFlowOutboundMatchesOriginalSelection &&
|
|
2417
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2418
|
+
[isChangeFlow, changeFlowOutboundMatchesOriginalSelection, changeFlowReturnMatchesOriginalSelection],
|
|
2419
|
+
);
|
|
2420
|
+
|
|
2137
2421
|
const returnOptionsWithFloor = useMemo(() => {
|
|
2138
2422
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
2139
|
-
if (!isChangeFlow
|
|
2423
|
+
if (!isChangeFlow && changeFlowReturnUnitFloorPerPerson == null) return options;
|
|
2140
2424
|
return options.map((opt) => {
|
|
2425
|
+
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2426
|
+
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2141
2427
|
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2142
|
-
const
|
|
2143
|
-
|
|
2428
|
+
const applyReturnFloor =
|
|
2429
|
+
changeFlowReturnUnitFloorPerPerson != null && (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking);
|
|
2430
|
+
const flooredPerPerson = applyReturnFloor
|
|
2431
|
+
? Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson!)
|
|
2432
|
+
: rawPerPerson;
|
|
2433
|
+
if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
|
|
2144
2434
|
return {
|
|
2145
2435
|
...opt,
|
|
2436
|
+
vacancies: adjustedVacancies,
|
|
2146
2437
|
priceAdjustmentByCurrency: {
|
|
2147
2438
|
...(opt.priceAdjustmentByCurrency ?? {}),
|
|
2148
2439
|
[currency]: flooredPerPerson,
|
|
2149
2440
|
},
|
|
2150
2441
|
};
|
|
2151
2442
|
});
|
|
2152
|
-
}, [
|
|
2443
|
+
}, [
|
|
2444
|
+
selectedAvailability?.returnOptions,
|
|
2445
|
+
isChangeFlow,
|
|
2446
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
2447
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2448
|
+
currency,
|
|
2449
|
+
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2450
|
+
]);
|
|
2153
2451
|
|
|
2154
2452
|
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2155
2453
|
if (!selectedReturnOption || !isChangeFlow || changeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
|
|
2454
|
+
if (changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
|
|
2156
2455
|
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2157
2456
|
const flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
|
|
2158
2457
|
if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
|
|
@@ -2163,7 +2462,30 @@ export function BookingFlow({
|
|
|
2163
2462
|
[currency]: flooredPerPerson,
|
|
2164
2463
|
},
|
|
2165
2464
|
};
|
|
2166
|
-
}, [
|
|
2465
|
+
}, [
|
|
2466
|
+
selectedReturnOption,
|
|
2467
|
+
isChangeFlow,
|
|
2468
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
2469
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2470
|
+
currency,
|
|
2471
|
+
]);
|
|
2472
|
+
|
|
2473
|
+
const returnOptionForOrderSummary = useMemo(() => {
|
|
2474
|
+
if (isChangeFlow && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption ?? null;
|
|
2475
|
+
return selectedReturnOptionWithFloor;
|
|
2476
|
+
}, [isChangeFlow, changeFlowSameItineraryAsOriginalBooking, selectedReturnOption, selectedReturnOptionWithFloor]);
|
|
2477
|
+
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2478
|
+
if (!selectedAvailability) return 0;
|
|
2479
|
+
const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
|
|
2480
|
+
return Math.max(0, selectedAvailability.vacancies ?? 0) + seatCredit;
|
|
2481
|
+
}, [selectedAvailability, changeFlowSeatCreditForOutboundAvailability]);
|
|
2482
|
+
const effectiveSelectedReturnVacancies = useMemo(() => {
|
|
2483
|
+
if (!selectedReturnOption) return null;
|
|
2484
|
+
const seatCredit = changeFlowSeatCreditForReturnAvailabilityId(
|
|
2485
|
+
selectedReturnOption.returnAvailabilityId
|
|
2486
|
+
);
|
|
2487
|
+
return Math.max(0, selectedReturnOption.vacancies ?? 0) + seatCredit;
|
|
2488
|
+
}, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
|
|
2167
2489
|
|
|
2168
2490
|
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2169
2491
|
const pricing = useMemo(() => {
|
|
@@ -2257,56 +2579,71 @@ export function BookingFlow({
|
|
|
2257
2579
|
computeOrderSummary(
|
|
2258
2580
|
quantities,
|
|
2259
2581
|
pricing,
|
|
2260
|
-
|
|
2582
|
+
returnOptionForOrderSummary,
|
|
2261
2583
|
pricingConfig ?? null,
|
|
2262
2584
|
currency,
|
|
2263
2585
|
hasFees,
|
|
2264
2586
|
cancellationPolicyId
|
|
2265
2587
|
),
|
|
2266
|
-
[quantities, pricing,
|
|
2588
|
+
[quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2267
2589
|
);
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
for (const
|
|
2273
|
-
|
|
2274
|
-
if (!category) continue;
|
|
2275
|
-
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2590
|
+
|
|
2591
|
+
/** Initial booking ticket counts on the same pricing grid (for catalog delta vs receipt anchoring). */
|
|
2592
|
+
const changeFlowBaselineQuantitiesForSummary = useMemo((): Record<string, number> => {
|
|
2593
|
+
const next: Record<string, number> = {};
|
|
2594
|
+
for (const rate of pricing) {
|
|
2595
|
+
next[rate.category] = 0;
|
|
2276
2596
|
}
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2597
|
+
for (const item of initialValues?.bookingItems ?? []) {
|
|
2598
|
+
const c = item.category?.trim();
|
|
2599
|
+
if (c) next[c] = Math.max(0, Number(item.count) || 0);
|
|
2600
|
+
}
|
|
2601
|
+
return next;
|
|
2602
|
+
}, [pricing, initialValues?.bookingItems]);
|
|
2603
|
+
|
|
2604
|
+
const orderSummaryChangeFlowBaseline: OrderSummary = useMemo(
|
|
2605
|
+
() =>
|
|
2606
|
+
computeOrderSummary(
|
|
2607
|
+
changeFlowBaselineQuantitiesForSummary,
|
|
2608
|
+
pricing,
|
|
2609
|
+
returnOptionForOrderSummary,
|
|
2610
|
+
pricingConfig ?? null,
|
|
2611
|
+
currency,
|
|
2612
|
+
hasFees,
|
|
2613
|
+
cancellationPolicyId
|
|
2614
|
+
),
|
|
2615
|
+
[
|
|
2616
|
+
changeFlowBaselineQuantitiesForSummary,
|
|
2617
|
+
pricing,
|
|
2618
|
+
returnOptionForOrderSummary,
|
|
2619
|
+
pricingConfig,
|
|
2620
|
+
currency,
|
|
2621
|
+
hasFees,
|
|
2622
|
+
cancellationPolicyId,
|
|
2623
|
+
],
|
|
2624
|
+
);
|
|
2625
|
+
|
|
2626
|
+
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2290
2627
|
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2291
2628
|
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2292
2629
|
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2293
2630
|
0,
|
|
2294
2631
|
);
|
|
2295
|
-
if (!
|
|
2632
|
+
if (!changeFlowSameItineraryAsOriginalBooking) return currentTicketSubtotal;
|
|
2296
2633
|
return ticketLineItems.reduce((sum, line) => {
|
|
2297
2634
|
const category = line.category?.trim().toUpperCase();
|
|
2298
2635
|
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2299
|
-
const liveUnitPrice = qty > 0 ? line.itemTotal / qty : 0;
|
|
2300
2636
|
if (!category || qty <= 0) return sum;
|
|
2301
2637
|
const bookedUnitPrice = changeFlowTicketBookedUnitPriceByCategory.get(category);
|
|
2302
2638
|
if (bookedUnitPrice == null) return sum + line.itemTotal;
|
|
2303
|
-
const baselineQty =
|
|
2639
|
+
const baselineQty = changeFlowInitialTicketQtyByCategory.get(category) ?? 0;
|
|
2304
2640
|
const protectedQty = Math.min(qty, baselineQty);
|
|
2305
2641
|
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
2306
|
-
|
|
2642
|
+
const liveUnit = qty > 0 ? Math.max(0, Number(line.itemTotal) || 0) / qty : 0;
|
|
2643
|
+
return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnit;
|
|
2307
2644
|
}, 0);
|
|
2308
2645
|
}, [
|
|
2309
|
-
|
|
2646
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2310
2647
|
ticketLineItems,
|
|
2311
2648
|
changeFlowTicketBookedUnitPriceByCategory,
|
|
2312
2649
|
changeFlowInitialTicketQtyByCategory,
|
|
@@ -2314,10 +2651,25 @@ export function BookingFlow({
|
|
|
2314
2651
|
/** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
|
|
2315
2652
|
const effectivePartySizeCap = useMemo(() => {
|
|
2316
2653
|
if (!selectedAvailability) return 0;
|
|
2317
|
-
const
|
|
2654
|
+
const outboundSeatCredit =
|
|
2655
|
+
changeFlowOutboundMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2656
|
+
? changeFlowInitialTicketCount
|
|
2657
|
+
: 0;
|
|
2658
|
+
const outbound = Math.max(0, selectedAvailability.vacancies ?? 0) + outboundSeatCredit;
|
|
2318
2659
|
if (selectedReturnOption == null) return outbound;
|
|
2319
|
-
|
|
2320
|
-
|
|
2660
|
+
const returnSeatCredit =
|
|
2661
|
+
changeFlowReturnMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2662
|
+
? changeFlowInitialTicketCount
|
|
2663
|
+
: 0;
|
|
2664
|
+
const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0) + returnSeatCredit;
|
|
2665
|
+
return Math.min(outbound, returnCap);
|
|
2666
|
+
}, [
|
|
2667
|
+
selectedAvailability,
|
|
2668
|
+
selectedReturnOption,
|
|
2669
|
+
changeFlowInitialTicketCount,
|
|
2670
|
+
changeFlowOutboundMatchesOriginalSelection,
|
|
2671
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2672
|
+
]);
|
|
2321
2673
|
|
|
2322
2674
|
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
2323
2675
|
/** Label for display when policy may be forced by promo (not in pricingConfig list). */
|
|
@@ -2378,50 +2730,51 @@ export function BookingFlow({
|
|
|
2378
2730
|
() => ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0),
|
|
2379
2731
|
[ticketLineItems],
|
|
2380
2732
|
);
|
|
2381
|
-
const changeFlowInitialTotalTicketQty =
|
|
2382
|
-
let sum = 0;
|
|
2383
|
-
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2384
|
-
return sum;
|
|
2385
|
-
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2733
|
+
const changeFlowInitialTotalTicketQty = changeFlowInitialTicketCount;
|
|
2386
2734
|
const currentFeeSubtotal = useMemo(
|
|
2387
2735
|
() => feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0),
|
|
2388
2736
|
[feeLineItems],
|
|
2389
2737
|
);
|
|
2390
2738
|
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2391
|
-
if (!
|
|
2739
|
+
if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2740
|
+
const initialParty = changeFlowInitialTicketCount;
|
|
2741
|
+
const protectedP = Math.min(initialParty, totalQuantity);
|
|
2742
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
2392
2743
|
return feeLineItems.reduce((sum, line) => {
|
|
2393
2744
|
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2394
|
-
const currentUnitPrice = line.totalAmount / totalQuantity;
|
|
2395
2745
|
const bookedUnitPrice = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : null;
|
|
2396
2746
|
if (bookedUnitPrice == null) return sum + line.totalAmount;
|
|
2397
|
-
const
|
|
2398
|
-
const
|
|
2399
|
-
return sum +
|
|
2747
|
+
const liveTotal = Math.max(0, Number(line.totalAmount) || 0);
|
|
2748
|
+
const livePer = totalQuantity > 0 ? liveTotal / totalQuantity : 0;
|
|
2749
|
+
return sum + protectedP * bookedUnitPrice + incrementalP * livePer;
|
|
2400
2750
|
}, 0);
|
|
2401
2751
|
}, [
|
|
2402
|
-
|
|
2752
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2403
2753
|
totalQuantity,
|
|
2404
2754
|
currentFeeSubtotal,
|
|
2405
2755
|
feeLineItems,
|
|
2406
2756
|
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2407
|
-
|
|
2757
|
+
changeFlowInitialTicketCount,
|
|
2408
2758
|
]);
|
|
2409
2759
|
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2410
|
-
if (!
|
|
2760
|
+
if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return returnPriceAdjustment;
|
|
2411
2761
|
if (changeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2412
|
-
const
|
|
2413
|
-
const
|
|
2414
|
-
const
|
|
2415
|
-
|
|
2762
|
+
const rawPerPerson = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2763
|
+
const initialParty = changeFlowInitialTicketCount;
|
|
2764
|
+
const protectedP = Math.min(initialParty, totalQuantity);
|
|
2765
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
2766
|
+
return protectedP * changeFlowReturnUnitFloorPerPerson + incrementalP * rawPerPerson;
|
|
2416
2767
|
}, [
|
|
2417
|
-
|
|
2768
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2418
2769
|
totalQuantity,
|
|
2419
2770
|
returnPriceAdjustment,
|
|
2420
2771
|
changeFlowReturnUnitFloorPerPerson,
|
|
2421
|
-
|
|
2772
|
+
changeFlowInitialTicketCount,
|
|
2773
|
+
selectedReturnOption,
|
|
2774
|
+
currency,
|
|
2422
2775
|
]);
|
|
2423
2776
|
const effectiveSubtotalBeforeAddOns =
|
|
2424
|
-
isChangeFlow &&
|
|
2777
|
+
isChangeFlow && changeFlowSameItineraryAsOriginalBooking
|
|
2425
2778
|
? subtotal -
|
|
2426
2779
|
currentTicketSubtotal -
|
|
2427
2780
|
currentFeeSubtotal -
|
|
@@ -2713,9 +3066,93 @@ export function BookingFlow({
|
|
|
2713
3066
|
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
2714
3067
|
: taxOnSubtotal;
|
|
2715
3068
|
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
3069
|
+
/** Cent-round for change quote only; matches server final rounding and avoids float noise in clientProposedTotal. */
|
|
2716
3070
|
const changeFlowProposedTotal = isChangeFlow && originalReceipt
|
|
2717
|
-
? reconcileChangeBookingProposedTotal(totalPrice, originalReceipt.total)
|
|
3071
|
+
? reconcileChangeBookingProposedTotal(Math.round(totalPrice * 100) / 100, originalReceipt.total)
|
|
2718
3072
|
: totalPrice;
|
|
3073
|
+
|
|
3074
|
+
const changeFlowBaselineEffectiveSubtotalBeforeAddOns = useMemo(() => {
|
|
3075
|
+
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveSubtotalBeforeAddOns;
|
|
3076
|
+
return computeChangeFlowEffectiveSubtotalBeforeAddOnsForOrderSummary(
|
|
3077
|
+
true,
|
|
3078
|
+
true,
|
|
3079
|
+
orderSummaryChangeFlowBaseline,
|
|
3080
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
3081
|
+
changeFlowInitialTicketQtyByCategory,
|
|
3082
|
+
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
3083
|
+
changeFlowInitialTicketCount,
|
|
3084
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
3085
|
+
selectedReturnOption,
|
|
3086
|
+
currency,
|
|
3087
|
+
);
|
|
3088
|
+
}, [
|
|
3089
|
+
isChangeFlow,
|
|
3090
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
3091
|
+
effectiveSubtotalBeforeAddOns,
|
|
3092
|
+
orderSummaryChangeFlowBaseline,
|
|
3093
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
3094
|
+
changeFlowInitialTicketQtyByCategory,
|
|
3095
|
+
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
3096
|
+
changeFlowInitialTicketCount,
|
|
3097
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
3098
|
+
selectedReturnOption,
|
|
3099
|
+
currency,
|
|
3100
|
+
]);
|
|
3101
|
+
|
|
3102
|
+
const changeFlowBaselineEffectiveSubtotalFull = useMemo(() => {
|
|
3103
|
+
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveSubtotal;
|
|
3104
|
+
return changeFlowBaselineEffectiveSubtotalBeforeAddOns + addOnTotal;
|
|
3105
|
+
}, [
|
|
3106
|
+
isChangeFlow,
|
|
3107
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
3108
|
+
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3109
|
+
addOnTotal,
|
|
3110
|
+
effectiveSubtotal,
|
|
3111
|
+
]);
|
|
3112
|
+
|
|
3113
|
+
const changeFlowBaselineEffectiveTax = useMemo(() => {
|
|
3114
|
+
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveTax;
|
|
3115
|
+
const es = changeFlowBaselineEffectiveSubtotalFull;
|
|
3116
|
+
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : es * (pricingConfig?.taxRate ?? 0);
|
|
3117
|
+
return effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
3118
|
+
? (es - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3119
|
+
: taxOnSubtotal;
|
|
3120
|
+
}, [
|
|
3121
|
+
isChangeFlow,
|
|
3122
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
3123
|
+
changeFlowBaselineEffectiveSubtotalFull,
|
|
3124
|
+
effectiveTax,
|
|
3125
|
+
isTaxIncludedInPrice,
|
|
3126
|
+
pricingConfig?.taxRate,
|
|
3127
|
+
effectivePromoDiscountAmount,
|
|
3128
|
+
isGiftCard,
|
|
3129
|
+
isVoucher,
|
|
3130
|
+
]);
|
|
3131
|
+
|
|
3132
|
+
const changeFlowBaselineTotalPrice = useMemo(() => {
|
|
3133
|
+
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) {
|
|
3134
|
+
return totalPrice;
|
|
3135
|
+
}
|
|
3136
|
+
return computeTotalPriceFromEffectiveSubtotalBeforeAddOns(
|
|
3137
|
+
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3138
|
+
addOnTotal,
|
|
3139
|
+
effectivePromoDiscountAmount,
|
|
3140
|
+
isGiftCard,
|
|
3141
|
+
isVoucher,
|
|
3142
|
+
isTaxIncludedInPrice,
|
|
3143
|
+
pricingConfig,
|
|
3144
|
+
);
|
|
3145
|
+
}, [
|
|
3146
|
+
isChangeFlow,
|
|
3147
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
3148
|
+
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3149
|
+
addOnTotal,
|
|
3150
|
+
effectivePromoDiscountAmount,
|
|
3151
|
+
isGiftCard,
|
|
3152
|
+
isVoucher,
|
|
3153
|
+
isTaxIncludedInPrice,
|
|
3154
|
+
pricingConfig,
|
|
3155
|
+
]);
|
|
2719
3156
|
const changeSelectionDetails = useMemo(() => {
|
|
2720
3157
|
if (!isChangeFlow || !initialValues) {
|
|
2721
3158
|
return {
|
|
@@ -2885,15 +3322,6 @@ export function BookingFlow({
|
|
|
2885
3322
|
showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
|
|
2886
3323
|
? providerPricingUi.totalsPreview
|
|
2887
3324
|
: null;
|
|
2888
|
-
const displayChangeFlowProposedTotal = providerTotalsPreview
|
|
2889
|
-
? providerTotalsPreview.totalAmount
|
|
2890
|
-
: changeFlowProposedTotal;
|
|
2891
|
-
const displayChangeFlowSubtotal = providerTotalsPreview
|
|
2892
|
-
? providerTotalsPreview.subtotalBeforeTax
|
|
2893
|
-
: effectiveSubtotal;
|
|
2894
|
-
const displayChangeFlowTax = providerTotalsPreview
|
|
2895
|
-
? providerTotalsPreview.taxAmount
|
|
2896
|
-
: effectiveTax;
|
|
2897
3325
|
const providerHasEditedLineOverrides =
|
|
2898
3326
|
isProviderDashboardChange &&
|
|
2899
3327
|
providerQuotedLines.some((line) => {
|
|
@@ -2921,6 +3349,40 @@ export function BookingFlow({
|
|
|
2921
3349
|
const hasEffectiveChangeSelection =
|
|
2922
3350
|
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
2923
3351
|
|
|
3352
|
+
/**
|
|
3353
|
+
* Same itinerary: amount owed must be catalog delta from the *initial* party size, anchored to what
|
|
3354
|
+
* was actually paid (`originalReceipt`). Otherwise `totalPrice − receipt.total` includes a bogus gap
|
|
3355
|
+
* whenever list pricing ≠ historical receipt (e.g. ~$703 + real ticket delta).
|
|
3356
|
+
*/
|
|
3357
|
+
const changeFlowReceiptAnchoredCatalogTotalRaw =
|
|
3358
|
+
isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3359
|
+
? originalReceipt.total + (totalPrice - changeFlowBaselineTotalPrice)
|
|
3360
|
+
: totalPrice;
|
|
3361
|
+
|
|
3362
|
+
const changeFlowProposedTotalResolved =
|
|
3363
|
+
isChangeFlow && originalReceipt
|
|
3364
|
+
? reconcileChangeBookingProposedTotal(
|
|
3365
|
+
Math.round(changeFlowReceiptAnchoredCatalogTotalRaw * 100) / 100,
|
|
3366
|
+
originalReceipt.total,
|
|
3367
|
+
)
|
|
3368
|
+
: changeFlowProposedTotal;
|
|
3369
|
+
|
|
3370
|
+
const displayChangeFlowProposedTotal = providerTotalsPreview
|
|
3371
|
+
? providerTotalsPreview.totalAmount
|
|
3372
|
+
: changeFlowProposedTotalResolved;
|
|
3373
|
+
const displayChangeFlowSubtotal = providerTotalsPreview
|
|
3374
|
+
? providerTotalsPreview.subtotalBeforeTax
|
|
3375
|
+
: isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3376
|
+
? Math.round(
|
|
3377
|
+
(originalReceipt.subtotal + (effectiveSubtotal - changeFlowBaselineEffectiveSubtotalFull)) * 100,
|
|
3378
|
+
) / 100
|
|
3379
|
+
: effectiveSubtotal;
|
|
3380
|
+
const displayChangeFlowTax = providerTotalsPreview
|
|
3381
|
+
? providerTotalsPreview.taxAmount
|
|
3382
|
+
: isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3383
|
+
? Math.round((originalReceipt.tax + (effectiveTax - changeFlowBaselineEffectiveTax)) * 100) / 100
|
|
3384
|
+
: effectiveTax;
|
|
3385
|
+
|
|
2924
3386
|
const changeFlowClientEstimateDue = originalReceipt
|
|
2925
3387
|
? (isProviderDashboardChange
|
|
2926
3388
|
? displayChangeFlowProposedTotal - originalReceipt.total
|
|
@@ -2928,7 +3390,7 @@ export function BookingFlow({
|
|
|
2928
3390
|
: totalPrice;
|
|
2929
3391
|
|
|
2930
3392
|
/**
|
|
2931
|
-
* Amount owed for change flow:
|
|
3393
|
+
* Amount owed for change flow: FE proposed total vs original receipt.
|
|
2932
3394
|
* Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
|
|
2933
3395
|
*/
|
|
2934
3396
|
const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
|
|
@@ -2939,6 +3401,116 @@ export function BookingFlow({
|
|
|
2939
3401
|
})()
|
|
2940
3402
|
: changeFlowAmountDueRaw;
|
|
2941
3403
|
|
|
3404
|
+
useEffect(() => {
|
|
3405
|
+
if (!isChangeFlow || !isLocalhostChangeBookingDebug()) return;
|
|
3406
|
+
const receiptTicketLines =
|
|
3407
|
+
originalReceipt?.lineItems?.filter((l) => (l.type || '').toUpperCase() === 'TICKET') ?? [];
|
|
3408
|
+
const ticketFloors: Record<string, number> = {};
|
|
3409
|
+
changeFlowTicketBookedUnitPriceByCategory.forEach((v, k) => {
|
|
3410
|
+
ticketFloors[k] = v;
|
|
3411
|
+
});
|
|
3412
|
+
const tag = '[viavia change-booking]';
|
|
3413
|
+
// Plain console.log only (no groupCollapsed — those are easy to miss / count as “hidden” in DevTools).
|
|
3414
|
+
console.log(tag, 'itinerary gates', {
|
|
3415
|
+
outboundMatch: changeFlowOutboundMatchesOriginalSelection,
|
|
3416
|
+
returnMatch: changeFlowReturnMatchesOriginalSelection,
|
|
3417
|
+
sameItinerary: changeFlowSameItineraryAsOriginalBooking,
|
|
3418
|
+
});
|
|
3419
|
+
console.log(tag, 'ids & times', {
|
|
3420
|
+
initialAvailabilityId: initialValues?.availabilityId ?? null,
|
|
3421
|
+
selectedAvailabilityId: selectedAvailability?.availabilityId ?? null,
|
|
3422
|
+
initialDateTime: initialValues?.dateTime ?? null,
|
|
3423
|
+
selectedDateTime: selectedAvailability?.dateTime ?? null,
|
|
3424
|
+
initialOptionId: initialValues?.productOptionId ?? null,
|
|
3425
|
+
selectedOptionId: selectedAvailability?.productOptionId ?? null,
|
|
3426
|
+
initialReturnId: initialValues?.returnAvailabilityId ?? null,
|
|
3427
|
+
selectedReturnId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3428
|
+
initialReturnDateTime: initialValues?.returnDateTime ?? null,
|
|
3429
|
+
selectedReturnDateTime: selectedReturnOption?.dateTime ?? null,
|
|
3430
|
+
});
|
|
3431
|
+
console.log(tag, 'selection flags', {
|
|
3432
|
+
hasChangeSelection,
|
|
3433
|
+
hasEffectiveChangeSelection,
|
|
3434
|
+
changeFlowNeedsServerPrice,
|
|
3435
|
+
dateChanged: changeSelectionDetails.dateChanged,
|
|
3436
|
+
returnChanged: changeSelectionDetails.returnChanged,
|
|
3437
|
+
optionChanged: changeSelectionDetails.optionChanged,
|
|
3438
|
+
countsChanged: changeSelectionDetails.countsChanged,
|
|
3439
|
+
});
|
|
3440
|
+
console.log(tag, 'promo', {
|
|
3441
|
+
promoFromApi: promoDiscountAmount,
|
|
3442
|
+
lockedPromoFallback: lockedPromoFallbackAmount,
|
|
3443
|
+
effectivePromo: effectivePromoDiscountAmount,
|
|
3444
|
+
});
|
|
3445
|
+
console.log(tag, 'receipt anchor (same itinerary)', {
|
|
3446
|
+
baselineCatalogTotal: changeFlowBaselineTotalPrice,
|
|
3447
|
+
catalogDeltaVsBaseline: totalPrice - changeFlowBaselineTotalPrice,
|
|
3448
|
+
anchoredTotalRaw: changeFlowReceiptAnchoredCatalogTotalRaw,
|
|
3449
|
+
resolvedProposedTotal: changeFlowProposedTotalResolved,
|
|
3450
|
+
});
|
|
3451
|
+
console.log(tag, 'subtotals (live vs protected)', {
|
|
3452
|
+
orderSummarySubtotal: subtotal,
|
|
3453
|
+
currentTicketSubtotal,
|
|
3454
|
+
changeFlowProtectedTicketSubtotal,
|
|
3455
|
+
currentFeeSubtotal,
|
|
3456
|
+
changeFlowProtectedFeeSubtotal,
|
|
3457
|
+
returnPriceAdjustment,
|
|
3458
|
+
changeFlowProtectedReturnAdjustment,
|
|
3459
|
+
effectiveSubtotal,
|
|
3460
|
+
tax: effectiveTax,
|
|
3461
|
+
totalPrice,
|
|
3462
|
+
changeFlowProposedTotal,
|
|
3463
|
+
changeFlowProposedTotalResolved,
|
|
3464
|
+
originalReceiptTotal: originalReceipt?.total ?? null,
|
|
3465
|
+
amountDue: changeFlowAmountDue,
|
|
3466
|
+
});
|
|
3467
|
+
console.log(tag, 'ticket price floors from receipt (empty => live catalog for tickets)', ticketFloors);
|
|
3468
|
+
console.log(tag, 'receipt TICKET lines', receiptTicketLines);
|
|
3469
|
+
}, [
|
|
3470
|
+
isChangeFlow,
|
|
3471
|
+
changeFlowOutboundMatchesOriginalSelection,
|
|
3472
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
3473
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
3474
|
+
initialValues?.availabilityId,
|
|
3475
|
+
initialValues?.dateTime,
|
|
3476
|
+
initialValues?.productOptionId,
|
|
3477
|
+
initialValues?.returnAvailabilityId,
|
|
3478
|
+
initialValues?.returnDateTime,
|
|
3479
|
+
selectedAvailability?.availabilityId,
|
|
3480
|
+
selectedAvailability?.dateTime,
|
|
3481
|
+
selectedAvailability?.productOptionId,
|
|
3482
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
3483
|
+
selectedReturnOption?.dateTime,
|
|
3484
|
+
hasChangeSelection,
|
|
3485
|
+
hasEffectiveChangeSelection,
|
|
3486
|
+
changeFlowNeedsServerPrice,
|
|
3487
|
+
changeSelectionDetails.dateChanged,
|
|
3488
|
+
changeSelectionDetails.returnChanged,
|
|
3489
|
+
changeSelectionDetails.optionChanged,
|
|
3490
|
+
changeSelectionDetails.countsChanged,
|
|
3491
|
+
subtotal,
|
|
3492
|
+
currentTicketSubtotal,
|
|
3493
|
+
changeFlowProtectedTicketSubtotal,
|
|
3494
|
+
currentFeeSubtotal,
|
|
3495
|
+
changeFlowProtectedFeeSubtotal,
|
|
3496
|
+
returnPriceAdjustment,
|
|
3497
|
+
changeFlowProtectedReturnAdjustment,
|
|
3498
|
+
effectiveSubtotal,
|
|
3499
|
+
effectivePromoDiscountAmount,
|
|
3500
|
+
effectiveTax,
|
|
3501
|
+
totalPrice,
|
|
3502
|
+
changeFlowProposedTotal,
|
|
3503
|
+
originalReceipt?.total,
|
|
3504
|
+
originalReceipt?.lineItems,
|
|
3505
|
+
changeFlowAmountDue,
|
|
3506
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
3507
|
+
changeFlowProposedTotalResolved,
|
|
3508
|
+
changeFlowBaselineTotalPrice,
|
|
3509
|
+
changeFlowReceiptAnchoredCatalogTotalRaw,
|
|
3510
|
+
promoDiscountAmount,
|
|
3511
|
+
lockedPromoFallbackAmount,
|
|
3512
|
+
]);
|
|
3513
|
+
|
|
2942
3514
|
const changeCheckoutButtonLabel = (() => {
|
|
2943
3515
|
if (!isChangeFlow) return undefined;
|
|
2944
3516
|
if (!hasEffectiveChangeSelection) return undefined;
|
|
@@ -3039,7 +3611,7 @@ export function BookingFlow({
|
|
|
3039
3611
|
dateChanged: changeSelectionDetails.dateChanged,
|
|
3040
3612
|
ticketsChanged: changeSelectionDetails.ticketsChanged,
|
|
3041
3613
|
hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
|
|
3042
|
-
selectionTotal: totalPrice,
|
|
3614
|
+
selectionTotal: originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
|
|
3043
3615
|
selectionCurrency: currency,
|
|
3044
3616
|
};
|
|
3045
3617
|
}, [
|
|
@@ -3052,6 +3624,8 @@ export function BookingFlow({
|
|
|
3052
3624
|
changeSelectionDetails,
|
|
3053
3625
|
totalPrice,
|
|
3054
3626
|
currency,
|
|
3627
|
+
originalReceipt,
|
|
3628
|
+
changeFlowProposedTotalResolved,
|
|
3055
3629
|
]);
|
|
3056
3630
|
|
|
3057
3631
|
useEffect(() => {
|
|
@@ -3087,9 +3661,14 @@ export function BookingFlow({
|
|
|
3087
3661
|
return;
|
|
3088
3662
|
}
|
|
3089
3663
|
onPricePreviewChange({
|
|
3090
|
-
subtotal: effectiveSubtotal,
|
|
3091
|
-
tax:
|
|
3092
|
-
|
|
3664
|
+
subtotal: isChangeFlow && originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
|
|
3665
|
+
tax:
|
|
3666
|
+
!isTaxIncludedInPrice
|
|
3667
|
+
? isChangeFlow && originalReceipt
|
|
3668
|
+
? displayChangeFlowTax
|
|
3669
|
+
: effectiveTax
|
|
3670
|
+
: 0,
|
|
3671
|
+
total: isChangeFlow && originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
|
|
3093
3672
|
currency,
|
|
3094
3673
|
});
|
|
3095
3674
|
}, [
|
|
@@ -3098,9 +3677,14 @@ export function BookingFlow({
|
|
|
3098
3677
|
totalQuantity,
|
|
3099
3678
|
effectiveSubtotal,
|
|
3100
3679
|
effectiveTax,
|
|
3101
|
-
|
|
3680
|
+
changeFlowProposedTotalResolved,
|
|
3681
|
+
displayChangeFlowSubtotal,
|
|
3682
|
+
displayChangeFlowTax,
|
|
3102
3683
|
currency,
|
|
3103
3684
|
isTaxIncludedInPrice,
|
|
3685
|
+
isChangeFlow,
|
|
3686
|
+
originalReceipt,
|
|
3687
|
+
totalPrice,
|
|
3104
3688
|
]);
|
|
3105
3689
|
|
|
3106
3690
|
/** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
|
|
@@ -3155,7 +3739,13 @@ export function BookingFlow({
|
|
|
3155
3739
|
newPassengerCounts: bookingItems,
|
|
3156
3740
|
// Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
|
|
3157
3741
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3158
|
-
clientProposedTotal:
|
|
3742
|
+
clientProposedTotal: changeFlowProposedTotalResolved,
|
|
3743
|
+
capacitySeatCredit: {
|
|
3744
|
+
enabled: true,
|
|
3745
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
3746
|
+
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
3747
|
+
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
3748
|
+
},
|
|
3159
3749
|
});
|
|
3160
3750
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
3161
3751
|
const canProceed = quote.canProceed !== false;
|
|
@@ -3200,9 +3790,13 @@ export function BookingFlow({
|
|
|
3200
3790
|
selectedReturnOption?.returnAvailabilityId,
|
|
3201
3791
|
quantities,
|
|
3202
3792
|
addOnSelections,
|
|
3793
|
+
changeFlowInitialTicketCount,
|
|
3794
|
+
changeFlowProposedTotalResolved,
|
|
3203
3795
|
totalPrice,
|
|
3204
3796
|
currency,
|
|
3205
3797
|
activeOptions,
|
|
3798
|
+
initialValues?.availabilityId,
|
|
3799
|
+
initialValues?.returnAvailabilityId,
|
|
3206
3800
|
]);
|
|
3207
3801
|
|
|
3208
3802
|
// Auto-select product option when date is selected: most popular if set, otherwise first available.
|
|
@@ -3778,6 +4372,12 @@ export function BookingFlow({
|
|
|
3778
4372
|
additionalAdjustments: providerAdditionalAdjustments,
|
|
3779
4373
|
}
|
|
3780
4374
|
: undefined,
|
|
4375
|
+
capacitySeatCredit: {
|
|
4376
|
+
enabled: true,
|
|
4377
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
4378
|
+
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
4379
|
+
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
4380
|
+
},
|
|
3781
4381
|
});
|
|
3782
4382
|
setLoading(false);
|
|
3783
4383
|
return;
|
|
@@ -3816,7 +4416,7 @@ export function BookingFlow({
|
|
|
3816
4416
|
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3817
4417
|
newPassengerCounts: bookingItems,
|
|
3818
4418
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3819
|
-
clientProposedTotal:
|
|
4419
|
+
clientProposedTotal: changeFlowProposedTotalResolved,
|
|
3820
4420
|
});
|
|
3821
4421
|
const canProceed = quote.canProceed !== false;
|
|
3822
4422
|
const quoteCurrency = (quote.currency || currency) as Currency;
|
|
@@ -3837,7 +4437,7 @@ export function BookingFlow({
|
|
|
3837
4437
|
if (!canProceed) {
|
|
3838
4438
|
throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
|
|
3839
4439
|
}
|
|
3840
|
-
const feChangeDue = Math.max(0,
|
|
4440
|
+
const feChangeDue = Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0));
|
|
3841
4441
|
const serverAmountDue =
|
|
3842
4442
|
quote.amountDueCents != null
|
|
3843
4443
|
? Math.max(0, quote.amountDueCents / 100)
|
|
@@ -3947,7 +4547,7 @@ export function BookingFlow({
|
|
|
3947
4547
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
3948
4548
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
3949
4549
|
const amountDueForCheckout = isCustomerSelfServeChange
|
|
3950
|
-
? Math.max(0,
|
|
4550
|
+
? Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0))
|
|
3951
4551
|
: totalPrice;
|
|
3952
4552
|
const lines = [
|
|
3953
4553
|
...ticketLineItems.map((line) => ({
|
|
@@ -4236,7 +4836,7 @@ export function BookingFlow({
|
|
|
4236
4836
|
isCustomerSelfServeChange && originalReceipt
|
|
4237
4837
|
? {
|
|
4238
4838
|
previousTotal: originalReceipt.total,
|
|
4239
|
-
newTotal:
|
|
4839
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
4240
4840
|
differenceTotal: amountDueForCheckout,
|
|
4241
4841
|
}
|
|
4242
4842
|
: undefined,
|
|
@@ -4642,11 +5242,9 @@ export function BookingFlow({
|
|
|
4642
5242
|
selectedVacancies={effectivePartySizeCap}
|
|
4643
5243
|
companyTimezone={companyTimezone}
|
|
4644
5244
|
pickupDateTime={selectedAvailability.dateTime}
|
|
4645
|
-
pickupVacancies={
|
|
5245
|
+
pickupVacancies={effectiveSelectedPickupVacancies}
|
|
4646
5246
|
returnDateTime={selectedReturnOption?.dateTime ?? null}
|
|
4647
|
-
returnVacancies={
|
|
4648
|
-
selectedReturnOption != null ? (selectedReturnOption.vacancies ?? 0) : null
|
|
4649
|
-
}
|
|
5247
|
+
returnVacancies={effectiveSelectedReturnVacancies}
|
|
4650
5248
|
resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
|
|
4651
5249
|
currency={currency}
|
|
4652
5250
|
locale={locale}
|
|
@@ -22,6 +22,12 @@ export type ProviderDashboardChangeBookingPayload = {
|
|
|
22
22
|
lineOverrides?: Array<{ lineKey: string; amount: number; reason?: string }>;
|
|
23
23
|
additionalAdjustments?: Array<{ label: string; amount: number }>;
|
|
24
24
|
} | null;
|
|
25
|
+
capacitySeatCredit?: {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
previousPassengerCount?: number | null;
|
|
28
|
+
previousAvailabilityId?: string | null;
|
|
29
|
+
previousReturnAvailabilityId?: string | null;
|
|
30
|
+
} | null;
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
/** Seeds the flow when opening provider change-booking (matches TicketBooth `BookingWidget` `initialBooking`). */
|
|
@@ -413,18 +413,35 @@ export function computeOrderSummary(
|
|
|
413
413
|
|
|
414
414
|
const isTaxIncludedInPrice = pricingConfig.currenciesWithTaxIncluded.includes(currency);
|
|
415
415
|
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
416
|
+
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
417
|
+
|
|
418
|
+
const ticketLineItems: OrderSummaryTicketLine[] = pricing
|
|
419
|
+
.map((rate) => {
|
|
420
|
+
const qty = quantities[rate.category] ?? 0;
|
|
421
|
+
if (qty === 0) return null;
|
|
422
|
+
const pricePerUnit = rate.price ?? 0;
|
|
423
|
+
return {
|
|
424
|
+
category: rate.category,
|
|
425
|
+
qty,
|
|
426
|
+
pricePerUnit,
|
|
427
|
+
itemTotal: round2(qty * pricePerUnit),
|
|
428
|
+
};
|
|
429
|
+
})
|
|
430
|
+
.filter((line): line is OrderSummaryTicketLine => line != null);
|
|
431
|
+
|
|
432
|
+
const basePrice = ticketLineItems.reduce((sum, line) => sum + line.itemTotal, 0);
|
|
420
433
|
|
|
421
434
|
const perPersonInDisplay = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
422
435
|
const returnPriceAdjustment =
|
|
423
|
-
totalQuantity > 0 && perPersonInDisplay !== 0
|
|
436
|
+
totalQuantity > 0 && perPersonInDisplay !== 0
|
|
437
|
+
? round2(totalQuantity * perPersonInDisplay)
|
|
438
|
+
: 0;
|
|
424
439
|
|
|
425
440
|
const cancellationPolicyFee =
|
|
426
441
|
cancellationPolicyId && pricingConfig?.cancellationPolicies?.length
|
|
427
|
-
? (
|
|
442
|
+
? round2(
|
|
443
|
+
pricingConfig.cancellationPolicies.find((p) => p.id === cancellationPolicyId)?.feeByCurrency?.[currency] ?? 0
|
|
444
|
+
)
|
|
428
445
|
: 0;
|
|
429
446
|
|
|
430
447
|
const fees = pricingConfig.fees ?? {};
|
|
@@ -434,23 +451,14 @@ export function computeOrderSummary(
|
|
|
434
451
|
? []
|
|
435
452
|
: Object.entries(byCurrency).map(([name, amountPerPerson]) => ({
|
|
436
453
|
name,
|
|
437
|
-
totalAmount: totalQuantity * amountPerPerson,
|
|
454
|
+
totalAmount: round2(totalQuantity * amountPerPerson),
|
|
438
455
|
description: fees[name]?.description,
|
|
439
456
|
}));
|
|
440
457
|
|
|
441
458
|
const feesTotal = feeLineItems.reduce((s, f) => s + f.totalAmount, 0);
|
|
442
|
-
const subtotal = basePrice + returnPriceAdjustment + cancellationPolicyFee + feesTotal;
|
|
443
|
-
const tax = isTaxIncludedInPrice ? 0 : subtotal * pricingConfig.taxRate;
|
|
444
|
-
const total = subtotal + tax;
|
|
445
|
-
|
|
446
|
-
const ticketLineItems: OrderSummaryTicketLine[] = pricing
|
|
447
|
-
.map((rate) => {
|
|
448
|
-
const qty = quantities[rate.category] ?? 0;
|
|
449
|
-
if (qty === 0) return null;
|
|
450
|
-
const pricePerUnit = rate.price ?? 0;
|
|
451
|
-
return { category: rate.category, qty, pricePerUnit, itemTotal: qty * pricePerUnit };
|
|
452
|
-
})
|
|
453
|
-
.filter((line): line is OrderSummaryTicketLine => line != null);
|
|
459
|
+
const subtotal = round2(basePrice + returnPriceAdjustment + cancellationPolicyFee + feesTotal);
|
|
460
|
+
const tax = isTaxIncludedInPrice ? 0 : round2(subtotal * pricingConfig.taxRate);
|
|
461
|
+
const total = round2(subtotal + tax);
|
|
454
462
|
|
|
455
463
|
return {
|
|
456
464
|
subtotal,
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -838,6 +838,13 @@ export interface ChangeBookingQuoteRequest {
|
|
|
838
838
|
newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
|
|
839
839
|
/** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
|
|
840
840
|
clientProposedTotal?: number;
|
|
841
|
+
/** Optional change-flow capacity hint so API can exclude current booking seats from sold counts. */
|
|
842
|
+
capacitySeatCredit?: {
|
|
843
|
+
enabled: boolean;
|
|
844
|
+
previousPassengerCount?: number | null;
|
|
845
|
+
previousAvailabilityId?: string | null;
|
|
846
|
+
previousReturnAvailabilityId?: string | null;
|
|
847
|
+
} | null;
|
|
841
848
|
}
|
|
842
849
|
|
|
843
850
|
export interface ChangeBookingQuoteReceipt {
|