@ticketboothapp/booking 1.2.59 → 1.2.61

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';
@@ -79,16 +90,6 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
79
90
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
80
91
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
81
92
 
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
-
92
93
  /** Live selection snapshot for change-booking compare UI (parent dialog). */
93
94
  export interface ChangeFlowSelectionPreview {
94
95
  tourName: string;
@@ -153,140 +154,6 @@ function normalizeLineLabelForCompare(label: string): string {
153
154
  .trim();
154
155
  }
155
156
 
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
-
290
157
  function deriveAddOnSelectionsFromReceiptLines(
291
158
  addOns: AddOn[],
292
159
  lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
@@ -346,6 +213,92 @@ function findMergedAvailabilityForSelection(
346
213
  return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
347
214
  }
348
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
+
349
302
  function findMergedReturnVacancies(
350
303
  outbound: Availability | undefined,
351
304
  selectedReturn: ReturnOption | null | undefined
@@ -429,12 +382,16 @@ interface BookingFlowProps {
429
382
  dateTime?: string | null;
430
383
  /** Inventory slot id from booking (when API persists it); strongest match for change flow. */
431
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;
432
387
  productOptionId?: string | null;
433
388
  pickupLocationId?: string | null;
434
389
  /** Original booked return slot (round-trip), when API provides it. */
435
390
  returnAvailabilityId?: string | null;
436
391
  /** Fallback when only return datetime is on the booking payload. */
437
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;
438
395
  bookingItems?: Array<{ category: string; count: number }> | null;
439
396
  addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
440
397
  customer?: {
@@ -910,6 +867,18 @@ export function BookingFlow({
910
867
  const hasAutoSelectedPartnerPickupRef = useRef(false);
911
868
  const handleDateSelectRef = useRef<(date: string) => void>(() => {});
912
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]);
913
882
  const isProviderDashboardChange = Boolean(onChangeBooking);
914
883
  const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
915
884
 
@@ -1720,6 +1689,83 @@ export function BookingFlow({
1720
1689
  [timesForSelectedDate],
1721
1690
  );
1722
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
+ /**
1758
+ * Receipt paid floors (max(receipt, catalog) on protected seats / return) — **customer self-serve only**.
1759
+ * Provider dashboard uses live catalog so cheaper itinerary → lower total / refund (`changeFlowBalanceVsOriginal` provider).
1760
+ */
1761
+ const changeFlowApplyReceiptPaidFloors = useMemo(
1762
+ () =>
1763
+ isChangeFlow &&
1764
+ !isProviderDashboardChange &&
1765
+ changeFlowBookingParentProductIdForFloors === product.productId.trim(),
1766
+ [isChangeFlow, isProviderDashboardChange, changeFlowBookingParentProductIdForFloors, product.productId],
1767
+ );
1768
+
1723
1769
  useEffect(() => {
1724
1770
  if (hasAppliedInitialValuesRef.current || !initialValues) return;
1725
1771
  const trimmedEmail = initialValues.customer?.email?.trim();
@@ -1760,7 +1806,7 @@ export function BookingFlow({
1760
1806
  target,
1761
1807
  companyTimezone,
1762
1808
  initialValues.availabilityId ?? null,
1763
- initialValues.productOptionId ?? null,
1809
+ changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
1764
1810
  initialValues.bookingItems ?? null,
1765
1811
  precomputedPricesByOption,
1766
1812
  currency,
@@ -1776,6 +1822,7 @@ export function BookingFlow({
1776
1822
  initialValues?.availabilityId,
1777
1823
  initialValues?.productOptionId,
1778
1824
  initialValues?.bookingItems,
1825
+ changeFlowResolvedInitialProductOptionId,
1779
1826
  selectedAvailability,
1780
1827
  selectedDate,
1781
1828
  timesForSelectedDate,
@@ -1943,14 +1990,20 @@ export function BookingFlow({
1943
1990
  const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
1944
1991
  const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
1945
1992
  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;
1993
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
1994
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
1995
+ if (selectedOpt != null && initialOpt != null) {
1996
+ return selectedOpt === initialOpt ? changeFlowInitialTicketCountForSeatCredit : 0;
1997
+ }
1998
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
1999
+ const legacySelected = availability.productOptionId?.trim() || null;
2000
+ if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCountForSeatCredit;
2001
+ return legacySelected === legacyInitial ? changeFlowInitialTicketCountForSeatCredit : 0;
1950
2002
  },
1951
2003
  [
1952
2004
  isChangeFlow,
1953
2005
  changeFlowInitialTicketCountForSeatCredit,
2006
+ changeFlowResolvedInitialProductOptionId,
1954
2007
  initialValues?.availabilityId,
1955
2008
  initialValues?.dateTime,
1956
2009
  initialValues?.productOptionId,
@@ -2298,7 +2351,11 @@ export function BookingFlow({
2298
2351
  return floors;
2299
2352
  }, [isChangeFlow, originalReceipt?.lineItems]);
2300
2353
 
2301
- const changeFlowReturnUnitFloorPerPerson = useMemo(() => {
2354
+ /**
2355
+ * When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
2356
+ * so catalog "free" return slots still show and price at the original return value in change flow.
2357
+ */
2358
+ const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
2302
2359
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return null;
2303
2360
  const fallbackBookedQty =
2304
2361
  (initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
@@ -2306,7 +2363,7 @@ export function BookingFlow({
2306
2363
  let totalQty = 0;
2307
2364
  for (const line of originalReceipt.lineItems) {
2308
2365
  const type = (line.type || '').trim().toUpperCase();
2309
- if (type !== 'RETURN_OPTION') continue;
2366
+ if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
2310
2367
  const amount = Number(line.amount ?? 0);
2311
2368
  const qtyRaw = Number(line.quantity ?? 0);
2312
2369
  const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
@@ -2317,6 +2374,15 @@ export function BookingFlow({
2317
2374
  if (totalAmount <= 0 || totalQty <= 0) return null;
2318
2375
  return totalAmount / totalQty;
2319
2376
  }, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
2377
+
2378
+ const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
2379
+ if (!isChangeFlow) return null;
2380
+ const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
2381
+ if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
2382
+ const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
2383
+ if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
2384
+ return null;
2385
+ }, [isChangeFlow, initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
2320
2386
  const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
2321
2387
  const feeUnitByLabel = new Map<string, number>();
2322
2388
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
@@ -2360,19 +2426,25 @@ export function BookingFlow({
2360
2426
  Boolean(initialAvailabilityId && selectedAvailabilityId) &&
2361
2427
  initialAvailabilityId === selectedAvailabilityId;
2362
2428
  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.
2429
+ // Same wall time + same product option (ids may rotate on refresh).
2364
2430
  const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
2365
2431
  const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
2366
2432
  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;
2433
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
2434
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
2435
+ if (selectedOpt != null && initialOpt != null) {
2436
+ return selectedOpt === initialOpt;
2437
+ }
2438
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
2439
+ const legacySelected = selectedAvailability.productOptionId?.trim() || null;
2440
+ return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
2370
2441
  }, [
2371
2442
  isChangeFlow,
2372
2443
  selectedAvailability,
2373
2444
  initialValues?.availabilityId,
2374
2445
  initialValues?.dateTime,
2375
2446
  initialValues?.productOptionId,
2447
+ changeFlowResolvedInitialProductOptionId,
2376
2448
  ]);
2377
2449
  const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
2378
2450
  if (!isChangeFlow) return false;
@@ -2410,25 +2482,18 @@ export function BookingFlow({
2410
2482
  selectedReturnOption?.dateTime,
2411
2483
  ]);
2412
2484
  /** 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
-
2421
2485
  const returnOptionsWithFloor = useMemo(() => {
2422
2486
  const options = selectedAvailability?.returnOptions ?? [];
2423
- if (!isChangeFlow && changeFlowReturnUnitFloorPerPerson == null) return options;
2487
+ if (!isChangeFlow && effectiveChangeFlowReturnUnitFloorPerPerson == null) return options;
2424
2488
  return options.map((opt) => {
2425
2489
  const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
2426
2490
  const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
2427
2491
  const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
2492
+ // Floors on return cards only for self-serve; provider sees catalog prices (refunds when cheaper).
2428
2493
  const applyReturnFloor =
2429
- changeFlowReturnUnitFloorPerPerson != null && (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking);
2494
+ effectiveChangeFlowReturnUnitFloorPerPerson != null && isCustomerSelfServeChange;
2430
2495
  const flooredPerPerson = applyReturnFloor
2431
- ? Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson!)
2496
+ ? Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson!)
2432
2497
  : rawPerPerson;
2433
2498
  if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
2434
2499
  return {
@@ -2443,17 +2508,17 @@ export function BookingFlow({
2443
2508
  }, [
2444
2509
  selectedAvailability?.returnOptions,
2445
2510
  isChangeFlow,
2446
- changeFlowReturnUnitFloorPerPerson,
2447
- changeFlowSameItineraryAsOriginalBooking,
2511
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2512
+ isCustomerSelfServeChange,
2448
2513
  currency,
2449
2514
  changeFlowSeatCreditForReturnAvailabilityId,
2450
2515
  ]);
2451
2516
 
2452
2517
  const selectedReturnOptionWithFloor = useMemo(() => {
2453
- if (!selectedReturnOption || !isChangeFlow || changeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
2454
- if (changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
2518
+ if (!selectedReturnOption || !isChangeFlow || effectiveChangeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
2519
+ if (!isCustomerSelfServeChange) return selectedReturnOption;
2455
2520
  const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
2456
- const flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
2521
+ const flooredPerPerson = Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson);
2457
2522
  if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
2458
2523
  return {
2459
2524
  ...selectedReturnOption,
@@ -2465,15 +2530,16 @@ export function BookingFlow({
2465
2530
  }, [
2466
2531
  selectedReturnOption,
2467
2532
  isChangeFlow,
2468
- changeFlowReturnUnitFloorPerPerson,
2469
- changeFlowSameItineraryAsOriginalBooking,
2533
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2534
+ isCustomerSelfServeChange,
2470
2535
  currency,
2471
2536
  ]);
2472
2537
 
2473
- const returnOptionForOrderSummary = useMemo(() => {
2474
- if (isChangeFlow && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption ?? null;
2475
- return selectedReturnOptionWithFloor;
2476
- }, [isChangeFlow, changeFlowSameItineraryAsOriginalBooking, selectedReturnOption, selectedReturnOptionWithFloor]);
2538
+ /** Order-summary return row uses self-serve floors via [selectedReturnOptionWithFloor]; provider stays catalog-only. */
2539
+ const returnOptionForOrderSummary = useMemo(
2540
+ () => selectedReturnOptionWithFloor,
2541
+ [selectedReturnOptionWithFloor],
2542
+ );
2477
2543
  const effectiveSelectedPickupVacancies = useMemo(() => {
2478
2544
  if (!selectedAvailability) return 0;
2479
2545
  const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
@@ -2488,71 +2554,19 @@ export function BookingFlow({
2488
2554
  }, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
2489
2555
 
2490
2556
  // Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
2491
- const pricing = useMemo(() => {
2492
- if (!selectedAvailability || !pricingConfig) return [];
2493
- const optionId = selectedAvailability.productOptionId;
2494
- const selectedOption = activeOptions.find(opt => opt.optionId === optionId);
2495
- const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
2496
- const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
2497
- getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
2498
- const getBaseInDisplayCurrency = (category: string) => {
2499
- const fromPrecomputed = precomputed?.[category]?.[currency];
2500
- if (fromPrecomputed != null) return fromPrecomputed;
2501
- return 0;
2502
- };
2503
- const buildRate = (
2504
- category: string,
2505
- backendPriceCAD: number,
2506
- backendInDisplayCurrency: number,
2507
- baseInDisplayCurrency: number,
2508
- appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>
2509
- ) => {
2510
- const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
2511
- const isPublicMode = isSimplifiedPricingView;
2512
- const breakdown = computePriceBreakdown(
2513
- pricingConfig,
2557
+ const pricing = useMemo(
2558
+ () =>
2559
+ buildPricingFromAvailability(
2560
+ selectedAvailability,
2561
+ activeOptions,
2562
+ precomputedPricesByOption,
2514
2563
  currency,
2515
- backendPriceCAD,
2516
- basePriceCAD,
2564
+ pricingConfig,
2517
2565
  hasFees,
2518
- appliedAdjustments,
2519
- undefined,
2520
- baseInDisplayCurrency,
2521
- isPublicMode
2522
- );
2523
- const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
2524
- return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
2525
- };
2526
- return selectedAvailability.rates?.map(rate => {
2527
- const backendPriceCAD = rate.price ?? 0;
2528
- const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
2529
- const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
2530
- const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
2531
- return {
2532
- category: rate.category,
2533
- rateId: rate.rateId || rate.category,
2534
- available: rate.available,
2535
- price: built.price,
2536
- priceCAD: built.priceCAD,
2537
- baseInDisplayCurrency: built.baseInDisplayCurrency,
2538
- appliedAdjustments: built.appliedAdjustments,
2539
- };
2540
- }) || selectedAvailability.pricesByCategory?.retailPrices?.map(p => {
2541
- const priceCADFromApi = p.price / 100;
2542
- const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
2543
- const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
2544
- const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
2545
- return {
2546
- category: p.category,
2547
- rateId: p.category,
2548
- available: selectedAvailability.vacancies,
2549
- price: built.price,
2550
- priceCAD: built.priceCAD,
2551
- baseInDisplayCurrency: built.baseInDisplayCurrency,
2552
- appliedAdjustments: built.appliedAdjustments,
2553
- };
2554
- }) || [];
2555
- }, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
2566
+ isSimplifiedPricingView,
2567
+ ),
2568
+ [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
2569
+ );
2556
2570
 
2557
2571
  // Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
2558
2572
  const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
@@ -2588,62 +2602,29 @@ export function BookingFlow({
2588
2602
  [quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
2589
2603
  );
2590
2604
 
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;
2596
- }
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
2605
  const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
2627
2606
  const changeFlowProtectedTicketSubtotal = useMemo(() => {
2628
2607
  const currentTicketSubtotal = ticketLineItems.reduce(
2629
2608
  (sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
2630
2609
  0,
2631
2610
  );
2632
- if (!changeFlowSameItineraryAsOriginalBooking) return currentTicketSubtotal;
2611
+ if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
2633
2612
  return ticketLineItems.reduce((sum, line) => {
2634
2613
  const category = line.category?.trim().toUpperCase();
2635
2614
  const qty = Math.max(0, Number(line.qty) || 0);
2636
2615
  if (!category || qty <= 0) return sum;
2637
- const bookedUnitPrice = changeFlowTicketBookedUnitPriceByCategory.get(category);
2638
- if (bookedUnitPrice == null) return sum + line.itemTotal;
2639
- const baselineQty = changeFlowInitialTicketQtyByCategory.get(category) ?? 0;
2640
- const protectedQty = Math.min(qty, baselineQty);
2641
- const incrementalQty = Math.max(0, qty - baselineQty);
2642
- const liveUnit = qty > 0 ? Math.max(0, Number(line.itemTotal) || 0) / qty : 0;
2643
- return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnit;
2616
+ return (
2617
+ sum +
2618
+ changeFlowTicketLineTotalWithReceiptFloor({
2619
+ qty,
2620
+ baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
2621
+ receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
2622
+ liveLineTotal: Number(line.itemTotal) || 0,
2623
+ })
2624
+ );
2644
2625
  }, 0);
2645
2626
  }, [
2646
- changeFlowSameItineraryAsOriginalBooking,
2627
+ changeFlowApplyReceiptPaidFloors,
2647
2628
  ticketLineItems,
2648
2629
  changeFlowTicketBookedUnitPriceByCategory,
2649
2630
  changeFlowInitialTicketQtyByCategory,
@@ -2736,45 +2717,98 @@ export function BookingFlow({
2736
2717
  [feeLineItems],
2737
2718
  );
2738
2719
  const changeFlowProtectedFeeSubtotal = useMemo(() => {
2739
- if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return currentFeeSubtotal;
2720
+ if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
2740
2721
  const initialParty = changeFlowInitialTicketCount;
2741
- const protectedP = Math.min(initialParty, totalQuantity);
2742
- const incrementalP = Math.max(0, totalQuantity - protectedP);
2743
2722
  return feeLineItems.reduce((sum, line) => {
2744
2723
  const key = normalizeLineLabelForCompare(line.name || '');
2745
- const bookedUnitPrice = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : null;
2746
- if (bookedUnitPrice == null) return sum + line.totalAmount;
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;
2724
+ const bookedFeePerPerson = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : undefined;
2725
+ return (
2726
+ sum +
2727
+ changeFlowFeeLineTotalWithReceiptFloor({
2728
+ totalQuantity,
2729
+ initialTicketCount: initialParty,
2730
+ bookedFeePerPerson,
2731
+ liveFeeLineTotal: Number(line.totalAmount) || 0,
2732
+ })
2733
+ );
2750
2734
  }, 0);
2751
2735
  }, [
2752
- changeFlowSameItineraryAsOriginalBooking,
2736
+ changeFlowApplyReceiptPaidFloors,
2753
2737
  totalQuantity,
2754
2738
  currentFeeSubtotal,
2755
2739
  feeLineItems,
2756
2740
  changeFlowBookedFeeUnitByNormalizedLabel,
2757
2741
  changeFlowInitialTicketCount,
2758
2742
  ]);
2743
+ /** Catalog (unfloored) return price per person — same slot as [selectedReturnOption] on the raw availability list. */
2744
+ const returnOptionCatalogPerPerson = useMemo(() => {
2745
+ if (!selectedReturnOption?.returnAvailabilityId || !selectedAvailability?.returnOptions?.length) {
2746
+ return null;
2747
+ }
2748
+ const raw = selectedAvailability.returnOptions.find(
2749
+ (o) => o.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
2750
+ );
2751
+ const v = raw?.priceAdjustmentByCurrency?.[currency];
2752
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
2753
+ }, [selectedReturnOption?.returnAvailabilityId, selectedAvailability?.returnOptions, currency]);
2754
+
2759
2755
  const changeFlowProtectedReturnAdjustment = useMemo(() => {
2760
- if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return returnPriceAdjustment;
2761
- if (changeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
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;
2756
+ if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return returnPriceAdjustment;
2757
+ if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
2758
+ const livePerPerson =
2759
+ returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
2760
+ const perPerson = changeFlowReturnPerPersonWithReceiptFloor({
2761
+ livePerPerson,
2762
+ receiptFloorPerPerson: effectiveChangeFlowReturnUnitFloorPerPerson,
2763
+ });
2764
+ return totalQuantity * perPerson;
2767
2765
  }, [
2768
- changeFlowSameItineraryAsOriginalBooking,
2766
+ changeFlowApplyReceiptPaidFloors,
2769
2767
  totalQuantity,
2770
2768
  returnPriceAdjustment,
2771
- changeFlowReturnUnitFloorPerPerson,
2772
- changeFlowInitialTicketCount,
2769
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2773
2770
  selectedReturnOption,
2771
+ returnOptionCatalogPerPerson,
2774
2772
  currency,
2775
2773
  ]);
2774
+
2775
+ /** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
2776
+ const checkoutReturnLineAmount = useMemo(() => {
2777
+ if (isChangeFlow && changeFlowApplyReceiptPaidFloors) {
2778
+ return changeFlowProtectedReturnAdjustment;
2779
+ }
2780
+ return returnPriceAdjustment;
2781
+ }, [
2782
+ isChangeFlow,
2783
+ changeFlowApplyReceiptPaidFloors,
2784
+ changeFlowProtectedReturnAdjustment,
2785
+ returnPriceAdjustment,
2786
+ ]);
2787
+
2788
+ /** Ticket lines with receipt floors applied for breakdown/modal (matches protected ticket subtotal). */
2789
+ const ticketLineItemsForChangeFlowDisplay = useMemo(() => {
2790
+ if (!changeFlowApplyReceiptPaidFloors) return ticketLineItems;
2791
+ return ticketLineItems.map((line) => {
2792
+ const category = line.category?.trim().toUpperCase();
2793
+ const qty = Math.max(0, Number(line.qty) || 0);
2794
+ if (!category || qty <= 0) return line;
2795
+ const newTotal = changeFlowTicketLineTotalWithReceiptFloor({
2796
+ qty,
2797
+ baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
2798
+ receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
2799
+ liveLineTotal: Number(line.itemTotal) || 0,
2800
+ });
2801
+ return { ...line, itemTotal: newTotal };
2802
+ });
2803
+ }, [
2804
+ changeFlowApplyReceiptPaidFloors,
2805
+ ticketLineItems,
2806
+ changeFlowTicketBookedUnitPriceByCategory,
2807
+ changeFlowInitialTicketQtyByCategory,
2808
+ ]);
2809
+
2776
2810
  const effectiveSubtotalBeforeAddOns =
2777
- isChangeFlow && changeFlowSameItineraryAsOriginalBooking
2811
+ isChangeFlow && changeFlowApplyReceiptPaidFloors
2778
2812
  ? subtotal -
2779
2813
  currentTicketSubtotal -
2780
2814
  currentFeeSubtotal -
@@ -2841,8 +2875,11 @@ export function BookingFlow({
2841
2875
 
2842
2876
  const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
2843
2877
  if (!selectedAvailability) return [];
2878
+ const returnLineAmount = checkoutReturnLineAmount;
2879
+ const showReturnLine =
2880
+ Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
2844
2881
  return [
2845
- ...ticketLineItems.map((line): PriceSummaryLine => {
2882
+ ...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
2846
2883
  const rate = pricing.find((r) => r.category === line.category);
2847
2884
  const breakdown = getPriceBreakdown(
2848
2885
  line.category,
@@ -2865,12 +2902,12 @@ export function BookingFlow({
2865
2902
  breakdown,
2866
2903
  };
2867
2904
  }),
2868
- ...(selectedReturnOption && returnPriceAdjustment !== 0
2905
+ ...(showReturnLine
2869
2906
  ? [
2870
2907
  {
2871
2908
  kind: 'line' as const,
2872
2909
  label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
2873
- amount: returnPriceAdjustment,
2910
+ amount: returnLineAmount,
2874
2911
  type: 'return',
2875
2912
  },
2876
2913
  ]
@@ -2925,11 +2962,11 @@ export function BookingFlow({
2925
2962
  ];
2926
2963
  }, [
2927
2964
  selectedAvailability,
2928
- ticketLineItems,
2965
+ ticketLineItemsForChangeFlowDisplay,
2929
2966
  pricing,
2930
2967
  getPriceBreakdown,
2931
2968
  selectedReturnOption,
2932
- returnPriceAdjustment,
2969
+ checkoutReturnLineAmount,
2933
2970
  totalQuantity,
2934
2971
  t,
2935
2972
  cancellationPolicyFee,
@@ -3066,93 +3103,13 @@ export function BookingFlow({
3066
3103
  ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
3067
3104
  : taxOnSubtotal;
3068
3105
  const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
3069
- /** Cent-round for change quote only; matches server final rounding and avoids float noise in clientProposedTotal. */
3070
- const changeFlowProposedTotal = isChangeFlow && originalReceipt
3071
- ? reconcileChangeBookingProposedTotal(Math.round(totalPrice * 100) / 100, originalReceipt.total)
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
- }, [
3106
+ /** Change-flow product rules: `lib/booking/change-flow-pricing.ts`. */
3107
+ const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
3121
3108
  isChangeFlow,
3122
- changeFlowSameItineraryAsOriginalBooking,
3123
- changeFlowBaselineEffectiveSubtotalFull,
3124
- effectiveTax,
3125
- isTaxIncludedInPrice,
3126
- pricingConfig?.taxRate,
3127
- effectivePromoDiscountAmount,
3128
- isGiftCard,
3129
- isVoucher,
3130
- ]);
3109
+ cartTotal: totalPrice,
3110
+ originalReceiptTotal: originalReceipt?.total,
3111
+ });
3131
3112
 
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
- ]);
3156
3113
  const changeSelectionDetails = useMemo(() => {
3157
3114
  if (!isChangeFlow || !initialValues) {
3158
3115
  return {
@@ -3184,11 +3141,12 @@ export function BookingFlow({
3184
3141
  const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
3185
3142
  // Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
3186
3143
  const dateChanged = initialMs != null && initialMs !== selectedMs;
3187
- // productOptionId on the booking is the option id, not productId — compare only when both are known.
3188
- const initialOpt = initialValues.productOptionId?.trim() || null;
3189
- const selectedOpt = selectedAvailability.productOptionId?.trim() || null;
3144
+ const initialOpt =
3145
+ changeFlowResolvedInitialProductOptionId ??
3146
+ (initialValues.productOptionId?.trim() || null);
3147
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
3190
3148
  const optionChanged = Boolean(
3191
- initialOpt && selectedOpt && initialOpt !== selectedOpt
3149
+ selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
3192
3150
  );
3193
3151
  const normalizePickupId = (value: string | null | undefined) => {
3194
3152
  const trimmed = value?.trim();
@@ -3288,6 +3246,7 @@ export function BookingFlow({
3288
3246
  }, [
3289
3247
  isChangeFlow,
3290
3248
  initialValues,
3249
+ changeFlowResolvedInitialProductOptionId,
3291
3250
  selectedAvailability,
3292
3251
  selectedReturnOption,
3293
3252
  implicitReturnBaselineId,
@@ -3349,167 +3308,29 @@ export function BookingFlow({
3349
3308
  const hasEffectiveChangeSelection =
3350
3309
  hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
3351
3310
 
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;
3311
+ const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
3312
+ providerPreview: providerTotalsPreview,
3313
+ fromCart: {
3314
+ total: changeFlowNewBookingTotal,
3315
+ subtotal: effectiveSubtotal,
3316
+ tax: effectiveTax,
3317
+ },
3318
+ });
3319
+ const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
3320
+ const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
3321
+ const displayChangeFlowTax = displayedChangeAmounts.tax;
3385
3322
 
3386
3323
  const changeFlowClientEstimateDue = originalReceipt
3387
- ? (isProviderDashboardChange
3388
- ? displayChangeFlowProposedTotal - originalReceipt.total
3389
- : Math.max(displayChangeFlowProposedTotal - originalReceipt.total, 0))
3324
+ ? changeFlowBalanceVsOriginal({
3325
+ newTotal: displayChangeFlowProposedTotal,
3326
+ originalReceiptTotal: originalReceipt.total,
3327
+ audience: isProviderDashboardChange ? 'provider' : 'customer',
3328
+ })
3390
3329
  : totalPrice;
3391
3330
 
3392
- /**
3393
- * Amount owed for change flow: FE proposed total vs original receipt.
3394
- * Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
3395
- */
3396
3331
  const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
3397
- const changeFlowAmountDue = isChangeFlow
3398
- ? (() => {
3399
- const rounded = Math.round(changeFlowAmountDueRaw * 100) / 100;
3400
- return Math.abs(rounded) < 0.01 ? 0 : rounded;
3401
- })()
3402
- : changeFlowAmountDueRaw;
3332
+ const changeFlowAmountDue = isChangeFlow ? normalizeNearZeroOwed(changeFlowAmountDueRaw) : changeFlowAmountDueRaw;
3403
3333
 
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
3334
 
3514
3335
  const changeCheckoutButtonLabel = (() => {
3515
3336
  if (!isChangeFlow) return undefined;
@@ -3611,7 +3432,7 @@ export function BookingFlow({
3611
3432
  dateChanged: changeSelectionDetails.dateChanged,
3612
3433
  ticketsChanged: changeSelectionDetails.ticketsChanged,
3613
3434
  hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
3614
- selectionTotal: originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
3435
+ selectionTotal: originalReceipt ? changeFlowNewBookingTotal : totalPrice,
3615
3436
  selectionCurrency: currency,
3616
3437
  };
3617
3438
  }, [
@@ -3625,7 +3446,7 @@ export function BookingFlow({
3625
3446
  totalPrice,
3626
3447
  currency,
3627
3448
  originalReceipt,
3628
- changeFlowProposedTotalResolved,
3449
+ changeFlowNewBookingTotal,
3629
3450
  ]);
3630
3451
 
3631
3452
  useEffect(() => {
@@ -3668,7 +3489,7 @@ export function BookingFlow({
3668
3489
  ? displayChangeFlowTax
3669
3490
  : effectiveTax
3670
3491
  : 0,
3671
- total: isChangeFlow && originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
3492
+ total: isChangeFlow && originalReceipt ? changeFlowNewBookingTotal : totalPrice,
3672
3493
  currency,
3673
3494
  });
3674
3495
  }, [
@@ -3677,7 +3498,7 @@ export function BookingFlow({
3677
3498
  totalQuantity,
3678
3499
  effectiveSubtotal,
3679
3500
  effectiveTax,
3680
- changeFlowProposedTotalResolved,
3501
+ changeFlowNewBookingTotal,
3681
3502
  displayChangeFlowSubtotal,
3682
3503
  displayChangeFlowTax,
3683
3504
  currency,
@@ -3739,7 +3560,7 @@ export function BookingFlow({
3739
3560
  newPassengerCounts: bookingItems,
3740
3561
  // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
3741
3562
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
3742
- clientProposedTotal: changeFlowProposedTotalResolved,
3563
+ clientProposedTotal: changeFlowNewBookingTotal,
3743
3564
  capacitySeatCredit: {
3744
3565
  enabled: true,
3745
3566
  previousPassengerCount: changeFlowInitialTicketCount,
@@ -3791,7 +3612,7 @@ export function BookingFlow({
3791
3612
  quantities,
3792
3613
  addOnSelections,
3793
3614
  changeFlowInitialTicketCount,
3794
- changeFlowProposedTotalResolved,
3615
+ changeFlowNewBookingTotal,
3795
3616
  totalPrice,
3796
3617
  currency,
3797
3618
  activeOptions,
@@ -3944,12 +3765,11 @@ export function BookingFlow({
3944
3765
 
3945
3766
  const preferBooked =
3946
3767
  isChangeFlow && initialReturnIdForSelect
3947
- ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect && opt.vacancies > 0)
3768
+ ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
3948
3769
  : undefined;
3949
3770
  const preferByDateTime =
3950
3771
  isChangeFlow && initialReturnDtForSelect && !preferBooked
3951
3772
  ? sorted.find((opt) => {
3952
- if (opt.vacancies <= 0) return false;
3953
3773
  try {
3954
3774
  return (
3955
3775
  parseAvailabilityDateTime(opt.dateTime).getTime() ===
@@ -4416,7 +4236,7 @@ export function BookingFlow({
4416
4236
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
4417
4237
  newPassengerCounts: bookingItems,
4418
4238
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
4419
- clientProposedTotal: changeFlowProposedTotalResolved,
4239
+ clientProposedTotal: changeFlowNewBookingTotal,
4420
4240
  });
4421
4241
  const canProceed = quote.canProceed !== false;
4422
4242
  const quoteCurrency = (quote.currency || currency) as Currency;
@@ -4437,7 +4257,11 @@ export function BookingFlow({
4437
4257
  if (!canProceed) {
4438
4258
  throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
4439
4259
  }
4440
- const feChangeDue = Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0));
4260
+ const feChangeDue = changeFlowBalanceVsOriginal({
4261
+ newTotal: changeFlowNewBookingTotal,
4262
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4263
+ audience: 'customer',
4264
+ });
4441
4265
  const serverAmountDue =
4442
4266
  quote.amountDueCents != null
4443
4267
  ? Math.max(0, quote.amountDueCents / 100)
@@ -4547,20 +4371,24 @@ export function BookingFlow({
4547
4371
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
4548
4372
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
4549
4373
  const amountDueForCheckout = isCustomerSelfServeChange
4550
- ? Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0))
4374
+ ? changeFlowBalanceVsOriginal({
4375
+ newTotal: changeFlowNewBookingTotal,
4376
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4377
+ audience: 'customer',
4378
+ })
4551
4379
  : totalPrice;
4552
4380
  const lines = [
4553
- ...ticketLineItems.map((line) => ({
4381
+ ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
4554
4382
  label: line.category,
4555
4383
  amount: line.itemTotal,
4556
4384
  type: 'TICKET' as const,
4557
4385
  quantity: line.qty,
4558
4386
  })),
4559
- ...(returnPriceAdjustment !== 0
4387
+ ...(checkoutReturnLineAmount !== 0
4560
4388
  ? [
4561
4389
  {
4562
4390
  label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
4563
- amount: returnPriceAdjustment,
4391
+ amount: checkoutReturnLineAmount,
4564
4392
  type: 'RETURN_OPTION' as const,
4565
4393
  quantity: totalQuantity,
4566
4394
  },
@@ -4758,7 +4586,7 @@ export function BookingFlow({
4758
4586
  availabilityProductOptionId,
4759
4587
  itineraryDisplay: itineraryDisplay ?? undefined,
4760
4588
  clientSecret: paymentIntent.clientSecret ?? '',
4761
- ticketLinesForModal: ticketLineItems.map((line) => {
4589
+ ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
4762
4590
  const rate = pricing.find((r) => r.category === line.category);
4763
4591
  const breakdown = getPriceBreakdown(
4764
4592
  line.category,
@@ -4769,7 +4597,7 @@ export function BookingFlow({
4769
4597
  return { line, breakdown };
4770
4598
  }),
4771
4599
  feeLineItems: feeLineItemsWithAddOns,
4772
- returnPriceAdjustment,
4600
+ returnPriceAdjustment: checkoutReturnLineAmount,
4773
4601
  cancellationPolicyFee,
4774
4602
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4775
4603
  subtotal: effectiveSubtotal,
@@ -4785,7 +4613,7 @@ export function BookingFlow({
4785
4613
  return;
4786
4614
  }
4787
4615
 
4788
- const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItems.map((line) => {
4616
+ const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
4789
4617
  const rate = pricing.find((r) => r.category === line.category);
4790
4618
  const breakdown = getPriceBreakdown(
4791
4619
  line.category,
@@ -4821,7 +4649,7 @@ export function BookingFlow({
4821
4649
  : undefined,
4822
4650
  ticketLines: ticketLinesForModal,
4823
4651
  feeLineItems: feeLineItemsWithAddOns,
4824
- returnPriceAdjustment,
4652
+ returnPriceAdjustment: checkoutReturnLineAmount,
4825
4653
  cancellationPolicyFee,
4826
4654
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4827
4655
  subtotal: effectiveSubtotal,
@@ -5107,6 +4935,7 @@ export function BookingFlow({
5107
4935
  availabilitiesByDate={availabilitiesByDate}
5108
4936
  selectedDate={selectedDate}
5109
4937
  syncVisibleWeekToSelectedDate={isChangeFlow}
4938
+ selectableSoldOutDate={changeFlowOriginalDate}
5110
4939
  isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
5111
4940
  onDateSelect={(date) => {
5112
4941
  setSelectedDate(date);
@@ -5129,7 +4958,13 @@ export function BookingFlow({
5129
4958
  currency={currency}
5130
4959
  showCapacity={isAdmin}
5131
4960
  extraDiscountPercent={calendarDiscountPercent}
5132
- capDiscountToSelectedDate={isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0}
4961
+ capDiscountBadgesToBookingDate={
4962
+ isChangeFlow &&
4963
+ changeFlowApplyReceiptPaidFloors &&
4964
+ changeFlowTicketBookedUnitPriceByCategory.size > 0
4965
+ ? changeFlowOriginalDate
4966
+ : null
4967
+ }
5133
4968
  />
5134
4969
  </div>
5135
4970
  </div>
@@ -5253,7 +5088,11 @@ export function BookingFlow({
5253
5088
  t={t}
5254
5089
  onQuantityChange={handleQuantityChange}
5255
5090
  minimumQuantities={changeBookingMinimumQuantities}
5256
- ticketUnitFloorByCategory={isChangeFlow ? changeFlowTicketBookedUnitPriceByCategory : undefined}
5091
+ ticketUnitFloorByCategory={
5092
+ isChangeFlow && changeFlowApplyReceiptPaidFloors
5093
+ ? changeFlowTicketBookedUnitPriceByCategory
5094
+ : undefined
5095
+ }
5257
5096
  />
5258
5097
  )}
5259
5098
 
@@ -5271,6 +5110,7 @@ export function BookingFlow({
5271
5110
 
5272
5111
  {/* Total and Checkout — shared PriceSummary component */}
5273
5112
  {selectedAvailability && (
5113
+ <>
5274
5114
  <CheckoutForm
5275
5115
  priceSummaryLines={checkoutPriceSummaryLines}
5276
5116
  totalPrice={changeFlowAmountDue}
@@ -5498,6 +5338,7 @@ export function BookingFlow({
5498
5338
  showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
5499
5339
  }
5500
5340
  />
5341
+ </>
5501
5342
  )}
5502
5343
  </div>
5503
5344
  </>