@ticketboothapp/booking 1.2.61 → 1.2.63
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/CHANGE_BOOKING_BE_HANDOFF.md +86 -0
- package/package.json +1 -1
- package/src/components/booking/AddOnsSection.tsx +6 -3
- package/src/components/booking/AdminChangeBookingFlow.tsx +4915 -0
- package/src/components/booking/BookingFlow.tsx +23 -5343
- package/src/components/booking/Calendar.tsx +79 -35
- package/src/components/booking/CancellationPolicySelector.tsx +9 -2
- package/src/components/booking/ChangeBookingDialog.tsx +20 -10
- package/src/components/booking/ChangeBookingFlow.tsx +4915 -0
- package/src/components/booking/ChangeBookingPricingDriftPanel.tsx +268 -0
- package/src/components/booking/CheckoutForm.tsx +29 -19
- package/src/components/booking/CurrencySwitcher.tsx +1 -1
- package/src/components/booking/MealDrinkAddOnSelector.tsx +4 -2
- package/src/components/booking/NewBookingFlow.tsx +3256 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -6
- package/src/components/booking/PromoCodeInput.tsx +4 -1
- package/src/components/booking/ReturnTimeSelector.tsx +12 -5
- package/src/components/booking/TicketSelector.tsx +6 -1
- package/src/components/booking/booking-flow-types.ts +141 -0
- package/src/index.ts +10 -1
- package/src/lib/booking/change-booking-pricing-drift.ts +331 -0
- package/src/lib/booking/change-booking-server-preview.ts +139 -0
- package/src/lib/booking/change-flow-pricing.ts +162 -27
- package/src/lib/booking-api.ts +72 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Server-owned change-booking preview** — canonical shape the FE consumes when BE is the source of truth.
|
|
3
|
+
* Built from `POST .../change/quote` via {@link buildChangeBookingServerPreview}.
|
|
4
|
+
*
|
|
5
|
+
* See `CHANGE_BOOKING_BE_HANDOFF.md` in this package for fields the API should populate.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChangeBookingQuoteResponse } from '../booking-api';
|
|
8
|
+
import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
|
|
9
|
+
import { roundMoney, serverTotalsFromChangeQuoteResponse } from './change-flow-pricing';
|
|
10
|
+
|
|
11
|
+
export type ChangeBookingPreviewCompleteness =
|
|
12
|
+
| 'totals_only'
|
|
13
|
+
/** BE sent structured picker pricing + line breakdown — FE hides no server-backed amounts. */
|
|
14
|
+
| 'full';
|
|
15
|
+
|
|
16
|
+
export interface ChangeBookingServerPreview {
|
|
17
|
+
currency: string;
|
|
18
|
+
amountDue: number;
|
|
19
|
+
subtotal: number;
|
|
20
|
+
tax: number;
|
|
21
|
+
totalNewBooking: number;
|
|
22
|
+
completeness: ChangeBookingPreviewCompleteness;
|
|
23
|
+
/** Rows for {@link PriceSummary} / checkout — from quote receipt line items + BE extensions. */
|
|
24
|
+
priceSummaryLines: PriceSummaryLine[];
|
|
25
|
+
/** From quote — per-category unit prices (major units) for ticket picker when BE sends them. */
|
|
26
|
+
ticketUnitPriceByCategory?: Record<string, number>;
|
|
27
|
+
cancellationPolicyFeeByPolicyId?: Record<string, number>;
|
|
28
|
+
returnOptionPriceByReturnAvailabilityId?: Record<string, number>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lineItemsForSummary(quote: ChangeBookingQuoteResponse): NonNullable<
|
|
32
|
+
ChangeBookingQuoteResponse['newReceipt']
|
|
33
|
+
>['lineItems'] {
|
|
34
|
+
return quote.newReceipt?.lineItems ?? quote.proposed?.lineItems ?? quote.original?.lineItems ?? undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Map receipt-style quote line items to checkout {@link PriceSummaryLine} rows (shared with price-check drift UI). */
|
|
38
|
+
export function mapQuoteLineItemsToPriceSummaryLines(
|
|
39
|
+
items:
|
|
40
|
+
| NonNullable<NonNullable<ChangeBookingQuoteResponse['newReceipt']>['lineItems']>
|
|
41
|
+
| Array<{ label?: string; amount?: number; type?: string; quantity?: number }>
|
|
42
|
+
| undefined
|
|
43
|
+
| null,
|
|
44
|
+
): PriceSummaryLine[] {
|
|
45
|
+
if (!items?.length) return [];
|
|
46
|
+
const out: PriceSummaryLine[] = [];
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
const amount = Number(item.amount) || 0;
|
|
49
|
+
const type = (item.type ?? '').toUpperCase();
|
|
50
|
+
const qty = Math.max(0, Number(item.quantity) || 0);
|
|
51
|
+
const label = (item.label ?? '').trim();
|
|
52
|
+
if (
|
|
53
|
+
type === 'TICKET' ||
|
|
54
|
+
type === 'ADULT' ||
|
|
55
|
+
type === 'CHILD' ||
|
|
56
|
+
type === 'INFANT' ||
|
|
57
|
+
type === 'SENIOR' ||
|
|
58
|
+
type === 'STUDENT'
|
|
59
|
+
) {
|
|
60
|
+
const category =
|
|
61
|
+
type === 'TICKET'
|
|
62
|
+
? (label.split(/\s+/)[0]?.replace(/[^a-zA-Z]/g, '') || 'TICKET').toUpperCase()
|
|
63
|
+
: type;
|
|
64
|
+
out.push({
|
|
65
|
+
kind: 'ticket',
|
|
66
|
+
category,
|
|
67
|
+
qty: qty > 0 ? qty : 1,
|
|
68
|
+
itemTotal: amount,
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Receipt exports sometimes use type LINE + label “ADULT” + quantity — treat as ticket for drift parity. */
|
|
74
|
+
const ticketWords = new Set(['ADULT', 'CHILD', 'INFANT', 'SENIOR', 'STUDENT']);
|
|
75
|
+
const lettersOnly = label.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
|
76
|
+
const wordCount = label.trim().split(/\s+/).filter(Boolean).length;
|
|
77
|
+
if (
|
|
78
|
+
qty > 0 &&
|
|
79
|
+
ticketWords.has(lettersOnly) &&
|
|
80
|
+
wordCount <= 2 &&
|
|
81
|
+
!ticketWords.has(type)
|
|
82
|
+
) {
|
|
83
|
+
out.push({
|
|
84
|
+
kind: 'ticket',
|
|
85
|
+
category: lettersOnly,
|
|
86
|
+
qty,
|
|
87
|
+
itemTotal: amount,
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Align with checkout drift keys (`ChangeBookingFlow` uses `type: 'return'`). */
|
|
93
|
+
const summaryType =
|
|
94
|
+
type === 'RETURN_OPTION' || type === 'RETURNOPTION' ? 'return' : (item.type ?? 'fee');
|
|
95
|
+
out.push({
|
|
96
|
+
kind: 'line',
|
|
97
|
+
label: label || type || 'Line',
|
|
98
|
+
amount,
|
|
99
|
+
type: summaryType,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Single builder: quote JSON → preview consumed by ChangeBookingFlow (no parallel FE “display math”).
|
|
107
|
+
*/
|
|
108
|
+
export function buildChangeBookingServerPreview(
|
|
109
|
+
quote: ChangeBookingQuoteResponse,
|
|
110
|
+
fallbackCart: { total: number; subtotal: number; tax: number },
|
|
111
|
+
currencyFallback: string,
|
|
112
|
+
): ChangeBookingServerPreview | null {
|
|
113
|
+
const totals = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
|
|
114
|
+
if (!totals) return null;
|
|
115
|
+
|
|
116
|
+
const currency = (quote.currency ?? currencyFallback).trim() || currencyFallback;
|
|
117
|
+
const amountDue =
|
|
118
|
+
quote.amountDueCents != null ? quote.amountDueCents / 100 : quote.priceDiff ?? 0;
|
|
119
|
+
|
|
120
|
+
const rawLines = lineItemsForSummary(quote);
|
|
121
|
+
const priceSummaryLines =
|
|
122
|
+
rawLines && rawLines.length > 0 ? mapQuoteLineItemsToPriceSummaryLines(rawLines) : [];
|
|
123
|
+
|
|
124
|
+
/** FE assumes the change-quote contract is implemented; no `totals_only` gating in UI. */
|
|
125
|
+
const completeness: ChangeBookingPreviewCompleteness = 'full';
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
currency,
|
|
129
|
+
amountDue: roundMoney(amountDue),
|
|
130
|
+
subtotal: totals.subtotal,
|
|
131
|
+
tax: totals.tax,
|
|
132
|
+
totalNewBooking: totals.total,
|
|
133
|
+
completeness,
|
|
134
|
+
priceSummaryLines,
|
|
135
|
+
ticketUnitPriceByCategory: quote.ticketUnitPriceByCategory,
|
|
136
|
+
cancellationPolicyFeeByPolicyId: quote.cancellationPolicyFeeByPolicyId,
|
|
137
|
+
returnOptionPriceByReturnAvailabilityId: quote.returnOptionPriceByReturnAvailabilityId,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -5,37 +5,48 @@
|
|
|
5
5
|
* channel) on top of these formulas.
|
|
6
6
|
*
|
|
7
7
|
* ### 1. When receipt “paid floors” apply
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* floors
|
|
8
|
+
* When the booking’s **parent catalog product** matches the **loaded product** (`changeFlowApplyReceiptPaidFloors` in
|
|
9
|
+
* BookingFlow). The **host embed** must pass `originalReceipt.lineItems` (or `returnUnitFloorPerPerson`) into change mode
|
|
10
|
+
* so return/ticket floors match the server; without receipt lines the UI can hide a $0 catalog return row while the BE
|
|
11
|
+
* still applies a receipt return floor.
|
|
11
12
|
*
|
|
12
13
|
* ### 2. Tickets (per category)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* **Unchanged itinerary** (same calendar departure date **and** same product option as the booking): among seats up to the
|
|
15
|
+
* originally booked count, unit price is **exactly** the receipt/booking unit — catalog moves do **not** change those seats.
|
|
16
|
+
* **Date or product option changed:** protected seats use **`max(receipt average unit, live catalog unit)`**; they never go
|
|
17
|
+
* below what was paid but may go up with catalog.
|
|
18
|
+
* **Extra** seats beyond the original count: **live catalog** only. If receipt unit is unknown, that category uses live
|
|
19
|
+
* catalog for the line.
|
|
19
20
|
*
|
|
20
21
|
* ### 3. Product fees (config fees, per line)
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* passengers pay each line’s **live** per-person amount only.
|
|
22
|
+
* Same **unchanged vs changed itinerary** split as tickets: protected passengers use **exact receipt-derived fee** when the
|
|
23
|
+
* itinerary is unchanged, else **`max(receipt, live)`** per fee line; incremental passengers pay **live** per-person only.
|
|
24
24
|
*
|
|
25
25
|
* ### 4. Return option — **order total / checkout**
|
|
26
|
-
* When
|
|
27
|
-
* **
|
|
28
|
-
*
|
|
26
|
+
* When a per-person return floor exists (API `returnUnitFloorPerPerson` or receipt RETURN / RETURN_OPTION lines), align with
|
|
27
|
+
* BE `PublicChangeBookingQuotePricing`: **baseline party** (originally booked headcount) vs **incremental** seats. **Rule A**
|
|
28
|
+
* (same `returnAvailabilityId` as the booking **and** unchanged outbound itinerary): protected pay exact locked per person;
|
|
29
|
+
* incremental pay **live catalog** return only ($0 when free). **Else Rule B:** protected pay **`max(floor, live)`**;
|
|
30
|
+
* incremental pay **live** only.
|
|
29
31
|
*
|
|
30
32
|
* ### 5. Return option — **picker UI only** (BookingFlow)
|
|
31
|
-
* **Self-serve:** return cards use the floored per-person price when a floor exists (aligned with §4)
|
|
32
|
-
*
|
|
33
|
+
* **Self-serve:** return cards use the floored per-person price when a floor exists (aligned with §4), for every return
|
|
34
|
+
* slot, regardless of date/option change.
|
|
35
|
+
* **Provider dashboard:** return cards typically show **catalog** prices; order summary still uses §4 when receipt floors are available.
|
|
33
36
|
*
|
|
34
37
|
* ### 6. Quote “new booking” total & balance
|
|
35
38
|
* **FE proposed total** = full cart math (subtotal + tax − promo), cent-rounded; optional **1¢ reconcile** to old receipt
|
|
36
|
-
* total when the difference is only rounding noise (`resolveChangeFlowNewBookingTotal`).
|
|
37
|
-
*
|
|
39
|
+
* total when the difference is only rounding noise (`resolveChangeFlowNewBookingTotal`). Sent to `POST .../change/quote`
|
|
40
|
+
* as `clientProposedTotal` so the API can verify within tolerance.
|
|
41
|
+
*
|
|
42
|
+
* **Customer self-serve display:** when `POST .../change/quote` returns `newReceipt` / `proposed` / `newTotalCents`,
|
|
43
|
+
* **`serverTotalsFromChangeQuoteResponse`** becomes the **display** source for subtotal/tax/total (`resolveChangeFlowDisplayedAmounts`).
|
|
44
|
+
* Use **`sliceChangeQuoteForUi`** to map a full quote response into UI state once (debounced quote + checkout quote).
|
|
45
|
+
* Amount owed for copy/CTA uses **`amountDueCents`** / **`priceDiff`** from the same response so UI matches PaymentIntent.
|
|
46
|
+
*
|
|
47
|
+
* **Provider** inline pricing may show a **signed** delta from cart or manual overrides.
|
|
38
48
|
*/
|
|
49
|
+
import type { ChangeBookingQuoteResponse } from '../booking-api';
|
|
39
50
|
import { reconcileChangeBookingProposedTotal } from '../currency';
|
|
40
51
|
|
|
41
52
|
/** Money in major units, rounded to cents (half-up). */
|
|
@@ -43,8 +54,11 @@ export function roundMoney(amount: number): number {
|
|
|
43
54
|
return Math.round(amount * 100) / 100;
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
/** Rule A: unchanged date + product option — receipt units/fees apply exactly on protected seats. Rule B: max with catalog. */
|
|
58
|
+
export type ChangeFlowProtectedReceiptPricing = 'exact_from_receipt' | 'max_with_catalog';
|
|
59
|
+
|
|
46
60
|
/**
|
|
47
|
-
* One ticket row: protected seats
|
|
61
|
+
* One ticket row: protected seats follow Rule A (exact receipt unit) or Rule B (`max(receipt, live)`); extra qty uses live only.
|
|
48
62
|
*/
|
|
49
63
|
export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
50
64
|
qty: number;
|
|
@@ -54,6 +68,7 @@ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
|
54
68
|
receiptUnitFloor: number | undefined;
|
|
55
69
|
/** Current catalog line total (before floors). */
|
|
56
70
|
liveLineTotal: number;
|
|
71
|
+
protectedReceiptPricing: ChangeFlowProtectedReceiptPricing;
|
|
57
72
|
}): number {
|
|
58
73
|
const qty = Math.max(0, input.qty);
|
|
59
74
|
if (qty <= 0) return 0;
|
|
@@ -64,18 +79,22 @@ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
|
64
79
|
const baselineQty = Math.max(0, input.baselineQtyForCategory);
|
|
65
80
|
const protectedQty = Math.min(qty, baselineQty);
|
|
66
81
|
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
82
|
+
if (input.protectedReceiptPricing === 'exact_from_receipt') {
|
|
83
|
+
return protectedQty * input.receiptUnitFloor + incrementalQty * liveUnit;
|
|
84
|
+
}
|
|
67
85
|
const protectedUnit = Math.max(input.receiptUnitFloor, liveUnit);
|
|
68
86
|
return protectedQty * protectedUnit + incrementalQty * liveUnit;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
/**
|
|
72
|
-
* One fee row:
|
|
90
|
+
* One fee row: protected headcount uses Rule A (exact booked fee) or Rule B (max with live per-person); rest live only.
|
|
73
91
|
*/
|
|
74
92
|
export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
75
93
|
totalQuantity: number;
|
|
76
94
|
initialTicketCount: number;
|
|
77
95
|
bookedFeePerPerson: number | undefined;
|
|
78
96
|
liveFeeLineTotal: number;
|
|
97
|
+
protectedReceiptPricing: ChangeFlowProtectedReceiptPricing;
|
|
79
98
|
}): number {
|
|
80
99
|
const totalQuantity = Math.max(0, input.totalQuantity);
|
|
81
100
|
if (totalQuantity <= 0) return Math.max(0, Number(input.liveFeeLineTotal) || 0);
|
|
@@ -86,6 +105,9 @@ export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
|
86
105
|
}
|
|
87
106
|
const protectedP = Math.min(Math.max(0, input.initialTicketCount), totalQuantity);
|
|
88
107
|
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
108
|
+
if (input.protectedReceiptPricing === 'exact_from_receipt') {
|
|
109
|
+
return protectedP * input.bookedFeePerPerson + incrementalP * livePer;
|
|
110
|
+
}
|
|
89
111
|
const protectedPerPerson = Math.max(input.bookedFeePerPerson, livePer);
|
|
90
112
|
return protectedP * protectedPerPerson + incrementalP * livePer;
|
|
91
113
|
}
|
|
@@ -103,6 +125,39 @@ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
|
103
125
|
return Math.max(live, floor);
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Full return row total aligned with BE `PublicChangeBookingQuotePricing`: receipt floor applies only to **original**
|
|
130
|
+
* party headcount; extra seats pay **live catalog** return only ($0 when the slot is free).
|
|
131
|
+
*
|
|
132
|
+
* Rule A (same return id + unchanged outbound itinerary): protected pay exact receipt locked per person.
|
|
133
|
+
* Else Rule B: protected pay max(locked, live); incremental pay live only.
|
|
134
|
+
*/
|
|
135
|
+
export function changeFlowReturnLineTotalWithReceiptFloor(input: {
|
|
136
|
+
totalPersons: number;
|
|
137
|
+
/** Original booked ticket count (party cap), same as BE `baselineParty`. */
|
|
138
|
+
baselineParty: number;
|
|
139
|
+
/** Catalog live per person for the selected return slot (major units). */
|
|
140
|
+
livePerPerson: number;
|
|
141
|
+
receiptFloorPerPerson: number | null;
|
|
142
|
+
/** Mirrors BE `returnPricingRuleA`. */
|
|
143
|
+
returnRuleA: boolean;
|
|
144
|
+
}): number {
|
|
145
|
+
const n = Math.max(0, input.totalPersons);
|
|
146
|
+
if (n <= 0) return 0;
|
|
147
|
+
const live = Number.isFinite(input.livePerPerson) ? input.livePerPerson : 0;
|
|
148
|
+
const floor = input.receiptFloorPerPerson;
|
|
149
|
+
if (floor == null || !Number.isFinite(floor)) {
|
|
150
|
+
return roundMoney(n * live);
|
|
151
|
+
}
|
|
152
|
+
const baseline = Math.max(0, input.baselineParty);
|
|
153
|
+
const protectedP = Math.min(baseline, n);
|
|
154
|
+
const incrementalP = Math.max(0, n - baseline);
|
|
155
|
+
if (input.returnRuleA) {
|
|
156
|
+
return roundMoney(protectedP * floor + incrementalP * live);
|
|
157
|
+
}
|
|
158
|
+
return roundMoney(protectedP * Math.max(floor, live) + incrementalP * live);
|
|
159
|
+
}
|
|
160
|
+
|
|
106
161
|
/**
|
|
107
162
|
* Product: **New booking price** for a change is the same cart total as a fresh booking
|
|
108
163
|
* (subtotal + tax − discounts), in cents. We send that on quotes so it matches line items and the server breakdown.
|
|
@@ -111,14 +166,10 @@ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
|
111
166
|
* total (see `reconcileChangeBookingProposedTotal`).
|
|
112
167
|
*/
|
|
113
168
|
export function resolveChangeFlowNewBookingTotal(input: {
|
|
114
|
-
isChangeFlow: boolean;
|
|
115
169
|
/** `effectiveSubtotal + effectiveTax - promo` from the live cart. */
|
|
116
170
|
cartTotal: number;
|
|
117
171
|
originalReceiptTotal: number | null | undefined;
|
|
118
172
|
}): number {
|
|
119
|
-
if (!input.isChangeFlow) {
|
|
120
|
-
return input.cartTotal;
|
|
121
|
-
}
|
|
122
173
|
const rounded = roundMoney(input.cartTotal);
|
|
123
174
|
if (input.originalReceiptTotal == null) {
|
|
124
175
|
return input.cartTotal;
|
|
@@ -141,8 +192,87 @@ export function changeFlowBalanceVsOriginal(input: {
|
|
|
141
192
|
}
|
|
142
193
|
|
|
143
194
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
195
|
+
* Maps a successful change-quote API payload to display subtotal/tax/total. Prefer server fields; fill gaps from
|
|
196
|
+
* `fallbackCart` only when the API omits breakdown (scaled to match server total when needed).
|
|
197
|
+
*/
|
|
198
|
+
export function serverTotalsFromChangeQuoteResponse(
|
|
199
|
+
quote: Pick<ChangeBookingQuoteResponse, 'newReceipt' | 'proposed' | 'newTotalCents'>,
|
|
200
|
+
fallbackCart: { total: number; subtotal: number; tax: number }
|
|
201
|
+
): { total: number; subtotal: number; tax: number } | null {
|
|
202
|
+
const nr = quote.newReceipt;
|
|
203
|
+
const prop = quote.proposed;
|
|
204
|
+
const total =
|
|
205
|
+
nr?.total ??
|
|
206
|
+
prop?.total ??
|
|
207
|
+
(quote.newTotalCents != null ? quote.newTotalCents / 100 : undefined);
|
|
208
|
+
if (total == null || !Number.isFinite(total)) return null;
|
|
209
|
+
|
|
210
|
+
let subtotal = nr?.subtotal;
|
|
211
|
+
let tax = nr?.tax;
|
|
212
|
+
if (subtotal != null && tax == null) {
|
|
213
|
+
tax = Math.max(0, roundMoney(total - subtotal));
|
|
214
|
+
} else if (tax != null && subtotal == null) {
|
|
215
|
+
subtotal = Math.max(0, roundMoney(total - tax));
|
|
216
|
+
}
|
|
217
|
+
if (subtotal != null && tax != null) {
|
|
218
|
+
return {
|
|
219
|
+
total: roundMoney(total),
|
|
220
|
+
subtotal: roundMoney(subtotal),
|
|
221
|
+
tax: roundMoney(tax),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fb = fallbackCart;
|
|
226
|
+
const absFb = Math.abs(fb.total);
|
|
227
|
+
if (absFb < 1e-6) {
|
|
228
|
+
return {
|
|
229
|
+
total: roundMoney(total),
|
|
230
|
+
subtotal: roundMoney(fb.subtotal),
|
|
231
|
+
tax: roundMoney(fb.tax),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const ratio = total / fb.total;
|
|
235
|
+
return {
|
|
236
|
+
total: roundMoney(total),
|
|
237
|
+
subtotal: roundMoney(fb.subtotal * ratio),
|
|
238
|
+
tax: roundMoney(fb.tax * ratio),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Shape merged into `latestChangeQuote` after each successful `quoteChangeBooking`. */
|
|
243
|
+
export interface ChangeQuoteUiSlice {
|
|
244
|
+
priceDiff: number;
|
|
245
|
+
currency: string | undefined;
|
|
246
|
+
canProceed: boolean;
|
|
247
|
+
reasonIfBlocked?: string;
|
|
248
|
+
changeIntentId?: string;
|
|
249
|
+
quotedTotal?: number;
|
|
250
|
+
serverDisplay?: { total: number; subtotal: number; tax: number };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Single mapping from `ChangeBookingQuoteResponse` → UI state (avoids duplicating parse logic in effects vs checkout). */
|
|
254
|
+
export function sliceChangeQuoteForUi(
|
|
255
|
+
quote: ChangeBookingQuoteResponse,
|
|
256
|
+
fallbackCart: { total: number; subtotal: number; tax: number },
|
|
257
|
+
currencyFallback: string
|
|
258
|
+
): ChangeQuoteUiSlice {
|
|
259
|
+
const priceDiff =
|
|
260
|
+
quote.amountDueCents != null ? quote.amountDueCents / 100 : quote.priceDiff ?? 0;
|
|
261
|
+
const serverDisplay = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
|
|
262
|
+
return {
|
|
263
|
+
priceDiff,
|
|
264
|
+
currency: quote.currency ?? currencyFallback,
|
|
265
|
+
canProceed: quote.canProceed !== false,
|
|
266
|
+
reasonIfBlocked: quote.reasonIfBlocked,
|
|
267
|
+
changeIntentId: quote.changeIntentId,
|
|
268
|
+
quotedTotal: quote.proposed?.total ?? quote.newReceipt?.total,
|
|
269
|
+
...(serverDisplay ? { serverDisplay } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Product: **What we show** in the change-flow price row — provider manual preview, then **server quote** (self-serve),
|
|
275
|
+
* then cart math.
|
|
146
276
|
*/
|
|
147
277
|
export function resolveChangeFlowDisplayedAmounts(input: {
|
|
148
278
|
providerPreview: {
|
|
@@ -150,6 +280,8 @@ export function resolveChangeFlowDisplayedAmounts(input: {
|
|
|
150
280
|
subtotalBeforeTax: number;
|
|
151
281
|
taxAmount: number;
|
|
152
282
|
} | null;
|
|
283
|
+
/** Customer self-serve: authoritative breakdown from last successful `quoteChangeBooking`. */
|
|
284
|
+
serverQuotePreview?: { total: number; subtotal: number; tax: number } | null;
|
|
153
285
|
fromCart: { total: number; subtotal: number; tax: number };
|
|
154
286
|
}): { total: number; subtotal: number; tax: number } {
|
|
155
287
|
if (input.providerPreview) {
|
|
@@ -159,6 +291,9 @@ export function resolveChangeFlowDisplayedAmounts(input: {
|
|
|
159
291
|
tax: input.providerPreview.taxAmount,
|
|
160
292
|
};
|
|
161
293
|
}
|
|
294
|
+
if (input.serverQuotePreview) {
|
|
295
|
+
return { ...input.serverQuotePreview };
|
|
296
|
+
}
|
|
162
297
|
return { ...input.fromCart };
|
|
163
298
|
}
|
|
164
299
|
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -860,12 +860,80 @@ export interface ChangeBookingQuoteReceipt {
|
|
|
860
860
|
}>;
|
|
861
861
|
}
|
|
862
862
|
|
|
863
|
+
/** Optional structured drift when `clientProposedTotal` and server pricing disagree beyond tolerance. */
|
|
864
|
+
export interface ChangeBookingQuotePricingDriftLine {
|
|
865
|
+
label: string;
|
|
866
|
+
clientAmountMajorUnits?: number | null;
|
|
867
|
+
serverAmountMajorUnits?: number | null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export type ChangeBookingQuotePricingDriftReceiptLine = {
|
|
871
|
+
label?: string;
|
|
872
|
+
amount?: number;
|
|
873
|
+
type?: string;
|
|
874
|
+
quantity?: number;
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
export interface ChangeBookingQuotePricingDriftDetail {
|
|
878
|
+
/** Mirror of the client-sent proposed total (major units). */
|
|
879
|
+
clientTotalMajorUnits?: number;
|
|
880
|
+
/** Server-computed new-booking total from the price check (major units). */
|
|
881
|
+
serverTotalMajorUnits?: number;
|
|
882
|
+
/** Convenience: client − server (major units). */
|
|
883
|
+
deltaMajorUnits?: number;
|
|
884
|
+
/** Pre-built rows from the API — shown as-is when present. */
|
|
885
|
+
lineComparisons?: ChangeBookingQuotePricingDriftLine[];
|
|
886
|
+
/**
|
|
887
|
+
* Temporary / extended comparison: full server price-engine lines (receipt shape). When set, drift UI prefers these
|
|
888
|
+
* over `newReceipt.lineItems` for the Server column.
|
|
889
|
+
*/
|
|
890
|
+
serverLineItems?: ChangeBookingQuotePricingDriftReceiptLine[];
|
|
891
|
+
/**
|
|
892
|
+
* Optional echo of client-side lines using the same shape as {@link serverLineItems} for apples-to-apples diff.
|
|
893
|
+
* When set, drift “In app” column uses this instead of the live FE checkout breakdown.
|
|
894
|
+
*/
|
|
895
|
+
clientLineItems?: ChangeBookingQuotePricingDriftReceiptLine[];
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** BE ticket-line recipe for debugging drift vs FE change rules (optional on quote responses). */
|
|
899
|
+
export interface ChangeBookingQuoteTicketLineTrace {
|
|
900
|
+
category: string;
|
|
901
|
+
/** CATALOG_NO_LOCKED | RULE_A_* | RULE_B_* | CROSS_PARENT_* — BE-defined. */
|
|
902
|
+
rule: string;
|
|
903
|
+
liveUnitMajorUnits: number;
|
|
904
|
+
lockedUnitMajorUnits?: number | null;
|
|
905
|
+
protectedQty: number;
|
|
906
|
+
incrementalQty: number;
|
|
907
|
+
catalogLineTotalMajorUnits: number;
|
|
908
|
+
ticketLineAmountMajorUnits: number;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export interface ChangeBookingQuoteTicketPricingTrace {
|
|
912
|
+
bookingParentProductId: string;
|
|
913
|
+
requestedParentProductId: string;
|
|
914
|
+
bookingOptionId: string;
|
|
915
|
+
requestedOptionId: string;
|
|
916
|
+
sameParentProduct: boolean;
|
|
917
|
+
unchangedItinerary: boolean;
|
|
918
|
+
lockedTicketUnitPriceByCategory?: Record<string, number>;
|
|
919
|
+
ticketLines: ChangeBookingQuoteTicketLineTrace[];
|
|
920
|
+
}
|
|
921
|
+
|
|
863
922
|
export interface ChangeBookingQuoteResponse {
|
|
864
923
|
changeIntentId?: string;
|
|
865
924
|
previousTotalCents?: number;
|
|
866
925
|
newTotalCents?: number;
|
|
867
926
|
amountDueCents?: number;
|
|
868
927
|
expiresAt?: string;
|
|
928
|
+
/**
|
|
929
|
+
* Optional (customer change-quote UI): per-category ticket unit prices in **major units**, display currency.
|
|
930
|
+
* Uppercase keys preferred (ADULT, CHILD, …). Enables full self-serve preview without catalog math.
|
|
931
|
+
*/
|
|
932
|
+
ticketUnitPriceByCategory?: Record<string, number>;
|
|
933
|
+
/** Optional: cancellation upgrade fee per policy id (major units, same currency as quote). */
|
|
934
|
+
cancellationPolicyFeeByPolicyId?: Record<string, number>;
|
|
935
|
+
/** Optional: per-person return add-on amount per `returnAvailabilityId` (major units). */
|
|
936
|
+
returnOptionPriceByReturnAvailabilityId?: Record<string, number>;
|
|
869
937
|
original?: {
|
|
870
938
|
total?: number;
|
|
871
939
|
currency?: string;
|
|
@@ -882,6 +950,10 @@ export interface ChangeBookingQuoteResponse {
|
|
|
882
950
|
currency?: string;
|
|
883
951
|
canProceed?: boolean;
|
|
884
952
|
reasonIfBlocked?: string;
|
|
953
|
+
/** When price check fails / disagrees: optional breakdown for UI line-vs-line comparison. */
|
|
954
|
+
pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
|
|
955
|
+
/** Optional BE debug: receipt-floor vs catalog ticket math (same-parent Rule A/B). */
|
|
956
|
+
ticketPricingTrace?: ChangeBookingQuoteTicketPricingTrace | null;
|
|
885
957
|
}
|
|
886
958
|
|
|
887
959
|
export interface CreateChangePaymentIntentResponse {
|