@ticketboothapp/booking 1.2.58 → 1.2.60

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.
@@ -49,14 +49,23 @@ interface CalendarProps {
49
49
  displayMode?: 'times' | 'status';
50
50
  /** Optional extra discount percent added to the day-level discount badge. */
51
51
  extraDiscountPercent?: number;
52
- /** When true, cap day discount badges to the currently selected date's discount percent. */
53
- capDiscountToSelectedDate?: boolean;
52
+ /**
53
+ * Change-booking: yyyy-MM-dd of the **existing** booking's tour date. When that date exists in
54
+ * [availabilitiesByDate], each day's badge uses `min(that day's catalog %, the booking date's catalog %)` so
55
+ * the cap stays tied to the current booking, not which day is selected in the UI.
56
+ */
57
+ capDiscountBadgesToBookingDate?: string | null;
54
58
  /**
55
59
  * When true (e.g. change-booking flow), scroll the visible grid once to the week containing
56
60
  * `selectedDate` after the parent sets it from the current booking. Otherwise the grid stays
57
61
  * anchored to the earliest available date.
58
62
  */
59
63
  syncVisibleWeekToSelectedDate?: boolean;
64
+ /**
65
+ * Optional date string (yyyy-MM-dd) that should remain selectable even when sold out.
66
+ * Used by change-booking so the original booked date can always be selected.
67
+ */
68
+ selectableSoldOutDate?: string | null;
60
69
  }
61
70
 
62
71
  // ============ Constants ============
@@ -152,18 +161,21 @@ interface DateCellProps {
152
161
  showCapacity?: boolean;
153
162
  timezone: string;
154
163
  displayMode: 'times' | 'status';
164
+ selectableSoldOutDate?: string | null;
155
165
  onClick: () => void;
156
166
  isMobile?: boolean;
157
167
  }
158
168
 
159
169
  const DateCell = memo(function DateCell({
160
170
  date,
171
+ dateStr,
161
172
  availability,
162
173
  isSelected,
163
174
  isToday,
164
175
  showCapacity,
165
176
  timezone,
166
177
  displayMode,
178
+ selectableSoldOutDate = null,
167
179
  onClick,
168
180
  isMobile = false,
169
181
  }: DateCellProps) {
@@ -171,8 +183,12 @@ const DateCell = memo(function DateCell({
171
183
  const { locale } = useLocale();
172
184
  const dateFnsLocale = dateFnsLocales[locale] || enUS;
173
185
  const dayNumber = formatInTimeZone(date, timezone, 'd');
174
- // When showCapacity (admin), allow selecting sold-out slots for overbooking
175
- const hasAvailability = availability !== null && (!availability.isSoldOut || showCapacity);
186
+ const allowPinnedSoldOutDate = Boolean(selectableSoldOutDate && selectableSoldOutDate === dateStr);
187
+ const hasAvailabilityData = availability !== null;
188
+ // When showCapacity (admin), allow selecting sold-out slots for overbooking.
189
+ // In change flow, also allow the original booked sold-out date to stay selectable.
190
+ const hasAvailability =
191
+ hasAvailabilityData && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate);
176
192
  const isDisabled = !hasAvailability;
177
193
 
178
194
  // Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
@@ -389,8 +405,9 @@ export function Calendar({
389
405
  isLoading = false,
390
406
  displayMode = 'times',
391
407
  extraDiscountPercent = 0,
392
- capDiscountToSelectedDate = false,
408
+ capDiscountBadgesToBookingDate = null,
393
409
  syncVisibleWeekToSelectedDate = false,
410
+ selectableSoldOutDate = null,
394
411
  }: CalendarProps) {
395
412
  const { t } = useTranslations();
396
413
  const { locale } = useLocale();
@@ -837,14 +854,15 @@ export function Calendar({
837
854
  };
838
855
  }
839
856
  });
840
-
841
- if (capDiscountToSelectedDate && selectedDate) {
842
- const selectedDiscount = map[selectedDate]?.totalDiscountPercent;
843
- if (selectedDiscount != null && selectedDiscount > 0) {
857
+
858
+ const anchor = capDiscountBadgesToBookingDate?.trim() || null;
859
+ if (anchor) {
860
+ const anchorDiscount = map[anchor]?.totalDiscountPercent;
861
+ if (anchorDiscount != null && anchorDiscount > 0) {
844
862
  const capped: Record<string, DateAvailability> = {};
845
863
  for (const [dateStr, availability] of Object.entries(map)) {
846
864
  const raw = availability.totalDiscountPercent;
847
- const adjusted = raw != null ? Math.min(raw, selectedDiscount) : undefined;
865
+ const adjusted = raw != null ? Math.min(raw, anchorDiscount) : undefined;
848
866
  capped[dateStr] = {
849
867
  ...availability,
850
868
  totalDiscountPercent: adjusted,
@@ -855,7 +873,7 @@ export function Calendar({
855
873
  }
856
874
 
857
875
  return map;
858
- }, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountToSelectedDate, selectedDate]);
876
+ }, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountBadgesToBookingDate]);
859
877
 
860
878
  // Get display range for header
861
879
  const displayRange = useMemo(() => {
@@ -884,9 +902,16 @@ export function Calendar({
884
902
  const handleDateClick = (date: Date) => {
885
903
  const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
886
904
  const availability = dateAvailabilityMap[dateStr] || null;
887
-
888
- // Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking
889
- if (availability && (!availability.isSoldOut || showCapacity)) {
905
+
906
+ const allowPinnedSoldOutDate = Boolean(
907
+ selectableSoldOutDate &&
908
+ selectableSoldOutDate === dateStr &&
909
+ availability?.isSoldOut,
910
+ );
911
+
912
+ // Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking.
913
+ // In change flow, also allow re-selecting the original booked sold-out date.
914
+ if (availability && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate)) {
890
915
  onDateSelect(dateStr);
891
916
  }
892
917
  };
@@ -1026,7 +1051,13 @@ export function Calendar({
1026
1051
  const hasAvailabilityData = availability !== null;
1027
1052
  const isSoldOut = availability?.isSoldOut === true;
1028
1053
  // When showCapacity (admin), sold-out days are still selectable for overbooking
1029
- const isAvailable = hasAvailabilityData && isInAllowedRange && (!isSoldOut || showCapacity);
1054
+ const allowPinnedSoldOutDate = Boolean(
1055
+ selectableSoldOutDate && selectableSoldOutDate === dateStr && isSoldOut,
1056
+ );
1057
+ const isAvailable =
1058
+ hasAvailabilityData &&
1059
+ isInAllowedRange &&
1060
+ (!isSoldOut || showCapacity || allowPinnedSoldOutDate);
1030
1061
  const isToday = isSameDay(day, new Date());
1031
1062
 
1032
1063
  const dayClass = cn(
@@ -1036,7 +1067,7 @@ export function Calendar({
1036
1067
  isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
1037
1068
  !isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
1038
1069
  !hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
1039
- isAvailable && isSelected && styles.calendarDropdownDaySelected,
1070
+ isSelected && isAvailable && styles.calendarDropdownDaySelected,
1040
1071
  isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
1041
1072
  isToday && isAvailable && !isSelected && styles.calendarDropdownDayToday
1042
1073
  );
@@ -1140,6 +1171,7 @@ export function Calendar({
1140
1171
  showCapacity={showCapacity}
1141
1172
  timezone={timezone}
1142
1173
  displayMode={displayMode}
1174
+ selectableSoldOutDate={selectableSoldOutDate}
1143
1175
  onClick={() => handleDateClick(date)}
1144
1176
  isMobile={isMobile}
1145
1177
  />
@@ -193,6 +193,8 @@ export default function ChangeBookingDialog({
193
193
  bookingReference: booking.bookingReference ?? null,
194
194
  dateTime: booking.dateTime ?? null,
195
195
  availabilityId: booking.availabilityId ?? null,
196
+ /** Parent id (`p_…`) when API did not persist option id; BookingFlow resolves option via availability row. */
197
+ productId: booking.productId ?? null,
196
198
  productOptionId: effectiveProductOptionIdForChangeFlow(booking),
197
199
  pickupLocationId: booking.pickupLocationId ?? null,
198
200
  returnAvailabilityId: booking.returnAvailabilityId ?? null,
@@ -208,6 +210,8 @@ export default function ChangeBookingDialog({
208
210
  },
209
211
  promoCode: lockedPromoCode,
210
212
  cancellationPolicyId: booking.cancellationPolicyId ?? null,
213
+ returnUnitFloorPerPerson:
214
+ (booking as { returnUnitFloorPerPerson?: number | null }).returnUnitFloorPerPerson ?? null,
211
215
  };
212
216
  const receiptCurrency = (booking.receipt.currency as Currency) || 'CAD';
213
217
  // Use booking + static config only — not `product.name` (minimal → API product swaps labels).
@@ -22,6 +22,12 @@ export type ProviderDashboardChangeBookingPayload = {
22
22
  lineOverrides?: Array<{ lineKey: string; amount: number; reason?: string }>;
23
23
  additionalAdjustments?: Array<{ label: string; amount: number }>;
24
24
  } | null;
25
+ capacitySeatCredit?: {
26
+ enabled: boolean;
27
+ previousPassengerCount?: number | null;
28
+ previousAvailabilityId?: string | null;
29
+ previousReturnAvailabilityId?: string | null;
30
+ } | null;
25
31
  };
26
32
 
27
33
  /** Seeds the flow when opening provider change-booking (matches TicketBooth `BookingWidget` `initialBooking`). */
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ## Change-booking pricing — product rules (frontend)
3
+ *
4
+ * Use this file as the **spec checklist** when debating behavior with product; BookingFlow adds **gates** (parent product,
5
+ * channel, same-itinerary) on top of these formulas.
6
+ *
7
+ * ### 1. When receipt “paid floors” apply
8
+ * Only in change flow when the booking’s **parent catalog product** matches the **loaded product** (`changeFlowApplyReceiptPaidFloors`
9
+ * in BookingFlow). Otherwise the session is treated like normal catalog pricing (no receipt floors in this layer).
10
+ *
11
+ * ### 2. Tickets (per category)
12
+ * For each category where we can infer an average **unit paid** from the stored receipt:
13
+ * - Among seats **up to** the **originally booked** count for that category, **unit price** =
14
+ * **`max(receipt average unit, live catalog unit)`** — not the minimum; the guest never pays **below** what they paid
15
+ * for those seats **and** never **below** today’s list price for those seats.
16
+ * - Any **extra** seats beyond the original count for that category: **live catalog only** (no receipt floor).
17
+ * If we cannot infer a receipt unit for a category, that line stays **live catalog** only.
18
+ *
19
+ * ### 3. Product fees (config fees, per line)
20
+ * Split the party into **protected** headcount (≤ original total ticket count) vs **incremental** passengers.
21
+ * For each fee line: **`max(receipt-derived fee per person, live fee per person)`** on protected passengers; incremental
22
+ * passengers pay each line’s **live** per-person amount only.
23
+ *
24
+ * ### 4. Return option — **order total / checkout**
25
+ * If a per-person return floor exists (API `returnUnitFloorPerPerson` or derived from receipt RETURN lines):
26
+ * **per person** = **`max(live catalog return for the selected slot, floor)`**; **row total** = party size × that amount
27
+ * (when floors apply).
28
+ *
29
+ * ### 5. Return option — **picker UI only** (BookingFlow)
30
+ * **Self-serve:** return choice cards always show the floored per-person price when a floor exists.
31
+ * **Provider / other:** if outbound **and** return still match the **original** booking exactly, cards show **catalog**
32
+ * only (floor hidden) until the itinerary changes; after a change, same as self-serve. **Totals** still follow §4 when
33
+ * floors are on.
34
+ *
35
+ * ### 6. Quote “new booking” total & balance
36
+ * **FE proposed total** = full cart math (subtotal + tax − promo), cent-rounded; optional **1¢ reconcile** to old receipt
37
+ * total when the difference is only rounding noise (`resolveChangeFlowNewBookingTotal`).
38
+ * **Customer** owes **`max(0, newTotal − oldReceipt)`**; **provider** inline pricing may show a **signed** delta.
39
+ */
40
+ import { reconcileChangeBookingProposedTotal } from '../currency';
41
+
42
+ /** Money in major units, rounded to cents (half-up). */
43
+ export function roundMoney(amount: number): number {
44
+ return Math.round(amount * 100) / 100;
45
+ }
46
+
47
+ /**
48
+ * One ticket row: protected seats use `max(receipt unit, live unit)`; extra qty uses live only.
49
+ */
50
+ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
51
+ qty: number;
52
+ /** Seats originally booked in this category (caps “protected” seats). */
53
+ baselineQtyForCategory: number;
54
+ /** Average unit paid on receipt for this category; omit if unknown. */
55
+ receiptUnitFloor: number | undefined;
56
+ /** Current catalog line total (before floors). */
57
+ liveLineTotal: number;
58
+ }): number {
59
+ const qty = Math.max(0, input.qty);
60
+ if (qty <= 0) return 0;
61
+ const liveUnit = Math.max(0, Number(input.liveLineTotal) || 0) / qty;
62
+ if (input.receiptUnitFloor == null || !Number.isFinite(input.receiptUnitFloor)) {
63
+ return Math.max(0, Number(input.liveLineTotal) || 0);
64
+ }
65
+ const baselineQty = Math.max(0, input.baselineQtyForCategory);
66
+ const protectedQty = Math.min(qty, baselineQty);
67
+ const incrementalQty = Math.max(0, qty - baselineQty);
68
+ const protectedUnit = Math.max(input.receiptUnitFloor, liveUnit);
69
+ return protectedQty * protectedUnit + incrementalQty * liveUnit;
70
+ }
71
+
72
+ /**
73
+ * One fee row: first `initialTicketCount` passengers use `max(receipt per-person, live per-person)`; rest live only.
74
+ */
75
+ export function changeFlowFeeLineTotalWithReceiptFloor(input: {
76
+ totalQuantity: number;
77
+ initialTicketCount: number;
78
+ bookedFeePerPerson: number | undefined;
79
+ liveFeeLineTotal: number;
80
+ }): number {
81
+ const totalQuantity = Math.max(0, input.totalQuantity);
82
+ if (totalQuantity <= 0) return Math.max(0, Number(input.liveFeeLineTotal) || 0);
83
+ const liveTotal = Math.max(0, Number(input.liveFeeLineTotal) || 0);
84
+ const livePer = liveTotal / totalQuantity;
85
+ if (input.bookedFeePerPerson == null || !Number.isFinite(input.bookedFeePerPerson)) {
86
+ return liveTotal;
87
+ }
88
+ const protectedP = Math.min(Math.max(0, input.initialTicketCount), totalQuantity);
89
+ const incrementalP = Math.max(0, totalQuantity - protectedP);
90
+ const protectedPerPerson = Math.max(input.bookedFeePerPerson, livePer);
91
+ return protectedP * protectedPerPerson + incrementalP * livePer;
92
+ }
93
+
94
+ /**
95
+ * Return add-on: **max(live, receipt floor)** per person (then multiply by party size in BookingFlow).
96
+ */
97
+ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
98
+ livePerPerson: number;
99
+ receiptFloorPerPerson: number | null;
100
+ }): number {
101
+ const live = Number.isFinite(input.livePerPerson) ? input.livePerPerson : 0;
102
+ const floor = input.receiptFloorPerPerson;
103
+ if (floor == null || !Number.isFinite(floor)) return live;
104
+ return Math.max(live, floor);
105
+ }
106
+
107
+ /**
108
+ * Product: **New booking price** for a change is the same cart total as a fresh booking
109
+ * (subtotal + tax − discounts), in cents. We send that on quotes so it matches line items and the server breakdown.
110
+ *
111
+ * If the customer is only “off” from the old receipt by a penny of float/rounding, treat as no change to the rolled-up
112
+ * total (see `reconcileChangeBookingProposedTotal`).
113
+ */
114
+ export function resolveChangeFlowNewBookingTotal(input: {
115
+ isChangeFlow: boolean;
116
+ /** `effectiveSubtotal + effectiveTax - promo` from the live cart. */
117
+ cartTotal: number;
118
+ originalReceiptTotal: number | null | undefined;
119
+ }): number {
120
+ if (!input.isChangeFlow) {
121
+ return input.cartTotal;
122
+ }
123
+ const rounded = roundMoney(input.cartTotal);
124
+ if (input.originalReceiptTotal == null) {
125
+ return input.cartTotal;
126
+ }
127
+ return reconcileChangeBookingProposedTotal(rounded, input.originalReceiptTotal);
128
+ }
129
+
130
+ /**
131
+ * Product: **What the customer owes** for the difference is `max(0, newTotal − oldReceipt)`.
132
+ * **Provider dashboard** may show a signed delta (credits/refunds as negative).
133
+ */
134
+ export function changeFlowBalanceVsOriginal(input: {
135
+ newTotal: number;
136
+ originalReceiptTotal: number;
137
+ /** `customer` = self-serve & admin change; `provider` = provider inline editor (can owe the guest). */
138
+ audience: 'customer' | 'provider';
139
+ }): number {
140
+ const delta = input.newTotal - input.originalReceiptTotal;
141
+ return input.audience === 'provider' ? delta : Math.max(0, delta);
142
+ }
143
+
144
+ /**
145
+ * Product: **What we show** in the change-flow price row — either the provider’s manual preview totals, or the same
146
+ * cart numbers as everywhere else (no second shadow total).
147
+ */
148
+ export function resolveChangeFlowDisplayedAmounts(input: {
149
+ providerPreview: {
150
+ totalAmount: number;
151
+ subtotalBeforeTax: number;
152
+ taxAmount: number;
153
+ } | null;
154
+ fromCart: { total: number; subtotal: number; tax: number };
155
+ }): { total: number; subtotal: number; tax: number } {
156
+ if (input.providerPreview) {
157
+ return {
158
+ total: input.providerPreview.totalAmount,
159
+ subtotal: input.providerPreview.subtotalBeforeTax,
160
+ tax: input.providerPreview.taxAmount,
161
+ };
162
+ }
163
+ return { ...input.fromCart };
164
+ }
165
+
166
+ /**
167
+ * Product: **Main “total owed” number** should not show -0.00 or noise when the balance is effectively zero.
168
+ */
169
+ export function normalizeNearZeroOwed(amount: number): number {
170
+ const r = roundMoney(amount);
171
+ return Math.abs(r) < 0.01 ? 0 : r;
172
+ }
@@ -413,18 +413,35 @@ export function computeOrderSummary(
413
413
 
414
414
  const isTaxIncludedInPrice = pricingConfig.currenciesWithTaxIncluded.includes(currency);
415
415
 
416
- const basePrice = pricing.reduce((sum, rate) => {
417
- const qty = quantities[rate.category] ?? 0;
418
- return sum + qty * (rate.price ?? 0);
419
- }, 0);
416
+ const round2 = (n: number) => Math.round(n * 100) / 100;
417
+
418
+ const ticketLineItems: OrderSummaryTicketLine[] = pricing
419
+ .map((rate) => {
420
+ const qty = quantities[rate.category] ?? 0;
421
+ if (qty === 0) return null;
422
+ const pricePerUnit = rate.price ?? 0;
423
+ return {
424
+ category: rate.category,
425
+ qty,
426
+ pricePerUnit,
427
+ itemTotal: round2(qty * pricePerUnit),
428
+ };
429
+ })
430
+ .filter((line): line is OrderSummaryTicketLine => line != null);
431
+
432
+ const basePrice = ticketLineItems.reduce((sum, line) => sum + line.itemTotal, 0);
420
433
 
421
434
  const perPersonInDisplay = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
422
435
  const returnPriceAdjustment =
423
- totalQuantity > 0 && perPersonInDisplay !== 0 ? totalQuantity * perPersonInDisplay : 0;
436
+ totalQuantity > 0 && perPersonInDisplay !== 0
437
+ ? round2(totalQuantity * perPersonInDisplay)
438
+ : 0;
424
439
 
425
440
  const cancellationPolicyFee =
426
441
  cancellationPolicyId && pricingConfig?.cancellationPolicies?.length
427
- ? (pricingConfig.cancellationPolicies.find((p) => p.id === cancellationPolicyId)?.feeByCurrency?.[currency] ?? 0)
442
+ ? round2(
443
+ pricingConfig.cancellationPolicies.find((p) => p.id === cancellationPolicyId)?.feeByCurrency?.[currency] ?? 0
444
+ )
428
445
  : 0;
429
446
 
430
447
  const fees = pricingConfig.fees ?? {};
@@ -434,23 +451,14 @@ export function computeOrderSummary(
434
451
  ? []
435
452
  : Object.entries(byCurrency).map(([name, amountPerPerson]) => ({
436
453
  name,
437
- totalAmount: totalQuantity * amountPerPerson,
454
+ totalAmount: round2(totalQuantity * amountPerPerson),
438
455
  description: fees[name]?.description,
439
456
  }));
440
457
 
441
458
  const feesTotal = feeLineItems.reduce((s, f) => s + f.totalAmount, 0);
442
- const subtotal = basePrice + returnPriceAdjustment + cancellationPolicyFee + feesTotal;
443
- const tax = isTaxIncludedInPrice ? 0 : subtotal * pricingConfig.taxRate;
444
- const total = subtotal + tax;
445
-
446
- const ticketLineItems: OrderSummaryTicketLine[] = pricing
447
- .map((rate) => {
448
- const qty = quantities[rate.category] ?? 0;
449
- if (qty === 0) return null;
450
- const pricePerUnit = rate.price ?? 0;
451
- return { category: rate.category, qty, pricePerUnit, itemTotal: qty * pricePerUnit };
452
- })
453
- .filter((line): line is OrderSummaryTicketLine => line != null);
459
+ const subtotal = round2(basePrice + returnPriceAdjustment + cancellationPolicyFee + feesTotal);
460
+ const tax = isTaxIncludedInPrice ? 0 : round2(subtotal * pricingConfig.taxRate);
461
+ const total = round2(subtotal + tax);
454
462
 
455
463
  return {
456
464
  subtotal,
@@ -838,6 +838,13 @@ export interface ChangeBookingQuoteRequest {
838
838
  newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
839
839
  /** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
840
840
  clientProposedTotal?: number;
841
+ /** Optional change-flow capacity hint so API can exclude current booking seats from sold counts. */
842
+ capacitySeatCredit?: {
843
+ enabled: boolean;
844
+ previousPassengerCount?: number | null;
845
+ previousAvailabilityId?: string | null;
846
+ previousReturnAvailabilityId?: string | null;
847
+ } | null;
841
848
  }
842
849
 
843
850
  export interface ChangeBookingQuoteReceipt {