@ticketboothapp/booking 1.2.165 → 1.2.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +97 -32
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
  5. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +9 -2
  7. package/src/components/booking/NewBookingFlow.tsx +14 -2
  8. package/src/components/booking/PriceBreakdown.tsx +66 -15
  9. package/src/components/booking/PriceSummary.tsx +12 -0
  10. package/src/components/booking/PrivateShuttleBookingFlow.tsx +25 -1
  11. package/src/components/booking/admin-change-payment-choice-runner.ts +12 -0
  12. package/src/components/booking/admin-change-provider-payload.ts +9 -1
  13. package/src/components/booking/admin-change-quote-request-key.ts +6 -6
  14. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  15. package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
  16. package/src/components/booking/admin-refund-decision-context.ts +85 -0
  17. package/src/components/booking/availability-date-selection.ts +16 -0
  18. package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
  19. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  20. package/src/components/booking/useAdminChangeCheckoutController.ts +90 -11
  21. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
  22. package/src/components/booking/useAdminChangeQuotePreview.ts +47 -37
  23. package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
  24. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  25. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  26. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  27. package/src/lib/booking/booking-cutoffs.ts +22 -0
  28. package/src/lib/booking/change-booking-server-preview.ts +75 -5
  29. package/src/lib/booking-api.ts +12 -1
  30. package/test/change-booking-helpers.test.ts +466 -3
@@ -32,9 +32,11 @@ import { PrivateShuttleStartTimeSection } from './PrivateShuttleStartTimeSection
32
32
  import {
33
33
  getPrivateShuttleAvailabilityOptionId,
34
34
  privateShuttleBookingCutoffMessage,
35
+ privateShuttleStartDateTime,
35
36
  privateShuttleStartTimeAllowed,
36
37
  resolveHydratedPrivateShuttleAvailability,
37
38
  } from './private-shuttle-availability';
39
+ import { formatInTimeZone } from 'date-fns-tz';
38
40
  import { usePrivateShuttleAvailability } from './use-private-shuttle-availability';
39
41
  import type {
40
42
  ProviderDashboardChangeBookingPayload,
@@ -285,13 +287,35 @@ export function PrivateShuttleBookingFlow({
285
287
  setAddOns([]);
286
288
  return;
287
289
  }
290
+ const dateTimeForAddOns =
291
+ selectedDate && selectedStartTime
292
+ ? formatInTimeZone(
293
+ privateShuttleStartDateTime(selectedDate, selectedStartTime, companyTimezone),
294
+ companyTimezone,
295
+ "yyyy-MM-dd'T'HH:mm:ssXXX",
296
+ )
297
+ : selectedDate || undefined;
288
298
  getAddOns(product.companyId!, {
289
299
  productOptionId: selectedOption,
290
300
  preCheckout: true,
301
+ dateTime: dateTimeForAddOns,
291
302
  })
292
303
  .then(setAddOns)
293
304
  .catch(() => setAddOns([]));
294
- }, [selectedOption, product.companyId]);
305
+ }, [selectedOption, selectedDate, selectedStartTime, product.companyId, companyTimezone]);
306
+
307
+ const previousAddOnIdsRef = useRef<Set<string>>(new Set());
308
+ useEffect(() => {
309
+ const availableIds = new Set(addOns.map((a) => a.addOnId));
310
+ const previousIds = previousAddOnIdsRef.current;
311
+ if (previousIds.size > 0) {
312
+ setAddOnSelections((prev) => {
313
+ const next = prev.filter((s) => !previousIds.has(s.addOnId) || availableIds.has(s.addOnId));
314
+ return next.length === prev.length ? prev : next;
315
+ });
316
+ }
317
+ previousAddOnIdsRef.current = availableIds;
318
+ }, [addOns]);
295
319
 
296
320
  useEffect(() => {
297
321
  if (!draftItineraryDestinations.includes('emerald_lake')) {
@@ -126,6 +126,18 @@ export function buildAdminChangePayNowCheckoutModalData(
126
126
  };
127
127
  }
128
128
 
129
+ export function shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(
130
+ isProviderDashboardChange: boolean,
131
+ isChangeBookingContext: boolean,
132
+ adminChoiceData: Pick<AdminChangePaymentChoiceData, 'pricingQuoteId'> | null,
133
+ ): boolean {
134
+ return Boolean(
135
+ isProviderDashboardChange &&
136
+ isChangeBookingContext &&
137
+ adminChoiceData?.pricingQuoteId?.trim(),
138
+ );
139
+ }
140
+
129
141
  export interface ConfirmAdminChangeBookingWithoutPaymentParams {
130
142
  adminChoiceData: AdminChangePaymentChoiceData;
131
143
  bookingSourceAttribution: Partial<BookingSourceMetadata>;
@@ -1,4 +1,9 @@
1
- import type { Availability, Product, ReturnOption } from '../../lib/booking-api';
1
+ import type {
2
+ AdminPromoApplicationScope,
3
+ Availability,
4
+ Product,
5
+ ReturnOption,
6
+ } from '../../lib/booking-api';
2
7
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
3
8
 
4
9
  export type AdminChangeBookingItem = { category: string; count: number };
@@ -29,6 +34,7 @@ export interface BuildAdminChangeProviderPayloadParams {
29
34
  cancellationPolicyId: string | null;
30
35
  initialCancellationPolicyId?: string | null;
31
36
  appliedPromoCode: string | null;
37
+ promoApplicationScope?: AdminPromoApplicationScope;
32
38
  newTotalAmount: number;
33
39
  providerPricingOverrides: AdminChangeProviderLineOverride[];
34
40
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
@@ -56,6 +62,7 @@ export function buildAdminChangeProviderPayload({
56
62
  cancellationPolicyId,
57
63
  initialCancellationPolicyId,
58
64
  appliedPromoCode,
65
+ promoApplicationScope = 'CHANGE_ONLY',
59
66
  newTotalAmount,
60
67
  providerPricingOverrides,
61
68
  mergedProviderAdditionalAdjustments,
@@ -92,6 +99,7 @@ export function buildAdminChangeProviderPayload({
92
99
  addOnSelections,
93
100
  cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
94
101
  promoCode: appliedPromoCode ?? null,
102
+ promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
95
103
  newTotalAmount,
96
104
  additionalHoursCount: null,
97
105
  pricingAdjustment:
@@ -1,4 +1,4 @@
1
- import type { Availability } from '../../lib/booking-api';
1
+ import type { AdminPromoApplicationScope, Availability } from '../../lib/booking-api';
2
2
  import { getAvailabilityOptionId } from './change-booking-flow-helpers';
3
3
 
4
4
  interface AdminChangeQuoteRequestKeyInput {
@@ -12,6 +12,8 @@ interface AdminChangeQuoteRequestKeyInput {
12
12
  addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
13
13
  adminCustomReceiptLines: unknown[];
14
14
  cancellationPolicyId?: string | null;
15
+ promoCode?: string | null;
16
+ promoApplicationScope?: AdminPromoApplicationScope | null;
15
17
  structuredAdjustments?: unknown[];
16
18
  refundDispositionsByOperationId?: Record<string, string>;
17
19
  useAdminFeAuthoritativeQuote: boolean;
@@ -36,6 +38,8 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
36
38
  addOnSelections: input.addOnSelections,
37
39
  adminCustomReceiptLines: input.adminCustomReceiptLines,
38
40
  cancellationPolicyId: input.cancellationPolicyId ?? null,
41
+ promoCode: input.promoCode?.trim().toUpperCase() ?? null,
42
+ promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
39
43
  structuredAdjustments: input.structuredAdjustments ?? [],
40
44
  refundDispositionsByOperationId: Object.fromEntries(
41
45
  Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
@@ -44,11 +48,7 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
44
48
  });
45
49
  }
46
50
 
47
- /**
48
- * A completed request is reusable only while its authoritative quote is still published.
49
- * Closing a nested payment dialog can clear transient quote state without changing the
50
- * selection inputs; in that case the same input key must be eligible for a refetch.
51
- */
51
+ /** A completed request is reusable only while its quote is still published in UI state. */
52
52
  export function canReuseCompletedAdminChangeQuote(
53
53
  inputKey: string,
54
54
  lastCompletedInputKey: string | null,
@@ -0,0 +1,49 @@
1
+ import type { PriceSummaryLine } from './PriceSummary';
2
+
3
+ function isSelectedPromoLine(line: PriceSummaryLine, promoCode: string): boolean {
4
+ if (line.kind !== 'line') return false;
5
+ const type = String(line.type ?? '').trim().toUpperCase();
6
+ if (type === 'PROMO_CODE') return true;
7
+ if (type !== 'DISCOUNT') return false;
8
+
9
+ const label = line.label.trim().toUpperCase();
10
+ return label.startsWith('DISCOUNT ·') ||
11
+ label.startsWith('PROMO ') ||
12
+ label.includes(promoCode);
13
+ }
14
+
15
+ /**
16
+ * Keep component-level promo allocations in the server quote/ledger while presenting
17
+ * the selected promo as one customer-facing row in the admin amendment summary.
18
+ */
19
+ export function collapseAdminPromoLines(
20
+ lines: PriceSummaryLine[],
21
+ promoCode: string | null | undefined,
22
+ ): PriceSummaryLine[] {
23
+ const normalizedCode = promoCode?.trim().toUpperCase() ?? '';
24
+ if (!normalizedCode) return lines;
25
+
26
+ const promoIndexes = lines
27
+ .map((line, index) => isSelectedPromoLine(line, normalizedCode) ? index : -1)
28
+ .filter((index) => index >= 0);
29
+ if (promoIndexes.length === 0) return lines;
30
+
31
+ const promoIndexSet = new Set(promoIndexes);
32
+ const firstPromoIndex = promoIndexes[0];
33
+ const promoAmount = Math.round(promoIndexes.reduce((sum, index) => {
34
+ const line = lines[index];
35
+ return sum + (line.kind === 'line' ? line.amount : 0);
36
+ }, 0) * 100) / 100;
37
+
38
+ return lines.flatMap((line, index) => {
39
+ if (index === firstPromoIndex) {
40
+ return [{
41
+ kind: 'line' as const,
42
+ label: `Promo: ${normalizedCode}`,
43
+ amount: promoAmount,
44
+ type: 'PROMO_CODE',
45
+ }];
46
+ }
47
+ return promoIndexSet.has(index) ? [] : [line];
48
+ });
49
+ }
@@ -0,0 +1,101 @@
1
+ import type {
2
+ AdminAmendmentRefundDisposition,
3
+ AdminPromoApplicationScope,
4
+ Availability,
5
+ ChangeBookingQuoteRequest,
6
+ ChangeBookingQuoteResponse,
7
+ } from '../../lib/booking-api';
8
+ import { quoteAdminChangeBookingV2 } from '../../lib/booking-api';
9
+ import type { Currency } from './CurrencySwitcher';
10
+ import {
11
+ sliceAndMergeChangeQuoteForUi,
12
+ type ChangeBookingMergedQuoteState,
13
+ } from './change-booking-quote-state';
14
+
15
+ export type AdminChangeV2QuoteRequest = ChangeBookingQuoteRequest & {
16
+ cancellationPolicyId?: string | null;
17
+ structuredAdjustments?: unknown[];
18
+ };
19
+
20
+ export interface BuildAdminChangeV2QuoteRequestParams {
21
+ bookingReference: string;
22
+ lastName: string;
23
+ parentProductId: string;
24
+ optionId: string;
25
+ selectedAvailability: Availability;
26
+ pickupLocationId: string | null;
27
+ returnAvailabilityId: string | null;
28
+ bookingItems: Array<{ category: string; count: number }>;
29
+ addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
30
+ cancellationPolicyId: string | null;
31
+ promoCode?: string | null;
32
+ promoApplicationScope?: AdminPromoApplicationScope;
33
+ structuredAdjustments: unknown[];
34
+ refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
35
+ previousPassengerCount: number;
36
+ previousAvailabilityId: string | null;
37
+ previousReturnAvailabilityId: string | null;
38
+ }
39
+
40
+ /**
41
+ * Canonical server-authoritative provider/admin quote request.
42
+ * Preview and checkout must use the same endpoint and request shape so checkout cannot
43
+ * replace a valid Pricing V2 quote with a legacy response that has no quote id.
44
+ */
45
+ export function buildAdminChangeV2QuoteRequest({
46
+ bookingReference,
47
+ lastName,
48
+ parentProductId,
49
+ optionId,
50
+ selectedAvailability,
51
+ pickupLocationId,
52
+ returnAvailabilityId,
53
+ bookingItems,
54
+ addOnSelections,
55
+ cancellationPolicyId,
56
+ promoCode,
57
+ promoApplicationScope = 'CHANGE_ONLY',
58
+ structuredAdjustments,
59
+ refundDispositionsByOperationId,
60
+ previousPassengerCount,
61
+ previousAvailabilityId,
62
+ previousReturnAvailabilityId,
63
+ }: BuildAdminChangeV2QuoteRequestParams): AdminChangeV2QuoteRequest {
64
+ return {
65
+ bookingReference: bookingReference.trim(),
66
+ lastName: lastName.trim(),
67
+ newProductId: optionId,
68
+ newParentProductId: parentProductId,
69
+ newDateTime: selectedAvailability.dateTime,
70
+ newAvailabilityId: selectedAvailability.availabilityId || null,
71
+ newPickupLocationId: pickupLocationId || null,
72
+ newReturnAvailabilityId: returnAvailabilityId,
73
+ newPassengerCounts: bookingItems,
74
+ // Admin owns the complete target selection. [] intentionally clears all add-ons.
75
+ newAddOnSelections: addOnSelections,
76
+ cancellationPolicyId,
77
+ promoCode: promoCode?.trim() || null,
78
+ promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
79
+ structuredAdjustments,
80
+ ...(Object.keys(refundDispositionsByOperationId).length > 0
81
+ ? { refundDispositionsByOperationId }
82
+ : {}),
83
+ capacitySeatCredit: {
84
+ enabled: true,
85
+ previousPassengerCount,
86
+ previousAvailabilityId,
87
+ previousReturnAvailabilityId,
88
+ },
89
+ };
90
+ }
91
+
92
+ export async function quoteAdminChangeV2ForUi(
93
+ request: AdminChangeV2QuoteRequest,
94
+ fallbackCart: { total: number; subtotal: number; tax: number },
95
+ currency: Currency,
96
+ quoteFn: (request: ChangeBookingQuoteRequest) => Promise<ChangeBookingQuoteResponse> =
97
+ quoteAdminChangeBookingV2,
98
+ ): Promise<ChangeBookingMergedQuoteState> {
99
+ const quote = await quoteFn(request);
100
+ return sliceAndMergeChangeQuoteForUi(quote, fallbackCart, currency).mergedQuote;
101
+ }
@@ -0,0 +1,85 @@
1
+ import type {
2
+ AdminAmendmentOperationSnapshot,
3
+ AdminAmendmentRefundDisposition,
4
+ } from '../../lib/booking-api';
5
+
6
+ export interface AdminRefundDecisionContext {
7
+ selectionKey: string;
8
+ operations: AdminAmendmentOperationSnapshot[];
9
+ allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
10
+ removedValueByOperationId: Record<string, number>;
11
+ }
12
+
13
+ interface MergeAdminRefundDecisionContextParams {
14
+ current: AdminRefundDecisionContext | null;
15
+ selectionKey: string;
16
+ operations: AdminAmendmentOperationSnapshot[];
17
+ allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
18
+ removedValueByOperationId: Record<string, number>;
19
+ }
20
+
21
+ /**
22
+ * Refund-decision quote responses are incremental: after one operation is answered,
23
+ * the server can return only the operations that still need an answer. Keep all of
24
+ * the operations for the current booking selection so an answered operation does
25
+ * not disappear from the next request and get requested again.
26
+ */
27
+ export function mergeAdminRefundDecisionContext({
28
+ current,
29
+ selectionKey,
30
+ operations,
31
+ allowedByOperationId,
32
+ removedValueByOperationId,
33
+ }: MergeAdminRefundDecisionContextParams): AdminRefundDecisionContext | null {
34
+ const sameSelection = current?.selectionKey === selectionKey ? current : null;
35
+ if (!sameSelection && operations.length === 0) return null;
36
+
37
+ const operationsById = new Map<string, AdminAmendmentOperationSnapshot>();
38
+ for (const operation of sameSelection?.operations ?? []) {
39
+ operationsById.set(operation.operationId, operation);
40
+ }
41
+ for (const operation of operations) {
42
+ operationsById.set(operation.operationId, operation);
43
+ }
44
+
45
+ const relevantOperationIds = new Set(operationsById.keys());
46
+ const mergedAllowed = {
47
+ ...(sameSelection?.allowedByOperationId ?? {}),
48
+ ...allowedByOperationId,
49
+ };
50
+ const mergedRemovedValues = {
51
+ ...(sameSelection?.removedValueByOperationId ?? {}),
52
+ ...removedValueByOperationId,
53
+ };
54
+
55
+ return {
56
+ selectionKey,
57
+ operations: [...operationsById.values()],
58
+ allowedByOperationId: Object.fromEntries(
59
+ Object.entries(mergedAllowed).filter(([operationId]) =>
60
+ relevantOperationIds.has(operationId),
61
+ ),
62
+ ),
63
+ removedValueByOperationId: Object.fromEntries(
64
+ Object.entries(mergedRemovedValues).filter(([operationId]) =>
65
+ relevantOperationIds.has(operationId),
66
+ ),
67
+ ),
68
+ };
69
+ }
70
+
71
+ export function selectRefundDispositionsForOperations(
72
+ context: AdminRefundDecisionContext | null,
73
+ additionalOperations: AdminAmendmentOperationSnapshot[],
74
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
75
+ ): Record<string, AdminAmendmentRefundDisposition> {
76
+ const relevantOperationIds = new Set([
77
+ ...(context?.operations ?? []).map((operation) => operation.operationId),
78
+ ...additionalOperations.map((operation) => operation.operationId),
79
+ ]);
80
+ return Object.fromEntries(
81
+ Object.entries(dispositionsByOperationId).filter(([operationId]) =>
82
+ relevantOperationIds.has(operationId),
83
+ ),
84
+ );
85
+ }
@@ -0,0 +1,16 @@
1
+ import type { Availability } from '../../lib/booking-api';
2
+
3
+ export function findFirstDateWithBookableAvailability(
4
+ dates: string[],
5
+ getRowsForDate: (date: string) => Availability[],
6
+ isBookable: (availability: Availability) => boolean,
7
+ ): string | undefined {
8
+ return dates.find((date) => getRowsForDate(date).some(isBookable));
9
+ }
10
+
11
+ export function selectedDateHasVisibleAvailability(
12
+ selectedDate: string,
13
+ getRowsForDate: (date: string) => Availability[],
14
+ ): boolean {
15
+ return selectedDate !== '' && getRowsForDate(selectedDate).length > 0;
16
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Currency } from './CurrencySwitcher';
2
- import type { ItineraryDisplayStep } from '../../lib/booking-api';
2
+ import type { AdminPromoApplicationScope, ItineraryDisplayStep } from '../../lib/booking-api';
3
3
 
4
4
  export type AdminAmendmentAdjustmentMode =
5
5
  | 'FIXED_AMOUNT'
@@ -52,6 +52,7 @@ export type ProviderDashboardChangeBookingPayload = {
52
52
  addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
53
53
  cancellationPolicyId?: string | null;
54
54
  promoCode?: string | null;
55
+ promoApplicationScope?: AdminPromoApplicationScope | null;
55
56
  newTotalAmount?: number;
56
57
  additionalHoursCount?: number | null;
57
58
  pricingAdjustment?: {
@@ -18,7 +18,10 @@ import {
18
18
  type Product,
19
19
  type ReturnOption,
20
20
  } from '../../lib/booking-api';
21
- import { filterAvailabilitiesAfterPublicBookingCutoff } from '../../lib/booking/booking-cutoffs';
21
+ import {
22
+ filterAvailabilitiesAfterCurrentTime,
23
+ filterAvailabilitiesAfterPublicBookingCutoff,
24
+ } from '../../lib/booking/booking-cutoffs';
22
25
  import { shouldReplacePricingConfig } from '../../lib/booking/pricing-config';
23
26
  import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
24
27
  import {
@@ -628,7 +631,7 @@ export function useStandardBookingAvailability({
628
631
  });
629
632
 
630
633
  return isAdmin
631
- ? currentOrFutureAvailabilities
634
+ ? filterAvailabilitiesAfterCurrentTime(currentOrFutureAvailabilities, bookingCutoffNow)
632
635
  : filterAvailabilitiesAfterPublicBookingCutoff(
633
636
  currentOrFutureAvailabilities,
634
637
  bookingCutoffNow,
@@ -13,6 +13,7 @@ import {
13
13
  type AdminAmendmentOperationSnapshot,
14
14
  type AdminAmendmentRefundDisposition,
15
15
  type AdminFeAuthoritativeReceipt,
16
+ type AdminPromoApplicationScope,
16
17
  type Availability,
17
18
  type ChangeBookingQuotePricingDriftDetail,
18
19
  type ChangeBookingQuoteTicketPricingTrace,
@@ -59,6 +60,7 @@ import {
59
60
  buildAdminChangePaymentChoiceData,
60
61
  buildAdminChangePayNowCheckoutModalData,
61
62
  confirmAdminChangeBookingWithoutPayment,
63
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
62
64
  type AdminChangeCheckoutModalData,
63
65
  type AdminChangePaymentChoiceData,
64
66
  } from './admin-change-payment-choice-runner';
@@ -66,6 +68,10 @@ import {
66
68
  confirmFreeAdminCustomerChange,
67
69
  quoteAdminCustomerChangeForCheckout,
68
70
  } from './admin-change-customer-quote-runner';
71
+ import {
72
+ buildAdminChangeV2QuoteRequest,
73
+ quoteAdminChangeV2ForUi,
74
+ } from './admin-change-v2-quote-request';
69
75
  import {
70
76
  reservationHoldHasExpired,
71
77
  RESERVATION_HOLD_EXPIRED_MESSAGE,
@@ -135,6 +141,7 @@ export interface UseAdminChangeCheckoutControllerParams {
135
141
  onChangeBooking?: ChangeBookingFlowProps['onChangeBooking'];
136
142
  cancellationPolicyId: string | null;
137
143
  appliedPromoCode: string | null;
144
+ promoApplicationScope: AdminPromoApplicationScope;
138
145
  displayChangeFlowProposedTotalWithEditableLines: number;
139
146
  providerPricingOverrides: AdminChangeProviderLineOverride[];
140
147
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
@@ -236,6 +243,7 @@ export function useAdminChangeCheckoutController({
236
243
  onChangeBooking,
237
244
  cancellationPolicyId,
238
245
  appliedPromoCode,
246
+ promoApplicationScope,
239
247
  displayChangeFlowProposedTotalWithEditableLines,
240
248
  providerPricingOverrides,
241
249
  mergedProviderAdditionalAdjustments,
@@ -290,8 +298,28 @@ export function useAdminChangeCheckoutController({
290
298
  setShowCheckoutModal(false);
291
299
  setCheckoutModalData(null);
292
300
  setCheckoutClientSecret('');
301
+ }, []);
302
+
303
+ const handleCheckoutClose = useCallback(() => {
304
+ if (paymentSubmitInFlightRef.current) return;
305
+ cancelPendingReservation();
293
306
  setError('');
294
- }, [setError]);
307
+ // An existing-booking Pay now attempt owns an immutable quote/payment-choice snapshot.
308
+ // Closing Stripe must return to that choice instead of making the user create a new quote.
309
+ if (shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(
310
+ isProviderDashboardChange,
311
+ isChangeBookingContext,
312
+ adminChoiceData,
313
+ )) {
314
+ setShowAdminPaymentChoice(true);
315
+ }
316
+ }, [
317
+ adminChoiceData?.pricingQuoteId,
318
+ cancelPendingReservation,
319
+ isChangeBookingContext,
320
+ isProviderDashboardChange,
321
+ setError,
322
+ ]);
295
323
 
296
324
  const cancelPendingReservationBestEffort = useCallback(() => {
297
325
  if (paymentSubmitInFlightRef.current) return;
@@ -324,6 +352,7 @@ export function useAdminChangeCheckoutController({
324
352
  const buildProviderChangePayload = (
325
353
  availabilityProductOptionId: string,
326
354
  bookingItems: AdminChangeBookingItem[],
355
+ authoritativeQuote: AdminChangeLatestQuote | null = latestChangeQuote,
327
356
  ): ProviderDashboardChangeBookingPayload | null => {
328
357
  return buildAdminChangeProviderPayload({
329
358
  selectedAvailability,
@@ -336,13 +365,14 @@ export function useAdminChangeCheckoutController({
336
365
  cancellationPolicyId,
337
366
  initialCancellationPolicyId: initialValues?.cancellationPolicyId ?? null,
338
367
  appliedPromoCode,
368
+ promoApplicationScope,
339
369
  newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
340
370
  providerPricingOverrides,
341
371
  mergedProviderAdditionalAdjustments,
342
372
  adminStructuredAdjustments,
343
373
  refundDispositionsByOperationId,
344
- pricingV2QuoteId: latestChangeQuote?.quoteId ?? null,
345
- pricingV2QuotedTotal: latestChangeQuote?.quotedTotal ?? latestChangeQuote?.serverDisplay?.total ?? null,
374
+ pricingV2QuoteId: authoritativeQuote?.quoteId ?? null,
375
+ pricingV2QuotedTotal: authoritativeQuote?.quotedTotal ?? authoritativeQuote?.serverDisplay?.total ?? null,
346
376
  providerApplyAuthoritativeReceipt,
347
377
  previousPassengerCount: changeFlowInitialTicketCount,
348
378
  previousAvailabilityId: initialValues?.availabilityId ?? null,
@@ -437,8 +467,40 @@ export function useAdminChangeCheckoutController({
437
467
  let changeIntentIdForCheckout: string | undefined;
438
468
  let changeBookingReferenceForPaidFlow: string | undefined;
439
469
  let confirmedChangeAmountDueForCheckout: number | null = null;
470
+ let authoritativeChangeQuoteForCheckout = latestChangeQuote;
440
471
 
441
- if (isCustomerSelfServeChange) {
472
+ if (isProviderDashboardChange && isChangeBookingContext) {
473
+ const bookingReference = initialValues?.bookingReference?.trim();
474
+ if (!bookingReference) throw new Error('Missing booking reference.');
475
+ authoritativeChangeQuoteForCheckout = await quoteAdminChangeV2ForUi(
476
+ buildAdminChangeV2QuoteRequest({
477
+ bookingReference,
478
+ lastName,
479
+ parentProductId: product.productId,
480
+ optionId: availabilityProductOptionId,
481
+ selectedAvailability,
482
+ pickupLocationId,
483
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
484
+ bookingItems,
485
+ addOnSelections,
486
+ cancellationPolicyId,
487
+ promoCode: appliedPromoCode,
488
+ promoApplicationScope,
489
+ structuredAdjustments: adminStructuredAdjustments,
490
+ refundDispositionsByOperationId,
491
+ previousPassengerCount: changeFlowInitialTicketCount,
492
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
493
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
494
+ }),
495
+ {
496
+ total: changeFlowNewBookingTotal,
497
+ subtotal: effectiveSubtotalForCheckout,
498
+ tax: effectiveTax,
499
+ },
500
+ currency,
501
+ );
502
+ setLatestChangeQuote(authoritativeChangeQuoteForCheckout);
503
+ } else if (isCustomerSelfServeChange) {
442
504
  const quoteResult = await quoteAdminCustomerChangeForCheckout({
443
505
  bookingReference: initialValues?.bookingReference,
444
506
  lastName,
@@ -465,11 +527,18 @@ export function useAdminChangeCheckoutController({
465
527
  });
466
528
  changeBookingReferenceForPaidFlow = quoteResult.bookingReference;
467
529
  setLatestChangeQuote(quoteResult.mergedQuote);
530
+ // React state is not updated synchronously. Everything in this checkout attempt must
531
+ // use the quote returned above, not the stale latestChangeQuote render closure.
532
+ authoritativeChangeQuoteForCheckout = quoteResult.mergedQuote;
468
533
  confirmedChangeAmountDueForCheckout = quoteResult.decision.amountDueForCheckout;
469
534
 
470
535
  if (quoteResult.decision.kind === 'free') {
471
536
  if (onChangeBooking) {
472
- const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
537
+ const providerPayload = buildProviderChangePayload(
538
+ availabilityProductOptionId,
539
+ bookingItems,
540
+ authoritativeChangeQuoteForCheckout,
541
+ );
473
542
  if (!providerPayload) {
474
543
  setError('No availability selected');
475
544
  setLoading(false);
@@ -502,7 +571,7 @@ export function useAdminChangeCheckoutController({
502
571
  const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
503
572
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
504
573
  const amountDueForCheckout = isProviderDashboardChange && isChangeBookingContext
505
- ? Math.max(0, latestChangeQuote?.priceDiff ?? 0)
574
+ ? Math.max(0, authoritativeChangeQuoteForCheckout?.priceDiff ?? 0)
506
575
  : isCustomerSelfServeChange
507
576
  ? confirmedChangeAmountDueForCheckout ??
508
577
  Math.max(
@@ -547,14 +616,21 @@ export function useAdminChangeCheckoutController({
547
616
  // quote-bound Stripe authorization and applies only after that authorization succeeds.
548
617
  if (isProviderDashboardChange && isChangeBookingContext) {
549
618
  const bookingReference = initialValues?.bookingReference?.trim();
550
- const quoteId = latestChangeQuote?.quoteId?.trim();
619
+ const quoteId = authoritativeChangeQuoteForCheckout?.quoteId?.trim();
551
620
  if (!bookingReference) throw new Error('Missing booking reference.');
552
- if (!quoteId || latestChangeQuote?.canProceed === false) {
553
- throw new Error(latestChangeQuote?.reasonIfBlocked || 'The authoritative change quote is not ready.');
621
+ if (!quoteId || authoritativeChangeQuoteForCheckout?.canProceed === false) {
622
+ throw new Error(
623
+ authoritativeChangeQuoteForCheckout?.reasonIfBlocked ||
624
+ 'The authoritative change quote is not ready.',
625
+ );
554
626
  }
555
627
  if (amountDueForCheckout <= 0) {
556
628
  if (!onChangeBooking) throw new Error('Provider change handler is unavailable.');
557
- const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
629
+ const providerPayload = buildProviderChangePayload(
630
+ availabilityProductOptionId,
631
+ bookingItems,
632
+ authoritativeChangeQuoteForCheckout,
633
+ );
558
634
  if (!providerPayload) throw new Error('No availability selected');
559
635
  await onChangeBooking(providerPayload);
560
636
  onSuccess?.({ reservationReference: bookingReference });
@@ -591,7 +667,9 @@ export function useAdminChangeCheckoutController({
591
667
  'Pay later to apply the change and add the amount to the booking balance, or collect a new card payment before applying it.',
592
668
  confirmWithoutPaymentLabel: 'Pay later',
593
669
  previousTotal: originalReceipt?.total,
594
- newTotal: latestChangeQuote?.serverDisplay?.total ?? latestChangeQuote?.quotedTotal,
670
+ newTotal:
671
+ authoritativeChangeQuoteForCheckout?.serverDisplay?.total ??
672
+ authoritativeChangeQuoteForCheckout?.quotedTotal,
595
673
  finalizeExistingChangeInline: true,
596
674
  preferConfirmWithoutPayment: true,
597
675
  }));
@@ -941,6 +1019,7 @@ export function useAdminChangeCheckoutController({
941
1019
  showAdminPaymentChoice,
942
1020
  adminChoiceData,
943
1021
  cancelPendingReservation,
1022
+ handleCheckoutClose,
944
1023
  handleCheckout,
945
1024
  handleConfirmWithoutPayment,
946
1025
  handlePayNow,
@@ -326,9 +326,9 @@ export function useAdminChangeQuoteDisplayState({
326
326
  productChanged: selectedParentProductChanged,
327
327
  });
328
328
  const hasChangeSelection =
329
- isProviderDashboardChange
329
+ (isProviderDashboardChange
330
330
  ? changeSelectionDetails.hasOperationalChangesFromInitial
331
- : changeSelectionDetails.hasChangesFromInitial;
331
+ : changeSelectionDetails.hasChangesFromInitial) || Boolean(appliedPromoCode);
332
332
 
333
333
  const {
334
334
  providerTotalsPreview,