@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 +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +1 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +2 -1
- package/src/components/booking/AdminChangeCheckoutDialogs.tsx +9 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +32 -10
- package/src/components/booking/CheckoutModal.tsx +35 -6
- package/src/components/booking/admin-change-payment-choice-runner.ts +26 -1
- package/src/components/booking/useAdminChangeCheckoutController.ts +116 -27
- package/src/lib/booking-api.ts +71 -0
- package/test/change-booking-helpers.test.ts +51 -0
- package/ticketboothapp-booking-1.2.48.tgz +0 -0
- package/ticketboothapp-booking-1.2.49.tgz +0 -0
- package/ticketboothapp-booking-1.2.50.tgz +0 -0
- package/ticketboothapp-booking-1.2.51.tgz +0 -0
- package/ticketboothapp-booking-1.2.52.tgz +0 -0
package/package.json
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
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
|
}
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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
|