@ticketboothapp/booking 1.2.162 → 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.162",
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;
@@ -930,6 +930,7 @@ export function AdminChangeBookingFlow({
930
930
  handlePayNow,
931
931
  handlePaymentSubmitStart,
932
932
  handlePaymentSubmitError,
933
+ handlePaymentConfirmed,
933
934
  handleAdminPaymentChoiceCancel,
934
935
  } = useAdminChangeCheckoutController({
935
936
  selectedAvailability,
@@ -1022,7 +1023,7 @@ export function AdminChangeBookingFlow({
1022
1023
  onPayNow: handlePayNow,
1023
1024
  onConfirmWithoutPayment: handleConfirmWithoutPayment, onAdminPaymentChoiceCancel: handleAdminPaymentChoiceCancel,
1024
1025
  onCheckoutClose: cancelPendingReservation, onPaymentSubmitStart: handlePaymentSubmitStart,
1025
- onPaymentSubmitError: handlePaymentSubmitError,
1026
+ onPaymentSubmitError: handlePaymentSubmitError, onPaymentConfirmed: handlePaymentConfirmed,
1026
1027
  isPartialLaunch,
1027
1028
  checkoutVisible: selectedAvailability != null,
1028
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
 
@@ -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,
@@ -419,31 +421,6 @@ export function useAdminChangeCheckoutController({
419
421
  return;
420
422
  }
421
423
 
422
- // Provider changes are applied by the dashboard through the exact Pricing V2 quote.
423
- // Do not create the legacy FE-authoritative change intent or collect a card payment first;
424
- // apply-v2 records any positive delta as a quote-linked pending balance.
425
- if (isProviderDashboardChange && isChangeBookingContext) {
426
- if (!onChangeBooking) {
427
- throw new Error('Provider change handler is unavailable.');
428
- }
429
- if (!latestChangeQuote?.quoteId || latestChangeQuote.canProceed === false) {
430
- throw new Error(latestChangeQuote?.reasonIfBlocked || 'The authoritative change quote is not ready.');
431
- }
432
- const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
433
- if (!providerPayload) {
434
- throw new Error('No availability selected');
435
- }
436
- await onChangeBooking(providerPayload);
437
- const bookingReference = initialValues?.bookingReference?.trim();
438
- if (!bookingReference) {
439
- throw new Error('Missing booking reference.');
440
- }
441
- onSuccess?.({ reservationReference: bookingReference });
442
- finishManageFlow(bookingReference);
443
- setLoading(false);
444
- return;
445
- }
446
-
447
424
  const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
448
425
  clientChannelSource: inferClientBookingSourceFromProductIds(
449
426
  product.productId,
@@ -523,7 +500,9 @@ export function useAdminChangeCheckoutController({
523
500
  const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
524
501
  const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
525
502
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
526
- const amountDueForCheckout = isCustomerSelfServeChange
503
+ const amountDueForCheckout = isProviderDashboardChange && isChangeBookingContext
504
+ ? Math.max(0, latestChangeQuote?.priceDiff ?? 0)
505
+ : isCustomerSelfServeChange
527
506
  ? confirmedChangeAmountDueForCheckout ??
528
507
  Math.max(
529
508
  0,
@@ -562,6 +541,63 @@ export function useAdminChangeCheckoutController({
562
541
  roundingLabel: t('booking.rounding') || 'Rounding',
563
542
  });
564
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
+
565
601
  const paymentIntent = isCustomerSelfServeChange
566
602
  ? await createChangeBookingPaymentIntent(
567
603
  (() => {
@@ -816,12 +852,38 @@ export function useAdminChangeCheckoutController({
816
852
  }
817
853
  };
818
854
 
819
- const handlePayNow = () => {
855
+ const handlePayNow = async () => {
820
856
  if (!adminChoiceData) return;
821
857
  if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
822
858
  setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
823
859
  return;
824
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
+ }
825
887
  setShowAdminPaymentChoice(false);
826
888
  setCheckoutClientSecret(adminChoiceData.clientSecret);
827
889
  setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
@@ -829,6 +891,32 @@ export function useAdminChangeCheckoutController({
829
891
  setAdminChoiceData(null);
830
892
  };
831
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
+
832
920
  const handlePaymentSubmitStart = useCallback(() => {
833
921
  paymentSubmitInFlightRef.current = true;
834
922
  }, []);
@@ -856,6 +944,7 @@ export function useAdminChangeCheckoutController({
856
944
  handlePayNow,
857
945
  handlePaymentSubmitStart,
858
946
  handlePaymentSubmitError,
947
+ handlePaymentConfirmed,
859
948
  handleAdminPaymentChoiceCancel,
860
949
  };
861
950
  }
@@ -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;
@@ -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);
Binary file
Binary file
Binary file
Binary file
Binary file