@ticketboothapp/booking 1.2.76 → 1.2.77

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.
@@ -100,3 +100,39 @@ Pickers show **catalog** prices by default (same booking **currency** as the ori
100
100
  - **Provider change API:** When `pricingAdjustment.additionalAdjustments` includes the same rows, persist them on the new receipt and charge the **same** total as `newTotalAmount` (within your rounding rules).
101
101
 
102
102
  If the server ignores `manualLineAdjustments` or `additionalAdjustments`, the customer will see the FE total but pay a different amount.
103
+
104
+ ---
105
+
106
+ ## Admin FE-authoritative quote path (new)
107
+
108
+ For provider dashboard / admin tooling, we need a dedicated quote route where the **frontend is authoritative** for
109
+ the full receipt preview (totals + line items), and BE does not re-price those values.
110
+
111
+ ### Endpoint
112
+
113
+ - **`POST /1/admin/bookings/:bookingReference/change/quote?lastName=...`**
114
+
115
+ Keep the public self-serve endpoint unchanged.
116
+
117
+ ### Request contract (admin)
118
+
119
+ Same selection fields as `ChangeBookingQuoteRequest`, plus:
120
+
121
+ - `feReceipt`:
122
+ - `subtotal` (major units)
123
+ - `tax` (major units)
124
+ - `total` (major units)
125
+ - `currency?`
126
+ - `lineItems: Array<{ label?: string; amount?: number; type?: string; quantity?: number }>`
127
+ - `feAmountDueMajorUnits?` (optional signed delta the FE displays; `newTotal - previousTotal`)
128
+
129
+ ### Backend expectations
130
+
131
+ - Validate admin auth + booking/availability eligibility only.
132
+ - Persist `feReceipt` on the change intent as the quote source-of-truth.
133
+ - Return a `ChangeBookingQuoteResponse`-compatible payload using the stored FE receipt:
134
+ - `newReceipt` / `proposed` line items from `feReceipt.lineItems`
135
+ - `newReceipt.subtotal/tax/total` from `feReceipt`
136
+ - `amountDueCents` / `balanceDeltaMajorUnits` / `priceDiff` coherent with stored totals
137
+ - `changeIntentId`, `canProceed`, `reasonIfBlocked`
138
+ - Payment intent + apply/confirm for this change intent must use persisted FE totals/lines (no repricing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.76",
3
+ "version": "1.2.77",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -9,6 +9,7 @@ import {
9
9
  cancelReservationBestEffort,
10
10
  createPaymentIntent,
11
11
  quoteChangeBooking,
12
+ quoteChangeBookingAdminFeReceipt,
12
13
  confirmFreeChangeBooking,
13
14
  createChangeBookingPaymentIntent,
14
15
  confirmBookingWithoutPayment,
@@ -25,6 +26,7 @@ import {
25
26
  isInsufficientCapacityReserveError,
26
27
  describeStandardTourCapacityConflictMessage,
27
28
  reportReserveCapacityConflictClientContext,
29
+ type AdminFeAuthoritativeReceipt,
28
30
  } from '../../lib/booking-api';
29
31
  import {
30
32
  EARLIEST_AVAILABILITY_DATE,
@@ -148,6 +150,26 @@ function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): Pri
148
150
  });
149
151
  }
150
152
 
153
+ function mapSummaryLinesToFeReceiptLineItems(lines: PriceSummaryLine[]): NonNullable<AdminFeAuthoritativeReceipt['lineItems']> {
154
+ return lines.map((line) => {
155
+ if (line.kind === 'ticket') {
156
+ return {
157
+ label: line.category,
158
+ amount: roundMoney(line.itemTotal),
159
+ type: 'TICKET',
160
+ quantity: line.qty,
161
+ };
162
+ }
163
+ const normalizedType = String(line.type ?? '').trim().toUpperCase();
164
+ return {
165
+ label: line.label,
166
+ amount: roundMoney(line.amount),
167
+ type: normalizedType || undefined,
168
+ quantity: line.quantity ?? undefined,
169
+ };
170
+ });
171
+ }
172
+
151
173
  function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
152
174
  const labels: Record<string, string> = {
153
175
  ADULT: 'adult',
@@ -733,6 +755,8 @@ export function AdminChangeBookingFlow({
733
755
  const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<
734
756
  Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
735
757
  >([]);
758
+ const [editableSummaryLineAmountInputs, setEditableSummaryLineAmountInputs] = useState<Record<string, string>>({});
759
+ const [editableSummaryLineLabelInputs, setEditableSummaryLineLabelInputs] = useState<Record<string, string>>({});
736
760
  const adminCustomLineIdRef = useRef(0);
737
761
 
738
762
  // Auto-apply promo code when parent page passes one (e.g. partner pages).
@@ -831,6 +855,10 @@ export function AdminChangeBookingFlow({
831
855
  }
832
856
  }, [initialValues?.dateTime, companyTimezone]);
833
857
  const isProviderDashboardChange = Boolean(onChangeBooking);
858
+ const useAdminFeAuthoritativeQuote =
859
+ isAdmin &&
860
+ isProviderDashboardChange &&
861
+ Boolean(flowUi?.adminFeAuthoritativeQuoteEnabled);
834
862
  /** Any change from an existing booking (public or provider). */
835
863
  const isChangeBookingContext = Boolean(initialValues?.bookingReference?.trim());
836
864
  /**
@@ -3120,6 +3148,140 @@ export function AdminChangeBookingFlow({
3120
3148
  ),
3121
3149
  [checkoutPriceSummaryLinesForCheckout],
3122
3150
  );
3151
+ const getEditableSummaryLineKey = useCallback((line: PriceSummaryLine, index: number): string => {
3152
+ if (line.lineKey) return line.lineKey;
3153
+ return line.kind === 'ticket'
3154
+ ? `change-ticket-${line.category}-${index}`
3155
+ : `change-line-${line.label}-${line.type ?? 'line'}-${index}`;
3156
+ }, []);
3157
+ const editableCheckoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
3158
+ const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
3159
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
3160
+ );
3161
+ return checkoutPriceSummaryLinesForCheckout.map((line, index) => {
3162
+ const isBeforeSubtotal = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
3163
+ const lineKey = getEditableSummaryLineKey(line, index);
3164
+ if (line.kind === 'ticket') {
3165
+ const amountInput = editableSummaryLineAmountInputs[lineKey];
3166
+ const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.itemTotal : Number(amountInput);
3167
+ return {
3168
+ ...line,
3169
+ lineKey,
3170
+ editable: isBeforeSubtotal,
3171
+ category: editableSummaryLineLabelInputs[lineKey] ?? line.category,
3172
+ itemTotal: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.itemTotal,
3173
+ };
3174
+ }
3175
+ const amountInput = editableSummaryLineAmountInputs[lineKey];
3176
+ const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.amount : Number(amountInput);
3177
+ return {
3178
+ ...line,
3179
+ lineKey,
3180
+ editable: isBeforeSubtotal,
3181
+ label: editableSummaryLineLabelInputs[lineKey] ?? line.label,
3182
+ amount: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.amount,
3183
+ };
3184
+ });
3185
+ }, [
3186
+ checkoutPriceSummaryLinesForCheckout,
3187
+ editableSummaryLineAmountInputs,
3188
+ editableSummaryLineLabelInputs,
3189
+ getEditableSummaryLineKey,
3190
+ ]);
3191
+ useEffect(() => {
3192
+ setEditableSummaryLineAmountInputs((prev) => {
3193
+ const next: Record<string, string> = {};
3194
+ for (let i = 0; i < editableCheckoutPriceSummaryLines.length; i += 1) {
3195
+ const line = editableCheckoutPriceSummaryLines[i];
3196
+ const key = line.lineKey;
3197
+ if (!key) continue;
3198
+ if (prev[key] != null) {
3199
+ next[key] = prev[key];
3200
+ continue;
3201
+ }
3202
+ next[key] = line.kind === 'ticket' ? String(line.itemTotal) : String(line.amount);
3203
+ }
3204
+ const prevKeys = Object.keys(prev);
3205
+ const nextKeys = Object.keys(next);
3206
+ if (
3207
+ prevKeys.length === nextKeys.length &&
3208
+ nextKeys.every((key) => prev[key] === next[key])
3209
+ ) {
3210
+ return prev;
3211
+ }
3212
+ return next;
3213
+ });
3214
+ setEditableSummaryLineLabelInputs((prev) => {
3215
+ const next: Record<string, string> = {};
3216
+ for (let i = 0; i < editableCheckoutPriceSummaryLines.length; i += 1) {
3217
+ const line = editableCheckoutPriceSummaryLines[i];
3218
+ const key = line.lineKey;
3219
+ if (!key) continue;
3220
+ if (prev[key] != null) {
3221
+ next[key] = prev[key];
3222
+ continue;
3223
+ }
3224
+ next[key] = line.kind === 'ticket' ? line.category : line.label;
3225
+ }
3226
+ const prevKeys = Object.keys(prev);
3227
+ const nextKeys = Object.keys(next);
3228
+ if (
3229
+ prevKeys.length === nextKeys.length &&
3230
+ nextKeys.every((key) => prev[key] === next[key])
3231
+ ) {
3232
+ return prev;
3233
+ }
3234
+ return next;
3235
+ });
3236
+ }, [editableCheckoutPriceSummaryLines]);
3237
+ const editableSummaryPreSubtotalDelta = useMemo(() => {
3238
+ const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
3239
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
3240
+ );
3241
+ let delta = 0;
3242
+ for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
3243
+ if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
3244
+ const line = checkoutPriceSummaryLinesForCheckout[i];
3245
+ const lineKey = getEditableSummaryLineKey(line, i);
3246
+ const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
3247
+ const raw = editableSummaryLineAmountInputs[lineKey];
3248
+ const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
3249
+ const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
3250
+ delta += editedAmount - baselineAmount;
3251
+ }
3252
+ return roundMoney(delta);
3253
+ }, [
3254
+ checkoutPriceSummaryLinesForCheckout,
3255
+ editableSummaryLineAmountInputs,
3256
+ getEditableSummaryLineKey,
3257
+ ]);
3258
+ const editableSummaryPreSubtotalDebugRows = useMemo(() => {
3259
+ const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
3260
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
3261
+ );
3262
+ const rows: Array<{ label: string; baseline: number; edited: number; delta: number }> = [];
3263
+ for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
3264
+ if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
3265
+ const line = checkoutPriceSummaryLinesForCheckout[i];
3266
+ const lineKey = getEditableSummaryLineKey(line, i);
3267
+ const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
3268
+ const raw = editableSummaryLineAmountInputs[lineKey];
3269
+ const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
3270
+ const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
3271
+ const delta = roundMoney(editedAmount - baselineAmount);
3272
+ rows.push({
3273
+ label: line.kind === 'ticket' ? line.category : line.label,
3274
+ baseline: roundMoney(baselineAmount),
3275
+ edited: editedAmount,
3276
+ delta,
3277
+ });
3278
+ }
3279
+ return rows;
3280
+ }, [
3281
+ checkoutPriceSummaryLinesForCheckout,
3282
+ editableSummaryLineAmountInputs,
3283
+ getEditableSummaryLineKey,
3284
+ ]);
3123
3285
 
3124
3286
  // Promo discount from backend (order-level only; rates are pre-promo)
3125
3287
  const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
@@ -3741,8 +3903,46 @@ export function AdminChangeBookingFlow({
3741
3903
  ? displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3742
3904
  : displayedChangeAmountsRaw.total
3743
3905
  );
3906
+ const displayChangeFlowProposedTotalWithEditableLines = roundMoney(
3907
+ displayChangeFlowProposedTotal + editableSummaryPreSubtotalDelta
3908
+ );
3909
+ const adminFeAuthoritativeReceipt = useMemo((): AdminFeAuthoritativeReceipt => {
3910
+ const hasTaxLine = editableCheckoutPriceSummaryLines.some(
3911
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
3912
+ );
3913
+ const lineItems = mapSummaryLinesToFeReceiptLineItems(editableCheckoutPriceSummaryLines);
3914
+ if (!hasTaxLine && Math.abs(displayChangeFlowTax) >= 0.0005) {
3915
+ lineItems.push({
3916
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
3917
+ amount: roundMoney(displayChangeFlowTax),
3918
+ type: 'TAX',
3919
+ });
3920
+ }
3921
+ let subtotal = roundMoney(displayChangeFlowSubtotal + editableSummaryPreSubtotalDelta);
3922
+ const tax = roundMoney(displayChangeFlowTax);
3923
+ const expectedTotal = roundMoney(displayChangeFlowProposedTotalWithEditableLines);
3924
+ const recomputed = roundMoney(subtotal + tax);
3925
+ if (Math.abs(recomputed - expectedTotal) >= 0.005) {
3926
+ subtotal = roundMoney(expectedTotal - tax);
3927
+ }
3928
+ return {
3929
+ subtotal,
3930
+ tax,
3931
+ total: expectedTotal,
3932
+ currency,
3933
+ lineItems,
3934
+ };
3935
+ }, [
3936
+ editableCheckoutPriceSummaryLines,
3937
+ displayChangeFlowTax,
3938
+ displayChangeFlowSubtotal,
3939
+ editableSummaryPreSubtotalDelta,
3940
+ displayChangeFlowProposedTotalWithEditableLines,
3941
+ currency,
3942
+ t,
3943
+ ]);
3744
3944
 
3745
- const changeFlowClientEstimateDue = (() => {
3945
+ const changeFlowClientEstimateDueBase = (() => {
3746
3946
  if (!originalReceipt) return totalPrice;
3747
3947
  // Self-serve quote: match BE receipt / intent: (newTotalCents − previousTotalCents) / 100.
3748
3948
  if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
@@ -3764,6 +3964,9 @@ export function AdminChangeBookingFlow({
3764
3964
  audience: 'admin',
3765
3965
  });
3766
3966
  })();
3967
+ const changeFlowClientEstimateDue = normalizeNearZeroOwed(
3968
+ roundMoney(changeFlowClientEstimateDueBase + editableSummaryPreSubtotalDelta)
3969
+ );
3767
3970
 
3768
3971
  const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
3769
3972
  const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
@@ -3797,18 +4000,33 @@ export function AdminChangeBookingFlow({
3797
4000
  <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
3798
4001
  {[
3799
4002
  `Amount-due path: ${path}`,
4003
+ `adminFeAuthoritativeQuoteEnabled: ${String(useAdminFeAuthoritativeQuote)}`,
3800
4004
  `displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
3801
4005
  `selfServePricingConfirmed: ${String(selfServePricingConfirmed)} · changeQuoteLoading: ${String(changeQuoteLoading)}`,
3802
4006
  `Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
3803
4007
  `Admin custom lines (sum): ${fmt(adminCustomAdjustmentTotal)}`,
4008
+ `Pre-subtotal line overrides:`,
4009
+ ...editableSummaryPreSubtotalDebugRows.map(
4010
+ (row, idx) =>
4011
+ ` ${idx + 1}. ${row.label} | baseline ${fmt(row.baseline)} -> edited ${fmt(row.edited)} (delta ${fmt(row.delta)})`
4012
+ ),
3804
4013
  `Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
3805
4014
  `effectiveTax · promo discount: ${fmt(effectiveTax)} · ${fmt(effectivePromoDiscountAmount)}`,
3806
4015
  `totalPrice (subtotal + tax − promo): ${fmt(totalPrice)}`,
3807
4016
  `changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
3808
4017
  `Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
3809
- `displayChangeFlowProposedTotal (summary / preview footer): ${fmt(displayChangeFlowProposedTotal)}`,
4018
+ `Pre-subtotal editable lines delta: ${fmt(editableSummaryPreSubtotalDelta)}`,
4019
+ `displayChangeFlowProposedTotal (base): ${fmt(displayChangeFlowProposedTotal)}`,
4020
+ `displayChangeFlowProposedTotal (with editable lines): ${fmt(displayChangeFlowProposedTotalWithEditableLines)}`,
4021
+ `feReceipt sent subtotal/tax/total: ${fmt(adminFeAuthoritativeReceipt.subtotal)} / ${fmt(adminFeAuthoritativeReceipt.tax)} / ${fmt(adminFeAuthoritativeReceipt.total)}`,
4022
+ `feReceipt sent lineItems count: ${String(adminFeAuthoritativeReceipt.lineItems.length)}`,
3810
4023
  originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: —',
3811
4024
  `Quote serverDisplay.total (if any): ${quoteTotalPreview}`,
4025
+ `Quote serverDisplay subtotal/tax/total: ${
4026
+ latestChangeQuote?.serverDisplay
4027
+ ? `${fmt(latestChangeQuote.serverDisplay.subtotal)} / ${fmt(latestChangeQuote.serverDisplay.tax)} / ${fmt(latestChangeQuote.serverDisplay.total)}`
4028
+ : '—'
4029
+ }`,
3812
4030
  `changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
3813
4031
  `changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
3814
4032
  ].join('\n')}
@@ -3819,6 +4037,7 @@ export function AdminChangeBookingFlow({
3819
4037
  isAdmin,
3820
4038
  selectedAvailability,
3821
4039
  totalQuantity,
4040
+ useAdminFeAuthoritativeQuote,
3822
4041
  currency,
3823
4042
  locale,
3824
4043
  originalReceipt,
@@ -3830,6 +4049,7 @@ export function AdminChangeBookingFlow({
3830
4049
  changeQuoteLoading,
3831
4050
  effectiveSubtotal,
3832
4051
  adminCustomAdjustmentTotal,
4052
+ editableSummaryPreSubtotalDebugRows,
3833
4053
  effectiveSubtotalForCheckout,
3834
4054
  effectiveTax,
3835
4055
  effectivePromoDiscountAmount,
@@ -3838,7 +4058,10 @@ export function AdminChangeBookingFlow({
3838
4058
  displayedChangeAmountsRaw.subtotal,
3839
4059
  displayedChangeAmountsRaw.tax,
3840
4060
  displayedChangeAmountsRaw.total,
4061
+ editableSummaryPreSubtotalDelta,
3841
4062
  displayChangeFlowProposedTotal,
4063
+ displayChangeFlowProposedTotalWithEditableLines,
4064
+ adminFeAuthoritativeReceipt,
3842
4065
  changeFlowClientEstimateDue,
3843
4066
  changeFlowAmountDue,
3844
4067
  ]);
@@ -3966,7 +4189,7 @@ export function AdminChangeBookingFlow({
3966
4189
  originalReceipt
3967
4190
  ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
3968
4191
  ? null
3969
- : displayChangeFlowProposedTotal
4192
+ : displayChangeFlowProposedTotalWithEditableLines
3970
4193
  : totalPrice,
3971
4194
  selectionCurrency: currency,
3972
4195
  };
@@ -3980,7 +4203,7 @@ export function AdminChangeBookingFlow({
3980
4203
  totalPrice,
3981
4204
  currency,
3982
4205
  originalReceipt,
3983
- displayChangeFlowProposedTotal,
4206
+ displayChangeFlowProposedTotalWithEditableLines,
3984
4207
  suppressSelfServeCurrencyUi,
3985
4208
  selfServePricingConfirmed,
3986
4209
  ]);
@@ -4025,7 +4248,7 @@ export function AdminChangeBookingFlow({
4025
4248
  ? displayChangeFlowTax
4026
4249
  : effectiveTax
4027
4250
  : 0,
4028
- total: originalReceipt ? displayChangeFlowProposedTotal : totalPrice,
4251
+ total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
4029
4252
  currency,
4030
4253
  });
4031
4254
  }, [
@@ -4034,7 +4257,7 @@ export function AdminChangeBookingFlow({
4034
4257
  totalQuantity,
4035
4258
  effectiveSubtotalForCheckout,
4036
4259
  effectiveTax,
4037
- displayChangeFlowProposedTotal,
4260
+ displayChangeFlowProposedTotalWithEditableLines,
4038
4261
  displayChangeFlowSubtotal,
4039
4262
  displayChangeFlowTax,
4040
4263
  currency,
@@ -4086,7 +4309,7 @@ export function AdminChangeBookingFlow({
4086
4309
  .filter(([, count]) => count > 0)
4087
4310
  .map(([category, count]) => ({ category, count }));
4088
4311
  try {
4089
- const quote = await quoteChangeBooking({
4312
+ const quoteRequestBase = {
4090
4313
  bookingReference: bookingReferenceForQuote,
4091
4314
  lastName: lastName.trim(),
4092
4315
  newProductId: optionId,
@@ -4107,7 +4330,14 @@ export function AdminChangeBookingFlow({
4107
4330
  previousAvailabilityId: initialValues.availabilityId ?? null,
4108
4331
  previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
4109
4332
  },
4110
- });
4333
+ };
4334
+ const quote = useAdminFeAuthoritativeQuote
4335
+ ? await quoteChangeBookingAdminFeReceipt({
4336
+ ...quoteRequestBase,
4337
+ feReceipt: adminFeAuthoritativeReceipt,
4338
+ feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4339
+ })
4340
+ : await quoteChangeBooking(quoteRequestBase);
4111
4341
  if (seq !== changeQuoteRequestSeq.current) return;
4112
4342
  const slice = sliceChangeQuoteForUi(
4113
4343
  quote,
@@ -4155,6 +4385,7 @@ export function AdminChangeBookingFlow({
4155
4385
  addOnSelections,
4156
4386
  changeFlowInitialTicketCount,
4157
4387
  changeFlowNewBookingTotal,
4388
+ changeFlowClientEstimateDue,
4158
4389
  effectiveSubtotalForCheckout,
4159
4390
  effectiveTax,
4160
4391
  totalPrice,
@@ -4163,6 +4394,8 @@ export function AdminChangeBookingFlow({
4163
4394
  initialValues?.availabilityId,
4164
4395
  initialValues?.returnAvailabilityId,
4165
4396
  adminCustomLinesAsAdditionalAdjustments,
4397
+ adminFeAuthoritativeReceipt,
4398
+ useAdminFeAuthoritativeQuote,
4166
4399
  ]);
4167
4400
 
4168
4401
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -4683,7 +4916,7 @@ export function AdminChangeBookingFlow({
4683
4916
  if (!changeBookingReference || !changeLastName) {
4684
4917
  throw new Error('Missing booking reference or last name for change quote');
4685
4918
  }
4686
- const quote = await quoteChangeBooking({
4919
+ const quoteRequestBase = {
4687
4920
  bookingReference: changeBookingReference,
4688
4921
  lastName: changeLastName,
4689
4922
  newProductId: availabilityProductOptionId,
@@ -4698,7 +4931,14 @@ export function AdminChangeBookingFlow({
4698
4931
  : {}),
4699
4932
  clientProposedTotal:
4700
4933
  latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
4701
- });
4934
+ };
4935
+ const quote = useAdminFeAuthoritativeQuote
4936
+ ? await quoteChangeBookingAdminFeReceipt({
4937
+ ...quoteRequestBase,
4938
+ feReceipt: adminFeAuthoritativeReceipt,
4939
+ feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4940
+ })
4941
+ : await quoteChangeBooking(quoteRequestBase);
4702
4942
  const quoteSlice = sliceChangeQuoteForUi(
4703
4943
  quote,
4704
4944
  {
@@ -5052,7 +5292,7 @@ export function AdminChangeBookingFlow({
5052
5292
  isCustomerSelfServeChange && originalReceipt
5053
5293
  ? {
5054
5294
  previousTotal: originalReceipt.total,
5055
- newTotal: displayChangeFlowProposedTotal,
5295
+ newTotal: displayChangeFlowProposedTotalWithEditableLines,
5056
5296
  differenceTotal: amountDueForCheckout,
5057
5297
  }
5058
5298
  : undefined,
@@ -5158,7 +5398,7 @@ export function AdminChangeBookingFlow({
5158
5398
  addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
5159
5399
  cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
5160
5400
  promoCode: appliedPromoCode ?? null,
5161
- newTotalAmount: displayChangeFlowProposedTotal,
5401
+ newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
5162
5402
  additionalHoursCount: null,
5163
5403
  pricingAdjustment:
5164
5404
  providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
@@ -5531,7 +5771,7 @@ export function AdminChangeBookingFlow({
5531
5771
  {selectedAvailability && (
5532
5772
  <>
5533
5773
  <CheckoutForm
5534
- priceSummaryLines={checkoutPriceSummaryLinesForCheckout}
5774
+ priceSummaryLines={editableCheckoutPriceSummaryLines}
5535
5775
  replacePriceSummary={selfServeCheckoutPlaceholder}
5536
5776
  totalPrice={changeFlowAmountDue}
5537
5777
  totalSummaryLabel={
@@ -5820,16 +6060,49 @@ export function AdminChangeBookingFlow({
5820
6060
  attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
5821
6061
  attributionConfirmed={partnerAttributionConfirmed}
5822
6062
  onAttributionConfirmedChange={setPartnerAttributionConfirmed}
5823
- lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
5824
- onLineAmountInputChange={
5825
- showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
5826
- }
5827
- onLineAmountInputBlur={
5828
- showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
5829
- }
5830
- onLineAmountReset={
5831
- showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
5832
- }
6063
+ lineAmountInputs={editableSummaryLineAmountInputs}
6064
+ onLineAmountInputChange={(lineKey, value) => {
6065
+ const sanitized = value.replace(/[^0-9.-]/g, '');
6066
+ setEditableSummaryLineAmountInputs((prev) => ({ ...prev, [lineKey]: sanitized }));
6067
+ if (showProviderPricingInlineEditor) {
6068
+ providerPricingUi?.onLineAmountInputChange?.(lineKey, sanitized);
6069
+ }
6070
+ }}
6071
+ onLineAmountInputBlur={(lineKey) => {
6072
+ setEditableSummaryLineAmountInputs((prev) => {
6073
+ const raw = prev[lineKey] ?? '';
6074
+ if (raw.trim() === '' || raw === '-' || raw === '.'
6075
+ || raw === '-.') {
6076
+ return prev;
6077
+ }
6078
+ const parsed = Number(raw);
6079
+ if (!Number.isFinite(parsed)) return prev;
6080
+ const normalized = roundMoney(parsed).toFixed(2);
6081
+ if (normalized === raw) return prev;
6082
+ return { ...prev, [lineKey]: normalized };
6083
+ });
6084
+ if (showProviderPricingInlineEditor) {
6085
+ providerPricingUi?.onLineAmountInputBlur?.(lineKey);
6086
+ }
6087
+ }}
6088
+ onLineAmountReset={(lineKey) => {
6089
+ const original = checkoutPriceSummaryLinesForCheckout.find(
6090
+ (line, index) => getEditableSummaryLineKey(line, index) === lineKey
6091
+ );
6092
+ if (!original) return;
6093
+ const nextValue =
6094
+ original.kind === 'ticket'
6095
+ ? String(roundMoney(original.itemTotal))
6096
+ : String(roundMoney(original.amount));
6097
+ setEditableSummaryLineAmountInputs((prev) => ({ ...prev, [lineKey]: nextValue }));
6098
+ if (showProviderPricingInlineEditor) {
6099
+ providerPricingUi?.onLineAmountReset?.(lineKey);
6100
+ }
6101
+ }}
6102
+ lineLabelInputs={editableSummaryLineLabelInputs}
6103
+ onLineLabelInputChange={(lineKey, value) => {
6104
+ setEditableSummaryLineLabelInputs((prev) => ({ ...prev, [lineKey]: value }));
6105
+ }}
5833
6106
  />
5834
6107
  </>
5835
6108
  )}
@@ -32,6 +32,9 @@ interface CheckoutFormProps {
32
32
  onLineAmountInputChange?: (lineKey: string, value: string) => void;
33
33
  onLineAmountInputBlur?: (lineKey: string) => void;
34
34
  onLineAmountReset?: (lineKey: string) => void;
35
+ /** Optional map + handler for inline editable labels in PriceSummary. */
36
+ lineLabelInputs?: Record<string, string>;
37
+ onLineLabelInputChange?: (lineKey: string, value: string) => void;
35
38
  extraBeforeSubtotal?: ReactNode;
36
39
  // Promo (passed as extraBetweenTaxAndTotal content - we'll keep it in parent for now)
37
40
  // Contact info
@@ -95,6 +98,8 @@ export function CheckoutForm({
95
98
  onLineAmountInputChange,
96
99
  onLineAmountInputBlur,
97
100
  onLineAmountReset,
101
+ lineLabelInputs,
102
+ onLineLabelInputChange,
98
103
  extraBeforeSubtotal,
99
104
  firstName,
100
105
  lastName,
@@ -168,6 +173,8 @@ export function CheckoutForm({
168
173
  onLineAmountInputChange={onLineAmountInputChange}
169
174
  onLineAmountInputBlur={onLineAmountInputBlur}
170
175
  onLineAmountReset={onLineAmountReset}
176
+ lineLabelInputs={lineLabelInputs}
177
+ onLineLabelInputChange={onLineLabelInputChange}
171
178
  extraBeforeSubtotal={extraBeforeSubtotal}
172
179
  />
173
180
  )}
@@ -20,6 +20,8 @@ export interface PriceBreakdownProps {
20
20
  onEditableChange?: (value: string) => void;
21
21
  onEditableBlur?: () => void;
22
22
  onEditableReset?: () => void;
23
+ editableLabelValue?: string;
24
+ onEditableLabelChange?: (value: string) => void;
23
25
  }
24
26
 
25
27
  /**
@@ -65,6 +67,8 @@ export function PriceBreakdown({
65
67
  onEditableChange,
66
68
  onEditableBlur,
67
69
  onEditableReset,
70
+ editableLabelValue,
71
+ onEditableLabelChange,
68
72
  }: PriceBreakdownProps) {
69
73
  const [showTooltip, setShowTooltip] = useState(false);
70
74
  const { t } = useTranslations();
@@ -81,9 +85,19 @@ export function PriceBreakdown({
81
85
  if (!breakdown) {
82
86
  return (
83
87
  <div className="flex items-center justify-between">
84
- <span className="text-sm text-stone-600">
85
- {category} {qty > 1 ? `× ${qty}` : ''}
86
- </span>
88
+ {editable && onEditableLabelChange ? (
89
+ <input
90
+ type="text"
91
+ className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
92
+ value={editableLabelValue ?? category}
93
+ onChange={(e) => onEditableLabelChange(e.target.value)}
94
+ aria-label={`Edit ${category} label`}
95
+ />
96
+ ) : (
97
+ <span className="text-sm text-stone-600">
98
+ {category} {qty > 1 ? `× ${qty}` : ''}
99
+ </span>
100
+ )}
87
101
  {editable && onEditableChange ? (
88
102
  <div className="flex items-center gap-1">
89
103
  {onEditableReset ? (
@@ -118,9 +132,19 @@ export function PriceBreakdown({
118
132
 
119
133
  return (
120
134
  <div className="flex items-center justify-between gap-3 min-w-0">
121
- <span className="text-sm text-stone-600 min-w-0 truncate">
122
- {category} {qty > 1 ? `× ${qty}` : ''}
123
- </span>
135
+ {editable && onEditableLabelChange ? (
136
+ <input
137
+ type="text"
138
+ className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
139
+ value={editableLabelValue ?? category}
140
+ onChange={(e) => onEditableLabelChange(e.target.value)}
141
+ aria-label={`Edit ${category} label`}
142
+ />
143
+ ) : (
144
+ <span className="text-sm text-stone-600 min-w-0 truncate">
145
+ {category} {qty > 1 ? `× ${qty}` : ''}
146
+ </span>
147
+ )}
124
148
  <div className="relative flex-shrink-0 whitespace-nowrap">
125
149
  {editable && onEditableChange ? (
126
150
  <div className="flex items-center gap-1">
@@ -73,8 +73,12 @@ export interface PriceSummaryProps {
73
73
  extraAfterTotal?: ReactNode;
74
74
  /** Optional map of editable line input values by line key. */
75
75
  lineAmountInputs?: Record<string, string>;
76
+ /** Optional map of editable line label values by line key. */
77
+ lineLabelInputs?: Record<string, string>;
76
78
  /** Optional change handler for inline editable line amounts. */
77
79
  onLineAmountInputChange?: (lineKey: string, value: string) => void;
80
+ /** Optional change handler for inline editable line labels. */
81
+ onLineLabelInputChange?: (lineKey: string, value: string) => void;
78
82
  /** Optional blur handler for enforcing display format (e.g. 2 decimals). */
79
83
  onLineAmountInputBlur?: (lineKey: string) => void;
80
84
  /** Optional reset handler for inline editable line amounts. */
@@ -134,7 +138,9 @@ export function PriceSummary({
134
138
  totalLabel,
135
139
  extraAfterTotal,
136
140
  lineAmountInputs,
141
+ lineLabelInputs,
137
142
  onLineAmountInputChange,
143
+ onLineLabelInputChange,
138
144
  onLineAmountInputBlur,
139
145
  onLineAmountReset,
140
146
  }: PriceSummaryProps) {
@@ -161,6 +167,7 @@ export function PriceSummary({
161
167
  return (
162
168
  <div className={`space-y-2 min-w-0 ${className}`}>
163
169
  {lines.map((row, index) => {
170
+ const isBeforeSubtotalBoundary = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
164
171
  if (row.kind === 'ticket') {
165
172
  return (
166
173
  <PriceBreakdown
@@ -171,23 +178,29 @@ export function PriceSummary({
171
178
  breakdown={row.breakdown ?? null}
172
179
  currency={currency}
173
180
  locale={locale}
174
- editable={row.editable}
181
+ editable={Boolean(row.editable && isBeforeSubtotalBoundary)}
175
182
  editableValue={row.lineKey ? lineAmountInputs?.[row.lineKey] : undefined}
176
183
  onEditableChange={
177
- row.editable && row.lineKey && onLineAmountInputChange
184
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountInputChange
178
185
  ? (value) => onLineAmountInputChange(row.lineKey!, value)
179
186
  : undefined
180
187
  }
181
188
  onEditableBlur={
182
- row.editable && row.lineKey && onLineAmountInputBlur
189
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountInputBlur
183
190
  ? () => onLineAmountInputBlur(row.lineKey!)
184
191
  : undefined
185
192
  }
186
193
  onEditableReset={
187
- row.editable && row.lineKey && onLineAmountReset
194
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountReset
188
195
  ? () => onLineAmountReset(row.lineKey!)
189
196
  : undefined
190
197
  }
198
+ editableLabelValue={row.lineKey ? lineLabelInputs?.[row.lineKey] : undefined}
199
+ onEditableLabelChange={
200
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineLabelInputChange
201
+ ? (value) => onLineLabelInputChange(row.lineKey!, value)
202
+ : undefined
203
+ }
191
204
  />
192
205
  );
193
206
  }
@@ -209,10 +222,20 @@ export function PriceSummary({
209
222
  )}
210
223
  <div className={`flex justify-between gap-3 min-w-0 ${textSize}`}>
211
224
  <span className="text-stone-600 min-w-0 flex items-center gap-1">
212
- <span className="min-w-0 truncate">
213
- {label}
214
- {type === 'TICKET' && quantity != null && quantity > 1 ? ` (x${quantity})` : ''}
215
- </span>
225
+ {editable && isBeforeSubtotalBoundary && lineKey && onLineLabelInputChange ? (
226
+ <input
227
+ type="text"
228
+ className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
229
+ value={lineLabelInputs?.[lineKey] ?? label}
230
+ onChange={(e) => onLineLabelInputChange(lineKey, e.target.value)}
231
+ aria-label={`Edit ${label} label`}
232
+ />
233
+ ) : (
234
+ <span className="min-w-0 truncate">
235
+ {label}
236
+ {type === 'TICKET' && quantity != null && quantity > 1 ? ` (x${quantity})` : ''}
237
+ </span>
238
+ )}
216
239
  {tooltip && <InfoTooltip text={tooltip} />}
217
240
  </span>
218
241
  {editable && lineKey && onLineAmountInputChange ? (
@@ -65,6 +65,11 @@ export interface BookingFlowUiOptions {
65
65
  itineraryStickyTopOffsetPx?: number;
66
66
  /** Provider dashboard change flow: quote/override panel shown immediately before confirm CTA. */
67
67
  providerDashboardChangePricingUi?: ProviderDashboardChangePricingUi;
68
+ /**
69
+ * Admin/provider-dashboard change flow: when true, use the admin FE-authoritative quote API.
70
+ * This is a rollout flag; public self-serve must stay on the existing public quote endpoint.
71
+ */
72
+ adminFeAuthoritativeQuoteEnabled?: boolean;
68
73
  }
69
74
 
70
75
  /**
@@ -870,6 +870,30 @@ export interface ChangeBookingQuoteRequest {
870
870
  } | null;
871
871
  }
872
872
 
873
+ /** FE-authored receipt payload for admin quote path (major units, booking currency). */
874
+ export interface AdminFeAuthoritativeReceipt {
875
+ subtotal: number;
876
+ tax: number;
877
+ total: number;
878
+ currency?: string;
879
+ lineItems: Array<{
880
+ label?: string;
881
+ amount?: number;
882
+ type?: string;
883
+ quantity?: number;
884
+ }>;
885
+ }
886
+
887
+ /**
888
+ * Admin quote path where FE is authoritative for receipt lines/totals.
889
+ * Mirrors {@link ChangeBookingQuoteRequest} selection fields and adds a full receipt payload.
890
+ */
891
+ export interface AdminChangeBookingQuoteRequest extends ChangeBookingQuoteRequest {
892
+ feReceipt: AdminFeAuthoritativeReceipt;
893
+ /** Optional explicit signed delta shown by FE (new total − original total), major units. */
894
+ feAmountDueMajorUnits?: number;
895
+ }
896
+
873
897
  export interface ChangeBookingQuoteReceipt {
874
898
  subtotal?: number;
875
899
  tax?: number;
@@ -1108,6 +1132,31 @@ export async function quoteChangeBooking(
1108
1132
  data) as ChangeBookingQuoteResponse;
1109
1133
  }
1110
1134
 
1135
+ /** Admin-only quote endpoint: FE-authored receipt totals + line items are authoritative. */
1136
+ export async function quoteChangeBookingAdminFeReceipt(
1137
+ request: AdminChangeBookingQuoteRequest
1138
+ ): Promise<ChangeBookingQuoteResponse> {
1139
+ const { bookingReference, lastName, ...payload } = request;
1140
+ const res = await fetch(
1141
+ `${API_BASE}/1/admin/bookings/${encodeURIComponent(bookingReference)}/change/quote?lastName=${encodeURIComponent(lastName)}`,
1142
+ {
1143
+ method: 'POST',
1144
+ headers: getAuthHeaders(),
1145
+ body: JSON.stringify(payload),
1146
+ }
1147
+ );
1148
+ if (!res.ok) {
1149
+ const err = await parseJsonSafely(res);
1150
+ const message = isApiErrorPayload(err)
1151
+ ? err.errorMessage || err.error || 'Failed to quote admin booking change'
1152
+ : 'Failed to quote admin booking change';
1153
+ throw new Error(message);
1154
+ }
1155
+ const data = await parseJsonSafely(res);
1156
+ return ((data as { data?: ChangeBookingQuoteResponse } | null)?.data ??
1157
+ data) as ChangeBookingQuoteResponse;
1158
+ }
1159
+
1111
1160
  export async function createChangeBookingPaymentIntent(
1112
1161
  changeIntentId: string
1113
1162
  ): Promise<CreateChangePaymentIntentResponse> {