@ticketboothapp/booking 1.2.58 → 1.2.60

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.
@@ -47,9 +47,20 @@ import { useTranslations, useLocale } from '../../lib/booking/i18n';
47
47
  import { type Currency } from './CurrencySwitcher';
48
48
  import { formatBookingRefForDisplay } from '../../lib/booking-ref';
49
49
  import {
50
- formatCurrencyAmount,
51
- reconcileChangeBookingProposedTotal,
52
- } from '../../lib/currency';
50
+ effectiveProductOptionIdForChangeFlow,
51
+ isParentProductId,
52
+ normalizeProductOptionIdForChangeFlow,
53
+ } from '../../lib/booking/product-option-id';
54
+ import { formatCurrencyAmount } from '../../lib/currency';
55
+ import {
56
+ resolveChangeFlowNewBookingTotal,
57
+ changeFlowBalanceVsOriginal,
58
+ resolveChangeFlowDisplayedAmounts,
59
+ normalizeNearZeroOwed,
60
+ changeFlowTicketLineTotalWithReceiptFloor,
61
+ changeFlowFeeLineTotalWithReceiptFloor,
62
+ changeFlowReturnPerPersonWithReceiptFloor,
63
+ } from '../../lib/booking/change-flow-pricing';
53
64
  import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
54
65
  import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
55
66
  import { ItineraryStepType as StepType } from '../../lib/booking-api';
@@ -202,6 +213,92 @@ function findMergedAvailabilityForSelection(
202
213
  return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
203
214
  }
204
215
 
216
+ /** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
217
+ function buildPricingFromAvailability(
218
+ selectedAvailability: Availability | null,
219
+ activeOptions: Product['options'],
220
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
221
+ currency: Currency,
222
+ pricingConfig: PricingConfig | null,
223
+ hasFees: boolean,
224
+ isSimplifiedPricingView: boolean,
225
+ ) {
226
+ if (!selectedAvailability || !pricingConfig) return [];
227
+ const optionId = selectedAvailability.productOptionId;
228
+ const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
229
+ const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
230
+ const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
231
+ getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
232
+ const getBaseInDisplayCurrency = (category: string) => {
233
+ const fromPrecomputed = precomputed?.[category]?.[currency];
234
+ if (fromPrecomputed != null) return fromPrecomputed;
235
+ return 0;
236
+ };
237
+ const buildRate = (
238
+ category: string,
239
+ backendPriceCAD: number,
240
+ backendInDisplayCurrency: number,
241
+ baseInDisplayCurrency: number,
242
+ appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
243
+ ) => {
244
+ const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
245
+ const isPublicMode = isSimplifiedPricingView;
246
+ const breakdown = computePriceBreakdown(
247
+ pricingConfig,
248
+ currency,
249
+ backendPriceCAD,
250
+ basePriceCAD,
251
+ hasFees,
252
+ appliedAdjustments,
253
+ undefined,
254
+ baseInDisplayCurrency,
255
+ isPublicMode,
256
+ );
257
+ const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
258
+ return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
259
+ };
260
+ return (
261
+ selectedAvailability.rates?.map((rate) => {
262
+ const backendPriceCAD = rate.price ?? 0;
263
+ const backendInDisplayCurrency =
264
+ rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
265
+ const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
266
+ const built = buildRate(
267
+ rate.category,
268
+ backendPriceCAD,
269
+ backendInDisplayCurrency,
270
+ baseInDisplayCurrency,
271
+ rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
272
+ );
273
+ return {
274
+ category: rate.category,
275
+ rateId: rate.rateId || rate.category,
276
+ available: rate.available,
277
+ price: built.price,
278
+ priceCAD: built.priceCAD,
279
+ baseInDisplayCurrency: built.baseInDisplayCurrency,
280
+ appliedAdjustments: built.appliedAdjustments,
281
+ };
282
+ }) ||
283
+ selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
284
+ const priceCADFromApi = p.price / 100;
285
+ const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
286
+ const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
287
+ const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
288
+ return {
289
+ category: p.category,
290
+ rateId: p.category,
291
+ available: selectedAvailability.vacancies,
292
+ price: built.price,
293
+ priceCAD: built.priceCAD,
294
+ baseInDisplayCurrency: built.baseInDisplayCurrency,
295
+ appliedAdjustments: built.appliedAdjustments,
296
+ };
297
+ }) ||
298
+ []
299
+ );
300
+ }
301
+
205
302
  function findMergedReturnVacancies(
206
303
  outbound: Availability | undefined,
207
304
  selectedReturn: ReturnOption | null | undefined
@@ -285,12 +382,16 @@ interface BookingFlowProps {
285
382
  dateTime?: string | null;
286
383
  /** Inventory slot id from booking (when API persists it); strongest match for change flow. */
287
384
  availabilityId?: string | null;
385
+ /** Parent product id when option id is omitted or differs from availability rows (GYG/ticketbooth shape). */
386
+ productId?: string | null;
288
387
  productOptionId?: string | null;
289
388
  pickupLocationId?: string | null;
290
389
  /** Original booked return slot (round-trip), when API provides it. */
291
390
  returnAvailabilityId?: string | null;
292
391
  /** Fallback when only return datetime is on the booking payload. */
293
392
  returnDateTime?: string | null;
393
+ /** Persisted return floor per person from booking API (must match ticketbooth `return_unit_floor_per_person`). */
394
+ returnUnitFloorPerPerson?: number | null;
294
395
  bookingItems?: Array<{ category: string; count: number }> | null;
295
396
  addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
296
397
  customer?: {
@@ -766,6 +867,18 @@ export function BookingFlow({
766
867
  const hasAutoSelectedPartnerPickupRef = useRef(false);
767
868
  const handleDateSelectRef = useRef<(date: string) => void>(() => {});
768
869
  const isChangeFlow = mode === 'change';
870
+ const changeFlowOriginalDate = useMemo(() => {
871
+ if (!isChangeFlow || !initialValues?.dateTime?.trim()) return null;
872
+ try {
873
+ return formatInTimeZone(
874
+ parseAvailabilityDateTime(initialValues.dateTime.trim()),
875
+ companyTimezone,
876
+ 'yyyy-MM-dd',
877
+ );
878
+ } catch {
879
+ return null;
880
+ }
881
+ }, [isChangeFlow, initialValues?.dateTime, companyTimezone]);
769
882
  const isProviderDashboardChange = Boolean(onChangeBooking);
770
883
  const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
771
884
 
@@ -1576,6 +1689,79 @@ export function BookingFlow({
1576
1689
  [timesForSelectedDate],
1577
1690
  );
1578
1691
 
1692
+ /**
1693
+ * Bookings often store only parent `p_…` (no `po_…`). Resolve the booked option id from inventory rows,
1694
+ * wall-clock match, or single active option — must be defined before effects that seed selection / seat credit.
1695
+ */
1696
+ const changeFlowResolvedInitialProductOptionId = useMemo((): string | null => {
1697
+ if (!isChangeFlow) return null;
1698
+ const fromFields = effectiveProductOptionIdForChangeFlow({
1699
+ productId: initialValues?.productId,
1700
+ productOptionId: initialValues?.productOptionId,
1701
+ });
1702
+ if (fromFields) return fromFields;
1703
+
1704
+ const aid = initialValues?.availabilityId?.trim();
1705
+ if (aid) {
1706
+ for (const a of availabilities) {
1707
+ if (a.availabilityId === aid) {
1708
+ const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
1709
+ if (po) return po;
1710
+ }
1711
+ }
1712
+ }
1713
+
1714
+ if (initialValues?.dateTime?.trim()) {
1715
+ try {
1716
+ const initialDt = parseAvailabilityDateTime(initialValues.dateTime.trim());
1717
+ const initialMs = initialDt.getTime();
1718
+ const initialDay = formatInTimeZone(initialDt, companyTimezone, 'yyyy-MM-dd');
1719
+ for (const a of availabilities) {
1720
+ const avDt = parseAvailabilityDateTime(a.dateTime);
1721
+ if (formatInTimeZone(avDt, companyTimezone, 'yyyy-MM-dd') !== initialDay) continue;
1722
+ if (avDt.getTime() !== initialMs) continue;
1723
+ const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
1724
+ if (po) return po;
1725
+ }
1726
+ } catch {
1727
+ /* ignore */
1728
+ }
1729
+ }
1730
+
1731
+ if (activeOptions.length === 1) {
1732
+ return normalizeProductOptionIdForChangeFlow(activeOptions[0].optionId);
1733
+ }
1734
+
1735
+ return null;
1736
+ }, [
1737
+ isChangeFlow,
1738
+ initialValues?.productId,
1739
+ initialValues?.productOptionId,
1740
+ initialValues?.availabilityId,
1741
+ initialValues?.dateTime,
1742
+ availabilities,
1743
+ activeOptions,
1744
+ companyTimezone,
1745
+ ]);
1746
+
1747
+ /**
1748
+ * Parent catalog product id (`p_…`) for minimum-paid receipt floors: explicit parent on the booking,
1749
+ * otherwise the product loaded for this change session (booking payload may only carry `po_…`).
1750
+ */
1751
+ const changeFlowBookingParentProductIdForFloors = useMemo(() => {
1752
+ const pid = initialValues?.productId?.trim();
1753
+ if (pid && isParentProductId(pid)) return pid;
1754
+ return product.productId.trim();
1755
+ }, [initialValues?.productId, product.productId]);
1756
+
1757
+ /** Minimum paid price / receipt anchoring applies for any option under this parent product. */
1758
+ const changeFlowApplyReceiptPaidFloors = useMemo(
1759
+ () =>
1760
+ isChangeFlow &&
1761
+ changeFlowBookingParentProductIdForFloors === product.productId.trim(),
1762
+ [isChangeFlow, changeFlowBookingParentProductIdForFloors, product.productId],
1763
+ );
1764
+
1579
1765
  useEffect(() => {
1580
1766
  if (hasAppliedInitialValuesRef.current || !initialValues) return;
1581
1767
  const trimmedEmail = initialValues.customer?.email?.trim();
@@ -1616,7 +1802,7 @@ export function BookingFlow({
1616
1802
  target,
1617
1803
  companyTimezone,
1618
1804
  initialValues.availabilityId ?? null,
1619
- initialValues.productOptionId ?? null,
1805
+ changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
1620
1806
  initialValues.bookingItems ?? null,
1621
1807
  precomputedPricesByOption,
1622
1808
  currency,
@@ -1632,6 +1818,7 @@ export function BookingFlow({
1632
1818
  initialValues?.availabilityId,
1633
1819
  initialValues?.productOptionId,
1634
1820
  initialValues?.bookingItems,
1821
+ changeFlowResolvedInitialProductOptionId,
1635
1822
  selectedAvailability,
1636
1823
  selectedDate,
1637
1824
  timesForSelectedDate,
@@ -1779,7 +1966,61 @@ export function BookingFlow({
1779
1966
  }
1780
1967
  return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
1781
1968
  }, [product.pickupLocations]);
1969
+ const changeFlowInitialTicketCountForSeatCredit = useMemo(() => {
1970
+ if (!isChangeFlow || !initialValues?.bookingItems?.length) return 0;
1971
+ return initialValues.bookingItems.reduce(
1972
+ (sum, item) => sum + Math.max(0, Number(item.count) || 0),
1973
+ 0,
1974
+ );
1975
+ }, [isChangeFlow, initialValues?.bookingItems]);
1782
1976
 
1977
+ const changeFlowSeatCreditForOutboundAvailability = useCallback(
1978
+ (availability: Availability): number => {
1979
+ if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
1980
+ const initialAvailabilityId = initialValues?.availabilityId?.trim();
1981
+ const availabilityId = availability.availabilityId?.trim();
1982
+ if (initialAvailabilityId && availabilityId) {
1983
+ return initialAvailabilityId === availabilityId ? changeFlowInitialTicketCountForSeatCredit : 0;
1984
+ }
1985
+ if (!initialValues?.dateTime) return 0;
1986
+ const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
1987
+ const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
1988
+ if (selectedMs !== initialMs) return 0;
1989
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
1990
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
1991
+ if (selectedOpt != null && initialOpt != null) {
1992
+ return selectedOpt === initialOpt ? changeFlowInitialTicketCountForSeatCredit : 0;
1993
+ }
1994
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
1995
+ const legacySelected = availability.productOptionId?.trim() || null;
1996
+ if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCountForSeatCredit;
1997
+ return legacySelected === legacyInitial ? changeFlowInitialTicketCountForSeatCredit : 0;
1998
+ },
1999
+ [
2000
+ isChangeFlow,
2001
+ changeFlowInitialTicketCountForSeatCredit,
2002
+ changeFlowResolvedInitialProductOptionId,
2003
+ initialValues?.availabilityId,
2004
+ initialValues?.dateTime,
2005
+ initialValues?.productOptionId,
2006
+ ],
2007
+ );
2008
+ const changeFlowSeatCreditForReturnAvailabilityId = useCallback(
2009
+ (returnAvailabilityId: string | null | undefined): number => {
2010
+ if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
2011
+ const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
2012
+ const selectedReturnAvailabilityId = returnAvailabilityId?.trim() || null;
2013
+ if (!selectedReturnAvailabilityId) {
2014
+ return initialReturnAvailabilityId == null ? changeFlowInitialTicketCountForSeatCredit : 0;
2015
+ }
2016
+ if (!initialReturnAvailabilityId) return 0;
2017
+ return selectedReturnAvailabilityId === initialReturnAvailabilityId
2018
+ ? changeFlowInitialTicketCountForSeatCredit
2019
+ : 0;
2020
+ },
2021
+ [isChangeFlow, changeFlowInitialTicketCountForSeatCredit, initialValues?.returnAvailabilityId],
2022
+ );
2023
+
1783
2024
  // Calculate pickup times based on availability times + pickup location offset
1784
2025
  interface PickupTimeInfo extends Availability {
1785
2026
  pickupTime: string;
@@ -1802,6 +2043,8 @@ export function BookingFlow({
1802
2043
  return timesForSelectedDate.map(avail => {
1803
2044
  // Parse the dateTime (which should already be in company timezone from backend)
1804
2045
  const availabilityTime = parseISO(avail.dateTime);
2046
+ const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
2047
+ const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
1805
2048
 
1806
2049
  // Only apply offset if it's set and > 0 and location is selected
1807
2050
  const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
@@ -1822,13 +2065,22 @@ export function BookingFlow({
1822
2065
 
1823
2066
  return {
1824
2067
  ...avail,
2068
+ vacancies: adjustedVacancies,
1825
2069
  pickupTime: pickupTime.toISOString(),
1826
2070
  displayTime,
1827
2071
  originalTime,
1828
2072
  displayTimeRange,
1829
2073
  };
1830
2074
  });
1831
- }, [selectedDate, selectedPickupLocation, timesForSelectedDate, pickupLocationSkipped, maxTimeOffsetMinutes, companyTimezone]);
2075
+ }, [
2076
+ selectedDate,
2077
+ selectedPickupLocation,
2078
+ timesForSelectedDate,
2079
+ pickupLocationSkipped,
2080
+ maxTimeOffsetMinutes,
2081
+ companyTimezone,
2082
+ changeFlowSeatCreditForOutboundAvailability,
2083
+ ]);
1832
2084
 
1833
2085
  // Check if any pickup time has "most popular" tag (memoized for performance)
1834
2086
  const hasAnyMostPopular = useMemo(() => {
@@ -2095,7 +2347,11 @@ export function BookingFlow({
2095
2347
  return floors;
2096
2348
  }, [isChangeFlow, originalReceipt?.lineItems]);
2097
2349
 
2098
- const changeFlowReturnUnitFloorPerPerson = useMemo(() => {
2350
+ /**
2351
+ * When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
2352
+ * so catalog "free" return slots still show and price at the original return value in change flow.
2353
+ */
2354
+ const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
2099
2355
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return null;
2100
2356
  const fallbackBookedQty =
2101
2357
  (initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
@@ -2103,7 +2359,7 @@ export function BookingFlow({
2103
2359
  let totalQty = 0;
2104
2360
  for (const line of originalReceipt.lineItems) {
2105
2361
  const type = (line.type || '').trim().toUpperCase();
2106
- if (type !== 'RETURN_OPTION') continue;
2362
+ if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
2107
2363
  const amount = Number(line.amount ?? 0);
2108
2364
  const qtyRaw = Number(line.quantity ?? 0);
2109
2365
  const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
@@ -2114,6 +2370,15 @@ export function BookingFlow({
2114
2370
  if (totalAmount <= 0 || totalQty <= 0) return null;
2115
2371
  return totalAmount / totalQty;
2116
2372
  }, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
2373
+
2374
+ const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
2375
+ if (!isChangeFlow) return null;
2376
+ const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
2377
+ if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
2378
+ const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
2379
+ if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
2380
+ return null;
2381
+ }, [isChangeFlow, initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
2117
2382
  const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
2118
2383
  const feeUnitByLabel = new Map<string, number>();
2119
2384
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
@@ -2134,27 +2399,137 @@ export function BookingFlow({
2134
2399
  return feeUnitByLabel;
2135
2400
  }, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
2136
2401
 
2402
+ const changeFlowInitialTicketQtyByCategory = useMemo(() => {
2403
+ const qtyByCategory = new Map<string, number>();
2404
+ if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
2405
+ for (const item of initialValues.bookingItems) {
2406
+ const category = item.category?.trim().toUpperCase();
2407
+ if (!category) continue;
2408
+ qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
2409
+ }
2410
+ return qtyByCategory;
2411
+ }, [isChangeFlow, initialValues?.bookingItems]);
2412
+ const changeFlowInitialTicketCount = useMemo(() => {
2413
+ let sum = 0;
2414
+ for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
2415
+ return sum;
2416
+ }, [changeFlowInitialTicketQtyByCategory]);
2417
+ const changeFlowOutboundMatchesOriginalSelection = useMemo(() => {
2418
+ if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
2419
+ const initialAvailabilityId = initialValues.availabilityId?.trim();
2420
+ const selectedAvailabilityId = selectedAvailability.availabilityId?.trim();
2421
+ const idsMatch =
2422
+ Boolean(initialAvailabilityId && selectedAvailabilityId) &&
2423
+ initialAvailabilityId === selectedAvailabilityId;
2424
+ if (idsMatch) return true;
2425
+ // Same wall time + same product option (ids may rotate on refresh).
2426
+ const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
2427
+ const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
2428
+ if (selectedMs !== initialMs) return false;
2429
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
2430
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
2431
+ if (selectedOpt != null && initialOpt != null) {
2432
+ return selectedOpt === initialOpt;
2433
+ }
2434
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
2435
+ const legacySelected = selectedAvailability.productOptionId?.trim() || null;
2436
+ return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
2437
+ }, [
2438
+ isChangeFlow,
2439
+ selectedAvailability,
2440
+ initialValues?.availabilityId,
2441
+ initialValues?.dateTime,
2442
+ initialValues?.productOptionId,
2443
+ changeFlowResolvedInitialProductOptionId,
2444
+ ]);
2445
+ const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
2446
+ if (!isChangeFlow) return false;
2447
+ const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
2448
+ const selectedReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
2449
+ const initialReturnDt = initialValues?.returnDateTime?.trim() || null;
2450
+ const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
2451
+
2452
+ if (!selectedReturnAvailabilityId) {
2453
+ return initialReturnAvailabilityId == null;
2454
+ }
2455
+ if (initialReturnAvailabilityId && selectedReturnAvailabilityId) {
2456
+ if (initialReturnAvailabilityId === selectedReturnAvailabilityId) return true;
2457
+ if (initialReturnDt && selectedReturnDt) {
2458
+ return (
2459
+ parseAvailabilityDateTime(initialReturnDt).getTime() ===
2460
+ parseAvailabilityDateTime(selectedReturnDt).getTime()
2461
+ );
2462
+ }
2463
+ return false;
2464
+ }
2465
+ // Bookings often store returnDateTime without returnAvailabilityId; still the same return if wall times match.
2466
+ if (!initialReturnAvailabilityId && initialReturnDt && selectedReturnDt) {
2467
+ return (
2468
+ parseAvailabilityDateTime(initialReturnDt).getTime() ===
2469
+ parseAvailabilityDateTime(selectedReturnDt).getTime()
2470
+ );
2471
+ }
2472
+ return false;
2473
+ }, [
2474
+ isChangeFlow,
2475
+ initialValues?.returnAvailabilityId,
2476
+ initialValues?.returnDateTime,
2477
+ selectedReturnOption?.returnAvailabilityId,
2478
+ selectedReturnOption?.dateTime,
2479
+ ]);
2480
+ /** Same outbound + return as original booking: incremental seats use live pricing; receipt floors apply only after itinerary changes. */
2481
+ const changeFlowSameItineraryAsOriginalBooking = useMemo(
2482
+ () =>
2483
+ isChangeFlow &&
2484
+ changeFlowOutboundMatchesOriginalSelection &&
2485
+ changeFlowReturnMatchesOriginalSelection,
2486
+ [isChangeFlow, changeFlowOutboundMatchesOriginalSelection, changeFlowReturnMatchesOriginalSelection],
2487
+ );
2488
+
2137
2489
  const returnOptionsWithFloor = useMemo(() => {
2138
2490
  const options = selectedAvailability?.returnOptions ?? [];
2139
- if (!isChangeFlow || changeFlowReturnUnitFloorPerPerson == null) return options;
2491
+ if (!isChangeFlow && effectiveChangeFlowReturnUnitFloorPerPerson == null) return options;
2140
2492
  return options.map((opt) => {
2493
+ const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
2494
+ const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
2141
2495
  const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
2142
- const flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
2143
- if (flooredPerPerson === rawPerPerson) return opt;
2496
+ const applyReturnFloor =
2497
+ effectiveChangeFlowReturnUnitFloorPerPerson != null &&
2498
+ (
2499
+ // Public self-serve change flow should always display the paid return floor on cards.
2500
+ isCustomerSelfServeChange ||
2501
+ // For non-public paths, only apply floor after itinerary changes.
2502
+ !isChangeFlow ||
2503
+ !changeFlowSameItineraryAsOriginalBooking
2504
+ );
2505
+ const flooredPerPerson = applyReturnFloor
2506
+ ? Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson!)
2507
+ : rawPerPerson;
2508
+ if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
2144
2509
  return {
2145
2510
  ...opt,
2511
+ vacancies: adjustedVacancies,
2146
2512
  priceAdjustmentByCurrency: {
2147
2513
  ...(opt.priceAdjustmentByCurrency ?? {}),
2148
2514
  [currency]: flooredPerPerson,
2149
2515
  },
2150
2516
  };
2151
2517
  });
2152
- }, [selectedAvailability?.returnOptions, isChangeFlow, changeFlowReturnUnitFloorPerPerson, currency]);
2518
+ }, [
2519
+ selectedAvailability?.returnOptions,
2520
+ isChangeFlow,
2521
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2522
+ changeFlowSameItineraryAsOriginalBooking,
2523
+ isCustomerSelfServeChange,
2524
+ currency,
2525
+ changeFlowSeatCreditForReturnAvailabilityId,
2526
+ ]);
2153
2527
 
2154
2528
  const selectedReturnOptionWithFloor = useMemo(() => {
2155
- if (!selectedReturnOption || !isChangeFlow || changeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
2529
+ if (!selectedReturnOption || !isChangeFlow || effectiveChangeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
2530
+ if (!isCustomerSelfServeChange && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
2156
2531
  const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
2157
- const flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
2532
+ const flooredPerPerson = Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson);
2158
2533
  if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
2159
2534
  return {
2160
2535
  ...selectedReturnOption,
@@ -2163,74 +2538,60 @@ export function BookingFlow({
2163
2538
  [currency]: flooredPerPerson,
2164
2539
  },
2165
2540
  };
2166
- }, [selectedReturnOption, isChangeFlow, changeFlowReturnUnitFloorPerPerson, currency]);
2541
+ }, [
2542
+ selectedReturnOption,
2543
+ isChangeFlow,
2544
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2545
+ changeFlowSameItineraryAsOriginalBooking,
2546
+ isCustomerSelfServeChange,
2547
+ currency,
2548
+ ]);
2549
+
2550
+ const returnOptionForOrderSummary = useMemo(() => {
2551
+ // Same itinerary + dashboard change: keep raw return option (floor only after itinerary changes).
2552
+ // Public self-serve: always apply API return floor on options so totals match paid return.
2553
+ if (
2554
+ isChangeFlow &&
2555
+ changeFlowSameItineraryAsOriginalBooking &&
2556
+ !isCustomerSelfServeChange
2557
+ ) {
2558
+ return selectedReturnOption ?? null;
2559
+ }
2560
+ return selectedReturnOptionWithFloor;
2561
+ }, [
2562
+ isChangeFlow,
2563
+ changeFlowSameItineraryAsOriginalBooking,
2564
+ isCustomerSelfServeChange,
2565
+ selectedReturnOption,
2566
+ selectedReturnOptionWithFloor,
2567
+ ]);
2568
+ const effectiveSelectedPickupVacancies = useMemo(() => {
2569
+ if (!selectedAvailability) return 0;
2570
+ const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
2571
+ return Math.max(0, selectedAvailability.vacancies ?? 0) + seatCredit;
2572
+ }, [selectedAvailability, changeFlowSeatCreditForOutboundAvailability]);
2573
+ const effectiveSelectedReturnVacancies = useMemo(() => {
2574
+ if (!selectedReturnOption) return null;
2575
+ const seatCredit = changeFlowSeatCreditForReturnAvailabilityId(
2576
+ selectedReturnOption.returnAvailabilityId
2577
+ );
2578
+ return Math.max(0, selectedReturnOption.vacancies ?? 0) + seatCredit;
2579
+ }, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
2167
2580
 
2168
2581
  // Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
2169
- const pricing = useMemo(() => {
2170
- if (!selectedAvailability || !pricingConfig) return [];
2171
- const optionId = selectedAvailability.productOptionId;
2172
- const selectedOption = activeOptions.find(opt => opt.optionId === optionId);
2173
- const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
2174
- const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
2175
- getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
2176
- const getBaseInDisplayCurrency = (category: string) => {
2177
- const fromPrecomputed = precomputed?.[category]?.[currency];
2178
- if (fromPrecomputed != null) return fromPrecomputed;
2179
- return 0;
2180
- };
2181
- const buildRate = (
2182
- category: string,
2183
- backendPriceCAD: number,
2184
- backendInDisplayCurrency: number,
2185
- baseInDisplayCurrency: number,
2186
- appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>
2187
- ) => {
2188
- const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
2189
- const isPublicMode = isSimplifiedPricingView;
2190
- const breakdown = computePriceBreakdown(
2191
- pricingConfig,
2582
+ const pricing = useMemo(
2583
+ () =>
2584
+ buildPricingFromAvailability(
2585
+ selectedAvailability,
2586
+ activeOptions,
2587
+ precomputedPricesByOption,
2192
2588
  currency,
2193
- backendPriceCAD,
2194
- basePriceCAD,
2589
+ pricingConfig,
2195
2590
  hasFees,
2196
- appliedAdjustments,
2197
- undefined,
2198
- baseInDisplayCurrency,
2199
- isPublicMode
2200
- );
2201
- const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
2202
- return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
2203
- };
2204
- return selectedAvailability.rates?.map(rate => {
2205
- const backendPriceCAD = rate.price ?? 0;
2206
- const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
2207
- const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
2208
- const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
2209
- return {
2210
- category: rate.category,
2211
- rateId: rate.rateId || rate.category,
2212
- available: rate.available,
2213
- price: built.price,
2214
- priceCAD: built.priceCAD,
2215
- baseInDisplayCurrency: built.baseInDisplayCurrency,
2216
- appliedAdjustments: built.appliedAdjustments,
2217
- };
2218
- }) || selectedAvailability.pricesByCategory?.retailPrices?.map(p => {
2219
- const priceCADFromApi = p.price / 100;
2220
- const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
2221
- const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
2222
- const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
2223
- return {
2224
- category: p.category,
2225
- rateId: p.category,
2226
- available: selectedAvailability.vacancies,
2227
- price: built.price,
2228
- priceCAD: built.priceCAD,
2229
- baseInDisplayCurrency: built.baseInDisplayCurrency,
2230
- appliedAdjustments: built.appliedAdjustments,
2231
- };
2232
- }) || [];
2233
- }, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
2591
+ isSimplifiedPricingView,
2592
+ ),
2593
+ [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
2594
+ );
2234
2595
 
2235
2596
  // Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
2236
2597
  const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
@@ -2257,56 +2618,38 @@ export function BookingFlow({
2257
2618
  computeOrderSummary(
2258
2619
  quantities,
2259
2620
  pricing,
2260
- selectedReturnOptionWithFloor,
2621
+ returnOptionForOrderSummary,
2261
2622
  pricingConfig ?? null,
2262
2623
  currency,
2263
2624
  hasFees,
2264
2625
  cancellationPolicyId
2265
2626
  ),
2266
- [quantities, pricing, selectedReturnOptionWithFloor, pricingConfig, currency, hasFees, cancellationPolicyId]
2627
+ [quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
2267
2628
  );
2629
+
2268
2630
  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));
2276
- }
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]);
2290
2631
  const changeFlowProtectedTicketSubtotal = useMemo(() => {
2291
2632
  const currentTicketSubtotal = ticketLineItems.reduce(
2292
2633
  (sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
2293
2634
  0,
2294
2635
  );
2295
- if (!changeFlowPreserveBookedTicketPricing) return currentTicketSubtotal;
2636
+ if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
2296
2637
  return ticketLineItems.reduce((sum, line) => {
2297
2638
  const category = line.category?.trim().toUpperCase();
2298
2639
  const qty = Math.max(0, Number(line.qty) || 0);
2299
- const liveUnitPrice = qty > 0 ? line.itemTotal / qty : 0;
2300
2640
  if (!category || qty <= 0) return sum;
2301
- const bookedUnitPrice = changeFlowTicketBookedUnitPriceByCategory.get(category);
2302
- if (bookedUnitPrice == null) return sum + line.itemTotal;
2303
- const baselineQty = Math.max(0, changeFlowInitialTicketQtyByCategory.get(category) ?? 0);
2304
- const protectedQty = Math.min(qty, baselineQty);
2305
- const incrementalQty = Math.max(0, qty - baselineQty);
2306
- return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnitPrice;
2641
+ return (
2642
+ sum +
2643
+ changeFlowTicketLineTotalWithReceiptFloor({
2644
+ qty,
2645
+ baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
2646
+ receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
2647
+ liveLineTotal: Number(line.itemTotal) || 0,
2648
+ })
2649
+ );
2307
2650
  }, 0);
2308
2651
  }, [
2309
- changeFlowPreserveBookedTicketPricing,
2652
+ changeFlowApplyReceiptPaidFloors,
2310
2653
  ticketLineItems,
2311
2654
  changeFlowTicketBookedUnitPriceByCategory,
2312
2655
  changeFlowInitialTicketQtyByCategory,
@@ -2314,10 +2657,25 @@ export function BookingFlow({
2314
2657
  /** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
2315
2658
  const effectivePartySizeCap = useMemo(() => {
2316
2659
  if (!selectedAvailability) return 0;
2317
- const outbound = selectedAvailability.vacancies ?? 0;
2660
+ const outboundSeatCredit =
2661
+ changeFlowOutboundMatchesOriginalSelection && changeFlowInitialTicketCount > 0
2662
+ ? changeFlowInitialTicketCount
2663
+ : 0;
2664
+ const outbound = Math.max(0, selectedAvailability.vacancies ?? 0) + outboundSeatCredit;
2318
2665
  if (selectedReturnOption == null) return outbound;
2319
- return Math.min(outbound, selectedReturnOption.vacancies ?? 0);
2320
- }, [selectedAvailability, selectedReturnOption]);
2666
+ const returnSeatCredit =
2667
+ changeFlowReturnMatchesOriginalSelection && changeFlowInitialTicketCount > 0
2668
+ ? changeFlowInitialTicketCount
2669
+ : 0;
2670
+ const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0) + returnSeatCredit;
2671
+ return Math.min(outbound, returnCap);
2672
+ }, [
2673
+ selectedAvailability,
2674
+ selectedReturnOption,
2675
+ changeFlowInitialTicketCount,
2676
+ changeFlowOutboundMatchesOriginalSelection,
2677
+ changeFlowReturnMatchesOriginalSelection,
2678
+ ]);
2321
2679
 
2322
2680
  const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
2323
2681
  /** Label for display when policy may be forced by promo (not in pricingConfig list). */
@@ -2378,50 +2736,104 @@ export function BookingFlow({
2378
2736
  () => ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0),
2379
2737
  [ticketLineItems],
2380
2738
  );
2381
- const changeFlowInitialTotalTicketQty = useMemo(() => {
2382
- let sum = 0;
2383
- for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
2384
- return sum;
2385
- }, [changeFlowInitialTicketQtyByCategory]);
2739
+ const changeFlowInitialTotalTicketQty = changeFlowInitialTicketCount;
2386
2740
  const currentFeeSubtotal = useMemo(
2387
2741
  () => feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0),
2388
2742
  [feeLineItems],
2389
2743
  );
2390
2744
  const changeFlowProtectedFeeSubtotal = useMemo(() => {
2391
- if (!changeFlowPreserveBookedTicketPricing || totalQuantity <= 0) return currentFeeSubtotal;
2745
+ if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
2746
+ const initialParty = changeFlowInitialTicketCount;
2392
2747
  return feeLineItems.reduce((sum, line) => {
2393
2748
  const key = normalizeLineLabelForCompare(line.name || '');
2394
- const currentUnitPrice = line.totalAmount / totalQuantity;
2395
- const bookedUnitPrice = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : null;
2396
- 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;
2749
+ const bookedFeePerPerson = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : undefined;
2750
+ return (
2751
+ sum +
2752
+ changeFlowFeeLineTotalWithReceiptFloor({
2753
+ totalQuantity,
2754
+ initialTicketCount: initialParty,
2755
+ bookedFeePerPerson,
2756
+ liveFeeLineTotal: Number(line.totalAmount) || 0,
2757
+ })
2758
+ );
2400
2759
  }, 0);
2401
2760
  }, [
2402
- changeFlowPreserveBookedTicketPricing,
2761
+ changeFlowApplyReceiptPaidFloors,
2403
2762
  totalQuantity,
2404
2763
  currentFeeSubtotal,
2405
2764
  feeLineItems,
2406
2765
  changeFlowBookedFeeUnitByNormalizedLabel,
2407
- changeFlowInitialTotalTicketQty,
2766
+ changeFlowInitialTicketCount,
2408
2767
  ]);
2768
+ /** Catalog (unfloored) return price per person — same slot as [selectedReturnOption] on the raw availability list. */
2769
+ const returnOptionCatalogPerPerson = useMemo(() => {
2770
+ if (!selectedReturnOption?.returnAvailabilityId || !selectedAvailability?.returnOptions?.length) {
2771
+ return null;
2772
+ }
2773
+ const raw = selectedAvailability.returnOptions.find(
2774
+ (o) => o.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
2775
+ );
2776
+ const v = raw?.priceAdjustmentByCurrency?.[currency];
2777
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
2778
+ }, [selectedReturnOption?.returnAvailabilityId, selectedAvailability?.returnOptions, currency]);
2779
+
2409
2780
  const changeFlowProtectedReturnAdjustment = useMemo(() => {
2410
- if (!changeFlowPreserveBookedTicketPricing || totalQuantity <= 0) return returnPriceAdjustment;
2411
- 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;
2781
+ if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return returnPriceAdjustment;
2782
+ if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
2783
+ const livePerPerson =
2784
+ returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
2785
+ const perPerson = changeFlowReturnPerPersonWithReceiptFloor({
2786
+ livePerPerson,
2787
+ receiptFloorPerPerson: effectiveChangeFlowReturnUnitFloorPerPerson,
2788
+ });
2789
+ return totalQuantity * perPerson;
2416
2790
  }, [
2417
- changeFlowPreserveBookedTicketPricing,
2791
+ changeFlowApplyReceiptPaidFloors,
2418
2792
  totalQuantity,
2419
2793
  returnPriceAdjustment,
2420
- changeFlowReturnUnitFloorPerPerson,
2421
- changeFlowInitialTotalTicketQty,
2794
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2795
+ selectedReturnOption,
2796
+ returnOptionCatalogPerPerson,
2797
+ currency,
2798
+ ]);
2799
+
2800
+ /** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
2801
+ const checkoutReturnLineAmount = useMemo(() => {
2802
+ if (isChangeFlow && changeFlowApplyReceiptPaidFloors) {
2803
+ return changeFlowProtectedReturnAdjustment;
2804
+ }
2805
+ return returnPriceAdjustment;
2806
+ }, [
2807
+ isChangeFlow,
2808
+ changeFlowApplyReceiptPaidFloors,
2809
+ changeFlowProtectedReturnAdjustment,
2810
+ returnPriceAdjustment,
2811
+ ]);
2812
+
2813
+ /** Ticket lines with receipt floors applied for breakdown/modal (matches protected ticket subtotal). */
2814
+ const ticketLineItemsForChangeFlowDisplay = useMemo(() => {
2815
+ if (!changeFlowApplyReceiptPaidFloors) return ticketLineItems;
2816
+ return ticketLineItems.map((line) => {
2817
+ const category = line.category?.trim().toUpperCase();
2818
+ const qty = Math.max(0, Number(line.qty) || 0);
2819
+ if (!category || qty <= 0) return line;
2820
+ const newTotal = changeFlowTicketLineTotalWithReceiptFloor({
2821
+ qty,
2822
+ baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
2823
+ receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
2824
+ liveLineTotal: Number(line.itemTotal) || 0,
2825
+ });
2826
+ return { ...line, itemTotal: newTotal };
2827
+ });
2828
+ }, [
2829
+ changeFlowApplyReceiptPaidFloors,
2830
+ ticketLineItems,
2831
+ changeFlowTicketBookedUnitPriceByCategory,
2832
+ changeFlowInitialTicketQtyByCategory,
2422
2833
  ]);
2834
+
2423
2835
  const effectiveSubtotalBeforeAddOns =
2424
- isChangeFlow && changeFlowPreserveBookedTicketPricing
2836
+ isChangeFlow && changeFlowApplyReceiptPaidFloors
2425
2837
  ? subtotal -
2426
2838
  currentTicketSubtotal -
2427
2839
  currentFeeSubtotal -
@@ -2488,8 +2900,11 @@ export function BookingFlow({
2488
2900
 
2489
2901
  const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
2490
2902
  if (!selectedAvailability) return [];
2903
+ const returnLineAmount = checkoutReturnLineAmount;
2904
+ const showReturnLine =
2905
+ Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
2491
2906
  return [
2492
- ...ticketLineItems.map((line): PriceSummaryLine => {
2907
+ ...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
2493
2908
  const rate = pricing.find((r) => r.category === line.category);
2494
2909
  const breakdown = getPriceBreakdown(
2495
2910
  line.category,
@@ -2512,12 +2927,12 @@ export function BookingFlow({
2512
2927
  breakdown,
2513
2928
  };
2514
2929
  }),
2515
- ...(selectedReturnOption && returnPriceAdjustment !== 0
2930
+ ...(showReturnLine
2516
2931
  ? [
2517
2932
  {
2518
2933
  kind: 'line' as const,
2519
2934
  label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
2520
- amount: returnPriceAdjustment,
2935
+ amount: returnLineAmount,
2521
2936
  type: 'return',
2522
2937
  },
2523
2938
  ]
@@ -2572,11 +2987,11 @@ export function BookingFlow({
2572
2987
  ];
2573
2988
  }, [
2574
2989
  selectedAvailability,
2575
- ticketLineItems,
2990
+ ticketLineItemsForChangeFlowDisplay,
2576
2991
  pricing,
2577
2992
  getPriceBreakdown,
2578
2993
  selectedReturnOption,
2579
- returnPriceAdjustment,
2994
+ checkoutReturnLineAmount,
2580
2995
  totalQuantity,
2581
2996
  t,
2582
2997
  cancellationPolicyFee,
@@ -2713,9 +3128,13 @@ export function BookingFlow({
2713
3128
  ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
2714
3129
  : taxOnSubtotal;
2715
3130
  const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
2716
- const changeFlowProposedTotal = isChangeFlow && originalReceipt
2717
- ? reconcileChangeBookingProposedTotal(totalPrice, originalReceipt.total)
2718
- : totalPrice;
3131
+ /** Change-flow product rules: `lib/booking/change-flow-pricing.ts`. */
3132
+ const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
3133
+ isChangeFlow,
3134
+ cartTotal: totalPrice,
3135
+ originalReceiptTotal: originalReceipt?.total,
3136
+ });
3137
+
2719
3138
  const changeSelectionDetails = useMemo(() => {
2720
3139
  if (!isChangeFlow || !initialValues) {
2721
3140
  return {
@@ -2747,11 +3166,12 @@ export function BookingFlow({
2747
3166
  const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
2748
3167
  // Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
2749
3168
  const dateChanged = initialMs != null && initialMs !== selectedMs;
2750
- // productOptionId on the booking is the option id, not productId — compare only when both are known.
2751
- const initialOpt = initialValues.productOptionId?.trim() || null;
2752
- const selectedOpt = selectedAvailability.productOptionId?.trim() || null;
3169
+ const initialOpt =
3170
+ changeFlowResolvedInitialProductOptionId ??
3171
+ (initialValues.productOptionId?.trim() || null);
3172
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
2753
3173
  const optionChanged = Boolean(
2754
- initialOpt && selectedOpt && initialOpt !== selectedOpt
3174
+ selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
2755
3175
  );
2756
3176
  const normalizePickupId = (value: string | null | undefined) => {
2757
3177
  const trimmed = value?.trim();
@@ -2851,6 +3271,7 @@ export function BookingFlow({
2851
3271
  }, [
2852
3272
  isChangeFlow,
2853
3273
  initialValues,
3274
+ changeFlowResolvedInitialProductOptionId,
2854
3275
  selectedAvailability,
2855
3276
  selectedReturnOption,
2856
3277
  implicitReturnBaselineId,
@@ -2885,15 +3306,6 @@ export function BookingFlow({
2885
3306
  showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
2886
3307
  ? providerPricingUi.totalsPreview
2887
3308
  : 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
3309
  const providerHasEditedLineOverrides =
2898
3310
  isProviderDashboardChange &&
2899
3311
  providerQuotedLines.some((line) => {
@@ -2921,23 +3333,29 @@ export function BookingFlow({
2921
3333
  const hasEffectiveChangeSelection =
2922
3334
  hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
2923
3335
 
3336
+ const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
3337
+ providerPreview: providerTotalsPreview,
3338
+ fromCart: {
3339
+ total: changeFlowNewBookingTotal,
3340
+ subtotal: effectiveSubtotal,
3341
+ tax: effectiveTax,
3342
+ },
3343
+ });
3344
+ const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
3345
+ const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
3346
+ const displayChangeFlowTax = displayedChangeAmounts.tax;
3347
+
2924
3348
  const changeFlowClientEstimateDue = originalReceipt
2925
- ? (isProviderDashboardChange
2926
- ? displayChangeFlowProposedTotal - originalReceipt.total
2927
- : Math.max(displayChangeFlowProposedTotal - originalReceipt.total, 0))
3349
+ ? changeFlowBalanceVsOriginal({
3350
+ newTotal: displayChangeFlowProposedTotal,
3351
+ originalReceiptTotal: originalReceipt.total,
3352
+ audience: isProviderDashboardChange ? 'provider' : 'customer',
3353
+ })
2928
3354
  : totalPrice;
2929
3355
 
2930
- /**
2931
- * Amount owed for change flow: same recipe as normal booking — FE `totalPrice` vs original receipt.
2932
- * Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
2933
- */
2934
3356
  const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
2935
- const changeFlowAmountDue = isChangeFlow
2936
- ? (() => {
2937
- const rounded = Math.round(changeFlowAmountDueRaw * 100) / 100;
2938
- return Math.abs(rounded) < 0.01 ? 0 : rounded;
2939
- })()
2940
- : changeFlowAmountDueRaw;
3357
+ const changeFlowAmountDue = isChangeFlow ? normalizeNearZeroOwed(changeFlowAmountDueRaw) : changeFlowAmountDueRaw;
3358
+
2941
3359
 
2942
3360
  const changeCheckoutButtonLabel = (() => {
2943
3361
  if (!isChangeFlow) return undefined;
@@ -3039,7 +3457,7 @@ export function BookingFlow({
3039
3457
  dateChanged: changeSelectionDetails.dateChanged,
3040
3458
  ticketsChanged: changeSelectionDetails.ticketsChanged,
3041
3459
  hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
3042
- selectionTotal: totalPrice,
3460
+ selectionTotal: originalReceipt ? changeFlowNewBookingTotal : totalPrice,
3043
3461
  selectionCurrency: currency,
3044
3462
  };
3045
3463
  }, [
@@ -3052,6 +3470,8 @@ export function BookingFlow({
3052
3470
  changeSelectionDetails,
3053
3471
  totalPrice,
3054
3472
  currency,
3473
+ originalReceipt,
3474
+ changeFlowNewBookingTotal,
3055
3475
  ]);
3056
3476
 
3057
3477
  useEffect(() => {
@@ -3087,9 +3507,14 @@ export function BookingFlow({
3087
3507
  return;
3088
3508
  }
3089
3509
  onPricePreviewChange({
3090
- subtotal: effectiveSubtotal,
3091
- tax: !isTaxIncludedInPrice ? effectiveTax : 0,
3092
- total: totalPrice,
3510
+ subtotal: isChangeFlow && originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
3511
+ tax:
3512
+ !isTaxIncludedInPrice
3513
+ ? isChangeFlow && originalReceipt
3514
+ ? displayChangeFlowTax
3515
+ : effectiveTax
3516
+ : 0,
3517
+ total: isChangeFlow && originalReceipt ? changeFlowNewBookingTotal : totalPrice,
3093
3518
  currency,
3094
3519
  });
3095
3520
  }, [
@@ -3098,9 +3523,14 @@ export function BookingFlow({
3098
3523
  totalQuantity,
3099
3524
  effectiveSubtotal,
3100
3525
  effectiveTax,
3101
- changeFlowProposedTotal,
3526
+ changeFlowNewBookingTotal,
3527
+ displayChangeFlowSubtotal,
3528
+ displayChangeFlowTax,
3102
3529
  currency,
3103
3530
  isTaxIncludedInPrice,
3531
+ isChangeFlow,
3532
+ originalReceipt,
3533
+ totalPrice,
3104
3534
  ]);
3105
3535
 
3106
3536
  /** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
@@ -3155,7 +3585,13 @@ export function BookingFlow({
3155
3585
  newPassengerCounts: bookingItems,
3156
3586
  // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
3157
3587
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
3158
- clientProposedTotal: changeFlowProposedTotal,
3588
+ clientProposedTotal: changeFlowNewBookingTotal,
3589
+ capacitySeatCredit: {
3590
+ enabled: true,
3591
+ previousPassengerCount: changeFlowInitialTicketCount,
3592
+ previousAvailabilityId: initialValues.availabilityId ?? null,
3593
+ previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
3594
+ },
3159
3595
  });
3160
3596
  if (seq !== changeQuoteRequestSeq.current) return;
3161
3597
  const canProceed = quote.canProceed !== false;
@@ -3200,9 +3636,13 @@ export function BookingFlow({
3200
3636
  selectedReturnOption?.returnAvailabilityId,
3201
3637
  quantities,
3202
3638
  addOnSelections,
3639
+ changeFlowInitialTicketCount,
3640
+ changeFlowNewBookingTotal,
3203
3641
  totalPrice,
3204
3642
  currency,
3205
3643
  activeOptions,
3644
+ initialValues?.availabilityId,
3645
+ initialValues?.returnAvailabilityId,
3206
3646
  ]);
3207
3647
 
3208
3648
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -3350,12 +3790,11 @@ export function BookingFlow({
3350
3790
 
3351
3791
  const preferBooked =
3352
3792
  isChangeFlow && initialReturnIdForSelect
3353
- ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect && opt.vacancies > 0)
3793
+ ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
3354
3794
  : undefined;
3355
3795
  const preferByDateTime =
3356
3796
  isChangeFlow && initialReturnDtForSelect && !preferBooked
3357
3797
  ? sorted.find((opt) => {
3358
- if (opt.vacancies <= 0) return false;
3359
3798
  try {
3360
3799
  return (
3361
3800
  parseAvailabilityDateTime(opt.dateTime).getTime() ===
@@ -3778,6 +4217,12 @@ export function BookingFlow({
3778
4217
  additionalAdjustments: providerAdditionalAdjustments,
3779
4218
  }
3780
4219
  : undefined,
4220
+ capacitySeatCredit: {
4221
+ enabled: true,
4222
+ previousPassengerCount: changeFlowInitialTicketCount,
4223
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
4224
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
4225
+ },
3781
4226
  });
3782
4227
  setLoading(false);
3783
4228
  return;
@@ -3816,7 +4261,7 @@ export function BookingFlow({
3816
4261
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
3817
4262
  newPassengerCounts: bookingItems,
3818
4263
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
3819
- clientProposedTotal: changeFlowProposedTotal,
4264
+ clientProposedTotal: changeFlowNewBookingTotal,
3820
4265
  });
3821
4266
  const canProceed = quote.canProceed !== false;
3822
4267
  const quoteCurrency = (quote.currency || currency) as Currency;
@@ -3837,7 +4282,11 @@ export function BookingFlow({
3837
4282
  if (!canProceed) {
3838
4283
  throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
3839
4284
  }
3840
- const feChangeDue = Math.max(0, changeFlowProposedTotal - (originalReceipt?.total ?? 0));
4285
+ const feChangeDue = changeFlowBalanceVsOriginal({
4286
+ newTotal: changeFlowNewBookingTotal,
4287
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4288
+ audience: 'customer',
4289
+ });
3841
4290
  const serverAmountDue =
3842
4291
  quote.amountDueCents != null
3843
4292
  ? Math.max(0, quote.amountDueCents / 100)
@@ -3947,20 +4396,24 @@ export function BookingFlow({
3947
4396
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
3948
4397
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
3949
4398
  const amountDueForCheckout = isCustomerSelfServeChange
3950
- ? Math.max(0, changeFlowProposedTotal - (originalReceipt?.total ?? 0))
4399
+ ? changeFlowBalanceVsOriginal({
4400
+ newTotal: changeFlowNewBookingTotal,
4401
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4402
+ audience: 'customer',
4403
+ })
3951
4404
  : totalPrice;
3952
4405
  const lines = [
3953
- ...ticketLineItems.map((line) => ({
4406
+ ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
3954
4407
  label: line.category,
3955
4408
  amount: line.itemTotal,
3956
4409
  type: 'TICKET' as const,
3957
4410
  quantity: line.qty,
3958
4411
  })),
3959
- ...(returnPriceAdjustment !== 0
4412
+ ...(checkoutReturnLineAmount !== 0
3960
4413
  ? [
3961
4414
  {
3962
4415
  label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
3963
- amount: returnPriceAdjustment,
4416
+ amount: checkoutReturnLineAmount,
3964
4417
  type: 'RETURN_OPTION' as const,
3965
4418
  quantity: totalQuantity,
3966
4419
  },
@@ -4158,7 +4611,7 @@ export function BookingFlow({
4158
4611
  availabilityProductOptionId,
4159
4612
  itineraryDisplay: itineraryDisplay ?? undefined,
4160
4613
  clientSecret: paymentIntent.clientSecret ?? '',
4161
- ticketLinesForModal: ticketLineItems.map((line) => {
4614
+ ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
4162
4615
  const rate = pricing.find((r) => r.category === line.category);
4163
4616
  const breakdown = getPriceBreakdown(
4164
4617
  line.category,
@@ -4169,7 +4622,7 @@ export function BookingFlow({
4169
4622
  return { line, breakdown };
4170
4623
  }),
4171
4624
  feeLineItems: feeLineItemsWithAddOns,
4172
- returnPriceAdjustment,
4625
+ returnPriceAdjustment: checkoutReturnLineAmount,
4173
4626
  cancellationPolicyFee,
4174
4627
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4175
4628
  subtotal: effectiveSubtotal,
@@ -4185,7 +4638,7 @@ export function BookingFlow({
4185
4638
  return;
4186
4639
  }
4187
4640
 
4188
- const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItems.map((line) => {
4641
+ const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
4189
4642
  const rate = pricing.find((r) => r.category === line.category);
4190
4643
  const breakdown = getPriceBreakdown(
4191
4644
  line.category,
@@ -4221,7 +4674,7 @@ export function BookingFlow({
4221
4674
  : undefined,
4222
4675
  ticketLines: ticketLinesForModal,
4223
4676
  feeLineItems: feeLineItemsWithAddOns,
4224
- returnPriceAdjustment,
4677
+ returnPriceAdjustment: checkoutReturnLineAmount,
4225
4678
  cancellationPolicyFee,
4226
4679
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4227
4680
  subtotal: effectiveSubtotal,
@@ -4236,7 +4689,7 @@ export function BookingFlow({
4236
4689
  isCustomerSelfServeChange && originalReceipt
4237
4690
  ? {
4238
4691
  previousTotal: originalReceipt.total,
4239
- newTotal: totalPrice,
4692
+ newTotal: displayChangeFlowProposedTotal,
4240
4693
  differenceTotal: amountDueForCheckout,
4241
4694
  }
4242
4695
  : undefined,
@@ -4507,6 +4960,7 @@ export function BookingFlow({
4507
4960
  availabilitiesByDate={availabilitiesByDate}
4508
4961
  selectedDate={selectedDate}
4509
4962
  syncVisibleWeekToSelectedDate={isChangeFlow}
4963
+ selectableSoldOutDate={changeFlowOriginalDate}
4510
4964
  isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
4511
4965
  onDateSelect={(date) => {
4512
4966
  setSelectedDate(date);
@@ -4529,7 +4983,11 @@ export function BookingFlow({
4529
4983
  currency={currency}
4530
4984
  showCapacity={isAdmin}
4531
4985
  extraDiscountPercent={calendarDiscountPercent}
4532
- capDiscountToSelectedDate={isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0}
4986
+ capDiscountBadgesToBookingDate={
4987
+ isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0
4988
+ ? changeFlowOriginalDate
4989
+ : null
4990
+ }
4533
4991
  />
4534
4992
  </div>
4535
4993
  </div>
@@ -4642,11 +5100,9 @@ export function BookingFlow({
4642
5100
  selectedVacancies={effectivePartySizeCap}
4643
5101
  companyTimezone={companyTimezone}
4644
5102
  pickupDateTime={selectedAvailability.dateTime}
4645
- pickupVacancies={selectedAvailability.vacancies ?? 0}
5103
+ pickupVacancies={effectiveSelectedPickupVacancies}
4646
5104
  returnDateTime={selectedReturnOption?.dateTime ?? null}
4647
- returnVacancies={
4648
- selectedReturnOption != null ? (selectedReturnOption.vacancies ?? 0) : null
4649
- }
5105
+ returnVacancies={effectiveSelectedReturnVacancies}
4650
5106
  resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
4651
5107
  currency={currency}
4652
5108
  locale={locale}
@@ -4673,6 +5129,7 @@ export function BookingFlow({
4673
5129
 
4674
5130
  {/* Total and Checkout — shared PriceSummary component */}
4675
5131
  {selectedAvailability && (
5132
+ <>
4676
5133
  <CheckoutForm
4677
5134
  priceSummaryLines={checkoutPriceSummaryLines}
4678
5135
  totalPrice={changeFlowAmountDue}
@@ -4900,6 +5357,7 @@ export function BookingFlow({
4900
5357
  showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
4901
5358
  }
4902
5359
  />
5360
+ </>
4903
5361
  )}
4904
5362
  </div>
4905
5363
  </>