@ticketboothapp/booking 1.2.80 → 1.2.82

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.80",
3
+ "version": "1.2.82",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -61,6 +61,7 @@ import {
61
61
  changeFlowTicketLineTotalWithReceiptFloor,
62
62
  changeFlowFeeLineTotalWithReceiptFloor,
63
63
  changeFlowReturnLineTotalWithReceiptFloor,
64
+ reconcileTaxIncludedChangeFlowTicketLines,
64
65
  type ChangeFlowProtectedReceiptPricing,
65
66
  roundMoney,
66
67
  } from '../../lib/booking/change-flow-pricing';
@@ -885,7 +886,8 @@ export function AdminChangeBookingFlow({
885
886
  /** Public self-serve only: cannot reduce tickets below original counts. Provider-dashboard admins may reduce party size. */
886
887
  const changeBookingMinimumQuantities = useMemo(() => {
887
888
  if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
888
- if (isAdmin && isProviderDashboardChange) return undefined;
889
+ // Any admin-facing change flow should allow lowering quantities.
890
+ if (isAdmin) return undefined;
889
891
  const m: Record<string, number> = {};
890
892
  for (const item of initialValues.bookingItems) {
891
893
  const key = item.category?.trim();
@@ -3710,7 +3712,6 @@ export function AdminChangeBookingFlow({
3710
3712
  quantities,
3711
3713
  addOnSelections,
3712
3714
  adminCustomReceiptLines,
3713
- editableCheckoutPriceSummaryLines,
3714
3715
  editableSummaryLineAmountInputs,
3715
3716
  editableSummaryLineLabelInputs,
3716
3717
  useAdminFeAuthoritativeQuote,
@@ -3726,7 +3727,6 @@ export function AdminChangeBookingFlow({
3726
3727
  quantities,
3727
3728
  addOnSelections,
3728
3729
  adminCustomReceiptLines,
3729
- editableCheckoutPriceSummaryLines,
3730
3730
  editableSummaryLineAmountInputs,
3731
3731
  editableSummaryLineLabelInputs,
3732
3732
  useAdminFeAuthoritativeQuote,
@@ -3828,11 +3828,30 @@ export function AdminChangeBookingFlow({
3828
3828
  const displayChangeFlowProposedTotalWithEditableLines = roundMoney(
3829
3829
  displayChangeFlowProposedTotal + editableSummaryPreSubtotalDelta
3830
3830
  );
3831
+ /** Tax-included currencies: ticket row amounts match authoritative new-booking total (quote/display), not raw catalog/floor rollup. */
3832
+ const taxIncludedReconciledCheckoutPriceSummaryLines = useMemo(() => {
3833
+ if (!isTaxIncludedInPrice || !originalReceipt || showProviderPricingInlineEditor) {
3834
+ return editableCheckoutPriceSummaryLines;
3835
+ }
3836
+ return reconcileTaxIncludedChangeFlowTicketLines(
3837
+ editableCheckoutPriceSummaryLines,
3838
+ displayChangeFlowProposedTotalWithEditableLines,
3839
+ effectivePromoDiscountAmount,
3840
+ );
3841
+ }, [
3842
+ isTaxIncludedInPrice,
3843
+ originalReceipt,
3844
+ showProviderPricingInlineEditor,
3845
+ editableCheckoutPriceSummaryLines,
3846
+ displayChangeFlowProposedTotalWithEditableLines,
3847
+ effectivePromoDiscountAmount,
3848
+ ]);
3849
+
3831
3850
  const adminFeAuthoritativeReceipt = useMemo((): AdminFeAuthoritativeReceipt => {
3832
- const hasTaxLine = editableCheckoutPriceSummaryLines.some(
3851
+ const hasTaxLine = taxIncludedReconciledCheckoutPriceSummaryLines.some(
3833
3852
  (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
3834
3853
  );
3835
- const lineItems = mapSummaryLinesToFeReceiptLineItems(editableCheckoutPriceSummaryLines);
3854
+ const lineItems = mapSummaryLinesToFeReceiptLineItems(taxIncludedReconciledCheckoutPriceSummaryLines);
3836
3855
  if (!hasTaxLine && Math.abs(displayChangeFlowTax) >= 0.0005) {
3837
3856
  lineItems.push({
3838
3857
  label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
@@ -3855,7 +3874,7 @@ export function AdminChangeBookingFlow({
3855
3874
  lineItems,
3856
3875
  };
3857
3876
  }, [
3858
- editableCheckoutPriceSummaryLines,
3877
+ taxIncludedReconciledCheckoutPriceSummaryLines,
3859
3878
  displayChangeFlowTax,
3860
3879
  displayChangeFlowSubtotal,
3861
3880
  editableSummaryPreSubtotalDelta,
@@ -5015,8 +5034,11 @@ export function AdminChangeBookingFlow({
5015
5034
  }),
5016
5035
  )
5017
5036
  : totalPrice;
5037
+ const ticketRowsForCheckoutBreakdown = taxIncludedReconciledCheckoutPriceSummaryLines.filter(
5038
+ (l): l is Extract<PriceSummaryLine, { kind: 'ticket' }> => l.kind === 'ticket',
5039
+ );
5018
5040
  const lines = [
5019
- ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
5041
+ ...ticketRowsForCheckoutBreakdown.map((line) => ({
5020
5042
  label: line.category,
5021
5043
  amount: line.itemTotal,
5022
5044
  type: 'TICKET' as const,
@@ -5159,7 +5181,7 @@ export function AdminChangeBookingFlow({
5159
5181
  availabilityProductOptionId,
5160
5182
  itineraryDisplay: itineraryDisplay ?? undefined,
5161
5183
  clientSecret: paymentIntent.clientSecret ?? '',
5162
- ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
5184
+ ticketLinesForModal: ticketRowsForCheckoutBreakdown.map((line) => {
5163
5185
  const rate = pricing.find((r) => r.category === line.category);
5164
5186
  const breakdown = getPriceBreakdown(
5165
5187
  line.category,
@@ -5186,7 +5208,7 @@ export function AdminChangeBookingFlow({
5186
5208
  return;
5187
5209
  }
5188
5210
 
5189
- const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
5211
+ const ticketLinesForModal: CheckoutModalLineItem[] = ticketRowsForCheckoutBreakdown.map((line) => {
5190
5212
  const rate = pricing.find((r) => r.category === line.category);
5191
5213
  const breakdown = getPriceBreakdown(
5192
5214
  line.category,
@@ -5725,7 +5747,7 @@ export function AdminChangeBookingFlow({
5725
5747
  {selectedAvailability && (
5726
5748
  <>
5727
5749
  <CheckoutForm
5728
- priceSummaryLines={editableCheckoutPriceSummaryLines}
5750
+ priceSummaryLines={taxIncludedReconciledCheckoutPriceSummaryLines}
5729
5751
  replacePriceSummary={hasChangeSelection ? selfServeCheckoutPlaceholder : undefined}
5730
5752
  totalPrice={changeFlowAmountDue}
5731
5753
  totalSummaryLabel={
@@ -56,6 +56,7 @@ import {
56
56
  changeFlowTicketLineTotalWithReceiptFloor,
57
57
  changeFlowFeeLineTotalWithReceiptFloor,
58
58
  changeFlowReturnLineTotalWithReceiptFloor,
59
+ reconcileTaxIncludedChangeFlowTicketLines,
59
60
  type ChangeFlowProtectedReceiptPricing,
60
61
  roundMoney,
61
62
  } from '../../lib/booking/change-flow-pricing';
@@ -3535,6 +3536,34 @@ export function ChangeBookingFlow({
3535
3536
  const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
3536
3537
  const displayChangeFlowTax = displayedChangeAmounts.tax;
3537
3538
 
3539
+ /** When quote is confirmed but BE sends no `priceSummaryLines`, we still show FE-built lines while total is quote-driven — reconcile ticket amounts for tax-included currencies. */
3540
+ const selfServeSummaryUsesServerLines =
3541
+ suppressSelfServeCurrencyUi &&
3542
+ selfServePricingConfirmed &&
3543
+ (latestChangeQuote?.serverPreview?.priceSummaryLines?.length ?? 0) > 0;
3544
+
3545
+ const taxIncludedSelfServeReconciledPriceSummaryLines = useMemo(() => {
3546
+ if (!isTaxIncludedInPrice || !originalReceipt || !isCustomerSelfServeChange) {
3547
+ return checkoutPriceSummaryLinesForCheckout;
3548
+ }
3549
+ if (selfServeSummaryUsesServerLines) {
3550
+ return checkoutPriceSummaryLinesForCheckout;
3551
+ }
3552
+ return reconcileTaxIncludedChangeFlowTicketLines(
3553
+ checkoutPriceSummaryLinesForCheckout,
3554
+ displayChangeFlowProposedTotal,
3555
+ effectivePromoDiscountAmount,
3556
+ );
3557
+ }, [
3558
+ isTaxIncludedInPrice,
3559
+ originalReceipt,
3560
+ isCustomerSelfServeChange,
3561
+ selfServeSummaryUsesServerLines,
3562
+ checkoutPriceSummaryLinesForCheckout,
3563
+ displayChangeFlowProposedTotal,
3564
+ effectivePromoDiscountAmount,
3565
+ ]);
3566
+
3538
3567
  const changeFlowClientEstimateDue = (() => {
3539
3568
  if (!originalReceipt) return totalPrice;
3540
3569
  // Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
@@ -4437,8 +4466,11 @@ export function ChangeBookingFlow({
4437
4466
  originalReceiptTotal: originalReceipt?.total ?? 0,
4438
4467
  audience: 'customer',
4439
4468
  });
4469
+ const ticketRowsForCheckoutBreakdown = taxIncludedSelfServeReconciledPriceSummaryLines.filter(
4470
+ (l): l is Extract<PriceSummaryLine, { kind: 'ticket' }> => l.kind === 'ticket',
4471
+ );
4440
4472
  const lines = [
4441
- ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
4473
+ ...ticketRowsForCheckoutBreakdown.map((line) => ({
4442
4474
  label: line.category,
4443
4475
  amount: line.itemTotal,
4444
4476
  type: 'TICKET' as const,
@@ -4523,7 +4555,7 @@ export function ChangeBookingFlow({
4523
4555
  })()
4524
4556
  );
4525
4557
 
4526
- const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
4558
+ const ticketLinesForModal: CheckoutModalLineItem[] = ticketRowsForCheckoutBreakdown.map((line) => {
4527
4559
  const rate = pricing.find((r) => r.category === line.category);
4528
4560
  const breakdown = getPriceBreakdown(
4529
4561
  line.category,
@@ -4883,7 +4915,7 @@ export function ChangeBookingFlow({
4883
4915
  {selectedAvailability && (
4884
4916
  <>
4885
4917
  <CheckoutForm
4886
- priceSummaryLines={checkoutPriceSummaryLinesForCheckout}
4918
+ priceSummaryLines={taxIncludedSelfServeReconciledPriceSummaryLines}
4887
4919
  replacePriceSummary={selfServeCheckoutPlaceholder}
4888
4920
  totalPrice={changeFlowAmountDue}
4889
4921
  totalSummaryLabel={
@@ -46,6 +46,7 @@
46
46
  * **Provider** inline pricing may show a **signed** delta from cart or manual overrides.
47
47
  */
48
48
  import type { ChangeBookingQuoteResponse } from '../booking-api';
49
+ import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
49
50
  import { reconcileChangeBookingProposedTotal } from '../currency';
50
51
 
51
52
  /** Money in major units, rounded to cents (half-up). */
@@ -301,6 +302,77 @@ export function resolveChangeFlowDisplayedAmounts(input: {
301
302
  return { ...input.fromCart };
302
303
  }
303
304
 
305
+ function isPromoSummaryLine(row: PriceSummaryLine): boolean {
306
+ if (row.kind !== 'line') return false;
307
+ const t = String(row.type ?? '').toUpperCase();
308
+ if (t === 'PROMO_CODE' || t === 'GIFT_CARD' || t === 'DISCOUNT' || t === 'VOUCHER') return true;
309
+ return row.amount < -0.005 && /promo|discount|voucher|gift/i.test(row.label);
310
+ }
311
+
312
+ /**
313
+ * Tax-included currencies (e.g. EUR): ticket rows from catalog + receipt floors can disagree with the
314
+ * authoritative **new booking total** (quote / display layer). Scale ticket `itemTotal` values so
315
+ * tickets + non-ticket lines + promo = `targetNewBookingTotal` without changing quantities or non-ticket rows.
316
+ */
317
+ export function reconcileTaxIncludedChangeFlowTicketLines(
318
+ lines: PriceSummaryLine[],
319
+ targetNewBookingTotal: number,
320
+ promoDiscountAmountIfNotInLines: number,
321
+ ): PriceSummaryLine[] {
322
+ if (!Number.isFinite(targetNewBookingTotal) || lines.length === 0) return lines;
323
+
324
+ const ticketIndices: number[] = [];
325
+ let currentTicketTotal = 0;
326
+ for (let i = 0; i < lines.length; i++) {
327
+ const row = lines[i];
328
+ if (row.kind === 'ticket') {
329
+ ticketIndices.push(i);
330
+ currentTicketTotal += row.itemTotal;
331
+ }
332
+ }
333
+ if (ticketIndices.length === 0) return lines;
334
+
335
+ let nonTicketSum = 0;
336
+ let promoLineSum = 0;
337
+ for (const row of lines) {
338
+ if (row.kind === 'line') {
339
+ const t = String(row.type ?? '').toUpperCase();
340
+ if (t === 'TAX') continue;
341
+ if (isPromoSummaryLine(row)) {
342
+ promoLineSum += row.amount;
343
+ continue;
344
+ }
345
+ nonTicketSum += row.amount;
346
+ }
347
+ }
348
+
349
+ const desiredTicketTotal =
350
+ Math.abs(promoLineSum) >= 0.005
351
+ ? roundMoney(targetNewBookingTotal - nonTicketSum - promoLineSum)
352
+ : roundMoney(
353
+ targetNewBookingTotal - nonTicketSum + Math.max(0, promoDiscountAmountIfNotInLines),
354
+ );
355
+
356
+ if (currentTicketTotal <= 0 || Math.abs(desiredTicketTotal - currentTicketTotal) < 0.02) {
357
+ return lines;
358
+ }
359
+
360
+ const scale = desiredTicketTotal / currentTicketTotal;
361
+ const next = lines.slice() as PriceSummaryLine[];
362
+ let running = 0;
363
+ for (let k = 0; k < ticketIndices.length; k++) {
364
+ const i = ticketIndices[k];
365
+ const row = next[i] as Extract<PriceSummaryLine, { kind: 'ticket' }>;
366
+ const isLast = k === ticketIndices.length - 1;
367
+ const newTotal = isLast
368
+ ? roundMoney(desiredTicketTotal - running)
369
+ : roundMoney(row.itemTotal * scale);
370
+ running += newTotal;
371
+ next[i] = { ...row, itemTotal: newTotal };
372
+ }
373
+ return next;
374
+ }
375
+
304
376
  /**
305
377
  * Product: **Main “total owed” number** should not show -0.00 or noise when the balance is effectively zero.
306
378
  */