@ticketboothapp/booking 1.2.123 → 1.2.125
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.
- package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +651 -6202
- package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
- package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
- package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
- package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
- package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
- package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
- package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
- package/src/components/booking/CheckoutModal.tsx +68 -49
- package/src/components/booking/NewBookingFlow.tsx +485 -3505
- package/src/components/booking/PriceBreakdown.tsx +17 -3
- package/src/components/booking/PriceSummary.tsx +21 -3
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
- package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
- package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
- package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
- package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
- package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
- package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
- package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
- package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
- package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
- package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
- package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
- package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
- package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
- package/src/components/booking/admin-change-provider-payload.ts +101 -0
- package/src/components/booking/change-booking-checkout-builders.ts +185 -0
- package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
- package/src/components/booking/change-booking-flow-helpers.ts +706 -0
- package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
- package/src/components/booking/change-booking-quote-guards.ts +223 -0
- package/src/components/booking/change-booking-quote-state.ts +64 -0
- package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
- package/src/components/booking/private-shuttle-availability.ts +111 -0
- package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
- package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
- package/src/components/booking/standard-booking-availability.ts +305 -0
- package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
- package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
- package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
- package/src/components/booking/use-private-shuttle-availability.ts +602 -0
- package/src/components/booking/use-standard-booking-availability.ts +806 -0
- package/src/components/booking/useActiveProductOptions.ts +26 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
- package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
- package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
- package/src/components/booking/useAdminChangeProductReset.ts +60 -0
- package/src/components/booking/useAdminChangeProducts.ts +102 -0
- package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
- package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
- package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
- package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
- package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
- package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
- package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
- package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
- package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
- package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
- package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
- package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
- package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
- package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
- package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
- package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
- package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
- package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
- package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
- package/src/components/booking/useChangeBookingSelection.ts +362 -0
- package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
- package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
- package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
- package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
- package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
- package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
- package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
- package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
- package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
- package/src/components/booking/useStandardBookingPromoController.ts +258 -0
- package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
- package/src/data/products-config.json +1 -1
- package/src/lib/booking/change-booking-server-preview.ts +134 -20
- package/src/lib/booking/change-flow-pricing.ts +31 -11
- package/src/lib/booking/checkout-breakdown.ts +66 -1
- package/src/lib/booking/pricing-config.ts +10 -0
- package/src/lib/booking/source-metadata.ts +58 -0
- package/src/lib/booking-api.ts +155 -3
- package/src/lib/pricing-v2-shadow.ts +272 -0
- 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
|
+
}
|