@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 +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +1 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +8 -2
- 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/admin-change-provider-payload.ts +9 -1
- package/src/components/booking/admin-change-quote-request-key.ts +5 -1
- package/src/components/booking/change-booking-checkout-quote-runner.ts +2 -6
- package/src/components/booking/provider-dashboard-change-booking.ts +5 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +118 -2
- package/src/components/booking/useAdminChangeQuotePreview.ts +22 -9
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +52 -8
- package/src/components/booking/useChangeBookingQuotePreview.ts +2 -2
- package/src/lib/booking-api.ts +72 -1
- package/test/change-booking-helpers.test.ts +86 -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
|
@@ -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=
|
|
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
|
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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 = {
|
|
34
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
376
|
-
|
|
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,
|
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;
|
|
@@ -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
|