@ticketboothapp/booking 1.2.140 → 1.2.142

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.140",
3
+ "version": "1.2.142",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -155,6 +155,9 @@ const checkoutPanelPropKeys = [
155
155
  'receiptTotal',
156
156
  'originalReceipt',
157
157
  'priceSummaryLinesIncludeTaxRow',
158
+ 'serverAmendmentLines',
159
+ 'serverAmountDue',
160
+ 'serverUpdatedTotal',
158
161
  'isTaxIncludedInPrice',
159
162
  'taxRate',
160
163
  'currency',
@@ -971,6 +971,18 @@ export function AdminChangeBookingFlow({
971
971
  receiptTax: adminFeAuthoritativeReceipt.tax,
972
972
  receiptTotal: adminFeAuthoritativeReceipt.total,
973
973
  originalReceipt,
974
+ serverAmendmentLines:
975
+ latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
976
+ ? latestChangeQuote.serverPreview?.priceSummaryLines ?? []
977
+ : null,
978
+ serverAmountDue:
979
+ latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
980
+ ? latestChangeQuote.serverPreview?.amountDue ?? null
981
+ : null,
982
+ serverUpdatedTotal:
983
+ latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
984
+ ? latestChangeQuote.serverPreview?.totalNewBooking ?? null
985
+ : null,
974
986
  ...{ priceSummaryLinesIncludeTaxRow, isTaxIncludedInPrice, showProviderPricingInlineEditor },
975
987
  taxRate: pricingConfig?.taxRate,
976
988
  ...{ providerPricingUi, providerQuotedLines },
@@ -30,6 +30,9 @@ export interface AdminChangeCheckoutPanelProps {
30
30
  receiptTotal: number;
31
31
  originalReceipt: ChangeBookingFlowProps['originalReceipt'];
32
32
  priceSummaryLinesIncludeTaxRow: boolean;
33
+ serverAmendmentLines: PriceSummaryLine[] | null;
34
+ serverAmountDue: number | null;
35
+ serverUpdatedTotal: number | null;
33
36
  isTaxIncludedInPrice: boolean;
34
37
  taxRate?: number;
35
38
  currency: Currency;
@@ -99,6 +102,9 @@ export function AdminChangeCheckoutPanel({
99
102
  receiptTotal,
100
103
  originalReceipt,
101
104
  priceSummaryLinesIncludeTaxRow,
105
+ serverAmendmentLines,
106
+ serverAmountDue,
107
+ serverUpdatedTotal,
102
108
  isTaxIncludedInPrice,
103
109
  taxRate,
104
110
  currency,
@@ -181,25 +187,15 @@ export function AdminChangeCheckoutPanel({
181
187
  isAdmin && originalReceipt ? (
182
188
  <AdminChangeReceiptComparison
183
189
  originalReceipt={originalReceipt}
184
- newReceipt={{
185
- subtotal: receiptSubtotal,
186
- tax: receiptTax,
187
- total: receiptTotal,
188
- }}
189
- newPriceSummaryLines={priceSummaryLines}
190
- newPriceSummaryLinesIncludeTaxRow={priceSummaryLinesIncludeTaxRow}
191
- amountDue={changeFlowAmountDue}
190
+ amendmentLines={serverAmendmentLines}
191
+ amountDue={serverAmountDue}
192
+ updatedTotal={serverUpdatedTotal}
192
193
  amountDueLabel={totalSummaryLabel}
193
194
  currency={currency}
194
195
  locale={locale}
195
196
  t={t}
196
197
  taxRate={taxRate}
197
- newReceiptAdjustments={pricingAdjustments}
198
- lineAmountInputs={lineAmountInputs}
199
- onLineAmountInputChange={onLineAmountInputChange}
200
- onLineAmountInputBlur={onLineAmountInputBlur}
201
- onLineAmountReset={onLineAmountReset}
202
- lineLabelInputs={lineLabelInputs}
198
+ adjustments={pricingAdjustments}
203
199
  />
204
200
  ) : null;
205
201
 
@@ -1,6 +1,4 @@
1
- import { useMemo, type ReactNode } from 'react';
2
- import type { AdminFeAuthoritativeReceipt } from '../../lib/booking-api';
3
- import { roundMoney } from '../../lib/booking/change-flow-pricing';
1
+ import type { ReactNode } from 'react';
4
2
  import { mapQuoteLineItemsToPriceSummaryLines } from '../../lib/booking/change-booking-server-preview';
5
3
  import { formatCurrencyAmount } from '../../lib/currency';
6
4
  import type { Locale } from '../../lib/booking/i18n/config';
@@ -13,206 +11,61 @@ type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
13
11
 
14
12
  export interface AdminChangeReceiptComparisonProps {
15
13
  originalReceipt: OriginalReceipt;
16
- newReceipt: Pick<AdminFeAuthoritativeReceipt, 'subtotal' | 'tax' | 'total'>;
17
- newPriceSummaryLines: PriceSummaryLine[];
18
- newPriceSummaryLinesIncludeTaxRow: boolean;
19
- amountDue: number;
14
+ /** Amendment-only rows returned by the Pricing V2 server quote. */
15
+ amendmentLines: PriceSummaryLine[] | null;
16
+ /** Signed server balance delta. Never calculated by this component. */
17
+ amountDue: number | null;
18
+ /** Full resulting booking total returned by the same server quote. */
19
+ updatedTotal: number | null;
20
20
  amountDueLabel: string;
21
21
  currency: Currency;
22
22
  locale: string;
23
23
  t: TranslationFn;
24
24
  taxRate?: number;
25
- newReceiptAdjustments?: ReactNode;
26
- lineAmountInputs?: Record<string, string>;
27
- onLineAmountInputChange?: (lineKey: string, value: string) => void;
28
- onLineAmountInputBlur?: (lineKey: string) => void;
29
- onLineAmountReset?: (lineKey: string) => void;
30
- lineLabelInputs?: Record<string, string>;
25
+ adjustments?: ReactNode;
31
26
  }
32
27
 
33
28
  function normalizeLocale(locale: string): Locale {
34
29
  return locale === 'fr' ? 'fr' : 'en';
35
30
  }
36
31
 
32
+ function originalLines(receipt: OriginalReceipt): PriceSummaryLine[] {
33
+ return receipt.lineItems?.length
34
+ ? mapQuoteLineItemsToPriceSummaryLines(receipt.lineItems)
35
+ : [];
36
+ }
37
+
37
38
  function hasTaxLine(lines: PriceSummaryLine[]): boolean {
38
39
  return lines.some(
39
40
  (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
40
41
  );
41
42
  }
42
43
 
43
- function mapOriginalReceiptLines(originalReceipt: OriginalReceipt): PriceSummaryLine[] {
44
- if (!originalReceipt.lineItems?.length) return [];
45
- return mapQuoteLineItemsToPriceSummaryLines(originalReceipt.lineItems);
46
- }
47
-
48
- function lineIdentity(line: PriceSummaryLine): string {
49
- const normalizedLabel = (value: string) =>
50
- value
51
- .replace(/\s*\([^)]*(?:people|person|x\s*\d+)[^)]*\)\s*$/i, '')
52
- .replace(/\s+/g, ' ')
53
- .trim()
54
- .toUpperCase();
55
- return line.kind === 'ticket'
56
- ? `ticket:${line.category.trim().toUpperCase()}`
57
- : `line:${String(line.type ?? '').trim().toUpperCase()}:${normalizedLabel(line.label)}`;
58
- }
59
-
60
- function lineAmount(line: PriceSummaryLine): number {
61
- return line.kind === 'ticket' ? line.itemTotal : line.amount;
62
- }
63
-
64
- function lineQuantity(line: PriceSummaryLine): number | null {
65
- return line.kind === 'ticket' ? line.qty : line.quantity ?? null;
66
- }
67
-
68
- function amendmentDeltaLines(
69
- originalLines: PriceSummaryLine[],
70
- proposedLines: PriceSummaryLine[],
71
- ): PriceSummaryLine[] {
72
- const remainingOriginal = new Map<string, { amount: number; quantity: number | null }>();
73
- for (const line of originalLines) {
74
- const key = lineIdentity(line);
75
- const previous = remainingOriginal.get(key);
76
- remainingOriginal.set(key, {
77
- amount: roundMoney((previous?.amount ?? 0) + lineAmount(line)),
78
- quantity:
79
- previous?.quantity == null && lineQuantity(line) == null
80
- ? null
81
- : (previous?.quantity ?? 0) + (lineQuantity(line) ?? 0),
82
- });
83
- }
84
-
85
- return proposedLines.flatMap((line): PriceSummaryLine[] => {
86
- const original = remainingOriginal.get(lineIdentity(line));
87
- const proposedQuantity = lineQuantity(line);
88
- const quantity =
89
- proposedQuantity == null && original?.quantity == null
90
- ? null
91
- : (proposedQuantity ?? 0) - (original?.quantity ?? 0);
92
- const proposedAmount = lineAmount(line);
93
- const amount = roundMoney(
94
- quantity != null && quantity > 0 && proposedQuantity != null && proposedQuantity > 0
95
- ? (proposedAmount / proposedQuantity) * quantity
96
- : proposedAmount - (original?.amount ?? 0),
97
- );
98
- if (Math.abs(amount) < 0.005 && (quantity == null || quantity === 0)) return [];
99
-
100
- if (line.kind === 'ticket') {
101
- return [{
102
- ...line,
103
- qty: Math.abs(quantity ?? 0) || line.qty,
104
- itemTotal: amount,
105
- editable: false,
106
- lineKey: undefined,
107
- }];
108
- }
109
- const isTax = String(line.type ?? '').toUpperCase() === 'TAX';
110
- return [{
111
- ...line,
112
- label: isTax
113
- ? (amount >= 0 ? 'Additional tax' : 'Tax reduction')
114
- : line.label.replace(/\s*\([^)]*(?:people|person|x\s*\d+)[^)]*\)\s*$/i, '').trim(),
115
- amount,
116
- quantity: quantity == null ? null : Math.abs(quantity),
117
- editable: false,
118
- lineKey: undefined,
119
- }];
120
- });
121
- }
122
-
123
44
  export function AdminChangeReceiptComparison({
124
45
  originalReceipt,
125
- newReceipt,
126
- newPriceSummaryLines,
46
+ amendmentLines,
47
+ amountDue,
48
+ updatedTotal,
127
49
  amountDueLabel,
128
50
  currency,
129
51
  locale,
130
52
  t,
131
53
  taxRate,
132
- newReceiptAdjustments,
54
+ adjustments,
133
55
  }: AdminChangeReceiptComparisonProps) {
134
56
  const displayLocale = normalizeLocale(locale);
135
- const originalReceiptLines = useMemo(
136
- () => mapOriginalReceiptLines(originalReceipt),
137
- [originalReceipt],
138
- );
139
- const originalLinesIncludeTaxRow = hasTaxLine(originalReceiptLines);
140
- const calculatedChangeLines = useMemo(
141
- () => amendmentDeltaLines(originalReceiptLines, newPriceSummaryLines),
142
- [newPriceSummaryLines, originalReceiptLines],
143
- );
144
- const nonTaxChangeSubtotal = roundMoney(
145
- calculatedChangeLines.reduce(
146
- (sum, line) =>
147
- line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
148
- ? sum
149
- : sum + lineAmount(line),
150
- 0,
151
- ),
152
- );
153
- const receiptTaxDelta = roundMoney(
154
- taxRate != null && nonTaxChangeSubtotal > 0
155
- ? nonTaxChangeSubtotal * taxRate
156
- : newReceipt.tax - originalReceipt.tax,
157
- );
158
- const changeLines = useMemo(() => {
159
- if (hasTaxLine(calculatedChangeLines) || Math.abs(receiptTaxDelta) < 0.005) {
160
- return calculatedChangeLines;
161
- }
162
- return [
163
- ...calculatedChangeLines,
164
- {
165
- kind: 'line' as const,
166
- label: receiptTaxDelta >= 0 ? 'Additional tax' : 'Tax reduction',
167
- amount: receiptTaxDelta,
168
- type: 'TAX',
169
- },
170
- ];
171
- }, [calculatedChangeLines, receiptTaxDelta]);
172
- const changeLinesIncludeTaxRow = hasTaxLine(changeLines);
173
- const changeTax = roundMoney(
174
- changeLines.reduce(
175
- (sum, line) =>
176
- line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
177
- ? sum + line.amount
178
- : sum,
179
- 0,
180
- ),
181
- );
182
- const displayedAmountDue = roundMoney(changeLines.reduce((sum, line) => sum + lineAmount(line), 0));
183
- const displayedUpdatedTotal = roundMoney(originalReceipt.total + displayedAmountDue);
184
- const displayedUpdatedSubtotal = roundMoney(originalReceipt.subtotal + nonTaxChangeSubtotal);
185
- const displayedUpdatedTax = roundMoney(originalReceipt.tax + changeTax);
57
+ const existingLines = originalLines(originalReceipt);
186
58
  const originalCurrency = originalReceipt.currency ?? currency;
187
- const originalReceiptLinesWithComparableBreakdowns = useMemo(
188
- () =>
189
- originalReceiptLines.map((line) =>
190
- line.kind === 'ticket' && line.qty > 0
191
- ? {
192
- ...line,
193
- breakdown: line.breakdown ?? {
194
- lineItems: [],
195
- subtotalAfterAdjustments: roundMoney(line.itemTotal / line.qty),
196
- feeLines: [],
197
- taxRate: 0,
198
- taxAmount: 0,
199
- isTaxIncluded: false,
200
- finalPrice: roundMoney(line.itemTotal / line.qty),
201
- },
202
- }
203
- : line,
204
- ),
205
- [originalReceiptLines],
206
- );
59
+ const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
207
60
  const amountDueClass =
208
- displayedAmountDue < -0.005
61
+ amountDue != null && amountDue < -0.005
209
62
  ? 'text-emerald-700'
210
- : displayedAmountDue > 0.005
63
+ : amountDue != null && amountDue > 0.005
211
64
  ? 'text-stone-900'
212
65
  : 'text-stone-700';
213
66
 
214
67
  return (
215
- <div className="space-y-3 min-w-0 overflow-visible">
68
+ <div className="min-w-0 space-y-3 overflow-visible">
216
69
  <div className="grid gap-4 md:grid-cols-2">
217
70
  <section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
218
71
  <div className="mb-3 flex items-center justify-between gap-3">
@@ -222,82 +75,72 @@ export function AdminChangeReceiptComparison({
222
75
  </span>
223
76
  </div>
224
77
  <PriceSummary
225
- lines={originalReceiptLinesWithComparableBreakdowns}
78
+ lines={existingLines}
226
79
  total={originalReceipt.total}
227
80
  currency={originalCurrency}
228
81
  locale={displayLocale}
229
82
  subtotal={originalReceipt.subtotal}
230
- taxAmount={!originalLinesIncludeTaxRow ? originalReceipt.tax : 0}
83
+ taxAmount={hasTaxLine(existingLines) ? 0 : originalReceipt.tax}
231
84
  taxRate={taxRate}
232
85
  size="sm"
233
86
  t={t}
234
87
  />
235
88
  </section>
89
+
236
90
  <section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
237
91
  <div className="mb-3 flex items-center justify-between gap-3">
238
92
  <div>
239
93
  <h3 className="text-sm font-semibold text-stone-900">This change</h3>
240
- <p className="mt-0.5 text-xs text-stone-500">Only newly charged, credited, or repriced value</p>
94
+ <p className="mt-0.5 text-xs text-stone-500">Server-priced amendment</p>
241
95
  </div>
242
96
  <span className="shrink-0 text-xs font-medium text-stone-500">
243
- {formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
97
+ {amountDue == null ? '—' : formatCurrencyAmount(amountDue, currency, displayLocale)}
244
98
  </span>
245
99
  </div>
246
- {changeLines.length === 0 && Math.abs(displayedAmountDue) < 0.005 ? (
100
+ {!quoteReady ? (
101
+ <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
102
+ <p className="text-sm font-medium text-stone-700">Waiting for server price…</p>
103
+ </div>
104
+ ) : amendmentLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
247
105
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
248
106
  <p className="text-sm font-medium text-stone-700">No pricing changes</p>
249
- <p className="mt-1 text-xs text-stone-500">The selected booking details match the existing purchase.</p>
250
107
  </div>
251
108
  ) : (
252
109
  <PriceSummary
253
- lines={changeLines}
254
- total={displayedAmountDue}
110
+ lines={amendmentLines}
111
+ total={amountDue}
255
112
  currency={currency}
256
113
  locale={displayLocale}
257
- subtotal={nonTaxChangeSubtotal}
258
- taxAmount={!changeLinesIncludeTaxRow ? changeTax : 0}
259
- taxRate={taxRate}
260
114
  size="sm"
261
115
  t={t}
262
116
  />
263
117
  )}
264
118
  </section>
265
119
  </div>
266
- <section className="min-w-0 overflow-visible rounded-lg border border-stone-300 bg-white p-3 shadow-sm">
267
- <div className="mb-3 flex items-center justify-between gap-3">
120
+
121
+ {quoteReady ? (
122
+ <section className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-300 bg-white px-3 py-3 shadow-sm">
268
123
  <div>
269
- <h3 className="text-sm font-semibold text-stone-900">Updated booking receipt</h3>
270
- <p className="mt-0.5 text-xs text-stone-500">Read-only pricing result; historical and added cohorts remain separate when supplied by the server</p>
124
+ <h3 className="text-sm font-semibold text-stone-900">Updated booking total</h3>
125
+ <p className="mt-0.5 text-xs text-stone-500">Authoritative total returned by the server quote</p>
271
126
  </div>
272
- <span className="shrink-0 text-xs font-medium text-stone-500">
273
- {formatCurrencyAmount(displayedUpdatedTotal, currency, displayLocale)}
127
+ <span className="text-lg font-semibold tabular-nums text-stone-900">
128
+ {formatCurrencyAmount(updatedTotal, currency, displayLocale)}
274
129
  </span>
275
- </div>
276
- <PriceSummary
277
- lines={[...originalReceiptLines, ...changeLines].map((line) => ({ ...line, editable: false, lineKey: undefined }))}
278
- total={displayedUpdatedTotal}
279
- currency={currency}
280
- locale={displayLocale}
281
- subtotal={displayedUpdatedSubtotal}
282
- taxAmount={!hasTaxLine([...originalReceiptLines, ...changeLines]) ? displayedUpdatedTax : 0}
283
- taxRate={taxRate}
284
- size="sm"
285
- t={t}
286
- />
287
- </section>
288
- {newReceiptAdjustments ? (
130
+ </section>
131
+ ) : null}
132
+
133
+ {adjustments ? (
289
134
  <section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
290
- <div className="mb-2">
291
- <h3 className="text-sm font-semibold text-stone-900">Admin adjustments</h3>
292
- <p className="mt-0.5 text-xs text-stone-500">Optional structured charges or credits applied to this amendment</p>
293
- </div>
294
- {newReceiptAdjustments}
135
+ <h3 className="mb-2 text-sm font-semibold text-stone-900">Admin adjustments</h3>
136
+ {adjustments}
295
137
  </section>
296
138
  ) : null}
139
+
297
140
  <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
298
141
  <span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
299
142
  <span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
300
- {formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
143
+ {amountDue == null ? '—' : formatCurrencyAmount(amountDue, currency, displayLocale)}
301
144
  </span>
302
145
  </div>
303
146
  </div>
@@ -336,7 +336,7 @@ export function useAdminChangePriceSummary({
336
336
 
337
337
  const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
338
338
  let raw: PriceSummaryLine[];
339
- if (!useAdminFeAuthoritativeQuote && suppressSelfServeCurrencyUi && selfServePricingConfirmed) {
339
+ if (suppressSelfServeCurrencyUi && selfServePricingConfirmed) {
340
340
  raw =
341
341
  latestServerPriceSummaryLines && latestServerPriceSummaryLines.length > 0
342
342
  ? latestServerPriceSummaryLines
@@ -366,7 +366,6 @@ export function useAdminChangePriceSummary({
366
366
  }, [
367
367
  suppressSelfServeCurrencyUi,
368
368
  selfServePricingConfirmed,
369
- useAdminFeAuthoritativeQuote,
370
369
  checkoutPriceSummaryLines,
371
370
  latestServerPriceSummaryLines,
372
371
  effectivePromoDiscountAmount,
@@ -261,9 +261,11 @@ export function useAdminChangeQuotePreview({
261
261
  hasChangesFromInitial: hasEffectiveChangeSelection,
262
262
  selectionTotal:
263
263
  originalReceipt
264
- ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed && !useAdminFeAuthoritativeQuote
265
- ? null
266
- : displayChangeFlowProposedTotalWithEditableLines
264
+ ? selfServePricingConfirmed && latestChangeQuote?.serverPreview?.totalNewBooking != null
265
+ ? latestChangeQuote.serverPreview.totalNewBooking
266
+ : suppressSelfServeCurrencyUi
267
+ ? null
268
+ : displayChangeFlowProposedTotalWithEditableLines
267
269
  : totalPrice,
268
270
  selectionCurrency: currency,
269
271
  };
@@ -281,7 +283,7 @@ export function useAdminChangeQuotePreview({
281
283
  displayChangeFlowProposedTotalWithEditableLines,
282
284
  suppressSelfServeCurrencyUi,
283
285
  selfServePricingConfirmed,
284
- useAdminFeAuthoritativeQuote,
286
+ latestChangeQuote?.serverPreview?.totalNewBooking,
285
287
  ]);
286
288
 
287
289
  useEffect(() => {
@@ -188,7 +188,12 @@ export function useAdminChangeReceiptDerivation({
188
188
  });
189
189
  })();
190
190
  const changeFlowClientEstimateDue = normalizeNearZeroOwed(
191
- roundMoney(changeFlowClientEstimateDueBase + editableSummaryPreSubtotalDelta),
191
+ roundMoney(
192
+ changeFlowClientEstimateDueBase +
193
+ (latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
194
+ ? 0
195
+ : editableSummaryPreSubtotalDelta),
196
+ ),
192
197
  );
193
198
  const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowClientEstimateDue);
194
199