@ticketboothapp/booking 1.2.166 → 1.2.168

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 (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  7. package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
  8. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  9. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  10. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  11. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  12. package/src/components/booking/NewBookingFlow.tsx +14 -2
  13. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  14. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  15. package/src/components/booking/PriceBreakdown.tsx +66 -15
  16. package/src/components/booking/PriceSummary.tsx +16 -0
  17. package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
  18. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  19. package/src/components/booking/admin-adjustment-components.ts +44 -0
  20. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  21. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  22. package/src/components/booking/admin-change-provider-payload.ts +12 -1
  23. package/src/components/booking/admin-change-quote-request-key.ts +41 -1
  24. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  25. package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
  26. package/src/components/booking/admin-refund-decision-context.ts +122 -0
  27. package/src/components/booking/admin-refund-disposition.ts +71 -2
  28. package/src/components/booking/availability-date-selection.ts +16 -0
  29. package/src/components/booking/booking-flow-types.ts +4 -0
  30. package/src/components/booking/booking-flow.css +5 -0
  31. package/src/components/booking/change-booking-error-message.ts +137 -0
  32. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  33. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  34. package/src/components/booking/change-booking-quote-state.ts +44 -1
  35. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  36. package/src/components/booking/private-shuttle-availability.ts +14 -0
  37. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  38. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  39. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  40. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  41. package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
  42. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  43. package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
  44. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  45. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  46. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
  47. package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
  48. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  49. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  50. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  51. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  52. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  53. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  54. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  55. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  56. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  57. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  58. package/src/lib/booking/booking-cutoffs.ts +22 -0
  59. package/src/lib/booking/change-booking-server-preview.ts +92 -5
  60. package/src/lib/booking/change-flow-pricing.ts +12 -0
  61. package/src/lib/booking/i18n/messages/en.json +1 -0
  62. package/src/lib/booking/i18n/messages/fr.json +1 -0
  63. package/src/lib/booking-api.ts +76 -1
  64. package/test/change-booking-helpers.test.ts +1319 -7
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -8,8 +8,8 @@ import {
8
8
  } from 'react';
9
9
  import {
10
10
  quoteChangeBooking,
11
- quoteAdminChangeBookingV2,
12
11
  type AdminAmendmentRefundDisposition,
12
+ type AdminPromoApplicationScope,
13
13
  type AdminFeAuthoritativeReceipt,
14
14
  type Availability,
15
15
  type ItineraryDisplayStep,
@@ -27,7 +27,16 @@ import {
27
27
  } from './change-booking-flow-helpers';
28
28
  import type { Currency } from './CurrencySwitcher';
29
29
  import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
30
- import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
30
+ import {
31
+ buildAdminChangeQuoteRequestKey,
32
+ canRequestAdminChangeQuote,
33
+ canReuseCompletedAdminChangeQuote,
34
+ } from './admin-change-quote-request-key';
35
+ import {
36
+ buildAdminChangeV2QuoteRequest,
37
+ quoteAdminChangeV2ForUi,
38
+ } from './admin-change-v2-quote-request';
39
+ import { friendlyChangeBookingError } from './change-booking-error-message';
31
40
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
32
41
 
33
42
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
@@ -36,7 +45,12 @@ type AdminCustomReceiptLine = {
36
45
  amountInput: string;
37
46
  amountSign?: number;
38
47
  calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
39
- 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[];
40
54
  taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
41
55
  reasonCode: string;
42
56
  };
@@ -60,8 +74,12 @@ export interface UseAdminChangeQuotePreviewParams {
60
74
  quantities: Record<string, number>;
61
75
  addOnSelections: AddOnSelection[];
62
76
  cancellationPolicyId: string | null;
77
+ appliedPromoCode: string | null;
78
+ promoApplicationScope: AdminPromoApplicationScope;
63
79
  adminCustomReceiptLines: AdminCustomReceiptLine[];
64
80
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
81
+ adminReverseAdjustmentIds: string[];
82
+ needsAdjustmentDiscovery: boolean;
65
83
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
66
84
  useAdminFeAuthoritativeQuote: boolean;
67
85
  latestChangeQuote: AdminChangeLatestQuote | null;
@@ -119,8 +137,12 @@ export function useAdminChangeQuotePreview({
119
137
  quantities,
120
138
  addOnSelections,
121
139
  cancellationPolicyId,
140
+ appliedPromoCode,
141
+ promoApplicationScope,
122
142
  adminCustomReceiptLines,
123
143
  adminStructuredAdjustments,
144
+ adminReverseAdjustmentIds,
145
+ needsAdjustmentDiscovery,
124
146
  refundDispositionsByOperationId,
125
147
  useAdminFeAuthoritativeQuote,
126
148
  latestChangeQuote,
@@ -156,10 +178,9 @@ export function useAdminChangeQuotePreview({
156
178
  onPricePreviewChange,
157
179
  }: UseAdminChangeQuotePreviewParams): UseAdminChangeQuotePreviewResult {
158
180
  const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
159
-
160
181
  const changeFlowNeedsServerPrice =
161
182
  isCustomerSelfServeChange &&
162
- hasChangeSelection &&
183
+ hasEffectiveChangeSelection &&
163
184
  !!initialValues?.bookingReference?.trim() &&
164
185
  !!lastName.trim();
165
186
 
@@ -167,37 +188,85 @@ export function useAdminChangeQuotePreview({
167
188
  latestChangeQuote?.canProceed === false &&
168
189
  (latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
169
190
  const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
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);
170
223
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
171
224
  bookingReference: initialValues?.bookingReference,
172
225
  lastName,
173
226
  productId: product.productId,
174
- selectedAvailability,
227
+ selectedAvailability: quoteAvailability,
175
228
  pickupLocationId,
176
- returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
229
+ returnAvailabilityId: quoteReturnAvailabilityId,
177
230
  quantities,
178
231
  addOnSelections,
179
232
  adminCustomReceiptLines,
180
233
  cancellationPolicyId,
234
+ promoCode: appliedPromoCode,
235
+ promoApplicationScope,
181
236
  structuredAdjustments: adminStructuredAdjustments,
237
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
182
238
  refundDispositionsByOperationId,
183
239
  useAdminFeAuthoritativeQuote,
184
240
  }), [
185
241
  initialValues?.bookingReference,
186
242
  lastName,
187
243
  product.productId,
188
- selectedAvailability,
244
+ quoteAvailability,
189
245
  pickupLocationId,
190
- selectedReturnOption?.returnAvailabilityId,
246
+ quoteReturnAvailabilityId,
191
247
  quantities,
192
248
  addOnSelections,
193
249
  adminCustomReceiptLines,
194
250
  cancellationPolicyId,
251
+ appliedPromoCode,
252
+ promoApplicationScope,
195
253
  adminStructuredAdjustments,
254
+ adminReverseAdjustmentIds,
196
255
  refundDispositionsByOperationId,
197
256
  useAdminFeAuthoritativeQuote,
198
257
  ]);
199
258
  const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
200
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
+ });
201
270
 
202
271
  const changeFlowSubmitDisabled =
203
272
  missingRequiredReturnSelection ||
@@ -208,6 +277,14 @@ export function useAdminChangeQuotePreview({
208
277
  const changeCheckoutButtonLabel = (() => {
209
278
  if (!hasEffectiveChangeSelection) return undefined;
210
279
  if (isProviderDashboardChange) {
280
+ if (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)) {
281
+ const tr = t('booking.updatingPrice');
282
+ return tr !== 'booking.updatingPrice' ? tr : 'Updating price\u2026';
283
+ }
284
+ if (changeQuoteFetchError) {
285
+ const tr = t('booking.changeBookingRetry');
286
+ return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
287
+ }
211
288
  if (isChangeQuoteBlocked) {
212
289
  const tr = t('booking.changeBookingNotAvailable');
213
290
  return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
@@ -215,8 +292,8 @@ export function useAdminChangeQuotePreview({
215
292
  // Prefer authoritative V2 signed due (charge positive / refund negative). Do not use the
216
293
  // FE cart estimate once a server quote exists — receipt total deltas can disagree with
217
294
  // floored/settlement amounts (DEFECT-PV2-018 follow-up).
218
- const serverDue = latestChangeQuote?.serverPreview?.amountDue;
219
- const due = Math.round((serverDue ?? changeFlowClientEstimateDue) * 100) / 100;
295
+ const serverDue = latestChangeQuote?.serverPreview?.amountDue ?? latestChangeQuote?.priceDiff ?? 0;
296
+ const due = Math.round(serverDue * 100) / 100;
220
297
  return due > 0
221
298
  ? `Change booking (${formatCurrencyAmount(due, currency, locale as 'en' | 'fr')})`
222
299
  : due < 0
@@ -255,20 +332,23 @@ export function useAdminChangeQuotePreview({
255
332
  : 'Change booking (no charge)';
256
333
  })();
257
334
 
258
- const checkoutFormError =
335
+ const rawCheckoutFormError =
259
336
  (error || '') ||
260
337
  (missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
261
338
  (isChangeQuoteBlocked && !refundDecisionRequired
262
339
  ? (latestChangeQuote?.reasonIfBlocked ?? 'This change cannot be priced right now.')
263
340
  : '') ||
264
341
  (changeQuoteFetchError ?? '');
342
+ const checkoutFormError = friendlyChangeBookingError(rawCheckoutFormError);
265
343
 
266
344
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
267
345
  if (!selectedAvailability || totalQuantity <= 0) return null;
268
346
  const ticketsLine = formatTicketLineItemsForSummary(
269
347
  ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }))
270
348
  );
271
- const itinerary = computeItineraryDisplay();
349
+ const itinerary =
350
+ latestChangeQuote?.authoritativeItineraryDisplay ??
351
+ computeItineraryDisplay();
272
352
  const itinerarySteps =
273
353
  itinerary?.map((s) => ({
274
354
  time: s.time?.trim() || null,
@@ -312,6 +392,7 @@ export function useAdminChangeQuotePreview({
312
392
  latestChangeQuote?.quotedTotal,
313
393
  latestChangeQuote?.serverDisplay?.total,
314
394
  latestChangeQuote?.serverPreview?.totalNewBooking,
395
+ latestChangeQuote?.authoritativeItineraryDisplay,
315
396
  ]);
316
397
 
317
398
  useEffect(() => {
@@ -383,13 +464,7 @@ export function useAdminChangeQuotePreview({
383
464
  return;
384
465
  }
385
466
 
386
- if (
387
- !hasChangeSelection ||
388
- !selectedAvailability ||
389
- !initialValues?.bookingReference?.trim() ||
390
- !lastName.trim() ||
391
- missingRequiredReturnSelection
392
- ) {
467
+ if (!canRequestChangeQuote || !quoteAvailability || !initialValues?.bookingReference?.trim()) {
393
468
  setLatestChangeQuote(null);
394
469
  setChangeQuoteLoading(false);
395
470
  setChangeQuoteFetchError(null);
@@ -398,16 +473,20 @@ export function useAdminChangeQuotePreview({
398
473
  return;
399
474
  }
400
475
 
401
- if (
402
- changeQuoteInputsKey &&
403
- (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey ||
404
- lastCompletedQuoteInputsKeyRef.current === changeQuoteInputsKey)
405
- ) {
476
+ if (changeQuoteInputsKey && inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
477
+ return;
478
+ }
479
+
480
+ if (canReuseCompletedAdminChangeQuote(
481
+ changeQuoteInputsKey,
482
+ lastCompletedQuoteInputsKeyRef.current,
483
+ hasPublishedChangeQuote,
484
+ )) {
406
485
  setChangeQuoteLoading(false);
407
486
  return;
408
487
  }
409
488
 
410
- const optionId = getAvailabilityOptionId(selectedAvailability);
489
+ const optionId = getAvailabilityOptionId(quoteAvailability);
411
490
  if (!optionId) {
412
491
  setLatestChangeQuote(null);
413
492
  setChangeQuoteLoading(false);
@@ -429,44 +508,49 @@ export function useAdminChangeQuotePreview({
429
508
  .filter(([, count]) => count > 0)
430
509
  .map(([category, count]) => ({ category, count }));
431
510
  try {
432
- const quoteRequestBase = {
511
+ const quoteRequestBase = buildAdminChangeV2QuoteRequest({
433
512
  bookingReference: bookingReferenceForQuote,
434
- lastName: lastName.trim(),
435
- newProductId: optionId,
436
- newParentProductId: product.productId,
437
- newDateTime: selectedAvailability.dateTime,
438
- newAvailabilityId: selectedAvailability.availabilityId || null,
439
- newPickupLocationId: pickupLocationId || null,
440
- newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
441
- newPassengerCounts: bookingItems,
442
- // Admin owns the complete target selection. [] intentionally clears all add-ons.
443
- newAddOnSelections: addOnSelections,
444
- cancellationPolicyId,
513
+ lastName,
514
+ parentProductId: product.productId,
515
+ optionId,
516
+ selectedAvailability: quoteAvailability,
517
+ pickupLocationId,
518
+ returnAvailabilityId: quoteReturnAvailabilityId,
519
+ bookingItems,
520
+ addOnSelections,
521
+ cancellationPolicyId: cancellationPolicyId ?? null,
522
+ promoCode: appliedPromoCode,
523
+ promoApplicationScope,
445
524
  structuredAdjustments: adminStructuredAdjustments,
446
- ...(Object.keys(refundDispositionsByOperationId).length > 0
447
- ? { refundDispositionsByOperationId }
448
- : {}),
449
- clientProposedTotal: changeFlowNewBookingTotal,
450
- capacitySeatCredit: {
451
- enabled: true,
452
- previousPassengerCount: changeFlowInitialTicketCount,
453
- previousAvailabilityId: initialValues.availabilityId ?? null,
454
- previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
455
- },
456
- };
457
- const quote = useAdminFeAuthoritativeQuote
458
- ? await quoteAdminChangeBookingV2({
525
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
526
+ includeSelectableAdjustmentComponents:
527
+ needsAdjustmentDiscovery || adminCustomReceiptLines.some(
528
+ (line) => line.scope === 'SELECTED_COMPONENTS',
529
+ ),
530
+ refundDispositionsByOperationId,
531
+ previousPassengerCount: changeFlowInitialTicketCount,
532
+ previousAvailabilityId: initialValues.availabilityId ?? null,
533
+ previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
534
+ });
535
+ const mergedQuote = useAdminFeAuthoritativeQuote
536
+ ? await quoteAdminChangeV2ForUi(
537
+ quoteRequestBase,
538
+ {
539
+ total: changeFlowNewBookingTotal,
540
+ subtotal: effectiveSubtotalForCheckout,
541
+ tax: effectiveTax,
542
+ },
543
+ currency,
544
+ )
545
+ : sliceAndMergeChangeQuoteForUi(await quoteChangeBooking({
459
546
  ...quoteRequestBase,
460
- // V2 is server-authoritative. Do not bind it to the outbound-only FE estimate.
461
- clientProposedTotal: undefined,
462
- })
463
- : await quoteChangeBooking(quoteRequestBase);
547
+ clientProposedTotal: changeFlowNewBookingTotal,
548
+ }), {
549
+ total: changeFlowNewBookingTotal,
550
+ subtotal: effectiveSubtotalForCheckout,
551
+ tax: effectiveTax,
552
+ }, currency).mergedQuote;
464
553
  if (seq !== changeQuoteRequestSeq.current) return;
465
- const mergedQuote = sliceAndMergeChangeQuoteForUi(quote, {
466
- total: changeFlowNewBookingTotal,
467
- subtotal: effectiveSubtotalForCheckout,
468
- tax: effectiveTax,
469
- }, currency).mergedQuote;
470
554
  // Commit the completed identity before publishing its result. This keeps a
471
555
  // render caused by setLatestChangeQuote from being interpreted as a new request.
472
556
  lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
@@ -494,9 +578,10 @@ export function useAdminChangeQuotePreview({
494
578
  // Fetching is controlled by the canonical user-input identity. Do not add quote-derived
495
579
  // totals, receipt objects, or display lines here: a response must not trigger another quote.
496
580
  isCustomerSelfServeChange,
497
- hasChangeSelection,
581
+ canRequestChangeQuote,
498
582
  missingRequiredReturnSelection,
499
583
  changeQuoteInputsKey,
584
+ hasPublishedChangeQuote,
500
585
  ]);
501
586
 
502
587
  return {
@@ -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
  }