@ticketboothapp/booking 1.2.61 → 1.2.62
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/BookingFlow.tsx +19 -5344
- 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 +5381 -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 +143 -0
- package/src/index.ts +9 -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 +157 -23
- 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
|
+
}
|
|
@@ -10,32 +10,42 @@
|
|
|
10
10
|
* floors) so a cheaper date/return yields a lower total / refund via signed balance.
|
|
11
11
|
*
|
|
12
12
|
* ### 2. Tickets (per category)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* **Unchanged itinerary** (same calendar departure date **and** same product option as the booking): among seats up to the
|
|
14
|
+
* originally booked count, unit price is **exactly** the receipt/booking unit — catalog moves do **not** change those seats.
|
|
15
|
+
* **Date or product option changed:** protected seats use **`max(receipt average unit, live catalog unit)`**; they never go
|
|
16
|
+
* below what was paid but may go up with catalog.
|
|
17
|
+
* **Extra** seats beyond the original count: **live catalog** only. If receipt unit is unknown, that category uses live
|
|
18
|
+
* catalog for the line.
|
|
19
19
|
*
|
|
20
20
|
* ### 3. Product fees (config fees, per line)
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* passengers pay each line’s **live** per-person amount only.
|
|
21
|
+
* Same **unchanged vs changed itinerary** split as tickets: protected passengers use **exact receipt-derived fee** when the
|
|
22
|
+
* itinerary is unchanged, else **`max(receipt, live)`** per fee line; incremental passengers pay **live** per-person only.
|
|
24
23
|
*
|
|
25
24
|
* ### 4. Return option — **order total / checkout**
|
|
26
|
-
* When
|
|
27
|
-
* **
|
|
28
|
-
*
|
|
25
|
+
* When a per-person return floor exists (API `returnUnitFloorPerPerson` or receipt RETURN / RETURN_OPTION lines), align with
|
|
26
|
+
* BE `PublicChangeBookingQuotePricing`: **baseline party** (originally booked headcount) vs **incremental** seats. **Rule A**
|
|
27
|
+
* (same `returnAvailabilityId` as the booking **and** unchanged outbound itinerary): protected pay exact locked per person;
|
|
28
|
+
* incremental pay **live catalog** return only ($0 when free). **Else Rule B:** protected pay **`max(floor, live)`**;
|
|
29
|
+
* incremental pay **live** only. Provider dashboard: **live catalog return only** (no floor).
|
|
29
30
|
*
|
|
30
31
|
* ### 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
|
+
* **Self-serve:** return cards use the floored per-person price when a floor exists (aligned with §4), for every return
|
|
33
|
+
* slot, regardless of date/option change.
|
|
32
34
|
* **Provider dashboard:** cards show **catalog** prices only (no floor).
|
|
33
35
|
*
|
|
34
36
|
* ### 6. Quote “new booking” total & balance
|
|
35
37
|
* **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
|
-
*
|
|
38
|
+
* total when the difference is only rounding noise (`resolveChangeFlowNewBookingTotal`). Sent to `POST .../change/quote`
|
|
39
|
+
* as `clientProposedTotal` so the API can verify within tolerance.
|
|
40
|
+
*
|
|
41
|
+
* **Customer self-serve display:** when `POST .../change/quote` returns `newReceipt` / `proposed` / `newTotalCents`,
|
|
42
|
+
* **`serverTotalsFromChangeQuoteResponse`** becomes the **display** source for subtotal/tax/total (`resolveChangeFlowDisplayedAmounts`).
|
|
43
|
+
* Use **`sliceChangeQuoteForUi`** to map a full quote response into UI state once (debounced quote + checkout quote).
|
|
44
|
+
* Amount owed for copy/CTA uses **`amountDueCents`** / **`priceDiff`** from the same response so UI matches PaymentIntent.
|
|
45
|
+
*
|
|
46
|
+
* **Provider** inline pricing may show a **signed** delta from cart or manual overrides.
|
|
38
47
|
*/
|
|
48
|
+
import type { ChangeBookingQuoteResponse } from '../booking-api';
|
|
39
49
|
import { reconcileChangeBookingProposedTotal } from '../currency';
|
|
40
50
|
|
|
41
51
|
/** Money in major units, rounded to cents (half-up). */
|
|
@@ -43,8 +53,11 @@ export function roundMoney(amount: number): number {
|
|
|
43
53
|
return Math.round(amount * 100) / 100;
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
/** Rule A: unchanged date + product option — receipt units/fees apply exactly on protected seats. Rule B: max with catalog. */
|
|
57
|
+
export type ChangeFlowProtectedReceiptPricing = 'exact_from_receipt' | 'max_with_catalog';
|
|
58
|
+
|
|
46
59
|
/**
|
|
47
|
-
* One ticket row: protected seats
|
|
60
|
+
* One ticket row: protected seats follow Rule A (exact receipt unit) or Rule B (`max(receipt, live)`); extra qty uses live only.
|
|
48
61
|
*/
|
|
49
62
|
export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
50
63
|
qty: number;
|
|
@@ -54,6 +67,7 @@ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
|
54
67
|
receiptUnitFloor: number | undefined;
|
|
55
68
|
/** Current catalog line total (before floors). */
|
|
56
69
|
liveLineTotal: number;
|
|
70
|
+
protectedReceiptPricing: ChangeFlowProtectedReceiptPricing;
|
|
57
71
|
}): number {
|
|
58
72
|
const qty = Math.max(0, input.qty);
|
|
59
73
|
if (qty <= 0) return 0;
|
|
@@ -64,18 +78,22 @@ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
|
64
78
|
const baselineQty = Math.max(0, input.baselineQtyForCategory);
|
|
65
79
|
const protectedQty = Math.min(qty, baselineQty);
|
|
66
80
|
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
81
|
+
if (input.protectedReceiptPricing === 'exact_from_receipt') {
|
|
82
|
+
return protectedQty * input.receiptUnitFloor + incrementalQty * liveUnit;
|
|
83
|
+
}
|
|
67
84
|
const protectedUnit = Math.max(input.receiptUnitFloor, liveUnit);
|
|
68
85
|
return protectedQty * protectedUnit + incrementalQty * liveUnit;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
/**
|
|
72
|
-
* One fee row:
|
|
89
|
+
* One fee row: protected headcount uses Rule A (exact booked fee) or Rule B (max with live per-person); rest live only.
|
|
73
90
|
*/
|
|
74
91
|
export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
75
92
|
totalQuantity: number;
|
|
76
93
|
initialTicketCount: number;
|
|
77
94
|
bookedFeePerPerson: number | undefined;
|
|
78
95
|
liveFeeLineTotal: number;
|
|
96
|
+
protectedReceiptPricing: ChangeFlowProtectedReceiptPricing;
|
|
79
97
|
}): number {
|
|
80
98
|
const totalQuantity = Math.max(0, input.totalQuantity);
|
|
81
99
|
if (totalQuantity <= 0) return Math.max(0, Number(input.liveFeeLineTotal) || 0);
|
|
@@ -86,6 +104,9 @@ export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
|
86
104
|
}
|
|
87
105
|
const protectedP = Math.min(Math.max(0, input.initialTicketCount), totalQuantity);
|
|
88
106
|
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
107
|
+
if (input.protectedReceiptPricing === 'exact_from_receipt') {
|
|
108
|
+
return protectedP * input.bookedFeePerPerson + incrementalP * livePer;
|
|
109
|
+
}
|
|
89
110
|
const protectedPerPerson = Math.max(input.bookedFeePerPerson, livePer);
|
|
90
111
|
return protectedP * protectedPerPerson + incrementalP * livePer;
|
|
91
112
|
}
|
|
@@ -103,6 +124,39 @@ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
|
103
124
|
return Math.max(live, floor);
|
|
104
125
|
}
|
|
105
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Full return row total aligned with BE `PublicChangeBookingQuotePricing`: receipt floor applies only to **original**
|
|
129
|
+
* party headcount; extra seats pay **live catalog** return only ($0 when the slot is free).
|
|
130
|
+
*
|
|
131
|
+
* Rule A (same return id + unchanged outbound itinerary): protected pay exact receipt locked per person.
|
|
132
|
+
* Else Rule B: protected pay max(locked, live); incremental pay live only.
|
|
133
|
+
*/
|
|
134
|
+
export function changeFlowReturnLineTotalWithReceiptFloor(input: {
|
|
135
|
+
totalPersons: number;
|
|
136
|
+
/** Original booked ticket count (party cap), same as BE `baselineParty`. */
|
|
137
|
+
baselineParty: number;
|
|
138
|
+
/** Catalog live per person for the selected return slot (major units). */
|
|
139
|
+
livePerPerson: number;
|
|
140
|
+
receiptFloorPerPerson: number | null;
|
|
141
|
+
/** Mirrors BE `returnPricingRuleA`. */
|
|
142
|
+
returnRuleA: boolean;
|
|
143
|
+
}): number {
|
|
144
|
+
const n = Math.max(0, input.totalPersons);
|
|
145
|
+
if (n <= 0) return 0;
|
|
146
|
+
const live = Number.isFinite(input.livePerPerson) ? input.livePerPerson : 0;
|
|
147
|
+
const floor = input.receiptFloorPerPerson;
|
|
148
|
+
if (floor == null || !Number.isFinite(floor)) {
|
|
149
|
+
return roundMoney(n * live);
|
|
150
|
+
}
|
|
151
|
+
const baseline = Math.max(0, input.baselineParty);
|
|
152
|
+
const protectedP = Math.min(baseline, n);
|
|
153
|
+
const incrementalP = Math.max(0, n - baseline);
|
|
154
|
+
if (input.returnRuleA) {
|
|
155
|
+
return roundMoney(protectedP * floor + incrementalP * live);
|
|
156
|
+
}
|
|
157
|
+
return roundMoney(protectedP * Math.max(floor, live) + incrementalP * live);
|
|
158
|
+
}
|
|
159
|
+
|
|
106
160
|
/**
|
|
107
161
|
* Product: **New booking price** for a change is the same cart total as a fresh booking
|
|
108
162
|
* (subtotal + tax − discounts), in cents. We send that on quotes so it matches line items and the server breakdown.
|
|
@@ -111,14 +165,10 @@ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
|
111
165
|
* total (see `reconcileChangeBookingProposedTotal`).
|
|
112
166
|
*/
|
|
113
167
|
export function resolveChangeFlowNewBookingTotal(input: {
|
|
114
|
-
isChangeFlow: boolean;
|
|
115
168
|
/** `effectiveSubtotal + effectiveTax - promo` from the live cart. */
|
|
116
169
|
cartTotal: number;
|
|
117
170
|
originalReceiptTotal: number | null | undefined;
|
|
118
171
|
}): number {
|
|
119
|
-
if (!input.isChangeFlow) {
|
|
120
|
-
return input.cartTotal;
|
|
121
|
-
}
|
|
122
172
|
const rounded = roundMoney(input.cartTotal);
|
|
123
173
|
if (input.originalReceiptTotal == null) {
|
|
124
174
|
return input.cartTotal;
|
|
@@ -141,8 +191,87 @@ export function changeFlowBalanceVsOriginal(input: {
|
|
|
141
191
|
}
|
|
142
192
|
|
|
143
193
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
194
|
+
* Maps a successful change-quote API payload to display subtotal/tax/total. Prefer server fields; fill gaps from
|
|
195
|
+
* `fallbackCart` only when the API omits breakdown (scaled to match server total when needed).
|
|
196
|
+
*/
|
|
197
|
+
export function serverTotalsFromChangeQuoteResponse(
|
|
198
|
+
quote: Pick<ChangeBookingQuoteResponse, 'newReceipt' | 'proposed' | 'newTotalCents'>,
|
|
199
|
+
fallbackCart: { total: number; subtotal: number; tax: number }
|
|
200
|
+
): { total: number; subtotal: number; tax: number } | null {
|
|
201
|
+
const nr = quote.newReceipt;
|
|
202
|
+
const prop = quote.proposed;
|
|
203
|
+
const total =
|
|
204
|
+
nr?.total ??
|
|
205
|
+
prop?.total ??
|
|
206
|
+
(quote.newTotalCents != null ? quote.newTotalCents / 100 : undefined);
|
|
207
|
+
if (total == null || !Number.isFinite(total)) return null;
|
|
208
|
+
|
|
209
|
+
let subtotal = nr?.subtotal;
|
|
210
|
+
let tax = nr?.tax;
|
|
211
|
+
if (subtotal != null && tax == null) {
|
|
212
|
+
tax = Math.max(0, roundMoney(total - subtotal));
|
|
213
|
+
} else if (tax != null && subtotal == null) {
|
|
214
|
+
subtotal = Math.max(0, roundMoney(total - tax));
|
|
215
|
+
}
|
|
216
|
+
if (subtotal != null && tax != null) {
|
|
217
|
+
return {
|
|
218
|
+
total: roundMoney(total),
|
|
219
|
+
subtotal: roundMoney(subtotal),
|
|
220
|
+
tax: roundMoney(tax),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const fb = fallbackCart;
|
|
225
|
+
const absFb = Math.abs(fb.total);
|
|
226
|
+
if (absFb < 1e-6) {
|
|
227
|
+
return {
|
|
228
|
+
total: roundMoney(total),
|
|
229
|
+
subtotal: roundMoney(fb.subtotal),
|
|
230
|
+
tax: roundMoney(fb.tax),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const ratio = total / fb.total;
|
|
234
|
+
return {
|
|
235
|
+
total: roundMoney(total),
|
|
236
|
+
subtotal: roundMoney(fb.subtotal * ratio),
|
|
237
|
+
tax: roundMoney(fb.tax * ratio),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Shape merged into `latestChangeQuote` after each successful `quoteChangeBooking`. */
|
|
242
|
+
export interface ChangeQuoteUiSlice {
|
|
243
|
+
priceDiff: number;
|
|
244
|
+
currency: string | undefined;
|
|
245
|
+
canProceed: boolean;
|
|
246
|
+
reasonIfBlocked?: string;
|
|
247
|
+
changeIntentId?: string;
|
|
248
|
+
quotedTotal?: number;
|
|
249
|
+
serverDisplay?: { total: number; subtotal: number; tax: number };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Single mapping from `ChangeBookingQuoteResponse` → UI state (avoids duplicating parse logic in effects vs checkout). */
|
|
253
|
+
export function sliceChangeQuoteForUi(
|
|
254
|
+
quote: ChangeBookingQuoteResponse,
|
|
255
|
+
fallbackCart: { total: number; subtotal: number; tax: number },
|
|
256
|
+
currencyFallback: string
|
|
257
|
+
): ChangeQuoteUiSlice {
|
|
258
|
+
const priceDiff =
|
|
259
|
+
quote.amountDueCents != null ? quote.amountDueCents / 100 : quote.priceDiff ?? 0;
|
|
260
|
+
const serverDisplay = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
|
|
261
|
+
return {
|
|
262
|
+
priceDiff,
|
|
263
|
+
currency: quote.currency ?? currencyFallback,
|
|
264
|
+
canProceed: quote.canProceed !== false,
|
|
265
|
+
reasonIfBlocked: quote.reasonIfBlocked,
|
|
266
|
+
changeIntentId: quote.changeIntentId,
|
|
267
|
+
quotedTotal: quote.proposed?.total ?? quote.newReceipt?.total,
|
|
268
|
+
...(serverDisplay ? { serverDisplay } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Product: **What we show** in the change-flow price row — provider manual preview, then **server quote** (self-serve),
|
|
274
|
+
* then cart math.
|
|
146
275
|
*/
|
|
147
276
|
export function resolveChangeFlowDisplayedAmounts(input: {
|
|
148
277
|
providerPreview: {
|
|
@@ -150,6 +279,8 @@ export function resolveChangeFlowDisplayedAmounts(input: {
|
|
|
150
279
|
subtotalBeforeTax: number;
|
|
151
280
|
taxAmount: number;
|
|
152
281
|
} | null;
|
|
282
|
+
/** Customer self-serve: authoritative breakdown from last successful `quoteChangeBooking`. */
|
|
283
|
+
serverQuotePreview?: { total: number; subtotal: number; tax: number } | null;
|
|
153
284
|
fromCart: { total: number; subtotal: number; tax: number };
|
|
154
285
|
}): { total: number; subtotal: number; tax: number } {
|
|
155
286
|
if (input.providerPreview) {
|
|
@@ -159,6 +290,9 @@ export function resolveChangeFlowDisplayedAmounts(input: {
|
|
|
159
290
|
tax: input.providerPreview.taxAmount,
|
|
160
291
|
};
|
|
161
292
|
}
|
|
293
|
+
if (input.serverQuotePreview) {
|
|
294
|
+
return { ...input.serverQuotePreview };
|
|
295
|
+
}
|
|
162
296
|
return { ...input.fromCart };
|
|
163
297
|
}
|
|
164
298
|
|
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 {
|