@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.58",
3
+ "version": "1.2.59",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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
- }, [selectedDate, selectedPickupLocation, timesForSelectedDate, pickupLocationSkipped, maxTimeOffsetMinutes, companyTimezone]);
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 || changeFlowReturnUnitFloorPerPerson == null) return options;
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 flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
2143
- if (flooredPerPerson === rawPerPerson) return opt;
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
- }, [selectedAvailability?.returnOptions, isChangeFlow, changeFlowReturnUnitFloorPerPerson, currency]);
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
- }, [selectedReturnOption, isChangeFlow, changeFlowReturnUnitFloorPerPerson, currency]);
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
- selectedReturnOptionWithFloor,
2582
+ returnOptionForOrderSummary,
2261
2583
  pricingConfig ?? null,
2262
2584
  currency,
2263
2585
  hasFees,
2264
2586
  cancellationPolicyId
2265
2587
  ),
2266
- [quantities, pricing, selectedReturnOptionWithFloor, pricingConfig, currency, hasFees, cancellationPolicyId]
2588
+ [quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
2267
2589
  );
2268
- const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
2269
- const changeFlowInitialTicketQtyByCategory = useMemo(() => {
2270
- const qtyByCategory = new Map<string, number>();
2271
- if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
2272
- for (const item of initialValues.bookingItems) {
2273
- const category = item.category?.trim().toUpperCase();
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
- return qtyByCategory;
2278
- }, [isChangeFlow, initialValues?.bookingItems]);
2279
- const changeFlowPreserveBookedTicketPricing = useMemo(() => {
2280
- if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
2281
- const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
2282
- const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
2283
- if (selectedMs !== initialMs) return false;
2284
- const selectedOptionId = selectedAvailability.productOptionId?.trim() || null;
2285
- const initialOptionId = initialValues.productOptionId?.trim() || null;
2286
- // If option IDs are missing, still protect pricing when date/time is unchanged.
2287
- if (!selectedOptionId || !initialOptionId) return true;
2288
- return selectedOptionId === initialOptionId;
2289
- }, [isChangeFlow, selectedAvailability, initialValues?.dateTime, initialValues?.productOptionId]);
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 (!changeFlowPreserveBookedTicketPricing) return currentTicketSubtotal;
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 = Math.max(0, changeFlowInitialTicketQtyByCategory.get(category) ?? 0);
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
- return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnitPrice;
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
- changeFlowPreserveBookedTicketPricing,
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 outbound = selectedAvailability.vacancies ?? 0;
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
- return Math.min(outbound, selectedReturnOption.vacancies ?? 0);
2320
- }, [selectedAvailability, selectedReturnOption]);
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 = useMemo(() => {
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 (!changeFlowPreserveBookedTicketPricing || totalQuantity <= 0) return currentFeeSubtotal;
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 protectedQty = Math.min(totalQuantity, changeFlowInitialTotalTicketQty);
2398
- const incrementalQty = Math.max(0, totalQuantity - changeFlowInitialTotalTicketQty);
2399
- return sum + protectedQty * bookedUnitPrice + incrementalQty * currentUnitPrice;
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
- changeFlowPreserveBookedTicketPricing,
2752
+ changeFlowSameItineraryAsOriginalBooking,
2403
2753
  totalQuantity,
2404
2754
  currentFeeSubtotal,
2405
2755
  feeLineItems,
2406
2756
  changeFlowBookedFeeUnitByNormalizedLabel,
2407
- changeFlowInitialTotalTicketQty,
2757
+ changeFlowInitialTicketCount,
2408
2758
  ]);
2409
2759
  const changeFlowProtectedReturnAdjustment = useMemo(() => {
2410
- if (!changeFlowPreserveBookedTicketPricing || totalQuantity <= 0) return returnPriceAdjustment;
2760
+ if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return returnPriceAdjustment;
2411
2761
  if (changeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
2412
- const currentUnitPrice = returnPriceAdjustment / totalQuantity;
2413
- const protectedQty = Math.min(totalQuantity, changeFlowInitialTotalTicketQty);
2414
- const incrementalQty = Math.max(0, totalQuantity - changeFlowInitialTotalTicketQty);
2415
- return protectedQty * changeFlowReturnUnitFloorPerPerson + incrementalQty * currentUnitPrice;
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
- changeFlowPreserveBookedTicketPricing,
2768
+ changeFlowSameItineraryAsOriginalBooking,
2418
2769
  totalQuantity,
2419
2770
  returnPriceAdjustment,
2420
2771
  changeFlowReturnUnitFloorPerPerson,
2421
- changeFlowInitialTotalTicketQty,
2772
+ changeFlowInitialTicketCount,
2773
+ selectedReturnOption,
2774
+ currency,
2422
2775
  ]);
2423
2776
  const effectiveSubtotalBeforeAddOns =
2424
- isChangeFlow && changeFlowPreserveBookedTicketPricing
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: same recipe as normal booking — FE `totalPrice` vs original receipt.
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: !isTaxIncludedInPrice ? effectiveTax : 0,
3092
- total: totalPrice,
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
- changeFlowProposedTotal,
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: changeFlowProposedTotal,
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: changeFlowProposedTotal,
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, changeFlowProposedTotal - (originalReceipt?.total ?? 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, changeFlowProposedTotal - (originalReceipt?.total ?? 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: totalPrice,
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={selectedAvailability.vacancies ?? 0}
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 basePrice = pricing.reduce((sum, rate) => {
417
- const qty = quantities[rate.category] ?? 0;
418
- return sum + qty * (rate.price ?? 0);
419
- }, 0);
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 ? totalQuantity * 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
- ? (pricingConfig.cancellationPolicies.find((p) => p.id === cancellationPolicyId)?.feeByCurrency?.[currency] ?? 0)
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,
@@ -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 {