@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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +5446 -0
- package/src/components/booking/BookingFlow.tsx +7 -3
- package/src/components/booking/ChangeBookingFlow.tsx +73 -6
- package/src/components/booking/ChangeBookingPricingDriftPanel.tsx +130 -5
- package/src/components/booking/booking-flow-types.ts +5 -0
- package/src/index.ts +1 -0
- package/src/lib/booking/change-booking-pricing-drift.ts +157 -9
- package/src/lib/booking-api.ts +17 -0
|
@@ -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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3315
|
+
const rows =
|
|
3277
3316
|
api?.lineComparisons && api.lineComparisons.length > 0
|
|
3278
|
-
?
|
|
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 (
|
|
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={
|
|
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 {
|
|
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>
|
|
@@ -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
|
|
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
|
+
}
|
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). */
|