@ticketboothapp/booking 1.2.152 → 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/AdminChangeCheckoutPanel.tsx +28 -1
- package/src/components/booking/AdminChangeReceiptComparison.tsx +108 -4
- 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/useAdminChangeCheckoutController.ts +10 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +12 -1
- package/src/lib/booking/change-flow-pricing.ts +6 -0
- package/src/lib/booking-api.ts +63 -27
- package/test/change-booking-helpers.test.ts +94 -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 },
|
|
@@ -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>
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -82,6 +84,9 @@ export interface AdminChangeLatestQuote {
|
|
|
82
84
|
pricingVersion?: string;
|
|
83
85
|
quotedTotal?: number;
|
|
84
86
|
paymentCreditTotal?: number;
|
|
87
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
88
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
89
|
+
removedValueByOperationId?: Record<string, number>;
|
|
85
90
|
serverDisplay?: { total: number; subtotal: number; tax: number };
|
|
86
91
|
quotePreviousTotalCents?: number;
|
|
87
92
|
quoteNewTotalCents?: number;
|
|
@@ -131,6 +136,9 @@ export interface UseAdminChangeCheckoutControllerParams {
|
|
|
131
136
|
providerPricingOverrides: AdminChangeProviderLineOverride[];
|
|
132
137
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
133
138
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
139
|
+
refundDispositionsByOperationId: NonNullable<
|
|
140
|
+
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
141
|
+
>;
|
|
134
142
|
providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
|
|
135
143
|
onSuccess?: ChangeBookingFlowProps['onSuccess'];
|
|
136
144
|
totalPrice: number;
|
|
@@ -229,6 +237,7 @@ export function useAdminChangeCheckoutController({
|
|
|
229
237
|
providerPricingOverrides,
|
|
230
238
|
mergedProviderAdditionalAdjustments,
|
|
231
239
|
adminStructuredAdjustments,
|
|
240
|
+
refundDispositionsByOperationId,
|
|
232
241
|
providerApplyAuthoritativeReceipt,
|
|
233
242
|
onSuccess,
|
|
234
243
|
totalPrice,
|
|
@@ -327,6 +336,7 @@ export function useAdminChangeCheckoutController({
|
|
|
327
336
|
providerPricingOverrides,
|
|
328
337
|
mergedProviderAdditionalAdjustments,
|
|
329
338
|
adminStructuredAdjustments,
|
|
339
|
+
refundDispositionsByOperationId,
|
|
330
340
|
providerApplyAuthoritativeReceipt,
|
|
331
341
|
previousPassengerCount: changeFlowInitialTicketCount,
|
|
332
342
|
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
@@ -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
|
@@ -1246,6 +1246,22 @@ export interface CreatePaymentIntentResponse {
|
|
|
1246
1246
|
pricingQuoteInputsHash?: string | null;
|
|
1247
1247
|
}
|
|
1248
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
|
+
|
|
1249
1265
|
export interface ChangeBookingQuoteRequest {
|
|
1250
1266
|
bookingReference: string;
|
|
1251
1267
|
lastName: string;
|
|
@@ -1275,6 +1291,8 @@ export interface ChangeBookingQuoteRequest {
|
|
|
1275
1291
|
previousAvailabilityId?: string | null;
|
|
1276
1292
|
previousReturnAvailabilityId?: string | null;
|
|
1277
1293
|
} | null;
|
|
1294
|
+
/** Explicit admin treatment for server-classified removed paid value. Never defaulted by the client. */
|
|
1295
|
+
refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
|
|
1278
1296
|
}
|
|
1279
1297
|
|
|
1280
1298
|
/** FE-authored receipt payload for admin quote path (major units, booking currency). */
|
|
@@ -1316,6 +1334,9 @@ export interface AdminChangeBookingQuoteV2Data {
|
|
|
1316
1334
|
canApply: boolean;
|
|
1317
1335
|
reasonIfBlocked?: string | null;
|
|
1318
1336
|
unsupportedFeatures?: string[];
|
|
1337
|
+
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
1338
|
+
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
1339
|
+
removedValueByOperationId?: Record<string, number>;
|
|
1319
1340
|
}
|
|
1320
1341
|
|
|
1321
1342
|
export interface ChangeBookingQuoteReceipt {
|
|
@@ -1452,6 +1473,12 @@ export interface ChangeBookingQuoteResponse {
|
|
|
1452
1473
|
currency?: string;
|
|
1453
1474
|
canProceed?: boolean;
|
|
1454
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>;
|
|
1455
1482
|
/** When price check fails / disagrees: optional breakdown for UI line-vs-line comparison. */
|
|
1456
1483
|
pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
|
|
1457
1484
|
/** Optional BE debug: receipt-floor vs catalog ticket math (same-parent Rule A/B). */
|
|
@@ -1621,6 +1648,41 @@ export async function quoteChangeBookingAdminFeReceipt(
|
|
|
1621
1648
|
data) as ChangeBookingQuoteResponse;
|
|
1622
1649
|
}
|
|
1623
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
|
+
|
|
1624
1686
|
/** Server-authoritative Pricing V2 quote used by the provider-dashboard amendment preview. */
|
|
1625
1687
|
export async function quoteAdminChangeBookingV2(
|
|
1626
1688
|
request: ChangeBookingQuoteRequest
|
|
@@ -1665,33 +1727,7 @@ export async function quoteAdminChangeBookingV2(
|
|
|
1665
1727
|
) {
|
|
1666
1728
|
throw new Error('Invalid admin Pricing V2 quote response');
|
|
1667
1729
|
}
|
|
1668
|
-
|
|
1669
|
-
const oldReceipt = data.oldReceipt;
|
|
1670
|
-
const newQuote = data.newQuote ?? null;
|
|
1671
|
-
const currency = newQuote?.currency ?? oldReceipt.currency ?? undefined;
|
|
1672
|
-
return {
|
|
1673
|
-
pricingQuote: newQuote,
|
|
1674
|
-
quote: newQuote,
|
|
1675
|
-
balanceDelta: data.balanceDelta ?? newQuote?.balanceDelta ?? undefined,
|
|
1676
|
-
amountToCharge: data.amountToCharge ?? newQuote?.amountToCharge ?? undefined,
|
|
1677
|
-
refundCandidate: data.refundCandidate ?? newQuote?.refundCandidate ?? undefined,
|
|
1678
|
-
priceDiff: data.balanceDelta ?? newQuote?.balanceDelta ?? 0,
|
|
1679
|
-
currency: currency ?? undefined,
|
|
1680
|
-
canProceed: data.canApply,
|
|
1681
|
-
reasonIfBlocked: data.reasonIfBlocked ?? undefined,
|
|
1682
|
-
originalReceipt: {
|
|
1683
|
-
subtotal: oldReceipt.grossSubtotal ?? undefined,
|
|
1684
|
-
tax: oldReceipt.taxAmount ?? undefined,
|
|
1685
|
-
total: oldReceipt.payableTotal ?? 0,
|
|
1686
|
-
currency: oldReceipt.currency ?? undefined,
|
|
1687
|
-
lineItems: oldReceipt.lines?.map((line) => ({
|
|
1688
|
-
label: line.label ?? undefined,
|
|
1689
|
-
amount: line.amount ?? undefined,
|
|
1690
|
-
type: line.type ?? undefined,
|
|
1691
|
-
quantity: line.quantity ?? undefined,
|
|
1692
|
-
})),
|
|
1693
|
-
},
|
|
1694
|
-
};
|
|
1730
|
+
return mapAdminChangeBookingQuoteV2Data(candidate as AdminChangeBookingQuoteV2Data);
|
|
1695
1731
|
}
|
|
1696
1732
|
|
|
1697
1733
|
export async function createChangeBookingPaymentIntent(
|
|
@@ -5,9 +5,20 @@ 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,
|
|
@@ -127,6 +138,87 @@ test('admin quote response display state cannot invalidate its request identity'
|
|
|
127
138
|
assert.equal(keyAfterResponse, keyBeforeResponse);
|
|
128
139
|
});
|
|
129
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
|
+
|
|
130
222
|
test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
|
|
131
223
|
const selectedSummary = {
|
|
132
224
|
availabilityId: 'a_private',
|