@ticketboothapp/booking 1.2.59 → 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';
@@ -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,79 @@ 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
+ /** 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
+
1723
1765
  useEffect(() => {
1724
1766
  if (hasAppliedInitialValuesRef.current || !initialValues) return;
1725
1767
  const trimmedEmail = initialValues.customer?.email?.trim();
@@ -1760,7 +1802,7 @@ export function BookingFlow({
1760
1802
  target,
1761
1803
  companyTimezone,
1762
1804
  initialValues.availabilityId ?? null,
1763
- initialValues.productOptionId ?? null,
1805
+ changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
1764
1806
  initialValues.bookingItems ?? null,
1765
1807
  precomputedPricesByOption,
1766
1808
  currency,
@@ -1776,6 +1818,7 @@ export function BookingFlow({
1776
1818
  initialValues?.availabilityId,
1777
1819
  initialValues?.productOptionId,
1778
1820
  initialValues?.bookingItems,
1821
+ changeFlowResolvedInitialProductOptionId,
1779
1822
  selectedAvailability,
1780
1823
  selectedDate,
1781
1824
  timesForSelectedDate,
@@ -1943,14 +1986,20 @@ export function BookingFlow({
1943
1986
  const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
1944
1987
  const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
1945
1988
  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;
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;
1950
1998
  },
1951
1999
  [
1952
2000
  isChangeFlow,
1953
2001
  changeFlowInitialTicketCountForSeatCredit,
2002
+ changeFlowResolvedInitialProductOptionId,
1954
2003
  initialValues?.availabilityId,
1955
2004
  initialValues?.dateTime,
1956
2005
  initialValues?.productOptionId,
@@ -2298,7 +2347,11 @@ export function BookingFlow({
2298
2347
  return floors;
2299
2348
  }, [isChangeFlow, originalReceipt?.lineItems]);
2300
2349
 
2301
- 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(() => {
2302
2355
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return null;
2303
2356
  const fallbackBookedQty =
2304
2357
  (initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
@@ -2306,7 +2359,7 @@ export function BookingFlow({
2306
2359
  let totalQty = 0;
2307
2360
  for (const line of originalReceipt.lineItems) {
2308
2361
  const type = (line.type || '').trim().toUpperCase();
2309
- if (type !== 'RETURN_OPTION') continue;
2362
+ if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
2310
2363
  const amount = Number(line.amount ?? 0);
2311
2364
  const qtyRaw = Number(line.quantity ?? 0);
2312
2365
  const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
@@ -2317,6 +2370,15 @@ export function BookingFlow({
2317
2370
  if (totalAmount <= 0 || totalQty <= 0) return null;
2318
2371
  return totalAmount / totalQty;
2319
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]);
2320
2382
  const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
2321
2383
  const feeUnitByLabel = new Map<string, number>();
2322
2384
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
@@ -2360,19 +2422,25 @@ export function BookingFlow({
2360
2422
  Boolean(initialAvailabilityId && selectedAvailabilityId) &&
2361
2423
  initialAvailabilityId === selectedAvailabilityId;
2362
2424
  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.
2425
+ // Same wall time + same product option (ids may rotate on refresh).
2364
2426
  const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
2365
2427
  const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
2366
2428
  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;
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;
2370
2437
  }, [
2371
2438
  isChangeFlow,
2372
2439
  selectedAvailability,
2373
2440
  initialValues?.availabilityId,
2374
2441
  initialValues?.dateTime,
2375
2442
  initialValues?.productOptionId,
2443
+ changeFlowResolvedInitialProductOptionId,
2376
2444
  ]);
2377
2445
  const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
2378
2446
  if (!isChangeFlow) return false;
@@ -2420,15 +2488,22 @@ export function BookingFlow({
2420
2488
 
2421
2489
  const returnOptionsWithFloor = useMemo(() => {
2422
2490
  const options = selectedAvailability?.returnOptions ?? [];
2423
- if (!isChangeFlow && changeFlowReturnUnitFloorPerPerson == null) return options;
2491
+ if (!isChangeFlow && effectiveChangeFlowReturnUnitFloorPerPerson == null) return options;
2424
2492
  return options.map((opt) => {
2425
2493
  const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
2426
2494
  const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
2427
2495
  const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
2428
2496
  const applyReturnFloor =
2429
- changeFlowReturnUnitFloorPerPerson != null && (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking);
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
+ );
2430
2505
  const flooredPerPerson = applyReturnFloor
2431
- ? Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson!)
2506
+ ? Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson!)
2432
2507
  : rawPerPerson;
2433
2508
  if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
2434
2509
  return {
@@ -2443,17 +2518,18 @@ export function BookingFlow({
2443
2518
  }, [
2444
2519
  selectedAvailability?.returnOptions,
2445
2520
  isChangeFlow,
2446
- changeFlowReturnUnitFloorPerPerson,
2521
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2447
2522
  changeFlowSameItineraryAsOriginalBooking,
2523
+ isCustomerSelfServeChange,
2448
2524
  currency,
2449
2525
  changeFlowSeatCreditForReturnAvailabilityId,
2450
2526
  ]);
2451
2527
 
2452
2528
  const selectedReturnOptionWithFloor = useMemo(() => {
2453
- if (!selectedReturnOption || !isChangeFlow || changeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
2454
- if (changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
2529
+ if (!selectedReturnOption || !isChangeFlow || effectiveChangeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
2530
+ if (!isCustomerSelfServeChange && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
2455
2531
  const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
2456
- const flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
2532
+ const flooredPerPerson = Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson);
2457
2533
  if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
2458
2534
  return {
2459
2535
  ...selectedReturnOption,
@@ -2465,15 +2541,30 @@ export function BookingFlow({
2465
2541
  }, [
2466
2542
  selectedReturnOption,
2467
2543
  isChangeFlow,
2468
- changeFlowReturnUnitFloorPerPerson,
2544
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2469
2545
  changeFlowSameItineraryAsOriginalBooking,
2546
+ isCustomerSelfServeChange,
2470
2547
  currency,
2471
2548
  ]);
2472
2549
 
2473
2550
  const returnOptionForOrderSummary = useMemo(() => {
2474
- if (isChangeFlow && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption ?? null;
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
+ }
2475
2560
  return selectedReturnOptionWithFloor;
2476
- }, [isChangeFlow, changeFlowSameItineraryAsOriginalBooking, selectedReturnOption, selectedReturnOptionWithFloor]);
2561
+ }, [
2562
+ isChangeFlow,
2563
+ changeFlowSameItineraryAsOriginalBooking,
2564
+ isCustomerSelfServeChange,
2565
+ selectedReturnOption,
2566
+ selectedReturnOptionWithFloor,
2567
+ ]);
2477
2568
  const effectiveSelectedPickupVacancies = useMemo(() => {
2478
2569
  if (!selectedAvailability) return 0;
2479
2570
  const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
@@ -2488,71 +2579,19 @@ export function BookingFlow({
2488
2579
  }, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
2489
2580
 
2490
2581
  // 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,
2582
+ const pricing = useMemo(
2583
+ () =>
2584
+ buildPricingFromAvailability(
2585
+ selectedAvailability,
2586
+ activeOptions,
2587
+ precomputedPricesByOption,
2514
2588
  currency,
2515
- backendPriceCAD,
2516
- basePriceCAD,
2589
+ pricingConfig,
2517
2590
  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]);
2591
+ isSimplifiedPricingView,
2592
+ ),
2593
+ [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
2594
+ );
2556
2595
 
2557
2596
  // Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
2558
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 => {
@@ -2588,62 +2627,29 @@ export function BookingFlow({
2588
2627
  [quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
2589
2628
  );
2590
2629
 
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
2630
  const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
2627
2631
  const changeFlowProtectedTicketSubtotal = useMemo(() => {
2628
2632
  const currentTicketSubtotal = ticketLineItems.reduce(
2629
2633
  (sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
2630
2634
  0,
2631
2635
  );
2632
- if (!changeFlowSameItineraryAsOriginalBooking) return currentTicketSubtotal;
2636
+ if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
2633
2637
  return ticketLineItems.reduce((sum, line) => {
2634
2638
  const category = line.category?.trim().toUpperCase();
2635
2639
  const qty = Math.max(0, Number(line.qty) || 0);
2636
2640
  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;
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
+ );
2644
2650
  }, 0);
2645
2651
  }, [
2646
- changeFlowSameItineraryAsOriginalBooking,
2652
+ changeFlowApplyReceiptPaidFloors,
2647
2653
  ticketLineItems,
2648
2654
  changeFlowTicketBookedUnitPriceByCategory,
2649
2655
  changeFlowInitialTicketQtyByCategory,
@@ -2736,45 +2742,98 @@ export function BookingFlow({
2736
2742
  [feeLineItems],
2737
2743
  );
2738
2744
  const changeFlowProtectedFeeSubtotal = useMemo(() => {
2739
- if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return currentFeeSubtotal;
2745
+ if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
2740
2746
  const initialParty = changeFlowInitialTicketCount;
2741
- const protectedP = Math.min(initialParty, totalQuantity);
2742
- const incrementalP = Math.max(0, totalQuantity - protectedP);
2743
2747
  return feeLineItems.reduce((sum, line) => {
2744
2748
  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;
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
+ );
2750
2759
  }, 0);
2751
2760
  }, [
2752
- changeFlowSameItineraryAsOriginalBooking,
2761
+ changeFlowApplyReceiptPaidFloors,
2753
2762
  totalQuantity,
2754
2763
  currentFeeSubtotal,
2755
2764
  feeLineItems,
2756
2765
  changeFlowBookedFeeUnitByNormalizedLabel,
2757
2766
  changeFlowInitialTicketCount,
2758
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
+
2759
2780
  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;
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;
2767
2790
  }, [
2768
- changeFlowSameItineraryAsOriginalBooking,
2791
+ changeFlowApplyReceiptPaidFloors,
2769
2792
  totalQuantity,
2770
2793
  returnPriceAdjustment,
2771
- changeFlowReturnUnitFloorPerPerson,
2772
- changeFlowInitialTicketCount,
2794
+ effectiveChangeFlowReturnUnitFloorPerPerson,
2773
2795
  selectedReturnOption,
2796
+ returnOptionCatalogPerPerson,
2774
2797
  currency,
2775
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,
2833
+ ]);
2834
+
2776
2835
  const effectiveSubtotalBeforeAddOns =
2777
- isChangeFlow && changeFlowSameItineraryAsOriginalBooking
2836
+ isChangeFlow && changeFlowApplyReceiptPaidFloors
2778
2837
  ? subtotal -
2779
2838
  currentTicketSubtotal -
2780
2839
  currentFeeSubtotal -
@@ -2841,8 +2900,11 @@ export function BookingFlow({
2841
2900
 
2842
2901
  const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
2843
2902
  if (!selectedAvailability) return [];
2903
+ const returnLineAmount = checkoutReturnLineAmount;
2904
+ const showReturnLine =
2905
+ Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
2844
2906
  return [
2845
- ...ticketLineItems.map((line): PriceSummaryLine => {
2907
+ ...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
2846
2908
  const rate = pricing.find((r) => r.category === line.category);
2847
2909
  const breakdown = getPriceBreakdown(
2848
2910
  line.category,
@@ -2865,12 +2927,12 @@ export function BookingFlow({
2865
2927
  breakdown,
2866
2928
  };
2867
2929
  }),
2868
- ...(selectedReturnOption && returnPriceAdjustment !== 0
2930
+ ...(showReturnLine
2869
2931
  ? [
2870
2932
  {
2871
2933
  kind: 'line' as const,
2872
2934
  label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
2873
- amount: returnPriceAdjustment,
2935
+ amount: returnLineAmount,
2874
2936
  type: 'return',
2875
2937
  },
2876
2938
  ]
@@ -2925,11 +2987,11 @@ export function BookingFlow({
2925
2987
  ];
2926
2988
  }, [
2927
2989
  selectedAvailability,
2928
- ticketLineItems,
2990
+ ticketLineItemsForChangeFlowDisplay,
2929
2991
  pricing,
2930
2992
  getPriceBreakdown,
2931
2993
  selectedReturnOption,
2932
- returnPriceAdjustment,
2994
+ checkoutReturnLineAmount,
2933
2995
  totalQuantity,
2934
2996
  t,
2935
2997
  cancellationPolicyFee,
@@ -3066,93 +3128,13 @@ export function BookingFlow({
3066
3128
  ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
3067
3129
  : taxOnSubtotal;
3068
3130
  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
- }, [
3131
+ /** Change-flow product rules: `lib/booking/change-flow-pricing.ts`. */
3132
+ const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
3121
3133
  isChangeFlow,
3122
- changeFlowSameItineraryAsOriginalBooking,
3123
- changeFlowBaselineEffectiveSubtotalFull,
3124
- effectiveTax,
3125
- isTaxIncludedInPrice,
3126
- pricingConfig?.taxRate,
3127
- effectivePromoDiscountAmount,
3128
- isGiftCard,
3129
- isVoucher,
3130
- ]);
3134
+ cartTotal: totalPrice,
3135
+ originalReceiptTotal: originalReceipt?.total,
3136
+ });
3131
3137
 
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
3138
  const changeSelectionDetails = useMemo(() => {
3157
3139
  if (!isChangeFlow || !initialValues) {
3158
3140
  return {
@@ -3184,11 +3166,12 @@ export function BookingFlow({
3184
3166
  const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
3185
3167
  // Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
3186
3168
  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;
3169
+ const initialOpt =
3170
+ changeFlowResolvedInitialProductOptionId ??
3171
+ (initialValues.productOptionId?.trim() || null);
3172
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
3190
3173
  const optionChanged = Boolean(
3191
- initialOpt && selectedOpt && initialOpt !== selectedOpt
3174
+ selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
3192
3175
  );
3193
3176
  const normalizePickupId = (value: string | null | undefined) => {
3194
3177
  const trimmed = value?.trim();
@@ -3288,6 +3271,7 @@ export function BookingFlow({
3288
3271
  }, [
3289
3272
  isChangeFlow,
3290
3273
  initialValues,
3274
+ changeFlowResolvedInitialProductOptionId,
3291
3275
  selectedAvailability,
3292
3276
  selectedReturnOption,
3293
3277
  implicitReturnBaselineId,
@@ -3349,167 +3333,29 @@ export function BookingFlow({
3349
3333
  const hasEffectiveChangeSelection =
3350
3334
  hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
3351
3335
 
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;
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;
3385
3347
 
3386
3348
  const changeFlowClientEstimateDue = originalReceipt
3387
- ? (isProviderDashboardChange
3388
- ? displayChangeFlowProposedTotal - originalReceipt.total
3389
- : Math.max(displayChangeFlowProposedTotal - originalReceipt.total, 0))
3349
+ ? changeFlowBalanceVsOriginal({
3350
+ newTotal: displayChangeFlowProposedTotal,
3351
+ originalReceiptTotal: originalReceipt.total,
3352
+ audience: isProviderDashboardChange ? 'provider' : 'customer',
3353
+ })
3390
3354
  : totalPrice;
3391
3355
 
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
3356
  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;
3357
+ const changeFlowAmountDue = isChangeFlow ? normalizeNearZeroOwed(changeFlowAmountDueRaw) : changeFlowAmountDueRaw;
3403
3358
 
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
3359
 
3514
3360
  const changeCheckoutButtonLabel = (() => {
3515
3361
  if (!isChangeFlow) return undefined;
@@ -3611,7 +3457,7 @@ export function BookingFlow({
3611
3457
  dateChanged: changeSelectionDetails.dateChanged,
3612
3458
  ticketsChanged: changeSelectionDetails.ticketsChanged,
3613
3459
  hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
3614
- selectionTotal: originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
3460
+ selectionTotal: originalReceipt ? changeFlowNewBookingTotal : totalPrice,
3615
3461
  selectionCurrency: currency,
3616
3462
  };
3617
3463
  }, [
@@ -3625,7 +3471,7 @@ export function BookingFlow({
3625
3471
  totalPrice,
3626
3472
  currency,
3627
3473
  originalReceipt,
3628
- changeFlowProposedTotalResolved,
3474
+ changeFlowNewBookingTotal,
3629
3475
  ]);
3630
3476
 
3631
3477
  useEffect(() => {
@@ -3668,7 +3514,7 @@ export function BookingFlow({
3668
3514
  ? displayChangeFlowTax
3669
3515
  : effectiveTax
3670
3516
  : 0,
3671
- total: isChangeFlow && originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
3517
+ total: isChangeFlow && originalReceipt ? changeFlowNewBookingTotal : totalPrice,
3672
3518
  currency,
3673
3519
  });
3674
3520
  }, [
@@ -3677,7 +3523,7 @@ export function BookingFlow({
3677
3523
  totalQuantity,
3678
3524
  effectiveSubtotal,
3679
3525
  effectiveTax,
3680
- changeFlowProposedTotalResolved,
3526
+ changeFlowNewBookingTotal,
3681
3527
  displayChangeFlowSubtotal,
3682
3528
  displayChangeFlowTax,
3683
3529
  currency,
@@ -3739,7 +3585,7 @@ export function BookingFlow({
3739
3585
  newPassengerCounts: bookingItems,
3740
3586
  // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
3741
3587
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
3742
- clientProposedTotal: changeFlowProposedTotalResolved,
3588
+ clientProposedTotal: changeFlowNewBookingTotal,
3743
3589
  capacitySeatCredit: {
3744
3590
  enabled: true,
3745
3591
  previousPassengerCount: changeFlowInitialTicketCount,
@@ -3791,7 +3637,7 @@ export function BookingFlow({
3791
3637
  quantities,
3792
3638
  addOnSelections,
3793
3639
  changeFlowInitialTicketCount,
3794
- changeFlowProposedTotalResolved,
3640
+ changeFlowNewBookingTotal,
3795
3641
  totalPrice,
3796
3642
  currency,
3797
3643
  activeOptions,
@@ -3944,12 +3790,11 @@ export function BookingFlow({
3944
3790
 
3945
3791
  const preferBooked =
3946
3792
  isChangeFlow && initialReturnIdForSelect
3947
- ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect && opt.vacancies > 0)
3793
+ ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
3948
3794
  : undefined;
3949
3795
  const preferByDateTime =
3950
3796
  isChangeFlow && initialReturnDtForSelect && !preferBooked
3951
3797
  ? sorted.find((opt) => {
3952
- if (opt.vacancies <= 0) return false;
3953
3798
  try {
3954
3799
  return (
3955
3800
  parseAvailabilityDateTime(opt.dateTime).getTime() ===
@@ -4416,7 +4261,7 @@ export function BookingFlow({
4416
4261
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
4417
4262
  newPassengerCounts: bookingItems,
4418
4263
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
4419
- clientProposedTotal: changeFlowProposedTotalResolved,
4264
+ clientProposedTotal: changeFlowNewBookingTotal,
4420
4265
  });
4421
4266
  const canProceed = quote.canProceed !== false;
4422
4267
  const quoteCurrency = (quote.currency || currency) as Currency;
@@ -4437,7 +4282,11 @@ export function BookingFlow({
4437
4282
  if (!canProceed) {
4438
4283
  throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
4439
4284
  }
4440
- const feChangeDue = Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0));
4285
+ const feChangeDue = changeFlowBalanceVsOriginal({
4286
+ newTotal: changeFlowNewBookingTotal,
4287
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4288
+ audience: 'customer',
4289
+ });
4441
4290
  const serverAmountDue =
4442
4291
  quote.amountDueCents != null
4443
4292
  ? Math.max(0, quote.amountDueCents / 100)
@@ -4547,20 +4396,24 @@ export function BookingFlow({
4547
4396
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
4548
4397
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
4549
4398
  const amountDueForCheckout = isCustomerSelfServeChange
4550
- ? Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0))
4399
+ ? changeFlowBalanceVsOriginal({
4400
+ newTotal: changeFlowNewBookingTotal,
4401
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4402
+ audience: 'customer',
4403
+ })
4551
4404
  : totalPrice;
4552
4405
  const lines = [
4553
- ...ticketLineItems.map((line) => ({
4406
+ ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
4554
4407
  label: line.category,
4555
4408
  amount: line.itemTotal,
4556
4409
  type: 'TICKET' as const,
4557
4410
  quantity: line.qty,
4558
4411
  })),
4559
- ...(returnPriceAdjustment !== 0
4412
+ ...(checkoutReturnLineAmount !== 0
4560
4413
  ? [
4561
4414
  {
4562
4415
  label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
4563
- amount: returnPriceAdjustment,
4416
+ amount: checkoutReturnLineAmount,
4564
4417
  type: 'RETURN_OPTION' as const,
4565
4418
  quantity: totalQuantity,
4566
4419
  },
@@ -4758,7 +4611,7 @@ export function BookingFlow({
4758
4611
  availabilityProductOptionId,
4759
4612
  itineraryDisplay: itineraryDisplay ?? undefined,
4760
4613
  clientSecret: paymentIntent.clientSecret ?? '',
4761
- ticketLinesForModal: ticketLineItems.map((line) => {
4614
+ ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
4762
4615
  const rate = pricing.find((r) => r.category === line.category);
4763
4616
  const breakdown = getPriceBreakdown(
4764
4617
  line.category,
@@ -4769,7 +4622,7 @@ export function BookingFlow({
4769
4622
  return { line, breakdown };
4770
4623
  }),
4771
4624
  feeLineItems: feeLineItemsWithAddOns,
4772
- returnPriceAdjustment,
4625
+ returnPriceAdjustment: checkoutReturnLineAmount,
4773
4626
  cancellationPolicyFee,
4774
4627
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4775
4628
  subtotal: effectiveSubtotal,
@@ -4785,7 +4638,7 @@ export function BookingFlow({
4785
4638
  return;
4786
4639
  }
4787
4640
 
4788
- const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItems.map((line) => {
4641
+ const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
4789
4642
  const rate = pricing.find((r) => r.category === line.category);
4790
4643
  const breakdown = getPriceBreakdown(
4791
4644
  line.category,
@@ -4821,7 +4674,7 @@ export function BookingFlow({
4821
4674
  : undefined,
4822
4675
  ticketLines: ticketLinesForModal,
4823
4676
  feeLineItems: feeLineItemsWithAddOns,
4824
- returnPriceAdjustment,
4677
+ returnPriceAdjustment: checkoutReturnLineAmount,
4825
4678
  cancellationPolicyFee,
4826
4679
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4827
4680
  subtotal: effectiveSubtotal,
@@ -5107,6 +4960,7 @@ export function BookingFlow({
5107
4960
  availabilitiesByDate={availabilitiesByDate}
5108
4961
  selectedDate={selectedDate}
5109
4962
  syncVisibleWeekToSelectedDate={isChangeFlow}
4963
+ selectableSoldOutDate={changeFlowOriginalDate}
5110
4964
  isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
5111
4965
  onDateSelect={(date) => {
5112
4966
  setSelectedDate(date);
@@ -5129,7 +4983,11 @@ export function BookingFlow({
5129
4983
  currency={currency}
5130
4984
  showCapacity={isAdmin}
5131
4985
  extraDiscountPercent={calendarDiscountPercent}
5132
- capDiscountToSelectedDate={isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0}
4986
+ capDiscountBadgesToBookingDate={
4987
+ isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0
4988
+ ? changeFlowOriginalDate
4989
+ : null
4990
+ }
5133
4991
  />
5134
4992
  </div>
5135
4993
  </div>
@@ -5271,6 +5129,7 @@ export function BookingFlow({
5271
5129
 
5272
5130
  {/* Total and Checkout — shared PriceSummary component */}
5273
5131
  {selectedAvailability && (
5132
+ <>
5274
5133
  <CheckoutForm
5275
5134
  priceSummaryLines={checkoutPriceSummaryLines}
5276
5135
  totalPrice={changeFlowAmountDue}
@@ -5498,6 +5357,7 @@ export function BookingFlow({
5498
5357
  showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
5499
5358
  }
5500
5359
  />
5360
+ </>
5501
5361
  )}
5502
5362
  </div>
5503
5363
  </>