@ticketboothapp/booking 1.2.62 → 1.2.64

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.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type { BookingFlowProps } from './booking-flow-types';
4
+ import { AdminChangeBookingFlow } from './AdminChangeBookingFlow';
4
5
  import { ChangeBookingFlow } from './ChangeBookingFlow';
5
6
  import { NewBookingFlow } from './NewBookingFlow';
6
7
 
@@ -13,12 +14,15 @@ export type {
13
14
  } from './booking-flow-types';
14
15
 
15
16
  /**
16
- * Routes to {@link NewBookingFlow} or {@link ChangeBookingFlow} two duplicated implementations
17
- * so each flow can be refactored without coupling (no shared runtime core).
17
+ * Routes to {@link NewBookingFlow}, {@link ChangeBookingFlow}, or {@link AdminChangeBookingFlow}
18
+ * — duplicated implementations so each flow can be refactored without coupling (no shared runtime core).
18
19
  */
19
20
  export function BookingFlow(props: BookingFlowProps) {
20
21
  if (props.mode === 'change') {
21
- const { mode: _mode, ...changeProps } = props;
22
+ const { mode: _mode, useAdminChangeBookingFlow, ...changeProps } = props;
23
+ if (useAdminChangeBookingFlow) {
24
+ return <AdminChangeBookingFlow {...changeProps} />;
25
+ }
22
26
  return <ChangeBookingFlow {...changeProps} />;
23
27
  }
24
28
  const { mode: _mode, ...newProps } = props;
@@ -64,10 +64,11 @@ import {
64
64
  } from '../../lib/booking/change-flow-pricing';
65
65
  import {
66
66
  mergePriceSummaryLinesForDrift,
67
+ mergeLineComparisonsWithFullDrift,
67
68
  normalizePricingDriftDetailFromQuote,
68
69
  normalizeTicketPricingTraceFromQuote,
69
70
  sumPriceSummaryLinesMajorUnits,
70
- enrichLineComparisonsWithMergedRows,
71
+ computePricingDriftDelta,
71
72
  } from '../../lib/booking/change-booking-pricing-drift';
72
73
  import { ChangeBookingPricingDriftPanel } from './ChangeBookingPricingDriftPanel';
73
74
  import {
@@ -3229,7 +3230,45 @@ export function ChangeBookingFlow({
3229
3230
 
3230
3231
  const clientMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.clientLineItems);
3231
3232
  const useBeClientLines = clientMappedFromApi.length > 0;
3232
- const clientLinesForMerge = useBeClientLines ? clientMappedFromApi : checkoutPriceSummaryLines;
3233
+ /**
3234
+ * Checkout passes tax/discount via PriceSummary props, not always as `priceSummaryLines`. Include them here so
3235
+ * drift rows and their sums align with `changeFlowNewBookingTotal` (subtotal + tax − promo).
3236
+ */
3237
+ const clientLinesForMerge: PriceSummaryLine[] = (() => {
3238
+ if (useBeClientLines) return clientMappedFromApi;
3239
+ const lines: PriceSummaryLine[] = [...checkoutPriceSummaryLines];
3240
+ const hasTaxLine = lines.some(
3241
+ (l) => l.kind === 'line' && String(l.type ?? '').toUpperCase() === 'TAX',
3242
+ );
3243
+ if (!hasTaxLine && !isTaxIncludedInPrice && Math.abs(effectiveTax) >= 0.005) {
3244
+ lines.push({
3245
+ kind: 'line',
3246
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
3247
+ amount: effectiveTax,
3248
+ type: 'TAX',
3249
+ });
3250
+ }
3251
+ const hasPromoSummaryLine = lines.some(
3252
+ (l) =>
3253
+ l.kind === 'line' &&
3254
+ (/PROMO|DISCOUNT|VOUCHER|GIFT/i.test(String(l.type ?? '')) ||
3255
+ (l.amount < -0.005 && /promo|discount/i.test(l.label))),
3256
+ );
3257
+ if (!hasPromoSummaryLine && Math.abs(effectivePromoDiscountAmount) >= 0.005) {
3258
+ const trimmedPromoCode = appliedPromoCode?.trim() ?? '';
3259
+ const promoLabel =
3260
+ trimmedPromoCode.length > 0
3261
+ ? `Promo: ${trimmedPromoCode}`
3262
+ : originalReceipt?.promoLabel?.trim() || 'Discount';
3263
+ lines.push({
3264
+ kind: 'line',
3265
+ label: promoLabel,
3266
+ amount: -effectivePromoDiscountAmount,
3267
+ type: 'PROMO_CODE',
3268
+ });
3269
+ }
3270
+ return lines;
3271
+ })();
3233
3272
 
3234
3273
  const serverMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.serverLineItems);
3235
3274
  const useBeServerLines = serverMappedFromApi.length > 0;
@@ -3273,14 +3312,35 @@ export function ChangeBookingFlow({
3273
3312
  }
3274
3313
 
3275
3314
  const mergedForDrift = mergePriceSummaryLinesForDrift(clientLinesForMerge, serverLinesForMerge);
3276
- let rows =
3315
+ const rows =
3277
3316
  api?.lineComparisons && api.lineComparisons.length > 0
3278
- ? enrichLineComparisonsWithMergedRows(api.lineComparisons, mergedForDrift)
3317
+ ? mergeLineComparisonsWithFullDrift(api.lineComparisons, mergedForDrift)
3279
3318
  : mergedForDrift;
3280
3319
 
3320
+ const receiptAnchoring = api?.receiptAnchoring;
3321
+ let driftRows = rows;
3322
+ if (receiptAnchoring) {
3323
+ const adj = roundMoney(
3324
+ receiptAnchoring.reconciledTotalMajorUnits -
3325
+ receiptAnchoring.requestedCatalogLineSumMajorUnits,
3326
+ );
3327
+ if (Math.abs(adj) >= 0.005) {
3328
+ driftRows = [
3329
+ ...rows,
3330
+ {
3331
+ key: 'meta:receipt-anchoring',
3332
+ label: 'Receipt anchoring adjustment (BE only — not included in app total)',
3333
+ clientAmount: null,
3334
+ serverAmount: adj,
3335
+ delta: computePricingDriftDelta(null, adj),
3336
+ },
3337
+ ];
3338
+ }
3339
+ }
3340
+
3281
3341
  const hasTotalDelta = totalDelta != null && Math.abs(totalDelta) >= 0.005;
3282
3342
 
3283
- if (rows.length === 0 && !hasTotalDelta) {
3343
+ if (driftRows.length === 0 && !hasTotalDelta) {
3284
3344
  return null;
3285
3345
  }
3286
3346
 
@@ -3309,7 +3369,7 @@ export function ChangeBookingFlow({
3309
3369
 
3310
3370
  return (
3311
3371
  <ChangeBookingPricingDriftPanel
3312
- rows={rows}
3372
+ rows={driftRows}
3313
3373
  clientTotal={clientTotalForDrift}
3314
3374
  serverTotal={serverTotalFromQuote}
3315
3375
  totalDelta={totalDelta}
@@ -3317,6 +3377,7 @@ export function ChangeBookingFlow({
3317
3377
  locale={locale}
3318
3378
  ticketCartDetail={ticketCartDetail.length > 0 ? ticketCartDetail : undefined}
3319
3379
  serverTicketPricingTrace={latestChangeQuote.ticketPricingTrace ?? undefined}
3380
+ receiptAnchoring={receiptAnchoring}
3320
3381
  footnote={
3321
3382
  usesBeLinePayload
3322
3383
  ? 'Lines use pricingDriftDetail.clientLineItems / serverLineItems when the quote includes them; totals prefer explicit major-unit fields, then sums of those lines, then the live cart / receipt preview.'
@@ -3335,6 +3396,12 @@ export function ChangeBookingFlow({
3335
3396
  locale,
3336
3397
  ticketLineItemsForChangeFlowDisplay,
3337
3398
  pricingForTicketSelector,
3399
+ isTaxIncludedInPrice,
3400
+ effectiveTax,
3401
+ effectivePromoDiscountAmount,
3402
+ appliedPromoCode,
3403
+ originalReceipt?.promoLabel,
3404
+ t,
3338
3405
  ]);
3339
3406
 
3340
3407
  /** Replaces PriceSummary with non-numeric status until quote returns authoritative totals (no FE dollar amounts). */
@@ -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>
@@ -132,6 +132,11 @@ export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
132
132
  * quote and payment (`quoteChangeBooking`, Stripe).
133
133
  */
134
134
  onChangeBooking?: (data: ProviderDashboardChangeBookingPayload) => Promise<void>;
135
+ /**
136
+ * Provider dashboard only: {@link BookingFlow} renders {@link AdminChangeBookingFlow} instead of
137
+ * {@link ChangeBookingFlow}. Same UI/behavior until admin-specific tweaks are added.
138
+ */
139
+ useAdminChangeBookingFlow?: boolean;
135
140
  }
136
141
 
137
142
  /**
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  export { BookingFlow } from './components/booking/BookingFlow';
16
16
  export { NewBookingFlow } from './components/booking/NewBookingFlow';
17
17
  export { ChangeBookingFlow } from './components/booking/ChangeBookingFlow';
18
+ export { AdminChangeBookingFlow } from './components/booking/AdminChangeBookingFlow';
18
19
  export type {
19
20
  BookingFlowBaseProps,
20
21
  BookingFlowProps,
@@ -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
+ }
@@ -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). */