@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.
@@ -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, same-itinerary) on top of these formulas.
5
+ * channel) on top of these formulas.
6
6
  *
7
7
  * ### 1. When receipt “paid floors” apply
8
- * Only in change flow when the booking’s **parent catalog product** matches the **loaded product** (`changeFlowApplyReceiptPaidFloors`
9
- * in BookingFlow). Otherwise the session is treated like normal catalog pricing (no receipt floors in this layer).
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
- * For each category where we can infer an average **unit paid** from the stored receipt:
13
- * - Among seats **up to** the **originally booked** count for that category, **unit price** =
14
- * **`max(receipt average unit, live catalog unit)`** not the minimum; the guest never pays **below** what they paid
15
- * for those seats **and** never **below** today’s list price for those seats.
16
- * - Any **extra** seats beyond the original count for that category: **live catalog only** (no receipt floor).
17
- * If we cannot infer a receipt unit for a category, that line stays **live catalog** only.
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
- * Split the party into **protected** headcount (≤ original total ticket count) vs **incremental** passengers.
21
- * For each fee line: **`max(receipt-derived fee per person, live fee per person)`** on protected passengers; incremental
22
- * passengers pay each line’s **live** per-person amount only.
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
- * If a per-person return floor exists (API `returnUnitFloorPerPerson` or derived from receipt RETURN lines):
26
- * **per person** = **`max(live catalog return for the selected slot, floor)`**; **row total** = party size × that amount
27
- * (when floors apply).
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 choice cards always show the floored per-person price when a floor exists.
31
- * **Provider / other:** if outbound **and** return still match the **original** booking exactly, cards show **catalog**
32
- * only (floor hidden) until the itinerary changes; after a change, same as self-serve. **Totals** still follow §4 when
33
- * floors are on.
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
- * **Customer** owes **`max(0, newTotal oldReceipt)`**; **provider** inline pricing may show a **signed** delta.
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 use `max(receipt unit, live unit)`; extra qty uses live only.
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: first `initialTicketCount` passengers use `max(receipt per-person, live per-person)`; rest live only.
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
- * Product: **What we show** in the change-flow price row either the provider’s manual preview totals, or the same
146
- * cart numbers as everywhere else (no second shadow total).
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
 
@@ -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 {