@ticketboothapp/booking 1.2.175 → 1.2.177

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.175",
3
+ "version": "1.2.177",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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
- dispositionsWithPendingRefundDefaults,
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
- // Only explicit user inputs belong to quote identity. Automatic provider
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
- (serverRefundCandidate ?? 0) > 0.005
218
- ? 'Amount to refund customer'
219
- : authoritativeAmountDue > 0.005
220
- ? 'Amount due from customer'
221
- : 'No payment or refund due';
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={error}
356
+ error={checkoutError}
350
357
  loading={loading}
351
358
  totalQuantity={totalQuantity}
352
359
  onCheckout={onCheckout}
@@ -298,7 +298,7 @@ export function AdminChangeReceiptComparison({
298
298
  {formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
299
299
  </p>
300
300
  </div>
301
- ) : summaryDisplayLines.length === 0 && Math.abs(bookingTotalChange ?? 0) < 0.005 ? (
301
+ ) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
302
302
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
303
303
  <p className="text-sm font-medium text-stone-700">No pricing changes</p>
304
304
  </div>
@@ -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={checkoutFormError}
990
+ error={visibleCheckoutFormError}
979
991
  loading={loading}
980
992
  totalQuantity={totalQuantity}
981
993
  onCheckout={handleCheckout}
@@ -44,7 +44,10 @@ export function buildChangeBookingQuoteStatusPlaceholder({
44
44
  }
45
45
  if (quoteCanProceed === false) {
46
46
  return (
47
- <div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
47
+ <div
48
+ className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
49
+ role="alert"
50
+ >
48
51
  <div>
49
52
  {friendlyChangeBookingError(
50
53
  quoteBlockedReason ?? 'This booking change is not available.',
@@ -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
- () => initialBooking?.privateShuttleDetails?.passengerCount ?? initialValues?.passengers ?? 1
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(false);
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 pickupLabel = pickupLocationSkipped
652
- ? (t('booking.pickupLocationUnknown') || "I don't know")
653
- : customPickupAddress
654
- ? customPickupAddress
655
- : selectedPickupLocation?.name;
656
- items.push({
657
- time: selectedStartTime,
658
- label: pickupLabel || (t('booking.pickupAt') || 'Pickup'),
659
- clickableLabel: !pickupLocationId && !customPickupAddress && !pickupLocationSkipped ? (t('pickup.title') || 'Select pickup') : undefined,
660
- prefix: t('booking.pickupAtPrefix') || 'Pickup at ',
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
- 'This booking needs a financial review before it can be changed.',
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
- 'This booking needs a financial review before it can be changed.',
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
- 'This booking needs a financial review before it can be changed.',
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
- return itineraryDisplayItems.map((item) => {
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
- buildAdminChangeV2QuoteRequest,
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
- const bookingReference = initialValues?.bookingReference?.trim();
486
- if (!bookingReference) throw new Error('Missing booking reference.');
487
- authoritativeChangeQuoteForCheckout = await quoteAdminChangeV2ForUi(
488
- buildAdminChangeV2QuoteRequest({
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 hasPublishedChangeQuoteRef = useRef(latestChangeQuote !== null);
192
- hasPublishedChangeQuoteRef.current = latestChangeQuote !== null;
193
- // Quote only against a live availability. Existing-adjustment discovery is
194
- // bundled into the first real change quote; it must not issue a quote merely
195
- // because the modal is hydrating.
196
- const quoteAvailability = selectedAvailability;
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
- (quoteAvailability && needsAdjustmentDiscovery
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 = missingRequiredReturnSelection;
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
- hasPublishedChangeQuoteRef.current,
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
- setLatestChangeQuote(mergedQuote);
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 quote gate requires a real change while allowing adjustment-only changes', () => {
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 checkout maps the fresh Pricing V2 quote id instead of a legacy response', async () => {
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 stores the first actual location as pickup when proposed stops come first', async () => {
1460
- let captured: Parameters<typeof applyPrivateShuttleProviderChange>[0] extends {
1461
- onChangeBooking: (data: infer T) => Promise<void>;
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
- assert.deepEqual(captured!.itineraryDisplay, [
1504
- { stepType: 'draft', time: 'Proposed stops', place: 'Moraine Lake, Lake Louise' },
1505
- { stepType: 'pickup', time: '7:00 AM', place: 'Banff Hotel' },
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(