@ticketboothapp/booking 1.2.122 → 1.2.124

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.
Files changed (112) hide show
  1. package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
  2. package/package.json +1 -1
  3. package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +647 -6157
  5. package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
  6. package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
  7. package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
  8. package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
  9. package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
  10. package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
  11. package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
  12. package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
  13. package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
  14. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
  15. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
  16. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
  17. package/src/components/booking/CheckoutModal.tsx +68 -49
  18. package/src/components/booking/NewBookingFlow.tsx +485 -3505
  19. package/src/components/booking/PriceBreakdown.tsx +17 -3
  20. package/src/components/booking/PriceSummary.tsx +21 -3
  21. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
  22. package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
  23. package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
  24. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
  25. package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
  26. package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
  27. package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
  29. package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
  30. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
  31. package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
  32. package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
  33. package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
  34. package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
  35. package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
  36. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
  37. package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
  38. package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
  39. package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
  40. package/src/components/booking/admin-change-provider-payload.ts +101 -0
  41. package/src/components/booking/change-booking-checkout-builders.ts +185 -0
  42. package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
  43. package/src/components/booking/change-booking-flow-helpers.ts +706 -0
  44. package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
  45. package/src/components/booking/change-booking-quote-guards.ts +223 -0
  46. package/src/components/booking/change-booking-quote-state.ts +64 -0
  47. package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
  48. package/src/components/booking/private-shuttle-availability.ts +111 -0
  49. package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
  50. package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
  51. package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
  52. package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
  53. package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
  54. package/src/components/booking/standard-booking-availability.ts +305 -0
  55. package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
  56. package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
  57. package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
  58. package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
  59. package/src/components/booking/use-private-shuttle-availability.ts +602 -0
  60. package/src/components/booking/use-standard-booking-availability.ts +806 -0
  61. package/src/components/booking/useActiveProductOptions.ts +26 -0
  62. package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
  63. package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
  64. package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
  66. package/src/components/booking/useAdminChangeProductReset.ts +60 -0
  67. package/src/components/booking/useAdminChangeProducts.ts +102 -0
  68. package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
  69. package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
  70. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
  71. package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
  72. package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
  73. package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
  74. package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
  75. package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
  76. package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
  77. package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
  78. package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
  79. package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
  80. package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
  81. package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
  82. package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
  83. package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
  84. package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
  85. package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
  86. package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
  87. package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
  88. package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
  89. package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
  90. package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
  91. package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
  92. package/src/components/booking/useChangeBookingSelection.ts +362 -0
  93. package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
  94. package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
  95. package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
  96. package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
  97. package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
  98. package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
  99. package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
  100. package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
  101. package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
  102. package/src/components/booking/useStandardBookingPromoController.ts +258 -0
  103. package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
  104. package/src/data/products-config.json +1 -1
  105. package/src/lib/booking/change-booking-server-preview.ts +134 -20
  106. package/src/lib/booking/change-flow-pricing.ts +31 -11
  107. package/src/lib/booking/checkout-breakdown.ts +66 -1
  108. package/src/lib/booking/pricing-config.ts +10 -0
  109. package/src/lib/booking/source-metadata.ts +58 -0
  110. package/src/lib/booking-api.ts +154 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -0,0 +1,506 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ type Dispatch,
6
+ type MutableRefObject,
7
+ type SetStateAction,
8
+ } from 'react';
9
+ import {
10
+ quoteChangeBooking,
11
+ quoteChangeBookingAdminFeReceipt,
12
+ type AdminFeAuthoritativeReceipt,
13
+ type Availability,
14
+ type ItineraryDisplayStep,
15
+ type Product,
16
+ type ReturnOption,
17
+ } from '../../lib/booking-api';
18
+ import { roundMoney } from '../../lib/booking/change-flow-pricing';
19
+ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
20
+ import type { OrderSummaryTicketLine } from '../../lib/booking/pricing';
21
+ import { formatCurrencyAmount } from '../../lib/currency';
22
+ import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
23
+ import { sliceAndMergeChangeQuoteForUi } from './change-booking-quote-state';
24
+ import {
25
+ formatTicketLineItemsForSummary,
26
+ getAvailabilityOptionId,
27
+ } from './change-booking-flow-helpers';
28
+ import type { Currency } from './CurrencySwitcher';
29
+ import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
30
+
31
+ type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
32
+ type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
33
+ type AdditionalAdjustment = { label: string; amount: number };
34
+
35
+ interface ChangeSelectionDetailsForPreview {
36
+ dateChanged: boolean;
37
+ ticketsChanged: boolean;
38
+ }
39
+
40
+ export interface UseAdminChangeQuotePreviewParams {
41
+ isCustomerSelfServeChange: boolean;
42
+ isProviderDashboardChange: boolean;
43
+ hasChangeSelection: boolean;
44
+ hasEffectiveChangeSelection: boolean;
45
+ selectedAvailability: Availability | null;
46
+ selectedReturnOption: ReturnOption | null;
47
+ initialValues: ChangeBookingFlowProps['initialValues'];
48
+ lastName: string;
49
+ product: Product;
50
+ pickupLocationId: string | null;
51
+ quantities: Record<string, number>;
52
+ addOnSelections: AddOnSelection[];
53
+ adminCustomReceiptLines: AdminCustomReceiptLine[];
54
+ editableSummaryLineAmountInputs: Record<string, string>;
55
+ editableSummaryLineLabelInputs: Record<string, string>;
56
+ useAdminFeAuthoritativeQuote: boolean;
57
+ latestChangeQuote: AdminChangeLatestQuote | null;
58
+ setLatestChangeQuote: Dispatch<SetStateAction<AdminChangeLatestQuote | null>>;
59
+ changeQuoteLoading: boolean;
60
+ setChangeQuoteLoading: Dispatch<SetStateAction<boolean>>;
61
+ changeQuoteFetchError: string | null;
62
+ setChangeQuoteFetchError: Dispatch<SetStateAction<string | null>>;
63
+ lastCompletedQuoteInputsKeyRef: MutableRefObject<string | null>;
64
+ inFlightQuoteInputsKeyRef: MutableRefObject<string | null>;
65
+ changeQuoteRequestSeq: MutableRefObject<number>;
66
+ totalQuantity: number;
67
+ ticketLineItems: OrderSummaryTicketLine[];
68
+ computeItineraryDisplay: () => ItineraryDisplayStep[] | null;
69
+ changeSelectionDetails: ChangeSelectionDetailsForPreview;
70
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
71
+ suppressSelfServeCurrencyUi: boolean;
72
+ selfServePricingConfirmed: boolean;
73
+ displayChangeFlowProposedTotalWithEditableLines: number;
74
+ totalPrice: number;
75
+ currency: Currency;
76
+ changeFlowClientEstimateDue: number;
77
+ changeFlowInitialTicketCount: number;
78
+ changeFlowNewBookingTotal: number;
79
+ effectiveSubtotalForCheckout: number;
80
+ effectiveTax: number;
81
+ adminCustomLinesAsAdditionalAdjustments: AdditionalAdjustment[];
82
+ adminFeAuthoritativeReceipt: AdminFeAuthoritativeReceipt;
83
+ isTaxIncludedInPrice: boolean;
84
+ t: (key: string) => string;
85
+ locale: string;
86
+ error: string;
87
+ onChangeFlowSelectionPreview?: ChangeBookingFlowProps['onChangeFlowSelectionPreview'];
88
+ onPricePreviewChange?: ChangeBookingFlowProps['onPricePreviewChange'];
89
+ }
90
+
91
+ export interface UseAdminChangeQuotePreviewResult {
92
+ missingRequiredReturnSelection: boolean;
93
+ isChangeQuoteBlocked: boolean;
94
+ changeFlowSubmitDisabled: boolean;
95
+ changeCheckoutButtonLabel: string | undefined;
96
+ checkoutFormError: string;
97
+ }
98
+
99
+ export function useAdminChangeQuotePreview({
100
+ isCustomerSelfServeChange,
101
+ isProviderDashboardChange,
102
+ hasChangeSelection,
103
+ hasEffectiveChangeSelection,
104
+ selectedAvailability,
105
+ selectedReturnOption,
106
+ initialValues,
107
+ lastName,
108
+ product,
109
+ pickupLocationId,
110
+ quantities,
111
+ addOnSelections,
112
+ adminCustomReceiptLines,
113
+ editableSummaryLineAmountInputs,
114
+ editableSummaryLineLabelInputs,
115
+ useAdminFeAuthoritativeQuote,
116
+ latestChangeQuote,
117
+ setLatestChangeQuote,
118
+ changeQuoteLoading,
119
+ setChangeQuoteLoading,
120
+ changeQuoteFetchError,
121
+ setChangeQuoteFetchError,
122
+ lastCompletedQuoteInputsKeyRef,
123
+ inFlightQuoteInputsKeyRef,
124
+ changeQuoteRequestSeq,
125
+ totalQuantity,
126
+ ticketLineItems,
127
+ computeItineraryDisplay,
128
+ changeSelectionDetails,
129
+ originalReceipt,
130
+ suppressSelfServeCurrencyUi,
131
+ selfServePricingConfirmed,
132
+ displayChangeFlowProposedTotalWithEditableLines,
133
+ totalPrice,
134
+ currency,
135
+ changeFlowClientEstimateDue,
136
+ changeFlowInitialTicketCount,
137
+ changeFlowNewBookingTotal,
138
+ effectiveSubtotalForCheckout,
139
+ effectiveTax,
140
+ adminCustomLinesAsAdditionalAdjustments,
141
+ adminFeAuthoritativeReceipt,
142
+ isTaxIncludedInPrice,
143
+ t,
144
+ locale,
145
+ error,
146
+ onChangeFlowSelectionPreview,
147
+ onPricePreviewChange,
148
+ }: UseAdminChangeQuotePreviewParams): UseAdminChangeQuotePreviewResult {
149
+ const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
150
+
151
+ const changeFlowNeedsServerPrice =
152
+ isCustomerSelfServeChange &&
153
+ hasChangeSelection &&
154
+ !!initialValues?.bookingReference?.trim() &&
155
+ !!lastName.trim();
156
+
157
+ const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
158
+ const changeQuoteInputsKey = useMemo(() => JSON.stringify({
159
+ bookingReference: initialValues?.bookingReference?.trim() ?? '',
160
+ lastName: lastName.trim().toLowerCase(),
161
+ parentProductId: product.productId,
162
+ optionId: selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : '',
163
+ dateTime: selectedAvailability?.dateTime ?? '',
164
+ availabilityId: selectedAvailability?.availabilityId ?? null,
165
+ pickupLocationId: pickupLocationId ?? null,
166
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
167
+ quantities,
168
+ addOnSelections,
169
+ adminCustomReceiptLines,
170
+ editableSummaryLineAmountInputs,
171
+ editableSummaryLineLabelInputs,
172
+ useAdminFeAuthoritativeQuote,
173
+ }), [
174
+ initialValues?.bookingReference,
175
+ lastName,
176
+ product.productId,
177
+ selectedAvailability,
178
+ pickupLocationId,
179
+ selectedReturnOption?.returnAvailabilityId,
180
+ quantities,
181
+ addOnSelections,
182
+ adminCustomReceiptLines,
183
+ editableSummaryLineAmountInputs,
184
+ editableSummaryLineLabelInputs,
185
+ useAdminFeAuthoritativeQuote,
186
+ ]);
187
+ const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
188
+ const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
189
+
190
+ const changeFlowSubmitDisabled =
191
+ missingRequiredReturnSelection ||
192
+ (isCustomerSelfServeChange &&
193
+ changeFlowNeedsServerPrice &&
194
+ (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)));
195
+
196
+ const changeCheckoutButtonLabel = (() => {
197
+ if (!hasEffectiveChangeSelection) return undefined;
198
+ if (isProviderDashboardChange) {
199
+ const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
200
+ return est > 0
201
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
202
+ : est < 0
203
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
204
+ : 'Change booking (no charge)';
205
+ }
206
+ if (changeFlowNeedsServerPrice) {
207
+ if (changeQuoteLoading) {
208
+ const tr = t('booking.updatingPrice');
209
+ return tr !== 'booking.updatingPrice' ? tr : 'Updating price\u2026';
210
+ }
211
+ if (changeQuoteFetchError) {
212
+ const tr = t('booking.changeBookingRetry');
213
+ return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
214
+ }
215
+ if (isChangeQuoteBlocked) {
216
+ const tr = t('booking.changeBookingNotAvailable');
217
+ return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
218
+ }
219
+ if (latestChangeQuote) {
220
+ const d = Math.round(changeFlowClientEstimateDue * 100) / 100;
221
+ return d > 0
222
+ ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
223
+ : d < 0
224
+ ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
225
+ : 'Change booking (no charge)';
226
+ }
227
+ const tr = t('booking.changeBooking');
228
+ return tr !== 'booking.changeBooking' ? tr : 'Change booking';
229
+ }
230
+ const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
231
+ return est > 0
232
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
233
+ : est < 0
234
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
235
+ : 'Change booking (no charge)';
236
+ })();
237
+
238
+ const checkoutFormError =
239
+ (error || '') ||
240
+ (missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
241
+ (isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
242
+ (isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
243
+
244
+ const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
245
+ if (!selectedAvailability || totalQuantity <= 0) return null;
246
+ const ticketsLine = formatTicketLineItemsForSummary(
247
+ ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }))
248
+ );
249
+ const itinerary = computeItineraryDisplay();
250
+ const itinerarySteps =
251
+ itinerary?.map((s) => ({
252
+ time: s.time?.trim() || null,
253
+ label: getItineraryStepLabel(s),
254
+ })) ?? [];
255
+ return {
256
+ tourName: product.name?.trim() || '',
257
+ dateTime: selectedAvailability.dateTime,
258
+ ticketsLine,
259
+ itinerarySteps,
260
+ dateChanged: changeSelectionDetails.dateChanged,
261
+ ticketsChanged: changeSelectionDetails.ticketsChanged,
262
+ hasChangesFromInitial: hasEffectiveChangeSelection,
263
+ selectionTotal:
264
+ originalReceipt
265
+ ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed && !useAdminFeAuthoritativeQuote
266
+ ? null
267
+ : displayChangeFlowProposedTotalWithEditableLines
268
+ : totalPrice,
269
+ selectionCurrency: currency,
270
+ };
271
+ }, [
272
+ selectedAvailability,
273
+ totalQuantity,
274
+ ticketLineItems,
275
+ computeItineraryDisplay,
276
+ product.name,
277
+ changeSelectionDetails,
278
+ hasEffectiveChangeSelection,
279
+ totalPrice,
280
+ currency,
281
+ originalReceipt,
282
+ displayChangeFlowProposedTotalWithEditableLines,
283
+ suppressSelfServeCurrencyUi,
284
+ selfServePricingConfirmed,
285
+ useAdminFeAuthoritativeQuote,
286
+ ]);
287
+
288
+ useEffect(() => {
289
+ if (!onChangeFlowSelectionPreview) return;
290
+ const next = changeFlowSelectionPreview;
291
+ const key =
292
+ next === null
293
+ ? 'null'
294
+ : JSON.stringify({
295
+ tourName: next.tourName,
296
+ dateTime: next.dateTime,
297
+ ticketsLine: next.ticketsLine,
298
+ itinerarySteps: next.itinerarySteps,
299
+ dateChanged: next.dateChanged,
300
+ ticketsChanged: next.ticketsChanged,
301
+ hasChangesFromInitial: next.hasChangesFromInitial,
302
+ selectionTotal: next.selectionTotal,
303
+ selectionCurrency: next.selectionCurrency,
304
+ });
305
+ if (key === lastChangeFlowPreviewKeyRef.current) return;
306
+ lastChangeFlowPreviewKeyRef.current = key;
307
+ onChangeFlowSelectionPreview(next);
308
+ }, [changeFlowSelectionPreview, onChangeFlowSelectionPreview]);
309
+
310
+ useEffect(() => {
311
+ if (!onPricePreviewChange) return;
312
+ if (!selectedAvailability || totalQuantity <= 0) {
313
+ onPricePreviewChange(null);
314
+ return;
315
+ }
316
+ if (suppressSelfServeCurrencyUi && !selfServePricingConfirmed) {
317
+ onPricePreviewChange(null);
318
+ return;
319
+ }
320
+ onPricePreviewChange({
321
+ subtotal: originalReceipt ? adminFeAuthoritativeReceipt.subtotal : effectiveSubtotalForCheckout,
322
+ tax:
323
+ !isTaxIncludedInPrice
324
+ ? originalReceipt
325
+ ? adminFeAuthoritativeReceipt.tax
326
+ : effectiveTax
327
+ : 0,
328
+ total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
329
+ currency,
330
+ });
331
+ }, [
332
+ onPricePreviewChange,
333
+ selectedAvailability,
334
+ totalQuantity,
335
+ effectiveSubtotalForCheckout,
336
+ effectiveTax,
337
+ displayChangeFlowProposedTotalWithEditableLines,
338
+ adminFeAuthoritativeReceipt.subtotal,
339
+ adminFeAuthoritativeReceipt.tax,
340
+ currency,
341
+ isTaxIncludedInPrice,
342
+ originalReceipt,
343
+ totalPrice,
344
+ suppressSelfServeCurrencyUi,
345
+ selfServePricingConfirmed,
346
+ ]);
347
+
348
+ /** Debounced server quote so CTA + amount owed match PaymentIntent; avoids free confirm when FE estimate differs. */
349
+ useEffect(() => {
350
+ if (!isCustomerSelfServeChange) {
351
+ setChangeQuoteLoading(false);
352
+ setChangeQuoteFetchError(null);
353
+ setLatestChangeQuote(null);
354
+ return;
355
+ }
356
+
357
+ if (
358
+ !hasChangeSelection ||
359
+ !selectedAvailability ||
360
+ !initialValues?.bookingReference?.trim() ||
361
+ !lastName.trim() ||
362
+ missingRequiredReturnSelection
363
+ ) {
364
+ setLatestChangeQuote(null);
365
+ setChangeQuoteLoading(false);
366
+ setChangeQuoteFetchError(null);
367
+ lastCompletedQuoteInputsKeyRef.current = null;
368
+ inFlightQuoteInputsKeyRef.current = null;
369
+ return;
370
+ }
371
+
372
+ if (
373
+ changeQuoteInputsKey &&
374
+ (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey ||
375
+ lastCompletedQuoteInputsKeyRef.current === changeQuoteInputsKey)
376
+ ) {
377
+ setChangeQuoteLoading(false);
378
+ return;
379
+ }
380
+
381
+ const optionId = getAvailabilityOptionId(selectedAvailability);
382
+ if (!optionId) {
383
+ setLatestChangeQuote(null);
384
+ setChangeQuoteLoading(false);
385
+ setChangeQuoteFetchError(null);
386
+ return;
387
+ }
388
+
389
+ if (changeQuoteInputsKey && lastCompletedQuoteInputsKeyRef.current !== changeQuoteInputsKey) {
390
+ setLatestChangeQuote(null);
391
+ }
392
+ setChangeQuoteLoading(true);
393
+ setChangeQuoteFetchError(null);
394
+ const seq = ++changeQuoteRequestSeq.current;
395
+ const bookingReferenceForQuote = initialValues.bookingReference.trim();
396
+ const timer = window.setTimeout(() => {
397
+ void (async () => {
398
+ inFlightQuoteInputsKeyRef.current = changeQuoteInputsKey;
399
+ const bookingItems = Object.entries(quantities)
400
+ .filter(([, count]) => count > 0)
401
+ .map(([category, count]) => ({ category, count }));
402
+ try {
403
+ const quoteRequestBase = {
404
+ bookingReference: bookingReferenceForQuote,
405
+ lastName: lastName.trim(),
406
+ newProductId: optionId,
407
+ newParentProductId: product.productId,
408
+ newDateTime: selectedAvailability.dateTime,
409
+ newAvailabilityId: selectedAvailability.availabilityId || null,
410
+ newPickupLocationId: pickupLocationId || null,
411
+ newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
412
+ newPassengerCounts: bookingItems,
413
+ // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
414
+ ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
415
+ ...(adminCustomLinesAsAdditionalAdjustments.length > 0
416
+ ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
417
+ : {}),
418
+ clientProposedTotal: changeFlowNewBookingTotal,
419
+ capacitySeatCredit: {
420
+ enabled: true,
421
+ previousPassengerCount: changeFlowInitialTicketCount,
422
+ previousAvailabilityId: initialValues.availabilityId ?? null,
423
+ previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
424
+ },
425
+ };
426
+ const deterministicClientProposedTotal = useAdminFeAuthoritativeQuote
427
+ ? adminFeAuthoritativeReceipt.total
428
+ : changeFlowNewBookingTotal;
429
+ const deterministicAmountDue = originalReceipt
430
+ ? roundMoney(adminFeAuthoritativeReceipt.total - originalReceipt.total)
431
+ : roundMoney(changeFlowClientEstimateDue);
432
+ const quote = useAdminFeAuthoritativeQuote
433
+ ? await quoteChangeBookingAdminFeReceipt({
434
+ ...quoteRequestBase,
435
+ feReceipt: adminFeAuthoritativeReceipt,
436
+ feAmountDueMajorUnits: deterministicAmountDue,
437
+ clientProposedTotal: deterministicClientProposedTotal,
438
+ })
439
+ : await quoteChangeBooking(quoteRequestBase);
440
+ if (seq !== changeQuoteRequestSeq.current) return;
441
+ setLatestChangeQuote(
442
+ sliceAndMergeChangeQuoteForUi(quote, {
443
+ total: changeFlowNewBookingTotal,
444
+ subtotal: effectiveSubtotalForCheckout,
445
+ tax: effectiveTax,
446
+ }, currency).mergedQuote,
447
+ );
448
+ lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
449
+ } catch (e) {
450
+ if (seq !== changeQuoteRequestSeq.current) return;
451
+ setLatestChangeQuote(null);
452
+ setChangeQuoteFetchError(e instanceof Error ? e.message : 'Failed to get price for this change');
453
+ lastCompletedQuoteInputsKeyRef.current = null;
454
+ } finally {
455
+ if (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
456
+ inFlightQuoteInputsKeyRef.current = null;
457
+ }
458
+ if (seq === changeQuoteRequestSeq.current) {
459
+ setChangeQuoteLoading(false);
460
+ }
461
+ }
462
+ })();
463
+ }, 450);
464
+
465
+ return () => {
466
+ window.clearTimeout(timer);
467
+ };
468
+ }, [
469
+ isCustomerSelfServeChange,
470
+ hasChangeSelection,
471
+ selectedAvailability,
472
+ initialValues,
473
+ lastName,
474
+ missingRequiredReturnSelection,
475
+ pickupLocationId,
476
+ selectedReturnOption?.returnAvailabilityId,
477
+ quantities,
478
+ addOnSelections,
479
+ changeFlowInitialTicketCount,
480
+ changeFlowNewBookingTotal,
481
+ changeFlowClientEstimateDue,
482
+ effectiveSubtotalForCheckout,
483
+ effectiveTax,
484
+ currency,
485
+ adminCustomLinesAsAdditionalAdjustments,
486
+ adminFeAuthoritativeReceipt,
487
+ useAdminFeAuthoritativeQuote,
488
+ changeQuoteInputsKey,
489
+ originalReceipt,
490
+ product.productId,
491
+ setChangeQuoteLoading,
492
+ setChangeQuoteFetchError,
493
+ setLatestChangeQuote,
494
+ lastCompletedQuoteInputsKeyRef,
495
+ inFlightQuoteInputsKeyRef,
496
+ changeQuoteRequestSeq,
497
+ ]);
498
+
499
+ return {
500
+ missingRequiredReturnSelection,
501
+ isChangeQuoteBlocked,
502
+ changeFlowSubmitDisabled,
503
+ changeCheckoutButtonLabel,
504
+ checkoutFormError,
505
+ };
506
+ }
@@ -0,0 +1,203 @@
1
+ import { useMemo } from 'react';
2
+ import type { AdminFeAuthoritativeReceipt } from '../../lib/booking-api';
3
+ import {
4
+ changeFlowBalanceVsOriginal,
5
+ normalizeNearZeroOwed,
6
+ reconcileTaxIncludedChangeFlowTicketLines,
7
+ roundMoney,
8
+ } from '../../lib/booking/change-flow-pricing';
9
+ import type { PriceBreakdown as PriceBreakdownData } from '../../lib/booking/pricing';
10
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
11
+ import type { CheckoutModalLineItem } from './CheckoutModal';
12
+ import type { Currency } from './CurrencySwitcher';
13
+ import type { PriceSummaryLine } from './PriceSummary';
14
+ import {
15
+ mapSummaryLinesToFeReceiptLineItems,
16
+ normalizeFeReceiptLineItemType,
17
+ type PricingRate,
18
+ } from './change-booking-flow-helpers';
19
+ import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
20
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
21
+
22
+ type GetPriceBreakdown = (
23
+ category: string,
24
+ priceCAD: number,
25
+ baseInDisplayCurrency: number | undefined,
26
+ appliedAdjustments?: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
27
+ ) => PriceBreakdownData | null;
28
+
29
+ export interface UseAdminChangeReceiptDerivationParams {
30
+ isTaxIncludedInPrice: boolean;
31
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
32
+ showProviderPricingInlineEditor: boolean;
33
+ editableCheckoutPriceSummaryLines: PriceSummaryLine[];
34
+ displayChangeFlowProposedTotalWithEditableLines: number;
35
+ effectivePromoDiscountAmount: number;
36
+ pricing: PricingRate[];
37
+ getPriceBreakdown: GetPriceBreakdown;
38
+ quantities: Record<string, number>;
39
+ displayChangeFlowTax: number;
40
+ t: (key: string) => string;
41
+ currency: Currency;
42
+ useAdminFeAuthoritativeQuote: boolean;
43
+ totalPrice: number;
44
+ unchangedReceiptAnchor: { subtotal: number; tax: number; total: number } | null;
45
+ isCustomerSelfServeChange: boolean;
46
+ latestChangeQuote: AdminChangeLatestQuote | null;
47
+ changeQuoteFetchError: string | null;
48
+ displayChangeFlowProposedTotal: number;
49
+ editableSummaryPreSubtotalDelta: number;
50
+ }
51
+
52
+ export function useAdminChangeReceiptDerivation({
53
+ isTaxIncludedInPrice,
54
+ originalReceipt,
55
+ showProviderPricingInlineEditor,
56
+ editableCheckoutPriceSummaryLines,
57
+ displayChangeFlowProposedTotalWithEditableLines,
58
+ effectivePromoDiscountAmount,
59
+ pricing,
60
+ getPriceBreakdown,
61
+ quantities,
62
+ displayChangeFlowTax,
63
+ t,
64
+ currency,
65
+ useAdminFeAuthoritativeQuote,
66
+ totalPrice,
67
+ unchangedReceiptAnchor,
68
+ isCustomerSelfServeChange,
69
+ latestChangeQuote,
70
+ changeQuoteFetchError,
71
+ displayChangeFlowProposedTotal,
72
+ editableSummaryPreSubtotalDelta,
73
+ }: UseAdminChangeReceiptDerivationParams) {
74
+ const taxIncludedReconciledCheckoutPriceSummaryLines = useMemo(() => {
75
+ if (!isTaxIncludedInPrice || !originalReceipt || showProviderPricingInlineEditor) {
76
+ return editableCheckoutPriceSummaryLines;
77
+ }
78
+ return reconcileTaxIncludedChangeFlowTicketLines(
79
+ editableCheckoutPriceSummaryLines,
80
+ displayChangeFlowProposedTotalWithEditableLines,
81
+ effectivePromoDiscountAmount,
82
+ );
83
+ }, [
84
+ isTaxIncludedInPrice,
85
+ originalReceipt,
86
+ showProviderPricingInlineEditor,
87
+ editableCheckoutPriceSummaryLines,
88
+ displayChangeFlowProposedTotalWithEditableLines,
89
+ effectivePromoDiscountAmount,
90
+ ]);
91
+
92
+ const checkoutModalTicketLinesDisplay = useMemo((): CheckoutModalLineItem[] => {
93
+ return taxIncludedReconciledCheckoutPriceSummaryLines
94
+ .filter((l): l is Extract<PriceSummaryLine, { kind: 'ticket' }> => l.kind === 'ticket')
95
+ .map((line) => {
96
+ const rate = pricing.find((r) => r.category === line.category);
97
+ const breakdown = getPriceBreakdown(
98
+ line.category,
99
+ rate?.priceCAD ?? 0,
100
+ rate?.baseInDisplayCurrency,
101
+ rate?.appliedAdjustments ?? [],
102
+ );
103
+ const qty = Math.max(0, line.qty);
104
+ return {
105
+ line: {
106
+ category: line.category,
107
+ qty,
108
+ itemTotal: line.itemTotal,
109
+ pricePerUnit: qty > 0 ? roundMoney(line.itemTotal / qty) : 0,
110
+ },
111
+ breakdown,
112
+ };
113
+ });
114
+ }, [taxIncludedReconciledCheckoutPriceSummaryLines, pricing, getPriceBreakdown]);
115
+
116
+ const adminFeAuthoritativeReceipt = useMemo((): AdminFeAuthoritativeReceipt => {
117
+ const hasTaxLine = taxIncludedReconciledCheckoutPriceSummaryLines.some(
118
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
119
+ );
120
+ const lineItems = mapSummaryLinesToFeReceiptLineItems(
121
+ taxIncludedReconciledCheckoutPriceSummaryLines,
122
+ quantities,
123
+ );
124
+ if (!hasTaxLine && Math.abs(displayChangeFlowTax) >= 0.0005) {
125
+ lineItems.push({
126
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
127
+ amount: roundMoney(displayChangeFlowTax),
128
+ type: 'TAX',
129
+ });
130
+ }
131
+ const tax = roundMoney(displayChangeFlowTax);
132
+ const expectedTotal = roundMoney(displayChangeFlowProposedTotalWithEditableLines);
133
+ const subtotal = roundMoney(expectedTotal - tax);
134
+ return {
135
+ subtotal,
136
+ tax,
137
+ total: expectedTotal,
138
+ currency,
139
+ lineItems,
140
+ };
141
+ }, [
142
+ taxIncludedReconciledCheckoutPriceSummaryLines,
143
+ quantities,
144
+ displayChangeFlowTax,
145
+ displayChangeFlowProposedTotalWithEditableLines,
146
+ currency,
147
+ t,
148
+ ]);
149
+
150
+ const providerApplyAuthoritativeReceipt = useMemo(():
151
+ | ProviderDashboardChangeBookingPayload['authoritativeReceipt']
152
+ | undefined => {
153
+ if (!useAdminFeAuthoritativeQuote) return undefined;
154
+ return {
155
+ subtotalBeforeTax: adminFeAuthoritativeReceipt.subtotal,
156
+ taxAmount: adminFeAuthoritativeReceipt.tax,
157
+ totalAmount: adminFeAuthoritativeReceipt.total,
158
+ currency: adminFeAuthoritativeReceipt.currency ?? currency,
159
+ lineItems: adminFeAuthoritativeReceipt.lineItems.map((line) => ({
160
+ type: normalizeFeReceiptLineItemType(line.type) || 'FEE',
161
+ label: String(line.label || 'Adjustment'),
162
+ amount: Number(line.amount) || 0,
163
+ ...(line.quantity != null ? { quantity: Number(line.quantity) || 0 } : {}),
164
+ })),
165
+ };
166
+ }, [adminFeAuthoritativeReceipt, currency, useAdminFeAuthoritativeQuote]);
167
+
168
+ const changeFlowClientEstimateDueBase = (() => {
169
+ if (!originalReceipt) return totalPrice;
170
+ if (unchangedReceiptAnchor) return 0;
171
+ if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
172
+ const pq = latestChangeQuote.quotePreviousTotalCents;
173
+ const nq = latestChangeQuote.quoteNewTotalCents;
174
+ if (pq != null && nq != null) {
175
+ return normalizeNearZeroOwed(roundMoney((nq - pq) / 100));
176
+ }
177
+ if (latestChangeQuote.serverDisplay != null) {
178
+ return normalizeNearZeroOwed(
179
+ roundMoney(latestChangeQuote.serverDisplay.total - originalReceipt.total),
180
+ );
181
+ }
182
+ return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
183
+ }
184
+ return changeFlowBalanceVsOriginal({
185
+ newTotal: displayChangeFlowProposedTotal,
186
+ originalReceiptTotal: originalReceipt.total,
187
+ audience: 'admin',
188
+ });
189
+ })();
190
+ const changeFlowClientEstimateDue = normalizeNearZeroOwed(
191
+ roundMoney(changeFlowClientEstimateDueBase + editableSummaryPreSubtotalDelta),
192
+ );
193
+ const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowClientEstimateDue);
194
+
195
+ return {
196
+ taxIncludedReconciledCheckoutPriceSummaryLines,
197
+ checkoutModalTicketLinesDisplay,
198
+ adminFeAuthoritativeReceipt,
199
+ providerApplyAuthoritativeReceipt,
200
+ changeFlowClientEstimateDue,
201
+ changeFlowAmountDue,
202
+ };
203
+ }