@ticketboothapp/booking 1.2.174 → 1.2.176
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/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +25 -15
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +14 -7
- package/src/components/booking/ChangeBookingFlow.tsx +13 -1
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +43 -13
- package/src/components/booking/admin-change-checkout-message.ts +34 -0
- package/src/components/booking/admin-change-quote-request-key.ts +17 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +14 -0
- package/src/components/booking/change-booking-error-message.ts +26 -4
- package/src/components/booking/private-shuttle-availability.ts +15 -1
- package/src/components/booking/private-shuttle-passenger-count.ts +50 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +9 -1
- package/src/components/booking/useAdminChangeCheckoutController.ts +5 -35
- package/src/components/booking/useAdminChangeQuotePreview.ts +53 -17
- package/test/change-booking-helpers.test.ts +156 -19
package/package.json
CHANGED
|
@@ -833,6 +833,28 @@ export function AdminChangeBookingFlow({
|
|
|
833
833
|
latestChangeQuote?.allowedRefundDispositionsByOperationId,
|
|
834
834
|
latestChangeQuote?.removedValueByOperationId,
|
|
835
835
|
]);
|
|
836
|
+
useEffect(() => {
|
|
837
|
+
if (!isProviderDashboardChange || responseRefundDecisionOperations.length === 0) return;
|
|
838
|
+
setRefundDispositionsByOperationId((current) =>
|
|
839
|
+
defaultPendingRefundDispositions(responseRefundDecisionOperations, current),
|
|
840
|
+
);
|
|
841
|
+
}, [isProviderDashboardChange, responseRefundDecisionOperations]);
|
|
842
|
+
const handleRefundDecisionDiscovery = useCallback((quote: AdminChangeLatestQuote) => {
|
|
843
|
+
const operations = quote.refundDecisionOperations ?? [];
|
|
844
|
+
if (operations.length === 0) return;
|
|
845
|
+
setRefundDecisionContext((current) => mergeAdminRefundDecisionContext({
|
|
846
|
+
current,
|
|
847
|
+
selectionKey: refundDecisionSelectionKey,
|
|
848
|
+
operations,
|
|
849
|
+
allowedByOperationId: quote.allowedRefundDispositionsByOperationId ?? {},
|
|
850
|
+
removedValueByOperationId: quote.removedValueByOperationId ?? {},
|
|
851
|
+
}));
|
|
852
|
+
if (isProviderDashboardChange) {
|
|
853
|
+
setRefundDispositionsByOperationId((current) =>
|
|
854
|
+
defaultPendingRefundDispositions(operations, current),
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}, [isProviderDashboardChange, refundDecisionSelectionKey]);
|
|
836
858
|
const activeRefundDecisionContext = useMemo(() => mergeAdminRefundDecisionContext({
|
|
837
859
|
current: refundDecisionContext,
|
|
838
860
|
selectionKey: refundDecisionSelectionKey,
|
|
@@ -851,19 +873,9 @@ export function AdminChangeBookingFlow({
|
|
|
851
873
|
? responseReturnPriceTreatmentOperations
|
|
852
874
|
: [];
|
|
853
875
|
const activeRefundDispositionsByOperationId = useMemo(() => {
|
|
854
|
-
// Provider changes default discovered refund operations to pending at
|
|
855
|
-
// settlement time. Do not write those response-derived defaults into the
|
|
856
|
-
// explicit input state: doing so changes the request key after the first
|
|
857
|
-
// quote and immediately triggers an identical second quote.
|
|
858
|
-
const dispositionsWithPendingRefundDefaults = isProviderDashboardChange
|
|
859
|
-
? defaultPendingRefundDispositions(
|
|
860
|
-
activeRefundDecisionContext?.operations ?? [],
|
|
861
|
-
refundDispositionsByOperationId,
|
|
862
|
-
)
|
|
863
|
-
: refundDispositionsByOperationId;
|
|
864
876
|
const dispositionsWithReturnPriceDefaults = defaultKeepOriginalReturnPriceDispositions(
|
|
865
877
|
activeReturnPriceTreatmentOperations,
|
|
866
|
-
|
|
878
|
+
refundDispositionsByOperationId,
|
|
867
879
|
);
|
|
868
880
|
return selectRefundDispositionsForOperations(
|
|
869
881
|
activeRefundDecisionContext,
|
|
@@ -873,7 +885,6 @@ export function AdminChangeBookingFlow({
|
|
|
873
885
|
}, [
|
|
874
886
|
activeRefundDecisionContext?.operations,
|
|
875
887
|
activeReturnPriceTreatmentOperations,
|
|
876
|
-
isProviderDashboardChange,
|
|
877
888
|
refundDispositionsByOperationId,
|
|
878
889
|
]);
|
|
879
890
|
const handleRefundDispositionChange = useCallback((
|
|
@@ -915,12 +926,11 @@ export function AdminChangeBookingFlow({
|
|
|
915
926
|
adminReverseAdjustmentIds,
|
|
916
927
|
needsAdjustmentDiscovery:
|
|
917
928
|
isProviderDashboardChange && !hasHydratedExistingAdjustments,
|
|
918
|
-
|
|
919
|
-
// defaults are derived separately for settlement/submission below.
|
|
920
|
-
refundDispositionsByOperationId,
|
|
929
|
+
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
921
930
|
useAdminFeAuthoritativeQuote,
|
|
922
931
|
latestChangeQuote,
|
|
923
932
|
setLatestChangeQuote,
|
|
933
|
+
onRefundDecisionDiscovery: handleRefundDecisionDiscovery,
|
|
924
934
|
changeQuoteLoading,
|
|
925
935
|
setChangeQuoteLoading,
|
|
926
936
|
changeQuoteFetchError,
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
AdminChangePricingMessages,
|
|
22
22
|
type AdminCustomReceiptLine,
|
|
23
23
|
} from './AdminChangePricingPanel';
|
|
24
|
+
import {
|
|
25
|
+
adminChangeSettlementSummaryLabel,
|
|
26
|
+
dedupeAdminChangeCheckoutError,
|
|
27
|
+
} from './admin-change-checkout-message';
|
|
24
28
|
|
|
25
29
|
type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
26
30
|
|
|
@@ -213,12 +217,15 @@ export function AdminChangeCheckoutPanel({
|
|
|
213
217
|
lineLabelInputs,
|
|
214
218
|
}: AdminChangeCheckoutPanelProps) {
|
|
215
219
|
const authoritativeAmountDue = serverAmountDue ?? changeFlowAmountDue;
|
|
216
|
-
const totalSummaryLabel =
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
const totalSummaryLabel = adminChangeSettlementSummaryLabel({
|
|
221
|
+
amountDue: authoritativeAmountDue,
|
|
222
|
+
refundCandidate: serverRefundCandidate ?? 0,
|
|
223
|
+
serverQuoteError,
|
|
224
|
+
});
|
|
225
|
+
const checkoutError = dedupeAdminChangeCheckoutError({
|
|
226
|
+
checkoutError: error,
|
|
227
|
+
serverQuoteError,
|
|
228
|
+
});
|
|
222
229
|
const pricingAdjustments = (
|
|
223
230
|
<AdminChangePricingAdjustments
|
|
224
231
|
showChangeFlowManualPriceLines={showChangeFlowManualPriceLines}
|
|
@@ -346,7 +353,7 @@ export function AdminChangeCheckoutPanel({
|
|
|
346
353
|
disableAutoCommunications={disableAutoCommunications}
|
|
347
354
|
onSkipConfirmationChange={onSkipConfirmationChange}
|
|
348
355
|
onDisableCommunicationsChange={onDisableCommunicationsChange}
|
|
349
|
-
error={
|
|
356
|
+
error={checkoutError}
|
|
350
357
|
loading={loading}
|
|
351
358
|
totalQuantity={totalQuantity}
|
|
352
359
|
onCheckout={onCheckout}
|
|
@@ -61,6 +61,7 @@ import { useBookingQuantityCapTrim } from './useBookingQuantityCapTrim';
|
|
|
61
61
|
import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
|
|
62
62
|
import type { ChangeBookingMergedQuoteState } from './change-booking-quote-state';
|
|
63
63
|
import { AdminChangeReceiptComparison } from './AdminChangeReceiptComparison';
|
|
64
|
+
import { dedupeChangeBookingCheckoutError } from './change-booking-error-message';
|
|
64
65
|
|
|
65
66
|
export type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
66
67
|
|
|
@@ -668,6 +669,17 @@ export function ChangeBookingFlow({
|
|
|
668
669
|
onChangeFlowSelectionPreview,
|
|
669
670
|
onPricePreviewChange,
|
|
670
671
|
});
|
|
672
|
+
const displayedQuoteError = selfServeCheckoutPlaceholder
|
|
673
|
+
? changeQuoteFetchError ?? (
|
|
674
|
+
latestChangeQuote?.canProceed === false
|
|
675
|
+
? latestChangeQuote.reasonIfBlocked
|
|
676
|
+
: null
|
|
677
|
+
)
|
|
678
|
+
: null;
|
|
679
|
+
const visibleCheckoutFormError = dedupeChangeBookingCheckoutError({
|
|
680
|
+
checkoutError: checkoutFormError,
|
|
681
|
+
displayedQuoteError,
|
|
682
|
+
});
|
|
671
683
|
/** Partner deferred-invoice path applies to {@link NewBookingFlow} only. */
|
|
672
684
|
const deferredInvoiceSubmitLabel = undefined;
|
|
673
685
|
|
|
@@ -975,7 +987,7 @@ export function ChangeBookingFlow({
|
|
|
975
987
|
termsAccepted={termsAccepted}
|
|
976
988
|
onTermsAcceptedChange={setTermsAccepted}
|
|
977
989
|
onTermsAcceptedAtChange={setTermsAcceptedAt}
|
|
978
|
-
error={
|
|
990
|
+
error={visibleCheckoutFormError}
|
|
979
991
|
loading={loading}
|
|
980
992
|
totalQuantity={totalQuantity}
|
|
981
993
|
onCheckout={handleCheckout}
|
|
@@ -32,6 +32,7 @@ import { PrivateShuttleStartTimeSection } from './PrivateShuttleStartTimeSection
|
|
|
32
32
|
import {
|
|
33
33
|
getPrivateShuttleAvailabilityOptionId,
|
|
34
34
|
privateShuttleBookingCutoffMessage,
|
|
35
|
+
privateShuttleEndTimeLabel,
|
|
35
36
|
privateShuttleInitialDateKey,
|
|
36
37
|
privateShuttleStartDateTime,
|
|
37
38
|
privateShuttleStartTimeAllowed,
|
|
@@ -53,6 +54,10 @@ import {
|
|
|
53
54
|
import { usePrivateShuttlePromoController } from './usePrivateShuttlePromoController';
|
|
54
55
|
import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
|
|
55
56
|
import { resolvePrivateShuttleCancellationPolicyId } from './private-shuttle-cancellation-policy';
|
|
57
|
+
import {
|
|
58
|
+
privateShuttleTermsInitiallyAccepted,
|
|
59
|
+
resolveInitialPrivateShuttlePassengerCount,
|
|
60
|
+
} from './private-shuttle-passenger-count';
|
|
56
61
|
|
|
57
62
|
interface PrivateShuttleBookingFlowProps {
|
|
58
63
|
product: Product;
|
|
@@ -144,7 +149,11 @@ export function PrivateShuttleBookingFlow({
|
|
|
144
149
|
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
|
|
145
150
|
const [isCustomTimeMode, setIsCustomTimeMode] = useState(false);
|
|
146
151
|
const [passengerCount, setPassengerCount] = useState<number>(
|
|
147
|
-
() =>
|
|
152
|
+
() => resolveInitialPrivateShuttlePassengerCount({
|
|
153
|
+
privateShuttleDetails: initialBooking?.privateShuttleDetails,
|
|
154
|
+
bookingItems: initialBooking?.bookingItems,
|
|
155
|
+
initialPassengers: initialValues?.passengers,
|
|
156
|
+
})
|
|
148
157
|
);
|
|
149
158
|
const [email, setEmail] = useState(() => initialBooking?.customer?.email?.trim() ?? '');
|
|
150
159
|
const [firstName, setFirstName] = useState(
|
|
@@ -181,7 +190,9 @@ export function PrivateShuttleBookingFlow({
|
|
|
181
190
|
const [loading, setLoading] = useState(false);
|
|
182
191
|
const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
|
|
183
192
|
const [error, setError] = useState('');
|
|
184
|
-
const [termsAccepted, setTermsAccepted] = useState(
|
|
193
|
+
const [termsAccepted, setTermsAccepted] = useState(
|
|
194
|
+
() => privateShuttleTermsInitiallyAccepted(isProviderChangeMode)
|
|
195
|
+
);
|
|
185
196
|
const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
|
|
186
197
|
const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
|
|
187
198
|
const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
|
|
@@ -634,6 +645,17 @@ export function PrivateShuttleBookingFlow({
|
|
|
634
645
|
timesNote?: string;
|
|
635
646
|
isProposedStops?: boolean;
|
|
636
647
|
}> = [];
|
|
648
|
+
const pickupLabel = pickupLocationSkipped
|
|
649
|
+
? (t('booking.pickupLocationUnknown') || "I don't know")
|
|
650
|
+
: customPickupAddress
|
|
651
|
+
? customPickupAddress
|
|
652
|
+
: selectedPickupLocation?.name;
|
|
653
|
+
items.push({
|
|
654
|
+
time: selectedStartTime,
|
|
655
|
+
label: pickupLabel || (t('booking.pickupAt') || 'Pickup'),
|
|
656
|
+
clickableLabel: !pickupLocationId && !customPickupAddress && !pickupLocationSkipped ? (t('pickup.title') || 'Select pickup') : undefined,
|
|
657
|
+
prefix: t('booking.pickupAtPrefix') || 'Pickup at ',
|
|
658
|
+
});
|
|
637
659
|
const destMap = product.itineraryBuilder?.destinations
|
|
638
660
|
? new Map(product.itineraryBuilder.destinations.map((d) => [d.id, d.label]))
|
|
639
661
|
: new Map<string, string>();
|
|
@@ -648,27 +670,35 @@ export function PrivateShuttleBookingFlow({
|
|
|
648
670
|
timesNote: t('booking.orderAndTimesToBeConfirmed') || '(order and times to be confirmed)',
|
|
649
671
|
});
|
|
650
672
|
}
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
673
|
+
const durationMinutes =
|
|
674
|
+
(privateShuttleConfig?.baseDurationMinutes ?? 0) + additionalHoursCount * 60;
|
|
675
|
+
const dropOffTime = privateShuttleEndTimeLabel(
|
|
676
|
+
selectedDate,
|
|
677
|
+
selectedStartTime,
|
|
678
|
+
companyTimezone,
|
|
679
|
+
durationMinutes,
|
|
680
|
+
);
|
|
681
|
+
if (dropOffTime) {
|
|
682
|
+
items.push({
|
|
683
|
+
time: dropOffTime,
|
|
684
|
+
label: pickupLabel || (t('booking.pickupAt') || 'Pickup'),
|
|
685
|
+
prefix: t('booking.dropOffAtPrefix') || 'Drop off at ',
|
|
686
|
+
});
|
|
687
|
+
}
|
|
662
688
|
return items;
|
|
663
689
|
}, [
|
|
664
690
|
selectedOption,
|
|
665
691
|
selectedStartTime,
|
|
692
|
+
selectedDate,
|
|
666
693
|
draftItineraryDestinations,
|
|
667
694
|
customPickupAddress,
|
|
668
695
|
selectedPickupLocation,
|
|
669
696
|
pickupLocationSkipped,
|
|
670
697
|
pickupLocationId,
|
|
671
698
|
product.itineraryBuilder,
|
|
699
|
+
privateShuttleConfig?.baseDurationMinutes,
|
|
700
|
+
additionalHoursCount,
|
|
701
|
+
companyTimezone,
|
|
672
702
|
t,
|
|
673
703
|
]);
|
|
674
704
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
2
|
+
|
|
3
|
+
interface AdminChangeSettlementSummaryLabelInput {
|
|
4
|
+
amountDue: number;
|
|
5
|
+
refundCandidate: number;
|
|
6
|
+
serverQuoteError?: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function adminChangeSettlementSummaryLabel({
|
|
10
|
+
amountDue,
|
|
11
|
+
refundCandidate,
|
|
12
|
+
serverQuoteError,
|
|
13
|
+
}: AdminChangeSettlementSummaryLabelInput): string {
|
|
14
|
+
if (friendlyChangeBookingError(serverQuoteError)) return 'Price unavailable';
|
|
15
|
+
if (refundCandidate > 0.005) return 'Amount to refund customer';
|
|
16
|
+
if (amountDue > 0.005) return 'Amount due from customer';
|
|
17
|
+
return 'No payment or refund due';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AdminChangeCheckoutErrorInput {
|
|
21
|
+
checkoutError: string;
|
|
22
|
+
serverQuoteError?: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function dedupeAdminChangeCheckoutError({
|
|
26
|
+
checkoutError,
|
|
27
|
+
serverQuoteError,
|
|
28
|
+
}: AdminChangeCheckoutErrorInput): string {
|
|
29
|
+
const readableCheckoutError = friendlyChangeBookingError(checkoutError);
|
|
30
|
+
const readableServerQuoteError = friendlyChangeBookingError(serverQuoteError);
|
|
31
|
+
return readableServerQuoteError && readableCheckoutError === readableServerQuoteError
|
|
32
|
+
? ''
|
|
33
|
+
: checkoutError;
|
|
34
|
+
}
|
|
@@ -29,6 +29,12 @@ interface AdminChangeQuoteRequestGateInput {
|
|
|
29
29
|
missingRequiredReturnSelection: boolean;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
interface AdminRefundDecisionDiscoveryInput {
|
|
33
|
+
isProviderDashboardChange: boolean;
|
|
34
|
+
canProceed: boolean | undefined;
|
|
35
|
+
refundDecisionCount: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
/**
|
|
33
39
|
* Whether the current admin inputs are complete enough to request an authoritative quote.
|
|
34
40
|
* `hasEffectiveChangeSelection` deliberately includes adjustment-only changes, even when
|
|
@@ -83,3 +89,14 @@ export function canReuseCompletedAdminChangeQuote(
|
|
|
83
89
|
): boolean {
|
|
84
90
|
return Boolean(inputKey) && lastCompletedInputKey === inputKey && hasPublishedQuote;
|
|
85
91
|
}
|
|
92
|
+
|
|
93
|
+
/** A blocked quote with decision operations is protocol state, not display state. */
|
|
94
|
+
export function isAdminRefundDecisionDiscoveryQuote(
|
|
95
|
+
input: AdminRefundDecisionDiscoveryInput,
|
|
96
|
+
): boolean {
|
|
97
|
+
return Boolean(
|
|
98
|
+
input.isProviderDashboardChange &&
|
|
99
|
+
input.canProceed === false &&
|
|
100
|
+
input.refundDecisionCount > 0,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -107,3 +107,17 @@ export async function quoteAdminChangeV2ForUi(
|
|
|
107
107
|
const quote = await quoteFn(request);
|
|
108
108
|
return sliceAndMergeChangeQuoteForUi(quote, fallbackCart, currency).mergedQuote;
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checkout must apply the immutable quote the operator reviewed in the UI. Re-quoting during
|
|
113
|
+
* submit creates a different quote identity and breaks the review/apply audit trail even when
|
|
114
|
+
* the inputs and totals happen to be identical.
|
|
115
|
+
*/
|
|
116
|
+
export function requireReviewedAdminChangeQuoteForCheckout<
|
|
117
|
+
T extends { quoteId?: string; canProceed: boolean; reasonIfBlocked?: string },
|
|
118
|
+
>(quote: T | null): T {
|
|
119
|
+
if (!quote?.quoteId?.trim() || quote.canProceed === false) {
|
|
120
|
+
throw new Error(quote?.reasonIfBlocked || 'The reviewed change quote is not ready.');
|
|
121
|
+
}
|
|
122
|
+
return quote;
|
|
123
|
+
}
|
|
@@ -17,6 +17,8 @@ const CHANGE_BOOKING_ERROR_MESSAGES: Record<string, string> = {
|
|
|
17
17
|
'A confirmed price is not available for the selected return. Choose another return time.',
|
|
18
18
|
pricing_v2_itinerary_not_available:
|
|
19
19
|
'An itinerary is not available for this selection. Choose another date, time, or pickup location.',
|
|
20
|
+
private_shuttle_itinerary_endpoints_required:
|
|
21
|
+
'The private-tour itinerary is incomplete. Reopen the change and select the pickup time and location again.',
|
|
20
22
|
pricing_v2_invalid_additional_hours:
|
|
21
23
|
'Review the number of additional hours before continuing.',
|
|
22
24
|
pricing_v2_additional_hours_not_supported:
|
|
@@ -54,11 +56,11 @@ const CHANGE_BOOKING_ERROR_MESSAGES: Record<string, string> = {
|
|
|
54
56
|
amendment_quote_blocked:
|
|
55
57
|
'This booking change cannot be completed with the selected options.',
|
|
56
58
|
admin_ledger_requires_review_or_repair:
|
|
57
|
-
|
|
59
|
+
"This older booking's pricing and payment history must be reviewed before it can be changed. Please contact support.",
|
|
58
60
|
amendment_ledger_summary_missing:
|
|
59
|
-
|
|
61
|
+
"This older booking hasn't been prepared for changes yet. Please contact support to prepare it, then reopen the booking.",
|
|
60
62
|
amendment_ledger_components_missing:
|
|
61
|
-
|
|
63
|
+
"This older booking's pricing details are incomplete, so it can't be changed safely. Please contact support.",
|
|
62
64
|
booking_amendment_ledger_pointer_mismatch:
|
|
63
65
|
'This booking changed after the page was opened. Refresh it before trying again.',
|
|
64
66
|
promo_change_only_no_eligible_new_value:
|
|
@@ -91,7 +93,7 @@ export function incompatibleAddOnChangeError(
|
|
|
91
93
|
|
|
92
94
|
function errorCodeFromMessage(message: string): string | null {
|
|
93
95
|
const match = message.match(
|
|
94
|
-
/\b(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|quote|structured_adjustment)_[a-z0-9_]+/i,
|
|
96
|
+
/\b(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|private_shuttle|quote|structured_adjustment)_[a-z0-9_]+/i,
|
|
95
97
|
);
|
|
96
98
|
return match?.[0]?.toLowerCase() ?? null;
|
|
97
99
|
}
|
|
@@ -135,3 +137,23 @@ export function friendlyChangeBookingError(message: string | null | undefined):
|
|
|
135
137
|
|
|
136
138
|
return 'This booking change cannot be completed with the selected options. Review the selections and try again.';
|
|
137
139
|
}
|
|
140
|
+
|
|
141
|
+
interface ChangeBookingCheckoutErrorInput {
|
|
142
|
+
checkoutError: string;
|
|
143
|
+
displayedQuoteError?: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The quote-status panel already renders blocked/fetch errors beside the price.
|
|
148
|
+
* Do not repeat that same friendly message in the checkout form's error slot.
|
|
149
|
+
*/
|
|
150
|
+
export function dedupeChangeBookingCheckoutError({
|
|
151
|
+
checkoutError,
|
|
152
|
+
displayedQuoteError,
|
|
153
|
+
}: ChangeBookingCheckoutErrorInput): string {
|
|
154
|
+
const readableCheckoutError = friendlyChangeBookingError(checkoutError);
|
|
155
|
+
const readableDisplayedQuoteError = friendlyChangeBookingError(displayedQuoteError);
|
|
156
|
+
return readableDisplayedQuoteError && readableCheckoutError === readableDisplayedQuoteError
|
|
157
|
+
? ''
|
|
158
|
+
: checkoutError;
|
|
159
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseISO } from 'date-fns';
|
|
1
|
+
import { addMinutes, parseISO } from 'date-fns';
|
|
2
2
|
import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
|
|
3
3
|
|
|
4
4
|
import type { Availability } from '../../lib/booking-api';
|
|
@@ -38,6 +38,20 @@ export function privateShuttleStartDateTime(dateStr: string, timeStr: string, ti
|
|
|
38
38
|
return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function privateShuttleEndTimeLabel(
|
|
42
|
+
dateStr: string,
|
|
43
|
+
timeStr: string,
|
|
44
|
+
timezone: string,
|
|
45
|
+
durationMinutes: number,
|
|
46
|
+
): string | null {
|
|
47
|
+
if (!dateStr || !timeStr || !Number.isFinite(durationMinutes) || durationMinutes <= 0) return null;
|
|
48
|
+
return formatInTimeZone(
|
|
49
|
+
addMinutes(privateShuttleStartDateTime(dateStr, timeStr, timezone), durationMinutes),
|
|
50
|
+
timezone,
|
|
51
|
+
'HH:mm',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
export function privateShuttleStartTimeAllowed(
|
|
42
56
|
dateStr: string,
|
|
43
57
|
timeStr: string,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type InitialPrivateShuttlePassengerCountInput = {
|
|
2
|
+
privateShuttleDetails?: {
|
|
3
|
+
passengerCount?: number | null;
|
|
4
|
+
requestedPassengerCount?: number | null;
|
|
5
|
+
} | null;
|
|
6
|
+
bookingItems?: ReadonlyArray<{
|
|
7
|
+
category?: string | null;
|
|
8
|
+
count?: number | null;
|
|
9
|
+
}> | null;
|
|
10
|
+
initialPassengers?: number | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function positiveWholeNumber(value: number | null | undefined): number | null {
|
|
14
|
+
if (!Number.isFinite(value) || Number(value) <= 0) return null;
|
|
15
|
+
return Math.floor(Number(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Seed the people count when entering the private-shuttle flow.
|
|
20
|
+
*
|
|
21
|
+
* Existing private bookings persist an explicit passenger count while standard
|
|
22
|
+
* bookings persist one booking item per passenger category. RESOURCE is the
|
|
23
|
+
* number of shuttles being sold, not the number of people, and must never be
|
|
24
|
+
* treated as a passenger count during a product-family conversion.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveInitialPrivateShuttlePassengerCount({
|
|
27
|
+
privateShuttleDetails,
|
|
28
|
+
bookingItems,
|
|
29
|
+
initialPassengers,
|
|
30
|
+
}: InitialPrivateShuttlePassengerCountInput): number {
|
|
31
|
+
const explicitPrivateCount =
|
|
32
|
+
positiveWholeNumber(privateShuttleDetails?.requestedPassengerCount) ??
|
|
33
|
+
positiveWholeNumber(privateShuttleDetails?.passengerCount);
|
|
34
|
+
if (explicitPrivateCount != null) return explicitPrivateCount;
|
|
35
|
+
|
|
36
|
+
const explicitInitialCount = positiveWholeNumber(initialPassengers);
|
|
37
|
+
if (explicitInitialCount != null) return explicitInitialCount;
|
|
38
|
+
|
|
39
|
+
const standardPassengerCount = (bookingItems ?? []).reduce((total, item) => {
|
|
40
|
+
if (item.category?.trim().toUpperCase() === 'RESOURCE') return total;
|
|
41
|
+
return total + (positiveWholeNumber(item.count) ?? 0);
|
|
42
|
+
}, 0);
|
|
43
|
+
|
|
44
|
+
return standardPassengerCount > 0 ? standardPassengerCount : 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Existing bookings already captured terms acceptance; fresh bookings must opt in. */
|
|
48
|
+
export function privateShuttleTermsInitiallyAccepted(isProviderChangeMode: boolean): boolean {
|
|
49
|
+
return isProviderChangeMode;
|
|
50
|
+
}
|
|
@@ -88,7 +88,7 @@ export function buildPrivateShuttleItineraryDisplayForStorage(
|
|
|
88
88
|
itineraryDisplayItems: PrivateShuttleCheckoutItineraryItem[]
|
|
89
89
|
): ItineraryDisplayStep[] {
|
|
90
90
|
let hasPickup = false;
|
|
91
|
-
|
|
91
|
+
const steps = itineraryDisplayItems.map((item) => {
|
|
92
92
|
if (item.isProposedStops) {
|
|
93
93
|
return {
|
|
94
94
|
stepType: ItineraryStepType.draft,
|
|
@@ -104,6 +104,14 @@ export function buildPrivateShuttleItineraryDisplayForStorage(
|
|
|
104
104
|
place: item.clickableLabel ?? item.label,
|
|
105
105
|
};
|
|
106
106
|
});
|
|
107
|
+
if (
|
|
108
|
+
steps.length > 0 &&
|
|
109
|
+
(steps[0]?.stepType !== ItineraryStepType.pickup ||
|
|
110
|
+
steps[steps.length - 1]?.stepType !== ItineraryStepType.drop_off)
|
|
111
|
+
) {
|
|
112
|
+
throw new Error('Private shuttle itinerary requires fixed pickup and drop-off endpoints.');
|
|
113
|
+
}
|
|
114
|
+
return steps;
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export async function reservePrivateShuttleForCheckout({
|
|
@@ -71,8 +71,7 @@ import {
|
|
|
71
71
|
quoteAdminCustomerChangeForCheckout,
|
|
72
72
|
} from './admin-change-customer-quote-runner';
|
|
73
73
|
import {
|
|
74
|
-
|
|
75
|
-
quoteAdminChangeV2ForUi,
|
|
74
|
+
requireReviewedAdminChangeQuoteForCheckout,
|
|
76
75
|
} from './admin-change-v2-quote-request';
|
|
77
76
|
import {
|
|
78
77
|
reservationHoldHasExpired,
|
|
@@ -482,40 +481,11 @@ export function useAdminChangeCheckoutController({
|
|
|
482
481
|
let authoritativeChangeQuoteForCheckout = latestChangeQuote;
|
|
483
482
|
|
|
484
483
|
if (isProviderDashboardChange && isChangeBookingContext) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
authoritativeChangeQuoteForCheckout =
|
|
488
|
-
|
|
489
|
-
bookingReference,
|
|
490
|
-
lastName,
|
|
491
|
-
parentProductId: product.productId,
|
|
492
|
-
optionId: availabilityProductOptionId,
|
|
493
|
-
selectedAvailability,
|
|
494
|
-
pickupLocationId,
|
|
495
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
496
|
-
bookingItems,
|
|
497
|
-
addOnSelections,
|
|
498
|
-
cancellationPolicyId,
|
|
499
|
-
promoCode: appliedPromoCode,
|
|
500
|
-
promoApplicationScope,
|
|
501
|
-
structuredAdjustments: adminStructuredAdjustments,
|
|
502
|
-
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
503
|
-
includeSelectableAdjustmentComponents: adminStructuredAdjustments.some(
|
|
504
|
-
(adjustment) => adjustment.scope === 'SELECTED_COMPONENTS',
|
|
505
|
-
),
|
|
506
|
-
refundDispositionsByOperationId,
|
|
507
|
-
previousPassengerCount: changeFlowInitialTicketCount,
|
|
508
|
-
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
509
|
-
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
510
|
-
}),
|
|
511
|
-
{
|
|
512
|
-
total: changeFlowNewBookingTotal,
|
|
513
|
-
subtotal: effectiveSubtotalForCheckout,
|
|
514
|
-
tax: effectiveTax,
|
|
515
|
-
},
|
|
516
|
-
currency,
|
|
484
|
+
// Preview owns quote creation and invalidates it whenever a quote input changes.
|
|
485
|
+
// Submit must carry that exact reviewed identity through provider apply/payment.
|
|
486
|
+
authoritativeChangeQuoteForCheckout = requireReviewedAdminChangeQuoteForCheckout(
|
|
487
|
+
latestChangeQuote,
|
|
517
488
|
);
|
|
518
|
-
setLatestChangeQuote(authoritativeChangeQuoteForCheckout);
|
|
519
489
|
} else if (isCustomerSelfServeChange) {
|
|
520
490
|
const quoteResult = await quoteAdminCustomerChangeForCheckout({
|
|
521
491
|
bookingReference: initialValues?.bookingReference,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
buildAdminChangeQuoteRequestKey,
|
|
32
32
|
canRequestAdminChangeQuote,
|
|
33
33
|
canReuseCompletedAdminChangeQuote,
|
|
34
|
+
isAdminRefundDecisionDiscoveryQuote,
|
|
34
35
|
} from './admin-change-quote-request-key';
|
|
35
36
|
import {
|
|
36
37
|
buildAdminChangeV2QuoteRequest,
|
|
@@ -84,6 +85,7 @@ export interface UseAdminChangeQuotePreviewParams {
|
|
|
84
85
|
useAdminFeAuthoritativeQuote: boolean;
|
|
85
86
|
latestChangeQuote: AdminChangeLatestQuote | null;
|
|
86
87
|
setLatestChangeQuote: Dispatch<SetStateAction<AdminChangeLatestQuote | null>>;
|
|
88
|
+
onRefundDecisionDiscovery?: (quote: AdminChangeLatestQuote) => void;
|
|
87
89
|
changeQuoteLoading: boolean;
|
|
88
90
|
setChangeQuoteLoading: Dispatch<SetStateAction<boolean>>;
|
|
89
91
|
changeQuoteFetchError: string | null;
|
|
@@ -147,6 +149,7 @@ export function useAdminChangeQuotePreview({
|
|
|
147
149
|
useAdminFeAuthoritativeQuote,
|
|
148
150
|
latestChangeQuote,
|
|
149
151
|
setLatestChangeQuote,
|
|
152
|
+
onRefundDecisionDiscovery,
|
|
150
153
|
changeQuoteLoading,
|
|
151
154
|
setChangeQuoteLoading,
|
|
152
155
|
changeQuoteFetchError,
|
|
@@ -188,17 +191,38 @@ export function useAdminChangeQuotePreview({
|
|
|
188
191
|
latestChangeQuote?.canProceed === false &&
|
|
189
192
|
(latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
|
|
190
193
|
const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
const hasPublishedChangeQuote = latestChangeQuote !== null;
|
|
195
|
+
/**
|
|
196
|
+
* Existing admin adjustments must be discoverable before the operator makes a change.
|
|
197
|
+
* Initial selection hydration can briefly leave the live availability/return unset, so use
|
|
198
|
+
* the persisted booking identities for this read-only discovery quote instead of waiting for
|
|
199
|
+
* the editable target controls to become complete.
|
|
200
|
+
*/
|
|
201
|
+
const quoteAvailability = useMemo<Availability | null>(() => {
|
|
202
|
+
if (selectedAvailability) return selectedAvailability;
|
|
203
|
+
if (!needsAdjustmentDiscovery || !initialValues?.dateTime?.trim()) return null;
|
|
204
|
+
const optionId = initialValues.productOptionId?.trim() || initialValues.productId?.trim();
|
|
205
|
+
if (!optionId) return null;
|
|
206
|
+
return {
|
|
207
|
+
dateTime: initialValues.dateTime.trim(),
|
|
208
|
+
availabilityId: initialValues.availabilityId?.trim() || undefined,
|
|
209
|
+
productOptionId: optionId,
|
|
210
|
+
vacancies: Math.max(totalQuantity, 1),
|
|
211
|
+
currency,
|
|
212
|
+
};
|
|
213
|
+
}, [
|
|
214
|
+
selectedAvailability,
|
|
215
|
+
needsAdjustmentDiscovery,
|
|
216
|
+
initialValues?.dateTime,
|
|
217
|
+
initialValues?.availabilityId,
|
|
218
|
+
initialValues?.productOptionId,
|
|
219
|
+
initialValues?.productId,
|
|
220
|
+
totalQuantity,
|
|
221
|
+
currency,
|
|
222
|
+
]);
|
|
197
223
|
const quoteReturnAvailabilityId =
|
|
198
224
|
selectedReturnOption?.returnAvailabilityId ??
|
|
199
|
-
(
|
|
200
|
-
? initialValues?.returnAvailabilityId?.trim() || null
|
|
201
|
-
: null);
|
|
225
|
+
(needsAdjustmentDiscovery ? initialValues?.returnAvailabilityId?.trim() || null : null);
|
|
202
226
|
const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
|
|
203
227
|
bookingReference: initialValues?.bookingReference,
|
|
204
228
|
lastName,
|
|
@@ -236,19 +260,16 @@ export function useAdminChangeQuotePreview({
|
|
|
236
260
|
]);
|
|
237
261
|
const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
|
|
238
262
|
const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
|
|
239
|
-
const quoteMissingRequiredReturnSelection =
|
|
263
|
+
const quoteMissingRequiredReturnSelection =
|
|
264
|
+
missingRequiredReturnSelection && !needsAdjustmentDiscovery;
|
|
240
265
|
const canRequestChangeQuote = canRequestAdminChangeQuote({
|
|
241
266
|
isCustomerSelfServeChange,
|
|
242
|
-
// Opening the change flow is not itself a change. Existing adjustment
|
|
243
|
-
// discovery can be included with the first real quote instead of issuing a
|
|
244
|
-
// quote during modal hydration.
|
|
245
267
|
hasEffectiveChangeSelection,
|
|
246
268
|
hasSelectedAvailability: quoteAvailability != null,
|
|
247
269
|
bookingReference: initialValues?.bookingReference,
|
|
248
270
|
lastName,
|
|
249
271
|
missingRequiredReturnSelection: quoteMissingRequiredReturnSelection,
|
|
250
272
|
});
|
|
251
|
-
|
|
252
273
|
const changeFlowSubmitDisabled =
|
|
253
274
|
missingRequiredReturnSelection ||
|
|
254
275
|
(isCustomerSelfServeChange &&
|
|
@@ -461,7 +482,7 @@ export function useAdminChangeQuotePreview({
|
|
|
461
482
|
if (canReuseCompletedAdminChangeQuote(
|
|
462
483
|
changeQuoteInputsKey,
|
|
463
484
|
lastCompletedQuoteInputsKeyRef.current,
|
|
464
|
-
|
|
485
|
+
hasPublishedChangeQuote,
|
|
465
486
|
)) {
|
|
466
487
|
setChangeQuoteLoading(false);
|
|
467
488
|
return;
|
|
@@ -484,6 +505,7 @@ export function useAdminChangeQuotePreview({
|
|
|
484
505
|
const bookingReferenceForQuote = initialValues.bookingReference.trim();
|
|
485
506
|
const timer = window.setTimeout(() => {
|
|
486
507
|
void (async () => {
|
|
508
|
+
let awaitingRefundDecisionRequote = false;
|
|
487
509
|
inFlightQuoteInputsKeyRef.current = changeQuoteInputsKey;
|
|
488
510
|
const bookingItems = Object.entries(quantities)
|
|
489
511
|
.filter(([, count]) => count > 0)
|
|
@@ -535,7 +557,19 @@ export function useAdminChangeQuotePreview({
|
|
|
535
557
|
// Commit the completed identity before publishing its result. This keeps a
|
|
536
558
|
// render caused by setLatestChangeQuote from being interpreted as a new request.
|
|
537
559
|
lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
|
|
538
|
-
|
|
560
|
+
awaitingRefundDecisionRequote = Boolean(
|
|
561
|
+
onRefundDecisionDiscovery &&
|
|
562
|
+
isAdminRefundDecisionDiscoveryQuote({
|
|
563
|
+
isProviderDashboardChange,
|
|
564
|
+
canProceed: mergedQuote.canProceed,
|
|
565
|
+
refundDecisionCount: mergedQuote.refundDecisionOperations?.length ?? 0,
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
if (awaitingRefundDecisionRequote) {
|
|
569
|
+
onRefundDecisionDiscovery?.(mergedQuote);
|
|
570
|
+
} else {
|
|
571
|
+
setLatestChangeQuote(mergedQuote);
|
|
572
|
+
}
|
|
539
573
|
} catch (e) {
|
|
540
574
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
541
575
|
setLatestChangeQuote(null);
|
|
@@ -545,7 +579,7 @@ export function useAdminChangeQuotePreview({
|
|
|
545
579
|
if (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
|
|
546
580
|
inFlightQuoteInputsKeyRef.current = null;
|
|
547
581
|
}
|
|
548
|
-
if (seq === changeQuoteRequestSeq.current) {
|
|
582
|
+
if (seq === changeQuoteRequestSeq.current && !awaitingRefundDecisionRequote) {
|
|
549
583
|
setChangeQuoteLoading(false);
|
|
550
584
|
}
|
|
551
585
|
}
|
|
@@ -562,6 +596,8 @@ export function useAdminChangeQuotePreview({
|
|
|
562
596
|
canRequestChangeQuote,
|
|
563
597
|
missingRequiredReturnSelection,
|
|
564
598
|
changeQuoteInputsKey,
|
|
599
|
+
hasPublishedChangeQuote,
|
|
600
|
+
onRefundDecisionDiscovery,
|
|
565
601
|
]);
|
|
566
602
|
|
|
567
603
|
return {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
buildAdminChangeQuoteRequestKey,
|
|
32
32
|
canRequestAdminChangeQuote,
|
|
33
33
|
canReuseCompletedAdminChangeQuote,
|
|
34
|
+
isAdminRefundDecisionDiscoveryQuote,
|
|
34
35
|
} from '../src/components/booking/admin-change-quote-request-key';
|
|
35
36
|
import {
|
|
36
37
|
authoritativeItineraryDisplayFromChangeQuote,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
import {
|
|
46
47
|
buildAdminChangeV2QuoteRequest,
|
|
47
48
|
quoteAdminChangeV2ForUi,
|
|
49
|
+
requireReviewedAdminChangeQuoteForCheckout,
|
|
48
50
|
} from '../src/components/booking/admin-change-v2-quote-request';
|
|
49
51
|
import {
|
|
50
52
|
noRefundRemovedValue,
|
|
@@ -63,6 +65,7 @@ import {
|
|
|
63
65
|
} from '../src/components/booking/admin-refund-decision-context';
|
|
64
66
|
import {
|
|
65
67
|
applyResourceAdjustments,
|
|
68
|
+
privateShuttleEndTimeLabel,
|
|
66
69
|
privateShuttleInitialDateKey,
|
|
67
70
|
privateShuttlePriceChanged,
|
|
68
71
|
resolveHydratedPrivateShuttleAvailability,
|
|
@@ -95,11 +98,21 @@ import {
|
|
|
95
98
|
removeAddOnSelections,
|
|
96
99
|
} from '../src/components/booking/incompatible-add-on-selections';
|
|
97
100
|
import {
|
|
101
|
+
dedupeChangeBookingCheckoutError,
|
|
98
102
|
friendlyChangeBookingError,
|
|
99
103
|
incompatibleAddOnChangeError,
|
|
100
104
|
} from '../src/components/booking/change-booking-error-message';
|
|
105
|
+
import {
|
|
106
|
+
adminChangeSettlementSummaryLabel,
|
|
107
|
+
dedupeAdminChangeCheckoutError,
|
|
108
|
+
} from '../src/components/booking/admin-change-checkout-message';
|
|
109
|
+
import { shouldRefreshAfterChangeDialogClose } from '../../../src/lib/manage-booking-post-checkout';
|
|
101
110
|
import { applyPrivateShuttleProviderChange } from '../src/components/booking/private-shuttle-provider-change-runner';
|
|
102
111
|
import { resolvePrivateShuttleCancellationPolicyId } from '../src/components/booking/private-shuttle-cancellation-policy';
|
|
112
|
+
import {
|
|
113
|
+
privateShuttleTermsInitiallyAccepted,
|
|
114
|
+
resolveInitialPrivateShuttlePassengerCount,
|
|
115
|
+
} from '../src/components/booking/private-shuttle-passenger-count';
|
|
103
116
|
import { hasEffectiveAdminChangeSelection } from '../src/components/booking/useAdminProviderPricingAdjustments';
|
|
104
117
|
import {
|
|
105
118
|
lineFromActiveAdjustment,
|
|
@@ -612,7 +625,20 @@ test('admin quote dedupe refetches unchanged inputs after the published quote is
|
|
|
612
625
|
assert.equal(canReuseCompletedAdminChangeQuote(inputKey, 'different-inputs', true), false);
|
|
613
626
|
});
|
|
614
627
|
|
|
615
|
-
test('admin
|
|
628
|
+
test('admin refund discovery response is protocol state, not display state', () => {
|
|
629
|
+
assert.equal(isAdminRefundDecisionDiscoveryQuote({
|
|
630
|
+
isProviderDashboardChange: true,
|
|
631
|
+
canProceed: false,
|
|
632
|
+
refundDecisionCount: 1,
|
|
633
|
+
}), true);
|
|
634
|
+
assert.equal(isAdminRefundDecisionDiscoveryQuote({
|
|
635
|
+
isProviderDashboardChange: true,
|
|
636
|
+
canProceed: true,
|
|
637
|
+
refundDecisionCount: 0,
|
|
638
|
+
}), false);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('admin quote gate allows adjustment-only changes', () => {
|
|
616
642
|
assert.equal(canRequestAdminChangeQuote({
|
|
617
643
|
isCustomerSelfServeChange: true,
|
|
618
644
|
hasEffectiveChangeSelection: true,
|
|
@@ -624,7 +650,6 @@ test('admin quote gate requires a real change while allowing adjustment-only cha
|
|
|
624
650
|
|
|
625
651
|
assert.equal(canRequestAdminChangeQuote({
|
|
626
652
|
isCustomerSelfServeChange: true,
|
|
627
|
-
// A fully hydrated modal is still not quoteable until something changes.
|
|
628
653
|
hasEffectiveChangeSelection: false,
|
|
629
654
|
hasSelectedAvailability: true,
|
|
630
655
|
bookingReference: 'DIFG66OF',
|
|
@@ -677,7 +702,7 @@ test('admin preview and checkout share the Pricing V2 request shape', () => {
|
|
|
677
702
|
assert.equal('clientProposedTotal' in request, false);
|
|
678
703
|
});
|
|
679
704
|
|
|
680
|
-
test('admin
|
|
705
|
+
test('admin Pricing V2 quote mapper preserves the server quote identity', async () => {
|
|
681
706
|
const request = buildAdminChangeV2QuoteRequest({
|
|
682
707
|
bookingReference: 'VFSCAEKQ',
|
|
683
708
|
lastName: 'Tauro',
|
|
@@ -722,6 +747,34 @@ test('admin checkout maps the fresh Pricing V2 quote id instead of a legacy resp
|
|
|
722
747
|
assert.equal(mergedQuote.quotedTotal, 768.11);
|
|
723
748
|
});
|
|
724
749
|
|
|
750
|
+
test('admin checkout reuses the exact quote reviewed by the operator', () => {
|
|
751
|
+
const reviewedQuote = {
|
|
752
|
+
quoteId: 'quote_reviewed',
|
|
753
|
+
canProceed: true,
|
|
754
|
+
priceDiff: 0,
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const checkoutQuote = requireReviewedAdminChangeQuoteForCheckout(reviewedQuote);
|
|
758
|
+
|
|
759
|
+
assert.equal(checkoutQuote, reviewedQuote);
|
|
760
|
+
assert.equal(checkoutQuote.quoteId, 'quote_reviewed');
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test('admin checkout fails closed when no applicable reviewed quote exists', () => {
|
|
764
|
+
assert.throws(
|
|
765
|
+
() => requireReviewedAdminChangeQuoteForCheckout(null),
|
|
766
|
+
/reviewed change quote is not ready/i,
|
|
767
|
+
);
|
|
768
|
+
assert.throws(
|
|
769
|
+
() => requireReviewedAdminChangeQuoteForCheckout({
|
|
770
|
+
quoteId: 'quote_blocked',
|
|
771
|
+
canProceed: false,
|
|
772
|
+
reasonIfBlocked: 'Booking requires review.',
|
|
773
|
+
}),
|
|
774
|
+
/Booking requires review/,
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
|
|
725
778
|
test('admin no-charge provider payload carries the selected pickup itinerary', () => {
|
|
726
779
|
const itineraryDisplay: NonNullable<
|
|
727
780
|
Parameters<typeof buildAdminChangeProviderPayload>[0]['itineraryDisplay']
|
|
@@ -1392,6 +1445,39 @@ test('private conversion replaces an incompatible source cancellation policy wit
|
|
|
1392
1445
|
);
|
|
1393
1446
|
});
|
|
1394
1447
|
|
|
1448
|
+
test('standard-to-private conversion preserves the original passenger total', () => {
|
|
1449
|
+
assert.equal(
|
|
1450
|
+
resolveInitialPrivateShuttlePassengerCount({
|
|
1451
|
+
bookingItems: [
|
|
1452
|
+
{ category: 'ADULT', count: 4 },
|
|
1453
|
+
{ category: 'CHILD', count: 1 },
|
|
1454
|
+
],
|
|
1455
|
+
}),
|
|
1456
|
+
5,
|
|
1457
|
+
);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
test('private passenger count wins and RESOURCE is never treated as people', () => {
|
|
1461
|
+
assert.equal(
|
|
1462
|
+
resolveInitialPrivateShuttlePassengerCount({
|
|
1463
|
+
privateShuttleDetails: { passengerCount: 5, requestedPassengerCount: 14 },
|
|
1464
|
+
bookingItems: [{ category: 'RESOURCE', count: 2 }],
|
|
1465
|
+
}),
|
|
1466
|
+
14,
|
|
1467
|
+
);
|
|
1468
|
+
assert.equal(
|
|
1469
|
+
resolveInitialPrivateShuttlePassengerCount({
|
|
1470
|
+
bookingItems: [{ category: 'RESOURCE', count: 2 }],
|
|
1471
|
+
}),
|
|
1472
|
+
1,
|
|
1473
|
+
);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test('private change bookings inherit terms acceptance but fresh bookings do not', () => {
|
|
1477
|
+
assert.equal(privateShuttleTermsInitiallyAccepted(true), true);
|
|
1478
|
+
assert.equal(privateShuttleTermsInitiallyAccepted(false), false);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1395
1481
|
test('admin standard-to-private conversion sends complete private target identity and state', async () => {
|
|
1396
1482
|
let captured: Parameters<typeof applyPrivateShuttleProviderChange>[0] extends {
|
|
1397
1483
|
onChangeBooking: (data: infer T) => Promise<void>;
|
|
@@ -1456,17 +1542,9 @@ test('admin standard-to-private conversion sends complete private target identit
|
|
|
1456
1542
|
]);
|
|
1457
1543
|
});
|
|
1458
1544
|
|
|
1459
|
-
test('private conversion
|
|
1460
|
-
|
|
1461
|
-
onChangeBooking: (
|
|
1462
|
-
}
|
|
1463
|
-
? T
|
|
1464
|
-
: never;
|
|
1465
|
-
|
|
1466
|
-
await applyPrivateShuttleProviderChange({
|
|
1467
|
-
onChangeBooking: async (data) => {
|
|
1468
|
-
captured = data;
|
|
1469
|
-
},
|
|
1545
|
+
test('private conversion rejects an itinerary without fixed pickup and drop-off endpoints', async () => {
|
|
1546
|
+
await assert.rejects(() => applyPrivateShuttleProviderChange({
|
|
1547
|
+
onChangeBooking: async () => {},
|
|
1470
1548
|
parentProductId: 'p_private',
|
|
1471
1549
|
selectedOption: 'po_private',
|
|
1472
1550
|
selectedAvailabilityId: 'a_private',
|
|
@@ -1498,12 +1576,14 @@ test('private conversion stores the first actual location as pickup when propose
|
|
|
1498
1576
|
],
|
|
1499
1577
|
isAdmin: true,
|
|
1500
1578
|
providerChangeAuthoritativeReceipt: undefined,
|
|
1501
|
-
});
|
|
1579
|
+
}), /requires fixed pickup and drop-off endpoints/i);
|
|
1580
|
+
});
|
|
1502
1581
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1582
|
+
test('private shuttle end time includes the selected option duration', () => {
|
|
1583
|
+
assert.equal(
|
|
1584
|
+
privateShuttleEndTimeLabel('2026-10-01', '03:00', 'America/Edmonton', 360),
|
|
1585
|
+
'09:00',
|
|
1586
|
+
);
|
|
1507
1587
|
});
|
|
1508
1588
|
|
|
1509
1589
|
test('private shuttle checkout detects a quote total that differs from the displayed total', () => {
|
|
@@ -2000,6 +2080,18 @@ test('translates booking-change reason codes into operator-facing messages', ()
|
|
|
2000
2080
|
friendlyChangeBookingError('structured_adjustment_basis_is_zero'),
|
|
2001
2081
|
'This credit needs a value to apply to. Make a priced booking change, or choose All active tickets, Entire active booking, or Selected components.',
|
|
2002
2082
|
);
|
|
2083
|
+
assert.equal(
|
|
2084
|
+
friendlyChangeBookingError('amendment_ledger_summary_missing'),
|
|
2085
|
+
"This older booking hasn't been prepared for changes yet. Please contact support to prepare it, then reopen the booking.",
|
|
2086
|
+
);
|
|
2087
|
+
assert.equal(
|
|
2088
|
+
friendlyChangeBookingError('admin_ledger_requires_review_or_repair'),
|
|
2089
|
+
"This older booking's pricing and payment history must be reviewed before it can be changed. Please contact support.",
|
|
2090
|
+
);
|
|
2091
|
+
assert.equal(
|
|
2092
|
+
friendlyChangeBookingError('private_shuttle_itinerary_endpoints_required'),
|
|
2093
|
+
'The private-tour itinerary is incomplete. Reopen the change and select the pickup time and location again.',
|
|
2094
|
+
);
|
|
2003
2095
|
assert.equal(
|
|
2004
2096
|
friendlyChangeBookingError('Remove the unavailable add-on before recalculating this amendment.'),
|
|
2005
2097
|
'Remove the unavailable add-on before recalculating this amendment.',
|
|
@@ -2010,6 +2102,51 @@ test('translates booking-change reason codes into operator-facing messages', ()
|
|
|
2010
2102
|
);
|
|
2011
2103
|
});
|
|
2012
2104
|
|
|
2105
|
+
test('blocked admin quotes show one actionable error and no settlement claim', () => {
|
|
2106
|
+
const serverQuoteError = 'amendment_ledger_summary_missing';
|
|
2107
|
+
const checkoutError = friendlyChangeBookingError(serverQuoteError);
|
|
2108
|
+
assert.equal(
|
|
2109
|
+
adminChangeSettlementSummaryLabel({ amountDue: 0, refundCandidate: 0, serverQuoteError }),
|
|
2110
|
+
'Price unavailable',
|
|
2111
|
+
);
|
|
2112
|
+
assert.equal(
|
|
2113
|
+
dedupeAdminChangeCheckoutError({ checkoutError, serverQuoteError }),
|
|
2114
|
+
'',
|
|
2115
|
+
);
|
|
2116
|
+
assert.equal(
|
|
2117
|
+
dedupeAdminChangeCheckoutError({
|
|
2118
|
+
checkoutError: 'Enter the customer email before continuing.',
|
|
2119
|
+
serverQuoteError,
|
|
2120
|
+
}),
|
|
2121
|
+
'Enter the customer email before continuing.',
|
|
2122
|
+
);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
test('blocked public quotes show the readiness message only in the quote status panel', () => {
|
|
2126
|
+
const displayedQuoteError = 'amendment_ledger_summary_missing';
|
|
2127
|
+
const checkoutError = friendlyChangeBookingError(displayedQuoteError);
|
|
2128
|
+
assert.equal(
|
|
2129
|
+
dedupeChangeBookingCheckoutError({ checkoutError, displayedQuoteError }),
|
|
2130
|
+
'',
|
|
2131
|
+
);
|
|
2132
|
+
assert.equal(
|
|
2133
|
+
dedupeChangeBookingCheckoutError({
|
|
2134
|
+
checkoutError: 'Enter your email before continuing.',
|
|
2135
|
+
displayedQuoteError,
|
|
2136
|
+
}),
|
|
2137
|
+
'Enter your email before continuing.',
|
|
2138
|
+
);
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
test('closing public change booking refreshes only after a completed change', () => {
|
|
2142
|
+
assert.equal(shouldRefreshAfterChangeDialogClose(undefined), false);
|
|
2143
|
+
assert.equal(shouldRefreshAfterChangeDialogClose(null), true);
|
|
2144
|
+
assert.equal(
|
|
2145
|
+
shouldRefreshAfterChangeDialogClose({ dateChanged: false, ticketsChanged: true }),
|
|
2146
|
+
true,
|
|
2147
|
+
);
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2013
2150
|
test('names the incompatible add-on and target tour in the operator error', () => {
|
|
2014
2151
|
assert.equal(
|
|
2015
2152
|
incompatibleAddOnChangeError(
|