@ticketboothapp/booking 1.2.167 → 1.2.169

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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +13 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +166 -145
  7. package/src/components/booking/ChangeBookingFlow.tsx +7 -3
  8. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  9. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  10. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  11. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  12. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  13. package/src/components/booking/PriceSummary.tsx +4 -0
  14. package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
  15. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  16. package/src/components/booking/admin-adjustment-components.ts +44 -0
  17. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  18. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  19. package/src/components/booking/admin-change-provider-payload.ts +3 -0
  20. package/src/components/booking/admin-change-quote-request-key.ts +27 -0
  21. package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
  22. package/src/components/booking/admin-refund-decision-context.ts +37 -0
  23. package/src/components/booking/admin-refund-disposition.ts +71 -2
  24. package/src/components/booking/booking-flow-types.ts +4 -0
  25. package/src/components/booking/booking-flow.css +5 -0
  26. package/src/components/booking/change-booking-error-message.ts +137 -0
  27. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  28. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  29. package/src/components/booking/change-booking-quote-state.ts +44 -1
  30. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  31. package/src/components/booking/private-shuttle-availability.ts +14 -0
  32. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  33. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  34. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  35. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  36. package/src/components/booking/provider-dashboard-change-booking.ts +16 -1
  37. package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
  38. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  39. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  40. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
  41. package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
  42. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  43. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  44. package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
  45. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  46. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  47. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  48. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  49. package/src/lib/booking/change-booking-server-preview.ts +26 -9
  50. package/src/lib/booking/change-flow-pricing.ts +12 -0
  51. package/src/lib/booking/i18n/messages/en.json +1 -0
  52. package/src/lib/booking/i18n/messages/fr.json +1 -0
  53. package/src/lib/booking-api.ts +64 -0
  54. package/test/change-booking-helpers.test.ts +853 -3
  55. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -12,6 +12,7 @@ import {
12
12
  type AddOn,
13
13
  type AdminAmendmentOperationSnapshot,
14
14
  type AdminAmendmentRefundDisposition,
15
+ type AdminAmendmentActiveAdjustmentSnapshot,
15
16
  type AdminFeAuthoritativeReceipt,
16
17
  type AdminPromoApplicationScope,
17
18
  type Availability,
@@ -42,6 +43,7 @@ import {
42
43
  import type { ChangeBookingFlowProps } from './booking-flow-types';
43
44
  import type { CheckoutModalLineItem } from './CheckoutModal';
44
45
  import type { Currency } from './CurrencySwitcher';
46
+ import type { AdminAdjustmentComponentOption } from './admin-adjustment-components';
45
47
  import {
46
48
  findMergedAvailabilityForSelection,
47
49
  findMergedReturnVacancies,
@@ -91,6 +93,10 @@ export interface AdminChangeLatestQuote {
91
93
  quoteExpiresAt?: string;
92
94
  pricingVersion?: string;
93
95
  quotedTotal?: number;
96
+ amountPreviouslyPaid?: number;
97
+ currentPaymentCreditTotal?: number;
98
+ projectedPaymentCreditTotal?: number;
99
+ refundCandidate?: number;
94
100
  paymentCreditTotal?: number;
95
101
  refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
96
102
  returnPriceTreatmentOperations?: AdminAmendmentOperationSnapshot[];
@@ -100,8 +106,11 @@ export interface AdminChangeLatestQuote {
100
106
  quotePreviousTotalCents?: number;
101
107
  quoteNewTotalCents?: number;
102
108
  serverPreview: ChangeBookingServerPreview;
109
+ authoritativeItineraryDisplay?: ItineraryDisplayStep[] | null;
103
110
  pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
104
111
  ticketPricingTrace?: ChangeBookingQuoteTicketPricingTrace | null;
112
+ selectableAdjustmentComponents?: AdminAdjustmentComponentOption[];
113
+ reversibleAdjustments?: AdminAmendmentActiveAdjustmentSnapshot[];
105
114
  }
106
115
 
107
116
  export interface UseAdminChangeCheckoutControllerParams {
@@ -146,6 +155,7 @@ export interface UseAdminChangeCheckoutControllerParams {
146
155
  providerPricingOverrides: AdminChangeProviderLineOverride[];
147
156
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
148
157
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
158
+ adminReverseAdjustmentIds: string[];
149
159
  refundDispositionsByOperationId: NonNullable<
150
160
  ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
151
161
  >;
@@ -248,6 +258,7 @@ export function useAdminChangeCheckoutController({
248
258
  providerPricingOverrides,
249
259
  mergedProviderAdditionalAdjustments,
250
260
  adminStructuredAdjustments,
261
+ adminReverseAdjustmentIds,
251
262
  refundDispositionsByOperationId,
252
263
  providerApplyAuthoritativeReceipt,
253
264
  onSuccess,
@@ -370,6 +381,7 @@ export function useAdminChangeCheckoutController({
370
381
  providerPricingOverrides,
371
382
  mergedProviderAdditionalAdjustments,
372
383
  adminStructuredAdjustments,
384
+ adminReverseAdjustmentIds,
373
385
  refundDispositionsByOperationId,
374
386
  pricingV2QuoteId: authoritativeQuote?.quoteId ?? null,
375
387
  pricingV2QuotedTotal: authoritativeQuote?.quotedTotal ?? authoritativeQuote?.serverDisplay?.total ?? null,
@@ -487,6 +499,10 @@ export function useAdminChangeCheckoutController({
487
499
  promoCode: appliedPromoCode,
488
500
  promoApplicationScope,
489
501
  structuredAdjustments: adminStructuredAdjustments,
502
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
503
+ includeSelectableAdjustmentComponents: adminStructuredAdjustments.some(
504
+ (adjustment) => adjustment.scope === 'SELECTED_COMPONENTS',
505
+ ),
490
506
  refundDispositionsByOperationId,
491
507
  previousPassengerCount: changeFlowInitialTicketCount,
492
508
  previousAvailabilityId: initialValues?.availabilityId ?? null,
@@ -8,6 +8,7 @@ interface ResetRef {
8
8
 
9
9
  export interface UseAdminChangeProductResetParams {
10
10
  selectedChangeProductId: string;
11
+ setQuantities: Dispatch<SetStateAction<Record<string, number>>>;
11
12
  setSelectedAvailability: Dispatch<SetStateAction<Availability | null>>;
12
13
  setSelectedReturnOption: Dispatch<SetStateAction<ReturnOption | null>>;
13
14
  setAddOns: Dispatch<SetStateAction<AddOn[]>>;
@@ -21,6 +22,7 @@ export interface UseAdminChangeProductResetParams {
21
22
 
22
23
  export function useAdminChangeProductReset({
23
24
  selectedChangeProductId,
25
+ setQuantities,
24
26
  setSelectedAvailability,
25
27
  setSelectedReturnOption,
26
28
  setAddOns,
@@ -36,6 +38,9 @@ export function useAdminChangeProductReset({
36
38
  useEffect(() => {
37
39
  if (previousSelectedChangeProductIdRef.current === selectedChangeProductId) return;
38
40
  previousSelectedChangeProductIdRef.current = selectedChangeProductId;
41
+ // Ticket categories belong to the selected product family. In particular, a private
42
+ // RESOURCE quantity must not survive a switch to a regular ADULT/CHILD/INFANT product.
43
+ setQuantities({});
39
44
  setSelectedAvailability(null);
40
45
  setSelectedReturnOption(null);
41
46
  setAddOns([]);
@@ -47,6 +52,7 @@ export function useAdminChangeProductReset({
47
52
  setError('');
48
53
  }, [
49
54
  selectedChangeProductId,
55
+ setQuantities,
50
56
  setSelectedAvailability,
51
57
  setSelectedReturnOption,
52
58
  setAddOns,
@@ -20,6 +20,7 @@ type AdminCustomReceiptLine = {
20
20
  amountInput: string;
21
21
  amountSign?: number;
22
22
  calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
23
+ sourceAdjustmentId?: string;
23
24
  };
24
25
 
25
26
  function addOnSelectionTotal(selections: AddOnSelection[] | null | undefined, addOns: AddOn[]): number {
@@ -220,6 +221,9 @@ export function useAdminChangeProtectedPricing({
220
221
  () =>
221
222
  roundMoney(
222
223
  adminCustomReceiptLines.reduce((sum, line) => {
224
+ // Existing ledger adjustments are already included in the persisted receipt. Their
225
+ // edit delta is server-priced as reverse + replace and must not be added again here.
226
+ if (line.sourceAdjustmentId) return sum;
223
227
  if (line.calculation !== 'FIXED_AMOUNT') return sum;
224
228
  const raw = line.amountInput.trim();
225
229
  if (raw === '' || raw === '+' || raw === '-') return sum;
@@ -55,7 +55,7 @@ export interface UseAdminChangeQuoteDisplayStateParams {
55
55
  pickupLocationId: string | null;
56
56
  quantities: Record<string, number>;
57
57
  addOnSelections: AddOnSelection[];
58
- initialAddOnMinQtyByKey: Map<string, number>;
58
+ initialAddOnQtyByKey: Map<string, number>;
59
59
  changeFlowResolvedInitialProductOptionId: string | null;
60
60
  selectedParentProductChanged: boolean;
61
61
  isProviderDashboardChange: boolean;
@@ -65,6 +65,7 @@ export interface UseAdminChangeQuoteDisplayStateParams {
65
65
  editableSummaryPreSubtotalDelta: number;
66
66
  adminCustomAdjustmentTotal: number;
67
67
  adminCustomReceiptLines: AdminCustomReceiptLine[];
68
+ adminReverseAdjustmentIds: string[];
68
69
  isManualOverrideEligibleLine: (line: ProviderDashboardPricingLine) => boolean;
69
70
  isAdmin: boolean;
70
71
  useAdminFeAuthoritativeQuote: boolean;
@@ -99,7 +100,7 @@ export function useAdminChangeQuoteDisplayState({
99
100
  pickupLocationId,
100
101
  quantities,
101
102
  addOnSelections,
102
- initialAddOnMinQtyByKey,
103
+ initialAddOnQtyByKey,
103
104
  changeFlowResolvedInitialProductOptionId,
104
105
  selectedParentProductChanged,
105
106
  isProviderDashboardChange,
@@ -109,6 +110,7 @@ export function useAdminChangeQuoteDisplayState({
109
110
  editableSummaryPreSubtotalDelta,
110
111
  adminCustomAdjustmentTotal,
111
112
  adminCustomReceiptLines,
113
+ adminReverseAdjustmentIds,
112
114
  isManualOverrideEligibleLine,
113
115
  isAdmin,
114
116
  useAdminFeAuthoritativeQuote,
@@ -321,7 +323,7 @@ export function useAdminChangeQuoteDisplayState({
321
323
  pickupLocationId,
322
324
  quantities,
323
325
  addOnSelections,
324
- initialAddOnMinQtyByKey,
326
+ initialAddOnQtyByKey,
325
327
  changeFlowResolvedInitialProductOptionId,
326
328
  productChanged: selectedParentProductChanged,
327
329
  });
@@ -344,8 +346,8 @@ export function useAdminChangeQuoteDisplayState({
344
346
  providerQuotedLines,
345
347
  hasChangeSelection,
346
348
  editableSummaryPreSubtotalDelta,
347
- adminCustomAdjustmentTotal,
348
349
  adminCustomReceiptLines,
350
+ adminReverseAdjustmentIds,
349
351
  isManualOverrideEligibleLine,
350
352
  });
351
353
 
@@ -29,12 +29,14 @@ import type { Currency } from './CurrencySwitcher';
29
29
  import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
30
30
  import {
31
31
  buildAdminChangeQuoteRequestKey,
32
+ canRequestAdminChangeQuote,
32
33
  canReuseCompletedAdminChangeQuote,
33
34
  } from './admin-change-quote-request-key';
34
35
  import {
35
36
  buildAdminChangeV2QuoteRequest,
36
37
  quoteAdminChangeV2ForUi,
37
38
  } from './admin-change-v2-quote-request';
39
+ import { friendlyChangeBookingError } from './change-booking-error-message';
38
40
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
39
41
 
40
42
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
@@ -43,7 +45,12 @@ type AdminCustomReceiptLine = {
43
45
  amountInput: string;
44
46
  amountSign?: number;
45
47
  calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
46
- scope: 'POSITIVE_AMENDMENT_VALUE' | 'CURRENT_ACTIVE_TICKETS' | 'CURRENT_ACTIVE_BOOKING';
48
+ scope:
49
+ | 'POSITIVE_AMENDMENT_VALUE'
50
+ | 'CURRENT_ACTIVE_TICKETS'
51
+ | 'CURRENT_ACTIVE_BOOKING'
52
+ | 'SELECTED_COMPONENTS';
53
+ selectedComponentIds: string[];
47
54
  taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
48
55
  reasonCode: string;
49
56
  };
@@ -71,6 +78,8 @@ export interface UseAdminChangeQuotePreviewParams {
71
78
  promoApplicationScope: AdminPromoApplicationScope;
72
79
  adminCustomReceiptLines: AdminCustomReceiptLine[];
73
80
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
81
+ adminReverseAdjustmentIds: string[];
82
+ needsAdjustmentDiscovery: boolean;
74
83
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
75
84
  useAdminFeAuthoritativeQuote: boolean;
76
85
  latestChangeQuote: AdminChangeLatestQuote | null;
@@ -132,6 +141,8 @@ export function useAdminChangeQuotePreview({
132
141
  promoApplicationScope,
133
142
  adminCustomReceiptLines,
134
143
  adminStructuredAdjustments,
144
+ adminReverseAdjustmentIds,
145
+ needsAdjustmentDiscovery,
135
146
  refundDispositionsByOperationId,
136
147
  useAdminFeAuthoritativeQuote,
137
148
  latestChangeQuote,
@@ -167,10 +178,9 @@ export function useAdminChangeQuotePreview({
167
178
  onPricePreviewChange,
168
179
  }: UseAdminChangeQuotePreviewParams): UseAdminChangeQuotePreviewResult {
169
180
  const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
170
-
171
181
  const changeFlowNeedsServerPrice =
172
182
  isCustomerSelfServeChange &&
173
- hasChangeSelection &&
183
+ hasEffectiveChangeSelection &&
174
184
  !!initialValues?.bookingReference?.trim() &&
175
185
  !!lastName.trim();
176
186
 
@@ -179,13 +189,44 @@ export function useAdminChangeQuotePreview({
179
189
  (latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
180
190
  const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
181
191
  const hasPublishedChangeQuote = latestChangeQuote !== null;
192
+ /**
193
+ * Existing admin adjustments must be discoverable before the operator makes a change.
194
+ * Initial selection hydration can briefly leave the live availability/return unset, so use
195
+ * the persisted booking identities for this read-only discovery quote instead of waiting for
196
+ * the editable target controls to become complete.
197
+ */
198
+ const quoteAvailability = useMemo<Availability | null>(() => {
199
+ if (selectedAvailability) return selectedAvailability;
200
+ if (!needsAdjustmentDiscovery || !initialValues?.dateTime?.trim()) return null;
201
+ const optionId = initialValues.productOptionId?.trim() || initialValues.productId?.trim();
202
+ if (!optionId) return null;
203
+ return {
204
+ dateTime: initialValues.dateTime.trim(),
205
+ availabilityId: initialValues.availabilityId?.trim() || undefined,
206
+ productOptionId: optionId,
207
+ vacancies: Math.max(totalQuantity, 1),
208
+ currency,
209
+ };
210
+ }, [
211
+ selectedAvailability,
212
+ needsAdjustmentDiscovery,
213
+ initialValues?.dateTime,
214
+ initialValues?.availabilityId,
215
+ initialValues?.productOptionId,
216
+ initialValues?.productId,
217
+ totalQuantity,
218
+ currency,
219
+ ]);
220
+ const quoteReturnAvailabilityId =
221
+ selectedReturnOption?.returnAvailabilityId ??
222
+ (needsAdjustmentDiscovery ? initialValues?.returnAvailabilityId?.trim() || null : null);
182
223
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
183
224
  bookingReference: initialValues?.bookingReference,
184
225
  lastName,
185
226
  productId: product.productId,
186
- selectedAvailability,
227
+ selectedAvailability: quoteAvailability,
187
228
  pickupLocationId,
188
- returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
229
+ returnAvailabilityId: quoteReturnAvailabilityId,
189
230
  quantities,
190
231
  addOnSelections,
191
232
  adminCustomReceiptLines,
@@ -193,15 +234,16 @@ export function useAdminChangeQuotePreview({
193
234
  promoCode: appliedPromoCode,
194
235
  promoApplicationScope,
195
236
  structuredAdjustments: adminStructuredAdjustments,
237
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
196
238
  refundDispositionsByOperationId,
197
239
  useAdminFeAuthoritativeQuote,
198
240
  }), [
199
241
  initialValues?.bookingReference,
200
242
  lastName,
201
243
  product.productId,
202
- selectedAvailability,
244
+ quoteAvailability,
203
245
  pickupLocationId,
204
- selectedReturnOption?.returnAvailabilityId,
246
+ quoteReturnAvailabilityId,
205
247
  quantities,
206
248
  addOnSelections,
207
249
  adminCustomReceiptLines,
@@ -209,11 +251,22 @@ export function useAdminChangeQuotePreview({
209
251
  appliedPromoCode,
210
252
  promoApplicationScope,
211
253
  adminStructuredAdjustments,
254
+ adminReverseAdjustmentIds,
212
255
  refundDispositionsByOperationId,
213
256
  useAdminFeAuthoritativeQuote,
214
257
  ]);
215
258
  const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
216
259
  const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
260
+ const quoteMissingRequiredReturnSelection =
261
+ missingRequiredReturnSelection && !needsAdjustmentDiscovery;
262
+ const canRequestChangeQuote = canRequestAdminChangeQuote({
263
+ isCustomerSelfServeChange,
264
+ hasEffectiveChangeSelection: hasEffectiveChangeSelection || needsAdjustmentDiscovery,
265
+ hasSelectedAvailability: quoteAvailability != null,
266
+ bookingReference: initialValues?.bookingReference,
267
+ lastName,
268
+ missingRequiredReturnSelection: quoteMissingRequiredReturnSelection,
269
+ });
217
270
 
218
271
  const changeFlowSubmitDisabled =
219
272
  missingRequiredReturnSelection ||
@@ -279,20 +332,23 @@ export function useAdminChangeQuotePreview({
279
332
  : 'Change booking (no charge)';
280
333
  })();
281
334
 
282
- const checkoutFormError =
335
+ const rawCheckoutFormError =
283
336
  (error || '') ||
284
337
  (missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
285
338
  (isChangeQuoteBlocked && !refundDecisionRequired
286
339
  ? (latestChangeQuote?.reasonIfBlocked ?? 'This change cannot be priced right now.')
287
340
  : '') ||
288
341
  (changeQuoteFetchError ?? '');
342
+ const checkoutFormError = friendlyChangeBookingError(rawCheckoutFormError);
289
343
 
290
344
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
291
345
  if (!selectedAvailability || totalQuantity <= 0) return null;
292
346
  const ticketsLine = formatTicketLineItemsForSummary(
293
347
  ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }))
294
348
  );
295
- const itinerary = computeItineraryDisplay();
349
+ const itinerary =
350
+ latestChangeQuote?.authoritativeItineraryDisplay ??
351
+ computeItineraryDisplay();
296
352
  const itinerarySteps =
297
353
  itinerary?.map((s) => ({
298
354
  time: s.time?.trim() || null,
@@ -336,6 +392,7 @@ export function useAdminChangeQuotePreview({
336
392
  latestChangeQuote?.quotedTotal,
337
393
  latestChangeQuote?.serverDisplay?.total,
338
394
  latestChangeQuote?.serverPreview?.totalNewBooking,
395
+ latestChangeQuote?.authoritativeItineraryDisplay,
339
396
  ]);
340
397
 
341
398
  useEffect(() => {
@@ -407,13 +464,7 @@ export function useAdminChangeQuotePreview({
407
464
  return;
408
465
  }
409
466
 
410
- if (
411
- !hasChangeSelection ||
412
- !selectedAvailability ||
413
- !initialValues?.bookingReference?.trim() ||
414
- !lastName.trim() ||
415
- missingRequiredReturnSelection
416
- ) {
467
+ if (!canRequestChangeQuote || !quoteAvailability || !initialValues?.bookingReference?.trim()) {
417
468
  setLatestChangeQuote(null);
418
469
  setChangeQuoteLoading(false);
419
470
  setChangeQuoteFetchError(null);
@@ -435,7 +486,7 @@ export function useAdminChangeQuotePreview({
435
486
  return;
436
487
  }
437
488
 
438
- const optionId = getAvailabilityOptionId(selectedAvailability);
489
+ const optionId = getAvailabilityOptionId(quoteAvailability);
439
490
  if (!optionId) {
440
491
  setLatestChangeQuote(null);
441
492
  setChangeQuoteLoading(false);
@@ -462,15 +513,20 @@ export function useAdminChangeQuotePreview({
462
513
  lastName,
463
514
  parentProductId: product.productId,
464
515
  optionId,
465
- selectedAvailability,
516
+ selectedAvailability: quoteAvailability,
466
517
  pickupLocationId,
467
- returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
518
+ returnAvailabilityId: quoteReturnAvailabilityId,
468
519
  bookingItems,
469
520
  addOnSelections,
470
521
  cancellationPolicyId: cancellationPolicyId ?? null,
471
522
  promoCode: appliedPromoCode,
472
523
  promoApplicationScope,
473
524
  structuredAdjustments: adminStructuredAdjustments,
525
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
526
+ includeSelectableAdjustmentComponents:
527
+ needsAdjustmentDiscovery || adminCustomReceiptLines.some(
528
+ (line) => line.scope === 'SELECTED_COMPONENTS',
529
+ ),
474
530
  refundDispositionsByOperationId,
475
531
  previousPassengerCount: changeFlowInitialTicketCount,
476
532
  previousAvailabilityId: initialValues.availabilityId ?? null,
@@ -522,7 +578,7 @@ export function useAdminChangeQuotePreview({
522
578
  // Fetching is controlled by the canonical user-input identity. Do not add quote-derived
523
579
  // totals, receipt objects, or display lines here: a response must not trigger another quote.
524
580
  isCustomerSelfServeChange,
525
- hasChangeSelection,
581
+ canRequestChangeQuote,
526
582
  missingRequiredReturnSelection,
527
583
  changeQuoteInputsKey,
528
584
  hasPublishedChangeQuote,
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { AdminAmendmentActiveAdjustmentSnapshot } from '../../lib/booking-api';
2
3
 
3
4
  export type AdminCustomReceiptLine = {
4
5
  id: string;
@@ -6,63 +7,223 @@ export type AdminCustomReceiptLine = {
6
7
  amountInput: string;
7
8
  amountSign: 1 | -1;
8
9
  calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
9
- scope: 'POSITIVE_AMENDMENT_VALUE' | 'CURRENT_ACTIVE_TICKETS' | 'CURRENT_ACTIVE_BOOKING';
10
+ scope:
11
+ | 'POSITIVE_AMENDMENT_VALUE'
12
+ | 'CURRENT_ACTIVE_TICKETS'
13
+ | 'CURRENT_ACTIVE_BOOKING'
14
+ | 'SELECTED_COMPONENTS';
15
+ selectedComponentIds: string[];
10
16
  taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
11
17
  reasonCode: string;
18
+ /** Immutable ledger id. Existing lines are edited by reversing this id and adding a replacement. */
19
+ sourceAdjustmentId?: string;
20
+ replacementAdjustmentId?: string;
21
+ isDirty?: boolean;
12
22
  };
13
23
 
14
24
  type AdminCustomReceiptLinePatch = Partial<
15
25
  Pick<
16
26
  AdminCustomReceiptLine,
17
- 'label' | 'amountInput' | 'amountSign' | 'calculation' | 'scope' | 'taxBehavior' | 'reasonCode'
27
+ | 'label'
28
+ | 'amountInput'
29
+ | 'amountSign'
30
+ | 'calculation'
31
+ | 'scope'
32
+ | 'selectedComponentIds'
33
+ | 'taxBehavior'
34
+ | 'reasonCode'
18
35
  >
19
36
  >;
20
37
 
21
- export function useAdminCustomReceiptLines(resetKey: string | null | undefined) {
38
+ export function lineFromActiveAdjustment(
39
+ adjustment: AdminAmendmentActiveAdjustmentSnapshot,
40
+ replacementAdjustmentId: string,
41
+ ): AdminCustomReceiptLine {
42
+ const percentage = adjustment.mode !== 'FIXED_AMOUNT';
43
+ return {
44
+ id: `existing:${adjustment.adjustmentId}`,
45
+ label:
46
+ adjustment.reason?.trim() ||
47
+ adjustment.referenceCode?.trim() ||
48
+ adjustment.reasonCode.replace(/_/g, ' ').toLowerCase(),
49
+ amountInput: percentage
50
+ ? String(Math.abs(adjustment.percentageBasisPoints ?? 0) / 100)
51
+ : (Math.abs(adjustment.amountCents) / 100).toFixed(2),
52
+ amountSign: adjustment.amountCents < 0 ? -1 : 1,
53
+ calculation: percentage ? 'PERCENTAGE' : 'FIXED_AMOUNT',
54
+ scope: adjustment.scope,
55
+ selectedComponentIds:
56
+ adjustment.scope === 'SELECTED_COMPONENTS' ? adjustment.affectedComponentIds : [],
57
+ taxBehavior: adjustment.taxBehavior,
58
+ reasonCode: adjustment.reasonCode,
59
+ sourceAdjustmentId: adjustment.adjustmentId,
60
+ replacementAdjustmentId,
61
+ isDirty: false,
62
+ };
63
+ }
64
+
65
+ export function reverseAdjustmentIdsForAdminCustomLines(
66
+ lines: AdminCustomReceiptLine[],
67
+ removedExistingAdjustmentIds: string[] = [],
68
+ ): string[] {
69
+ return Array.from(new Set([
70
+ ...removedExistingAdjustmentIds,
71
+ ...lines.flatMap((line) =>
72
+ line.sourceAdjustmentId && line.isDirty ? [line.sourceAdjustmentId] : [],
73
+ ),
74
+ ])).sort();
75
+ }
76
+
77
+ export function useAdminCustomReceiptLines(
78
+ resetKey: string | null | undefined,
79
+ reversibleAdjustments: AdminAmendmentActiveAdjustmentSnapshot[] | undefined,
80
+ ) {
22
81
  const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<AdminCustomReceiptLine[]>([]);
82
+ const [appliedAdminCustomReceiptLines, setAppliedAdminCustomReceiptLines] = useState<
83
+ AdminCustomReceiptLine[]
84
+ >([]);
85
+ const [removedExistingAdjustmentIds, setRemovedExistingAdjustmentIds] = useState<string[]>([]);
86
+ const [appliedRemovedExistingAdjustmentIds, setAppliedRemovedExistingAdjustmentIds] = useState<
87
+ string[]
88
+ >([]);
89
+ const [hasHydratedExistingAdjustments, setHasHydratedExistingAdjustments] = useState(false);
23
90
  const adminCustomLineIdRef = useRef(0);
24
91
  const previousResetKeyRef = useRef(resetKey);
92
+ const hydratedResetKeyRef = useRef<string | null | undefined>(undefined);
93
+ const skipHydrationForResetKeyRef = useRef<string | null | undefined>(undefined);
25
94
 
26
- const handleAddAdminCustomReceiptLine = useCallback(() => {
95
+ const nextDraftId = useCallback((prefix: 'admin-rcpt' | 'admin-edit') => {
27
96
  adminCustomLineIdRef.current += 1;
97
+ return `${prefix}-${Date.now().toString(36)}-${adminCustomLineIdRef.current}`;
98
+ }, []);
99
+
100
+ const handleAddAdminCustomReceiptLine = useCallback(() => {
101
+ const id = nextDraftId('admin-rcpt');
28
102
  setAdminCustomReceiptLines((prev) => [
29
103
  ...prev,
30
104
  {
31
- id: `admin-rcpt-${adminCustomLineIdRef.current}`,
105
+ id,
32
106
  label: '',
33
107
  amountInput: '',
34
108
  amountSign: 1,
35
109
  calculation: 'FIXED_AMOUNT',
36
110
  scope: 'POSITIVE_AMENDMENT_VALUE',
37
- taxBehavior: 'NON_TAXABLE',
111
+ selectedComponentIds: [],
112
+ taxBehavior: 'TAXABLE',
38
113
  reasonCode: 'STAFF_ADJUSTMENT',
39
114
  },
40
115
  ]);
41
- }, []);
116
+ return id;
117
+ }, [nextDraftId]);
42
118
 
43
119
  const handleUpdateAdminCustomReceiptLine = useCallback(
44
120
  (id: string, patch: AdminCustomReceiptLinePatch) => {
45
121
  setAdminCustomReceiptLines((prev) =>
46
- prev.map((line) => (line.id === id ? { ...line, ...patch } : line))
122
+ prev.map((line) =>
123
+ line.id === id
124
+ ? { ...line, ...patch, ...(line.sourceAdjustmentId ? { isDirty: true } : {}) }
125
+ : line,
126
+ )
47
127
  );
48
128
  },
49
129
  [],
50
130
  );
51
131
 
52
132
  const handleRemoveAdminCustomReceiptLine = useCallback((id: string) => {
133
+ const removed = adminCustomReceiptLines.find((line) => line.id === id);
134
+ if (removed?.sourceAdjustmentId) {
135
+ setRemovedExistingAdjustmentIds((current) =>
136
+ current.includes(removed.sourceAdjustmentId!)
137
+ ? current
138
+ : [...current, removed.sourceAdjustmentId!],
139
+ );
140
+ }
53
141
  setAdminCustomReceiptLines((prev) => prev.filter((line) => line.id !== id));
54
- }, []);
142
+ }, [adminCustomReceiptLines]);
143
+
144
+ const handleApplyAdminCustomReceiptLines = useCallback(() => {
145
+ setAppliedAdminCustomReceiptLines(adminCustomReceiptLines);
146
+ setAppliedRemovedExistingAdjustmentIds(removedExistingAdjustmentIds);
147
+ }, [adminCustomReceiptLines, removedExistingAdjustmentIds]);
55
148
 
56
149
  useEffect(() => {
57
150
  if (previousResetKeyRef.current === resetKey) return;
58
151
  previousResetKeyRef.current = resetKey;
59
152
  setAdminCustomReceiptLines([]);
153
+ setAppliedAdminCustomReceiptLines([]);
154
+ setRemovedExistingAdjustmentIds([]);
155
+ setAppliedRemovedExistingAdjustmentIds([]);
156
+ setHasHydratedExistingAdjustments(false);
157
+ hydratedResetKeyRef.current = undefined;
158
+ skipHydrationForResetKeyRef.current =
159
+ reversibleAdjustments === undefined ? undefined : resetKey;
160
+ // `reversibleAdjustments` is intentionally sampled only when the reset key changes.
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
162
  }, [resetKey]);
61
163
 
164
+ useEffect(() => {
165
+ if (reversibleAdjustments === undefined) return;
166
+ // A product-family reset and clearing the previous quote happen in separate effects.
167
+ // Never hydrate the new editor from the previous target's quote during that transition.
168
+ if (skipHydrationForResetKeyRef.current === resetKey) {
169
+ skipHydrationForResetKeyRef.current = undefined;
170
+ return;
171
+ }
172
+ if (hydratedResetKeyRef.current === resetKey) return;
173
+ hydratedResetKeyRef.current = resetKey;
174
+ const hydratedLines = reversibleAdjustments.map((adjustment) =>
175
+ lineFromActiveAdjustment(adjustment, nextDraftId('admin-edit')),
176
+ );
177
+ setAdminCustomReceiptLines(hydratedLines);
178
+ setAppliedAdminCustomReceiptLines(hydratedLines);
179
+ setRemovedExistingAdjustmentIds([]);
180
+ setAppliedRemovedExistingAdjustmentIds([]);
181
+ setHasHydratedExistingAdjustments(true);
182
+ }, [nextDraftId, resetKey, reversibleAdjustments]);
183
+
184
+ const adminReverseAdjustmentIds = reverseAdjustmentIdsForAdminCustomLines(
185
+ appliedAdminCustomReceiptLines,
186
+ appliedRemovedExistingAdjustmentIds,
187
+ );
188
+ const hasPendingAdminCustomReceiptLineChanges = useMemo(
189
+ () =>
190
+ JSON.stringify({
191
+ lines: adminCustomReceiptLines,
192
+ removedIds: [...removedExistingAdjustmentIds].sort(),
193
+ }) !==
194
+ JSON.stringify({
195
+ lines: appliedAdminCustomReceiptLines,
196
+ removedIds: [...appliedRemovedExistingAdjustmentIds].sort(),
197
+ }),
198
+ [
199
+ adminCustomReceiptLines,
200
+ appliedAdminCustomReceiptLines,
201
+ removedExistingAdjustmentIds,
202
+ appliedRemovedExistingAdjustmentIds,
203
+ ],
204
+ );
205
+ const pendingAdminCustomReceiptLineIds = useMemo(() => {
206
+ const appliedLinesById = new Map(
207
+ appliedAdminCustomReceiptLines.map((line) => [line.id, line]),
208
+ );
209
+ return adminCustomReceiptLines.flatMap((line) => {
210
+ const appliedLine = appliedLinesById.get(line.id);
211
+ return !appliedLine || JSON.stringify(line) !== JSON.stringify(appliedLine)
212
+ ? [line.id]
213
+ : [];
214
+ });
215
+ }, [adminCustomReceiptLines, appliedAdminCustomReceiptLines]);
216
+
62
217
  return {
63
218
  adminCustomReceiptLines,
219
+ appliedAdminCustomReceiptLines,
220
+ adminReverseAdjustmentIds,
221
+ hasPendingAdminCustomReceiptLineChanges,
222
+ pendingAdminCustomReceiptLineIds,
223
+ hasHydratedExistingAdjustments,
64
224
  handleAddAdminCustomReceiptLine,
65
225
  handleUpdateAdminCustomReceiptLine,
66
226
  handleRemoveAdminCustomReceiptLine,
227
+ handleApplyAdminCustomReceiptLines,
67
228
  };
68
229
  }