@ticketboothapp/booking 1.2.76 → 1.2.78

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.78",
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,
@@ -102,6 +104,7 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
102
104
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
103
105
  import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
104
106
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
107
+ import { useEditableSummaryLines } from './useEditableSummaryLines';
105
108
 
106
109
  /**
107
110
  * ## Pricing contract (customer self-serve)
@@ -148,6 +151,26 @@ function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): Pri
148
151
  });
149
152
  }
150
153
 
154
+ function mapSummaryLinesToFeReceiptLineItems(lines: PriceSummaryLine[]): NonNullable<AdminFeAuthoritativeReceipt['lineItems']> {
155
+ return lines.map((line) => {
156
+ if (line.kind === 'ticket') {
157
+ return {
158
+ label: line.category,
159
+ amount: roundMoney(line.itemTotal),
160
+ type: 'TICKET',
161
+ quantity: line.qty,
162
+ };
163
+ }
164
+ const normalizedType = String(line.type ?? '').trim().toUpperCase();
165
+ return {
166
+ label: line.label,
167
+ amount: roundMoney(line.amount),
168
+ type: normalizedType || undefined,
169
+ quantity: line.quantity ?? undefined,
170
+ };
171
+ });
172
+ }
173
+
151
174
  function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
152
175
  const labels: Record<string, string> = {
153
176
  ADULT: 'adult',
@@ -831,6 +854,10 @@ export function AdminChangeBookingFlow({
831
854
  }
832
855
  }, [initialValues?.dateTime, companyTimezone]);
833
856
  const isProviderDashboardChange = Boolean(onChangeBooking);
857
+ const useAdminFeAuthoritativeQuote =
858
+ isAdmin &&
859
+ isProviderDashboardChange &&
860
+ Boolean(flowUi?.adminFeAuthoritativeQuoteEnabled);
834
861
  /** Any change from an existing booking (public or provider). */
835
862
  const isChangeBookingContext = Boolean(initialValues?.bookingReference?.trim());
836
863
  /**
@@ -3120,6 +3147,24 @@ export function AdminChangeBookingFlow({
3120
3147
  ),
3121
3148
  [checkoutPriceSummaryLinesForCheckout],
3122
3149
  );
3150
+ const {
3151
+ editableCheckoutPriceSummaryLines,
3152
+ editableSummaryLineAmountInputs,
3153
+ editableSummaryLineLabelInputs,
3154
+ editableSummaryPreSubtotalDelta,
3155
+ editableSummaryPreSubtotalDebugRows,
3156
+ onLineAmountInputChange: handleEditableLineAmountInputChange,
3157
+ onLineAmountInputBlur: handleEditableLineAmountInputBlur,
3158
+ onLineAmountReset: handleEditableLineAmountReset,
3159
+ } = useEditableSummaryLines(
3160
+ checkoutPriceSummaryLinesForCheckout,
3161
+ {
3162
+ enabled: showProviderPricingInlineEditor,
3163
+ onChange: providerPricingUi?.onLineAmountInputChange,
3164
+ onBlur: providerPricingUi?.onLineAmountInputBlur,
3165
+ onReset: providerPricingUi?.onLineAmountReset,
3166
+ },
3167
+ );
3123
3168
 
3124
3169
  // Promo discount from backend (order-level only; rates are pre-promo)
3125
3170
  const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
@@ -3709,7 +3754,11 @@ export function AdminChangeBookingFlow({
3709
3754
  * UI-only gate — does not alter ticket line items or receipt-floor rules.
3710
3755
  */
3711
3756
  const showChangeFlowManualPriceLines =
3712
- changeFlowFinalPricePending || selfServePricingConfirmed;
3757
+ hasEffectiveChangeSelection && (changeFlowFinalPricePending || selfServePricingConfirmed);
3758
+ const showAdminCustomLineEditor =
3759
+ isAdmin &&
3760
+ selectedAvailability != null &&
3761
+ totalQuantity > 0;
3713
3762
 
3714
3763
  const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
3715
3764
  providerPreview: providerTotalsPreview,
@@ -3741,8 +3790,46 @@ export function AdminChangeBookingFlow({
3741
3790
  ? displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3742
3791
  : displayedChangeAmountsRaw.total
3743
3792
  );
3793
+ const displayChangeFlowProposedTotalWithEditableLines = roundMoney(
3794
+ displayChangeFlowProposedTotal + editableSummaryPreSubtotalDelta
3795
+ );
3796
+ const adminFeAuthoritativeReceipt = useMemo((): AdminFeAuthoritativeReceipt => {
3797
+ const hasTaxLine = editableCheckoutPriceSummaryLines.some(
3798
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
3799
+ );
3800
+ const lineItems = mapSummaryLinesToFeReceiptLineItems(editableCheckoutPriceSummaryLines);
3801
+ if (!hasTaxLine && Math.abs(displayChangeFlowTax) >= 0.0005) {
3802
+ lineItems.push({
3803
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
3804
+ amount: roundMoney(displayChangeFlowTax),
3805
+ type: 'TAX',
3806
+ });
3807
+ }
3808
+ let subtotal = roundMoney(displayChangeFlowSubtotal + editableSummaryPreSubtotalDelta);
3809
+ const tax = roundMoney(displayChangeFlowTax);
3810
+ const expectedTotal = roundMoney(displayChangeFlowProposedTotalWithEditableLines);
3811
+ const recomputed = roundMoney(subtotal + tax);
3812
+ if (Math.abs(recomputed - expectedTotal) >= 0.005) {
3813
+ subtotal = roundMoney(expectedTotal - tax);
3814
+ }
3815
+ return {
3816
+ subtotal,
3817
+ tax,
3818
+ total: expectedTotal,
3819
+ currency,
3820
+ lineItems,
3821
+ };
3822
+ }, [
3823
+ editableCheckoutPriceSummaryLines,
3824
+ displayChangeFlowTax,
3825
+ displayChangeFlowSubtotal,
3826
+ editableSummaryPreSubtotalDelta,
3827
+ displayChangeFlowProposedTotalWithEditableLines,
3828
+ currency,
3829
+ t,
3830
+ ]);
3744
3831
 
3745
- const changeFlowClientEstimateDue = (() => {
3832
+ const changeFlowClientEstimateDueBase = (() => {
3746
3833
  if (!originalReceipt) return totalPrice;
3747
3834
  // Self-serve quote: match BE receipt / intent: (newTotalCents − previousTotalCents) / 100.
3748
3835
  if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
@@ -3764,6 +3851,9 @@ export function AdminChangeBookingFlow({
3764
3851
  audience: 'admin',
3765
3852
  });
3766
3853
  })();
3854
+ const changeFlowClientEstimateDue = normalizeNearZeroOwed(
3855
+ roundMoney(changeFlowClientEstimateDueBase + editableSummaryPreSubtotalDelta)
3856
+ );
3767
3857
 
3768
3858
  const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
3769
3859
  const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
@@ -3797,18 +3887,33 @@ export function AdminChangeBookingFlow({
3797
3887
  <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
3798
3888
  {[
3799
3889
  `Amount-due path: ${path}`,
3890
+ `adminFeAuthoritativeQuoteEnabled: ${String(useAdminFeAuthoritativeQuote)}`,
3800
3891
  `displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
3801
3892
  `selfServePricingConfirmed: ${String(selfServePricingConfirmed)} · changeQuoteLoading: ${String(changeQuoteLoading)}`,
3802
3893
  `Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
3803
3894
  `Admin custom lines (sum): ${fmt(adminCustomAdjustmentTotal)}`,
3895
+ `Pre-subtotal line overrides:`,
3896
+ ...editableSummaryPreSubtotalDebugRows.map(
3897
+ (row, idx) =>
3898
+ ` ${idx + 1}. ${row.label} | baseline ${fmt(row.baseline)} -> edited ${fmt(row.edited)} (delta ${fmt(row.delta)})`
3899
+ ),
3804
3900
  `Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
3805
3901
  `effectiveTax · promo discount: ${fmt(effectiveTax)} · ${fmt(effectivePromoDiscountAmount)}`,
3806
3902
  `totalPrice (subtotal + tax − promo): ${fmt(totalPrice)}`,
3807
3903
  `changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
3808
3904
  `Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
3809
- `displayChangeFlowProposedTotal (summary / preview footer): ${fmt(displayChangeFlowProposedTotal)}`,
3905
+ `Pre-subtotal editable lines delta: ${fmt(editableSummaryPreSubtotalDelta)}`,
3906
+ `displayChangeFlowProposedTotal (base): ${fmt(displayChangeFlowProposedTotal)}`,
3907
+ `displayChangeFlowProposedTotal (with editable lines): ${fmt(displayChangeFlowProposedTotalWithEditableLines)}`,
3908
+ `feReceipt sent subtotal/tax/total: ${fmt(adminFeAuthoritativeReceipt.subtotal)} / ${fmt(adminFeAuthoritativeReceipt.tax)} / ${fmt(adminFeAuthoritativeReceipt.total)}`,
3909
+ `feReceipt sent lineItems count: ${String(adminFeAuthoritativeReceipt.lineItems.length)}`,
3810
3910
  originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: —',
3811
3911
  `Quote serverDisplay.total (if any): ${quoteTotalPreview}`,
3912
+ `Quote serverDisplay subtotal/tax/total: ${
3913
+ latestChangeQuote?.serverDisplay
3914
+ ? `${fmt(latestChangeQuote.serverDisplay.subtotal)} / ${fmt(latestChangeQuote.serverDisplay.tax)} / ${fmt(latestChangeQuote.serverDisplay.total)}`
3915
+ : '—'
3916
+ }`,
3812
3917
  `changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
3813
3918
  `changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
3814
3919
  ].join('\n')}
@@ -3819,6 +3924,7 @@ export function AdminChangeBookingFlow({
3819
3924
  isAdmin,
3820
3925
  selectedAvailability,
3821
3926
  totalQuantity,
3927
+ useAdminFeAuthoritativeQuote,
3822
3928
  currency,
3823
3929
  locale,
3824
3930
  originalReceipt,
@@ -3830,6 +3936,7 @@ export function AdminChangeBookingFlow({
3830
3936
  changeQuoteLoading,
3831
3937
  effectiveSubtotal,
3832
3938
  adminCustomAdjustmentTotal,
3939
+ editableSummaryPreSubtotalDebugRows,
3833
3940
  effectiveSubtotalForCheckout,
3834
3941
  effectiveTax,
3835
3942
  effectivePromoDiscountAmount,
@@ -3838,7 +3945,10 @@ export function AdminChangeBookingFlow({
3838
3945
  displayedChangeAmountsRaw.subtotal,
3839
3946
  displayedChangeAmountsRaw.tax,
3840
3947
  displayedChangeAmountsRaw.total,
3948
+ editableSummaryPreSubtotalDelta,
3841
3949
  displayChangeFlowProposedTotal,
3950
+ displayChangeFlowProposedTotalWithEditableLines,
3951
+ adminFeAuthoritativeReceipt,
3842
3952
  changeFlowClientEstimateDue,
3843
3953
  changeFlowAmountDue,
3844
3954
  ]);
@@ -3966,7 +4076,7 @@ export function AdminChangeBookingFlow({
3966
4076
  originalReceipt
3967
4077
  ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
3968
4078
  ? null
3969
- : displayChangeFlowProposedTotal
4079
+ : displayChangeFlowProposedTotalWithEditableLines
3970
4080
  : totalPrice,
3971
4081
  selectionCurrency: currency,
3972
4082
  };
@@ -3980,7 +4090,7 @@ export function AdminChangeBookingFlow({
3980
4090
  totalPrice,
3981
4091
  currency,
3982
4092
  originalReceipt,
3983
- displayChangeFlowProposedTotal,
4093
+ displayChangeFlowProposedTotalWithEditableLines,
3984
4094
  suppressSelfServeCurrencyUi,
3985
4095
  selfServePricingConfirmed,
3986
4096
  ]);
@@ -4018,14 +4128,14 @@ export function AdminChangeBookingFlow({
4018
4128
  return;
4019
4129
  }
4020
4130
  onPricePreviewChange({
4021
- subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotalForCheckout,
4131
+ subtotal: originalReceipt ? adminFeAuthoritativeReceipt.subtotal : effectiveSubtotalForCheckout,
4022
4132
  tax:
4023
4133
  !isTaxIncludedInPrice
4024
4134
  ? originalReceipt
4025
- ? displayChangeFlowTax
4135
+ ? adminFeAuthoritativeReceipt.tax
4026
4136
  : effectiveTax
4027
4137
  : 0,
4028
- total: originalReceipt ? displayChangeFlowProposedTotal : totalPrice,
4138
+ total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
4029
4139
  currency,
4030
4140
  });
4031
4141
  }, [
@@ -4034,9 +4144,9 @@ export function AdminChangeBookingFlow({
4034
4144
  totalQuantity,
4035
4145
  effectiveSubtotalForCheckout,
4036
4146
  effectiveTax,
4037
- displayChangeFlowProposedTotal,
4038
- displayChangeFlowSubtotal,
4039
- displayChangeFlowTax,
4147
+ displayChangeFlowProposedTotalWithEditableLines,
4148
+ adminFeAuthoritativeReceipt.subtotal,
4149
+ adminFeAuthoritativeReceipt.tax,
4040
4150
  currency,
4041
4151
  isTaxIncludedInPrice,
4042
4152
  originalReceipt,
@@ -4086,7 +4196,7 @@ export function AdminChangeBookingFlow({
4086
4196
  .filter(([, count]) => count > 0)
4087
4197
  .map(([category, count]) => ({ category, count }));
4088
4198
  try {
4089
- const quote = await quoteChangeBooking({
4199
+ const quoteRequestBase = {
4090
4200
  bookingReference: bookingReferenceForQuote,
4091
4201
  lastName: lastName.trim(),
4092
4202
  newProductId: optionId,
@@ -4107,7 +4217,14 @@ export function AdminChangeBookingFlow({
4107
4217
  previousAvailabilityId: initialValues.availabilityId ?? null,
4108
4218
  previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
4109
4219
  },
4110
- });
4220
+ };
4221
+ const quote = useAdminFeAuthoritativeQuote
4222
+ ? await quoteChangeBookingAdminFeReceipt({
4223
+ ...quoteRequestBase,
4224
+ feReceipt: adminFeAuthoritativeReceipt,
4225
+ feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4226
+ })
4227
+ : await quoteChangeBooking(quoteRequestBase);
4111
4228
  if (seq !== changeQuoteRequestSeq.current) return;
4112
4229
  const slice = sliceChangeQuoteForUi(
4113
4230
  quote,
@@ -4155,6 +4272,7 @@ export function AdminChangeBookingFlow({
4155
4272
  addOnSelections,
4156
4273
  changeFlowInitialTicketCount,
4157
4274
  changeFlowNewBookingTotal,
4275
+ changeFlowClientEstimateDue,
4158
4276
  effectiveSubtotalForCheckout,
4159
4277
  effectiveTax,
4160
4278
  totalPrice,
@@ -4163,6 +4281,8 @@ export function AdminChangeBookingFlow({
4163
4281
  initialValues?.availabilityId,
4164
4282
  initialValues?.returnAvailabilityId,
4165
4283
  adminCustomLinesAsAdditionalAdjustments,
4284
+ adminFeAuthoritativeReceipt,
4285
+ useAdminFeAuthoritativeQuote,
4166
4286
  ]);
4167
4287
 
4168
4288
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -4683,7 +4803,7 @@ export function AdminChangeBookingFlow({
4683
4803
  if (!changeBookingReference || !changeLastName) {
4684
4804
  throw new Error('Missing booking reference or last name for change quote');
4685
4805
  }
4686
- const quote = await quoteChangeBooking({
4806
+ const quoteRequestBase = {
4687
4807
  bookingReference: changeBookingReference,
4688
4808
  lastName: changeLastName,
4689
4809
  newProductId: availabilityProductOptionId,
@@ -4698,7 +4818,14 @@ export function AdminChangeBookingFlow({
4698
4818
  : {}),
4699
4819
  clientProposedTotal:
4700
4820
  latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
4701
- });
4821
+ };
4822
+ const quote = useAdminFeAuthoritativeQuote
4823
+ ? await quoteChangeBookingAdminFeReceipt({
4824
+ ...quoteRequestBase,
4825
+ feReceipt: adminFeAuthoritativeReceipt,
4826
+ feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4827
+ })
4828
+ : await quoteChangeBooking(quoteRequestBase);
4702
4829
  const quoteSlice = sliceChangeQuoteForUi(
4703
4830
  quote,
4704
4831
  {
@@ -5052,7 +5179,7 @@ export function AdminChangeBookingFlow({
5052
5179
  isCustomerSelfServeChange && originalReceipt
5053
5180
  ? {
5054
5181
  previousTotal: originalReceipt.total,
5055
- newTotal: displayChangeFlowProposedTotal,
5182
+ newTotal: displayChangeFlowProposedTotalWithEditableLines,
5056
5183
  differenceTotal: amountDueForCheckout,
5057
5184
  }
5058
5185
  : undefined,
@@ -5158,7 +5285,7 @@ export function AdminChangeBookingFlow({
5158
5285
  addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
5159
5286
  cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
5160
5287
  promoCode: appliedPromoCode ?? null,
5161
- newTotalAmount: displayChangeFlowProposedTotal,
5288
+ newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
5162
5289
  additionalHoursCount: null,
5163
5290
  pricingAdjustment:
5164
5291
  providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
@@ -5531,8 +5658,8 @@ export function AdminChangeBookingFlow({
5531
5658
  {selectedAvailability && (
5532
5659
  <>
5533
5660
  <CheckoutForm
5534
- priceSummaryLines={checkoutPriceSummaryLinesForCheckout}
5535
- replacePriceSummary={selfServeCheckoutPlaceholder}
5661
+ priceSummaryLines={editableCheckoutPriceSummaryLines}
5662
+ replacePriceSummary={hasChangeSelection ? selfServeCheckoutPlaceholder : undefined}
5536
5663
  totalPrice={changeFlowAmountDue}
5537
5664
  totalSummaryLabel={
5538
5665
  changeFlowAmountDue < -0.005
@@ -5542,12 +5669,12 @@ export function AdminChangeBookingFlow({
5542
5669
  ? t('booking.totalOwedForBookingChange')
5543
5670
  : 'Total owed for booking difference'
5544
5671
  }
5545
- subtotal={displayChangeFlowSubtotal}
5672
+ subtotal={adminFeAuthoritativeReceipt.subtotal}
5546
5673
  taxAmount={
5547
5674
  !isTaxIncludedInPrice &&
5548
- displayChangeFlowTax > 0 &&
5675
+ adminFeAuthoritativeReceipt.tax > 0 &&
5549
5676
  !priceSummaryLinesIncludeTaxRow
5550
- ? displayChangeFlowTax
5677
+ ? adminFeAuthoritativeReceipt.tax
5551
5678
  : 0
5552
5679
  }
5553
5680
  taxRate={pricingConfig?.taxRate}
@@ -5573,9 +5700,11 @@ export function AdminChangeBookingFlow({
5573
5700
  </>
5574
5701
  }
5575
5702
  extraBeforeSubtotal={
5576
- showChangeFlowManualPriceLines ? (
5703
+ (showChangeFlowManualPriceLines || showAdminCustomLineEditor) ? (
5577
5704
  <>
5578
- {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5705
+ {showChangeFlowManualPriceLines &&
5706
+ showProviderPricingInlineEditor &&
5707
+ (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5579
5708
  <div className="space-y-1">
5580
5709
  {providerPricingUi?.additionalAdjustments?.map((adj) => (
5581
5710
  <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
@@ -5589,7 +5718,7 @@ export function AdminChangeBookingFlow({
5589
5718
  </button>
5590
5719
  <input
5591
5720
  type="text"
5592
- className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5721
+ className="admin-custom-receipt-input w-40 rounded border border-stone-300 bg-white text-sm text-stone-800 placeholder:text-stone-400"
5593
5722
  placeholder="Line description"
5594
5723
  value={adj.label}
5595
5724
  onChange={(e) =>
@@ -5613,7 +5742,7 @@ export function AdminChangeBookingFlow({
5613
5742
  <input
5614
5743
  type="text"
5615
5744
  inputMode="decimal"
5616
- className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
5745
+ className="admin-custom-receipt-input-amount w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
5617
5746
  placeholder="0.00"
5618
5747
  value={adj.amountInput}
5619
5748
  onChange={(e) =>
@@ -5633,7 +5762,7 @@ export function AdminChangeBookingFlow({
5633
5762
  + add line item
5634
5763
  </button>
5635
5764
  </div>
5636
- ) : showProviderPricingInlineEditor ? (
5765
+ ) : showChangeFlowManualPriceLines && showProviderPricingInlineEditor ? (
5637
5766
  <button
5638
5767
  type="button"
5639
5768
  className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
@@ -5642,6 +5771,7 @@ export function AdminChangeBookingFlow({
5642
5771
  + add line item
5643
5772
  </button>
5644
5773
  ) : null}
5774
+ {showAdminCustomLineEditor ? (
5645
5775
  <div className="space-y-3">
5646
5776
  <div className="flex justify-center">
5647
5777
  <button
@@ -5755,6 +5885,7 @@ export function AdminChangeBookingFlow({
5755
5885
  );
5756
5886
  })}
5757
5887
  </div>
5888
+ ) : null}
5758
5889
  </>
5759
5890
  ) : null
5760
5891
  }
@@ -5820,16 +5951,11 @@ export function AdminChangeBookingFlow({
5820
5951
  attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
5821
5952
  attributionConfirmed={partnerAttributionConfirmed}
5822
5953
  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
- }
5954
+ lineAmountInputs={editableSummaryLineAmountInputs}
5955
+ onLineAmountInputChange={handleEditableLineAmountInputChange}
5956
+ onLineAmountInputBlur={handleEditableLineAmountInputBlur}
5957
+ onLineAmountReset={handleEditableLineAmountReset}
5958
+ lineLabelInputs={editableSummaryLineLabelInputs}
5833
5959
  />
5834
5960
  </>
5835
5961
  )}
@@ -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 ? (
@@ -101,7 +115,7 @@ export function PriceBreakdown({
101
115
  <input
102
116
  type="text"
103
117
  inputMode="decimal"
104
- className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
118
+ className="admin-editable-summary-amount-input w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
105
119
  value={editableValue ?? String(itemTotal)}
106
120
  onChange={(e) => onEditableChange(e.target.value)}
107
121
  onBlur={onEditableBlur}
@@ -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">
@@ -139,7 +163,7 @@ export function PriceBreakdown({
139
163
  <input
140
164
  type="text"
141
165
  inputMode="decimal"
142
- className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
166
+ className="admin-editable-summary-amount-input w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
143
167
  value={editableValue ?? String(itemTotal)}
144
168
  onChange={(e) => onEditableChange(e.target.value)}
145
169
  onBlur={onEditableBlur}
@@ -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. */
@@ -102,6 +106,24 @@ function formatLineAmount(
102
106
  return formatted;
103
107
  }
104
108
 
109
+ function shouldAppendQuantitySuffix(type: string | undefined, label: string, quantity: number | null | undefined): boolean {
110
+ if (quantity == null || quantity <= 1) return false;
111
+ const normalizedType = String(type ?? '').trim().toUpperCase();
112
+ const eligibleType =
113
+ normalizedType === 'TICKET' ||
114
+ normalizedType === 'FEE' ||
115
+ normalizedType === 'RETURN_OPTION' ||
116
+ normalizedType === 'RETURNOPTION' ||
117
+ normalizedType === 'RETURN';
118
+ if (!eligibleType) return false;
119
+ const normalizedLabel = label.toLowerCase();
120
+ const alreadyHasCountHint =
121
+ /\bx\s*\d+\b/.test(normalizedLabel) ||
122
+ /\(\s*x\s*\d+\s*\)/.test(normalizedLabel) ||
123
+ /\b\d+\s*(person|people|pax|traveler|travellers|ticket|tickets)\b/.test(normalizedLabel);
124
+ return !alreadyHasCountHint;
125
+ }
126
+
105
127
  function getCurrencySymbol(currency: Currency, locale: Locale): string {
106
128
  return formatCurrencyAmount(0, currency, locale).replace(/[0-9\s,.-]/g, '') || currency;
107
129
  }
@@ -134,7 +156,9 @@ export function PriceSummary({
134
156
  totalLabel,
135
157
  extraAfterTotal,
136
158
  lineAmountInputs,
159
+ lineLabelInputs,
137
160
  onLineAmountInputChange,
161
+ onLineLabelInputChange,
138
162
  onLineAmountInputBlur,
139
163
  onLineAmountReset,
140
164
  }: PriceSummaryProps) {
@@ -161,6 +185,7 @@ export function PriceSummary({
161
185
  return (
162
186
  <div className={`space-y-2 min-w-0 ${className}`}>
163
187
  {lines.map((row, index) => {
188
+ const isBeforeSubtotalBoundary = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
164
189
  if (row.kind === 'ticket') {
165
190
  return (
166
191
  <PriceBreakdown
@@ -171,23 +196,29 @@ export function PriceSummary({
171
196
  breakdown={row.breakdown ?? null}
172
197
  currency={currency}
173
198
  locale={locale}
174
- editable={row.editable}
199
+ editable={Boolean(row.editable && isBeforeSubtotalBoundary)}
175
200
  editableValue={row.lineKey ? lineAmountInputs?.[row.lineKey] : undefined}
176
201
  onEditableChange={
177
- row.editable && row.lineKey && onLineAmountInputChange
202
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountInputChange
178
203
  ? (value) => onLineAmountInputChange(row.lineKey!, value)
179
204
  : undefined
180
205
  }
181
206
  onEditableBlur={
182
- row.editable && row.lineKey && onLineAmountInputBlur
207
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountInputBlur
183
208
  ? () => onLineAmountInputBlur(row.lineKey!)
184
209
  : undefined
185
210
  }
186
211
  onEditableReset={
187
- row.editable && row.lineKey && onLineAmountReset
212
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountReset
188
213
  ? () => onLineAmountReset(row.lineKey!)
189
214
  : undefined
190
215
  }
216
+ editableLabelValue={row.lineKey ? lineLabelInputs?.[row.lineKey] : undefined}
217
+ onEditableLabelChange={
218
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineLabelInputChange
219
+ ? (value) => onLineLabelInputChange(row.lineKey!, value)
220
+ : undefined
221
+ }
191
222
  />
192
223
  );
193
224
  }
@@ -209,10 +240,20 @@ export function PriceSummary({
209
240
  )}
210
241
  <div className={`flex justify-between gap-3 min-w-0 ${textSize}`}>
211
242
  <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>
243
+ {editable && isBeforeSubtotalBoundary && lineKey && onLineLabelInputChange ? (
244
+ <input
245
+ type="text"
246
+ className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
247
+ value={lineLabelInputs?.[lineKey] ?? label}
248
+ onChange={(e) => onLineLabelInputChange(lineKey, e.target.value)}
249
+ aria-label={`Edit ${label} label`}
250
+ />
251
+ ) : (
252
+ <span className="min-w-0 truncate">
253
+ {label}
254
+ {shouldAppendQuantitySuffix(type, label, quantity) ? ` (x${quantity})` : ''}
255
+ </span>
256
+ )}
216
257
  {tooltip && <InfoTooltip text={tooltip} />}
217
258
  </span>
218
259
  {editable && lineKey && onLineAmountInputChange ? (
@@ -232,7 +273,7 @@ export function PriceSummary({
232
273
  <input
233
274
  type="text"
234
275
  inputMode="decimal"
235
- className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
276
+ className="admin-editable-summary-amount-input w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
236
277
  value={lineAmountInputs?.[lineKey] ?? String(amount)}
237
278
  onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
238
279
  onBlur={() => onLineAmountInputBlur?.(lineKey)}
@@ -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
  /**
@@ -293,6 +293,14 @@
293
293
  padding: 0.5rem 0.75rem;
294
294
  min-height: 2.75rem;
295
295
  }
296
+ .booking-flow-preflight input.admin-editable-summary-amount-input[type='text'] {
297
+ box-sizing: border-box;
298
+ padding: 0.5rem 0.75rem;
299
+ min-height: 2.75rem;
300
+ font-size: 0.875rem;
301
+ line-height: 1.25rem;
302
+ font-weight: 500;
303
+ }
296
304
  .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-segment {
297
305
  box-sizing: border-box;
298
306
  padding: 0.5rem 0.65rem;
@@ -0,0 +1,244 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import type { PriceSummaryLine } from './PriceSummary';
3
+ import { roundMoney } from '../../lib/booking/change-flow-pricing';
4
+
5
+ type EditableSummaryLineInputState = {
6
+ amountInput: string;
7
+ labelInput: string;
8
+ amountDirty: boolean;
9
+ labelDirty: boolean;
10
+ };
11
+
12
+ type ProviderAmountSync = {
13
+ enabled: boolean;
14
+ onChange?: (lineKey: string, value: string) => void;
15
+ onBlur?: (lineKey: string) => void;
16
+ onReset?: (lineKey: string) => void;
17
+ };
18
+
19
+ function getEditableSummaryLineKey(line: PriceSummaryLine, index: number): string {
20
+ if (line.lineKey) return line.lineKey;
21
+ return line.kind === 'ticket'
22
+ ? `change-ticket-${line.category}-${index}`
23
+ : `change-line-${line.label}-${line.type ?? 'line'}-${index}`;
24
+ }
25
+
26
+ export function useEditableSummaryLines(
27
+ checkoutPriceSummaryLinesForCheckout: PriceSummaryLine[],
28
+ providerAmountSync: ProviderAmountSync,
29
+ ) {
30
+ const [editableSummaryInputsByKey, setEditableSummaryInputsByKey] = useState<
31
+ Record<string, EditableSummaryLineInputState>
32
+ >({});
33
+
34
+ const editableSummaryLineAmountInputs = useMemo(() => {
35
+ const out: Record<string, string> = {};
36
+ for (const [lineKey, state] of Object.entries(editableSummaryInputsByKey)) {
37
+ out[lineKey] = state.amountInput;
38
+ }
39
+ return out;
40
+ }, [editableSummaryInputsByKey]);
41
+
42
+ const editableSummaryLineLabelInputs = useMemo(() => {
43
+ const out: Record<string, string> = {};
44
+ for (const [lineKey, state] of Object.entries(editableSummaryInputsByKey)) {
45
+ out[lineKey] = state.labelInput;
46
+ }
47
+ return out;
48
+ }, [editableSummaryInputsByKey]);
49
+
50
+ const editableCheckoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
51
+ const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
52
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
53
+ );
54
+ return checkoutPriceSummaryLinesForCheckout.map((line, index) => {
55
+ const isBeforeSubtotal = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
56
+ const lineKey = getEditableSummaryLineKey(line, index);
57
+ const lineInputState = editableSummaryInputsByKey[lineKey];
58
+ if (line.kind === 'ticket') {
59
+ const amountInput = lineInputState?.amountInput;
60
+ const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.itemTotal : Number(amountInput);
61
+ return {
62
+ ...line,
63
+ lineKey,
64
+ editable: isBeforeSubtotal,
65
+ category: lineInputState?.labelInput ?? line.category,
66
+ itemTotal: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.itemTotal,
67
+ };
68
+ }
69
+ const amountInput = lineInputState?.amountInput;
70
+ const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.amount : Number(amountInput);
71
+ return {
72
+ ...line,
73
+ lineKey,
74
+ editable: isBeforeSubtotal,
75
+ label: lineInputState?.labelInput ?? line.label,
76
+ amount: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.amount,
77
+ };
78
+ });
79
+ }, [checkoutPriceSummaryLinesForCheckout, editableSummaryInputsByKey]);
80
+
81
+ useEffect(() => {
82
+ setEditableSummaryInputsByKey((prev) => {
83
+ const next: Record<string, EditableSummaryLineInputState> = {};
84
+ for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
85
+ const line = checkoutPriceSummaryLinesForCheckout[i];
86
+ const key = getEditableSummaryLineKey(line, i);
87
+ const baselineAmount =
88
+ line.kind === 'ticket' ? String(roundMoney(line.itemTotal)) : String(roundMoney(line.amount));
89
+ const baselineLabel = line.kind === 'ticket' ? line.category : line.label;
90
+ const current = prev[key];
91
+ const amountDirty = current?.amountDirty === true;
92
+ const labelDirty = current?.labelDirty === true;
93
+ next[key] = {
94
+ amountInput: amountDirty ? (current?.amountInput ?? baselineAmount) : baselineAmount,
95
+ labelInput: labelDirty ? (current?.labelInput ?? baselineLabel) : baselineLabel,
96
+ amountDirty,
97
+ labelDirty,
98
+ };
99
+ }
100
+ const prevKeys = Object.keys(prev);
101
+ const nextKeys = Object.keys(next);
102
+ if (prevKeys.length !== nextKeys.length) return next;
103
+ const unchanged = nextKeys.every((key) => {
104
+ const a = prev[key];
105
+ const b = next[key];
106
+ return (
107
+ a?.amountInput === b.amountInput &&
108
+ a?.labelInput === b.labelInput &&
109
+ a?.amountDirty === b.amountDirty &&
110
+ a?.labelDirty === b.labelDirty
111
+ );
112
+ });
113
+ return unchanged ? prev : next;
114
+ });
115
+ }, [checkoutPriceSummaryLinesForCheckout]);
116
+
117
+ const editableSummaryPreSubtotalDelta = useMemo(() => {
118
+ const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
119
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
120
+ );
121
+ let delta = 0;
122
+ for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
123
+ if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
124
+ const line = checkoutPriceSummaryLinesForCheckout[i];
125
+ const lineKey = getEditableSummaryLineKey(line, i);
126
+ const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
127
+ const raw = editableSummaryLineAmountInputs[lineKey];
128
+ const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
129
+ const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
130
+ delta += editedAmount - baselineAmount;
131
+ }
132
+ return roundMoney(delta);
133
+ }, [checkoutPriceSummaryLinesForCheckout, editableSummaryLineAmountInputs]);
134
+
135
+ const editableSummaryPreSubtotalDebugRows = useMemo(() => {
136
+ const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
137
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
138
+ );
139
+ const rows: Array<{ label: string; baseline: number; edited: number; delta: number }> = [];
140
+ for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
141
+ if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
142
+ const line = checkoutPriceSummaryLinesForCheckout[i];
143
+ const lineKey = getEditableSummaryLineKey(line, i);
144
+ const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
145
+ const raw = editableSummaryLineAmountInputs[lineKey];
146
+ const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
147
+ const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
148
+ const delta = roundMoney(editedAmount - baselineAmount);
149
+ rows.push({
150
+ label: line.kind === 'ticket' ? line.category : line.label,
151
+ baseline: roundMoney(baselineAmount),
152
+ edited: editedAmount,
153
+ delta,
154
+ });
155
+ }
156
+ return rows;
157
+ }, [checkoutPriceSummaryLinesForCheckout, editableSummaryLineAmountInputs]);
158
+
159
+ const onLineAmountInputChange = (lineKey: string, value: string) => {
160
+ const sanitized = value.replace(/[^0-9.-]/g, '');
161
+ setEditableSummaryInputsByKey((prev) => {
162
+ const current = prev[lineKey] ?? {
163
+ amountInput: '',
164
+ labelInput: '',
165
+ amountDirty: false,
166
+ labelDirty: false,
167
+ };
168
+ return {
169
+ ...prev,
170
+ [lineKey]: { ...current, amountInput: sanitized, amountDirty: true },
171
+ };
172
+ });
173
+ if (providerAmountSync.enabled) providerAmountSync.onChange?.(lineKey, sanitized);
174
+ };
175
+
176
+ const onLineAmountInputBlur = (lineKey: string) => {
177
+ setEditableSummaryInputsByKey((prev) => {
178
+ const current = prev[lineKey];
179
+ if (!current) return prev;
180
+ const raw = current.amountInput ?? '';
181
+ if (raw.trim() === '' || raw === '-' || raw === '.' || raw === '-.') {
182
+ return prev;
183
+ }
184
+ const parsed = Number(raw);
185
+ if (!Number.isFinite(parsed)) return prev;
186
+ const normalized = roundMoney(parsed).toFixed(2);
187
+ if (normalized === raw) return prev;
188
+ return {
189
+ ...prev,
190
+ [lineKey]: { ...current, amountInput: normalized },
191
+ };
192
+ });
193
+ if (providerAmountSync.enabled) providerAmountSync.onBlur?.(lineKey);
194
+ };
195
+
196
+ const onLineAmountReset = (lineKey: string) => {
197
+ const original = checkoutPriceSummaryLinesForCheckout.find(
198
+ (line, index) => getEditableSummaryLineKey(line, index) === lineKey,
199
+ );
200
+ if (!original) return;
201
+ const nextValue =
202
+ original.kind === 'ticket' ? String(roundMoney(original.itemTotal)) : String(roundMoney(original.amount));
203
+ setEditableSummaryInputsByKey((prev) => {
204
+ const current = prev[lineKey] ?? {
205
+ amountInput: nextValue,
206
+ labelInput: '',
207
+ amountDirty: false,
208
+ labelDirty: false,
209
+ };
210
+ return {
211
+ ...prev,
212
+ [lineKey]: { ...current, amountInput: nextValue, amountDirty: false },
213
+ };
214
+ });
215
+ if (providerAmountSync.enabled) providerAmountSync.onReset?.(lineKey);
216
+ };
217
+
218
+ const onLineLabelInputChange = (lineKey: string, value: string) => {
219
+ setEditableSummaryInputsByKey((prev) => {
220
+ const current = prev[lineKey] ?? {
221
+ amountInput: '',
222
+ labelInput: '',
223
+ amountDirty: false,
224
+ labelDirty: false,
225
+ };
226
+ return {
227
+ ...prev,
228
+ [lineKey]: { ...current, labelInput: value, labelDirty: true },
229
+ };
230
+ });
231
+ };
232
+
233
+ return {
234
+ editableCheckoutPriceSummaryLines,
235
+ editableSummaryLineAmountInputs,
236
+ editableSummaryLineLabelInputs,
237
+ editableSummaryPreSubtotalDelta,
238
+ editableSummaryPreSubtotalDebugRows,
239
+ onLineAmountInputChange,
240
+ onLineAmountInputBlur,
241
+ onLineAmountReset,
242
+ onLineLabelInputChange,
243
+ };
244
+ }
@@ -97,6 +97,7 @@ export function mapQuoteLineItemsToPriceSummaryLines(
97
97
  label: label || type || 'Line',
98
98
  amount,
99
99
  type: summaryType,
100
+ quantity: qty > 0 ? qty : null,
100
101
  });
101
102
  }
102
103
  return out;
@@ -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> {