@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.
- package/package.json +1 -1
- package/src/components/BookingDetails.ts +1 -0
- package/src/components/booking/BookingFlow.tsx +652 -194
- package/src/components/booking/Calendar.tsx +48 -16
- package/src/components/booking/ChangeBookingDialog.tsx +4 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +6 -0
- package/src/lib/booking/change-flow-pricing.ts +172 -0
- package/src/lib/booking/pricing.ts +27 -19
- package/src/lib/booking-api.ts +7 -0
|
@@ -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
|
-
/**
|
|
53
|
-
|
|
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
|
-
|
|
175
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
889
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
436
|
+
totalQuantity > 0 && perPersonInDisplay !== 0
|
|
437
|
+
? round2(totalQuantity * perPersonInDisplay)
|
|
438
|
+
: 0;
|
|
424
439
|
|
|
425
440
|
const cancellationPolicyFee =
|
|
426
441
|
cancellationPolicyId && pricingConfig?.cancellationPolicies?.length
|
|
427
|
-
? (
|
|
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,
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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 {
|