@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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +158 -106
- package/src/components/booking/BookingFlow.tsx +2 -3
- package/src/components/booking/ChangeBookingFlow.tsx +158 -103
- package/src/components/booking/ChangeBookingPricingDriftPanel.tsx +130 -5
- package/src/components/booking/booking-flow-types.ts +3 -2
- package/src/lib/booking/change-booking-pricing-drift.ts +157 -9
- package/src/lib/booking/change-flow-pricing.ts +5 -6
- package/src/lib/booking-api.ts +17 -0
|
@@ -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 {
|
|
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>.
|
|
261
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
* BookingFlow).
|
|
10
|
-
*
|
|
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:**
|
|
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
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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). */
|