@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.
@@ -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
- * 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.
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
- * For each category where we can infer an average **unit paid** from the stored receipt:
14
- * - Among seats **up to** the **originally booked** count for that category, **unit price** =
15
- * **`max(receipt average unit, live catalog unit)`** not the minimum; the guest never pays **below** what they paid
16
- * for those seats **and** never **below** today’s list price for those seats.
17
- * - Any **extra** seats beyond the original count for that category: **live catalog only** (no receipt floor).
18
- * If we cannot infer a receipt unit for a category, that line stays **live catalog** only.
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
- * Split the party into **protected** headcount (≤ original total ticket count) vs **incremental** passengers.
22
- * For each fee line: **`max(receipt-derived fee per person, live fee per person)`** on protected passengers; incremental
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 §1 floors apply and a per-person return floor exists (API `returnUnitFloorPerPerson` or receipt RETURN lines):
27
- * **per person** = **`max(live catalog return for the selected slot, floor)`**; **row total** = party size × that amount.
28
- * Provider dashboard: **live catalog return only** (no floor).
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
- * **Provider dashboard:** cards show **catalog** prices only (no floor).
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
- * **Customer** owes **`max(0, newTotal oldReceipt)`**; **provider** inline pricing may show a **signed** delta.
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 use `max(receipt unit, live unit)`; extra qty uses live only.
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: first `initialTicketCount` passengers use `max(receipt per-person, live per-person)`; rest live only.
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
- * Product: **What we show** in the change-flow price row either the provider’s manual preview totals, or the same
145
- * cart numbers as everywhere else (no second shadow total).
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
 
@@ -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 {