@ticketboothapp/booking 1.2.75 → 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.75",
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,
@@ -126,6 +128,9 @@ function mergeQuoteSliceWithServerPreview(
126
128
  serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
127
129
  pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
128
130
  ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
131
+ /** Same cent pair the BE uses for `amountDueCents` / intent & receipt "New Booking Difference" — use for signed refund display. */
132
+ quotePreviousTotalCents: quote.previousTotalCents,
133
+ quoteNewTotalCents: quote.newTotalCents,
129
134
  };
130
135
  }
131
136
 
@@ -145,6 +150,26 @@ function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): Pri
145
150
  });
146
151
  }
147
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
+
148
173
  function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
149
174
  const labels: Record<string, string> = {
150
175
  ADULT: 'adult',
@@ -730,6 +755,8 @@ export function AdminChangeBookingFlow({
730
755
  const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<
731
756
  Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
732
757
  >([]);
758
+ const [editableSummaryLineAmountInputs, setEditableSummaryLineAmountInputs] = useState<Record<string, string>>({});
759
+ const [editableSummaryLineLabelInputs, setEditableSummaryLineLabelInputs] = useState<Record<string, string>>({});
733
760
  const adminCustomLineIdRef = useRef(0);
734
761
 
735
762
  // Auto-apply promo code when parent page passes one (e.g. partner pages).
@@ -828,6 +855,10 @@ export function AdminChangeBookingFlow({
828
855
  }
829
856
  }, [initialValues?.dateTime, companyTimezone]);
830
857
  const isProviderDashboardChange = Boolean(onChangeBooking);
858
+ const useAdminFeAuthoritativeQuote =
859
+ isAdmin &&
860
+ isProviderDashboardChange &&
861
+ Boolean(flowUi?.adminFeAuthoritativeQuoteEnabled);
831
862
  /** Any change from an existing booking (public or provider). */
832
863
  const isChangeBookingContext = Boolean(initialValues?.bookingReference?.trim());
833
864
  /**
@@ -852,9 +883,10 @@ export function AdminChangeBookingFlow({
852
883
  const lockedPromoCode = initialValues?.promoCode?.trim()
853
884
  ? initialValues.promoCode.trim().toUpperCase()
854
885
  : null;
855
- /** Public self-serve only: cannot reduce tickets below original counts. */
886
+ /** Public self-serve only: cannot reduce tickets below original counts. Provider-dashboard admins may reduce party size. */
856
887
  const changeBookingMinimumQuantities = useMemo(() => {
857
888
  if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
889
+ if (isAdmin && isProviderDashboardChange) return undefined;
858
890
  const m: Record<string, number> = {};
859
891
  for (const item of initialValues.bookingItems) {
860
892
  const key = item.category?.trim();
@@ -862,7 +894,7 @@ export function AdminChangeBookingFlow({
862
894
  m[key] = Math.max(0, Number(item.count) || 0);
863
895
  }
864
896
  return m;
865
- }, [isCustomerSelfServeChange, initialValues?.bookingItems]);
897
+ }, [isCustomerSelfServeChange, initialValues?.bookingItems, isAdmin, isProviderDashboardChange]);
866
898
  const [adminChoiceData, setAdminChoiceData] = useState<{
867
899
  reservationReference: string;
868
900
  reservationExpiration?: string;
@@ -895,6 +927,9 @@ export function AdminChangeBookingFlow({
895
927
  quotedTotal?: number;
896
928
  /** From `quoteChangeBooking` receipt fields — drives PriceSummary when self-serve. */
897
929
  serverDisplay?: { total: number; subtotal: number; tax: number };
930
+ /** Server quote cents — authoritative vs `serverDisplay` scaling (aligns with receipt difference line). */
931
+ quotePreviousTotalCents?: number;
932
+ quoteNewTotalCents?: number;
898
933
  /** Parsed from last quote — unified server-owned preview for lines + picker overrides. */
899
934
  serverPreview: ReturnType<typeof buildChangeBookingServerPreview>;
900
935
  pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
@@ -3113,6 +3148,140 @@ export function AdminChangeBookingFlow({
3113
3148
  ),
3114
3149
  [checkoutPriceSummaryLinesForCheckout],
3115
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
+ ]);
3116
3285
 
3117
3286
  // Promo discount from backend (order-level only; rates are pre-promo)
3118
3287
  const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
@@ -3687,6 +3856,23 @@ export function AdminChangeBookingFlow({
3687
3856
  providerHasAdditionalAdjustments ||
3688
3857
  Math.abs(adminCustomAdjustmentTotal) >= 0.005;
3689
3858
 
3859
+ /**
3860
+ * True until `quoteChangeBooking` returns a confirmed `serverDisplay` for the current selection.
3861
+ * Named so manual price-line UI can align with “still settling final price” without touching ticket/receipt math.
3862
+ */
3863
+ const changeFlowFinalPricePending =
3864
+ suppressSelfServeCurrencyUi &&
3865
+ selectedAvailability != null &&
3866
+ totalQuantity > 0 &&
3867
+ !selfServePricingConfirmed;
3868
+
3869
+ /**
3870
+ * Provider inline edits + admin custom lines: show while waiting on or after the authoritative quote.
3871
+ * UI-only gate — does not alter ticket line items or receipt-floor rules.
3872
+ */
3873
+ const showChangeFlowManualPriceLines =
3874
+ changeFlowFinalPricePending || selfServePricingConfirmed;
3875
+
3690
3876
  const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
3691
3877
  providerPreview: providerTotalsPreview,
3692
3878
  serverQuotePreview:
@@ -3713,25 +3899,173 @@ export function AdminChangeBookingFlow({
3713
3899
  );
3714
3900
  const displayChangeFlowTax = roundMoney(displayedChangeAmountsRaw.tax + adminTaxDeltaForExternalDisplay);
3715
3901
  const displayChangeFlowProposedTotal = roundMoney(
3716
- displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3902
+ displayLayerUsesExternalPricing
3903
+ ? displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3904
+ : displayedChangeAmountsRaw.total
3717
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
+ ]);
3718
3944
 
3719
- const changeFlowClientEstimateDue = (() => {
3945
+ const changeFlowClientEstimateDueBase = (() => {
3720
3946
  if (!originalReceipt) return totalPrice;
3721
- // Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
3947
+ // Self-serve quote: match BE receipt / intent: (newTotalCents previousTotalCents) / 100.
3722
3948
  if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3949
+ const pq = latestChangeQuote.quotePreviousTotalCents;
3950
+ const nq = latestChangeQuote.quoteNewTotalCents;
3951
+ if (pq != null && nq != null) {
3952
+ return normalizeNearZeroOwed(roundMoney((nq - pq) / 100));
3953
+ }
3954
+ if (latestChangeQuote.serverDisplay != null) {
3955
+ return normalizeNearZeroOwed(
3956
+ roundMoney(latestChangeQuote.serverDisplay.total - originalReceipt.total),
3957
+ );
3958
+ }
3723
3959
  return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
3724
3960
  }
3725
3961
  return changeFlowBalanceVsOriginal({
3726
3962
  newTotal: displayChangeFlowProposedTotal,
3727
3963
  originalReceiptTotal: originalReceipt.total,
3728
- audience: isProviderDashboardChange ? 'provider' : 'customer',
3964
+ audience: 'admin',
3729
3965
  });
3730
3966
  })();
3967
+ const changeFlowClientEstimateDue = normalizeNearZeroOwed(
3968
+ roundMoney(changeFlowClientEstimateDueBase + editableSummaryPreSubtotalDelta)
3969
+ );
3731
3970
 
3732
3971
  const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
3733
3972
  const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
3734
3973
 
3974
+ const changeFlowAdminPricingDebugPanel = useMemo(() => {
3975
+ if (!isAdmin || !selectedAvailability || totalQuantity <= 0) return null;
3976
+ const fmt = (n: number) => formatCurrencyAmount(roundMoney(n), currency, locale as 'en' | 'fr');
3977
+ const path = (() => {
3978
+ if (!originalReceipt) return 'totalPrice (no original receipt)';
3979
+ if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3980
+ if (
3981
+ latestChangeQuote.quotePreviousTotalCents != null &&
3982
+ latestChangeQuote.quoteNewTotalCents != null
3983
+ ) {
3984
+ return 'POST /change/quote: (quoteNewTotalCents − quotePreviousTotalCents) / 100';
3985
+ }
3986
+ if (latestChangeQuote.serverDisplay != null) {
3987
+ return 'POST /change/quote: serverDisplay.total − originalReceipt.total';
3988
+ }
3989
+ return 'POST /change/quote: priceDiff (API balance delta)';
3990
+ }
3991
+ return 'FE: displayChangeFlowProposedTotal − originalReceipt.total (signed; used when quote not driving amount-due)';
3992
+ })();
3993
+ const quoteTotalPreview =
3994
+ latestChangeQuote?.serverDisplay != null ? fmt(latestChangeQuote.serverDisplay.total) : '—';
3995
+ return (
3996
+ <details className="mt-3 rounded-md border border-amber-200/80 bg-amber-50/50 p-2 text-left text-xs text-amber-950">
3997
+ <summary className="cursor-pointer select-none font-medium text-stone-800">
3998
+ Price calculation (admin debug)
3999
+ </summary>
4000
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
4001
+ {[
4002
+ `Amount-due path: ${path}`,
4003
+ `adminFeAuthoritativeQuoteEnabled: ${String(useAdminFeAuthoritativeQuote)}`,
4004
+ `displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
4005
+ `selfServePricingConfirmed: ${String(selfServePricingConfirmed)} · changeQuoteLoading: ${String(changeQuoteLoading)}`,
4006
+ `Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
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
+ ),
4013
+ `Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
4014
+ `effectiveTax · promo discount: ${fmt(effectiveTax)} · ${fmt(effectivePromoDiscountAmount)}`,
4015
+ `totalPrice (subtotal + tax − promo): ${fmt(totalPrice)}`,
4016
+ `changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
4017
+ `Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
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)}`,
4023
+ originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: —',
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
+ }`,
4030
+ `changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
4031
+ `changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
4032
+ ].join('\n')}
4033
+ </pre>
4034
+ </details>
4035
+ );
4036
+ }, [
4037
+ isAdmin,
4038
+ selectedAvailability,
4039
+ totalQuantity,
4040
+ useAdminFeAuthoritativeQuote,
4041
+ currency,
4042
+ locale,
4043
+ originalReceipt,
4044
+ isCustomerSelfServeChange,
4045
+ latestChangeQuote,
4046
+ changeQuoteFetchError,
4047
+ displayLayerUsesExternalPricing,
4048
+ selfServePricingConfirmed,
4049
+ changeQuoteLoading,
4050
+ effectiveSubtotal,
4051
+ adminCustomAdjustmentTotal,
4052
+ editableSummaryPreSubtotalDebugRows,
4053
+ effectiveSubtotalForCheckout,
4054
+ effectiveTax,
4055
+ effectivePromoDiscountAmount,
4056
+ totalPrice,
4057
+ changeFlowNewBookingTotal,
4058
+ displayedChangeAmountsRaw.subtotal,
4059
+ displayedChangeAmountsRaw.tax,
4060
+ displayedChangeAmountsRaw.total,
4061
+ editableSummaryPreSubtotalDelta,
4062
+ displayChangeFlowProposedTotal,
4063
+ displayChangeFlowProposedTotalWithEditableLines,
4064
+ adminFeAuthoritativeReceipt,
4065
+ changeFlowClientEstimateDue,
4066
+ changeFlowAmountDue,
4067
+ ]);
4068
+
3735
4069
  const changeCheckoutButtonLabel = (() => {
3736
4070
  if (!hasEffectiveChangeSelection) return undefined;
3737
4071
  if (isProviderDashboardChange) {
@@ -3759,7 +4093,9 @@ export function AdminChangeBookingFlow({
3759
4093
  const d = Math.round(changeFlowClientEstimateDue * 100) / 100;
3760
4094
  return d > 0
3761
4095
  ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
3762
- : 'Change booking (no charge)';
4096
+ : d < 0
4097
+ ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
4098
+ : 'Change booking (no charge)';
3763
4099
  }
3764
4100
  const tr = t('booking.changeBooking');
3765
4101
  return tr !== 'booking.changeBooking' ? tr : 'Change booking';
@@ -3767,7 +4103,9 @@ export function AdminChangeBookingFlow({
3767
4103
  const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
3768
4104
  return est > 0
3769
4105
  ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3770
- : 'Change booking (no charge)';
4106
+ : est < 0
4107
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
4108
+ : 'Change booking (no charge)';
3771
4109
  })();
3772
4110
  /** Partner deferred-invoice path applies to {@link NewBookingFlow} only. */
3773
4111
  const deferredInvoiceSubmitLabel = undefined;
@@ -3851,7 +4189,7 @@ export function AdminChangeBookingFlow({
3851
4189
  originalReceipt
3852
4190
  ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
3853
4191
  ? null
3854
- : displayChangeFlowProposedTotal
4192
+ : displayChangeFlowProposedTotalWithEditableLines
3855
4193
  : totalPrice,
3856
4194
  selectionCurrency: currency,
3857
4195
  };
@@ -3865,7 +4203,7 @@ export function AdminChangeBookingFlow({
3865
4203
  totalPrice,
3866
4204
  currency,
3867
4205
  originalReceipt,
3868
- displayChangeFlowProposedTotal,
4206
+ displayChangeFlowProposedTotalWithEditableLines,
3869
4207
  suppressSelfServeCurrencyUi,
3870
4208
  selfServePricingConfirmed,
3871
4209
  ]);
@@ -3910,7 +4248,7 @@ export function AdminChangeBookingFlow({
3910
4248
  ? displayChangeFlowTax
3911
4249
  : effectiveTax
3912
4250
  : 0,
3913
- total: originalReceipt ? displayChangeFlowProposedTotal : totalPrice,
4251
+ total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
3914
4252
  currency,
3915
4253
  });
3916
4254
  }, [
@@ -3919,7 +4257,7 @@ export function AdminChangeBookingFlow({
3919
4257
  totalQuantity,
3920
4258
  effectiveSubtotalForCheckout,
3921
4259
  effectiveTax,
3922
- displayChangeFlowProposedTotal,
4260
+ displayChangeFlowProposedTotalWithEditableLines,
3923
4261
  displayChangeFlowSubtotal,
3924
4262
  displayChangeFlowTax,
3925
4263
  currency,
@@ -3971,7 +4309,7 @@ export function AdminChangeBookingFlow({
3971
4309
  .filter(([, count]) => count > 0)
3972
4310
  .map(([category, count]) => ({ category, count }));
3973
4311
  try {
3974
- const quote = await quoteChangeBooking({
4312
+ const quoteRequestBase = {
3975
4313
  bookingReference: bookingReferenceForQuote,
3976
4314
  lastName: lastName.trim(),
3977
4315
  newProductId: optionId,
@@ -3992,7 +4330,14 @@ export function AdminChangeBookingFlow({
3992
4330
  previousAvailabilityId: initialValues.availabilityId ?? null,
3993
4331
  previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
3994
4332
  },
3995
- });
4333
+ };
4334
+ const quote = useAdminFeAuthoritativeQuote
4335
+ ? await quoteChangeBookingAdminFeReceipt({
4336
+ ...quoteRequestBase,
4337
+ feReceipt: adminFeAuthoritativeReceipt,
4338
+ feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4339
+ })
4340
+ : await quoteChangeBooking(quoteRequestBase);
3996
4341
  if (seq !== changeQuoteRequestSeq.current) return;
3997
4342
  const slice = sliceChangeQuoteForUi(
3998
4343
  quote,
@@ -4040,6 +4385,7 @@ export function AdminChangeBookingFlow({
4040
4385
  addOnSelections,
4041
4386
  changeFlowInitialTicketCount,
4042
4387
  changeFlowNewBookingTotal,
4388
+ changeFlowClientEstimateDue,
4043
4389
  effectiveSubtotalForCheckout,
4044
4390
  effectiveTax,
4045
4391
  totalPrice,
@@ -4048,6 +4394,8 @@ export function AdminChangeBookingFlow({
4048
4394
  initialValues?.availabilityId,
4049
4395
  initialValues?.returnAvailabilityId,
4050
4396
  adminCustomLinesAsAdditionalAdjustments,
4397
+ adminFeAuthoritativeReceipt,
4398
+ useAdminFeAuthoritativeQuote,
4051
4399
  ]);
4052
4400
 
4053
4401
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -4568,7 +4916,7 @@ export function AdminChangeBookingFlow({
4568
4916
  if (!changeBookingReference || !changeLastName) {
4569
4917
  throw new Error('Missing booking reference or last name for change quote');
4570
4918
  }
4571
- const quote = await quoteChangeBooking({
4919
+ const quoteRequestBase = {
4572
4920
  bookingReference: changeBookingReference,
4573
4921
  lastName: changeLastName,
4574
4922
  newProductId: availabilityProductOptionId,
@@ -4583,7 +4931,14 @@ export function AdminChangeBookingFlow({
4583
4931
  : {}),
4584
4932
  clientProposedTotal:
4585
4933
  latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
4586
- });
4934
+ };
4935
+ const quote = useAdminFeAuthoritativeQuote
4936
+ ? await quoteChangeBookingAdminFeReceipt({
4937
+ ...quoteRequestBase,
4938
+ feReceipt: adminFeAuthoritativeReceipt,
4939
+ feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4940
+ })
4941
+ : await quoteChangeBooking(quoteRequestBase);
4587
4942
  const quoteSlice = sliceChangeQuoteForUi(
4588
4943
  quote,
4589
4944
  {
@@ -4611,30 +4966,41 @@ export function AdminChangeBookingFlow({
4611
4966
  quote.proposed?.total ??
4612
4967
  quote.newReceipt?.total ??
4613
4968
  changeFlowNewBookingTotal;
4614
- const feChangeDue = changeFlowBalanceVsOriginal({
4615
- newTotal: serverNewTotalForGuard,
4616
- originalReceiptTotal: originalReceipt?.total ?? 0,
4617
- audience: 'customer',
4618
- });
4619
- const serverAmountDue =
4620
- quote.amountDueCents != null
4621
- ? Math.max(0, quote.amountDueCents / 100)
4622
- : Math.max(0, quote.priceDiff ?? 0);
4623
- if (feChangeDue > 0.02 && serverAmountDue <= 0.009) {
4969
+ /** Signed proposed − previous (major units). Matches quote slices when cents present; refund owed < 0. */
4970
+ const signedBalanceMajor =
4971
+ quote.previousTotalCents != null && quote.newTotalCents != null
4972
+ ? (quote.newTotalCents - quote.previousTotalCents) / 100
4973
+ : quote.balanceDeltaMajorUnits ?? null;
4974
+ const chargeDue =
4975
+ signedBalanceMajor != null
4976
+ ? Math.max(0, signedBalanceMajor)
4977
+ : quote.amountDueCents != null
4978
+ ? quote.amountDueCents / 100
4979
+ : Math.max(0, quote.priceDiff ?? 0);
4980
+ const feChangeDue =
4981
+ signedBalanceMajor ??
4982
+ changeFlowBalanceVsOriginal({
4983
+ newTotal: serverNewTotalForGuard,
4984
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4985
+ audience: 'admin',
4986
+ });
4987
+ if (feChangeDue > 0.02 && chargeDue <= 0.009) {
4624
4988
  throw new Error(
4625
4989
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4626
4990
  );
4627
4991
  }
4628
- // No-payment change: FE shows nothing owed still require server agreement so we never confirm free when a charge is due.
4629
- if (serverAmountDue <= 0.009) {
4992
+ // No additional charge (includes refund-owed / downgrade): confirm-free apply only when server agrees no payment due.
4993
+ if (chargeDue <= 0.009) {
4630
4994
  if (feChangeDue > 0.02) {
4631
4995
  throw new Error(
4632
4996
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4633
4997
  );
4634
4998
  }
4635
- const p = quote.proposed?.total ?? quote.newReceipt?.total;
4636
- const o = quote.original?.total ?? quote.originalReceipt?.total;
4637
- if (p != null && o != null && p - o > 0.01) {
4999
+ const upgradeWithoutCharge =
5000
+ quote.newTotalCents != null &&
5001
+ quote.previousTotalCents != null &&
5002
+ quote.newTotalCents > quote.previousTotalCents + 1;
5003
+ if (upgradeWithoutCharge) {
4638
5004
  throw new Error(
4639
5005
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4640
5006
  );
@@ -4686,11 +5052,14 @@ export function AdminChangeBookingFlow({
4686
5052
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
4687
5053
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
4688
5054
  const amountDueForCheckout = isCustomerSelfServeChange
4689
- ? changeFlowBalanceVsOriginal({
4690
- newTotal: changeFlowNewBookingTotal,
4691
- originalReceiptTotal: originalReceipt?.total ?? 0,
4692
- audience: 'customer',
4693
- })
5055
+ ? Math.max(
5056
+ 0,
5057
+ changeFlowBalanceVsOriginal({
5058
+ newTotal: changeFlowNewBookingTotal,
5059
+ originalReceiptTotal: originalReceipt?.total ?? 0,
5060
+ audience: 'admin',
5061
+ }),
5062
+ )
4694
5063
  : totalPrice;
4695
5064
  const lines = [
4696
5065
  ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
@@ -4923,7 +5292,7 @@ export function AdminChangeBookingFlow({
4923
5292
  isCustomerSelfServeChange && originalReceipt
4924
5293
  ? {
4925
5294
  previousTotal: originalReceipt.total,
4926
- newTotal: displayChangeFlowProposedTotal,
5295
+ newTotal: displayChangeFlowProposedTotalWithEditableLines,
4927
5296
  differenceTotal: amountDueForCheckout,
4928
5297
  }
4929
5298
  : undefined,
@@ -5029,7 +5398,7 @@ export function AdminChangeBookingFlow({
5029
5398
  addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
5030
5399
  cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
5031
5400
  promoCode: appliedPromoCode ?? null,
5032
- newTotalAmount: displayChangeFlowProposedTotal,
5401
+ newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
5033
5402
  additionalHoursCount: null,
5034
5403
  pricingAdjustment:
5035
5404
  providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
@@ -5402,14 +5771,16 @@ export function AdminChangeBookingFlow({
5402
5771
  {selectedAvailability && (
5403
5772
  <>
5404
5773
  <CheckoutForm
5405
- priceSummaryLines={checkoutPriceSummaryLinesForCheckout}
5774
+ priceSummaryLines={editableCheckoutPriceSummaryLines}
5406
5775
  replacePriceSummary={selfServeCheckoutPlaceholder}
5407
5776
  totalPrice={changeFlowAmountDue}
5408
5777
  totalSummaryLabel={
5409
- t('booking.totalOwedForBookingChange') &&
5410
- t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
5411
- ? t('booking.totalOwedForBookingChange')
5412
- : 'Total owed for booking difference'
5778
+ changeFlowAmountDue < -0.005
5779
+ ? 'Refund owed (vs original booking)'
5780
+ : t('booking.totalOwedForBookingChange') &&
5781
+ t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
5782
+ ? t('booking.totalOwedForBookingChange')
5783
+ : 'Total owed for booking difference'
5413
5784
  }
5414
5785
  subtotal={displayChangeFlowSubtotal}
5415
5786
  taxAmount={
@@ -5438,9 +5809,11 @@ export function AdminChangeBookingFlow({
5438
5809
  !providerPricingUi.error ? (
5439
5810
  <div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
5440
5811
  ) : null}
5812
+ {changeFlowAdminPricingDebugPanel}
5441
5813
  </>
5442
5814
  }
5443
5815
  extraBeforeSubtotal={
5816
+ showChangeFlowManualPriceLines ? (
5444
5817
  <>
5445
5818
  {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5446
5819
  <div className="space-y-1">
@@ -5623,6 +5996,7 @@ export function AdminChangeBookingFlow({
5623
5996
  })}
5624
5997
  </div>
5625
5998
  </>
5999
+ ) : null
5626
6000
  }
5627
6001
  firstName={firstName}
5628
6002
  lastName={lastName}
@@ -5686,16 +6060,49 @@ export function AdminChangeBookingFlow({
5686
6060
  attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
5687
6061
  attributionConfirmed={partnerAttributionConfirmed}
5688
6062
  onAttributionConfirmedChange={setPartnerAttributionConfirmed}
5689
- lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
5690
- onLineAmountInputChange={
5691
- showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
5692
- }
5693
- onLineAmountInputBlur={
5694
- showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
5695
- }
5696
- onLineAmountReset={
5697
- showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
5698
- }
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
+ }}
5699
6106
  />
5700
6107
  </>
5701
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,
@@ -146,6 +151,7 @@ export function CheckoutForm({
146
151
  <div className={styles.summaryWrapper}>
147
152
  {replacePriceSummary ? (
148
153
  <>
154
+ {extraBeforeSubtotal ? <div className="mb-4 min-w-0">{extraBeforeSubtotal}</div> : null}
149
155
  {replacePriceSummary}
150
156
  <div className="mt-4">{extraBetweenTaxAndTotal}</div>
151
157
  </>
@@ -167,6 +173,8 @@ export function CheckoutForm({
167
173
  onLineAmountInputChange={onLineAmountInputChange}
168
174
  onLineAmountInputBlur={onLineAmountInputBlur}
169
175
  onLineAmountReset={onLineAmountReset}
176
+ lineLabelInputs={lineLabelInputs}
177
+ onLineLabelInputChange={onLineLabelInputChange}
170
178
  extraBeforeSubtotal={extraBeforeSubtotal}
171
179
  />
172
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
  /**
@@ -178,16 +178,16 @@ export function resolveChangeFlowNewBookingTotal(input: {
178
178
 
179
179
  /**
180
180
  * Product: **What the customer owes** for the difference is `max(0, newTotal − oldReceipt)`.
181
- * **Provider dashboard** may show a signed delta (credits/refunds as negative).
181
+ * **Provider dashboard** and **admin change booking** may show a signed delta (refund owed as negative).
182
182
  */
183
183
  export function changeFlowBalanceVsOriginal(input: {
184
184
  newTotal: number;
185
185
  originalReceiptTotal: number;
186
- /** `customer` = self-serve & admin change; `provider` = provider inline editor (can owe the guest). */
187
- audience: 'customer' | 'provider';
186
+ /** `customer` = self-serve change only; `provider` | `admin` = signed newTotal original receipt total. */
187
+ audience: 'customer' | 'provider' | 'admin';
188
188
  }): number {
189
189
  const delta = input.newTotal - input.originalReceiptTotal;
190
- return input.audience === 'provider' ? delta : Math.max(0, delta);
190
+ return input.audience === 'customer' ? Math.max(0, delta) : delta;
191
191
  }
192
192
 
193
193
  /**
@@ -255,8 +255,13 @@ export function sliceChangeQuoteForUi(
255
255
  fallbackCart: { total: number; subtotal: number; tax: number },
256
256
  currencyFallback: string
257
257
  ): ChangeQuoteUiSlice {
258
+ /** Staff JWT on quote: signed new − previous (refund owed negative). Otherwise same as legacy nonnegative amount-due. */
258
259
  const priceDiff =
259
- quote.amountDueCents != null ? quote.amountDueCents / 100 : quote.priceDiff ?? 0;
260
+ quote.balanceDeltaMajorUnits != null
261
+ ? quote.balanceDeltaMajorUnits
262
+ : quote.amountDueCents != null
263
+ ? quote.amountDueCents / 100
264
+ : quote.priceDiff ?? 0;
260
265
  const serverDisplay = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
261
266
  return {
262
267
  priceDiff,
@@ -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;
@@ -987,6 +1011,11 @@ export interface ChangeBookingQuoteResponse {
987
1011
  originalReceipt?: ChangeBookingQuoteReceipt;
988
1012
  newReceipt?: ChangeBookingQuoteReceipt;
989
1013
  priceDiff: number;
1014
+ /**
1015
+ * Present when the quote was requested with a **staff** JWT: signed (new total − previous receipt total) in major units.
1016
+ * Negative = refund owed to the guest. Omitted for unauthenticated / customer-only quotes.
1017
+ */
1018
+ balanceDeltaMajorUnits?: number;
990
1019
  currency?: string;
991
1020
  canProceed?: boolean;
992
1021
  reasonIfBlocked?: string;
@@ -1103,6 +1132,31 @@ export async function quoteChangeBooking(
1103
1132
  data) as ChangeBookingQuoteResponse;
1104
1133
  }
1105
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
+
1106
1160
  export async function createChangeBookingPaymentIntent(
1107
1161
  changeIntentId: string
1108
1162
  ): Promise<CreateChangePaymentIntentResponse> {