@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.186",
3
+ "version": "1.2.188",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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 flex items-center justify-between gap-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
- <span className="shrink-0 text-xs font-medium text-stone-500">
371
- {isProductFamilyConversion
372
- ? formatCurrencyAmount(updatedTotal ?? 0, currency, displayLocale)
373
- : bookingTotalChange == null
374
- ? '—'
375
- : formatCurrencyAmount(bookingTotalChange, currency, displayLocale)}
376
- </span>
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
- {promoEditor}
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
- providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
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_]+/i,
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
- * Existing admin adjustments must be discoverable before the operator makes a change.
197
- * Initial selection hydration can briefly leave the live availability/return unset, so use
198
- * the persisted booking identities for this read-only discovery quote instead of waiting for
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: quoteMissingRequiredReturnSelection,
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 trimmedPromo = initialValues.promoCode?.trim().toUpperCase();
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
- if (trimmedPromo) {
96
- setPromoCodeInput(trimmedPromo);
97
- setAppliedPromoCode(trimmedPromo);
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,
@@ -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.',