@ticketboothapp/booking 1.2.123 → 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 +651 -6202
  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,151 @@
1
+ import { formatBookingRefForDisplay } from '../../lib/booking-ref';
2
+ import {
3
+ MANAGE_BOOKING_FROM_CHANGE_PAYMENT,
4
+ MANAGE_BOOKING_QUERY_FROM,
5
+ } from '../../lib/manage-booking-post-checkout';
6
+ import type { OrderSummary } from '../../lib/booking/pricing';
7
+ import type { CheckoutModalLineItem } from './CheckoutModal';
8
+
9
+ export type ChangeBookingSuccessUrlFactory = (params: {
10
+ reservationRef: string;
11
+ lastName: string;
12
+ focusDate?: string;
13
+ }) => string;
14
+
15
+ export interface ChangeBookingPaidCheckoutModalData {
16
+ reservationReference: string;
17
+ reservationExpiration?: string;
18
+ customerLastName?: string;
19
+ bookingDate?: string;
20
+ successUrlOverride?: string;
21
+ ticketLines: CheckoutModalLineItem[];
22
+ feeLineItems: OrderSummary['feeLineItems'];
23
+ returnPriceAdjustment: number;
24
+ cancellationPolicyFee: number;
25
+ cancellationPolicyLabel?: string;
26
+ subtotal: number;
27
+ tax: number;
28
+ total: number;
29
+ promoDiscountAmount?: number;
30
+ discountLabel?: string | null;
31
+ totalQuantity: number;
32
+ isTaxIncludedInPrice: boolean;
33
+ taxRate: number;
34
+ changeTotals?: {
35
+ previousTotal: number;
36
+ newTotal: number;
37
+ differenceTotal: number;
38
+ };
39
+ }
40
+
41
+ export interface BuildChangeBookingPaymentSuccessUrlParams {
42
+ enabled: boolean;
43
+ bookingReference?: string | null;
44
+ lastName: string;
45
+ datePart: string;
46
+ changeIntentId?: string;
47
+ preferHostSuccessUrl?: boolean;
48
+ getSuccessUrl?: ChangeBookingSuccessUrlFactory;
49
+ }
50
+
51
+ export function buildChangeBookingPaymentSuccessUrl({
52
+ enabled,
53
+ bookingReference,
54
+ lastName,
55
+ datePart,
56
+ changeIntentId,
57
+ preferHostSuccessUrl,
58
+ getSuccessUrl,
59
+ }: BuildChangeBookingPaymentSuccessUrlParams): string | undefined {
60
+ if (!enabled || !bookingReference) return undefined;
61
+ const displayRef = formatBookingRefForDisplay(bookingReference) || bookingReference;
62
+ const trimmedLastName = lastName.trim();
63
+
64
+ if (preferHostSuccessUrl && getSuccessUrl) {
65
+ return getSuccessUrl({
66
+ reservationRef: displayRef,
67
+ lastName: trimmedLastName,
68
+ focusDate: datePart,
69
+ });
70
+ }
71
+
72
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
73
+ const ref = encodeURIComponent(displayRef);
74
+ const ln = encodeURIComponent(trimmedLastName);
75
+ const fromQ = `${MANAGE_BOOKING_QUERY_FROM}=${encodeURIComponent(MANAGE_BOOKING_FROM_CHANGE_PAYMENT)}`;
76
+ const intentQ = changeIntentId ? `&changeIntentId=${encodeURIComponent(changeIntentId)}` : '';
77
+ return `${origin}/manage-booking?ref=${ref}&lastName=${ln}&${fromQ}${intentQ}`;
78
+ }
79
+
80
+ export interface BuildChangeBookingPaidCheckoutModalDataParams {
81
+ bookingReference?: string | null;
82
+ lastName: string;
83
+ datePart: string;
84
+ ticketLines: CheckoutModalLineItem[];
85
+ feeLineItems: OrderSummary['feeLineItems'];
86
+ returnPriceAdjustment: number;
87
+ cancellationPolicyFee: number;
88
+ cancellationPolicyLabel?: string;
89
+ subtotal: number;
90
+ tax: number;
91
+ total: number;
92
+ totalQuantity: number;
93
+ isTaxIncludedInPrice: boolean;
94
+ taxRate: number;
95
+ promoDiscountAmount: number;
96
+ discountLabel?: string | null;
97
+ previousTotal?: number | null;
98
+ newTotal?: number | null;
99
+ successUrl: BuildChangeBookingPaymentSuccessUrlParams;
100
+ }
101
+
102
+ export function buildChangeBookingPaidCheckoutModalData({
103
+ bookingReference,
104
+ lastName,
105
+ datePart,
106
+ ticketLines,
107
+ feeLineItems,
108
+ returnPriceAdjustment,
109
+ cancellationPolicyFee,
110
+ cancellationPolicyLabel,
111
+ subtotal,
112
+ tax,
113
+ total,
114
+ totalQuantity,
115
+ isTaxIncludedInPrice,
116
+ taxRate,
117
+ promoDiscountAmount,
118
+ discountLabel,
119
+ previousTotal,
120
+ newTotal,
121
+ successUrl,
122
+ }: BuildChangeBookingPaidCheckoutModalDataParams): ChangeBookingPaidCheckoutModalData {
123
+ return {
124
+ reservationReference: bookingReference ?? '',
125
+ reservationExpiration: undefined,
126
+ customerLastName: lastName.trim(),
127
+ bookingDate: datePart,
128
+ successUrlOverride: buildChangeBookingPaymentSuccessUrl(successUrl),
129
+ ticketLines,
130
+ feeLineItems,
131
+ returnPriceAdjustment,
132
+ cancellationPolicyFee,
133
+ cancellationPolicyLabel,
134
+ subtotal,
135
+ tax,
136
+ total,
137
+ totalQuantity,
138
+ isTaxIncludedInPrice,
139
+ taxRate,
140
+ promoDiscountAmount,
141
+ discountLabel,
142
+ changeTotals:
143
+ previousTotal != null && newTotal != null
144
+ ? {
145
+ previousTotal,
146
+ newTotal,
147
+ differenceTotal: total,
148
+ }
149
+ : undefined,
150
+ };
151
+ }
@@ -0,0 +1,223 @@
1
+ import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
2
+ import {
3
+ changeFlowBalanceVsOriginal,
4
+ normalizeNearZeroOwed,
5
+ roundMoney,
6
+ type ChangeQuoteUiSlice,
7
+ } from '../../lib/booking/change-flow-pricing';
8
+
9
+ const CHANGE_PAYMENT_CONFIRMATION_ERROR =
10
+ 'This change requires payment, but the price could not be confirmed. Please refresh and try again.';
11
+
12
+ export type ChangeBookingCheckoutQuoteAudience = 'customer' | 'admin';
13
+
14
+ export type ChangeBookingCheckoutQuoteDecision =
15
+ | {
16
+ kind: 'free';
17
+ changeIntentId: string;
18
+ amountDueForCheckout: number;
19
+ }
20
+ | {
21
+ kind: 'paid';
22
+ changeIntentId: string;
23
+ amountDueForCheckout: number;
24
+ };
25
+
26
+ export interface EvaluateChangeBookingQuoteForCheckoutParams {
27
+ quote: ChangeBookingQuoteResponse;
28
+ quoteSlice: ChangeQuoteUiSlice;
29
+ fallbackNewBookingTotal: number;
30
+ originalReceiptTotal: number;
31
+ audience: ChangeBookingCheckoutQuoteAudience;
32
+ useAdminFeAuthoritativeQuote?: boolean;
33
+ adminFeAmountDueMajorUnits?: number;
34
+ }
35
+
36
+ function resolveSignedBalanceMajor(quote: ChangeBookingQuoteResponse): number | null {
37
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
38
+ if (quote.previousTotalCents != null && quote.newTotalCents != null) {
39
+ return (quote.newTotalCents - quote.previousTotalCents) / 100;
40
+ }
41
+ return quote.balanceDeltaMajorUnits ?? quote.balanceDelta ?? pricingQuote?.balanceDelta ?? null;
42
+ }
43
+
44
+ function resolveServerNewTotalForGuard({
45
+ quote,
46
+ quoteSlice,
47
+ fallbackNewBookingTotal,
48
+ }: Pick<
49
+ EvaluateChangeBookingQuoteForCheckoutParams,
50
+ 'quote' | 'quoteSlice' | 'fallbackNewBookingTotal'
51
+ >): number {
52
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
53
+ return (
54
+ quoteSlice.quotedTotal ??
55
+ pricingQuote?.payableTotal ??
56
+ pricingQuote?.totalAmount ??
57
+ quoteSlice.serverDisplay?.total ??
58
+ quote.proposed?.total ??
59
+ quote.newReceipt?.total ??
60
+ fallbackNewBookingTotal
61
+ );
62
+ }
63
+
64
+ function resolveAmountDueForCheckout({
65
+ quote,
66
+ signedBalanceMajor,
67
+ audience,
68
+ useAdminFeAuthoritativeQuote,
69
+ adminFeAmountDueMajorUnits,
70
+ }: {
71
+ quote: ChangeBookingQuoteResponse;
72
+ signedBalanceMajor: number | null;
73
+ audience: ChangeBookingCheckoutQuoteAudience;
74
+ useAdminFeAuthoritativeQuote?: boolean;
75
+ adminFeAmountDueMajorUnits?: number;
76
+ }): number {
77
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
78
+ if (useAdminFeAuthoritativeQuote) {
79
+ return Math.max(0, adminFeAmountDueMajorUnits ?? 0);
80
+ }
81
+ if (quote.amountToCharge != null) return quote.amountToCharge;
82
+ if (pricingQuote?.amountToCharge != null) return pricingQuote.amountToCharge;
83
+ if (signedBalanceMajor != null) return Math.max(0, signedBalanceMajor);
84
+ if (quote.amountDueCents != null) {
85
+ const amountDue = quote.amountDueCents / 100;
86
+ return audience === 'customer' ? Math.max(0, amountDue) : amountDue;
87
+ }
88
+ return Math.max(0, quote.priceDiff ?? 0);
89
+ }
90
+
91
+ function resolveFeChangeDue({
92
+ signedBalanceMajor,
93
+ serverNewTotalForGuard,
94
+ originalReceiptTotal,
95
+ audience,
96
+ useAdminFeAuthoritativeQuote,
97
+ adminFeAmountDueMajorUnits,
98
+ }: {
99
+ signedBalanceMajor: number | null;
100
+ serverNewTotalForGuard: number;
101
+ originalReceiptTotal: number;
102
+ audience: ChangeBookingCheckoutQuoteAudience;
103
+ useAdminFeAuthoritativeQuote?: boolean;
104
+ adminFeAmountDueMajorUnits?: number;
105
+ }): number {
106
+ if (useAdminFeAuthoritativeQuote) return adminFeAmountDueMajorUnits ?? 0;
107
+ if (audience === 'admin' && signedBalanceMajor != null) return signedBalanceMajor;
108
+ return changeFlowBalanceVsOriginal({
109
+ newTotal: serverNewTotalForGuard,
110
+ originalReceiptTotal,
111
+ audience,
112
+ });
113
+ }
114
+
115
+ function assertNoUnconfirmedNoChargeUpgrade({
116
+ quote,
117
+ quoteSlice,
118
+ audience,
119
+ useAdminFeAuthoritativeQuote,
120
+ }: {
121
+ quote: ChangeBookingQuoteResponse;
122
+ quoteSlice: ChangeQuoteUiSlice;
123
+ audience: ChangeBookingCheckoutQuoteAudience;
124
+ useAdminFeAuthoritativeQuote?: boolean;
125
+ }): void {
126
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
127
+
128
+ if (audience === 'admin') {
129
+ const upgradeWithoutCharge =
130
+ !useAdminFeAuthoritativeQuote &&
131
+ quote.newTotalCents != null &&
132
+ quote.previousTotalCents != null &&
133
+ quote.newTotalCents > quote.previousTotalCents + 1;
134
+ if (upgradeWithoutCharge) throw new Error(CHANGE_PAYMENT_CONFIRMATION_ERROR);
135
+ return;
136
+ }
137
+
138
+ const proposedTotal =
139
+ quoteSlice.quotedTotal ??
140
+ pricingQuote?.payableTotal ??
141
+ pricingQuote?.totalAmount ??
142
+ quote.proposed?.total ??
143
+ quote.newReceipt?.total;
144
+ const originalTotal =
145
+ pricingQuote?.amountPreviouslyPaid ?? quote.original?.total ?? quote.originalReceipt?.total;
146
+ if (proposedTotal != null && originalTotal != null && proposedTotal - originalTotal > 0.01) {
147
+ throw new Error(CHANGE_PAYMENT_CONFIRMATION_ERROR);
148
+ }
149
+ }
150
+
151
+ export function evaluateChangeBookingQuoteForCheckout(
152
+ params: EvaluateChangeBookingQuoteForCheckoutParams
153
+ ): ChangeBookingCheckoutQuoteDecision {
154
+ const {
155
+ quote,
156
+ quoteSlice,
157
+ fallbackNewBookingTotal,
158
+ originalReceiptTotal,
159
+ audience,
160
+ useAdminFeAuthoritativeQuote,
161
+ adminFeAmountDueMajorUnits,
162
+ } = params;
163
+
164
+ if (!quoteSlice.canProceed) {
165
+ throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
166
+ }
167
+
168
+ const signedBalanceMajor = resolveSignedBalanceMajor(quote);
169
+ const serverNewTotalForGuard = resolveServerNewTotalForGuard({
170
+ quote,
171
+ quoteSlice,
172
+ fallbackNewBookingTotal,
173
+ });
174
+ const amountDue = resolveAmountDueForCheckout({
175
+ quote,
176
+ signedBalanceMajor,
177
+ audience,
178
+ useAdminFeAuthoritativeQuote,
179
+ adminFeAmountDueMajorUnits,
180
+ });
181
+ const feChangeDue = resolveFeChangeDue({
182
+ signedBalanceMajor,
183
+ serverNewTotalForGuard,
184
+ originalReceiptTotal,
185
+ audience,
186
+ useAdminFeAuthoritativeQuote,
187
+ adminFeAmountDueMajorUnits,
188
+ });
189
+ const amountDueForCheckout =
190
+ audience === 'admin' ? normalizeNearZeroOwed(roundMoney(amountDue)) : amountDue;
191
+
192
+ if (feChangeDue > 0.02 && amountDue <= 0.009) {
193
+ throw new Error(CHANGE_PAYMENT_CONFIRMATION_ERROR);
194
+ }
195
+
196
+ if (amountDue <= 0.009) {
197
+ if (feChangeDue > 0.02) throw new Error(CHANGE_PAYMENT_CONFIRMATION_ERROR);
198
+ assertNoUnconfirmedNoChargeUpgrade({
199
+ quote,
200
+ quoteSlice,
201
+ audience,
202
+ useAdminFeAuthoritativeQuote,
203
+ });
204
+ if (!quote.changeIntentId) {
205
+ throw new Error('Missing change intent for booking change confirmation.');
206
+ }
207
+ return {
208
+ kind: 'free',
209
+ changeIntentId: quote.changeIntentId,
210
+ amountDueForCheckout,
211
+ };
212
+ }
213
+
214
+ const paidChangeIntentId = quoteSlice.changeIntentId;
215
+ if (!paidChangeIntentId) {
216
+ throw new Error('Missing change intent for payment.');
217
+ }
218
+ return {
219
+ kind: 'paid',
220
+ changeIntentId: paidChangeIntentId,
221
+ amountDueForCheckout,
222
+ };
223
+ }
@@ -0,0 +1,64 @@
1
+ import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
2
+ import {
3
+ type ChangeQuoteUiSlice,
4
+ sliceChangeQuoteForUi,
5
+ } from '../../lib/booking/change-flow-pricing';
6
+ import {
7
+ normalizePricingDriftDetailFromQuote,
8
+ normalizeTicketPricingTraceFromQuote,
9
+ } from '../../lib/booking/change-booking-pricing-drift';
10
+ import {
11
+ buildChangeBookingServerPreview,
12
+ } from '../../lib/booking/change-booking-server-preview';
13
+ import type { Currency } from './CurrencySwitcher';
14
+
15
+ export type ChangeBookingServerPreview = ReturnType<typeof buildChangeBookingServerPreview>;
16
+ export type ChangeBookingMergedQuoteState = ReturnType<typeof mergeQuoteSliceWithServerPreview>;
17
+
18
+ export function mergeQuoteSliceWithServerPreview(
19
+ slice: ChangeQuoteUiSlice,
20
+ quote: ChangeBookingQuoteResponse,
21
+ fallbackCart: { total: number; subtotal: number; tax: number },
22
+ currency: Currency,
23
+ ) {
24
+ return {
25
+ ...slice,
26
+ currency: (slice.currency || currency) as Currency,
27
+ serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
28
+ pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
29
+ ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
30
+ /** Same cent pair the BE uses for `amountDueCents` / intent & receipt "New Booking Difference". */
31
+ quotePreviousTotalCents: quote.previousTotalCents,
32
+ quoteNewTotalCents: quote.newTotalCents,
33
+ };
34
+ }
35
+
36
+ export function sliceAndMergeChangeQuoteForUi(
37
+ quote: ChangeBookingQuoteResponse,
38
+ fallbackCart: { total: number; subtotal: number; tax: number },
39
+ currency: Currency,
40
+ ): { quoteSlice: ChangeQuoteUiSlice; mergedQuote: ChangeBookingMergedQuoteState } {
41
+ const quoteSlice = sliceChangeQuoteForUi(quote, fallbackCart, currency);
42
+ return {
43
+ quoteSlice,
44
+ mergedQuote: mergeQuoteSliceWithServerPreview(quoteSlice, quote, fallbackCart, currency),
45
+ };
46
+ }
47
+
48
+ export function resolveConfirmedChangeNewTotalForCheckout({
49
+ quote,
50
+ quoteSlice,
51
+ fallbackNewBookingTotal,
52
+ }: {
53
+ quote: ChangeBookingQuoteResponse;
54
+ quoteSlice: ChangeQuoteUiSlice;
55
+ fallbackNewBookingTotal: number;
56
+ }): number {
57
+ return (
58
+ quoteSlice.serverDisplay?.total ??
59
+ quoteSlice.quotedTotal ??
60
+ quote.proposed?.total ??
61
+ quote.newReceipt?.total ??
62
+ fallbackNewBookingTotal
63
+ );
64
+ }
@@ -0,0 +1,144 @@
1
+ import type {
2
+ PricingV2QuoteLineSnapshot,
3
+ PricingV2QuoteSnapshot,
4
+ } from "../../lib/booking-api";
5
+ import {
6
+ pricingV2QuoteTotal,
7
+ round2,
8
+ } from "../../lib/booking/checkout-breakdown";
9
+ import type { CheckoutModalSummaryOverride } from "./CheckoutModal";
10
+ import type { PriceSummaryLine } from "./PriceSummary";
11
+
12
+ function finiteNumber(value: unknown): number | null {
13
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
14
+ }
15
+
16
+ function normalizedLineType(type?: string | null): string {
17
+ const normalized = (type ?? "").trim().toUpperCase();
18
+ if (!normalized) return "FEE";
19
+ if (normalized === "RETURN") return "RETURN_OPTION";
20
+ if (normalized === "RETURNOPTION") return "RETURN_OPTION";
21
+ return normalized;
22
+ }
23
+
24
+ function fallbackLabel(type: string): string {
25
+ switch (type) {
26
+ case "TAX":
27
+ return "Taxes and fees";
28
+ case "PROMO_CODE":
29
+ case "DISCOUNT":
30
+ case "DEAL":
31
+ return "Discount";
32
+ case "GIFT_CARD":
33
+ case "PAYMENT_CREDIT":
34
+ return "Gift card comp";
35
+ case "RETURN_OPTION":
36
+ return "Return option";
37
+ case "CANCELLATION_UPGRADE":
38
+ return "Cancellation upgrade";
39
+ case "TICKET":
40
+ return "Ticket";
41
+ default:
42
+ return "Fee";
43
+ }
44
+ }
45
+
46
+ function priceSummaryLineFromQuoteLine(
47
+ line: PricingV2QuoteLineSnapshot,
48
+ index: number,
49
+ ): PriceSummaryLine | null {
50
+ if (line.billable === false) return null;
51
+ const amount = finiteNumber(line.amount);
52
+ if (amount == null) return null;
53
+
54
+ const type = normalizedLineType(line.type);
55
+ const label = line.label?.trim() || fallbackLabel(type);
56
+ const quantity = finiteNumber(line.quantity);
57
+
58
+ if (type === "TICKET") {
59
+ return {
60
+ kind: "ticket",
61
+ lineKey: line.lineId ?? `pricing-v2-ticket-${index}`,
62
+ category: label,
63
+ qty: Math.max(1, Math.round(quantity ?? 1)),
64
+ itemTotal: round2(amount),
65
+ breakdown: null,
66
+ };
67
+ }
68
+
69
+ return {
70
+ kind: "line",
71
+ lineKey: line.lineId ?? `pricing-v2-line-${index}`,
72
+ label,
73
+ amount: round2(amount),
74
+ type,
75
+ quantity: quantity == null ? undefined : Math.max(1, Math.round(quantity)),
76
+ };
77
+ }
78
+
79
+ export function buildCheckoutModalSummaryFromPricingV2Quote(
80
+ quote: PricingV2QuoteSnapshot | null | undefined,
81
+ roundingLabel: string,
82
+ ): CheckoutModalSummaryOverride | undefined {
83
+ const total = pricingV2QuoteTotal(quote);
84
+ if (total == null) return undefined;
85
+
86
+ const lines =
87
+ quote?.lines
88
+ ?.map((line, index) => priceSummaryLineFromQuoteLine(line, index))
89
+ .filter((line): line is PriceSummaryLine => line != null) ?? [];
90
+
91
+ if (lines.length === 0) return undefined;
92
+
93
+ const lineTotal = round2(
94
+ lines.reduce((sum, line) => {
95
+ if (line.kind === "ticket") return sum + line.itemTotal;
96
+ return sum + line.amount;
97
+ }, 0),
98
+ );
99
+ const roundingDelta = round2(total - lineTotal);
100
+ if (Math.abs(roundingDelta) > 0.02) {
101
+ lines.push({
102
+ kind: "line",
103
+ lineKey: "pricing-v2-rounding",
104
+ label: roundingLabel,
105
+ amount: roundingDelta,
106
+ type: "ROUNDING",
107
+ });
108
+ }
109
+
110
+ const hasTaxLine = lines.some(
111
+ (line) => line.kind === "line" && normalizedLineType(line.type) === "TAX",
112
+ );
113
+ const hasDiscountLine = lines.some((line) => {
114
+ if (line.kind !== "line") return false;
115
+ const type = normalizedLineType(line.type);
116
+ return (
117
+ line.amount < 0 ||
118
+ type === "PROMO_CODE" ||
119
+ type === "DISCOUNT" ||
120
+ type === "DEAL" ||
121
+ type === "GIFT_CARD" ||
122
+ type === "PAYMENT_CREDIT"
123
+ );
124
+ });
125
+ const subtotal = finiteNumber(
126
+ quote?.netSubtotalBeforeTax ?? quote?.subtotal ?? quote?.grossSubtotal,
127
+ );
128
+ const taxAmount = hasTaxLine
129
+ ? 0
130
+ : Math.max(0, finiteNumber(quote?.taxAmount ?? quote?.tax) ?? 0);
131
+ const discountAmount = hasDiscountLine
132
+ ? 0
133
+ : Math.max(0, finiteNumber(quote?.discountTotal) ?? 0);
134
+
135
+ return {
136
+ lines,
137
+ subtotal: subtotal == null ? undefined : round2(subtotal),
138
+ taxAmount: round2(taxAmount),
139
+ discountAmount: round2(discountAmount),
140
+ fullTotalAmount: round2(
141
+ finiteNumber(quote?.payableTotal ?? quote?.totalAmount) ?? total,
142
+ ),
143
+ };
144
+ }
@@ -0,0 +1,111 @@
1
+ import { parseISO } from 'date-fns';
2
+ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
3
+
4
+ import type { Availability } from '../../lib/booking-api';
5
+ import {
6
+ isDateAfterPublicBookingCutoff,
7
+ resolvePublicBookingCutoffMinutes,
8
+ } from '../../lib/booking/booking-cutoffs';
9
+ import type { Currency } from './CurrencySwitcher';
10
+
11
+ const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
12
+
13
+ export function roundMoney(amount: number): number {
14
+ return Math.round(amount * 100) / 100;
15
+ }
16
+
17
+ export function parseAvailabilityDateTime(value: string): Date {
18
+ // If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
19
+ const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
20
+ return parseISO(hasExplicitOffset ? value : `${value}Z`);
21
+ }
22
+
23
+ export function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
24
+ return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
25
+ }
26
+
27
+ export function privateShuttleStartTimeAllowed(
28
+ dateStr: string,
29
+ timeStr: string,
30
+ timezone: string,
31
+ now: Date,
32
+ cutoffMinutes?: number | null,
33
+ ): boolean {
34
+ return isDateAfterPublicBookingCutoff(
35
+ privateShuttleStartDateTime(dateStr, timeStr, timezone),
36
+ now,
37
+ cutoffMinutes,
38
+ );
39
+ }
40
+
41
+ export function privateShuttleBookingCutoffMessage(cutoffMinutes?: number | null): string {
42
+ const resolvedCutoffMinutes = resolvePublicBookingCutoffMinutes(cutoffMinutes);
43
+ if (resolvedCutoffMinutes % 60 === 0) {
44
+ return `Private shuttles must be booked at least ${resolvedCutoffMinutes / 60} hours before departure.`;
45
+ }
46
+ return `Private shuttles must be booked at least ${resolvedCutoffMinutes} minutes before departure.`;
47
+ }
48
+
49
+ export function privateShuttleAvailabilityDateInZone(
50
+ availability: Availability,
51
+ timezone: string,
52
+ ): string {
53
+ return DATE_ONLY_REGEX.test(availability.dateTime)
54
+ ? availability.dateTime
55
+ : formatInTimeZone(parseAvailabilityDateTime(availability.dateTime), timezone, 'yyyy-MM-dd');
56
+ }
57
+
58
+ export function findMergedPrivateShuttleAvailability(
59
+ merged: Availability[],
60
+ sel: Availability | null,
61
+ optionId: string | null,
62
+ selectedDateStr: string | null,
63
+ tz: string
64
+ ): Availability | undefined {
65
+ if (!sel || !optionId || !selectedDateStr) return undefined;
66
+ const oidMatches = (a: Availability) => getPrivateShuttleAvailabilityOptionId(a) === optionId;
67
+ const availId = sel.availabilityId?.trim();
68
+ if (availId) {
69
+ const hit = merged.find((a) => a.availabilityId === availId && oidMatches(a));
70
+ if (hit) return hit;
71
+ }
72
+ const onDay = merged.filter((a) => {
73
+ if (!oidMatches(a)) return false;
74
+ return privateShuttleAvailabilityDateInZone(a, tz) === selectedDateStr;
75
+ });
76
+ return onDay[0];
77
+ }
78
+
79
+ export function getPrivateShuttleAvailabilityOptionId(availability: Availability): string {
80
+ const productOptionId = availability.productOptionId?.trim();
81
+ if (productOptionId) return productOptionId;
82
+ const productId = availability.productId?.trim();
83
+ return productId?.startsWith('po_') ? productId : '';
84
+ }
85
+
86
+ export function getPrivateShuttleAvailabilityCacheKey(availability: Availability): string {
87
+ return `${availability.dateTime}-${getPrivateShuttleAvailabilityOptionId(availability)}`;
88
+ }
89
+
90
+ export function getResourceRate(availability: Availability | undefined | null) {
91
+ return availability?.rates?.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
92
+ }
93
+
94
+ export function applyResourceAdjustments(
95
+ basePrice: number,
96
+ availability: Availability | undefined | null,
97
+ currency: Currency
98
+ ): number {
99
+ const resourceRate = getResourceRate(availability);
100
+ const adjustmentTotal =
101
+ resourceRate?.appliedAdjustments?.reduce(
102
+ (sum, adjustment) => sum + (adjustment.changeByCurrency?.[currency] ?? 0),
103
+ 0
104
+ ) ??
105
+ resourceRate?.applied_adjustments?.reduce(
106
+ (sum, adjustment) => sum + (adjustment.changeByCurrency?.[currency] ?? 0),
107
+ 0
108
+ ) ??
109
+ 0;
110
+ return basePrice + adjustmentTotal;
111
+ }