@ticketboothapp/booking 1.2.75 → 1.2.76

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.75",
3
+ "version": "1.2.76",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -126,6 +126,9 @@ function mergeQuoteSliceWithServerPreview(
126
126
  serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
127
127
  pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
128
128
  ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
129
+ /** Same cent pair the BE uses for `amountDueCents` / intent & receipt "New Booking Difference" — use for signed refund display. */
130
+ quotePreviousTotalCents: quote.previousTotalCents,
131
+ quoteNewTotalCents: quote.newTotalCents,
129
132
  };
130
133
  }
131
134
 
@@ -852,9 +855,10 @@ export function AdminChangeBookingFlow({
852
855
  const lockedPromoCode = initialValues?.promoCode?.trim()
853
856
  ? initialValues.promoCode.trim().toUpperCase()
854
857
  : null;
855
- /** Public self-serve only: cannot reduce tickets below original counts. */
858
+ /** Public self-serve only: cannot reduce tickets below original counts. Provider-dashboard admins may reduce party size. */
856
859
  const changeBookingMinimumQuantities = useMemo(() => {
857
860
  if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
861
+ if (isAdmin && isProviderDashboardChange) return undefined;
858
862
  const m: Record<string, number> = {};
859
863
  for (const item of initialValues.bookingItems) {
860
864
  const key = item.category?.trim();
@@ -862,7 +866,7 @@ export function AdminChangeBookingFlow({
862
866
  m[key] = Math.max(0, Number(item.count) || 0);
863
867
  }
864
868
  return m;
865
- }, [isCustomerSelfServeChange, initialValues?.bookingItems]);
869
+ }, [isCustomerSelfServeChange, initialValues?.bookingItems, isAdmin, isProviderDashboardChange]);
866
870
  const [adminChoiceData, setAdminChoiceData] = useState<{
867
871
  reservationReference: string;
868
872
  reservationExpiration?: string;
@@ -895,6 +899,9 @@ export function AdminChangeBookingFlow({
895
899
  quotedTotal?: number;
896
900
  /** From `quoteChangeBooking` receipt fields — drives PriceSummary when self-serve. */
897
901
  serverDisplay?: { total: number; subtotal: number; tax: number };
902
+ /** Server quote cents — authoritative vs `serverDisplay` scaling (aligns with receipt difference line). */
903
+ quotePreviousTotalCents?: number;
904
+ quoteNewTotalCents?: number;
898
905
  /** Parsed from last quote — unified server-owned preview for lines + picker overrides. */
899
906
  serverPreview: ReturnType<typeof buildChangeBookingServerPreview>;
900
907
  pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
@@ -3687,6 +3694,23 @@ export function AdminChangeBookingFlow({
3687
3694
  providerHasAdditionalAdjustments ||
3688
3695
  Math.abs(adminCustomAdjustmentTotal) >= 0.005;
3689
3696
 
3697
+ /**
3698
+ * True until `quoteChangeBooking` returns a confirmed `serverDisplay` for the current selection.
3699
+ * Named so manual price-line UI can align with “still settling final price” without touching ticket/receipt math.
3700
+ */
3701
+ const changeFlowFinalPricePending =
3702
+ suppressSelfServeCurrencyUi &&
3703
+ selectedAvailability != null &&
3704
+ totalQuantity > 0 &&
3705
+ !selfServePricingConfirmed;
3706
+
3707
+ /**
3708
+ * Provider inline edits + admin custom lines: show while waiting on or after the authoritative quote.
3709
+ * UI-only gate — does not alter ticket line items or receipt-floor rules.
3710
+ */
3711
+ const showChangeFlowManualPriceLines =
3712
+ changeFlowFinalPricePending || selfServePricingConfirmed;
3713
+
3690
3714
  const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
3691
3715
  providerPreview: providerTotalsPreview,
3692
3716
  serverQuotePreview:
@@ -3713,25 +3737,112 @@ export function AdminChangeBookingFlow({
3713
3737
  );
3714
3738
  const displayChangeFlowTax = roundMoney(displayedChangeAmountsRaw.tax + adminTaxDeltaForExternalDisplay);
3715
3739
  const displayChangeFlowProposedTotal = roundMoney(
3716
- displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3740
+ displayLayerUsesExternalPricing
3741
+ ? displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3742
+ : displayedChangeAmountsRaw.total
3717
3743
  );
3718
3744
 
3719
3745
  const changeFlowClientEstimateDue = (() => {
3720
3746
  if (!originalReceipt) return totalPrice;
3721
- // Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
3747
+ // Self-serve quote: match BE receipt / intent: (newTotalCents previousTotalCents) / 100.
3722
3748
  if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3749
+ const pq = latestChangeQuote.quotePreviousTotalCents;
3750
+ const nq = latestChangeQuote.quoteNewTotalCents;
3751
+ if (pq != null && nq != null) {
3752
+ return normalizeNearZeroOwed(roundMoney((nq - pq) / 100));
3753
+ }
3754
+ if (latestChangeQuote.serverDisplay != null) {
3755
+ return normalizeNearZeroOwed(
3756
+ roundMoney(latestChangeQuote.serverDisplay.total - originalReceipt.total),
3757
+ );
3758
+ }
3723
3759
  return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
3724
3760
  }
3725
3761
  return changeFlowBalanceVsOriginal({
3726
3762
  newTotal: displayChangeFlowProposedTotal,
3727
3763
  originalReceiptTotal: originalReceipt.total,
3728
- audience: isProviderDashboardChange ? 'provider' : 'customer',
3764
+ audience: 'admin',
3729
3765
  });
3730
3766
  })();
3731
3767
 
3732
3768
  const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
3733
3769
  const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
3734
3770
 
3771
+ const changeFlowAdminPricingDebugPanel = useMemo(() => {
3772
+ if (!isAdmin || !selectedAvailability || totalQuantity <= 0) return null;
3773
+ const fmt = (n: number) => formatCurrencyAmount(roundMoney(n), currency, locale as 'en' | 'fr');
3774
+ const path = (() => {
3775
+ if (!originalReceipt) return 'totalPrice (no original receipt)';
3776
+ if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3777
+ if (
3778
+ latestChangeQuote.quotePreviousTotalCents != null &&
3779
+ latestChangeQuote.quoteNewTotalCents != null
3780
+ ) {
3781
+ return 'POST /change/quote: (quoteNewTotalCents − quotePreviousTotalCents) / 100';
3782
+ }
3783
+ if (latestChangeQuote.serverDisplay != null) {
3784
+ return 'POST /change/quote: serverDisplay.total − originalReceipt.total';
3785
+ }
3786
+ return 'POST /change/quote: priceDiff (API balance delta)';
3787
+ }
3788
+ return 'FE: displayChangeFlowProposedTotal − originalReceipt.total (signed; used when quote not driving amount-due)';
3789
+ })();
3790
+ const quoteTotalPreview =
3791
+ latestChangeQuote?.serverDisplay != null ? fmt(latestChangeQuote.serverDisplay.total) : '—';
3792
+ return (
3793
+ <details className="mt-3 rounded-md border border-amber-200/80 bg-amber-50/50 p-2 text-left text-xs text-amber-950">
3794
+ <summary className="cursor-pointer select-none font-medium text-stone-800">
3795
+ Price calculation (admin debug)
3796
+ </summary>
3797
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
3798
+ {[
3799
+ `Amount-due path: ${path}`,
3800
+ `displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
3801
+ `selfServePricingConfirmed: ${String(selfServePricingConfirmed)} · changeQuoteLoading: ${String(changeQuoteLoading)}`,
3802
+ `Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
3803
+ `Admin custom lines (sum): ${fmt(adminCustomAdjustmentTotal)}`,
3804
+ `Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
3805
+ `effectiveTax · promo discount: ${fmt(effectiveTax)} · ${fmt(effectivePromoDiscountAmount)}`,
3806
+ `totalPrice (subtotal + tax − promo): ${fmt(totalPrice)}`,
3807
+ `changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
3808
+ `Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
3809
+ `displayChangeFlowProposedTotal (summary / preview footer): ${fmt(displayChangeFlowProposedTotal)}`,
3810
+ originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: —',
3811
+ `Quote serverDisplay.total (if any): ${quoteTotalPreview}`,
3812
+ `changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
3813
+ `changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
3814
+ ].join('\n')}
3815
+ </pre>
3816
+ </details>
3817
+ );
3818
+ }, [
3819
+ isAdmin,
3820
+ selectedAvailability,
3821
+ totalQuantity,
3822
+ currency,
3823
+ locale,
3824
+ originalReceipt,
3825
+ isCustomerSelfServeChange,
3826
+ latestChangeQuote,
3827
+ changeQuoteFetchError,
3828
+ displayLayerUsesExternalPricing,
3829
+ selfServePricingConfirmed,
3830
+ changeQuoteLoading,
3831
+ effectiveSubtotal,
3832
+ adminCustomAdjustmentTotal,
3833
+ effectiveSubtotalForCheckout,
3834
+ effectiveTax,
3835
+ effectivePromoDiscountAmount,
3836
+ totalPrice,
3837
+ changeFlowNewBookingTotal,
3838
+ displayedChangeAmountsRaw.subtotal,
3839
+ displayedChangeAmountsRaw.tax,
3840
+ displayedChangeAmountsRaw.total,
3841
+ displayChangeFlowProposedTotal,
3842
+ changeFlowClientEstimateDue,
3843
+ changeFlowAmountDue,
3844
+ ]);
3845
+
3735
3846
  const changeCheckoutButtonLabel = (() => {
3736
3847
  if (!hasEffectiveChangeSelection) return undefined;
3737
3848
  if (isProviderDashboardChange) {
@@ -3759,7 +3870,9 @@ export function AdminChangeBookingFlow({
3759
3870
  const d = Math.round(changeFlowClientEstimateDue * 100) / 100;
3760
3871
  return d > 0
3761
3872
  ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
3762
- : 'Change booking (no charge)';
3873
+ : d < 0
3874
+ ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
3875
+ : 'Change booking (no charge)';
3763
3876
  }
3764
3877
  const tr = t('booking.changeBooking');
3765
3878
  return tr !== 'booking.changeBooking' ? tr : 'Change booking';
@@ -3767,7 +3880,9 @@ export function AdminChangeBookingFlow({
3767
3880
  const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
3768
3881
  return est > 0
3769
3882
  ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3770
- : 'Change booking (no charge)';
3883
+ : est < 0
3884
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3885
+ : 'Change booking (no charge)';
3771
3886
  })();
3772
3887
  /** Partner deferred-invoice path applies to {@link NewBookingFlow} only. */
3773
3888
  const deferredInvoiceSubmitLabel = undefined;
@@ -4611,30 +4726,41 @@ export function AdminChangeBookingFlow({
4611
4726
  quote.proposed?.total ??
4612
4727
  quote.newReceipt?.total ??
4613
4728
  changeFlowNewBookingTotal;
4614
- const feChangeDue = changeFlowBalanceVsOriginal({
4615
- newTotal: serverNewTotalForGuard,
4616
- originalReceiptTotal: originalReceipt?.total ?? 0,
4617
- audience: 'customer',
4618
- });
4619
- const serverAmountDue =
4620
- quote.amountDueCents != null
4621
- ? Math.max(0, quote.amountDueCents / 100)
4622
- : Math.max(0, quote.priceDiff ?? 0);
4623
- if (feChangeDue > 0.02 && serverAmountDue <= 0.009) {
4729
+ /** Signed proposed − previous (major units). Matches quote slices when cents present; refund owed < 0. */
4730
+ const signedBalanceMajor =
4731
+ quote.previousTotalCents != null && quote.newTotalCents != null
4732
+ ? (quote.newTotalCents - quote.previousTotalCents) / 100
4733
+ : quote.balanceDeltaMajorUnits ?? null;
4734
+ const chargeDue =
4735
+ signedBalanceMajor != null
4736
+ ? Math.max(0, signedBalanceMajor)
4737
+ : quote.amountDueCents != null
4738
+ ? quote.amountDueCents / 100
4739
+ : Math.max(0, quote.priceDiff ?? 0);
4740
+ const feChangeDue =
4741
+ signedBalanceMajor ??
4742
+ changeFlowBalanceVsOriginal({
4743
+ newTotal: serverNewTotalForGuard,
4744
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4745
+ audience: 'admin',
4746
+ });
4747
+ if (feChangeDue > 0.02 && chargeDue <= 0.009) {
4624
4748
  throw new Error(
4625
4749
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4626
4750
  );
4627
4751
  }
4628
- // No-payment change: FE shows nothing owed still require server agreement so we never confirm free when a charge is due.
4629
- if (serverAmountDue <= 0.009) {
4752
+ // No additional charge (includes refund-owed / downgrade): confirm-free apply only when server agrees no payment due.
4753
+ if (chargeDue <= 0.009) {
4630
4754
  if (feChangeDue > 0.02) {
4631
4755
  throw new Error(
4632
4756
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4633
4757
  );
4634
4758
  }
4635
- const p = quote.proposed?.total ?? quote.newReceipt?.total;
4636
- const o = quote.original?.total ?? quote.originalReceipt?.total;
4637
- if (p != null && o != null && p - o > 0.01) {
4759
+ const upgradeWithoutCharge =
4760
+ quote.newTotalCents != null &&
4761
+ quote.previousTotalCents != null &&
4762
+ quote.newTotalCents > quote.previousTotalCents + 1;
4763
+ if (upgradeWithoutCharge) {
4638
4764
  throw new Error(
4639
4765
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4640
4766
  );
@@ -4686,11 +4812,14 @@ export function AdminChangeBookingFlow({
4686
4812
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
4687
4813
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
4688
4814
  const amountDueForCheckout = isCustomerSelfServeChange
4689
- ? changeFlowBalanceVsOriginal({
4690
- newTotal: changeFlowNewBookingTotal,
4691
- originalReceiptTotal: originalReceipt?.total ?? 0,
4692
- audience: 'customer',
4693
- })
4815
+ ? Math.max(
4816
+ 0,
4817
+ changeFlowBalanceVsOriginal({
4818
+ newTotal: changeFlowNewBookingTotal,
4819
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4820
+ audience: 'admin',
4821
+ }),
4822
+ )
4694
4823
  : totalPrice;
4695
4824
  const lines = [
4696
4825
  ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
@@ -5406,10 +5535,12 @@ export function AdminChangeBookingFlow({
5406
5535
  replacePriceSummary={selfServeCheckoutPlaceholder}
5407
5536
  totalPrice={changeFlowAmountDue}
5408
5537
  totalSummaryLabel={
5409
- t('booking.totalOwedForBookingChange') &&
5410
- t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
5411
- ? t('booking.totalOwedForBookingChange')
5412
- : 'Total owed for booking difference'
5538
+ changeFlowAmountDue < -0.005
5539
+ ? 'Refund owed (vs original booking)'
5540
+ : t('booking.totalOwedForBookingChange') &&
5541
+ t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
5542
+ ? t('booking.totalOwedForBookingChange')
5543
+ : 'Total owed for booking difference'
5413
5544
  }
5414
5545
  subtotal={displayChangeFlowSubtotal}
5415
5546
  taxAmount={
@@ -5438,9 +5569,11 @@ export function AdminChangeBookingFlow({
5438
5569
  !providerPricingUi.error ? (
5439
5570
  <div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
5440
5571
  ) : null}
5572
+ {changeFlowAdminPricingDebugPanel}
5441
5573
  </>
5442
5574
  }
5443
5575
  extraBeforeSubtotal={
5576
+ showChangeFlowManualPriceLines ? (
5444
5577
  <>
5445
5578
  {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5446
5579
  <div className="space-y-1">
@@ -5623,6 +5756,7 @@ export function AdminChangeBookingFlow({
5623
5756
  })}
5624
5757
  </div>
5625
5758
  </>
5759
+ ) : null
5626
5760
  }
5627
5761
  firstName={firstName}
5628
5762
  lastName={lastName}
@@ -146,6 +146,7 @@ export function CheckoutForm({
146
146
  <div className={styles.summaryWrapper}>
147
147
  {replacePriceSummary ? (
148
148
  <>
149
+ {extraBeforeSubtotal ? <div className="mb-4 min-w-0">{extraBeforeSubtotal}</div> : null}
149
150
  {replacePriceSummary}
150
151
  <div className="mt-4">{extraBetweenTaxAndTotal}</div>
151
152
  </>
@@ -178,16 +178,16 @@ export function resolveChangeFlowNewBookingTotal(input: {
178
178
 
179
179
  /**
180
180
  * Product: **What the customer owes** for the difference is `max(0, newTotal − oldReceipt)`.
181
- * **Provider dashboard** may show a signed delta (credits/refunds as negative).
181
+ * **Provider dashboard** and **admin change booking** may show a signed delta (refund owed as negative).
182
182
  */
183
183
  export function changeFlowBalanceVsOriginal(input: {
184
184
  newTotal: number;
185
185
  originalReceiptTotal: number;
186
- /** `customer` = self-serve & admin change; `provider` = provider inline editor (can owe the guest). */
187
- audience: 'customer' | 'provider';
186
+ /** `customer` = self-serve change only; `provider` | `admin` = signed newTotal original receipt total. */
187
+ audience: 'customer' | 'provider' | 'admin';
188
188
  }): number {
189
189
  const delta = input.newTotal - input.originalReceiptTotal;
190
- return input.audience === 'provider' ? delta : Math.max(0, delta);
190
+ return input.audience === 'customer' ? Math.max(0, delta) : delta;
191
191
  }
192
192
 
193
193
  /**
@@ -255,8 +255,13 @@ export function sliceChangeQuoteForUi(
255
255
  fallbackCart: { total: number; subtotal: number; tax: number },
256
256
  currencyFallback: string
257
257
  ): ChangeQuoteUiSlice {
258
+ /** Staff JWT on quote: signed new − previous (refund owed negative). Otherwise same as legacy nonnegative amount-due. */
258
259
  const priceDiff =
259
- quote.amountDueCents != null ? quote.amountDueCents / 100 : quote.priceDiff ?? 0;
260
+ quote.balanceDeltaMajorUnits != null
261
+ ? quote.balanceDeltaMajorUnits
262
+ : quote.amountDueCents != null
263
+ ? quote.amountDueCents / 100
264
+ : quote.priceDiff ?? 0;
260
265
  const serverDisplay = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
261
266
  return {
262
267
  priceDiff,
@@ -987,6 +987,11 @@ export interface ChangeBookingQuoteResponse {
987
987
  originalReceipt?: ChangeBookingQuoteReceipt;
988
988
  newReceipt?: ChangeBookingQuoteReceipt;
989
989
  priceDiff: number;
990
+ /**
991
+ * Present when the quote was requested with a **staff** JWT: signed (new total − previous receipt total) in major units.
992
+ * Negative = refund owed to the guest. Omitted for unauthenticated / customer-only quotes.
993
+ */
994
+ balanceDeltaMajorUnits?: number;
990
995
  currency?: string;
991
996
  canProceed?: boolean;
992
997
  reasonIfBlocked?: string;