@ticketboothapp/booking 1.2.165 → 1.2.166

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.165",
3
+ "version": "1.2.166",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -924,7 +924,7 @@ export function AdminChangeBookingFlow({
924
924
  checkoutModalData,
925
925
  showAdminPaymentChoice,
926
926
  adminChoiceData,
927
- cancelPendingReservation,
927
+ handleCheckoutClose,
928
928
  handleCheckout,
929
929
  handleConfirmWithoutPayment,
930
930
  handlePayNow,
@@ -1022,7 +1022,7 @@ export function AdminChangeBookingFlow({
1022
1022
  ...{ pickupLocationId, pickupLocationSkipped, firstName, lastName, email, getSuccessUrl, t },
1023
1023
  onPayNow: handlePayNow,
1024
1024
  onConfirmWithoutPayment: handleConfirmWithoutPayment, onAdminPaymentChoiceCancel: handleAdminPaymentChoiceCancel,
1025
- onCheckoutClose: cancelPendingReservation, onPaymentSubmitStart: handlePaymentSubmitStart,
1025
+ onCheckoutClose: handleCheckoutClose, onPaymentSubmitStart: handlePaymentSubmitStart,
1026
1026
  onPaymentSubmitError: handlePaymentSubmitError, onPaymentConfirmed: handlePaymentConfirmed,
1027
1027
  isPartialLaunch,
1028
1028
  checkoutVisible: selectedAvailability != null,
@@ -126,6 +126,18 @@ export function buildAdminChangePayNowCheckoutModalData(
126
126
  };
127
127
  }
128
128
 
129
+ export function shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(
130
+ isProviderDashboardChange: boolean,
131
+ isChangeBookingContext: boolean,
132
+ adminChoiceData: Pick<AdminChangePaymentChoiceData, 'pricingQuoteId'> | null,
133
+ ): boolean {
134
+ return Boolean(
135
+ isProviderDashboardChange &&
136
+ isChangeBookingContext &&
137
+ adminChoiceData?.pricingQuoteId?.trim(),
138
+ );
139
+ }
140
+
129
141
  export interface ConfirmAdminChangeBookingWithoutPaymentParams {
130
142
  adminChoiceData: AdminChangePaymentChoiceData;
131
143
  bookingSourceAttribution: Partial<BookingSourceMetadata>;
@@ -43,16 +43,3 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
43
43
  useAdminFeAuthoritativeQuote: input.useAdminFeAuthoritativeQuote,
44
44
  });
45
45
  }
46
-
47
- /**
48
- * A completed request is reusable only while its authoritative quote is still published.
49
- * Closing a nested payment dialog can clear transient quote state without changing the
50
- * selection inputs; in that case the same input key must be eligible for a refetch.
51
- */
52
- export function canReuseCompletedAdminChangeQuote(
53
- inputKey: string,
54
- lastCompletedInputKey: string | null,
55
- hasPublishedQuote: boolean,
56
- ): boolean {
57
- return Boolean(inputKey) && lastCompletedInputKey === inputKey && hasPublishedQuote;
58
- }
@@ -59,6 +59,7 @@ import {
59
59
  buildAdminChangePaymentChoiceData,
60
60
  buildAdminChangePayNowCheckoutModalData,
61
61
  confirmAdminChangeBookingWithoutPayment,
62
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
62
63
  type AdminChangeCheckoutModalData,
63
64
  type AdminChangePaymentChoiceData,
64
65
  } from './admin-change-payment-choice-runner';
@@ -290,8 +291,28 @@ export function useAdminChangeCheckoutController({
290
291
  setShowCheckoutModal(false);
291
292
  setCheckoutModalData(null);
292
293
  setCheckoutClientSecret('');
294
+ }, []);
295
+
296
+ const handleCheckoutClose = useCallback(() => {
297
+ if (paymentSubmitInFlightRef.current) return;
298
+ cancelPendingReservation();
293
299
  setError('');
294
- }, [setError]);
300
+ // An existing-booking Pay now attempt owns an immutable quote/payment-choice snapshot.
301
+ // Closing Stripe must return to that choice instead of making the user create a new quote.
302
+ if (shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(
303
+ isProviderDashboardChange,
304
+ isChangeBookingContext,
305
+ adminChoiceData,
306
+ )) {
307
+ setShowAdminPaymentChoice(true);
308
+ }
309
+ }, [
310
+ adminChoiceData?.pricingQuoteId,
311
+ cancelPendingReservation,
312
+ isChangeBookingContext,
313
+ isProviderDashboardChange,
314
+ setError,
315
+ ]);
295
316
 
296
317
  const cancelPendingReservationBestEffort = useCallback(() => {
297
318
  if (paymentSubmitInFlightRef.current) return;
@@ -324,6 +345,7 @@ export function useAdminChangeCheckoutController({
324
345
  const buildProviderChangePayload = (
325
346
  availabilityProductOptionId: string,
326
347
  bookingItems: AdminChangeBookingItem[],
348
+ authoritativeQuote: AdminChangeLatestQuote | null = latestChangeQuote,
327
349
  ): ProviderDashboardChangeBookingPayload | null => {
328
350
  return buildAdminChangeProviderPayload({
329
351
  selectedAvailability,
@@ -341,8 +363,8 @@ export function useAdminChangeCheckoutController({
341
363
  mergedProviderAdditionalAdjustments,
342
364
  adminStructuredAdjustments,
343
365
  refundDispositionsByOperationId,
344
- pricingV2QuoteId: latestChangeQuote?.quoteId ?? null,
345
- pricingV2QuotedTotal: latestChangeQuote?.quotedTotal ?? latestChangeQuote?.serverDisplay?.total ?? null,
366
+ pricingV2QuoteId: authoritativeQuote?.quoteId ?? null,
367
+ pricingV2QuotedTotal: authoritativeQuote?.quotedTotal ?? authoritativeQuote?.serverDisplay?.total ?? null,
346
368
  providerApplyAuthoritativeReceipt,
347
369
  previousPassengerCount: changeFlowInitialTicketCount,
348
370
  previousAvailabilityId: initialValues?.availabilityId ?? null,
@@ -437,6 +459,7 @@ export function useAdminChangeCheckoutController({
437
459
  let changeIntentIdForCheckout: string | undefined;
438
460
  let changeBookingReferenceForPaidFlow: string | undefined;
439
461
  let confirmedChangeAmountDueForCheckout: number | null = null;
462
+ let authoritativeChangeQuoteForCheckout = latestChangeQuote;
440
463
 
441
464
  if (isCustomerSelfServeChange) {
442
465
  const quoteResult = await quoteAdminCustomerChangeForCheckout({
@@ -465,11 +488,18 @@ export function useAdminChangeCheckoutController({
465
488
  });
466
489
  changeBookingReferenceForPaidFlow = quoteResult.bookingReference;
467
490
  setLatestChangeQuote(quoteResult.mergedQuote);
491
+ // React state is not updated synchronously. Everything in this checkout attempt must
492
+ // use the quote returned above, not the stale latestChangeQuote render closure.
493
+ authoritativeChangeQuoteForCheckout = quoteResult.mergedQuote;
468
494
  confirmedChangeAmountDueForCheckout = quoteResult.decision.amountDueForCheckout;
469
495
 
470
496
  if (quoteResult.decision.kind === 'free') {
471
497
  if (onChangeBooking) {
472
- const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
498
+ const providerPayload = buildProviderChangePayload(
499
+ availabilityProductOptionId,
500
+ bookingItems,
501
+ authoritativeChangeQuoteForCheckout,
502
+ );
473
503
  if (!providerPayload) {
474
504
  setError('No availability selected');
475
505
  setLoading(false);
@@ -502,7 +532,7 @@ export function useAdminChangeCheckoutController({
502
532
  const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
503
533
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
504
534
  const amountDueForCheckout = isProviderDashboardChange && isChangeBookingContext
505
- ? Math.max(0, latestChangeQuote?.priceDiff ?? 0)
535
+ ? Math.max(0, authoritativeChangeQuoteForCheckout?.priceDiff ?? 0)
506
536
  : isCustomerSelfServeChange
507
537
  ? confirmedChangeAmountDueForCheckout ??
508
538
  Math.max(
@@ -547,14 +577,21 @@ export function useAdminChangeCheckoutController({
547
577
  // quote-bound Stripe authorization and applies only after that authorization succeeds.
548
578
  if (isProviderDashboardChange && isChangeBookingContext) {
549
579
  const bookingReference = initialValues?.bookingReference?.trim();
550
- const quoteId = latestChangeQuote?.quoteId?.trim();
580
+ const quoteId = authoritativeChangeQuoteForCheckout?.quoteId?.trim();
551
581
  if (!bookingReference) throw new Error('Missing booking reference.');
552
- if (!quoteId || latestChangeQuote?.canProceed === false) {
553
- throw new Error(latestChangeQuote?.reasonIfBlocked || 'The authoritative change quote is not ready.');
582
+ if (!quoteId || authoritativeChangeQuoteForCheckout?.canProceed === false) {
583
+ throw new Error(
584
+ authoritativeChangeQuoteForCheckout?.reasonIfBlocked ||
585
+ 'The authoritative change quote is not ready.',
586
+ );
554
587
  }
555
588
  if (amountDueForCheckout <= 0) {
556
589
  if (!onChangeBooking) throw new Error('Provider change handler is unavailable.');
557
- const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
590
+ const providerPayload = buildProviderChangePayload(
591
+ availabilityProductOptionId,
592
+ bookingItems,
593
+ authoritativeChangeQuoteForCheckout,
594
+ );
558
595
  if (!providerPayload) throw new Error('No availability selected');
559
596
  await onChangeBooking(providerPayload);
560
597
  onSuccess?.({ reservationReference: bookingReference });
@@ -591,7 +628,9 @@ export function useAdminChangeCheckoutController({
591
628
  'Pay later to apply the change and add the amount to the booking balance, or collect a new card payment before applying it.',
592
629
  confirmWithoutPaymentLabel: 'Pay later',
593
630
  previousTotal: originalReceipt?.total,
594
- newTotal: latestChangeQuote?.serverDisplay?.total ?? latestChangeQuote?.quotedTotal,
631
+ newTotal:
632
+ authoritativeChangeQuoteForCheckout?.serverDisplay?.total ??
633
+ authoritativeChangeQuoteForCheckout?.quotedTotal,
595
634
  finalizeExistingChangeInline: true,
596
635
  preferConfirmWithoutPayment: true,
597
636
  }));
@@ -941,6 +980,7 @@ export function useAdminChangeCheckoutController({
941
980
  showAdminPaymentChoice,
942
981
  adminChoiceData,
943
982
  cancelPendingReservation,
983
+ handleCheckoutClose,
944
984
  handleCheckout,
945
985
  handleConfirmWithoutPayment,
946
986
  handlePayNow,
@@ -27,10 +27,7 @@ import {
27
27
  } from './change-booking-flow-helpers';
28
28
  import type { Currency } from './CurrencySwitcher';
29
29
  import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
30
- import {
31
- buildAdminChangeQuoteRequestKey,
32
- canReuseCompletedAdminChangeQuote,
33
- } from './admin-change-quote-request-key';
30
+ import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
34
31
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
35
32
 
36
33
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
@@ -170,7 +167,6 @@ export function useAdminChangeQuotePreview({
170
167
  latestChangeQuote?.canProceed === false &&
171
168
  (latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
172
169
  const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
173
- const hasPublishedChangeQuote = latestChangeQuote !== null;
174
170
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
175
171
  bookingReference: initialValues?.bookingReference,
176
172
  lastName,
@@ -212,14 +208,6 @@ export function useAdminChangeQuotePreview({
212
208
  const changeCheckoutButtonLabel = (() => {
213
209
  if (!hasEffectiveChangeSelection) return undefined;
214
210
  if (isProviderDashboardChange) {
215
- if (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)) {
216
- const tr = t('booking.updatingPrice');
217
- return tr !== 'booking.updatingPrice' ? tr : 'Updating price\u2026';
218
- }
219
- if (changeQuoteFetchError) {
220
- const tr = t('booking.changeBookingRetry');
221
- return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
222
- }
223
211
  if (isChangeQuoteBlocked) {
224
212
  const tr = t('booking.changeBookingNotAvailable');
225
213
  return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
@@ -227,8 +215,8 @@ export function useAdminChangeQuotePreview({
227
215
  // Prefer authoritative V2 signed due (charge positive / refund negative). Do not use the
228
216
  // FE cart estimate once a server quote exists — receipt total deltas can disagree with
229
217
  // floored/settlement amounts (DEFECT-PV2-018 follow-up).
230
- const serverDue = latestChangeQuote?.serverPreview?.amountDue ?? latestChangeQuote?.priceDiff ?? 0;
231
- const due = Math.round(serverDue * 100) / 100;
218
+ const serverDue = latestChangeQuote?.serverPreview?.amountDue;
219
+ const due = Math.round((serverDue ?? changeFlowClientEstimateDue) * 100) / 100;
232
220
  return due > 0
233
221
  ? `Change booking (${formatCurrencyAmount(due, currency, locale as 'en' | 'fr')})`
234
222
  : due < 0
@@ -410,15 +398,11 @@ export function useAdminChangeQuotePreview({
410
398
  return;
411
399
  }
412
400
 
413
- if (changeQuoteInputsKey && inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
414
- return;
415
- }
416
-
417
- if (canReuseCompletedAdminChangeQuote(
418
- changeQuoteInputsKey,
419
- lastCompletedQuoteInputsKeyRef.current,
420
- hasPublishedChangeQuote,
421
- )) {
401
+ if (
402
+ changeQuoteInputsKey &&
403
+ (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey ||
404
+ lastCompletedQuoteInputsKeyRef.current === changeQuoteInputsKey)
405
+ ) {
422
406
  setChangeQuoteLoading(false);
423
407
  return;
424
408
  }
@@ -509,13 +493,10 @@ export function useAdminChangeQuotePreview({
509
493
  }, [
510
494
  // Fetching is controlled by the canonical user-input identity. Do not add quote-derived
511
495
  // totals, receipt objects, or display lines here: a response must not trigger another quote.
512
- // Quote presence is intentionally included so a cleared transient quote can be restored by
513
- // refetching the same canonical input identity after a nested checkout is closed.
514
496
  isCustomerSelfServeChange,
515
497
  hasChangeSelection,
516
498
  missingRequiredReturnSelection,
517
499
  changeQuoteInputsKey,
518
- hasPublishedChangeQuote,
519
500
  ]);
520
501
 
521
502
  return {
@@ -13,10 +13,7 @@ import {
13
13
  mapAdminChangeBookingQuoteV2Data,
14
14
  type ChangeBookingQuoteResponse,
15
15
  } from '../src/lib/booking-api';
16
- import {
17
- buildAdminChangeQuoteRequestKey,
18
- canReuseCompletedAdminChangeQuote,
19
- } from '../src/components/booking/admin-change-quote-request-key';
16
+ import { buildAdminChangeQuoteRequestKey } from '../src/components/booking/admin-change-quote-request-key';
20
17
  import {
21
18
  noRefundRemovedValue,
22
19
  normalizeReturnPriceTreatment,
@@ -40,6 +37,7 @@ import { buildPublicChangeAmountDueSummary } from '../src/components/booking/cha
40
37
  import {
41
38
  buildAdminChangePaymentChoiceData,
42
39
  buildAdminChangePayNowCheckoutModalData,
40
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
43
41
  } from '../src/components/booking/admin-change-payment-choice-runner';
44
42
 
45
43
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
@@ -120,6 +118,16 @@ test('admin existing-booking pay-now review keeps previous, updated, and exact d
120
118
  assert.equal(checkout.finalizeExistingChangeInline, true);
121
119
  assert.equal(choice.confirmWithoutPaymentLabel, 'Pay later');
122
120
  assert.equal(choice.preferConfirmWithoutPayment, true);
121
+ assert.equal(
122
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(true, true, choice),
123
+ true,
124
+ );
125
+ assert.equal(
126
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(true, true, {
127
+ pricingQuoteId: ' ',
128
+ }),
129
+ false,
130
+ );
123
131
  assert.equal(
124
132
  buildAdminChangePayNowCheckoutModalData(
125
133
  { ...choice, finalizeExistingChangeInline: undefined },
@@ -198,13 +206,6 @@ test('admin quote response display state cannot invalidate its request identity'
198
206
  assert.equal(keyAfterResponse, keyBeforeResponse);
199
207
  });
200
208
 
201
- test('admin quote dedupe refetches unchanged inputs after the published quote is cleared', () => {
202
- const inputKey = 'same-admin-change-inputs';
203
- assert.equal(canReuseCompletedAdminChangeQuote(inputKey, inputKey, true), true);
204
- assert.equal(canReuseCompletedAdminChangeQuote(inputKey, inputKey, false), false);
205
- assert.equal(canReuseCompletedAdminChangeQuote(inputKey, 'different-inputs', true), false);
206
- });
207
-
208
209
  test('admin no-charge provider payload carries the selected pickup itinerary', () => {
209
210
  const itineraryDisplay: NonNullable<
210
211
  Parameters<typeof buildAdminChangeProviderPayload>[0]['itineraryDisplay']