@ticketboothapp/booking 1.2.60 → 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 -5363
- 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 +162 -29
- 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
|
+
}
|
|
@@ -2,41 +2,50 @@
|
|
|
2
2
|
* ## Change-booking pricing — product rules (frontend)
|
|
3
3
|
*
|
|
4
4
|
* Use this file as the **spec checklist** when debating behavior with product; BookingFlow adds **gates** (parent product,
|
|
5
|
-
* channel
|
|
5
|
+
* channel) on top of these formulas.
|
|
6
6
|
*
|
|
7
7
|
* ### 1. When receipt “paid floors” apply
|
|
8
|
-
* Only
|
|
9
|
-
* in BookingFlow).
|
|
8
|
+
* Only **customer self-serve** change flow, when the booking’s **parent catalog product** matches the **loaded product**
|
|
9
|
+
* (`changeFlowApplyReceiptPaidFloors` in BookingFlow). **Provider dashboard** change flow uses **live catalog only** (no
|
|
10
|
+
* floors) so a cheaper date/return yields a lower total / refund via signed balance.
|
|
10
11
|
*
|
|
11
12
|
* ### 2. Tickets (per category)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
19
|
*
|
|
19
20
|
* ### 3. Product fees (config fees, per line)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* 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.
|
|
23
23
|
*
|
|
24
24
|
* ### 4. Return option — **order total / checkout**
|
|
25
|
-
*
|
|
26
|
-
* **
|
|
27
|
-
* (
|
|
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).
|
|
28
30
|
*
|
|
29
31
|
* ### 5. Return option — **picker UI only** (BookingFlow)
|
|
30
|
-
* **Self-serve:** return
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* floors are on.
|
|
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.
|
|
34
|
+
* **Provider dashboard:** cards show **catalog** prices only (no floor).
|
|
34
35
|
*
|
|
35
36
|
* ### 6. Quote “new booking” total & balance
|
|
36
37
|
* **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
|
-
*
|
|
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.
|
|
39
47
|
*/
|
|
48
|
+
import type { ChangeBookingQuoteResponse } from '../booking-api';
|
|
40
49
|
import { reconcileChangeBookingProposedTotal } from '../currency';
|
|
41
50
|
|
|
42
51
|
/** Money in major units, rounded to cents (half-up). */
|
|
@@ -44,8 +53,11 @@ export function roundMoney(amount: number): number {
|
|
|
44
53
|
return Math.round(amount * 100) / 100;
|
|
45
54
|
}
|
|
46
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
|
+
|
|
47
59
|
/**
|
|
48
|
-
* 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.
|
|
49
61
|
*/
|
|
50
62
|
export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
51
63
|
qty: number;
|
|
@@ -55,6 +67,7 @@ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
|
55
67
|
receiptUnitFloor: number | undefined;
|
|
56
68
|
/** Current catalog line total (before floors). */
|
|
57
69
|
liveLineTotal: number;
|
|
70
|
+
protectedReceiptPricing: ChangeFlowProtectedReceiptPricing;
|
|
58
71
|
}): number {
|
|
59
72
|
const qty = Math.max(0, input.qty);
|
|
60
73
|
if (qty <= 0) return 0;
|
|
@@ -65,18 +78,22 @@ export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
|
65
78
|
const baselineQty = Math.max(0, input.baselineQtyForCategory);
|
|
66
79
|
const protectedQty = Math.min(qty, baselineQty);
|
|
67
80
|
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
81
|
+
if (input.protectedReceiptPricing === 'exact_from_receipt') {
|
|
82
|
+
return protectedQty * input.receiptUnitFloor + incrementalQty * liveUnit;
|
|
83
|
+
}
|
|
68
84
|
const protectedUnit = Math.max(input.receiptUnitFloor, liveUnit);
|
|
69
85
|
return protectedQty * protectedUnit + incrementalQty * liveUnit;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
|
-
* 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.
|
|
74
90
|
*/
|
|
75
91
|
export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
76
92
|
totalQuantity: number;
|
|
77
93
|
initialTicketCount: number;
|
|
78
94
|
bookedFeePerPerson: number | undefined;
|
|
79
95
|
liveFeeLineTotal: number;
|
|
96
|
+
protectedReceiptPricing: ChangeFlowProtectedReceiptPricing;
|
|
80
97
|
}): number {
|
|
81
98
|
const totalQuantity = Math.max(0, input.totalQuantity);
|
|
82
99
|
if (totalQuantity <= 0) return Math.max(0, Number(input.liveFeeLineTotal) || 0);
|
|
@@ -87,6 +104,9 @@ export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
|
87
104
|
}
|
|
88
105
|
const protectedP = Math.min(Math.max(0, input.initialTicketCount), totalQuantity);
|
|
89
106
|
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
107
|
+
if (input.protectedReceiptPricing === 'exact_from_receipt') {
|
|
108
|
+
return protectedP * input.bookedFeePerPerson + incrementalP * livePer;
|
|
109
|
+
}
|
|
90
110
|
const protectedPerPerson = Math.max(input.bookedFeePerPerson, livePer);
|
|
91
111
|
return protectedP * protectedPerPerson + incrementalP * livePer;
|
|
92
112
|
}
|
|
@@ -104,6 +124,39 @@ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
|
104
124
|
return Math.max(live, floor);
|
|
105
125
|
}
|
|
106
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
|
+
|
|
107
160
|
/**
|
|
108
161
|
* Product: **New booking price** for a change is the same cart total as a fresh booking
|
|
109
162
|
* (subtotal + tax − discounts), in cents. We send that on quotes so it matches line items and the server breakdown.
|
|
@@ -112,14 +165,10 @@ export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
|
112
165
|
* total (see `reconcileChangeBookingProposedTotal`).
|
|
113
166
|
*/
|
|
114
167
|
export function resolveChangeFlowNewBookingTotal(input: {
|
|
115
|
-
isChangeFlow: boolean;
|
|
116
168
|
/** `effectiveSubtotal + effectiveTax - promo` from the live cart. */
|
|
117
169
|
cartTotal: number;
|
|
118
170
|
originalReceiptTotal: number | null | undefined;
|
|
119
171
|
}): number {
|
|
120
|
-
if (!input.isChangeFlow) {
|
|
121
|
-
return input.cartTotal;
|
|
122
|
-
}
|
|
123
172
|
const rounded = roundMoney(input.cartTotal);
|
|
124
173
|
if (input.originalReceiptTotal == null) {
|
|
125
174
|
return input.cartTotal;
|
|
@@ -142,8 +191,87 @@ export function changeFlowBalanceVsOriginal(input: {
|
|
|
142
191
|
}
|
|
143
192
|
|
|
144
193
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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.
|
|
147
275
|
*/
|
|
148
276
|
export function resolveChangeFlowDisplayedAmounts(input: {
|
|
149
277
|
providerPreview: {
|
|
@@ -151,6 +279,8 @@ export function resolveChangeFlowDisplayedAmounts(input: {
|
|
|
151
279
|
subtotalBeforeTax: number;
|
|
152
280
|
taxAmount: number;
|
|
153
281
|
} | null;
|
|
282
|
+
/** Customer self-serve: authoritative breakdown from last successful `quoteChangeBooking`. */
|
|
283
|
+
serverQuotePreview?: { total: number; subtotal: number; tax: number } | null;
|
|
154
284
|
fromCart: { total: number; subtotal: number; tax: number };
|
|
155
285
|
}): { total: number; subtotal: number; tax: number } {
|
|
156
286
|
if (input.providerPreview) {
|
|
@@ -160,6 +290,9 @@ export function resolveChangeFlowDisplayedAmounts(input: {
|
|
|
160
290
|
tax: input.providerPreview.taxAmount,
|
|
161
291
|
};
|
|
162
292
|
}
|
|
293
|
+
if (input.serverQuotePreview) {
|
|
294
|
+
return { ...input.serverQuotePreview };
|
|
295
|
+
}
|
|
163
296
|
return { ...input.fromCart };
|
|
164
297
|
}
|
|
165
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 {
|