@ticketboothapp/booking 1.2.151 → 1.2.153
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 +6 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +92 -1
- package/src/components/booking/AdminChangeCheckoutDialogs.tsx +1 -0
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +28 -1
- package/src/components/booking/AdminChangeReceiptComparison.tsx +108 -4
- package/src/components/booking/AdminPaymentChoiceModal.tsx +49 -3
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +1 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +1 -0
- package/src/components/booking/admin-change-provider-payload.ts +5 -0
- package/src/components/booking/admin-change-quote-request-key.ts +4 -0
- package/src/components/booking/admin-refund-disposition.ts +62 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +13 -0
- package/src/components/booking/reservation-hold.ts +27 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +13 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +24 -1
- package/src/components/booking/useAdminChangeQuotePreview.ts +12 -1
- package/src/lib/booking/change-flow-pricing.ts +6 -0
- package/src/lib/booking-api.ts +95 -32
- package/test/change-booking-helpers.test.ts +116 -2
package/package.json
CHANGED
|
@@ -159,6 +159,12 @@ const checkoutPanelPropKeys = [
|
|
|
159
159
|
'serverAmountDue',
|
|
160
160
|
'serverUpdatedTotal',
|
|
161
161
|
'serverQuoteError',
|
|
162
|
+
'refundDecisionOperations',
|
|
163
|
+
'allowedRefundDispositionsByOperationId',
|
|
164
|
+
'removedValueByOperationId',
|
|
165
|
+
'refundDispositionsByOperationId',
|
|
166
|
+
'onRefundDispositionChange',
|
|
167
|
+
'refundQuoteLoading',
|
|
162
168
|
'isTaxIncludedInPrice',
|
|
163
169
|
'taxRate',
|
|
164
170
|
'currency',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
4
|
import {
|
|
5
|
+
type AdminAmendmentRefundDisposition,
|
|
5
6
|
type Availability,
|
|
6
7
|
type ReturnOption,
|
|
7
8
|
} from '../../lib/booking-api';
|
|
@@ -54,6 +55,15 @@ import { useSeedPromoCodeInput } from './useSeedPromoCodeInput';
|
|
|
54
55
|
import { useAdminChangeProductReset } from './useAdminChangeProductReset';
|
|
55
56
|
import { useBookingQuantityCapTrim } from './useBookingQuantityCapTrim';
|
|
56
57
|
import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
|
|
58
|
+
import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
|
|
59
|
+
import type { AdminReleaseRefundDisposition } from './admin-refund-disposition';
|
|
60
|
+
|
|
61
|
+
type AdminRefundDecisionContext = {
|
|
62
|
+
selectionKey: string;
|
|
63
|
+
operations: NonNullable<AdminChangeLatestQuote['refundDecisionOperations']>;
|
|
64
|
+
allowedByOperationId: NonNullable<AdminChangeLatestQuote['allowedRefundDispositionsByOperationId']>;
|
|
65
|
+
removedValueByOperationId: NonNullable<AdminChangeLatestQuote['removedValueByOperationId']>;
|
|
66
|
+
};
|
|
57
67
|
|
|
58
68
|
/**
|
|
59
69
|
* ## Pricing contract (customer self-serve)
|
|
@@ -229,6 +239,10 @@ export function AdminChangeBookingFlow({
|
|
|
229
239
|
[isCustomerSelfServeChange, initialValues?.bookingItems, isAdmin],
|
|
230
240
|
);
|
|
231
241
|
const [latestChangeQuote, setLatestChangeQuote] = useState<AdminChangeLatestQuote | null>(null);
|
|
242
|
+
const [refundDecisionContext, setRefundDecisionContext] = useState<AdminRefundDecisionContext | null>(null);
|
|
243
|
+
const [refundDispositionsByOperationId, setRefundDispositionsByOperationId] = useState<
|
|
244
|
+
Record<string, AdminAmendmentRefundDisposition>
|
|
245
|
+
>({});
|
|
232
246
|
const [changeQuoteLoading, setChangeQuoteLoading] = useState(false);
|
|
233
247
|
const [changeQuoteFetchError, setChangeQuoteFetchError] = useState<string | null>(null);
|
|
234
248
|
/** Dedupe quote calls while user input payload is unchanged. */
|
|
@@ -238,7 +252,8 @@ export function AdminChangeBookingFlow({
|
|
|
238
252
|
suppressSelfServeCurrencyUi &&
|
|
239
253
|
latestChangeQuote != null &&
|
|
240
254
|
changeQuoteFetchError == null &&
|
|
241
|
-
latestChangeQuote.canProceed !== false
|
|
255
|
+
(latestChangeQuote.canProceed !== false ||
|
|
256
|
+
(latestChangeQuote.refundDecisionOperations?.length ?? 0) > 0) &&
|
|
242
257
|
latestChangeQuote.serverDisplay != null;
|
|
243
258
|
const changeQuoteRequestSeq = useRef(0);
|
|
244
259
|
const { activeOptions, activeOptionIdsKey, optionsMap } = useActiveProductOptions(product.options);
|
|
@@ -667,6 +682,72 @@ export function AdminChangeBookingFlow({
|
|
|
667
682
|
changeFlowAmountDue,
|
|
668
683
|
});
|
|
669
684
|
|
|
685
|
+
const refundDecisionSelectionKey = useMemo(() => buildAdminChangeQuoteRequestKey({
|
|
686
|
+
bookingReference: initialValues?.bookingReference,
|
|
687
|
+
lastName,
|
|
688
|
+
productId: product.productId,
|
|
689
|
+
selectedAvailability,
|
|
690
|
+
pickupLocationId,
|
|
691
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
692
|
+
quantities,
|
|
693
|
+
addOnSelections,
|
|
694
|
+
adminCustomReceiptLines,
|
|
695
|
+
useAdminFeAuthoritativeQuote,
|
|
696
|
+
}), [
|
|
697
|
+
initialValues?.bookingReference,
|
|
698
|
+
lastName,
|
|
699
|
+
product.productId,
|
|
700
|
+
selectedAvailability,
|
|
701
|
+
pickupLocationId,
|
|
702
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
703
|
+
quantities,
|
|
704
|
+
addOnSelections,
|
|
705
|
+
adminCustomReceiptLines,
|
|
706
|
+
useAdminFeAuthoritativeQuote,
|
|
707
|
+
]);
|
|
708
|
+
const responseRefundDecisionOperations = latestChangeQuote?.refundDecisionOperations ?? [];
|
|
709
|
+
useEffect(() => {
|
|
710
|
+
if (responseRefundDecisionOperations.length === 0) return;
|
|
711
|
+
setRefundDecisionContext({
|
|
712
|
+
selectionKey: refundDecisionSelectionKey,
|
|
713
|
+
operations: responseRefundDecisionOperations,
|
|
714
|
+
allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
|
|
715
|
+
removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
|
|
716
|
+
});
|
|
717
|
+
}, [
|
|
718
|
+
refundDecisionSelectionKey,
|
|
719
|
+
responseRefundDecisionOperations,
|
|
720
|
+
latestChangeQuote?.allowedRefundDispositionsByOperationId,
|
|
721
|
+
latestChangeQuote?.removedValueByOperationId,
|
|
722
|
+
]);
|
|
723
|
+
const activeRefundDecisionContext = responseRefundDecisionOperations.length > 0
|
|
724
|
+
? {
|
|
725
|
+
selectionKey: refundDecisionSelectionKey,
|
|
726
|
+
operations: responseRefundDecisionOperations,
|
|
727
|
+
allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
|
|
728
|
+
removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
|
|
729
|
+
}
|
|
730
|
+
: refundDecisionContext?.selectionKey === refundDecisionSelectionKey
|
|
731
|
+
? refundDecisionContext
|
|
732
|
+
: null;
|
|
733
|
+
const activeRefundDispositionsByOperationId = useMemo(() => Object.fromEntries(
|
|
734
|
+
(activeRefundDecisionContext?.operations ?? []).flatMap((operation) => {
|
|
735
|
+
const disposition = refundDispositionsByOperationId[operation.operationId];
|
|
736
|
+
return disposition ? [[operation.operationId, disposition] as const] : [];
|
|
737
|
+
}),
|
|
738
|
+
), [activeRefundDecisionContext?.operations, refundDispositionsByOperationId]);
|
|
739
|
+
const handleRefundDispositionChange = useCallback((
|
|
740
|
+
operationId: string,
|
|
741
|
+
disposition: AdminReleaseRefundDisposition | null,
|
|
742
|
+
) => {
|
|
743
|
+
setRefundDispositionsByOperationId((current) => {
|
|
744
|
+
if (disposition) return { ...current, [operationId]: disposition };
|
|
745
|
+
const next = { ...current };
|
|
746
|
+
delete next[operationId];
|
|
747
|
+
return next;
|
|
748
|
+
});
|
|
749
|
+
}, []);
|
|
750
|
+
|
|
670
751
|
const {
|
|
671
752
|
missingRequiredReturnSelection,
|
|
672
753
|
isChangeQuoteBlocked,
|
|
@@ -687,6 +768,7 @@ export function AdminChangeBookingFlow({
|
|
|
687
768
|
quantities,
|
|
688
769
|
addOnSelections,
|
|
689
770
|
adminCustomReceiptLines,
|
|
771
|
+
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
690
772
|
useAdminFeAuthoritativeQuote,
|
|
691
773
|
latestChangeQuote,
|
|
692
774
|
setLatestChangeQuote,
|
|
@@ -870,6 +952,7 @@ export function AdminChangeBookingFlow({
|
|
|
870
952
|
providerPricingOverrides,
|
|
871
953
|
mergedProviderAdditionalAdjustments,
|
|
872
954
|
adminStructuredAdjustments,
|
|
955
|
+
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
873
956
|
providerApplyAuthoritativeReceipt,
|
|
874
957
|
onSuccess,
|
|
875
958
|
totalPrice,
|
|
@@ -976,6 +1059,14 @@ export function AdminChangeBookingFlow({
|
|
|
976
1059
|
serverAmountDue: latestChangeQuote?.serverPreview?.amountDue ?? null,
|
|
977
1060
|
serverUpdatedTotal: latestChangeQuote?.serverPreview?.totalNewBooking ?? null,
|
|
978
1061
|
serverQuoteError: changeQuoteFetchError,
|
|
1062
|
+
refundDecisionOperations: activeRefundDecisionContext?.operations ?? [],
|
|
1063
|
+
allowedRefundDispositionsByOperationId:
|
|
1064
|
+
activeRefundDecisionContext?.allowedByOperationId ?? {},
|
|
1065
|
+
removedValueByOperationId:
|
|
1066
|
+
activeRefundDecisionContext?.removedValueByOperationId ?? {},
|
|
1067
|
+
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
1068
|
+
onRefundDispositionChange: handleRefundDispositionChange,
|
|
1069
|
+
refundQuoteLoading: changeQuoteLoading,
|
|
979
1070
|
...{ priceSummaryLinesIncludeTaxRow, isTaxIncludedInPrice, showProviderPricingInlineEditor },
|
|
980
1071
|
taxRate: pricingConfig?.taxRate,
|
|
981
1072
|
...{ providerPricingUi, providerQuotedLines },
|
|
@@ -57,6 +57,7 @@ export function AdminChangeCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
onPayNow={onPayNow}
|
|
61
62
|
onConfirmWithoutPayment={onConfirmWithoutPayment}
|
|
62
63
|
onCancel={onAdminPaymentChoiceCancel}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AdminAmendmentOperationSnapshot,
|
|
4
|
+
AdminAmendmentRefundDisposition,
|
|
5
|
+
Destination,
|
|
6
|
+
PickupLocation,
|
|
7
|
+
} from '../../lib/booking-api';
|
|
3
8
|
import { CheckoutForm } from './CheckoutForm';
|
|
4
9
|
import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
5
10
|
import type { Currency } from './CurrencySwitcher';
|
|
@@ -9,6 +14,7 @@ import type {
|
|
|
9
14
|
ProviderDashboardPricingLine,
|
|
10
15
|
} from './booking-flow-ui';
|
|
11
16
|
import { AdminChangeReceiptComparison } from './AdminChangeReceiptComparison';
|
|
17
|
+
import type { AdminReleaseRefundDisposition } from './admin-refund-disposition';
|
|
12
18
|
import {
|
|
13
19
|
AdminChangePricingAdjustments,
|
|
14
20
|
AdminChangePricingMessages,
|
|
@@ -34,6 +40,15 @@ export interface AdminChangeCheckoutPanelProps {
|
|
|
34
40
|
serverAmountDue: number | null;
|
|
35
41
|
serverUpdatedTotal: number | null;
|
|
36
42
|
serverQuoteError?: string | null;
|
|
43
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
44
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
45
|
+
removedValueByOperationId?: Record<string, number>;
|
|
46
|
+
refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
|
|
47
|
+
onRefundDispositionChange?: (
|
|
48
|
+
operationId: string,
|
|
49
|
+
disposition: AdminReleaseRefundDisposition | null,
|
|
50
|
+
) => void;
|
|
51
|
+
refundQuoteLoading?: boolean;
|
|
37
52
|
isTaxIncludedInPrice: boolean;
|
|
38
53
|
taxRate?: number;
|
|
39
54
|
currency: Currency;
|
|
@@ -107,6 +122,12 @@ export function AdminChangeCheckoutPanel({
|
|
|
107
122
|
serverAmountDue,
|
|
108
123
|
serverUpdatedTotal,
|
|
109
124
|
serverQuoteError,
|
|
125
|
+
refundDecisionOperations,
|
|
126
|
+
allowedRefundDispositionsByOperationId,
|
|
127
|
+
removedValueByOperationId,
|
|
128
|
+
refundDispositionsByOperationId,
|
|
129
|
+
onRefundDispositionChange,
|
|
130
|
+
refundQuoteLoading,
|
|
110
131
|
isTaxIncludedInPrice,
|
|
111
132
|
taxRate,
|
|
112
133
|
currency,
|
|
@@ -194,6 +215,12 @@ export function AdminChangeCheckoutPanel({
|
|
|
194
215
|
updatedTotal={serverUpdatedTotal}
|
|
195
216
|
selectionChanged={hasEffectiveChangeSelection}
|
|
196
217
|
quoteError={serverQuoteError}
|
|
218
|
+
refundDecisionOperations={refundDecisionOperations}
|
|
219
|
+
allowedRefundDispositionsByOperationId={allowedRefundDispositionsByOperationId}
|
|
220
|
+
removedValueByOperationId={removedValueByOperationId}
|
|
221
|
+
refundDispositionsByOperationId={refundDispositionsByOperationId}
|
|
222
|
+
onRefundDispositionChange={onRefundDispositionChange}
|
|
223
|
+
refundQuoteLoading={refundQuoteLoading}
|
|
197
224
|
amountDueLabel={totalSummaryLabel}
|
|
198
225
|
currency={currency}
|
|
199
226
|
locale={locale}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
AdminAmendmentOperationSnapshot,
|
|
4
|
+
AdminAmendmentRefundDisposition,
|
|
5
|
+
} from '../../lib/booking-api';
|
|
2
6
|
import { mapQuoteLineItemsToPriceSummaryLines } from '../../lib/booking/change-booking-server-preview';
|
|
3
7
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
4
8
|
import type { Locale } from '../../lib/booking/i18n/config';
|
|
5
9
|
import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
6
10
|
import type { Currency } from './CurrencySwitcher';
|
|
7
11
|
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
12
|
+
import {
|
|
13
|
+
adminRemovalOperationLabel,
|
|
14
|
+
noRefundRemovedValue,
|
|
15
|
+
refundDecisionsComplete,
|
|
16
|
+
releaseRefundDispositionOptions,
|
|
17
|
+
type AdminReleaseRefundDisposition,
|
|
18
|
+
} from './admin-refund-disposition';
|
|
8
19
|
|
|
9
20
|
type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
10
21
|
type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
|
|
@@ -25,6 +36,15 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
25
36
|
t: TranslationFn;
|
|
26
37
|
taxRate?: number;
|
|
27
38
|
adjustments?: ReactNode;
|
|
39
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
40
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
41
|
+
removedValueByOperationId?: Record<string, number>;
|
|
42
|
+
refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
|
|
43
|
+
onRefundDispositionChange?: (
|
|
44
|
+
operationId: string,
|
|
45
|
+
disposition: AdminReleaseRefundDisposition | null,
|
|
46
|
+
) => void;
|
|
47
|
+
refundQuoteLoading?: boolean;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
function normalizeLocale(locale: string): Locale {
|
|
@@ -65,11 +85,29 @@ export function AdminChangeReceiptComparison({
|
|
|
65
85
|
t,
|
|
66
86
|
taxRate,
|
|
67
87
|
adjustments,
|
|
88
|
+
refundDecisionOperations = [],
|
|
89
|
+
allowedRefundDispositionsByOperationId = {},
|
|
90
|
+
removedValueByOperationId = {},
|
|
91
|
+
refundDispositionsByOperationId = {},
|
|
92
|
+
onRefundDispositionChange,
|
|
93
|
+
refundQuoteLoading = false,
|
|
68
94
|
}: AdminChangeReceiptComparisonProps) {
|
|
69
95
|
const displayLocale = normalizeLocale(locale);
|
|
70
96
|
const existingLines = originalLines(originalReceipt);
|
|
71
97
|
const originalCurrency = originalReceipt.currency ?? currency;
|
|
72
98
|
const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
|
|
99
|
+
const hasRefundDecisions = refundDecisionOperations.length > 0;
|
|
100
|
+
const refundSelectionsComplete = refundDecisionsComplete(
|
|
101
|
+
refundDecisionOperations,
|
|
102
|
+
refundDispositionsByOperationId,
|
|
103
|
+
);
|
|
104
|
+
const settlementPending = hasRefundDecisions && (!refundSelectionsComplete || refundQuoteLoading);
|
|
105
|
+
const displayedAmountDue = settlementPending ? null : amountDue;
|
|
106
|
+
const removedWithoutRefund = noRefundRemovedValue(
|
|
107
|
+
refundDecisionOperations,
|
|
108
|
+
refundDispositionsByOperationId,
|
|
109
|
+
removedValueByOperationId,
|
|
110
|
+
);
|
|
73
111
|
const amendmentTax = amendmentLines?.reduce(
|
|
74
112
|
(sum, line) =>
|
|
75
113
|
line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
|
|
@@ -104,14 +142,66 @@ export function AdminChangeReceiptComparison({
|
|
|
104
142
|
]
|
|
105
143
|
: [];
|
|
106
144
|
const amountDueClass =
|
|
107
|
-
|
|
145
|
+
displayedAmountDue != null && displayedAmountDue < -0.005
|
|
108
146
|
? 'text-emerald-700'
|
|
109
|
-
:
|
|
147
|
+
: displayedAmountDue != null && displayedAmountDue > 0.005
|
|
110
148
|
? 'text-stone-900'
|
|
111
149
|
: 'text-stone-700';
|
|
112
150
|
|
|
113
151
|
return (
|
|
114
152
|
<div className="min-w-0 space-y-3 overflow-visible">
|
|
153
|
+
{hasRefundDecisions ? (
|
|
154
|
+
<section className="rounded-lg border border-amber-300 bg-amber-50 p-3" aria-label="Refund treatment">
|
|
155
|
+
<div className="mb-3">
|
|
156
|
+
<h3 className="text-sm font-semibold text-stone-900">Choose refund treatment</h3>
|
|
157
|
+
<p className="mt-1 text-xs text-stone-600">
|
|
158
|
+
Removed paid value is never refunded automatically. Choose an explicit treatment, then the server will recalculate the settlement.
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="space-y-3">
|
|
162
|
+
{refundDecisionOperations.map((operation) => {
|
|
163
|
+
const options = releaseRefundDispositionOptions(
|
|
164
|
+
allowedRefundDispositionsByOperationId[operation.operationId],
|
|
165
|
+
);
|
|
166
|
+
const selectedValue = refundDispositionsByOperationId[operation.operationId];
|
|
167
|
+
const selected = options.find((option) => option.value === selectedValue);
|
|
168
|
+
const removedValue = removedValueByOperationId[operation.operationId];
|
|
169
|
+
return (
|
|
170
|
+
<div key={operation.operationId} className="rounded-md border border-amber-200 bg-white p-3">
|
|
171
|
+
<div className="mb-2 flex flex-wrap items-start justify-between gap-2 text-sm font-medium text-stone-900">
|
|
172
|
+
<span className="capitalize">{adminRemovalOperationLabel(operation)}</span>
|
|
173
|
+
{Number.isFinite(removedValue) ? (
|
|
174
|
+
<span>{formatCurrencyAmount(removedValue, currency, displayLocale)}</span>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
<select
|
|
178
|
+
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
|
|
179
|
+
value={selectedValue ?? ''}
|
|
180
|
+
onChange={(event) => {
|
|
181
|
+
const value = event.target.value;
|
|
182
|
+
onRefundDispositionChange?.(
|
|
183
|
+
operation.operationId,
|
|
184
|
+
value ? value as AdminReleaseRefundDisposition : null,
|
|
185
|
+
);
|
|
186
|
+
}}
|
|
187
|
+
aria-label={`Refund treatment for ${adminRemovalOperationLabel(operation)}`}
|
|
188
|
+
>
|
|
189
|
+
<option value="">Choose refund treatment…</option>
|
|
190
|
+
{options.map((option) => (
|
|
191
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
{selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
{refundQuoteLoading && refundSelectionsComplete ? (
|
|
200
|
+
<p className="mt-3 text-xs font-medium text-amber-800">Recalculating settlement…</p>
|
|
201
|
+
) : null}
|
|
202
|
+
</section>
|
|
203
|
+
) : null}
|
|
204
|
+
|
|
115
205
|
<div className="grid gap-4 md:grid-cols-2">
|
|
116
206
|
<section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
|
|
117
207
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
@@ -140,7 +230,7 @@ export function AdminChangeReceiptComparison({
|
|
|
140
230
|
<p className="mt-0.5 text-xs text-stone-500">Server-priced amendment</p>
|
|
141
231
|
</div>
|
|
142
232
|
<span className="shrink-0 text-xs font-medium text-stone-500">
|
|
143
|
-
{
|
|
233
|
+
{displayedAmountDue == null ? '—' : formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
|
|
144
234
|
</span>
|
|
145
235
|
</div>
|
|
146
236
|
{!selectionChanged ? (
|
|
@@ -156,6 +246,18 @@ export function AdminChangeReceiptComparison({
|
|
|
156
246
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
157
247
|
<p className="text-sm font-medium text-stone-700">Waiting for server price…</p>
|
|
158
248
|
</div>
|
|
249
|
+
) : settlementPending ? (
|
|
250
|
+
<div className="rounded-md border border-dashed border-amber-300 bg-white/70 px-3 py-4 text-center">
|
|
251
|
+
<p className="text-sm font-medium text-amber-900">Refund treatment required</p>
|
|
252
|
+
<p className="mt-1 text-xs text-stone-600">Choose how to handle the removed paid value above.</p>
|
|
253
|
+
</div>
|
|
254
|
+
) : amendmentDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
255
|
+
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
256
|
+
<p className="text-sm font-medium text-stone-800">No refund will be created</p>
|
|
257
|
+
<p className="mt-1 text-xs text-stone-600">
|
|
258
|
+
{formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
|
|
259
|
+
</p>
|
|
260
|
+
</div>
|
|
159
261
|
) : amendmentDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
160
262
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
161
263
|
<p className="text-sm font-medium text-stone-700">No pricing changes</p>
|
|
@@ -196,7 +298,9 @@ export function AdminChangeReceiptComparison({
|
|
|
196
298
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
|
197
299
|
<span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
|
|
198
300
|
<span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
|
|
199
|
-
{!selectionChanged ||
|
|
301
|
+
{!selectionChanged || displayedAmountDue == null
|
|
302
|
+
? '—'
|
|
303
|
+
: formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
|
|
200
304
|
</span>
|
|
201
305
|
</div>
|
|
202
306
|
</div>
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
3
4
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
4
5
|
import type { Currency } from './CurrencySwitcher';
|
|
6
|
+
import {
|
|
7
|
+
formatReservationHoldTime,
|
|
8
|
+
reservationHoldSecondsRemaining,
|
|
9
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
10
|
+
} from './reservation-hold';
|
|
5
11
|
|
|
6
12
|
interface AdminPaymentChoiceModalProps {
|
|
7
13
|
open: boolean;
|
|
@@ -9,6 +15,7 @@ interface AdminPaymentChoiceModalProps {
|
|
|
9
15
|
currency: Currency;
|
|
10
16
|
loading: boolean;
|
|
11
17
|
error: string;
|
|
18
|
+
reservationExpiration?: string;
|
|
12
19
|
onPayNow: () => void;
|
|
13
20
|
onConfirmWithoutPayment: () => void;
|
|
14
21
|
onCancel: () => void;
|
|
@@ -27,14 +34,27 @@ export function AdminPaymentChoiceModal({
|
|
|
27
34
|
currency,
|
|
28
35
|
loading,
|
|
29
36
|
error,
|
|
37
|
+
reservationExpiration,
|
|
30
38
|
onPayNow,
|
|
31
39
|
onConfirmWithoutPayment,
|
|
32
40
|
onCancel,
|
|
33
41
|
description,
|
|
34
42
|
payNowLabel,
|
|
35
43
|
}: AdminPaymentChoiceModalProps) {
|
|
44
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open || !reservationExpiration) return;
|
|
48
|
+
setNowMs(Date.now());
|
|
49
|
+
const interval = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
50
|
+
return () => window.clearInterval(interval);
|
|
51
|
+
}, [open, reservationExpiration]);
|
|
52
|
+
|
|
36
53
|
if (!open) return null;
|
|
37
54
|
|
|
55
|
+
const secondsRemaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
|
|
56
|
+
const expired = secondsRemaining !== null && secondsRemaining <= 0;
|
|
57
|
+
|
|
38
58
|
const modal = (
|
|
39
59
|
<div
|
|
40
60
|
className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
|
|
@@ -68,6 +88,32 @@ export function AdminPaymentChoiceModal({
|
|
|
68
88
|
</div>
|
|
69
89
|
|
|
70
90
|
<div className="p-6 flex flex-col gap-3 flex-1 min-h-0">
|
|
91
|
+
{secondsRemaining !== null ? (
|
|
92
|
+
<div
|
|
93
|
+
className={`rounded-lg border p-3 text-sm ${
|
|
94
|
+
expired
|
|
95
|
+
? 'border-red-200 bg-red-50 text-red-800'
|
|
96
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
|
97
|
+
}`}
|
|
98
|
+
role={expired ? 'alert' : 'status'}
|
|
99
|
+
aria-live="polite"
|
|
100
|
+
>
|
|
101
|
+
{expired ? (
|
|
102
|
+
<>
|
|
103
|
+
<p className="font-semibold">Reservation hold expired</p>
|
|
104
|
+
<p className="mt-1">{RESERVATION_HOLD_EXPIRED_MESSAGE}</p>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<p className="font-semibold">
|
|
109
|
+
Reservation held for {formatReservationHoldTime(secondsRemaining)}
|
|
110
|
+
</p>
|
|
111
|
+
<p className="mt-1">Complete payment or confirm the booking before the hold expires.</p>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
71
117
|
{error ? (
|
|
72
118
|
<p className="text-sm text-red-600" role="alert">
|
|
73
119
|
{error}
|
|
@@ -77,7 +123,7 @@ export function AdminPaymentChoiceModal({
|
|
|
77
123
|
<button
|
|
78
124
|
type="button"
|
|
79
125
|
onClick={onPayNow}
|
|
80
|
-
disabled={loading}
|
|
126
|
+
disabled={loading || expired}
|
|
81
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"
|
|
82
128
|
>
|
|
83
129
|
{loading ? 'Loading...' : `${payNowLabel ?? 'Pay now'} (${formatCurrencyAmount(totalAmount, currency)})`}
|
|
@@ -85,7 +131,7 @@ export function AdminPaymentChoiceModal({
|
|
|
85
131
|
<button
|
|
86
132
|
type="button"
|
|
87
133
|
onClick={onConfirmWithoutPayment}
|
|
88
|
-
disabled={loading}
|
|
134
|
+
disabled={loading || expired}
|
|
89
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"
|
|
90
136
|
>
|
|
91
137
|
Confirm without payment
|
|
@@ -96,7 +142,7 @@ export function AdminPaymentChoiceModal({
|
|
|
96
142
|
disabled={loading}
|
|
97
143
|
className="w-full py-2 text-sm text-stone-500 hover:text-stone-700 font-medium disabled:opacity-50"
|
|
98
144
|
>
|
|
99
|
-
Cancel
|
|
145
|
+
{expired ? 'Close and restart checkout' : 'Cancel'}
|
|
100
146
|
</button>
|
|
101
147
|
</div>
|
|
102
148
|
</div>
|
|
@@ -57,6 +57,7 @@ export function PrivateShuttleCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
description={
|
|
61
62
|
adminChoiceData?.isDepositPayment
|
|
62
63
|
? 'Pay the deposit now, or confirm without payment. The customer can pay the deposit or remaining balance from the Manage Booking page.'
|
|
@@ -57,6 +57,7 @@ export function StandardBookingCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
onPayNow={onPayNow}
|
|
61
62
|
onConfirmWithoutPayment={onConfirmWithoutPayment}
|
|
62
63
|
onCancel={onAdminPaymentChoiceCancel}
|
|
@@ -33,6 +33,9 @@ export interface BuildAdminChangeProviderPayloadParams {
|
|
|
33
33
|
providerPricingOverrides: AdminChangeProviderLineOverride[];
|
|
34
34
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
35
35
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
36
|
+
refundDispositionsByOperationId: NonNullable<
|
|
37
|
+
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
38
|
+
>;
|
|
36
39
|
providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
|
|
37
40
|
previousPassengerCount: number;
|
|
38
41
|
previousAvailabilityId?: string | null;
|
|
@@ -54,6 +57,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
54
57
|
providerPricingOverrides,
|
|
55
58
|
mergedProviderAdditionalAdjustments,
|
|
56
59
|
adminStructuredAdjustments,
|
|
60
|
+
refundDispositionsByOperationId,
|
|
57
61
|
providerApplyAuthoritativeReceipt,
|
|
58
62
|
previousPassengerCount,
|
|
59
63
|
previousAvailabilityId,
|
|
@@ -93,6 +97,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
93
97
|
: undefined,
|
|
94
98
|
authoritativeReceipt: providerApplyAuthoritativeReceipt,
|
|
95
99
|
structuredAdjustments: adminStructuredAdjustments,
|
|
100
|
+
refundDispositionsByOperationId,
|
|
96
101
|
capacitySeatCredit: {
|
|
97
102
|
enabled: true,
|
|
98
103
|
previousPassengerCount,
|
|
@@ -11,6 +11,7 @@ interface AdminChangeQuoteRequestKeyInput {
|
|
|
11
11
|
quantities: Record<string, number>;
|
|
12
12
|
addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
|
|
13
13
|
adminCustomReceiptLines: Array<{ label: string; amountInput: string; amountSign?: number }>;
|
|
14
|
+
refundDispositionsByOperationId?: Record<string, string>;
|
|
14
15
|
useAdminFeAuthoritativeQuote: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -32,6 +33,9 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
|
|
|
32
33
|
quantities: input.quantities,
|
|
33
34
|
addOnSelections: input.addOnSelections,
|
|
34
35
|
adminCustomReceiptLines: input.adminCustomReceiptLines,
|
|
36
|
+
refundDispositionsByOperationId: Object.fromEntries(
|
|
37
|
+
Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
38
|
+
),
|
|
35
39
|
useAdminFeAuthoritativeQuote: input.useAdminFeAuthoritativeQuote,
|
|
36
40
|
});
|
|
37
41
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdminAmendmentOperationSnapshot,
|
|
3
|
+
AdminAmendmentRefundDisposition,
|
|
4
|
+
} from '../../lib/booking-api';
|
|
5
|
+
|
|
6
|
+
export type AdminReleaseRefundDisposition = Exclude<
|
|
7
|
+
AdminAmendmentRefundDisposition,
|
|
8
|
+
'REFUND_TO_ORIGINAL_PAYMENT'
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
export type AdminRefundDispositionOption = {
|
|
12
|
+
value: AdminReleaseRefundDisposition;
|
|
13
|
+
label: string;
|
|
14
|
+
description: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const RELEASE_OPTIONS: AdminRefundDispositionOption[] = [
|
|
18
|
+
{
|
|
19
|
+
value: 'PENDING_REFUND',
|
|
20
|
+
label: 'Record a pending refund',
|
|
21
|
+
description: 'Save the change with a pending refund for a separate reviewed Stripe action.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
value: 'NO_REFUND',
|
|
25
|
+
label: 'No refund',
|
|
26
|
+
description: 'Remove the paid value without creating a refund or customer credit.',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function releaseRefundDispositionOptions(
|
|
31
|
+
allowed: readonly AdminAmendmentRefundDisposition[] | undefined,
|
|
32
|
+
): AdminRefundDispositionOption[] {
|
|
33
|
+
if (!allowed) return RELEASE_OPTIONS;
|
|
34
|
+
return RELEASE_OPTIONS.filter((option) => allowed.includes(option.value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function adminRemovalOperationLabel(operation: AdminAmendmentOperationSnapshot): string {
|
|
38
|
+
const component = operation.componentKey.split(':').slice(1).join(':') || operation.componentKey;
|
|
39
|
+
const quantity = operation.quantityDelta ? ` (${Math.abs(operation.quantityDelta)})` : '';
|
|
40
|
+
return `${operation.type.replaceAll('_', ' ').toLowerCase()}: ${component}${quantity}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function refundDecisionsComplete(
|
|
44
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
45
|
+
selections: Readonly<Record<string, AdminAmendmentRefundDisposition>>,
|
|
46
|
+
): boolean {
|
|
47
|
+
return operations.length > 0 && operations.every((operation) => selections[operation.operationId] != null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function noRefundRemovedValue(
|
|
51
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
52
|
+
selections: Readonly<Record<string, AdminAmendmentRefundDisposition>>,
|
|
53
|
+
removedValueByOperationId: Readonly<Record<string, number>>,
|
|
54
|
+
): number {
|
|
55
|
+
return operations.reduce(
|
|
56
|
+
(sum, operation) =>
|
|
57
|
+
selections[operation.operationId] === 'NO_REFUND'
|
|
58
|
+
? sum + (removedValueByOperationId[operation.operationId] ?? 0)
|
|
59
|
+
: sum,
|
|
60
|
+
0,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
type PrivateShuttleCheckoutModalData,
|
|
48
48
|
} from './private-shuttle-payment-choice-runner';
|
|
49
49
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
50
|
+
import {
|
|
51
|
+
reservationHoldHasExpired,
|
|
52
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
53
|
+
} from './reservation-hold';
|
|
50
54
|
|
|
51
55
|
export interface UsePrivateShuttleCheckoutControllerParams {
|
|
52
56
|
selectedOption: string;
|
|
@@ -676,6 +680,10 @@ export function usePrivateShuttleCheckoutController({
|
|
|
676
680
|
const handleConfirmWithoutPayment = async () => {
|
|
677
681
|
const choice = adminChoiceData;
|
|
678
682
|
if (!choice) return;
|
|
683
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
684
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
679
687
|
setLoading(true);
|
|
680
688
|
setError('');
|
|
681
689
|
try {
|
|
@@ -718,6 +726,10 @@ export function usePrivateShuttleCheckoutController({
|
|
|
718
726
|
const handlePayNow = () => {
|
|
719
727
|
const choice = adminChoiceData;
|
|
720
728
|
if (!choice) return;
|
|
729
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
730
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
721
733
|
setShowAdminPaymentChoice(false);
|
|
722
734
|
setCheckoutClientSecret(choice.clientSecret);
|
|
723
735
|
setCheckoutModalData(buildPrivateShuttlePayNowCheckoutModalData(choice, lastName.trim()));
|
|
@@ -726,6 +738,7 @@ export function usePrivateShuttleCheckoutController({
|
|
|
726
738
|
};
|
|
727
739
|
|
|
728
740
|
const handleAdminPaymentChoiceCancel = () => {
|
|
741
|
+
cancelPendingReservation();
|
|
729
742
|
setShowAdminPaymentChoice(false);
|
|
730
743
|
setAdminChoiceData(null);
|
|
731
744
|
setError('');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const RESERVATION_HOLD_EXPIRED_MESSAGE =
|
|
2
|
+
'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.';
|
|
3
|
+
|
|
4
|
+
export function reservationHoldSecondsRemaining(
|
|
5
|
+
reservationExpiration: string | undefined,
|
|
6
|
+
nowMs: number = Date.now(),
|
|
7
|
+
): number | null {
|
|
8
|
+
if (!reservationExpiration) return null;
|
|
9
|
+
const expirationMs = Date.parse(reservationExpiration);
|
|
10
|
+
if (!Number.isFinite(expirationMs)) return 0;
|
|
11
|
+
return Math.max(0, Math.ceil((expirationMs - nowMs) / 1000));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function reservationHoldHasExpired(
|
|
15
|
+
reservationExpiration: string | undefined,
|
|
16
|
+
nowMs: number = Date.now(),
|
|
17
|
+
): boolean {
|
|
18
|
+
const remaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
|
|
19
|
+
return remaining !== null && remaining <= 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatReservationHoldTime(seconds: number): string {
|
|
23
|
+
const safeSeconds = Math.max(0, Math.floor(seconds));
|
|
24
|
+
const minutes = Math.floor(safeSeconds / 60);
|
|
25
|
+
const remainder = safeSeconds % 60;
|
|
26
|
+
return `${minutes}:${String(remainder).padStart(2, '0')}`;
|
|
27
|
+
}
|
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type StandardCheckoutModalData,
|
|
43
43
|
} from './standard-booking-payment-choice-runner';
|
|
44
44
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
45
|
+
import {
|
|
46
|
+
reservationHoldHasExpired,
|
|
47
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
48
|
+
} from './reservation-hold';
|
|
45
49
|
|
|
46
50
|
export interface UseStandardBookingCheckoutControllerParams {
|
|
47
51
|
selectedAvailability: Availability | null;
|
|
@@ -590,6 +594,10 @@ export function useStandardBookingCheckoutController({
|
|
|
590
594
|
const handleConfirmWithoutPayment = async () => {
|
|
591
595
|
const choice = adminChoiceData;
|
|
592
596
|
if (!choice) return;
|
|
597
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
598
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
593
601
|
setLoading(true);
|
|
594
602
|
setError('');
|
|
595
603
|
try {
|
|
@@ -634,6 +642,10 @@ export function useStandardBookingCheckoutController({
|
|
|
634
642
|
const handlePayNow = () => {
|
|
635
643
|
const choice = adminChoiceData;
|
|
636
644
|
if (!choice) return;
|
|
645
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
646
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
637
649
|
setShowAdminPaymentChoice(false);
|
|
638
650
|
setCheckoutClientSecret(choice.clientSecret);
|
|
639
651
|
setCheckoutModalData(buildStandardPayNowCheckoutModalData(choice, lastName.trim()));
|
|
@@ -642,6 +654,7 @@ export function useStandardBookingCheckoutController({
|
|
|
642
654
|
};
|
|
643
655
|
|
|
644
656
|
const handleAdminPaymentChoiceCancel = () => {
|
|
657
|
+
cancelPendingReservation();
|
|
645
658
|
setShowAdminPaymentChoice(false);
|
|
646
659
|
setAdminChoiceData(null);
|
|
647
660
|
setError('');
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
isInsufficientCapacityReserveError,
|
|
9
9
|
reportReserveCapacityConflictClientContext,
|
|
10
10
|
type AddOn,
|
|
11
|
+
type AdminAmendmentOperationSnapshot,
|
|
12
|
+
type AdminAmendmentRefundDisposition,
|
|
11
13
|
type AdminFeAuthoritativeReceipt,
|
|
12
14
|
type Availability,
|
|
13
15
|
type ChangeBookingQuotePricingDriftDetail,
|
|
@@ -62,6 +64,10 @@ import {
|
|
|
62
64
|
confirmFreeAdminCustomerChange,
|
|
63
65
|
quoteAdminCustomerChangeForCheckout,
|
|
64
66
|
} from './admin-change-customer-quote-runner';
|
|
67
|
+
import {
|
|
68
|
+
reservationHoldHasExpired,
|
|
69
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
70
|
+
} from './reservation-hold';
|
|
65
71
|
|
|
66
72
|
type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
|
|
67
73
|
|
|
@@ -78,6 +84,9 @@ export interface AdminChangeLatestQuote {
|
|
|
78
84
|
pricingVersion?: string;
|
|
79
85
|
quotedTotal?: number;
|
|
80
86
|
paymentCreditTotal?: number;
|
|
87
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
88
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
89
|
+
removedValueByOperationId?: Record<string, number>;
|
|
81
90
|
serverDisplay?: { total: number; subtotal: number; tax: number };
|
|
82
91
|
quotePreviousTotalCents?: number;
|
|
83
92
|
quoteNewTotalCents?: number;
|
|
@@ -127,6 +136,9 @@ export interface UseAdminChangeCheckoutControllerParams {
|
|
|
127
136
|
providerPricingOverrides: AdminChangeProviderLineOverride[];
|
|
128
137
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
129
138
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
139
|
+
refundDispositionsByOperationId: NonNullable<
|
|
140
|
+
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
141
|
+
>;
|
|
130
142
|
providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
|
|
131
143
|
onSuccess?: ChangeBookingFlowProps['onSuccess'];
|
|
132
144
|
totalPrice: number;
|
|
@@ -225,6 +237,7 @@ export function useAdminChangeCheckoutController({
|
|
|
225
237
|
providerPricingOverrides,
|
|
226
238
|
mergedProviderAdditionalAdjustments,
|
|
227
239
|
adminStructuredAdjustments,
|
|
240
|
+
refundDispositionsByOperationId,
|
|
228
241
|
providerApplyAuthoritativeReceipt,
|
|
229
242
|
onSuccess,
|
|
230
243
|
totalPrice,
|
|
@@ -323,6 +336,7 @@ export function useAdminChangeCheckoutController({
|
|
|
323
336
|
providerPricingOverrides,
|
|
324
337
|
mergedProviderAdditionalAdjustments,
|
|
325
338
|
adminStructuredAdjustments,
|
|
339
|
+
refundDispositionsByOperationId,
|
|
326
340
|
providerApplyAuthoritativeReceipt,
|
|
327
341
|
previousPassengerCount: changeFlowInitialTicketCount,
|
|
328
342
|
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
@@ -690,6 +704,10 @@ export function useAdminChangeCheckoutController({
|
|
|
690
704
|
|
|
691
705
|
const handleConfirmWithoutPayment = async () => {
|
|
692
706
|
if (!adminChoiceData) return;
|
|
707
|
+
if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
|
|
708
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
693
711
|
setLoading(true);
|
|
694
712
|
setError('');
|
|
695
713
|
try {
|
|
@@ -771,6 +789,10 @@ export function useAdminChangeCheckoutController({
|
|
|
771
789
|
|
|
772
790
|
const handlePayNow = () => {
|
|
773
791
|
if (!adminChoiceData) return;
|
|
792
|
+
if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
|
|
793
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
774
796
|
setShowAdminPaymentChoice(false);
|
|
775
797
|
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
776
798
|
setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
|
|
@@ -787,10 +809,11 @@ export function useAdminChangeCheckoutController({
|
|
|
787
809
|
}, []);
|
|
788
810
|
|
|
789
811
|
const handleAdminPaymentChoiceCancel = useCallback(() => {
|
|
812
|
+
cancelPendingReservation();
|
|
790
813
|
setShowAdminPaymentChoice(false);
|
|
791
814
|
setAdminChoiceData(null);
|
|
792
815
|
setError('');
|
|
793
|
-
}, [setError]);
|
|
816
|
+
}, [cancelPendingReservation, setError]);
|
|
794
817
|
|
|
795
818
|
return {
|
|
796
819
|
showCheckoutModal,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
quoteChangeBooking,
|
|
11
11
|
quoteAdminChangeBookingV2,
|
|
12
|
+
type AdminAmendmentRefundDisposition,
|
|
12
13
|
type AdminFeAuthoritativeReceipt,
|
|
13
14
|
type Availability,
|
|
14
15
|
type ItineraryDisplayStep,
|
|
@@ -51,6 +52,7 @@ export interface UseAdminChangeQuotePreviewParams {
|
|
|
51
52
|
quantities: Record<string, number>;
|
|
52
53
|
addOnSelections: AddOnSelection[];
|
|
53
54
|
adminCustomReceiptLines: AdminCustomReceiptLine[];
|
|
55
|
+
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
54
56
|
useAdminFeAuthoritativeQuote: boolean;
|
|
55
57
|
latestChangeQuote: AdminChangeLatestQuote | null;
|
|
56
58
|
setLatestChangeQuote: Dispatch<SetStateAction<AdminChangeLatestQuote | null>>;
|
|
@@ -108,6 +110,7 @@ export function useAdminChangeQuotePreview({
|
|
|
108
110
|
quantities,
|
|
109
111
|
addOnSelections,
|
|
110
112
|
adminCustomReceiptLines,
|
|
113
|
+
refundDispositionsByOperationId,
|
|
111
114
|
useAdminFeAuthoritativeQuote,
|
|
112
115
|
latestChangeQuote,
|
|
113
116
|
setLatestChangeQuote,
|
|
@@ -151,6 +154,7 @@ export function useAdminChangeQuotePreview({
|
|
|
151
154
|
!!lastName.trim();
|
|
152
155
|
|
|
153
156
|
const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
|
|
157
|
+
const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
|
|
154
158
|
const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
|
|
155
159
|
bookingReference: initialValues?.bookingReference,
|
|
156
160
|
lastName,
|
|
@@ -161,6 +165,7 @@ export function useAdminChangeQuotePreview({
|
|
|
161
165
|
quantities,
|
|
162
166
|
addOnSelections,
|
|
163
167
|
adminCustomReceiptLines,
|
|
168
|
+
refundDispositionsByOperationId,
|
|
164
169
|
useAdminFeAuthoritativeQuote,
|
|
165
170
|
}), [
|
|
166
171
|
initialValues?.bookingReference,
|
|
@@ -172,6 +177,7 @@ export function useAdminChangeQuotePreview({
|
|
|
172
177
|
quantities,
|
|
173
178
|
addOnSelections,
|
|
174
179
|
adminCustomReceiptLines,
|
|
180
|
+
refundDispositionsByOperationId,
|
|
175
181
|
useAdminFeAuthoritativeQuote,
|
|
176
182
|
]);
|
|
177
183
|
const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
|
|
@@ -228,7 +234,9 @@ export function useAdminChangeQuotePreview({
|
|
|
228
234
|
const checkoutFormError =
|
|
229
235
|
(error || '') ||
|
|
230
236
|
(missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
|
|
231
|
-
(isCustomerSelfServeChange && isChangeQuoteBlocked
|
|
237
|
+
(isCustomerSelfServeChange && isChangeQuoteBlocked && !refundDecisionRequired
|
|
238
|
+
? (latestChangeQuote?.reasonIfBlocked ?? '')
|
|
239
|
+
: '') ||
|
|
232
240
|
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
233
241
|
|
|
234
242
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
@@ -407,6 +415,9 @@ export function useAdminChangeQuotePreview({
|
|
|
407
415
|
...(adminCustomLinesAsAdditionalAdjustments.length > 0
|
|
408
416
|
? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
|
|
409
417
|
: {}),
|
|
418
|
+
...(Object.keys(refundDispositionsByOperationId).length > 0
|
|
419
|
+
? { refundDispositionsByOperationId }
|
|
420
|
+
: {}),
|
|
410
421
|
clientProposedTotal: changeFlowNewBookingTotal,
|
|
411
422
|
capacitySeatCredit: {
|
|
412
423
|
enabled: true,
|
|
@@ -256,6 +256,9 @@ export interface ChangeQuoteUiSlice {
|
|
|
256
256
|
pricingVersion?: string;
|
|
257
257
|
quotedTotal?: number;
|
|
258
258
|
paymentCreditTotal?: number;
|
|
259
|
+
refundDecisionOperations?: ChangeBookingQuoteResponse['refundDecisionOperations'];
|
|
260
|
+
allowedRefundDispositionsByOperationId?: ChangeBookingQuoteResponse['allowedRefundDispositionsByOperationId'];
|
|
261
|
+
removedValueByOperationId?: ChangeBookingQuoteResponse['removedValueByOperationId'];
|
|
259
262
|
serverDisplay?: { total: number; subtotal: number; tax: number };
|
|
260
263
|
}
|
|
261
264
|
|
|
@@ -291,6 +294,9 @@ export function sliceChangeQuoteForUi(
|
|
|
291
294
|
pricingVersion: pricingQuote?.pricingVersion ?? undefined,
|
|
292
295
|
quotedTotal: pricingQuote?.payableTotal ?? pricingQuote?.totalAmount ?? quote.proposed?.total ?? quote.newReceipt?.total,
|
|
293
296
|
paymentCreditTotal: pricingQuote?.paymentCreditTotal ?? quote.paymentCreditTotal,
|
|
297
|
+
refundDecisionOperations: quote.refundDecisionOperations,
|
|
298
|
+
allowedRefundDispositionsByOperationId: quote.allowedRefundDispositionsByOperationId,
|
|
299
|
+
removedValueByOperationId: quote.removedValueByOperationId,
|
|
294
300
|
...(serverDisplay ? { serverDisplay } : {}),
|
|
295
301
|
};
|
|
296
302
|
}
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -68,6 +68,9 @@ function getUserFacingMessage(endpoint: string): string {
|
|
|
68
68
|
return 'Unable to hold your booking right now. Please try again.';
|
|
69
69
|
case '/checkout/payment-intent':
|
|
70
70
|
return 'Unable to continue to payment right now. Please try again.';
|
|
71
|
+
case '/checkout/confirm-booking-without-payment':
|
|
72
|
+
case '/1/partner/confirm-booking-without-payment':
|
|
73
|
+
return 'Unable to confirm this booking right now. Please try again or restart checkout.';
|
|
71
74
|
default:
|
|
72
75
|
return 'Something went wrong while loading booking details. Please try again.';
|
|
73
76
|
}
|
|
@@ -186,7 +189,11 @@ function createUserError(
|
|
|
186
189
|
bookingApiErrorCode?: string
|
|
187
190
|
): BookingClientError {
|
|
188
191
|
const supportCode = buildSupportCode(endpoint, errorClass);
|
|
189
|
-
const userMessage =
|
|
192
|
+
const userMessage = bookingApiErrorCode === 'RESERVATION_EXPIRED'
|
|
193
|
+
? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
|
|
194
|
+
: bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
|
|
195
|
+
? 'This reservation has already been completed or closed. Refresh before trying again.'
|
|
196
|
+
: `${getUserFacingMessage(endpoint)} (${supportCode})`;
|
|
190
197
|
const error = new Error(userMessage) as BookingClientError;
|
|
191
198
|
error.debugMessage = debugMessage;
|
|
192
199
|
if (bookingApiErrorCode) error.bookingApiErrorCode = bookingApiErrorCode;
|
|
@@ -1239,6 +1246,22 @@ export interface CreatePaymentIntentResponse {
|
|
|
1239
1246
|
pricingQuoteInputsHash?: string | null;
|
|
1240
1247
|
}
|
|
1241
1248
|
|
|
1249
|
+
export type AdminAmendmentRefundDisposition =
|
|
1250
|
+
| 'REFUND_TO_ORIGINAL_PAYMENT'
|
|
1251
|
+
| 'PENDING_REFUND'
|
|
1252
|
+
| 'NO_REFUND';
|
|
1253
|
+
|
|
1254
|
+
export interface AdminAmendmentOperationSnapshot {
|
|
1255
|
+
operationId: string;
|
|
1256
|
+
type: string;
|
|
1257
|
+
componentKey: string;
|
|
1258
|
+
quantityDelta?: number;
|
|
1259
|
+
oldIdentity?: string | null;
|
|
1260
|
+
targetIdentity?: string | null;
|
|
1261
|
+
changedFields?: string[];
|
|
1262
|
+
metadata?: Record<string, string>;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1242
1265
|
export interface ChangeBookingQuoteRequest {
|
|
1243
1266
|
bookingReference: string;
|
|
1244
1267
|
lastName: string;
|
|
@@ -1268,6 +1291,8 @@ export interface ChangeBookingQuoteRequest {
|
|
|
1268
1291
|
previousAvailabilityId?: string | null;
|
|
1269
1292
|
previousReturnAvailabilityId?: string | null;
|
|
1270
1293
|
} | null;
|
|
1294
|
+
/** Explicit admin treatment for server-classified removed paid value. Never defaulted by the client. */
|
|
1295
|
+
refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
|
|
1271
1296
|
}
|
|
1272
1297
|
|
|
1273
1298
|
/** FE-authored receipt payload for admin quote path (major units, booking currency). */
|
|
@@ -1309,6 +1334,9 @@ export interface AdminChangeBookingQuoteV2Data {
|
|
|
1309
1334
|
canApply: boolean;
|
|
1310
1335
|
reasonIfBlocked?: string | null;
|
|
1311
1336
|
unsupportedFeatures?: string[];
|
|
1337
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
1338
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
1339
|
+
removedValueByOperationId?: Record<string, number>;
|
|
1312
1340
|
}
|
|
1313
1341
|
|
|
1314
1342
|
export interface ChangeBookingQuoteReceipt {
|
|
@@ -1445,6 +1473,12 @@ export interface ChangeBookingQuoteResponse {
|
|
|
1445
1473
|
currency?: string;
|
|
1446
1474
|
canProceed?: boolean;
|
|
1447
1475
|
reasonIfBlocked?: string;
|
|
1476
|
+
/** Admin-only removal decisions required before this quote can be applied. */
|
|
1477
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
1478
|
+
/** Server-authorized choices. Release UI intentionally filters out immediate refund. */
|
|
1479
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
1480
|
+
/** Original paid basis plus allocated refundable tax for each removal operation. */
|
|
1481
|
+
removedValueByOperationId?: Record<string, number>;
|
|
1448
1482
|
/** When price check fails / disagrees: optional breakdown for UI line-vs-line comparison. */
|
|
1449
1483
|
pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
|
|
1450
1484
|
/** Optional BE debug: receipt-floor vs catalog ticket math (same-parent Rule A/B). */
|
|
@@ -1614,6 +1648,41 @@ export async function quoteChangeBookingAdminFeReceipt(
|
|
|
1614
1648
|
data) as ChangeBookingQuoteResponse;
|
|
1615
1649
|
}
|
|
1616
1650
|
|
|
1651
|
+
export function mapAdminChangeBookingQuoteV2Data(
|
|
1652
|
+
data: AdminChangeBookingQuoteV2Data,
|
|
1653
|
+
): ChangeBookingQuoteResponse {
|
|
1654
|
+
const oldReceipt = data.oldReceipt;
|
|
1655
|
+
const newQuote = data.newQuote ?? null;
|
|
1656
|
+
const currency = newQuote?.currency ?? oldReceipt.currency ?? undefined;
|
|
1657
|
+
return {
|
|
1658
|
+
pricingQuote: newQuote,
|
|
1659
|
+
quote: newQuote,
|
|
1660
|
+
balanceDelta: data.balanceDelta ?? newQuote?.balanceDelta ?? undefined,
|
|
1661
|
+
amountToCharge: data.amountToCharge ?? newQuote?.amountToCharge ?? undefined,
|
|
1662
|
+
refundCandidate: data.refundCandidate ?? newQuote?.refundCandidate ?? undefined,
|
|
1663
|
+
priceDiff: data.balanceDelta ?? newQuote?.balanceDelta ?? 0,
|
|
1664
|
+
currency: currency ?? undefined,
|
|
1665
|
+
canProceed: data.canApply,
|
|
1666
|
+
reasonIfBlocked: data.reasonIfBlocked ?? undefined,
|
|
1667
|
+
refundDecisionOperations: data.refundDecisionOperations ?? [],
|
|
1668
|
+
allowedRefundDispositionsByOperationId:
|
|
1669
|
+
data.allowedRefundDispositionsByOperationId ?? {},
|
|
1670
|
+
removedValueByOperationId: data.removedValueByOperationId ?? {},
|
|
1671
|
+
originalReceipt: {
|
|
1672
|
+
subtotal: oldReceipt.grossSubtotal ?? undefined,
|
|
1673
|
+
tax: oldReceipt.taxAmount ?? undefined,
|
|
1674
|
+
total: oldReceipt.payableTotal ?? 0,
|
|
1675
|
+
currency: oldReceipt.currency ?? undefined,
|
|
1676
|
+
lineItems: oldReceipt.lines?.map((line) => ({
|
|
1677
|
+
label: line.label ?? undefined,
|
|
1678
|
+
amount: line.amount ?? undefined,
|
|
1679
|
+
type: line.type ?? undefined,
|
|
1680
|
+
quantity: line.quantity ?? undefined,
|
|
1681
|
+
})),
|
|
1682
|
+
},
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1617
1686
|
/** Server-authoritative Pricing V2 quote used by the provider-dashboard amendment preview. */
|
|
1618
1687
|
export async function quoteAdminChangeBookingV2(
|
|
1619
1688
|
request: ChangeBookingQuoteRequest
|
|
@@ -1658,33 +1727,7 @@ export async function quoteAdminChangeBookingV2(
|
|
|
1658
1727
|
) {
|
|
1659
1728
|
throw new Error('Invalid admin Pricing V2 quote response');
|
|
1660
1729
|
}
|
|
1661
|
-
|
|
1662
|
-
const oldReceipt = data.oldReceipt;
|
|
1663
|
-
const newQuote = data.newQuote ?? null;
|
|
1664
|
-
const currency = newQuote?.currency ?? oldReceipt.currency ?? undefined;
|
|
1665
|
-
return {
|
|
1666
|
-
pricingQuote: newQuote,
|
|
1667
|
-
quote: newQuote,
|
|
1668
|
-
balanceDelta: data.balanceDelta ?? newQuote?.balanceDelta ?? undefined,
|
|
1669
|
-
amountToCharge: data.amountToCharge ?? newQuote?.amountToCharge ?? undefined,
|
|
1670
|
-
refundCandidate: data.refundCandidate ?? newQuote?.refundCandidate ?? undefined,
|
|
1671
|
-
priceDiff: data.balanceDelta ?? newQuote?.balanceDelta ?? 0,
|
|
1672
|
-
currency: currency ?? undefined,
|
|
1673
|
-
canProceed: data.canApply,
|
|
1674
|
-
reasonIfBlocked: data.reasonIfBlocked ?? undefined,
|
|
1675
|
-
originalReceipt: {
|
|
1676
|
-
subtotal: oldReceipt.grossSubtotal ?? undefined,
|
|
1677
|
-
tax: oldReceipt.taxAmount ?? undefined,
|
|
1678
|
-
total: oldReceipt.payableTotal ?? 0,
|
|
1679
|
-
currency: oldReceipt.currency ?? undefined,
|
|
1680
|
-
lineItems: oldReceipt.lines?.map((line) => ({
|
|
1681
|
-
label: line.label ?? undefined,
|
|
1682
|
-
amount: line.amount ?? undefined,
|
|
1683
|
-
type: line.type ?? undefined,
|
|
1684
|
-
quantity: line.quantity ?? undefined,
|
|
1685
|
-
})),
|
|
1686
|
-
},
|
|
1687
|
-
};
|
|
1730
|
+
return mapAdminChangeBookingQuoteV2Data(candidate as AdminChangeBookingQuoteV2Data);
|
|
1688
1731
|
}
|
|
1689
1732
|
|
|
1690
1733
|
export async function createChangeBookingPaymentIntent(
|
|
@@ -2580,7 +2623,12 @@ export async function confirmBookingWithoutPayment(
|
|
|
2580
2623
|
httpStatus: res.status,
|
|
2581
2624
|
errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2582
2625
|
});
|
|
2583
|
-
throw createUserError(
|
|
2626
|
+
throw createUserError(
|
|
2627
|
+
endpoint,
|
|
2628
|
+
'HTTP',
|
|
2629
|
+
debugMessage,
|
|
2630
|
+
isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2631
|
+
);
|
|
2584
2632
|
}
|
|
2585
2633
|
const data = await parseJsonSafely(res);
|
|
2586
2634
|
const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
|
|
@@ -2591,7 +2639,12 @@ export async function confirmBookingWithoutPayment(
|
|
|
2591
2639
|
message: appError,
|
|
2592
2640
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2593
2641
|
});
|
|
2594
|
-
throw createUserError(
|
|
2642
|
+
throw createUserError(
|
|
2643
|
+
endpoint,
|
|
2644
|
+
'APP_ERROR_200',
|
|
2645
|
+
appError,
|
|
2646
|
+
isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2647
|
+
);
|
|
2595
2648
|
}
|
|
2596
2649
|
logBookingSourceDebug('response', '/checkout/confirm-booking-without-payment', data);
|
|
2597
2650
|
return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
|
|
@@ -2636,7 +2689,12 @@ export async function confirmPartnerBookingWithoutPayment(
|
|
|
2636
2689
|
httpStatus: res.status,
|
|
2637
2690
|
errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2638
2691
|
});
|
|
2639
|
-
throw createUserError(
|
|
2692
|
+
throw createUserError(
|
|
2693
|
+
endpoint,
|
|
2694
|
+
'HTTP',
|
|
2695
|
+
debugMessage,
|
|
2696
|
+
isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2697
|
+
);
|
|
2640
2698
|
}
|
|
2641
2699
|
const data = await parseJsonSafely(res);
|
|
2642
2700
|
const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
|
|
@@ -2647,7 +2705,12 @@ export async function confirmPartnerBookingWithoutPayment(
|
|
|
2647
2705
|
message: appError,
|
|
2648
2706
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2649
2707
|
});
|
|
2650
|
-
throw createUserError(
|
|
2708
|
+
throw createUserError(
|
|
2709
|
+
endpoint,
|
|
2710
|
+
'APP_ERROR_200',
|
|
2711
|
+
appError,
|
|
2712
|
+
isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2713
|
+
);
|
|
2651
2714
|
}
|
|
2652
2715
|
logBookingSourceDebug('response', endpoint, data);
|
|
2653
2716
|
return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
|
|
@@ -5,15 +5,31 @@ import {
|
|
|
5
5
|
} from '../src/components/booking/change-booking-payment-modal-builders';
|
|
6
6
|
import { evaluateChangeBookingQuoteForCheckout } from '../src/components/booking/change-booking-quote-guards';
|
|
7
7
|
import { buildCheckoutModalSummaryFromPricingV2Quote } from '../src/components/booking/pricing-v2-checkout-summary';
|
|
8
|
-
import
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
sliceChangeQuoteForUi,
|
|
10
|
+
type ChangeQuoteUiSlice,
|
|
11
|
+
} from '../src/lib/booking/change-flow-pricing';
|
|
12
|
+
import {
|
|
13
|
+
mapAdminChangeBookingQuoteV2Data,
|
|
14
|
+
type ChangeBookingQuoteResponse,
|
|
15
|
+
} from '../src/lib/booking-api';
|
|
10
16
|
import { buildAdminChangeQuoteRequestKey } from '../src/components/booking/admin-change-quote-request-key';
|
|
17
|
+
import {
|
|
18
|
+
noRefundRemovedValue,
|
|
19
|
+
refundDecisionsComplete,
|
|
20
|
+
releaseRefundDispositionOptions,
|
|
21
|
+
} from '../src/components/booking/admin-refund-disposition';
|
|
11
22
|
import {
|
|
12
23
|
applyResourceAdjustments,
|
|
13
24
|
privateShuttlePriceChanged,
|
|
14
25
|
resolveHydratedPrivateShuttleAvailability,
|
|
15
26
|
} from '../src/components/booking/private-shuttle-availability';
|
|
16
27
|
import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
|
|
28
|
+
import {
|
|
29
|
+
formatReservationHoldTime,
|
|
30
|
+
reservationHoldHasExpired,
|
|
31
|
+
reservationHoldSecondsRemaining,
|
|
32
|
+
} from '../src/components/booking/reservation-hold';
|
|
17
33
|
|
|
18
34
|
function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
|
|
19
35
|
return {
|
|
@@ -42,6 +58,23 @@ function test(name: string, fn: () => void): void {
|
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
test('admin payment choice countdown expires exactly at the reservation deadline', () => {
|
|
62
|
+
const expiration = '2026-07-16T15:46:44+00:00';
|
|
63
|
+
assert.equal(
|
|
64
|
+
reservationHoldSecondsRemaining(expiration, Date.parse('2026-07-16T15:46:34Z')),
|
|
65
|
+
10,
|
|
66
|
+
);
|
|
67
|
+
assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:43.999Z')), false);
|
|
68
|
+
assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:44Z')), true);
|
|
69
|
+
assert.equal(formatReservationHoldTime(604), '10:04');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('malformed reservation expiration fails closed', () => {
|
|
73
|
+
assert.equal(reservationHoldSecondsRemaining('not-a-date', Date.now()), 0);
|
|
74
|
+
assert.equal(reservationHoldHasExpired('not-a-date', Date.now()), true);
|
|
75
|
+
assert.equal(reservationHoldHasExpired(undefined, Date.now()), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
45
78
|
test('source attribution strips Stripe credentials and transient partner checkout identity', () => {
|
|
46
79
|
const sanitized = sanitizeBookingSourceUrl(
|
|
47
80
|
'https://staging.booking.viaviamorainelake.com/?partnerId=par_safe&agentId=agent_safe&agentName=Isadora+Buttonmoss&embed_manage=1&reservationRef=resRef_previous&lastName=Tauro&bookingDate=2026-07-16&payment_intent=pi_previous&payment_intent_client_secret=do_not_store&redirect_status=succeeded&tab=bookings',
|
|
@@ -105,6 +138,87 @@ test('admin quote response display state cannot invalidate its request identity'
|
|
|
105
138
|
assert.equal(keyAfterResponse, keyBeforeResponse);
|
|
106
139
|
});
|
|
107
140
|
|
|
141
|
+
test('admin removal quote preserves explicit release refund decisions for the preview', () => {
|
|
142
|
+
const operation = {
|
|
143
|
+
operationId: 'op_remove_adult',
|
|
144
|
+
type: 'REMOVE_QUANTITY',
|
|
145
|
+
componentKey: 'ticket:ADULT',
|
|
146
|
+
quantityDelta: -1,
|
|
147
|
+
};
|
|
148
|
+
const mapped = mapAdminChangeBookingQuoteV2Data({
|
|
149
|
+
oldReceipt: { currency: 'CAD', grossSubtotal: 357.46, taxAmount: 30.38, payableTotal: 387.84 },
|
|
150
|
+
newQuote: { currency: 'CAD', payableTotal: 193.92, balanceDelta: 0, amountToCharge: 0, refundCandidate: 0 },
|
|
151
|
+
canApply: false,
|
|
152
|
+
reasonIfBlocked: 'admin_refund_disposition_required',
|
|
153
|
+
refundDecisionOperations: [operation],
|
|
154
|
+
allowedRefundDispositionsByOperationId: {
|
|
155
|
+
[operation.operationId]: [
|
|
156
|
+
'REFUND_TO_ORIGINAL_PAYMENT',
|
|
157
|
+
'PENDING_REFUND',
|
|
158
|
+
'NO_REFUND',
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
removedValueByOperationId: { [operation.operationId]: 193.92 },
|
|
162
|
+
});
|
|
163
|
+
const slice = sliceChangeQuoteForUi(
|
|
164
|
+
mapped,
|
|
165
|
+
{ total: 193.92, subtotal: 178.73, tax: 15.19 },
|
|
166
|
+
'CAD',
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
assert.deepEqual(slice.refundDecisionOperations, [operation]);
|
|
170
|
+
assert.equal(slice.removedValueByOperationId?.[operation.operationId], 193.92);
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
releaseRefundDispositionOptions(
|
|
173
|
+
slice.allowedRefundDispositionsByOperationId?.[operation.operationId],
|
|
174
|
+
).map((option) => option.value),
|
|
175
|
+
['PENDING_REFUND', 'NO_REFUND'],
|
|
176
|
+
);
|
|
177
|
+
assert.equal(refundDecisionsComplete([operation], {}), false);
|
|
178
|
+
assert.equal(refundDecisionsComplete([operation], { [operation.operationId]: 'NO_REFUND' }), true);
|
|
179
|
+
assert.equal(
|
|
180
|
+
noRefundRemovedValue(
|
|
181
|
+
[operation],
|
|
182
|
+
{ [operation.operationId]: 'NO_REFUND' },
|
|
183
|
+
{ [operation.operationId]: 193.92 },
|
|
184
|
+
),
|
|
185
|
+
193.92,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('changing admin refund treatment creates a distinct authoritative quote request', () => {
|
|
190
|
+
const base = {
|
|
191
|
+
bookingReference: 'DE8H04DX',
|
|
192
|
+
lastName: 'Tauro',
|
|
193
|
+
productId: 'p_1',
|
|
194
|
+
selectedAvailability: {
|
|
195
|
+
availabilityId: 'a_1',
|
|
196
|
+
productId: 'po_1',
|
|
197
|
+
dateTime: '2026-07-21T03:00:00-06:00',
|
|
198
|
+
vacancies: 10,
|
|
199
|
+
} as Parameters<typeof buildAdminChangeQuoteRequestKey>[0]['selectedAvailability'],
|
|
200
|
+
pickupLocationId: 'pickup_1',
|
|
201
|
+
returnAvailabilityId: 'return_1',
|
|
202
|
+
quantities: { ADULT: 1 },
|
|
203
|
+
addOnSelections: [],
|
|
204
|
+
adminCustomReceiptLines: [],
|
|
205
|
+
useAdminFeAuthoritativeQuote: true,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const undecided = buildAdminChangeQuoteRequestKey(base);
|
|
209
|
+
const pending = buildAdminChangeQuoteRequestKey({
|
|
210
|
+
...base,
|
|
211
|
+
refundDispositionsByOperationId: { op_remove_adult: 'PENDING_REFUND' },
|
|
212
|
+
});
|
|
213
|
+
const noRefund = buildAdminChangeQuoteRequestKey({
|
|
214
|
+
...base,
|
|
215
|
+
refundDispositionsByOperationId: { op_remove_adult: 'NO_REFUND' },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.notEqual(undecided, pending);
|
|
219
|
+
assert.notEqual(pending, noRefund);
|
|
220
|
+
});
|
|
221
|
+
|
|
108
222
|
test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
|
|
109
223
|
const selectedSummary = {
|
|
110
224
|
availabilityId: 'a_private',
|