@ticketboothapp/booking 1.2.63 → 1.2.65

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.
@@ -4,7 +4,10 @@ import type { ReactNode } from 'react';
4
4
  import { formatCurrencyAmount } from '../../lib/currency';
5
5
  import { roundMoney } from '../../lib/booking/change-flow-pricing';
6
6
  import type { ChangeBookingPricingDriftRow } from '../../lib/booking/change-booking-pricing-drift';
7
- import type { ChangeBookingQuoteTicketPricingTrace } from '../../lib/booking-api';
7
+ import type {
8
+ ChangeBookingQuoteReceiptAnchoringBreakdown,
9
+ ChangeBookingQuoteTicketPricingTrace,
10
+ } from '../../lib/booking-api';
8
11
  import type { Currency } from './CurrencySwitcher';
9
12
  import type { Locale } from '../../lib/booking/i18n/config';
10
13
 
@@ -41,6 +44,8 @@ export function ChangeBookingPricingDriftPanel(props: {
41
44
  ticketCartDetail?: ChangeBookingDriftTicketCartRow[];
42
45
  /** Optional BE snapshot: locked vs live units and Rule A/B branch (when API sends `ticketPricingTrace`). */
43
46
  serverTicketPricingTrace?: ChangeBookingQuoteTicketPricingTrace | null;
47
+ /** BE breakdown for reconciled total vs sum(lines) (same-parent receipt anchoring). */
48
+ receiptAnchoring?: ChangeBookingQuoteReceiptAnchoringBreakdown | null;
44
49
  }): ReactNode {
45
50
  const {
46
51
  rows,
@@ -53,6 +58,7 @@ export function ChangeBookingPricingDriftPanel(props: {
53
58
  footnote,
54
59
  ticketCartDetail,
55
60
  serverTicketPricingTrace,
61
+ receiptAnchoring,
56
62
  } = props;
57
63
 
58
64
  const showTotals =
@@ -61,10 +67,35 @@ export function ChangeBookingPricingDriftPanel(props: {
61
67
  totalDelta != null &&
62
68
  Math.abs(totalDelta) >= 0.005;
63
69
 
64
- if (rows.length === 0 && !showTotals) return null;
65
-
66
70
  const showTicketCols = rows.some((r) => r.key.startsWith('ticket:'));
67
71
 
72
+ const sumClientDisp = roundMoney(rows.reduce((acc, r) => acc + (r.clientAmount ?? 0), 0));
73
+ const sumServerDisp = roundMoney(rows.reduce((acc, r) => acc + (r.serverAmount ?? 0), 0));
74
+ const resClient =
75
+ clientTotal != null && Number.isFinite(clientTotal) ? roundMoney(clientTotal - sumClientDisp) : null;
76
+ const resServer =
77
+ serverTotal != null && Number.isFinite(serverTotal) ? roundMoney(serverTotal - sumServerDisp) : null;
78
+ const showResidual =
79
+ showTotals &&
80
+ ((resClient != null && Math.abs(resClient) >= 0.02) || (resServer != null && Math.abs(resServer) >= 0.02));
81
+
82
+ const anchoringMeta = receiptAnchoring
83
+ ? (() => {
84
+ const {
85
+ persistedReceiptTotalMajorUnits: receipt,
86
+ baselineCatalogTotalMajorUnits: baseline,
87
+ requestedCatalogLineSumMajorUnits: requestedSum,
88
+ reconciledTotalMajorUnits: reconciled,
89
+ } = receiptAnchoring;
90
+ const catalogDelta = roundMoney(requestedSum - baseline);
91
+ const carryGap = roundMoney(receipt - baseline);
92
+ const anchoringAdj = roundMoney(reconciled - requestedSum);
93
+ return { receipt, baseline, requestedSum, reconciled, catalogDelta, carryGap, anchoringAdj };
94
+ })()
95
+ : null;
96
+
97
+ if (rows.length === 0 && !showTotals) return null;
98
+
68
99
  function unitFmt(amount: number | null | undefined, qty: number | null | undefined): string {
69
100
  if (amount == null || qty == null || qty <= 0 || !Number.isFinite(amount)) return '—';
70
101
  return fmt(roundMoney(amount / qty), currency, locale);
@@ -73,6 +104,65 @@ export function ChangeBookingPricingDriftPanel(props: {
73
104
  return (
74
105
  <div className="mt-3 rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm shadow-sm">
75
106
  <div className="mb-2 font-medium text-stone-800">{title}</div>
107
+ {anchoringMeta ? (
108
+ <div className="mb-4 rounded-md border border-violet-200 bg-violet-50/90 px-2.5 py-2 text-[11px] leading-snug text-stone-900">
109
+ <div className="mb-1.5 text-xs font-semibold text-violet-950">
110
+ Receipt anchoring (diagnostic — not used for the quote price check)
111
+ </div>
112
+ <p className="mb-2 text-[10px] leading-snug text-stone-700">
113
+ The live quote compares your cart to the <span className="font-semibold">catalog line sum</span> (same as
114
+ the table above). Values below are the legacy receipt−baseline formula kept for debugging when persisted
115
+ receipt and baseline catalog differ.
116
+ </p>
117
+ <p className="mb-2 font-mono text-[10px] leading-relaxed text-stone-800">
118
+ legacyReconciled = persistedReceipt + (requestedCatalogLineSum − baselineCatalog)
119
+ </p>
120
+ <div className="overflow-x-auto">
121
+ <table className="w-full min-w-[340px] border-collapse text-left text-[11px]">
122
+ <thead>
123
+ <tr className="border-b border-violet-200 text-[10px] uppercase tracking-wide text-violet-900/80">
124
+ <th className="py-1 pr-2 font-medium">Term</th>
125
+ <th className="py-1 text-right font-medium">Amount</th>
126
+ </tr>
127
+ </thead>
128
+ <tbody className="tabular-nums text-stone-800">
129
+ <tr className="border-t border-violet-100">
130
+ <td className="py-1 pr-2">Persisted receipt total (booking on file)</td>
131
+ <td className="py-1 text-right">{fmt(anchoringMeta.receipt, currency, locale)}</td>
132
+ </tr>
133
+ <tr className="border-t border-violet-100">
134
+ <td className="py-1 pr-2">Baseline catalog total (current itinerary re-priced)</td>
135
+ <td className="py-1 text-right">{fmt(anchoringMeta.baseline, currency, locale)}</td>
136
+ </tr>
137
+ <tr className="border-t border-violet-100">
138
+ <td className="py-1 pr-2">Carry gap (receipt − baseline)</td>
139
+ <td className="py-1 text-right">{fmt(anchoringMeta.carryGap, currency, locale)}</td>
140
+ </tr>
141
+ <tr className="border-t border-violet-100">
142
+ <td className="py-1 pr-2">Requested change — catalog line sum (table above)</td>
143
+ <td className="py-1 text-right">{fmt(anchoringMeta.requestedSum, currency, locale)}</td>
144
+ </tr>
145
+ <tr className="border-t border-violet-100">
146
+ <td className="py-1 pr-2">Catalog delta (requestedSum − baseline)</td>
147
+ <td className="py-1 text-right">{fmt(anchoringMeta.catalogDelta, currency, locale)}</td>
148
+ </tr>
149
+ <tr className="border-t border-violet-100 font-semibold text-violet-950">
150
+ <td className="py-1 pr-2">Legacy reconciled total (old anchoring; not the price check)</td>
151
+ <td className="py-1 text-right">{fmt(anchoringMeta.reconciled, currency, locale)}</td>
152
+ </tr>
153
+ <tr className="border-t border-violet-100 text-stone-700">
154
+ <td className="py-1 pr-2">Legacy − catalog sum (would have changed the total by)</td>
155
+ <td className="py-1 text-right">{fmt(anchoringMeta.anchoringAdj, currency, locale)}</td>
156
+ </tr>
157
+ </tbody>
158
+ </table>
159
+ </div>
160
+ <p className="mt-2 text-[10px] leading-snug text-stone-700">
161
+ <span className="font-semibold text-stone-900">Price check today:</span> server uses the same catalog line
162
+ sum as this table. The figures above are only for investigating historical receipt-vs-baseline gaps.
163
+ </p>
164
+ </div>
165
+ ) : null}
76
166
  <div className="mb-3 rounded-md border border-sky-100 bg-sky-50/80 px-2.5 py-2 text-[11px] leading-snug text-stone-800">
77
167
  <span className="font-semibold text-stone-900">Why these differ — </span>
78
168
  List = catalog for this slot; effective = per-seat charge after change rules (floors, date/option). When the API
@@ -238,6 +328,36 @@ export function ChangeBookingPricingDriftPanel(props: {
238
328
  </tr>
239
329
  );
240
330
  })}
331
+ {showResidual ? (
332
+ <tr className="border-t border-stone-200 bg-violet-50/90 text-[11px] text-violet-950">
333
+ <td
334
+ className="py-1.5 pr-2 align-top"
335
+ title="Per column: footer total minus sum of line amounts above. This row’s Δ = (In app residual) − (Server residual). When In app residual is ~0, that Δ often equals the booking total Δ, but it is not the same quantity by definition."
336
+ >
337
+ Residual (footer − sum of lines)
338
+ </td>
339
+ {showTicketCols ? (
340
+ <>
341
+ <td className="py-1.5 pr-2" />
342
+ <td className="py-1.5 pr-2" />
343
+ <td className="py-1.5 pr-2" />
344
+ </>
345
+ ) : null}
346
+ <td className="py-1.5 pr-2 text-right tabular-nums font-medium">
347
+ {fmt(resClient, currency, locale)}
348
+ </td>
349
+ <td className="py-1.5 pr-2 text-right tabular-nums font-medium">
350
+ {fmt(resServer, currency, locale)}
351
+ </td>
352
+ <td className="py-1.5 text-right tabular-nums font-medium text-violet-950">
353
+ {fmt(
354
+ resClient != null && resServer != null ? roundMoney(resClient - resServer) : null,
355
+ currency,
356
+ locale,
357
+ )}
358
+ </td>
359
+ </tr>
360
+ ) : null}
241
361
  {showTotals ? (
242
362
  <tr className="border-t-2 border-stone-300 bg-stone-50 font-semibold text-stone-900">
243
363
  <td className="py-2 pr-2">New booking total</td>
@@ -257,8 +377,13 @@ export function ChangeBookingPricingDriftPanel(props: {
257
377
  </table>
258
378
  </div>
259
379
  <p className="mt-2 text-[11px] leading-snug text-stone-500">
260
- Δ is <span className="font-medium">in app minus server</span>. Rows highlighted when the difference is at least
261
- one cent.
380
+ Δ is <span className="font-medium">in app minus server</span>. A blank in-app or server cell counts as{' '}
381
+ <span className="font-medium">$0</span> for that row’s Δ so server-only (or app-only) lines still show how they
382
+ move the totals. Rows highlight when |Δ| ≥ 1¢. If “Residual” appears, the summed lines do not yet match that
383
+ column’s footer (rounding, duplicate line keys from the API, or rows not mapped). When a{' '}
384
+ <span className="font-medium">Receipt anchoring adjustment</span> row is present, include it in the sum — the
385
+ server footer should then match. The booking total Δ is always the “New booking total” row, not the residual
386
+ cells alone.
262
387
  </p>
263
388
  {footnote ? (
264
389
  <p className="mt-1 text-[11px] leading-snug text-stone-600">{footnote}</p>
@@ -108,7 +108,7 @@ export interface BookingFlowBaseProps {
108
108
  /** Standard (new) reservation flow — no change-booking receipt or callbacks. */
109
109
  export interface NewBookingFlowProps extends BookingFlowBaseProps {}
110
110
 
111
- /** Change booking — adds receipt snapshot, compare preview, and dashboard selection preview hooks. */
111
+ /** Change booking — adds receipt snapshot and compare preview. */
112
112
  export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
113
113
  /** Called when date/tickets/itinerary selection updates (for side-by-side compare in dialog). */
114
114
  onChangeFlowSelectionPreview?: (preview: ChangeFlowSelectionPreview | null) => void;
@@ -127,7 +127,8 @@ export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
127
127
  }>;
128
128
  } | null;
129
129
  /**
130
- * When true, {@link BookingFlow} renders {@link AdminChangeBookingFlow} (placeholder split from customer flow).
130
+ * Embed compatibility (e.g. ticketbooth provider dashboard): ignored same flow as the public site.
131
+ * When true, {@link BookingFlow} still renders the fork {@link AdminChangeBookingFlow} for future divergence.
131
132
  */
132
133
  useAdminChangeBookingFlow?: boolean;
133
134
  }
@@ -2,6 +2,7 @@ import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
2
2
  import type {
3
3
  ChangeBookingQuotePricingDriftDetail,
4
4
  ChangeBookingQuotePricingDriftLine,
5
+ ChangeBookingQuoteReceiptAnchoringBreakdown,
5
6
  ChangeBookingQuoteResponse,
6
7
  ChangeBookingQuoteTicketLineTrace,
7
8
  ChangeBookingQuoteTicketPricingTrace,
@@ -15,7 +16,7 @@ export interface ChangeBookingPricingDriftRow {
15
16
  clientAmount: number | null;
16
17
  /** Amount from the server price check (major units). */
17
18
  serverAmount: number | null;
18
- /** client − server when both present. */
19
+ /** client − server; missing side treated as 0 so asymmetric rows still explain total drift. */
19
20
  delta: number | null;
20
21
  /** Ticket qty when row resolved to a ticket category (for unit math in UI). */
21
22
  clientQty?: number | null;
@@ -69,6 +70,29 @@ export function canonicalDriftKey(line: PriceSummaryLine): string {
69
70
  }
70
71
 
71
72
  const lab = normalizeDriftLabelKey(line.label);
73
+ const lowerRaw = labRaw.toLowerCase();
74
+
75
+ /**
76
+ * Receipts often use `FEE` / `LINE` for rows the app models as `cancellation` or `TAX`. Without this, drift merge
77
+ * creates duplicate “Flexible cancellation” / tax rows and the residual row misleads.
78
+ */
79
+ if (
80
+ type.includes('CANCELLATION') ||
81
+ type === 'CANCELLATION' ||
82
+ (/\bcancellation\b/i.test(labRaw) &&
83
+ !/moraine|parks|occupation|license|road\s*access|access\s*fee/i.test(lowerRaw))
84
+ ) {
85
+ return `line:CANCELLATION:${lab}`;
86
+ }
87
+
88
+ if (
89
+ type === 'TAX' ||
90
+ type.includes('TAX') ||
91
+ (/\btax(es)?\b|\bgst\b|\bhst\b|\bpst\b|\bvat\b/i.test(labRaw) && !/included/i.test(lowerRaw))
92
+ ) {
93
+ return `line:TAX:${lab}`;
94
+ }
95
+
72
96
  return `line:${type}:${lab}`;
73
97
  }
74
98
 
@@ -92,6 +116,20 @@ export function sumPriceSummaryLinesMajorUnits(lines: PriceSummaryLine[]): numbe
92
116
  return roundMoney(s);
93
117
  }
94
118
 
119
+ /**
120
+ * Per-row drift for the debugger: treat a missing side as 0 so e.g. server-only tax still shows Δ = 0 − server.
121
+ * Returns null only when both inputs are null/undefined.
122
+ */
123
+ export function computePricingDriftDelta(
124
+ clientAmount: number | null | undefined,
125
+ serverAmount: number | null | undefined,
126
+ ): number | null {
127
+ if (clientAmount == null && serverAmount == null) return null;
128
+ const c = clientAmount != null && Number.isFinite(Number(clientAmount)) ? Number(clientAmount) : 0;
129
+ const s = serverAmount != null && Number.isFinite(Number(serverAmount)) ? Number(serverAmount) : 0;
130
+ return roundMoney(c - s);
131
+ }
132
+
95
133
  function num(v: unknown): number | undefined {
96
134
  if (v == null || v === '') return undefined;
97
135
  const n = Number(v);
@@ -130,6 +168,24 @@ export function normalizePricingDriftDetailFromQuote(
130
168
  })
131
169
  : undefined;
132
170
 
171
+ const raRaw = n.receiptAnchoring ?? n.receipt_anchoring;
172
+ let receiptAnchoring: ChangeBookingQuoteReceiptAnchoringBreakdown | undefined;
173
+ if (raRaw && typeof raRaw === 'object' && !Array.isArray(raRaw)) {
174
+ const ra = raRaw as Record<string, unknown>;
175
+ const pr = num(ra.persistedReceiptTotalMajorUnits ?? ra.persisted_receipt_total_major_units);
176
+ const bl = num(ra.baselineCatalogTotalMajorUnits ?? ra.baseline_catalog_total_major_units);
177
+ const rq = num(ra.requestedCatalogLineSumMajorUnits ?? ra.requested_catalog_line_sum_major_units);
178
+ const rc = num(ra.reconciledTotalMajorUnits ?? ra.reconciled_total_major_units);
179
+ if (pr != null && bl != null && rq != null && rc != null) {
180
+ receiptAnchoring = {
181
+ persistedReceiptTotalMajorUnits: pr,
182
+ baselineCatalogTotalMajorUnits: bl,
183
+ requestedCatalogLineSumMajorUnits: rq,
184
+ reconciledTotalMajorUnits: rc,
185
+ };
186
+ }
187
+ }
188
+
133
189
  const out: ChangeBookingQuotePricingDriftDetail = {
134
190
  clientTotalMajorUnits: num(n.clientTotalMajorUnits ?? n.client_total_major_units),
135
191
  serverTotalMajorUnits: num(n.serverTotalMajorUnits ?? n.server_total_major_units),
@@ -137,6 +193,7 @@ export function normalizePricingDriftDetailFromQuote(
137
193
  lineComparisons,
138
194
  serverLineItems: rawLines(n.serverLineItems ?? n.server_line_items),
139
195
  clientLineItems: rawLines(n.clientLineItems ?? n.client_line_items),
196
+ receiptAnchoring,
140
197
  };
141
198
 
142
199
  if (
@@ -145,7 +202,8 @@ export function normalizePricingDriftDetailFromQuote(
145
202
  out.deltaMajorUnits == null &&
146
203
  !out.lineComparisons?.length &&
147
204
  !out.serverLineItems?.length &&
148
- !out.clientLineItems?.length
205
+ !out.clientLineItems?.length &&
206
+ !out.receiptAnchoring
149
207
  ) {
150
208
  return undefined;
151
209
  }
@@ -245,10 +303,7 @@ export function mergePriceSummaryLinesForDrift(
245
303
  const clientAmount = c?.amount ?? null;
246
304
  const serverAmount = s?.amount ?? null;
247
305
  const label = c?.label ?? s?.label ?? key;
248
- let delta: number | null = null;
249
- if (clientAmount != null && serverAmount != null) {
250
- delta = roundMoney(clientAmount - serverAmount);
251
- }
306
+ const delta = computePricingDriftDelta(clientAmount, serverAmount);
252
307
  const clientQty = c?.qty ?? null;
253
308
  const serverQty = s?.qty ?? null;
254
309
  rows.push({
@@ -279,6 +334,16 @@ function findMergedRowForApiLabel(
279
334
  hit = mergedRows.find((r) => normalizeDriftLabelKey(r.label) === norm);
280
335
  if (hit) return hit;
281
336
 
337
+ /** Substring match on normalized labels (e.g. BE "Taxes and fees" vs FE i18n). Guard short norms to reduce false positives. */
338
+ if (norm.length >= 5) {
339
+ hit = mergedRows.find((r) => {
340
+ const rn = normalizeDriftLabelKey(r.label);
341
+ if (!rn) return false;
342
+ return rn === norm || rn.includes(norm) || norm.includes(rn);
343
+ });
344
+ if (hit) return hit;
345
+ }
346
+
282
347
  /** Return rows: BE label is often English "Return option (N people)" while the app may use i18n. */
283
348
  if (/\breturn\b/i.test(trimmed) || norm.includes('return')) {
284
349
  hit = mergedRows.find(
@@ -290,6 +355,38 @@ function findMergedRowForApiLabel(
290
355
  if (hit) return hit;
291
356
  }
292
357
 
358
+ /** Tax / GST / HST — receipt type TAX or label wording. */
359
+ if (/\btax(es)?\b|\bgst\b|\bhst\b|\bpst\b|\bvat\b/i.test(trimmed) || norm.includes('tax')) {
360
+ hit = mergedRows.find(
361
+ (r) =>
362
+ r.key.startsWith('line:TAX') ||
363
+ String(r.key).includes(':TAX:') ||
364
+ /\btax(es)?\b|\bgst\b|\bhst\b/i.test(r.label),
365
+ );
366
+ if (hit) return hit;
367
+ }
368
+
369
+ /** Promo, discount, voucher — align BE "Promo: CODE" with FE discount rows. */
370
+ if (/promo|discount|voucher|gift\s*card/i.test(trimmed)) {
371
+ hit = mergedRows.find(
372
+ (r) =>
373
+ /promo|discount|voucher|gift/i.test(r.label) ||
374
+ /PROMO|DISCOUNT|VOUCHER|GIFT/i.test(r.key),
375
+ );
376
+ if (hit) return hit;
377
+ }
378
+
379
+ /** Cancellation / flexible upgrade lines. */
380
+ if (/cancellation|flexible|upgrade policy|policy fee/i.test(trimmed)) {
381
+ hit = mergedRows.find(
382
+ (r) =>
383
+ r.key.includes('CANCELLATION') ||
384
+ /cancellation|flexible/i.test(r.label) ||
385
+ String(r.key).includes(':cancellation:'),
386
+ );
387
+ if (hit) return hit;
388
+ }
389
+
293
390
  for (const cat of TICKET_CATEGORY_WORDS) {
294
391
  if (upper === cat || upper.startsWith(`${cat} `) || new RegExp(`\\b${cat}\\b`, 'i').test(trimmed)) {
295
392
  hit = mergedRows.find((r) => r.key === `ticket:${cat}`);
@@ -316,10 +413,9 @@ export function enrichLineComparisonsWithMergedRows(
316
413
  line.serverAmountMajorUnits != null && Number.isFinite(Number(line.serverAmountMajorUnits))
317
414
  ? Number(line.serverAmountMajorUnits)
318
415
  : base?.serverAmount ?? null;
319
- const delta =
320
- clientAmount != null && serverAmount != null ? roundMoney(clientAmount - serverAmount) : null;
416
+ const delta = computePricingDriftDelta(clientAmount, serverAmount);
321
417
  return {
322
- key: `api-line:${i}`,
418
+ key: base?.key ?? `api-line:${i}`,
323
419
  label: line.label,
324
420
  clientAmount,
325
421
  serverAmount,
@@ -329,3 +425,55 @@ export function enrichLineComparisonsWithMergedRows(
329
425
  };
330
426
  });
331
427
  }
428
+
429
+ /**
430
+ * When the quote sends `lineComparisons`, keep that ordering for matched rows, then append any
431
+ * {@link mergePriceSummaryLinesForDrift} rows the API omitted so the table can reconcile to footer totals.
432
+ */
433
+ export function mergeLineComparisonsWithFullDrift(
434
+ apiLines: ChangeBookingQuotePricingDriftLine[] | undefined,
435
+ mergedRows: ChangeBookingPricingDriftRow[],
436
+ ): ChangeBookingPricingDriftRow[] {
437
+ if (!apiLines?.length) return mergedRows;
438
+
439
+ const consumedMergedKeys = new Set<string>();
440
+ const out: ChangeBookingPricingDriftRow[] = [];
441
+ const usedRowKeys = new Map<string, number>();
442
+
443
+ for (let i = 0; i < apiLines.length; i++) {
444
+ const line = apiLines[i];
445
+ const base = findMergedRowForApiLabel(mergedRows, line.label);
446
+ if (base) consumedMergedKeys.add(base.key);
447
+
448
+ const clientAmount =
449
+ line.clientAmountMajorUnits != null && Number.isFinite(Number(line.clientAmountMajorUnits))
450
+ ? Number(line.clientAmountMajorUnits)
451
+ : base?.clientAmount ?? null;
452
+ const serverAmount =
453
+ line.serverAmountMajorUnits != null && Number.isFinite(Number(line.serverAmountMajorUnits))
454
+ ? Number(line.serverAmountMajorUnits)
455
+ : base?.serverAmount ?? null;
456
+ const delta = computePricingDriftDelta(clientAmount, serverAmount);
457
+ const rawKey = base?.key ?? `api-line:${i}`;
458
+ const n = (usedRowKeys.get(rawKey) ?? 0) + 1;
459
+ usedRowKeys.set(rawKey, n);
460
+ const rowKey = n === 1 ? rawKey : `${rawKey}#${n}`;
461
+ out.push({
462
+ key: rowKey,
463
+ label: line.label,
464
+ clientAmount,
465
+ serverAmount,
466
+ delta,
467
+ clientQty: base?.clientQty ?? undefined,
468
+ serverQty: base?.serverQty ?? undefined,
469
+ });
470
+ }
471
+
472
+ for (const m of mergedRows) {
473
+ if (!consumedMergedKeys.has(m.key)) {
474
+ out.push(m);
475
+ }
476
+ }
477
+
478
+ return out;
479
+ }
@@ -5,10 +5,9 @@
5
5
  * channel) on top of these formulas.
6
6
  *
7
7
  * ### 1. When receipt “paid floors” apply
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.
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.
12
11
  *
13
12
  * ### 2. Tickets (per category)
14
13
  * **Unchanged itinerary** (same calendar departure date **and** same product option as the booking): among seats up to the
@@ -27,12 +26,12 @@
27
26
  * BE `PublicChangeBookingQuotePricing`: **baseline party** (originally booked headcount) vs **incremental** seats. **Rule A**
28
27
  * (same `returnAvailabilityId` as the booking **and** unchanged outbound itinerary): protected pay exact locked per person;
29
28
  * incremental pay **live catalog** return only ($0 when free). **Else Rule B:** protected pay **`max(floor, live)`**;
30
- * incremental pay **live** only.
29
+ * incremental pay **live** only. Provider dashboard: **live catalog return only** (no floor).
31
30
  *
32
31
  * ### 5. Return option — **picker UI only** (BookingFlow)
33
32
  * **Self-serve:** return cards use the floored per-person price when a floor exists (aligned with §4), for every return
34
33
  * 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.
34
+ * **Provider dashboard:** cards show **catalog** prices only (no floor).
36
35
  *
37
36
  * ### 6. Quote “new booking” total & balance
38
37
  * **FE proposed total** = full cart math (subtotal + tax − promo), cent-rounded; optional **1¢ reconcile** to old receipt
@@ -874,6 +874,21 @@ export type ChangeBookingQuotePricingDriftReceiptLine = {
874
874
  quantity?: number;
875
875
  };
876
876
 
877
+ /**
878
+ * Optional diagnostic: legacy receipt-anchored total vs catalog line sum (same-parent, settled receipt).
879
+ * Public quote **price check** uses `requestedCatalogLineSum` only; `reconciledTotalMajorUnits` is the old formula.
880
+ * reconciledTotal = persistedReceipt + (requestedCatalogLineSum − baselineCatalog).
881
+ */
882
+ export interface ChangeBookingQuoteReceiptAnchoringBreakdown {
883
+ persistedReceiptTotalMajorUnits: number;
884
+ /** Catalog recompute for the booking’s current stored itinerary (party, option, date, return, add-ons). */
885
+ baselineCatalogTotalMajorUnits: number;
886
+ /** Sum of proposed receipt lines for the requested change (Rule A/B); this is what the price check uses. */
887
+ requestedCatalogLineSumMajorUnits: number;
888
+ /** Legacy receipt-anchored total (not used for `clientProposedTotal` verification). */
889
+ reconciledTotalMajorUnits: number;
890
+ }
891
+
877
892
  export interface ChangeBookingQuotePricingDriftDetail {
878
893
  /** Mirror of the client-sent proposed total (major units). */
879
894
  clientTotalMajorUnits?: number;
@@ -893,6 +908,8 @@ export interface ChangeBookingQuotePricingDriftDetail {
893
908
  * When set, drift “In app” column uses this instead of the live FE checkout breakdown.
894
909
  */
895
910
  clientLineItems?: ChangeBookingQuotePricingDriftReceiptLine[];
911
+ /** When BE applies receipt anchoring on same parent product (settled receipt + baseline catalog). */
912
+ receiptAnchoring?: ChangeBookingQuoteReceiptAnchoringBreakdown;
896
913
  }
897
914
 
898
915
  /** BE ticket-line recipe for debugging drift vs FE change rules (optional on quote responses). */