@ticketboothapp/booking 1.2.161 → 1.2.163

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.161",
3
+ "version": "1.2.163",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -48,6 +48,7 @@ const dialogPropKeys = [
48
48
  'onCheckoutClose',
49
49
  'onPaymentSubmitStart',
50
50
  'onPaymentSubmitError',
51
+ 'onPaymentConfirmed',
51
52
  'getSuccessUrl',
52
53
  't',
53
54
  ] as const;
@@ -691,7 +691,9 @@ export function AdminChangeBookingFlow({
691
691
  returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
692
692
  quantities,
693
693
  addOnSelections,
694
+ cancellationPolicyId,
694
695
  adminCustomReceiptLines,
696
+ structuredAdjustments: adminStructuredAdjustments,
695
697
  useAdminFeAuthoritativeQuote,
696
698
  }), [
697
699
  initialValues?.bookingReference,
@@ -702,7 +704,9 @@ export function AdminChangeBookingFlow({
702
704
  selectedReturnOption?.returnAvailabilityId,
703
705
  quantities,
704
706
  addOnSelections,
707
+ cancellationPolicyId,
705
708
  adminCustomReceiptLines,
709
+ adminStructuredAdjustments,
706
710
  useAdminFeAuthoritativeQuote,
707
711
  ]);
708
712
  const responseRefundDecisionOperations = latestChangeQuote?.refundDecisionOperations ?? [];
@@ -782,7 +786,9 @@ export function AdminChangeBookingFlow({
782
786
  pickupLocationId,
783
787
  quantities,
784
788
  addOnSelections,
789
+ cancellationPolicyId,
785
790
  adminCustomReceiptLines,
791
+ adminStructuredAdjustments,
786
792
  refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
787
793
  useAdminFeAuthoritativeQuote,
788
794
  latestChangeQuote,
@@ -809,7 +815,6 @@ export function AdminChangeBookingFlow({
809
815
  changeFlowNewBookingTotal,
810
816
  effectiveSubtotalForCheckout,
811
817
  effectiveTax,
812
- adminCustomLinesAsAdditionalAdjustments,
813
818
  adminFeAuthoritativeReceipt,
814
819
  isTaxIncludedInPrice,
815
820
  t,
@@ -925,6 +930,7 @@ export function AdminChangeBookingFlow({
925
930
  handlePayNow,
926
931
  handlePaymentSubmitStart,
927
932
  handlePaymentSubmitError,
933
+ handlePaymentConfirmed,
928
934
  handleAdminPaymentChoiceCancel,
929
935
  } = useAdminChangeCheckoutController({
930
936
  selectedAvailability,
@@ -1017,7 +1023,7 @@ export function AdminChangeBookingFlow({
1017
1023
  onPayNow: handlePayNow,
1018
1024
  onConfirmWithoutPayment: handleConfirmWithoutPayment, onAdminPaymentChoiceCancel: handleAdminPaymentChoiceCancel,
1019
1025
  onCheckoutClose: cancelPendingReservation, onPaymentSubmitStart: handlePaymentSubmitStart,
1020
- onPaymentSubmitError: handlePaymentSubmitError,
1026
+ onPaymentSubmitError: handlePaymentSubmitError, onPaymentConfirmed: handlePaymentConfirmed,
1021
1027
  isPartialLaunch,
1022
1028
  checkoutVisible: selectedAvailability != null,
1023
1029
  visible: isAdmin && !isInitialPrivateShuttleBooking && availableChangeProducts.length > 1,
@@ -28,6 +28,7 @@ export interface AdminChangeCheckoutDialogsProps {
28
28
  onCheckoutClose: () => void;
29
29
  onPaymentSubmitStart: () => void;
30
30
  onPaymentSubmitError: () => void;
31
+ onPaymentConfirmed: (paymentIntentId: string) => Promise<void>;
31
32
  }
32
33
 
33
34
  export function AdminChangeCheckoutDialogs({
@@ -48,6 +49,7 @@ export function AdminChangeCheckoutDialogs({
48
49
  onCheckoutClose,
49
50
  onPaymentSubmitStart,
50
51
  onPaymentSubmitError,
52
+ onPaymentConfirmed,
51
53
  }: AdminChangeCheckoutDialogsProps) {
52
54
  return (
53
55
  <>
@@ -61,6 +63,10 @@ export function AdminChangeCheckoutDialogs({
61
63
  onPayNow={onPayNow}
62
64
  onConfirmWithoutPayment={onConfirmWithoutPayment}
63
65
  onCancel={onAdminPaymentChoiceCancel}
66
+ title={adminChoiceData?.dialogTitle}
67
+ description={adminChoiceData?.dialogDescription}
68
+ confirmWithoutPaymentLabel={adminChoiceData?.confirmWithoutPaymentLabel}
69
+ preferConfirmWithoutPayment={adminChoiceData?.preferConfirmWithoutPayment}
64
70
  />
65
71
  {checkoutModalData && (
66
72
  <CheckoutModal
@@ -68,6 +74,9 @@ export function AdminChangeCheckoutDialogs({
68
74
  onClose={onCheckoutClose}
69
75
  onPaymentSubmitStart={onPaymentSubmitStart}
70
76
  onPaymentSubmitError={onPaymentSubmitError}
77
+ onPaymentConfirmed={
78
+ checkoutModalData.finalizeExistingChangeInline ? onPaymentConfirmed : undefined
79
+ }
71
80
  clientSecret={checkoutClientSecret}
72
81
  reservationReference={checkoutModalData.reservationReference}
73
82
  reservationExpiration={checkoutModalData.reservationExpiration}
@@ -21,6 +21,9 @@ interface AdminPaymentChoiceModalProps {
21
21
  onCancel: () => void;
22
22
  description?: string;
23
23
  payNowLabel?: string;
24
+ title?: string;
25
+ confirmWithoutPaymentLabel?: string;
26
+ preferConfirmWithoutPayment?: boolean;
24
27
  }
25
28
 
26
29
  /**
@@ -40,6 +43,9 @@ export function AdminPaymentChoiceModal({
40
43
  onCancel,
41
44
  description,
42
45
  payNowLabel,
46
+ title,
47
+ confirmWithoutPaymentLabel,
48
+ preferConfirmWithoutPayment = false,
43
49
  }: AdminPaymentChoiceModalProps) {
44
50
  const [nowMs, setNowMs] = useState(() => Date.now());
45
51
 
@@ -69,7 +75,7 @@ export function AdminPaymentChoiceModal({
69
75
  <div className="p-6 border-b border-stone-200 flex-shrink-0">
70
76
  <div className="flex justify-between items-start gap-3">
71
77
  <h3 className="text-lg font-semibold text-stone-900 pr-2">
72
- Complete booking
78
+ {title ?? 'Complete booking'}
73
79
  </h3>
74
80
  <button
75
81
  type="button"
@@ -120,22 +126,38 @@ export function AdminPaymentChoiceModal({
120
126
  </p>
121
127
  ) : null}
122
128
 
129
+ {preferConfirmWithoutPayment ? (
130
+ <button
131
+ type="button"
132
+ onClick={onConfirmWithoutPayment}
133
+ disabled={loading || expired}
134
+ className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
135
+ >
136
+ {loading ? 'Applying...' : (confirmWithoutPaymentLabel ?? 'Confirm without payment')}
137
+ </button>
138
+ ) : null}
123
139
  <button
124
140
  type="button"
125
141
  onClick={onPayNow}
126
142
  disabled={loading || expired}
127
- className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
143
+ className={`w-full py-3 px-4 font-semibold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed ${
144
+ preferConfirmWithoutPayment
145
+ ? 'border border-stone-300 text-stone-700 hover:bg-stone-50'
146
+ : 'bg-emerald-600 text-white hover:bg-emerald-700'
147
+ }`}
128
148
  >
129
149
  {loading ? 'Loading...' : `${payNowLabel ?? 'Pay now'} (${formatCurrencyAmount(totalAmount, currency)})`}
130
150
  </button>
131
- <button
132
- type="button"
133
- onClick={onConfirmWithoutPayment}
134
- disabled={loading || expired}
135
- className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
136
- >
137
- Confirm without payment
138
- </button>
151
+ {!preferConfirmWithoutPayment ? (
152
+ <button
153
+ type="button"
154
+ onClick={onConfirmWithoutPayment}
155
+ disabled={loading || expired}
156
+ className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
157
+ >
158
+ {confirmWithoutPaymentLabel ?? 'Confirm without payment'}
159
+ </button>
160
+ ) : null}
139
161
  <button
140
162
  type="button"
141
163
  onClick={onCancel}
@@ -29,6 +29,8 @@ export interface CheckoutModalProps {
29
29
  onClose: () => void;
30
30
  onPaymentSubmitStart?: () => void;
31
31
  onPaymentSubmitError?: () => void;
32
+ /** Called after Stripe authorizes/confirms payment without redirecting. May commit a quoted change. */
33
+ onPaymentConfirmed?: (paymentIntentId: string) => Promise<void>;
32
34
  clientSecret: string;
33
35
  reservationReference: string;
34
36
  reservationExpiration?: string;
@@ -74,6 +76,7 @@ function CheckoutForm({
74
76
  onClose,
75
77
  onPaymentSubmitStart,
76
78
  onPaymentSubmitError,
79
+ onPaymentConfirmed,
77
80
  t,
78
81
  total,
79
82
  currency,
@@ -83,6 +86,7 @@ function CheckoutForm({
83
86
  onClose: () => void;
84
87
  onPaymentSubmitStart?: () => void;
85
88
  onPaymentSubmitError?: () => void;
89
+ onPaymentConfirmed?: (paymentIntentId: string) => Promise<void>;
86
90
  t: (key: string) => string;
87
91
  total: number;
88
92
  currency: Currency;
@@ -109,15 +113,38 @@ function CheckoutForm({
109
113
  onPaymentSubmitStart?.();
110
114
  // Store before redirect so success page can fire purchase event
111
115
  analytics.storePendingPurchase(total, currency);
112
- const { error: confirmError } = await stripe.confirmPayment({
113
- elements,
114
- confirmParams: {
115
- return_url: successUrl,
116
- },
117
- });
116
+ const confirmation = onPaymentConfirmed
117
+ ? await stripe.confirmPayment({
118
+ elements,
119
+ confirmParams: { return_url: successUrl },
120
+ redirect: 'if_required',
121
+ })
122
+ : await stripe.confirmPayment({
123
+ elements,
124
+ confirmParams: { return_url: successUrl },
125
+ });
126
+ const confirmError = 'error' in confirmation ? confirmation.error : undefined;
127
+ const paymentIntent = 'paymentIntent' in confirmation ? confirmation.paymentIntent : undefined;
118
128
  if (confirmError) {
119
129
  setError(confirmError.message ?? 'Payment failed');
120
130
  onPaymentSubmitError?.();
131
+ } else if (onPaymentConfirmed) {
132
+ const paymentIntentId = paymentIntent?.id;
133
+ if (!paymentIntentId) {
134
+ setError('Payment was authorized, but the booking change could not be completed. Please try again.');
135
+ onPaymentSubmitError?.();
136
+ } else {
137
+ try {
138
+ await onPaymentConfirmed(paymentIntentId);
139
+ } catch (completionError) {
140
+ setError(
141
+ completionError instanceof Error
142
+ ? completionError.message
143
+ : 'Payment was authorized, but the booking change could not be completed.',
144
+ );
145
+ onPaymentSubmitError?.();
146
+ }
147
+ }
121
148
  }
122
149
  setLoading(false);
123
150
  };
@@ -155,6 +182,7 @@ export function CheckoutModal({
155
182
  onClose,
156
183
  onPaymentSubmitStart,
157
184
  onPaymentSubmitError,
185
+ onPaymentConfirmed,
158
186
  clientSecret,
159
187
  reservationReference,
160
188
  reservationExpiration,
@@ -462,6 +490,7 @@ export function CheckoutModal({
462
490
  onClose={onClose}
463
491
  onPaymentSubmitStart={onPaymentSubmitStart}
464
492
  onPaymentSubmitError={onPaymentSubmitError}
493
+ onPaymentConfirmed={onPaymentConfirmed}
465
494
  t={t}
466
495
  total={total}
467
496
  currency={currency}
@@ -14,7 +14,9 @@ import type { CheckoutModalLineItem } from './CheckoutModal';
14
14
  import type { Currency } from './CurrencySwitcher';
15
15
  import type { ChangeBookingPaidCheckoutModalData } from './change-booking-payment-modal-builders';
16
16
 
17
- export type AdminChangeCheckoutModalData = ChangeBookingPaidCheckoutModalData;
17
+ export type AdminChangeCheckoutModalData = ChangeBookingPaidCheckoutModalData & {
18
+ finalizeExistingChangeInline?: boolean;
19
+ };
18
20
 
19
21
  export interface AdminChangePaymentChoiceData {
20
22
  reservationReference: string;
@@ -38,6 +40,13 @@ export interface AdminChangePaymentChoiceData {
38
40
  taxRate: number;
39
41
  promoDiscountAmount: number;
40
42
  discountLabel?: string | null;
43
+ dialogTitle?: string;
44
+ dialogDescription?: string;
45
+ confirmWithoutPaymentLabel?: string;
46
+ previousTotal?: number;
47
+ newTotal?: number;
48
+ finalizeExistingChangeInline?: boolean;
49
+ preferConfirmWithoutPayment?: boolean;
41
50
  }
42
51
 
43
52
  export interface BuildAdminChangePaymentChoiceDataParams {
@@ -61,6 +70,13 @@ export interface BuildAdminChangePaymentChoiceDataParams {
61
70
  taxRate: number;
62
71
  promoDiscountAmount: number;
63
72
  discountLabel?: string | null;
73
+ dialogTitle?: string;
74
+ dialogDescription?: string;
75
+ confirmWithoutPaymentLabel?: string;
76
+ previousTotal?: number;
77
+ newTotal?: number;
78
+ finalizeExistingChangeInline?: boolean;
79
+ preferConfirmWithoutPayment?: boolean;
64
80
  }
65
81
 
66
82
  export function buildAdminChangePaymentChoiceData(
@@ -93,6 +109,15 @@ export function buildAdminChangePayNowCheckoutModalData(
93
109
  taxRate: adminChoiceData.taxRate,
94
110
  promoDiscountAmount: adminChoiceData.promoDiscountAmount,
95
111
  discountLabel: adminChoiceData.discountLabel,
112
+ finalizeExistingChangeInline: adminChoiceData.finalizeExistingChangeInline,
113
+ changeTotals:
114
+ adminChoiceData.previousTotal != null && adminChoiceData.newTotal != null
115
+ ? {
116
+ previousTotal: adminChoiceData.previousTotal,
117
+ newTotal: adminChoiceData.newTotal,
118
+ differenceTotal: adminChoiceData.totalAmount,
119
+ }
120
+ : undefined,
96
121
  };
97
122
  }
98
123
 
@@ -36,6 +36,8 @@ export interface BuildAdminChangeProviderPayloadParams {
36
36
  refundDispositionsByOperationId: NonNullable<
37
37
  ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
38
38
  >;
39
+ pricingV2QuoteId?: string | null;
40
+ pricingV2QuotedTotal?: number | null;
39
41
  providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
40
42
  previousPassengerCount: number;
41
43
  previousAvailabilityId?: string | null;
@@ -59,6 +61,8 @@ export function buildAdminChangeProviderPayload({
59
61
  mergedProviderAdditionalAdjustments,
60
62
  adminStructuredAdjustments,
61
63
  refundDispositionsByOperationId,
64
+ pricingV2QuoteId,
65
+ pricingV2QuotedTotal,
62
66
  providerApplyAuthoritativeReceipt,
63
67
  previousPassengerCount,
64
68
  previousAvailabilityId,
@@ -73,6 +77,7 @@ export function buildAdminChangeProviderPayload({
73
77
  return {
74
78
  productId: availabilityProductOptionId,
75
79
  parentProductId: product.productId,
80
+ availabilityId: selectedAvailability.availabilityId ?? null,
76
81
  dateTime: selectedAvailability.dateTime,
77
82
  bookingItems,
78
83
  returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
@@ -83,7 +88,8 @@ export function buildAdminChangeProviderPayload({
83
88
  passengerCount: null,
84
89
  childSafetySeatsCount: null,
85
90
  foodRestrictions: null,
86
- addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
91
+ // The admin editor owns the complete target selection. [] intentionally means clear all.
92
+ addOnSelections,
87
93
  cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
88
94
  promoCode: appliedPromoCode ?? null,
89
95
  newTotalAmount,
@@ -101,6 +107,8 @@ export function buildAdminChangeProviderPayload({
101
107
  authoritativeReceipt: providerApplyAuthoritativeReceipt,
102
108
  structuredAdjustments: adminStructuredAdjustments,
103
109
  refundDispositionsByOperationId,
110
+ pricingV2QuoteId: pricingV2QuoteId ?? null,
111
+ pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
104
112
  capacitySeatCredit: {
105
113
  enabled: true,
106
114
  previousPassengerCount,
@@ -10,7 +10,9 @@ interface AdminChangeQuoteRequestKeyInput {
10
10
  returnAvailabilityId: string | null;
11
11
  quantities: Record<string, number>;
12
12
  addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
13
- adminCustomReceiptLines: Array<{ label: string; amountInput: string; amountSign?: number }>;
13
+ adminCustomReceiptLines: unknown[];
14
+ cancellationPolicyId?: string | null;
15
+ structuredAdjustments?: unknown[];
14
16
  refundDispositionsByOperationId?: Record<string, string>;
15
17
  useAdminFeAuthoritativeQuote: boolean;
16
18
  }
@@ -33,6 +35,8 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
33
35
  quantities: input.quantities,
34
36
  addOnSelections: input.addOnSelections,
35
37
  adminCustomReceiptLines: input.adminCustomReceiptLines,
38
+ cancellationPolicyId: input.cancellationPolicyId ?? null,
39
+ structuredAdjustments: input.structuredAdjustments ?? [],
36
40
  refundDispositionsByOperationId: Object.fromEntries(
37
41
  Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
38
42
  ),
@@ -74,9 +74,7 @@ export async function quoteCustomerChangeForCheckout({
74
74
  newPickupLocationId: pickupLocationId || null,
75
75
  newReturnAvailabilityId: returnAvailabilityId ?? null,
76
76
  newPassengerCounts: bookingItems,
77
- ...(addOnSelections && addOnSelections.length > 0
78
- ? { newAddOnSelections: addOnSelections }
79
- : {}),
77
+ ...(addOnSelections ? { newAddOnSelections: addOnSelections } : {}),
80
78
  clientProposedTotal,
81
79
  });
82
80
  const { quoteSlice, mergedQuote } = sliceAndMergeChangeQuoteForUi(
@@ -163,9 +161,7 @@ export async function quoteAdminChangeForCheckout({
163
161
  newPickupLocationId: pickupLocationId || null,
164
162
  newReturnAvailabilityId: returnAvailabilityId ?? null,
165
163
  newPassengerCounts: bookingItems,
166
- ...(addOnSelections && addOnSelections.length > 0
167
- ? { newAddOnSelections: addOnSelections }
168
- : {}),
164
+ ...(addOnSelections ? { newAddOnSelections: addOnSelections } : {}),
169
165
  ...(manualLineAdjustments && manualLineAdjustments.length > 0
170
166
  ? { manualLineAdjustments }
171
167
  : {}),
@@ -37,6 +37,8 @@ export type ProviderDashboardChangeBookingPayload = {
37
37
  parentProductId?: string;
38
38
  /** Product option id selected for the new booking. */
39
39
  productId: string;
40
+ /** Exact outbound availability selected and priced by the server. */
41
+ availabilityId?: string | null;
40
42
  dateTime: string;
41
43
  bookingItems: Array<{ category: string; count: number }>;
42
44
  returnAvailabilityId?: string | null;
@@ -67,6 +69,9 @@ export type ProviderDashboardChangeBookingPayload = {
67
69
  string,
68
70
  'REFUND_TO_ORIGINAL_PAYMENT' | 'PENDING_REFUND' | 'NO_REFUND'
69
71
  >;
72
+ /** Exact server quote shown to the operator. Apply reuses this quote when it is still valid. */
73
+ pricingV2QuoteId?: string | null;
74
+ pricingV2QuotedTotal?: number | null;
70
75
  /**
71
76
  * Admin/provider change flow: when FE-authoritative quoting is used, apply must persist
72
77
  * the exact receipt that was quoted instead of rebuilding from catalog pricing.
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
2
2
  import {
3
3
  cancelReservation,
4
4
  cancelReservationBestEffort,
5
+ applyAdminChangeBookingV2,
6
+ createAdminChangeBookingPaymentIntentV2,
5
7
  createChangeBookingPaymentIntent,
6
8
  createPaymentIntent,
7
9
  describeStandardTourCapacityConflictMessage,
@@ -338,6 +340,8 @@ export function useAdminChangeCheckoutController({
338
340
  mergedProviderAdditionalAdjustments,
339
341
  adminStructuredAdjustments,
340
342
  refundDispositionsByOperationId,
343
+ pricingV2QuoteId: latestChangeQuote?.quoteId ?? null,
344
+ pricingV2QuotedTotal: latestChangeQuote?.quotedTotal ?? latestChangeQuote?.serverDisplay?.total ?? null,
341
345
  providerApplyAuthoritativeReceipt,
342
346
  previousPassengerCount: changeFlowInitialTicketCount,
343
347
  previousAvailabilityId: initialValues?.availabilityId ?? null,
@@ -496,7 +500,9 @@ export function useAdminChangeCheckoutController({
496
500
  const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
497
501
  const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
498
502
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
499
- const amountDueForCheckout = isCustomerSelfServeChange
503
+ const amountDueForCheckout = isProviderDashboardChange && isChangeBookingContext
504
+ ? Math.max(0, latestChangeQuote?.priceDiff ?? 0)
505
+ : isCustomerSelfServeChange
500
506
  ? confirmedChangeAmountDueForCheckout ??
501
507
  Math.max(
502
508
  0,
@@ -535,6 +541,63 @@ export function useAdminChangeCheckoutController({
535
541
  roundingLabel: t('booking.rounding') || 'Rounding',
536
542
  });
537
543
 
544
+ // Provider changes reuse the exact stored admin quote for both settlement choices.
545
+ // Pay later applies immediately with a pending balance. Pay now first creates a
546
+ // quote-bound Stripe authorization and applies only after that authorization succeeds.
547
+ if (isProviderDashboardChange && isChangeBookingContext) {
548
+ const bookingReference = initialValues?.bookingReference?.trim();
549
+ const quoteId = latestChangeQuote?.quoteId?.trim();
550
+ if (!bookingReference) throw new Error('Missing booking reference.');
551
+ if (!quoteId || latestChangeQuote?.canProceed === false) {
552
+ throw new Error(latestChangeQuote?.reasonIfBlocked || 'The authoritative change quote is not ready.');
553
+ }
554
+ if (amountDueForCheckout <= 0) {
555
+ if (!onChangeBooking) throw new Error('Provider change handler is unavailable.');
556
+ const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
557
+ if (!providerPayload) throw new Error('No availability selected');
558
+ await onChangeBooking(providerPayload);
559
+ onSuccess?.({ reservationReference: bookingReference });
560
+ finishManageFlow(bookingReference);
561
+ setLoading(false);
562
+ return;
563
+ }
564
+ setAdminChoiceData(buildAdminChangePaymentChoiceData({
565
+ reservationReference: bookingReference,
566
+ checkoutBreakdown,
567
+ totalAmount: amountDueForCheckout,
568
+ datePart,
569
+ timePart,
570
+ availabilityProductOptionId,
571
+ itineraryDisplay: itineraryDisplay ?? undefined,
572
+ clientSecret: '',
573
+ ticketLinesForModal: checkoutModalTicketLinesDisplay,
574
+ feeLineItems: feeLineItemsWithAddOns,
575
+ returnPriceAdjustment: checkoutReturnLineAmount,
576
+ cancellationPolicyFee,
577
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
578
+ subtotal: effectiveSubtotalForCheckout,
579
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
580
+ totalQuantity,
581
+ isTaxIncludedInPrice,
582
+ taxRate: pricingConfig?.taxRate ?? 0,
583
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
584
+ discountLabel: appliedPromoCode
585
+ ? `Promo: ${appliedPromoCode}`
586
+ : (originalReceiptPromoAdjustment.label || originalReceipt?.promoLabel || undefined),
587
+ dialogTitle: 'Complete booking change',
588
+ dialogDescription:
589
+ 'Pay later to apply the change and add the amount to the booking balance, or collect a new card payment before applying it.',
590
+ confirmWithoutPaymentLabel: 'Pay later',
591
+ previousTotal: originalReceipt?.total,
592
+ newTotal: latestChangeQuote?.quotedTotal ?? latestChangeQuote?.serverDisplay?.total,
593
+ finalizeExistingChangeInline: true,
594
+ preferConfirmWithoutPayment: true,
595
+ }));
596
+ setShowAdminPaymentChoice(true);
597
+ setLoading(false);
598
+ return;
599
+ }
600
+
538
601
  const paymentIntent = isCustomerSelfServeChange
539
602
  ? await createChangeBookingPaymentIntent(
540
603
  (() => {
@@ -789,12 +852,38 @@ export function useAdminChangeCheckoutController({
789
852
  }
790
853
  };
791
854
 
792
- const handlePayNow = () => {
855
+ const handlePayNow = async () => {
793
856
  if (!adminChoiceData) return;
794
857
  if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
795
858
  setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
796
859
  return;
797
860
  }
861
+ if (isProviderDashboardChange && isChangeBookingContext) {
862
+ const bookingReference = initialValues?.bookingReference?.trim();
863
+ const quoteId = latestChangeQuote?.quoteId?.trim();
864
+ if (!bookingReference || !quoteId) {
865
+ setError('The authoritative change quote is no longer available.');
866
+ return;
867
+ }
868
+ setLoading(true);
869
+ setError('');
870
+ try {
871
+ const paymentIntent = await createAdminChangeBookingPaymentIntentV2(bookingReference, quoteId);
872
+ const expectedAmountCents = Math.round(adminChoiceData.totalAmount * 100);
873
+ if (paymentIntent.amountDueCents !== expectedAmountCents) {
874
+ throw new Error('The payment amount changed. Close this dialog and review a fresh quote.');
875
+ }
876
+ setShowAdminPaymentChoice(false);
877
+ setCheckoutClientSecret(paymentIntent.clientSecret);
878
+ setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
879
+ setShowCheckoutModal(true);
880
+ } catch (error) {
881
+ setError(error instanceof Error ? error.message : 'Failed to prepare payment.');
882
+ } finally {
883
+ setLoading(false);
884
+ }
885
+ return;
886
+ }
798
887
  setShowAdminPaymentChoice(false);
799
888
  setCheckoutClientSecret(adminChoiceData.clientSecret);
800
889
  setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
@@ -802,6 +891,32 @@ export function useAdminChangeCheckoutController({
802
891
  setAdminChoiceData(null);
803
892
  };
804
893
 
894
+ const handlePaymentConfirmed = useCallback(async (paymentIntentId: string) => {
895
+ if (!isProviderDashboardChange || !isChangeBookingContext) return;
896
+ const bookingReference = initialValues?.bookingReference?.trim();
897
+ const quoteId = latestChangeQuote?.quoteId?.trim();
898
+ if (!bookingReference || !quoteId) {
899
+ throw new Error('Payment was authorized, but the booking quote is no longer available.');
900
+ }
901
+ await applyAdminChangeBookingV2(bookingReference, quoteId, {
902
+ paymentMode: 'PAY_NOW',
903
+ paymentIntentId,
904
+ });
905
+ paymentSubmitInFlightRef.current = false;
906
+ setShowCheckoutModal(false);
907
+ setCheckoutModalData(null);
908
+ setCheckoutClientSecret('');
909
+ setAdminChoiceData(null);
910
+ onSuccess?.({ reservationReference: bookingReference });
911
+ finishManageFlow(bookingReference);
912
+ }, [
913
+ initialValues?.bookingReference,
914
+ isChangeBookingContext,
915
+ isProviderDashboardChange,
916
+ latestChangeQuote?.quoteId,
917
+ onSuccess,
918
+ ]);
919
+
805
920
  const handlePaymentSubmitStart = useCallback(() => {
806
921
  paymentSubmitInFlightRef.current = true;
807
922
  }, []);
@@ -829,6 +944,7 @@ export function useAdminChangeCheckoutController({
829
944
  handlePayNow,
830
945
  handlePaymentSubmitStart,
831
946
  handlePaymentSubmitError,
947
+ handlePaymentConfirmed,
832
948
  handleAdminPaymentChoiceCancel,
833
949
  };
834
950
  }
@@ -28,10 +28,18 @@ import {
28
28
  import type { Currency } from './CurrencySwitcher';
29
29
  import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
30
30
  import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
31
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
31
32
 
32
33
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
33
- type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
34
- type AdditionalAdjustment = { label: string; amount: number };
34
+ type AdminCustomReceiptLine = {
35
+ label: string;
36
+ amountInput: string;
37
+ amountSign?: number;
38
+ calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
39
+ scope: 'POSITIVE_AMENDMENT_VALUE' | 'CURRENT_ACTIVE_TICKETS' | 'CURRENT_ACTIVE_BOOKING';
40
+ taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
41
+ reasonCode: string;
42
+ };
35
43
 
36
44
  interface ChangeSelectionDetailsForPreview {
37
45
  dateChanged: boolean;
@@ -51,7 +59,9 @@ export interface UseAdminChangeQuotePreviewParams {
51
59
  pickupLocationId: string | null;
52
60
  quantities: Record<string, number>;
53
61
  addOnSelections: AddOnSelection[];
62
+ cancellationPolicyId: string | null;
54
63
  adminCustomReceiptLines: AdminCustomReceiptLine[];
64
+ adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
55
65
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
56
66
  useAdminFeAuthoritativeQuote: boolean;
57
67
  latestChangeQuote: AdminChangeLatestQuote | null;
@@ -78,7 +88,6 @@ export interface UseAdminChangeQuotePreviewParams {
78
88
  changeFlowNewBookingTotal: number;
79
89
  effectiveSubtotalForCheckout: number;
80
90
  effectiveTax: number;
81
- adminCustomLinesAsAdditionalAdjustments: AdditionalAdjustment[];
82
91
  adminFeAuthoritativeReceipt: AdminFeAuthoritativeReceipt;
83
92
  isTaxIncludedInPrice: boolean;
84
93
  t: (key: string) => string;
@@ -109,7 +118,9 @@ export function useAdminChangeQuotePreview({
109
118
  pickupLocationId,
110
119
  quantities,
111
120
  addOnSelections,
121
+ cancellationPolicyId,
112
122
  adminCustomReceiptLines,
123
+ adminStructuredAdjustments,
113
124
  refundDispositionsByOperationId,
114
125
  useAdminFeAuthoritativeQuote,
115
126
  latestChangeQuote,
@@ -136,7 +147,6 @@ export function useAdminChangeQuotePreview({
136
147
  changeFlowNewBookingTotal,
137
148
  effectiveSubtotalForCheckout,
138
149
  effectiveTax,
139
- adminCustomLinesAsAdditionalAdjustments,
140
150
  adminFeAuthoritativeReceipt,
141
151
  isTaxIncludedInPrice,
142
152
  t,
@@ -167,6 +177,8 @@ export function useAdminChangeQuotePreview({
167
177
  quantities,
168
178
  addOnSelections,
169
179
  adminCustomReceiptLines,
180
+ cancellationPolicyId,
181
+ structuredAdjustments: adminStructuredAdjustments,
170
182
  refundDispositionsByOperationId,
171
183
  useAdminFeAuthoritativeQuote,
172
184
  }), [
@@ -179,6 +191,8 @@ export function useAdminChangeQuotePreview({
179
191
  quantities,
180
192
  addOnSelections,
181
193
  adminCustomReceiptLines,
194
+ cancellationPolicyId,
195
+ adminStructuredAdjustments,
182
196
  refundDispositionsByOperationId,
183
197
  useAdminFeAuthoritativeQuote,
184
198
  ]);
@@ -420,11 +434,10 @@ export function useAdminChangeQuotePreview({
420
434
  newPickupLocationId: pickupLocationId || null,
421
435
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
422
436
  newPassengerCounts: bookingItems,
423
- // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
424
- ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
425
- ...(adminCustomLinesAsAdditionalAdjustments.length > 0
426
- ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
427
- : {}),
437
+ // Admin owns the complete target selection. [] intentionally clears all add-ons.
438
+ newAddOnSelections: addOnSelections,
439
+ cancellationPolicyId,
440
+ structuredAdjustments: adminStructuredAdjustments,
428
441
  ...(Object.keys(refundDispositionsByOperationId).length > 0
429
442
  ? { refundDispositionsByOperationId }
430
443
  : {}),
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useState } from 'react';
2
2
  import { roundMoney } from '../../lib/booking/change-flow-pricing';
3
3
  import type {
4
4
  ProviderDashboardChangePricingUi,
@@ -55,6 +55,9 @@ export function useAdminProviderPricingAdjustments({
55
55
  adminCustomReceiptLines,
56
56
  isManualOverrideEligibleLine,
57
57
  }: UseAdminProviderPricingAdjustmentsParams) {
58
+ const [adjustmentIdNamespace] = useState(() =>
59
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
60
+ );
58
61
  const providerTotalsPreview =
59
62
  showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
60
63
  ? providerPricingUi.totalsPreview
@@ -137,17 +140,18 @@ export function useAdminProviderPricingAdjustments({
137
140
  );
138
141
 
139
142
  const adminStructuredAdjustments = useMemo(
140
- (): AdminStructuredAdjustment[] =>
141
- adminCustomReceiptLines.flatMap((line) => {
143
+ (): AdminStructuredAdjustment[] => {
144
+ const customAdjustments = adminCustomReceiptLines.flatMap((line) => {
142
145
  const value = Number(line.amountInput.trim());
143
146
  if (!Number.isFinite(value) || value <= 0) return [];
144
147
  const discount = (line.amountSign ?? 1) < 0;
145
148
  const percentage = line.calculation === 'PERCENTAGE';
149
+ const mode: AdminStructuredAdjustment['mode'] = percentage
150
+ ? discount ? 'PERCENTAGE_DISCOUNT' : 'PERCENTAGE_SURCHARGE'
151
+ : 'FIXED_AMOUNT';
146
152
  return [{
147
153
  adjustmentId: line.id,
148
- mode: percentage
149
- ? discount ? 'PERCENTAGE_DISCOUNT' : 'PERCENTAGE_SURCHARGE'
150
- : 'FIXED_AMOUNT',
154
+ mode,
151
155
  ...(percentage
152
156
  ? { percentageBasisPoints: Math.round(value * 100) }
153
157
  : { fixedAmount: roundMoney((discount ? -1 : 1) * value) }),
@@ -156,8 +160,48 @@ export function useAdminProviderPricingAdjustments({
156
160
  reasonCode: line.reasonCode.trim() || 'STAFF_ADJUSTMENT',
157
161
  reason: line.label.trim() || null,
158
162
  }];
159
- }),
160
- [adminCustomReceiptLines],
163
+ });
164
+ const inlineAdjustments = isProviderDashboardChange
165
+ ? (providerPricingUi?.additionalAdjustments ?? []).flatMap((line) => {
166
+ const value = Number((line.amountInput ?? '').trim());
167
+ if (!Number.isFinite(value) || value <= 0) return [];
168
+ const discount = line.mode === 'DISCOUNT';
169
+ return [{
170
+ adjustmentId: line.id,
171
+ mode: 'FIXED_AMOUNT' as const,
172
+ fixedAmount: roundMoney((discount ? -1 : 1) * value),
173
+ scope: 'CURRENT_ACTIVE_BOOKING' as const,
174
+ taxBehavior: 'TAXABLE' as const,
175
+ reasonCode: 'PROVIDER_ADJUSTMENT',
176
+ reason: line.label?.trim() || (discount ? 'Provider discount' : 'Provider charge'),
177
+ }];
178
+ })
179
+ : [];
180
+ const lineOverrideAdjustments = providerPricingOverrides.flatMap((override, index) => {
181
+ const original = providerQuotedLines.find((line) => line.lineKey === override.lineKey);
182
+ if (!original) return [];
183
+ const delta = roundMoney(override.amount - original.amount);
184
+ if (Math.abs(delta) < 0.005) return [];
185
+ return [{
186
+ adjustmentId: `provider-line-${adjustmentIdNamespace}-${index}`,
187
+ mode: 'FIXED_AMOUNT' as const,
188
+ fixedAmount: delta,
189
+ scope: 'CURRENT_ACTIVE_BOOKING' as const,
190
+ taxBehavior: 'TAXABLE' as const,
191
+ reasonCode: 'PROVIDER_LINE_OVERRIDE',
192
+ reason: `Override ${original.label}`,
193
+ }];
194
+ });
195
+ return [...customAdjustments, ...inlineAdjustments, ...lineOverrideAdjustments];
196
+ },
197
+ [
198
+ adminCustomReceiptLines,
199
+ isProviderDashboardChange,
200
+ providerPricingUi?.additionalAdjustments,
201
+ providerPricingOverrides,
202
+ providerQuotedLines,
203
+ adjustmentIdNamespace,
204
+ ],
161
205
  );
162
206
 
163
207
  const mergedProviderAdditionalAdjustments = useMemo(
@@ -372,8 +372,8 @@ export function useChangeBookingQuotePreview({
372
372
  newPickupLocationId: pickupLocationId || null,
373
373
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
374
374
  newPassengerCounts: bookingItems,
375
- // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
376
- ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
375
+ // The editor owns the complete target selection. [] intentionally clears all add-ons.
376
+ newAddOnSelections: addOnSelections,
377
377
  clientProposedTotal: changeFlowNewBookingTotal,
378
378
  capacitySeatCredit: {
379
379
  enabled: true,
@@ -1502,6 +1502,21 @@ export interface CreateChangePaymentIntentResponse {
1502
1502
  currency: string;
1503
1503
  }
1504
1504
 
1505
+ export interface CreateAdminChangePaymentIntentV2Response extends CreateChangePaymentIntentResponse {
1506
+ quoteId: string;
1507
+ expiresAt: string;
1508
+ }
1509
+
1510
+ export interface ApplyAdminChangeBookingV2Response {
1511
+ bookingReference: string;
1512
+ reservationReference: string;
1513
+ status: string;
1514
+ quoteId: string;
1515
+ paymentAction: 'NONE' | 'PAID_CHARGE' | 'PENDING_CHARGE' | 'PENDING_REFUND' | string;
1516
+ amountToCharge: number;
1517
+ refundCandidate: number;
1518
+ }
1519
+
1505
1520
  export interface ConfirmFreeChangeResponse {
1506
1521
  status?: string;
1507
1522
  booking?: unknown;
@@ -1515,7 +1530,7 @@ export interface ApplyPaidChangeResponse {
1515
1530
  }
1516
1531
 
1517
1532
  export interface BookingChangeIntentStatusResponse {
1518
- status: 'CREATED' | 'PAYMENT_REQUIRED' | 'PAYMENT_SUCCEEDED' | 'APPLYING' | 'APPLIED' | 'FAILED' | 'EXPIRED';
1533
+ status: 'CREATED' | 'PAYMENT_REQUIRED' | 'PAYMENT_AUTHORIZED' | 'PAYMENT_SUCCEEDED' | 'APPLYING' | 'APPLIED' | 'FAILED' | 'EXPIRED';
1519
1534
  bookingReference?: string;
1520
1535
  booking?: unknown;
1521
1536
  failureCode?: string;
@@ -1763,6 +1778,62 @@ export async function createChangeBookingPaymentIntent(
1763
1778
  data) as CreateChangePaymentIntentResponse;
1764
1779
  }
1765
1780
 
1781
+ export async function createAdminChangeBookingPaymentIntentV2(
1782
+ bookingReference: string,
1783
+ quoteId: string,
1784
+ ): Promise<CreateAdminChangePaymentIntentV2Response> {
1785
+ const res = await fetch(
1786
+ `${API_BASE}/1/admin/bookings/${encodeURIComponent(bookingReference)}/change/payment-intent-v2`,
1787
+ {
1788
+ method: 'POST',
1789
+ headers: getAuthHeaders(),
1790
+ body: JSON.stringify({ quoteId }),
1791
+ },
1792
+ );
1793
+ if (!res.ok) {
1794
+ const err = await parseJsonSafely(res);
1795
+ const message = isApiErrorPayload(err)
1796
+ ? err.errorMessage || err.error || 'Failed to prepare payment for this booking change'
1797
+ : 'Failed to prepare payment for this booking change';
1798
+ throw new Error(message);
1799
+ }
1800
+ const data = await parseJsonSafely(res);
1801
+ return ((data as { data?: CreateAdminChangePaymentIntentV2Response } | null)?.data ??
1802
+ data) as CreateAdminChangePaymentIntentV2Response;
1803
+ }
1804
+
1805
+ export async function applyAdminChangeBookingV2(
1806
+ bookingReference: string,
1807
+ quoteId: string,
1808
+ options: {
1809
+ paymentMode: 'DEFER_TO_BALANCE' | 'PAY_NOW';
1810
+ paymentIntentId?: string;
1811
+ },
1812
+ ): Promise<ApplyAdminChangeBookingV2Response> {
1813
+ const res = await fetch(
1814
+ `${API_BASE}/1/admin/bookings/${encodeURIComponent(bookingReference)}/change/apply-v2`,
1815
+ {
1816
+ method: 'POST',
1817
+ headers: getAuthHeaders(),
1818
+ body: JSON.stringify({
1819
+ quoteId,
1820
+ paymentMode: options.paymentMode,
1821
+ ...(options.paymentIntentId ? { paymentIntentId: options.paymentIntentId } : {}),
1822
+ }),
1823
+ },
1824
+ );
1825
+ if (!res.ok) {
1826
+ const err = await parseJsonSafely(res);
1827
+ const message = isApiErrorPayload(err)
1828
+ ? err.errorMessage || err.error || 'Failed to apply this booking change'
1829
+ : 'Failed to apply this booking change';
1830
+ throw new Error(message);
1831
+ }
1832
+ const data = await parseJsonSafely(res);
1833
+ return ((data as { data?: ApplyAdminChangeBookingV2Response } | null)?.data ??
1834
+ data) as ApplyAdminChangeBookingV2Response;
1835
+ }
1836
+
1766
1837
  export async function confirmFreeChangeBooking(
1767
1838
  changeIntentId: string
1768
1839
  ): Promise<ConfirmFreeChangeResponse> {
@@ -34,6 +34,10 @@ import {
34
34
  } from '../src/components/booking/reservation-hold';
35
35
  import { buildAdminChangeProviderPayload } from '../src/components/booking/admin-change-provider-payload';
36
36
  import { buildPublicChangeAmountDueSummary } from '../src/components/booking/change-booking-flow-helpers';
37
+ import {
38
+ buildAdminChangePaymentChoiceData,
39
+ buildAdminChangePayNowCheckoutModalData,
40
+ } from '../src/components/booking/admin-change-payment-choice-runner';
37
41
 
38
42
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
39
43
  return {
@@ -73,6 +77,53 @@ test('admin payment choice countdown expires exactly at the reservation deadline
73
77
  assert.equal(formatReservationHoldTime(604), '10:04');
74
78
  });
75
79
 
80
+ test('admin existing-booking pay-now review keeps previous, updated, and exact difference totals', () => {
81
+ const choice = buildAdminChangePaymentChoiceData({
82
+ reservationReference: 'KBRCIGCO',
83
+ checkoutBreakdown: { lineItems: [] },
84
+ totalAmount: 256.58,
85
+ datePart: '2026-07-31',
86
+ timePart: '09:00',
87
+ availabilityProductOptionId: 'option_1',
88
+ clientSecret: '',
89
+ ticketLinesForModal: [],
90
+ feeLineItems: [],
91
+ returnPriceAdjustment: 39,
92
+ cancellationPolicyFee: 0,
93
+ subtotal: 236.48,
94
+ tax: 20.1,
95
+ totalQuantity: 3,
96
+ isTaxIncludedInPrice: false,
97
+ taxRate: 0.085,
98
+ promoDiscountAmount: 0,
99
+ previousTotal: 513.16,
100
+ newTotal: 769.74,
101
+ dialogTitle: 'Complete booking change',
102
+ dialogDescription: 'Pay later or collect a new card.',
103
+ confirmWithoutPaymentLabel: 'Pay later',
104
+ finalizeExistingChangeInline: true,
105
+ preferConfirmWithoutPayment: true,
106
+ } as Parameters<typeof buildAdminChangePaymentChoiceData>[0]);
107
+
108
+ const checkout = buildAdminChangePayNowCheckoutModalData(choice, 'Tauro');
109
+ assert.deepEqual(checkout.changeTotals, {
110
+ previousTotal: 513.16,
111
+ newTotal: 769.74,
112
+ differenceTotal: 256.58,
113
+ });
114
+ assert.equal(checkout.total, 256.58);
115
+ assert.equal(checkout.finalizeExistingChangeInline, true);
116
+ assert.equal(choice.confirmWithoutPaymentLabel, 'Pay later');
117
+ assert.equal(choice.preferConfirmWithoutPayment, true);
118
+ assert.equal(
119
+ buildAdminChangePayNowCheckoutModalData(
120
+ { ...choice, finalizeExistingChangeInline: undefined },
121
+ 'Tauro',
122
+ ).finalizeExistingChangeInline,
123
+ undefined,
124
+ );
125
+ });
126
+
76
127
  test('malformed reservation expiration fails closed', () => {
77
128
  assert.equal(reservationHoldSecondsRemaining('not-a-date', Date.now()), 0);
78
129
  assert.equal(reservationHoldHasExpired('not-a-date', Date.now()), true);
@@ -176,6 +227,8 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
176
227
  mergedProviderAdditionalAdjustments: [],
177
228
  adminStructuredAdjustments: [],
178
229
  refundDispositionsByOperationId: {},
230
+ pricingV2QuoteId: 'quote_reviewed',
231
+ pricingV2QuotedTotal: 202.87,
179
232
  previousPassengerCount: 1,
180
233
  previousAvailabilityId: 'availability_1',
181
234
  previousReturnAvailabilityId: 'return_1',
@@ -185,6 +238,10 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
185
238
  assert.deepEqual(payload?.itineraryDisplay, itineraryDisplay);
186
239
  assert.equal(payload?.pickupLocationId, 'pickup_new');
187
240
  assert.equal(payload?.travelerHotel, 'A Bear and Bison Inn');
241
+ assert.equal(payload?.availabilityId, 'availability_1');
242
+ assert.deepEqual(payload?.addOnSelections, []);
243
+ assert.equal(payload?.pricingV2QuoteId, 'quote_reviewed');
244
+ assert.equal(payload?.pricingV2QuotedTotal, 202.87);
188
245
  });
189
246
 
190
247
  test('public paid change preview shows amendment-only charges before combined tax', () => {
@@ -349,6 +406,35 @@ test('changing admin refund treatment creates a distinct authoritative quote req
349
406
  assert.notEqual(pending, noRefund);
350
407
  });
351
408
 
409
+ test('admin cancellation policy and structured adjustments are authoritative quote inputs', () => {
410
+ const base = {
411
+ bookingReference: 'DE8H04DX',
412
+ lastName: 'Tauro',
413
+ productId: 'p_1',
414
+ selectedAvailability: {
415
+ availabilityId: 'a_1',
416
+ productId: 'po_1',
417
+ dateTime: '2026-07-21T03:00:00-06:00',
418
+ vacancies: 10,
419
+ } as Parameters<typeof buildAdminChangeQuoteRequestKey>[0]['selectedAvailability'],
420
+ pickupLocationId: 'pickup_1',
421
+ returnAvailabilityId: 'return_1',
422
+ quantities: { ADULT: 1 },
423
+ addOnSelections: [],
424
+ adminCustomReceiptLines: [],
425
+ useAdminFeAuthoritativeQuote: true,
426
+ };
427
+ const original = buildAdminChangeQuoteRequestKey(base);
428
+ const policyChanged = buildAdminChangeQuoteRequestKey({ ...base, cancellationPolicyId: 'flexible' });
429
+ const adjustmentChanged = buildAdminChangeQuoteRequestKey({
430
+ ...base,
431
+ structuredAdjustments: [{ adjustmentId: 'adj_1', mode: 'FIXED_AMOUNT', fixedAmount: 10 }],
432
+ });
433
+
434
+ assert.notEqual(original, policyChanged);
435
+ assert.notEqual(original, adjustmentChanged);
436
+ });
437
+
352
438
  test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
353
439
  const selectedSummary = {
354
440
  availabilityId: 'a_private',
Binary file
Binary file
Binary file
Binary file
Binary file