@ticketboothapp/booking 1.2.186 → 1.2.188
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 +2 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +14 -1
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +36 -15
- package/src/components/booking/admin-change-provider-payload.ts +9 -5
- package/src/components/booking/admin-change-quote-request-key.ts +4 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +6 -3
- package/src/components/booking/change-booking-error-message.ts +5 -1
- package/src/components/booking/provider-dashboard-change-booking.ts +2 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +3 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +14 -37
- package/src/components/booking/useChangeBookingInitialHydration.ts +17 -4
- package/src/lib/booking-api.ts +2 -0
- package/test/change-booking-helpers.test.ts +95 -0
package/package.json
CHANGED
|
@@ -197,6 +197,8 @@ const checkoutPanelPropKeys = [
|
|
|
197
197
|
'onRemoveAdminCustomReceiptLine',
|
|
198
198
|
'onApplyAdminCustomReceiptLines',
|
|
199
199
|
'onSetAdminReceiptLineOverride',
|
|
200
|
+
'preserveExistingReceipt',
|
|
201
|
+
'onPreserveExistingReceiptChange',
|
|
200
202
|
'firstName',
|
|
201
203
|
'lastName',
|
|
202
204
|
'email',
|
|
@@ -348,6 +348,7 @@ export function AdminChangeBookingFlow({
|
|
|
348
348
|
>({});
|
|
349
349
|
const [changeQuoteLoading, setChangeQuoteLoading] = useState(false);
|
|
350
350
|
const [changeQuoteFetchError, setChangeQuoteFetchError] = useState<string | null>(null);
|
|
351
|
+
const [preserveExistingReceipt, setPreserveExistingReceipt] = useState(false);
|
|
351
352
|
/** Dedupe quote calls while user input payload is unchanged. */
|
|
352
353
|
const lastCompletedQuoteInputsKeyRef = useRef<string | null>(null);
|
|
353
354
|
const inFlightQuoteInputsKeyRef = useRef<string | null>(null);
|
|
@@ -804,6 +805,7 @@ export function AdminChangeBookingFlow({
|
|
|
804
805
|
adminCustomReceiptLines: appliedAdminCustomReceiptLines,
|
|
805
806
|
structuredAdjustments: adminStructuredAdjustments,
|
|
806
807
|
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
808
|
+
preserveExistingReceipt,
|
|
807
809
|
useAdminFeAuthoritativeQuote,
|
|
808
810
|
}), [
|
|
809
811
|
initialValues?.bookingReference,
|
|
@@ -820,6 +822,7 @@ export function AdminChangeBookingFlow({
|
|
|
820
822
|
appliedAdminCustomReceiptLines,
|
|
821
823
|
adminStructuredAdjustments,
|
|
822
824
|
adminReverseAdjustmentIds,
|
|
825
|
+
preserveExistingReceipt,
|
|
823
826
|
useAdminFeAuthoritativeQuote,
|
|
824
827
|
]);
|
|
825
828
|
const responseRefundDecisionOperations = latestChangeQuote?.refundDecisionOperations ?? [];
|
|
@@ -894,6 +897,12 @@ export function AdminChangeBookingFlow({
|
|
|
894
897
|
activeReturnPriceTreatmentOperations,
|
|
895
898
|
refundDispositionsByOperationId,
|
|
896
899
|
]);
|
|
900
|
+
const initialSelectionHydratedForQuote = Boolean(
|
|
901
|
+
selectedAvailability &&
|
|
902
|
+
totalQuantity > 0 &&
|
|
903
|
+
addOnCatalogLoaded &&
|
|
904
|
+
((selectedAvailability.returnOptions?.length ?? 0) === 0 || selectedReturnOption),
|
|
905
|
+
);
|
|
897
906
|
const handleRefundDispositionChange = useCallback((
|
|
898
907
|
operationId: string,
|
|
899
908
|
disposition: AdminReleaseRefundDisposition | null,
|
|
@@ -915,8 +924,8 @@ export function AdminChangeBookingFlow({
|
|
|
915
924
|
} = useAdminChangeQuotePreview({
|
|
916
925
|
isCustomerSelfServeChange,
|
|
917
926
|
isProviderDashboardChange,
|
|
918
|
-
hasChangeSelection,
|
|
919
927
|
hasEffectiveChangeSelection,
|
|
928
|
+
initialSelectionHydrated: initialSelectionHydratedForQuote,
|
|
920
929
|
selectedAvailability,
|
|
921
930
|
selectedReturnOption,
|
|
922
931
|
initialValues,
|
|
@@ -931,6 +940,7 @@ export function AdminChangeBookingFlow({
|
|
|
931
940
|
adminCustomReceiptLines: appliedAdminCustomReceiptLines,
|
|
932
941
|
adminStructuredAdjustments,
|
|
933
942
|
adminReverseAdjustmentIds,
|
|
943
|
+
preserveExistingReceipt,
|
|
934
944
|
needsAdjustmentDiscovery:
|
|
935
945
|
isProviderDashboardChange && !hasHydratedExistingAdjustments,
|
|
936
946
|
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
@@ -1153,6 +1163,7 @@ export function AdminChangeBookingFlow({
|
|
|
1153
1163
|
mergedProviderAdditionalAdjustments,
|
|
1154
1164
|
adminStructuredAdjustments,
|
|
1155
1165
|
adminReverseAdjustmentIds,
|
|
1166
|
+
preserveExistingReceipt,
|
|
1156
1167
|
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
1157
1168
|
providerApplyAuthoritativeReceipt,
|
|
1158
1169
|
onSuccess,
|
|
@@ -1301,6 +1312,8 @@ export function AdminChangeBookingFlow({
|
|
|
1301
1312
|
onRemoveAdminCustomReceiptLine: handleRemoveAdminCustomReceiptLine,
|
|
1302
1313
|
onApplyAdminCustomReceiptLines: handleApplyAdminCustomReceiptLines,
|
|
1303
1314
|
onSetAdminReceiptLineOverride: handleSetAdminReceiptLineOverride,
|
|
1315
|
+
preserveExistingReceipt,
|
|
1316
|
+
onPreserveExistingReceiptChange: setPreserveExistingReceipt,
|
|
1304
1317
|
onFirstNameChange: setFirstName, onLastNameChange: setLastName, onEmailChange: setEmail,
|
|
1305
1318
|
onClearError: () => setError(''),
|
|
1306
1319
|
pickupLocations: product.pickupLocations, destinations: product.destinations, highlightedPickupLocationIds,
|
|
@@ -87,6 +87,8 @@ export interface AdminChangeCheckoutPanelProps {
|
|
|
87
87
|
baseAmount: number;
|
|
88
88
|
targetAmount: number;
|
|
89
89
|
}) => void;
|
|
90
|
+
preserveExistingReceipt: boolean;
|
|
91
|
+
onPreserveExistingReceiptChange: (preserve: boolean) => void;
|
|
90
92
|
firstName: string;
|
|
91
93
|
lastName: string;
|
|
92
94
|
email: string;
|
|
@@ -179,6 +181,8 @@ export function AdminChangeCheckoutPanel({
|
|
|
179
181
|
onRemoveAdminCustomReceiptLine,
|
|
180
182
|
onApplyAdminCustomReceiptLines,
|
|
181
183
|
onSetAdminReceiptLineOverride,
|
|
184
|
+
preserveExistingReceipt,
|
|
185
|
+
onPreserveExistingReceiptChange,
|
|
182
186
|
firstName,
|
|
183
187
|
lastName,
|
|
184
188
|
email,
|
|
@@ -282,6 +286,8 @@ export function AdminChangeCheckoutPanel({
|
|
|
282
286
|
promoEditor={promoEditor}
|
|
283
287
|
promoCode={promoCode}
|
|
284
288
|
adjustments={pricingAdjustments}
|
|
289
|
+
preserveExistingReceipt={preserveExistingReceipt}
|
|
290
|
+
onPreserveExistingReceiptChange={onPreserveExistingReceiptChange}
|
|
285
291
|
adminCustomReceiptLines={adminCustomReceiptLines}
|
|
286
292
|
onSetReceiptLineOverride={onSetAdminReceiptLineOverride}
|
|
287
293
|
/>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, type ReactNode } from 'react';
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
AdminAmendmentOperationSnapshot,
|
|
4
4
|
AdminAmendmentRefundDisposition,
|
|
@@ -72,6 +72,8 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
72
72
|
baseAmount: number;
|
|
73
73
|
targetAmount: number;
|
|
74
74
|
}) => void;
|
|
75
|
+
preserveExistingReceipt?: boolean;
|
|
76
|
+
onPreserveExistingReceiptChange?: (preserve: boolean) => void;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
function normalizeLocale(locale: string): Locale {
|
|
@@ -128,8 +130,13 @@ export function AdminChangeReceiptComparison({
|
|
|
128
130
|
refundQuoteLoading = false,
|
|
129
131
|
adminCustomReceiptLines = [],
|
|
130
132
|
onSetReceiptLineOverride,
|
|
133
|
+
preserveExistingReceipt = false,
|
|
134
|
+
onPreserveExistingReceiptChange,
|
|
131
135
|
}: AdminChangeReceiptComparisonProps) {
|
|
132
136
|
const [overrideDrafts, setOverrideDrafts] = useState<Record<string, string>>({});
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (preserveExistingReceipt) setOverrideDrafts({});
|
|
139
|
+
}, [preserveExistingReceipt]);
|
|
133
140
|
const readableQuoteError = friendlyChangeBookingError(quoteError);
|
|
134
141
|
const displayLocale = normalizeLocale(locale);
|
|
135
142
|
const existingLines = originalLines(originalReceipt);
|
|
@@ -356,7 +363,7 @@ export function AdminChangeReceiptComparison({
|
|
|
356
363
|
</section>
|
|
357
364
|
|
|
358
365
|
<section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
|
|
359
|
-
<div className="mb-3
|
|
366
|
+
<div className="mb-3">
|
|
360
367
|
<div>
|
|
361
368
|
<h3 className="text-sm font-semibold text-stone-900">
|
|
362
369
|
{isProductFamilyConversion ? 'New purchase' : 'Current change'}
|
|
@@ -367,15 +374,29 @@ export function AdminChangeReceiptComparison({
|
|
|
367
374
|
</p>
|
|
368
375
|
) : null}
|
|
369
376
|
</div>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
+
{onPreserveExistingReceiptChange ? (
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
aria-pressed={preserveExistingReceipt}
|
|
381
|
+
className={`mt-3 inline-flex w-full items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold shadow-sm transition sm:w-auto ${
|
|
382
|
+
preserveExistingReceipt
|
|
383
|
+
? 'border-sky-700 bg-sky-700 text-white hover:bg-sky-800'
|
|
384
|
+
: 'border-sky-300 bg-white text-sky-800 hover:border-sky-500 hover:bg-sky-50'
|
|
385
|
+
}`}
|
|
386
|
+
onClick={() => onPreserveExistingReceiptChange(!preserveExistingReceipt)}
|
|
387
|
+
>
|
|
388
|
+
Keep receipt unchanged
|
|
389
|
+
</button>
|
|
390
|
+
) : null}
|
|
377
391
|
</div>
|
|
378
|
-
{
|
|
392
|
+
{preserveExistingReceipt ? (
|
|
393
|
+
<div className="mb-3 rounded-md border border-sky-200 bg-white px-3 py-2">
|
|
394
|
+
<p className="text-sm font-semibold text-stone-800">Receipt and balance will stay unchanged</p>
|
|
395
|
+
<p className="mt-0.5 text-xs text-stone-600">
|
|
396
|
+
Only the booking details will change. No charge, refund, or balance adjustment will be created.
|
|
397
|
+
</p>
|
|
398
|
+
</div>
|
|
399
|
+
) : promoEditor}
|
|
379
400
|
{!selectionChanged ? (
|
|
380
401
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
381
402
|
<p className="text-sm font-medium text-stone-700">No changes selected</p>
|
|
@@ -416,10 +437,10 @@ export function AdminChangeReceiptComparison({
|
|
|
416
437
|
subtotal={summarySubtotal}
|
|
417
438
|
size="sm"
|
|
418
439
|
t={t}
|
|
419
|
-
lineAmountInputs={{ ...quotedOverrideAmountInputs, ...overrideDrafts }}
|
|
420
|
-
onLineAmountInputChange={handleOverrideChange}
|
|
421
|
-
onLineAmountInputBlur={handleOverrideBlur}
|
|
422
|
-
onLineRemove={handleOverrideRemove}
|
|
440
|
+
lineAmountInputs={preserveExistingReceipt ? undefined : { ...quotedOverrideAmountInputs, ...overrideDrafts }}
|
|
441
|
+
onLineAmountInputChange={preserveExistingReceipt ? undefined : handleOverrideChange}
|
|
442
|
+
onLineAmountInputBlur={preserveExistingReceipt ? undefined : handleOverrideBlur}
|
|
443
|
+
onLineRemove={preserveExistingReceipt ? undefined : handleOverrideRemove}
|
|
423
444
|
/>
|
|
424
445
|
{removedSummaryDisplayLines.length > 0 ? (
|
|
425
446
|
<div className="mt-3 border-t border-stone-200 pt-2 text-xs text-stone-500">
|
|
@@ -446,7 +467,7 @@ export function AdminChangeReceiptComparison({
|
|
|
446
467
|
</section>
|
|
447
468
|
</div>
|
|
448
469
|
|
|
449
|
-
{adjustments ? (
|
|
470
|
+
{!preserveExistingReceipt && adjustments ? (
|
|
450
471
|
<section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
|
|
451
472
|
<h3 className="mb-2 text-sm font-semibold text-stone-900">Add custom line items</h3>
|
|
452
473
|
{adjustments}
|
|
@@ -40,6 +40,7 @@ export interface BuildAdminChangeProviderPayloadParams {
|
|
|
40
40
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
41
41
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
42
42
|
adminReverseAdjustmentIds?: string[];
|
|
43
|
+
preserveExistingReceipt?: boolean;
|
|
43
44
|
refundDispositionsByOperationId: NonNullable<
|
|
44
45
|
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
45
46
|
>;
|
|
@@ -69,6 +70,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
69
70
|
mergedProviderAdditionalAdjustments,
|
|
70
71
|
adminStructuredAdjustments,
|
|
71
72
|
adminReverseAdjustmentIds = [],
|
|
73
|
+
preserveExistingReceipt = false,
|
|
72
74
|
refundDispositionsByOperationId,
|
|
73
75
|
pricingV2QuoteId,
|
|
74
76
|
pricingV2QuotedTotal,
|
|
@@ -84,6 +86,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
84
86
|
: null;
|
|
85
87
|
|
|
86
88
|
return {
|
|
89
|
+
preserveExistingReceipt,
|
|
87
90
|
productId: availabilityProductOptionId,
|
|
88
91
|
parentProductId: product.productId,
|
|
89
92
|
availabilityId: selectedAvailability.availabilityId ?? null,
|
|
@@ -102,10 +105,11 @@ export function buildAdminChangeProviderPayload({
|
|
|
102
105
|
cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
|
|
103
106
|
promoCode: appliedPromoCode ?? null,
|
|
104
107
|
promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
|
|
105
|
-
newTotalAmount,
|
|
108
|
+
newTotalAmount: preserveExistingReceipt ? undefined : newTotalAmount,
|
|
106
109
|
additionalHoursCount: null,
|
|
107
110
|
pricingAdjustment:
|
|
108
|
-
|
|
111
|
+
!preserveExistingReceipt &&
|
|
112
|
+
(providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0)
|
|
109
113
|
? {
|
|
110
114
|
mode: 'MANUAL_LINES',
|
|
111
115
|
...(providerPricingOverrides.length > 0 ? { lineOverrides: providerPricingOverrides } : {}),
|
|
@@ -115,9 +119,9 @@ export function buildAdminChangeProviderPayload({
|
|
|
115
119
|
}
|
|
116
120
|
: undefined,
|
|
117
121
|
authoritativeReceipt: providerApplyAuthoritativeReceipt,
|
|
118
|
-
structuredAdjustments: adminStructuredAdjustments,
|
|
119
|
-
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
120
|
-
refundDispositionsByOperationId,
|
|
122
|
+
structuredAdjustments: preserveExistingReceipt ? [] : adminStructuredAdjustments,
|
|
123
|
+
reverseAdjustmentIds: preserveExistingReceipt ? [] : adminReverseAdjustmentIds,
|
|
124
|
+
refundDispositionsByOperationId: preserveExistingReceipt ? {} : refundDispositionsByOperationId,
|
|
121
125
|
pricingV2QuoteId: pricingV2QuoteId ?? null,
|
|
122
126
|
pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
|
|
123
127
|
capacitySeatCredit: {
|
|
@@ -16,6 +16,7 @@ interface AdminChangeQuoteRequestKeyInput {
|
|
|
16
16
|
promoApplicationScope?: AdminPromoApplicationScope | null;
|
|
17
17
|
structuredAdjustments?: unknown[];
|
|
18
18
|
reverseAdjustmentIds?: string[];
|
|
19
|
+
preserveExistingReceipt?: boolean;
|
|
19
20
|
refundDispositionsByOperationId?: Record<string, string>;
|
|
20
21
|
useAdminFeAuthoritativeQuote: boolean;
|
|
21
22
|
}
|
|
@@ -23,6 +24,7 @@ interface AdminChangeQuoteRequestKeyInput {
|
|
|
23
24
|
interface AdminChangeQuoteRequestGateInput {
|
|
24
25
|
isCustomerSelfServeChange: boolean;
|
|
25
26
|
hasEffectiveChangeSelection: boolean;
|
|
27
|
+
initialSelectionHydrated?: boolean;
|
|
26
28
|
hasSelectedAvailability: boolean;
|
|
27
29
|
bookingReference?: string | null;
|
|
28
30
|
lastName: string;
|
|
@@ -44,6 +46,7 @@ export function canRequestAdminChangeQuote(input: AdminChangeQuoteRequestGateInp
|
|
|
44
46
|
return Boolean(
|
|
45
47
|
input.isCustomerSelfServeChange &&
|
|
46
48
|
input.hasEffectiveChangeSelection &&
|
|
49
|
+
input.initialSelectionHydrated !== false &&
|
|
47
50
|
input.hasSelectedAvailability &&
|
|
48
51
|
input.bookingReference?.trim() &&
|
|
49
52
|
input.lastName.trim() &&
|
|
@@ -74,6 +77,7 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
|
|
|
74
77
|
promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
|
|
75
78
|
structuredAdjustments: input.structuredAdjustments ?? [],
|
|
76
79
|
reverseAdjustmentIds: [...(input.reverseAdjustmentIds ?? [])].sort(),
|
|
80
|
+
preserveExistingReceipt: input.preserveExistingReceipt === true,
|
|
77
81
|
refundDispositionsByOperationId: Object.fromEntries(
|
|
78
82
|
Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
79
83
|
),
|
|
@@ -32,6 +32,7 @@ export interface BuildAdminChangeV2QuoteRequestParams {
|
|
|
32
32
|
promoApplicationScope?: AdminPromoApplicationScope;
|
|
33
33
|
structuredAdjustments: NonNullable<ChangeBookingQuoteRequest['structuredAdjustments']>;
|
|
34
34
|
reverseAdjustmentIds?: string[];
|
|
35
|
+
preserveExistingReceipt?: boolean;
|
|
35
36
|
includeSelectableAdjustmentComponents?: boolean;
|
|
36
37
|
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
37
38
|
previousPassengerCount: number;
|
|
@@ -59,6 +60,7 @@ export function buildAdminChangeV2QuoteRequest({
|
|
|
59
60
|
promoApplicationScope = 'CHANGE_ONLY',
|
|
60
61
|
structuredAdjustments,
|
|
61
62
|
reverseAdjustmentIds = [],
|
|
63
|
+
preserveExistingReceipt = false,
|
|
62
64
|
includeSelectableAdjustmentComponents = false,
|
|
63
65
|
refundDispositionsByOperationId,
|
|
64
66
|
previousPassengerCount,
|
|
@@ -80,12 +82,13 @@ export function buildAdminChangeV2QuoteRequest({
|
|
|
80
82
|
cancellationPolicyId,
|
|
81
83
|
promoCode: promoCode?.trim() || null,
|
|
82
84
|
promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
|
|
83
|
-
structuredAdjustments,
|
|
84
|
-
reverseAdjustmentIds,
|
|
85
|
+
structuredAdjustments: preserveExistingReceipt ? [] : structuredAdjustments,
|
|
86
|
+
reverseAdjustmentIds: preserveExistingReceipt ? [] : reverseAdjustmentIds,
|
|
87
|
+
preserveExistingReceipt,
|
|
85
88
|
...(includeSelectableAdjustmentComponents
|
|
86
89
|
? { includeSelectableAdjustmentComponents: true }
|
|
87
90
|
: {}),
|
|
88
|
-
...(Object.keys(refundDispositionsByOperationId).length > 0
|
|
91
|
+
...(!preserveExistingReceipt && Object.keys(refundDispositionsByOperationId).length > 0
|
|
89
92
|
? { refundDispositionsByOperationId }
|
|
90
93
|
: {}),
|
|
91
94
|
capacitySeatCredit: {
|
|
@@ -93,7 +93,7 @@ export function incompatibleAddOnChangeError(
|
|
|
93
93
|
|
|
94
94
|
function errorCodeFromMessage(message: string): string | null {
|
|
95
95
|
const match = message.match(
|
|
96
|
-
/\b(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|private_shuttle|quote|structured_adjustment)_[a-z0-9_]
|
|
96
|
+
/\b(?:(?:missing_(?:add|remove)_operation):[a-z0-9_]+|(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|private_shuttle|quote|structured_adjustment)_[a-z0-9_]+)/i,
|
|
97
97
|
);
|
|
98
98
|
return match?.[0]?.toLowerCase() ?? null;
|
|
99
99
|
}
|
|
@@ -112,6 +112,10 @@ export function friendlyChangeBookingError(message: string | null | undefined):
|
|
|
112
112
|
const exact = CHANGE_BOOKING_ERROR_MESSAGES[code];
|
|
113
113
|
if (exact) return exact;
|
|
114
114
|
|
|
115
|
+
if (code.startsWith('missing_add_operation:return') || code.startsWith('missing_remove_operation:return')) {
|
|
116
|
+
return 'The return portion of this booking could not be priced. Re-select the return time and try again.';
|
|
117
|
+
}
|
|
118
|
+
|
|
115
119
|
if (code.startsWith('admin_adjustment_refund_disposition_required')) {
|
|
116
120
|
return 'Choose how to handle the removed adjustment value before continuing.';
|
|
117
121
|
}
|
|
@@ -33,6 +33,8 @@ export type AdminAmendmentStructuredAdjustment = {
|
|
|
33
33
|
|
|
34
34
|
/** Payload passed to `onChangeBooking` when applying a dashboard-managed booking change. */
|
|
35
35
|
export type ProviderDashboardChangeBookingPayload = {
|
|
36
|
+
/** Apply operational changes while preserving the existing receipt and balance exactly. */
|
|
37
|
+
preserveExistingReceipt?: boolean;
|
|
36
38
|
/** Parent catalog product id. Present when admin changes the booking to a different product. */
|
|
37
39
|
parentProductId?: string;
|
|
38
40
|
/** Product option id selected for the new booking. */
|
|
@@ -155,6 +155,7 @@ export interface UseAdminChangeCheckoutControllerParams {
|
|
|
155
155
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
156
156
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
157
157
|
adminReverseAdjustmentIds: string[];
|
|
158
|
+
preserveExistingReceipt: boolean;
|
|
158
159
|
refundDispositionsByOperationId: NonNullable<
|
|
159
160
|
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
160
161
|
>;
|
|
@@ -258,6 +259,7 @@ export function useAdminChangeCheckoutController({
|
|
|
258
259
|
mergedProviderAdditionalAdjustments,
|
|
259
260
|
adminStructuredAdjustments,
|
|
260
261
|
adminReverseAdjustmentIds,
|
|
262
|
+
preserveExistingReceipt,
|
|
261
263
|
refundDispositionsByOperationId,
|
|
262
264
|
providerApplyAuthoritativeReceipt,
|
|
263
265
|
onSuccess,
|
|
@@ -381,6 +383,7 @@ export function useAdminChangeCheckoutController({
|
|
|
381
383
|
mergedProviderAdditionalAdjustments,
|
|
382
384
|
adminStructuredAdjustments,
|
|
383
385
|
adminReverseAdjustmentIds,
|
|
386
|
+
preserveExistingReceipt,
|
|
384
387
|
refundDispositionsByOperationId,
|
|
385
388
|
pricingV2QuoteId: authoritativeQuote?.quoteId ?? null,
|
|
386
389
|
pricingV2QuotedTotal: authoritativeQuote?.quotedTotal ?? authoritativeQuote?.serverDisplay?.total ?? null,
|
|
@@ -64,8 +64,8 @@ interface ChangeSelectionDetailsForPreview {
|
|
|
64
64
|
export interface UseAdminChangeQuotePreviewParams {
|
|
65
65
|
isCustomerSelfServeChange: boolean;
|
|
66
66
|
isProviderDashboardChange: boolean;
|
|
67
|
-
hasChangeSelection: boolean;
|
|
68
67
|
hasEffectiveChangeSelection: boolean;
|
|
68
|
+
initialSelectionHydrated: boolean;
|
|
69
69
|
selectedAvailability: Availability | null;
|
|
70
70
|
selectedReturnOption: ReturnOption | null;
|
|
71
71
|
initialValues: ChangeBookingFlowProps['initialValues'];
|
|
@@ -80,6 +80,7 @@ export interface UseAdminChangeQuotePreviewParams {
|
|
|
80
80
|
adminCustomReceiptLines: AdminCustomReceiptLine[];
|
|
81
81
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
82
82
|
adminReverseAdjustmentIds: string[];
|
|
83
|
+
preserveExistingReceipt: boolean;
|
|
83
84
|
needsAdjustmentDiscovery: boolean;
|
|
84
85
|
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
85
86
|
useAdminFeAuthoritativeQuote: boolean;
|
|
@@ -128,8 +129,8 @@ export interface UseAdminChangeQuotePreviewResult {
|
|
|
128
129
|
export function useAdminChangeQuotePreview({
|
|
129
130
|
isCustomerSelfServeChange,
|
|
130
131
|
isProviderDashboardChange,
|
|
131
|
-
hasChangeSelection,
|
|
132
132
|
hasEffectiveChangeSelection,
|
|
133
|
+
initialSelectionHydrated,
|
|
133
134
|
selectedAvailability,
|
|
134
135
|
selectedReturnOption,
|
|
135
136
|
initialValues,
|
|
@@ -144,6 +145,7 @@ export function useAdminChangeQuotePreview({
|
|
|
144
145
|
adminCustomReceiptLines,
|
|
145
146
|
adminStructuredAdjustments,
|
|
146
147
|
adminReverseAdjustmentIds,
|
|
148
|
+
preserveExistingReceipt,
|
|
147
149
|
needsAdjustmentDiscovery,
|
|
148
150
|
refundDispositionsByOperationId,
|
|
149
151
|
useAdminFeAuthoritativeQuote,
|
|
@@ -192,37 +194,10 @@ export function useAdminChangeQuotePreview({
|
|
|
192
194
|
(latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
|
|
193
195
|
const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
|
|
194
196
|
const hasPublishedChangeQuote = latestChangeQuote !== null;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
* the editable target controls to become complete.
|
|
200
|
-
*/
|
|
201
|
-
const quoteAvailability = useMemo<Availability | null>(() => {
|
|
202
|
-
if (selectedAvailability) return selectedAvailability;
|
|
203
|
-
if (!needsAdjustmentDiscovery || !initialValues?.dateTime?.trim()) return null;
|
|
204
|
-
const optionId = initialValues.productOptionId?.trim() || initialValues.productId?.trim();
|
|
205
|
-
if (!optionId) return null;
|
|
206
|
-
return {
|
|
207
|
-
dateTime: initialValues.dateTime.trim(),
|
|
208
|
-
availabilityId: initialValues.availabilityId?.trim() || undefined,
|
|
209
|
-
productOptionId: optionId,
|
|
210
|
-
vacancies: Math.max(totalQuantity, 1),
|
|
211
|
-
currency,
|
|
212
|
-
};
|
|
213
|
-
}, [
|
|
214
|
-
selectedAvailability,
|
|
215
|
-
needsAdjustmentDiscovery,
|
|
216
|
-
initialValues?.dateTime,
|
|
217
|
-
initialValues?.availabilityId,
|
|
218
|
-
initialValues?.productOptionId,
|
|
219
|
-
initialValues?.productId,
|
|
220
|
-
totalQuantity,
|
|
221
|
-
currency,
|
|
222
|
-
]);
|
|
223
|
-
const quoteReturnAvailabilityId =
|
|
224
|
-
selectedReturnOption?.returnAvailabilityId ??
|
|
225
|
-
(needsAdjustmentDiscovery ? initialValues?.returnAvailabilityId?.trim() || null : null);
|
|
197
|
+
// Existing-adjustment discovery is still a real quote. Never send it with the temporary
|
|
198
|
+
// empty ticket/return state produced while the saved booking is being rehydrated.
|
|
199
|
+
const quoteAvailability = selectedAvailability;
|
|
200
|
+
const quoteReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId ?? null;
|
|
226
201
|
const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
|
|
227
202
|
bookingReference: initialValues?.bookingReference,
|
|
228
203
|
lastName,
|
|
@@ -238,6 +213,7 @@ export function useAdminChangeQuotePreview({
|
|
|
238
213
|
promoApplicationScope,
|
|
239
214
|
structuredAdjustments: adminStructuredAdjustments,
|
|
240
215
|
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
216
|
+
preserveExistingReceipt,
|
|
241
217
|
refundDispositionsByOperationId,
|
|
242
218
|
useAdminFeAuthoritativeQuote,
|
|
243
219
|
}), [
|
|
@@ -255,20 +231,20 @@ export function useAdminChangeQuotePreview({
|
|
|
255
231
|
promoApplicationScope,
|
|
256
232
|
adminStructuredAdjustments,
|
|
257
233
|
adminReverseAdjustmentIds,
|
|
234
|
+
preserveExistingReceipt,
|
|
258
235
|
refundDispositionsByOperationId,
|
|
259
236
|
useAdminFeAuthoritativeQuote,
|
|
260
237
|
]);
|
|
261
238
|
const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
|
|
262
239
|
const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
|
|
263
|
-
const quoteMissingRequiredReturnSelection =
|
|
264
|
-
missingRequiredReturnSelection && !needsAdjustmentDiscovery;
|
|
265
240
|
const canRequestChangeQuote = canRequestAdminChangeQuote({
|
|
266
241
|
isCustomerSelfServeChange,
|
|
267
|
-
hasEffectiveChangeSelection,
|
|
242
|
+
hasEffectiveChangeSelection: hasEffectiveChangeSelection || needsAdjustmentDiscovery,
|
|
243
|
+
initialSelectionHydrated,
|
|
268
244
|
hasSelectedAvailability: quoteAvailability != null,
|
|
269
245
|
bookingReference: initialValues?.bookingReference,
|
|
270
246
|
lastName,
|
|
271
|
-
missingRequiredReturnSelection
|
|
247
|
+
missingRequiredReturnSelection,
|
|
272
248
|
});
|
|
273
249
|
const changeFlowSubmitDisabled =
|
|
274
250
|
missingRequiredReturnSelection ||
|
|
@@ -526,6 +502,7 @@ export function useAdminChangeQuotePreview({
|
|
|
526
502
|
promoApplicationScope,
|
|
527
503
|
structuredAdjustments: adminStructuredAdjustments,
|
|
528
504
|
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
505
|
+
preserveExistingReceipt,
|
|
529
506
|
includeSelectableAdjustmentComponents:
|
|
530
507
|
needsAdjustmentDiscovery || adminCustomReceiptLines.some(
|
|
531
508
|
(line) => line.scope === 'SELECTED_COMPONENTS',
|
|
@@ -45,6 +45,16 @@ export interface UseChangeBookingInitialHydrationOptions {
|
|
|
45
45
|
setError: Dispatch<SetStateAction<string>>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export function initialPromoCodeToApply(
|
|
49
|
+
initialPromoCode: string | null | undefined,
|
|
50
|
+
lockedPromoCode: string | null,
|
|
51
|
+
): string | null {
|
|
52
|
+
if (!lockedPromoCode) return null;
|
|
53
|
+
const normalizedInitial = initialPromoCode?.trim().toUpperCase() ?? '';
|
|
54
|
+
const normalizedLocked = lockedPromoCode.trim().toUpperCase();
|
|
55
|
+
return normalizedInitial && normalizedInitial === normalizedLocked ? normalizedInitial : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
export function useChangeBookingInitialHydration({
|
|
49
59
|
initialValues,
|
|
50
60
|
lockedPromoCode,
|
|
@@ -84,7 +94,7 @@ export function useChangeBookingInitialHydration({
|
|
|
84
94
|
const trimmedEmail = initialValues.customer?.email?.trim();
|
|
85
95
|
const trimmedFirstName = initialValues.customer?.firstName?.trim();
|
|
86
96
|
const trimmedLastName = initialValues.customer?.lastName?.trim();
|
|
87
|
-
const
|
|
97
|
+
const initialPromoToApply = initialPromoCodeToApply(initialValues.promoCode, lockedPromoCode);
|
|
88
98
|
if (trimmedEmail) setEmail(trimmedEmail);
|
|
89
99
|
if (trimmedFirstName) setFirstName(trimmedFirstName);
|
|
90
100
|
if (trimmedLastName) setLastName(trimmedLastName);
|
|
@@ -92,13 +102,16 @@ export function useChangeBookingInitialHydration({
|
|
|
92
102
|
setPickupLocationId(initialValues.pickupLocationId.trim());
|
|
93
103
|
setPickupLocationSkipped(false);
|
|
94
104
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
// Provider amendments show the historical code separately and require the operator to
|
|
106
|
+
// explicitly apply it again. Only public/self-serve flows pass a locked promo here.
|
|
107
|
+
if (initialPromoToApply) {
|
|
108
|
+
setPromoCodeInput(initialPromoToApply);
|
|
109
|
+
setAppliedPromoCode(initialPromoToApply);
|
|
98
110
|
}
|
|
99
111
|
hasAppliedInitialValuesRef.current = true;
|
|
100
112
|
}, [
|
|
101
113
|
initialValues,
|
|
114
|
+
lockedPromoCode,
|
|
102
115
|
setAppliedPromoCode,
|
|
103
116
|
setEmail,
|
|
104
117
|
setFirstName,
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -1475,6 +1475,8 @@ export interface ChangeBookingQuoteRequest {
|
|
|
1475
1475
|
}>;
|
|
1476
1476
|
/** Existing immutable adjustment ids to reverse as part of this amendment. */
|
|
1477
1477
|
reverseAdjustmentIds?: string[];
|
|
1478
|
+
/** Admin only: change operations without changing the receipt or outstanding balance. */
|
|
1479
|
+
preserveExistingReceipt?: boolean;
|
|
1478
1480
|
}
|
|
1479
1481
|
|
|
1480
1482
|
/** FE-authored receipt payload for admin quote path (major units, booking currency). */
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
canReuseCompletedAdminChangeQuote,
|
|
36
36
|
isAdminRefundDecisionDiscoveryQuote,
|
|
37
37
|
} from '../src/components/booking/admin-change-quote-request-key';
|
|
38
|
+
import { initialPromoCodeToApply } from '../src/components/booking/useChangeBookingInitialHydration';
|
|
38
39
|
import {
|
|
39
40
|
authoritativeItineraryDisplayFromChangeQuote,
|
|
40
41
|
sliceAndMergeChangeQuoteForUi,
|
|
@@ -762,6 +763,21 @@ test('admin quote gate allows adjustment-only changes', () => {
|
|
|
762
763
|
lastName: 'Tauro',
|
|
763
764
|
missingRequiredReturnSelection: false,
|
|
764
765
|
}), false);
|
|
766
|
+
|
|
767
|
+
assert.equal(canRequestAdminChangeQuote({
|
|
768
|
+
isCustomerSelfServeChange: true,
|
|
769
|
+
hasEffectiveChangeSelection: true,
|
|
770
|
+
initialSelectionHydrated: false,
|
|
771
|
+
hasSelectedAvailability: true,
|
|
772
|
+
bookingReference: 'DIFG66OF',
|
|
773
|
+
lastName: 'Tauro',
|
|
774
|
+
missingRequiredReturnSelection: false,
|
|
775
|
+
}), false);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('provider reopen does not automatically reapply the historical promo', () => {
|
|
779
|
+
assert.equal(initialPromoCodeToApply(' LOCKEDIN2026 ', null), null);
|
|
780
|
+
assert.equal(initialPromoCodeToApply(' LOCKEDIN2026 ', 'LOCKEDIN2026'), 'LOCKEDIN2026');
|
|
765
781
|
});
|
|
766
782
|
|
|
767
783
|
test('admin preview and checkout share the Pricing V2 request shape', () => {
|
|
@@ -808,6 +824,38 @@ test('admin preview and checkout share the Pricing V2 request shape', () => {
|
|
|
808
824
|
assert.equal('clientProposedTotal' in request, false);
|
|
809
825
|
});
|
|
810
826
|
|
|
827
|
+
test('preserve-receipt quote is a first-class backend mode and excludes price edits', () => {
|
|
828
|
+
const request = buildAdminChangeV2QuoteRequest({
|
|
829
|
+
bookingReference: 'VFSCAEKQ',
|
|
830
|
+
lastName: 'Tauro',
|
|
831
|
+
parentProductId: 'product_1',
|
|
832
|
+
optionId: 'option_2',
|
|
833
|
+
selectedAvailability: {
|
|
834
|
+
availabilityId: 'availability_2',
|
|
835
|
+
productId: 'option_2',
|
|
836
|
+
dateTime: '2026-07-29T08:35:00-06:00',
|
|
837
|
+
vacancies: 10,
|
|
838
|
+
} as Parameters<typeof buildAdminChangeV2QuoteRequest>[0]['selectedAvailability'],
|
|
839
|
+
pickupLocationId: 'pickup_1',
|
|
840
|
+
returnAvailabilityId: null,
|
|
841
|
+
bookingItems: [{ category: 'ADULT', count: 2 }],
|
|
842
|
+
addOnSelections: [],
|
|
843
|
+
cancellationPolicyId: 'policy_1',
|
|
844
|
+
structuredAdjustments: [{ adjustmentId: 'ignored_draft' }],
|
|
845
|
+
reverseAdjustmentIds: ['ignored_reversal'],
|
|
846
|
+
preserveExistingReceipt: true,
|
|
847
|
+
refundDispositionsByOperationId: { ignored_removal: 'PENDING_REFUND' },
|
|
848
|
+
previousPassengerCount: 2,
|
|
849
|
+
previousAvailabilityId: 'availability_1',
|
|
850
|
+
previousReturnAvailabilityId: null,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
assert.equal(request.preserveExistingReceipt, true);
|
|
854
|
+
assert.deepEqual(request.structuredAdjustments, []);
|
|
855
|
+
assert.deepEqual(request.reverseAdjustmentIds, []);
|
|
856
|
+
assert.equal(request.refundDispositionsByOperationId, undefined);
|
|
857
|
+
});
|
|
858
|
+
|
|
811
859
|
test('admin Pricing V2 quote mapper preserves the server quote identity', async () => {
|
|
812
860
|
const request = buildAdminChangeV2QuoteRequest({
|
|
813
861
|
bookingReference: 'VFSCAEKQ',
|
|
@@ -934,6 +982,49 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
|
|
|
934
982
|
assert.equal(payload?.promoApplicationScope, null);
|
|
935
983
|
});
|
|
936
984
|
|
|
985
|
+
test('preserve-receipt provider payload carries the mode without price mutations', () => {
|
|
986
|
+
const payload = buildAdminChangeProviderPayload({
|
|
987
|
+
selectedAvailability: {
|
|
988
|
+
availabilityId: 'availability_2',
|
|
989
|
+
productId: 'option_2',
|
|
990
|
+
dateTime: '2026-07-29T08:35:00-06:00',
|
|
991
|
+
vacancies: 10,
|
|
992
|
+
} as Parameters<typeof buildAdminChangeProviderPayload>[0]['selectedAvailability'],
|
|
993
|
+
availabilityProductOptionId: 'option_2',
|
|
994
|
+
bookingItems: [{ category: 'ADULT', count: 2 }],
|
|
995
|
+
product: { productId: 'product_1', name: 'Changed tour' } as Parameters<
|
|
996
|
+
typeof buildAdminChangeProviderPayload
|
|
997
|
+
>[0]['product'],
|
|
998
|
+
pickupLocationId: null,
|
|
999
|
+
selectedReturnOption: null,
|
|
1000
|
+
addOnSelections: [],
|
|
1001
|
+
cancellationPolicyId: null,
|
|
1002
|
+
appliedPromoCode: null,
|
|
1003
|
+
newTotalAmount: 999,
|
|
1004
|
+
providerPricingOverrides: [{ lineKey: 'ticket', amount: 999 }],
|
|
1005
|
+
mergedProviderAdditionalAdjustments: [{ label: 'Draft fee', amount: 10 }],
|
|
1006
|
+
adminStructuredAdjustments: [{
|
|
1007
|
+
adjustmentId: 'draft',
|
|
1008
|
+
mode: 'FIXED_AMOUNT',
|
|
1009
|
+
fixedAmount: 10,
|
|
1010
|
+
scope: 'CURRENT_ACTIVE_BOOKING',
|
|
1011
|
+
taxBehavior: 'NON_TAXABLE',
|
|
1012
|
+
reasonCode: 'STAFF_ADJUSTMENT',
|
|
1013
|
+
}],
|
|
1014
|
+
adminReverseAdjustmentIds: ['old_adjustment'],
|
|
1015
|
+
preserveExistingReceipt: true,
|
|
1016
|
+
refundDispositionsByOperationId: { removal: 'PENDING_REFUND' },
|
|
1017
|
+
previousPassengerCount: 2,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
assert.equal(payload?.preserveExistingReceipt, true);
|
|
1021
|
+
assert.equal(payload?.newTotalAmount, undefined);
|
|
1022
|
+
assert.equal(payload?.pricingAdjustment, undefined);
|
|
1023
|
+
assert.deepEqual(payload?.structuredAdjustments, []);
|
|
1024
|
+
assert.deepEqual(payload?.reverseAdjustmentIds, []);
|
|
1025
|
+
assert.deepEqual(payload?.refundDispositionsByOperationId, {});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
937
1028
|
test('public paid change preview shows amendment-only charges before combined tax', () => {
|
|
938
1029
|
const summary = buildPublicChangeAmountDueSummary([
|
|
939
1030
|
{
|
|
@@ -2197,6 +2288,10 @@ test('translates booking-change reason codes into operator-facing messages', ()
|
|
|
2197
2288
|
friendlyChangeBookingError('private_shuttle_itinerary_endpoints_required'),
|
|
2198
2289
|
'The private-tour itinerary is incomplete. Reopen the change and select the pickup time and location again.',
|
|
2199
2290
|
);
|
|
2291
|
+
assert.equal(
|
|
2292
|
+
friendlyChangeBookingError('missing_add_operation:return'),
|
|
2293
|
+
'The return portion of this booking could not be priced. Re-select the return time and try again.',
|
|
2294
|
+
);
|
|
2200
2295
|
assert.equal(
|
|
2201
2296
|
friendlyChangeBookingError('Remove the unavailable add-on before recalculating this amendment.'),
|
|
2202
2297
|
'Remove the unavailable add-on before recalculating this amendment.',
|