@ticketboothapp/booking 1.2.74 → 1.2.76

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.
@@ -84,3 +84,19 @@ Pickers show **catalog** prices by default (same booking **currency** as the ori
84
84
  - **Currency**: align optional maps and line `amount` values with `currency` / receipt currency; change booking stays in the booking’s sold currency end-to-end on FE.
85
85
  - **Cancellation policy on change**: FE sends the booked policy on reserve/confirm paths; it is **not** chosen from product config during change — preserve the booking’s policy server-side unless product rules say otherwise.
86
86
  - **`serverPreview.completeness`**: the package sets this to **`full`** whenever a preview is built (informational); UI does not gate on `totals_only` vs `full`.
87
+
88
+ ---
89
+
90
+ ## Admin / provider manual line items (signed adjustments)
91
+
92
+ **Frontend (this package):** `AdminChangeBookingFlow` lets staff add custom receipt lines (label + signed amount). The same values are:
93
+
94
+ 1. **`POST …/change/quote`** — optional request field **`manualLineAdjustments`**: `Array<{ label: string; amount: number }>` in **major units** (same currency as the booking). Amounts are signed (credits negative). Omitted when there are no custom lines.
95
+ 2. **Provider apply** — `onChangeBooking` payload field **`pricingAdjustment`**, with **`mode: 'MANUAL_LINES'`** and **`additionalAdjustments`** equal to the **provider** inline editor rows **plus** these admin custom lines (concatenated). `newTotalAmount` remains the full proposed total (already includes those lines on the client).
96
+
97
+ **Backend expectations**
98
+
99
+ - **Quote:** When `manualLineAdjustments` is present, add each line to the server’s priced subtotal (before tax) for the change intent, and return quote totals / `amountDue` / line items that **include** these rows so the UI and `clientProposedTotal` stay in tolerance.
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
+
102
+ If the server ignores `manualLineAdjustments` or `additionalAdjustments`, the customer will see the FE total but pay a different amount.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # `@ticketboothapp/booking`
2
+
3
+ Published as `@ticketboothapp/booking` (see `package.json`).
4
+
5
+ **Local development in TicketBooth provider-dashboard** (link this package without publishing):
6
+
7
+ - [docs/local-ticketbooth-provider-dashboard.md](./docs/local-ticketbooth-provider-dashboard.md)
8
+
9
+ **In this monorepo:** apps use `workspace:*` / `transpilePackages`; run e.g. `npm run dev:booking` or `npm run dev:staff` from the website repo root.
@@ -0,0 +1,60 @@
1
+ # Test `@ticketboothapp/booking` in TicketBooth provider-dashboard (local link)
2
+
3
+ Use this when developing in **viavia-website** and you want **provider-dashboard** (`ticketbooth-fe`) to load your **workspace package** without publishing to npm.
4
+
5
+ Provider-dashboard already sets `transpilePackages: ['@ticketboothapp/booking']`, so Next will compile the linked package from source.
6
+
7
+ ## Option A — `npm link` (quick toggle)
8
+
9
+ **1. Register this package globally**
10
+
11
+ From this repo’s booking package directory:
12
+
13
+ ```bash
14
+ cd /path/to/viavia-website/packages/viavia-booking
15
+ npm link
16
+ ```
17
+
18
+ **2. Point provider-dashboard at that link**
19
+
20
+ ```bash
21
+ cd /path/to/ticketbooth-fe/apps/provider-dashboard
22
+ npm link @ticketboothapp/booking
23
+ ```
24
+
25
+ **3. Run provider-dashboard**
26
+
27
+ From the TicketBooth frontend monorepo root (typical for that repo):
28
+
29
+ ```bash
30
+ cd /path/to/ticketbooth-fe
31
+ npm run dev:provider
32
+ ```
33
+
34
+ **Undo** (back to the version from the registry):
35
+
36
+ ```bash
37
+ cd /path/to/ticketbooth-fe/apps/provider-dashboard
38
+ npm unlink @ticketboothapp/booking
39
+ npm install
40
+ ```
41
+
42
+ If the workspace layout is odd, run `npm install` once at the `ticketbooth-fe` root after unlink.
43
+
44
+ ---
45
+
46
+ ## Option B — `file:` in `package.json` (stays on until you revert)
47
+
48
+ In `apps/provider-dashboard/package.json`, set:
49
+
50
+ ```json
51
+ "@ticketboothapp/booking": "file:/absolute/path/to/viavia-website/packages/viavia-booking"
52
+ ```
53
+
54
+ Then from `ticketbooth-fe` root: `npm install` and `npm run dev:provider`.
55
+
56
+ ---
57
+
58
+ ## Option C — `overrides` in `ticketbooth-fe` root `package.json`
59
+
60
+ Add an `overrides` entry for `@ticketboothapp/booking` pointing at the same `file:` path, then `npm install` at the monorepo root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.74",
3
+ "version": "1.2.76",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -126,6 +126,9 @@ function mergeQuoteSliceWithServerPreview(
126
126
  serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
127
127
  pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
128
128
  ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
129
+ /** Same cent pair the BE uses for `amountDueCents` / intent & receipt "New Booking Difference" — use for signed refund display. */
130
+ quotePreviousTotalCents: quote.previousTotalCents,
131
+ quoteNewTotalCents: quote.newTotalCents,
129
132
  };
130
133
  }
131
134
 
@@ -726,6 +729,11 @@ export function AdminChangeBookingFlow({
726
729
  );
727
730
  /** Fetched add-ons for the selected product option */
728
731
  const [addOns, setAddOns] = useState<AddOn[]>([]);
732
+ /** Admin receipt-only adjustments (signed amount); affects FE subtotal/tax/total until persisted server-side. */
733
+ const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<
734
+ Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
735
+ >([]);
736
+ const adminCustomLineIdRef = useRef(0);
729
737
 
730
738
  // Auto-apply promo code when parent page passes one (e.g. partner pages).
731
739
  // Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
@@ -847,9 +855,10 @@ export function AdminChangeBookingFlow({
847
855
  const lockedPromoCode = initialValues?.promoCode?.trim()
848
856
  ? initialValues.promoCode.trim().toUpperCase()
849
857
  : null;
850
- /** Public self-serve only: cannot reduce tickets below original counts. */
858
+ /** Public self-serve only: cannot reduce tickets below original counts. Provider-dashboard admins may reduce party size. */
851
859
  const changeBookingMinimumQuantities = useMemo(() => {
852
860
  if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
861
+ if (isAdmin && isProviderDashboardChange) return undefined;
853
862
  const m: Record<string, number> = {};
854
863
  for (const item of initialValues.bookingItems) {
855
864
  const key = item.category?.trim();
@@ -857,7 +866,7 @@ export function AdminChangeBookingFlow({
857
866
  m[key] = Math.max(0, Number(item.count) || 0);
858
867
  }
859
868
  return m;
860
- }, [isCustomerSelfServeChange, initialValues?.bookingItems]);
869
+ }, [isCustomerSelfServeChange, initialValues?.bookingItems, isAdmin, isProviderDashboardChange]);
861
870
  const [adminChoiceData, setAdminChoiceData] = useState<{
862
871
  reservationReference: string;
863
872
  reservationExpiration?: string;
@@ -890,6 +899,9 @@ export function AdminChangeBookingFlow({
890
899
  quotedTotal?: number;
891
900
  /** From `quoteChangeBooking` receipt fields — drives PriceSummary when self-serve. */
892
901
  serverDisplay?: { total: number; subtotal: number; tax: number };
902
+ /** Server quote cents — authoritative vs `serverDisplay` scaling (aligns with receipt difference line). */
903
+ quotePreviousTotalCents?: number;
904
+ quoteNewTotalCents?: number;
893
905
  /** Parsed from last quote — unified server-owned preview for lines + picker overrides. */
894
906
  serverPreview: ReturnType<typeof buildChangeBookingServerPreview>;
895
907
  pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
@@ -2909,6 +2921,22 @@ export function AdminChangeBookingFlow({
2909
2921
  : subtotal;
2910
2922
  const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
2911
2923
 
2924
+ const adminCustomAdjustmentTotal = useMemo(
2925
+ () =>
2926
+ roundMoney(
2927
+ adminCustomReceiptLines.reduce((sum, line) => {
2928
+ const raw = line.amountInput.trim();
2929
+ if (raw === '' || raw === '+' || raw === '-') return sum;
2930
+ const n = Number(raw);
2931
+ if (!Number.isFinite(n)) return sum;
2932
+ const sign = line.amountSign ?? 1;
2933
+ return sum + sign * Math.abs(n);
2934
+ }, 0)
2935
+ ),
2936
+ [adminCustomReceiptLines]
2937
+ );
2938
+ const effectiveSubtotalForCheckout = roundMoney(effectiveSubtotal + adminCustomAdjustmentTotal);
2939
+
2912
2940
  /** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
2913
2941
  const quantitiesSignature = useMemo(
2914
2942
  () =>
@@ -3123,7 +3151,7 @@ export function AdminChangeBookingFlow({
3123
3151
  selectedAvailability.availabilityId ?? '',
3124
3152
  currency,
3125
3153
  quantitiesSignature,
3126
- String(Math.round(effectiveSubtotal * 100)),
3154
+ String(Math.round(effectiveSubtotalForCheckout * 100)),
3127
3155
  lockedPromoCode ? String(Math.round((originalReceipt?.promoAmount ?? 0) * 100)) : '',
3128
3156
  ].join('::');
3129
3157
  }, [
@@ -3136,7 +3164,7 @@ export function AdminChangeBookingFlow({
3136
3164
  product.productId,
3137
3165
  currency,
3138
3166
  quantitiesSignature,
3139
- effectiveSubtotal,
3167
+ effectiveSubtotalForCheckout,
3140
3168
  lockedPromoCode,
3141
3169
  originalReceipt?.promoAmount,
3142
3170
  ]);
@@ -3144,7 +3172,7 @@ export function AdminChangeBookingFlow({
3144
3172
  promoDiscountParamsRef.current = {
3145
3173
  selectedAvailability,
3146
3174
  ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
3147
- effectiveSubtotal,
3175
+ effectiveSubtotal: effectiveSubtotalForCheckout,
3148
3176
  appliedPromoCode,
3149
3177
  changeBookingPromo: lockedPromoCode
3150
3178
  ? { priorAmount: originalReceipt?.promoAmount }
@@ -3225,12 +3253,12 @@ export function AdminChangeBookingFlow({
3225
3253
  : 0;
3226
3254
  const effectivePromoDiscountAmount =
3227
3255
  promoDiscountAmount > 0 ? promoDiscountAmount : lockedPromoFallbackAmount;
3228
- const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
3256
+ const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotalForCheckout * (pricingConfig?.taxRate ?? 0);
3229
3257
  const effectiveTax =
3230
3258
  effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
3231
- ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
3259
+ ? (effectiveSubtotalForCheckout - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
3232
3260
  : taxOnSubtotal;
3233
- const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
3261
+ const totalPrice = effectiveSubtotalForCheckout + effectiveTax - effectivePromoDiscountAmount;
3234
3262
  /**
3235
3263
  * FE cart rollup for line math, breakdowns, and `clientProposedTotal` hint to the API. Self-serve **footer** totals
3236
3264
  * prefer `latestChangeQuote` once quote succeeds — this value is not an alternate source of truth for checkout.
@@ -3661,9 +3689,29 @@ export function AdminChangeBookingFlow({
3661
3689
  return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
3662
3690
  });
3663
3691
  const hasEffectiveChangeSelection =
3664
- hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
3692
+ hasChangeSelection ||
3693
+ providerHasEditedLineOverrides ||
3694
+ providerHasAdditionalAdjustments ||
3695
+ Math.abs(adminCustomAdjustmentTotal) >= 0.005;
3696
+
3697
+ /**
3698
+ * True until `quoteChangeBooking` returns a confirmed `serverDisplay` for the current selection.
3699
+ * Named so manual price-line UI can align with “still settling final price” without touching ticket/receipt math.
3700
+ */
3701
+ const changeFlowFinalPricePending =
3702
+ suppressSelfServeCurrencyUi &&
3703
+ selectedAvailability != null &&
3704
+ totalQuantity > 0 &&
3705
+ !selfServePricingConfirmed;
3706
+
3707
+ /**
3708
+ * Provider inline edits + admin custom lines: show while waiting on or after the authoritative quote.
3709
+ * UI-only gate — does not alter ticket line items or receipt-floor rules.
3710
+ */
3711
+ const showChangeFlowManualPriceLines =
3712
+ changeFlowFinalPricePending || selfServePricingConfirmed;
3665
3713
 
3666
- const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
3714
+ const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
3667
3715
  providerPreview: providerTotalsPreview,
3668
3716
  serverQuotePreview:
3669
3717
  isCustomerSelfServeChange && latestChangeQuote?.serverDisplay
@@ -3671,30 +3719,130 @@ export function AdminChangeBookingFlow({
3671
3719
  : null,
3672
3720
  fromCart: {
3673
3721
  total: changeFlowNewBookingTotal,
3674
- subtotal: effectiveSubtotal,
3722
+ subtotal: effectiveSubtotalForCheckout,
3675
3723
  tax: effectiveTax,
3676
3724
  },
3677
3725
  });
3678
- const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
3679
- const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
3680
- const displayChangeFlowTax = displayedChangeAmounts.tax;
3726
+ /** When cart drives display, {@link effectiveSubtotalForCheckout} already includes admin adjustments — do not add twice. */
3727
+ const displayLayerUsesExternalPricing =
3728
+ Boolean(providerTotalsPreview) ||
3729
+ (isCustomerSelfServeChange && Boolean(latestChangeQuote?.serverDisplay));
3730
+ const adminTaxDeltaForExternalDisplay =
3731
+ displayLayerUsesExternalPricing && !isTaxIncludedInPrice && Math.abs(adminCustomAdjustmentTotal) >= 0.0005
3732
+ ? roundMoney(adminCustomAdjustmentTotal * (pricingConfig?.taxRate ?? 0))
3733
+ : 0;
3734
+ const displayChangeFlowSubtotal = roundMoney(
3735
+ displayedChangeAmountsRaw.subtotal +
3736
+ (displayLayerUsesExternalPricing ? adminCustomAdjustmentTotal : 0)
3737
+ );
3738
+ const displayChangeFlowTax = roundMoney(displayedChangeAmountsRaw.tax + adminTaxDeltaForExternalDisplay);
3739
+ const displayChangeFlowProposedTotal = roundMoney(
3740
+ displayLayerUsesExternalPricing
3741
+ ? displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3742
+ : displayedChangeAmountsRaw.total
3743
+ );
3681
3744
 
3682
3745
  const changeFlowClientEstimateDue = (() => {
3683
3746
  if (!originalReceipt) return totalPrice;
3684
- // Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
3747
+ // Self-serve quote: match BE receipt / intent: (newTotalCents previousTotalCents) / 100.
3685
3748
  if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3749
+ const pq = latestChangeQuote.quotePreviousTotalCents;
3750
+ const nq = latestChangeQuote.quoteNewTotalCents;
3751
+ if (pq != null && nq != null) {
3752
+ return normalizeNearZeroOwed(roundMoney((nq - pq) / 100));
3753
+ }
3754
+ if (latestChangeQuote.serverDisplay != null) {
3755
+ return normalizeNearZeroOwed(
3756
+ roundMoney(latestChangeQuote.serverDisplay.total - originalReceipt.total),
3757
+ );
3758
+ }
3686
3759
  return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
3687
3760
  }
3688
3761
  return changeFlowBalanceVsOriginal({
3689
3762
  newTotal: displayChangeFlowProposedTotal,
3690
3763
  originalReceiptTotal: originalReceipt.total,
3691
- audience: isProviderDashboardChange ? 'provider' : 'customer',
3764
+ audience: 'admin',
3692
3765
  });
3693
3766
  })();
3694
3767
 
3695
3768
  const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
3696
3769
  const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
3697
3770
 
3771
+ const changeFlowAdminPricingDebugPanel = useMemo(() => {
3772
+ if (!isAdmin || !selectedAvailability || totalQuantity <= 0) return null;
3773
+ const fmt = (n: number) => formatCurrencyAmount(roundMoney(n), currency, locale as 'en' | 'fr');
3774
+ const path = (() => {
3775
+ if (!originalReceipt) return 'totalPrice (no original receipt)';
3776
+ if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3777
+ if (
3778
+ latestChangeQuote.quotePreviousTotalCents != null &&
3779
+ latestChangeQuote.quoteNewTotalCents != null
3780
+ ) {
3781
+ return 'POST /change/quote: (quoteNewTotalCents − quotePreviousTotalCents) / 100';
3782
+ }
3783
+ if (latestChangeQuote.serverDisplay != null) {
3784
+ return 'POST /change/quote: serverDisplay.total − originalReceipt.total';
3785
+ }
3786
+ return 'POST /change/quote: priceDiff (API balance delta)';
3787
+ }
3788
+ return 'FE: displayChangeFlowProposedTotal − originalReceipt.total (signed; used when quote not driving amount-due)';
3789
+ })();
3790
+ const quoteTotalPreview =
3791
+ latestChangeQuote?.serverDisplay != null ? fmt(latestChangeQuote.serverDisplay.total) : '—';
3792
+ return (
3793
+ <details className="mt-3 rounded-md border border-amber-200/80 bg-amber-50/50 p-2 text-left text-xs text-amber-950">
3794
+ <summary className="cursor-pointer select-none font-medium text-stone-800">
3795
+ Price calculation (admin debug)
3796
+ </summary>
3797
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
3798
+ {[
3799
+ `Amount-due path: ${path}`,
3800
+ `displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
3801
+ `selfServePricingConfirmed: ${String(selfServePricingConfirmed)} · changeQuoteLoading: ${String(changeQuoteLoading)}`,
3802
+ `Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
3803
+ `Admin custom lines (sum): ${fmt(adminCustomAdjustmentTotal)}`,
3804
+ `Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
3805
+ `effectiveTax · promo discount: ${fmt(effectiveTax)} · ${fmt(effectivePromoDiscountAmount)}`,
3806
+ `totalPrice (subtotal + tax − promo): ${fmt(totalPrice)}`,
3807
+ `changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
3808
+ `Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
3809
+ `displayChangeFlowProposedTotal (summary / preview footer): ${fmt(displayChangeFlowProposedTotal)}`,
3810
+ originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: —',
3811
+ `Quote serverDisplay.total (if any): ${quoteTotalPreview}`,
3812
+ `changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
3813
+ `changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
3814
+ ].join('\n')}
3815
+ </pre>
3816
+ </details>
3817
+ );
3818
+ }, [
3819
+ isAdmin,
3820
+ selectedAvailability,
3821
+ totalQuantity,
3822
+ currency,
3823
+ locale,
3824
+ originalReceipt,
3825
+ isCustomerSelfServeChange,
3826
+ latestChangeQuote,
3827
+ changeQuoteFetchError,
3828
+ displayLayerUsesExternalPricing,
3829
+ selfServePricingConfirmed,
3830
+ changeQuoteLoading,
3831
+ effectiveSubtotal,
3832
+ adminCustomAdjustmentTotal,
3833
+ effectiveSubtotalForCheckout,
3834
+ effectiveTax,
3835
+ effectivePromoDiscountAmount,
3836
+ totalPrice,
3837
+ changeFlowNewBookingTotal,
3838
+ displayedChangeAmountsRaw.subtotal,
3839
+ displayedChangeAmountsRaw.tax,
3840
+ displayedChangeAmountsRaw.total,
3841
+ displayChangeFlowProposedTotal,
3842
+ changeFlowClientEstimateDue,
3843
+ changeFlowAmountDue,
3844
+ ]);
3845
+
3698
3846
  const changeCheckoutButtonLabel = (() => {
3699
3847
  if (!hasEffectiveChangeSelection) return undefined;
3700
3848
  if (isProviderDashboardChange) {
@@ -3722,7 +3870,9 @@ export function AdminChangeBookingFlow({
3722
3870
  const d = Math.round(changeFlowClientEstimateDue * 100) / 100;
3723
3871
  return d > 0
3724
3872
  ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
3725
- : 'Change booking (no charge)';
3873
+ : d < 0
3874
+ ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
3875
+ : 'Change booking (no charge)';
3726
3876
  }
3727
3877
  const tr = t('booking.changeBooking');
3728
3878
  return tr !== 'booking.changeBooking' ? tr : 'Change booking';
@@ -3730,7 +3880,9 @@ export function AdminChangeBookingFlow({
3730
3880
  const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
3731
3881
  return est > 0
3732
3882
  ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3733
- : 'Change booking (no charge)';
3883
+ : est < 0
3884
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3885
+ : 'Change booking (no charge)';
3734
3886
  })();
3735
3887
  /** Partner deferred-invoice path applies to {@link NewBookingFlow} only. */
3736
3888
  const deferredInvoiceSubmitLabel = undefined;
@@ -3772,6 +3924,25 @@ export function AdminChangeBookingFlow({
3772
3924
  .filter((v): v is { label: string; amount: number } => v != null)
3773
3925
  : [];
3774
3926
 
3927
+ /** Same rows as checkout breakdown — sent on provider apply + optional public change-quote for BE parity. */
3928
+ const adminCustomLinesAsAdditionalAdjustments = useMemo((): Array<{ label: string; amount: number }> => {
3929
+ return adminCustomReceiptLines.flatMap((line) => {
3930
+ const raw = line.amountInput.trim();
3931
+ if (raw === '' || raw === '+' || raw === '-') return [];
3932
+ const n = Number(raw);
3933
+ if (!Number.isFinite(n) || Math.abs(n) < 0.0005) return [];
3934
+ const sign = line.amountSign ?? 1;
3935
+ const amount = roundMoney(sign * Math.abs(n));
3936
+ if (Math.abs(amount) < 0.005) return [];
3937
+ return [{ label: line.label.trim() || 'Adjustment', amount }];
3938
+ });
3939
+ }, [adminCustomReceiptLines]);
3940
+
3941
+ const mergedProviderAdditionalAdjustments = useMemo(
3942
+ () => [...providerAdditionalAdjustments, ...adminCustomLinesAsAdditionalAdjustments],
3943
+ [providerAdditionalAdjustments, adminCustomLinesAsAdditionalAdjustments]
3944
+ );
3945
+
3775
3946
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
3776
3947
  if (!selectedAvailability || totalQuantity <= 0) return null;
3777
3948
  const ticketsLine = formatTicketLineItemsForSummary(
@@ -3847,7 +4018,7 @@ export function AdminChangeBookingFlow({
3847
4018
  return;
3848
4019
  }
3849
4020
  onPricePreviewChange({
3850
- subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
4021
+ subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotalForCheckout,
3851
4022
  tax:
3852
4023
  !isTaxIncludedInPrice
3853
4024
  ? originalReceipt
@@ -3861,7 +4032,7 @@ export function AdminChangeBookingFlow({
3861
4032
  onPricePreviewChange,
3862
4033
  selectedAvailability,
3863
4034
  totalQuantity,
3864
- effectiveSubtotal,
4035
+ effectiveSubtotalForCheckout,
3865
4036
  effectiveTax,
3866
4037
  displayChangeFlowProposedTotal,
3867
4038
  displayChangeFlowSubtotal,
@@ -3926,6 +4097,9 @@ export function AdminChangeBookingFlow({
3926
4097
  newPassengerCounts: bookingItems,
3927
4098
  // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
3928
4099
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
4100
+ ...(adminCustomLinesAsAdditionalAdjustments.length > 0
4101
+ ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
4102
+ : {}),
3929
4103
  clientProposedTotal: changeFlowNewBookingTotal,
3930
4104
  capacitySeatCredit: {
3931
4105
  enabled: true,
@@ -3939,7 +4113,7 @@ export function AdminChangeBookingFlow({
3939
4113
  quote,
3940
4114
  {
3941
4115
  total: changeFlowNewBookingTotal,
3942
- subtotal: effectiveSubtotal,
4116
+ subtotal: effectiveSubtotalForCheckout,
3943
4117
  tax: effectiveTax,
3944
4118
  },
3945
4119
  currency
@@ -3947,7 +4121,7 @@ export function AdminChangeBookingFlow({
3947
4121
  setLatestChangeQuote(
3948
4122
  mergeQuoteSliceWithServerPreview(slice, quote, {
3949
4123
  total: changeFlowNewBookingTotal,
3950
- subtotal: effectiveSubtotal,
4124
+ subtotal: effectiveSubtotalForCheckout,
3951
4125
  tax: effectiveTax,
3952
4126
  }, currency),
3953
4127
  );
@@ -3981,13 +4155,14 @@ export function AdminChangeBookingFlow({
3981
4155
  addOnSelections,
3982
4156
  changeFlowInitialTicketCount,
3983
4157
  changeFlowNewBookingTotal,
3984
- effectiveSubtotal,
4158
+ effectiveSubtotalForCheckout,
3985
4159
  effectiveTax,
3986
4160
  totalPrice,
3987
4161
  currency,
3988
4162
  activeOptions,
3989
4163
  initialValues?.availabilityId,
3990
4164
  initialValues?.returnAvailabilityId,
4165
+ adminCustomLinesAsAdditionalAdjustments,
3991
4166
  ]);
3992
4167
 
3993
4168
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -4518,6 +4693,9 @@ export function AdminChangeBookingFlow({
4518
4693
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
4519
4694
  newPassengerCounts: bookingItems,
4520
4695
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
4696
+ ...(adminCustomLinesAsAdditionalAdjustments.length > 0
4697
+ ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
4698
+ : {}),
4521
4699
  clientProposedTotal:
4522
4700
  latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
4523
4701
  });
@@ -4525,7 +4703,7 @@ export function AdminChangeBookingFlow({
4525
4703
  quote,
4526
4704
  {
4527
4705
  total: changeFlowNewBookingTotal,
4528
- subtotal: effectiveSubtotal,
4706
+ subtotal: effectiveSubtotalForCheckout,
4529
4707
  tax: effectiveTax,
4530
4708
  },
4531
4709
  currency
@@ -4536,7 +4714,7 @@ export function AdminChangeBookingFlow({
4536
4714
  setLatestChangeQuote(
4537
4715
  mergeQuoteSliceWithServerPreview(quoteSlice, quote, {
4538
4716
  total: changeFlowNewBookingTotal,
4539
- subtotal: effectiveSubtotal,
4717
+ subtotal: effectiveSubtotalForCheckout,
4540
4718
  tax: effectiveTax,
4541
4719
  }, currency),
4542
4720
  );
@@ -4548,30 +4726,41 @@ export function AdminChangeBookingFlow({
4548
4726
  quote.proposed?.total ??
4549
4727
  quote.newReceipt?.total ??
4550
4728
  changeFlowNewBookingTotal;
4551
- const feChangeDue = changeFlowBalanceVsOriginal({
4552
- newTotal: serverNewTotalForGuard,
4553
- originalReceiptTotal: originalReceipt?.total ?? 0,
4554
- audience: 'customer',
4555
- });
4556
- const serverAmountDue =
4557
- quote.amountDueCents != null
4558
- ? Math.max(0, quote.amountDueCents / 100)
4559
- : Math.max(0, quote.priceDiff ?? 0);
4560
- if (feChangeDue > 0.02 && serverAmountDue <= 0.009) {
4729
+ /** Signed proposed − previous (major units). Matches quote slices when cents present; refund owed < 0. */
4730
+ const signedBalanceMajor =
4731
+ quote.previousTotalCents != null && quote.newTotalCents != null
4732
+ ? (quote.newTotalCents - quote.previousTotalCents) / 100
4733
+ : quote.balanceDeltaMajorUnits ?? null;
4734
+ const chargeDue =
4735
+ signedBalanceMajor != null
4736
+ ? Math.max(0, signedBalanceMajor)
4737
+ : quote.amountDueCents != null
4738
+ ? quote.amountDueCents / 100
4739
+ : Math.max(0, quote.priceDiff ?? 0);
4740
+ const feChangeDue =
4741
+ signedBalanceMajor ??
4742
+ changeFlowBalanceVsOriginal({
4743
+ newTotal: serverNewTotalForGuard,
4744
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4745
+ audience: 'admin',
4746
+ });
4747
+ if (feChangeDue > 0.02 && chargeDue <= 0.009) {
4561
4748
  throw new Error(
4562
4749
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4563
4750
  );
4564
4751
  }
4565
- // No-payment change: FE shows nothing owed still require server agreement so we never confirm free when a charge is due.
4566
- if (serverAmountDue <= 0.009) {
4752
+ // No additional charge (includes refund-owed / downgrade): confirm-free apply only when server agrees no payment due.
4753
+ if (chargeDue <= 0.009) {
4567
4754
  if (feChangeDue > 0.02) {
4568
4755
  throw new Error(
4569
4756
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4570
4757
  );
4571
4758
  }
4572
- const p = quote.proposed?.total ?? quote.newReceipt?.total;
4573
- const o = quote.original?.total ?? quote.originalReceipt?.total;
4574
- if (p != null && o != null && p - o > 0.01) {
4759
+ const upgradeWithoutCharge =
4760
+ quote.newTotalCents != null &&
4761
+ quote.previousTotalCents != null &&
4762
+ quote.newTotalCents > quote.previousTotalCents + 1;
4763
+ if (upgradeWithoutCharge) {
4575
4764
  throw new Error(
4576
4765
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
4577
4766
  );
@@ -4623,11 +4812,14 @@ export function AdminChangeBookingFlow({
4623
4812
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
4624
4813
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
4625
4814
  const amountDueForCheckout = isCustomerSelfServeChange
4626
- ? changeFlowBalanceVsOriginal({
4627
- newTotal: changeFlowNewBookingTotal,
4628
- originalReceiptTotal: originalReceipt?.total ?? 0,
4629
- audience: 'customer',
4630
- })
4815
+ ? Math.max(
4816
+ 0,
4817
+ changeFlowBalanceVsOriginal({
4818
+ newTotal: changeFlowNewBookingTotal,
4819
+ originalReceiptTotal: originalReceipt?.total ?? 0,
4820
+ audience: 'admin',
4821
+ }),
4822
+ )
4631
4823
  : totalPrice;
4632
4824
  const lines = [
4633
4825
  ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
@@ -4679,6 +4871,21 @@ export function AdminChangeBookingFlow({
4679
4871
  type: 'FEE' as const,
4680
4872
  quantity: totalQuantity,
4681
4873
  })),
4874
+ ...adminCustomReceiptLines.flatMap((adjLine) => {
4875
+ const raw = adjLine.amountInput.trim();
4876
+ if (raw === '' || raw === '+' || raw === '-') return [];
4877
+ const n = Number(raw);
4878
+ if (!Number.isFinite(n) || Math.abs(n) < 0.0005) return [];
4879
+ const sign = adjLine.amountSign ?? 1;
4880
+ const amount = sign * Math.abs(n);
4881
+ return [
4882
+ {
4883
+ label: adjLine.label.trim() || 'Adjustment',
4884
+ amount,
4885
+ type: 'FEE' as const,
4886
+ },
4887
+ ];
4888
+ }),
4682
4889
  ...(!isTaxIncludedInPrice && taxForBreakdown > 0
4683
4890
  ? [
4684
4891
  {
@@ -4772,7 +4979,7 @@ export function AdminChangeBookingFlow({
4772
4979
  returnPriceAdjustment: checkoutReturnLineAmount,
4773
4980
  cancellationPolicyFee,
4774
4981
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4775
- subtotal: effectiveSubtotal,
4982
+ subtotal: effectiveSubtotalForCheckout,
4776
4983
  tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
4777
4984
  totalQuantity,
4778
4985
  isTaxIncludedInPrice,
@@ -4833,7 +5040,7 @@ export function AdminChangeBookingFlow({
4833
5040
  returnPriceAdjustment: checkoutReturnLineAmount,
4834
5041
  cancellationPolicyFee,
4835
5042
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4836
- subtotal: effectiveSubtotal,
5043
+ subtotal: effectiveSubtotalForCheckout,
4837
5044
  tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
4838
5045
  total: amountDueForCheckout,
4839
5046
  totalQuantity,
@@ -4954,11 +5161,13 @@ export function AdminChangeBookingFlow({
4954
5161
  newTotalAmount: displayChangeFlowProposedTotal,
4955
5162
  additionalHoursCount: null,
4956
5163
  pricingAdjustment:
4957
- providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
5164
+ providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
4958
5165
  ? {
4959
5166
  mode: 'MANUAL_LINES',
4960
- lineOverrides: providerPricingOverrides,
4961
- additionalAdjustments: providerAdditionalAdjustments,
5167
+ ...(providerPricingOverrides.length > 0 ? { lineOverrides: providerPricingOverrides } : {}),
5168
+ ...(mergedProviderAdditionalAdjustments.length > 0
5169
+ ? { additionalAdjustments: mergedProviderAdditionalAdjustments }
5170
+ : {}),
4962
5171
  }
4963
5172
  : undefined,
4964
5173
  capacitySeatCredit: {
@@ -5326,10 +5535,12 @@ export function AdminChangeBookingFlow({
5326
5535
  replacePriceSummary={selfServeCheckoutPlaceholder}
5327
5536
  totalPrice={changeFlowAmountDue}
5328
5537
  totalSummaryLabel={
5329
- t('booking.totalOwedForBookingChange') &&
5330
- t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
5331
- ? t('booking.totalOwedForBookingChange')
5332
- : 'Total owed for booking difference'
5538
+ changeFlowAmountDue < -0.005
5539
+ ? 'Refund owed (vs original booking)'
5540
+ : t('booking.totalOwedForBookingChange') &&
5541
+ t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
5542
+ ? t('booking.totalOwedForBookingChange')
5543
+ : 'Total owed for booking difference'
5333
5544
  }
5334
5545
  subtotal={displayChangeFlowSubtotal}
5335
5546
  taxAmount={
@@ -5358,76 +5569,194 @@ export function AdminChangeBookingFlow({
5358
5569
  !providerPricingUi.error ? (
5359
5570
  <div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
5360
5571
  ) : null}
5572
+ {changeFlowAdminPricingDebugPanel}
5361
5573
  </>
5362
5574
  }
5363
5575
  extraBeforeSubtotal={
5364
- showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5365
- <div className="space-y-1">
5366
- {providerPricingUi?.additionalAdjustments?.map((adj) => (
5367
- <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
5368
- <div className="flex min-w-0 items-center gap-1">
5369
- <button
5370
- type="button"
5371
- className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
5372
- onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
5373
- >
5374
- -
5375
- </button>
5576
+ showChangeFlowManualPriceLines ? (
5577
+ <>
5578
+ {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5579
+ <div className="space-y-1">
5580
+ {providerPricingUi?.additionalAdjustments?.map((adj) => (
5581
+ <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
5582
+ <div className="flex min-w-0 items-center gap-1">
5583
+ <button
5584
+ type="button"
5585
+ className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
5586
+ onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
5587
+ >
5588
+ -
5589
+ </button>
5590
+ <input
5591
+ type="text"
5592
+ className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5593
+ placeholder="Line description"
5594
+ value={adj.label}
5595
+ onChange={(e) =>
5596
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
5597
+ }
5598
+ />
5599
+ </div>
5600
+ <div className="flex items-center gap-1">
5601
+ <select
5602
+ className="rounded border border-stone-300 px-1 py-0.5 text-xs"
5603
+ value={adj.mode}
5604
+ onChange={(e) =>
5605
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5606
+ mode: e.target.value as 'DISCOUNT' | 'CHARGE',
5607
+ })
5608
+ }
5609
+ >
5610
+ <option value="DISCOUNT">-</option>
5611
+ <option value="CHARGE">+</option>
5612
+ </select>
5613
+ <input
5614
+ type="text"
5615
+ 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"
5617
+ placeholder="0.00"
5618
+ value={adj.amountInput}
5619
+ onChange={(e) =>
5620
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5621
+ amountInput: e.target.value,
5622
+ })
5623
+ }
5624
+ />
5625
+ </div>
5626
+ </div>
5627
+ ))}
5628
+ <button
5629
+ type="button"
5630
+ className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5631
+ onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5632
+ >
5633
+ + add line item
5634
+ </button>
5635
+ </div>
5636
+ ) : showProviderPricingInlineEditor ? (
5637
+ <button
5638
+ type="button"
5639
+ className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5640
+ onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5641
+ >
5642
+ + add line item
5643
+ </button>
5644
+ ) : null}
5645
+ <div className="space-y-3">
5646
+ <div className="flex justify-center">
5647
+ <button
5648
+ type="button"
5649
+ className="admin-custom-receipt-line-add inline-flex items-center gap-1.5 rounded-full border border-dashed border-stone-300 bg-stone-50/80 text-xs font-medium text-stone-600 hover:border-stone-400 hover:bg-stone-100"
5650
+ onClick={() => {
5651
+ adminCustomLineIdRef.current += 1;
5652
+ setAdminCustomReceiptLines((prev) => [
5653
+ ...prev,
5654
+ {
5655
+ id: `admin-rcpt-${adminCustomLineIdRef.current}`,
5656
+ label: '',
5657
+ amountInput: '',
5658
+ amountSign: 1,
5659
+ },
5660
+ ]);
5661
+ }}
5662
+ >
5663
+ <span className="text-base font-semibold leading-none text-stone-700" aria-hidden>
5664
+ +
5665
+ </span>
5666
+ Add custom line
5667
+ </button>
5668
+ </div>
5669
+ {adminCustomReceiptLines.map((adj) => {
5670
+ const sign = adj.amountSign ?? 1;
5671
+ return (
5672
+ <div
5673
+ key={adj.id}
5674
+ className="admin-custom-receipt-line flex flex-wrap items-center gap-2 text-sm sm:flex-nowrap"
5675
+ >
5376
5676
  <input
5377
5677
  type="text"
5378
- className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5379
- placeholder="Line description"
5678
+ className="admin-custom-receipt-input min-w-[8rem] flex-1 rounded-md border border-stone-300 bg-white text-sm text-stone-800 placeholder:text-stone-400"
5679
+ placeholder="Description"
5380
5680
  value={adj.label}
5381
5681
  onChange={(e) =>
5382
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
5682
+ setAdminCustomReceiptLines((prev) =>
5683
+ prev.map((l) => (l.id === adj.id ? { ...l, label: e.target.value } : l))
5684
+ )
5383
5685
  }
5384
5686
  />
5385
- </div>
5386
- <div className="flex items-center gap-1">
5387
- <select
5388
- className="rounded border border-stone-300 px-1 py-0.5 text-xs"
5389
- value={adj.mode}
5390
- onChange={(e) =>
5391
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5392
- mode: e.target.value as 'DISCOUNT' | 'CHARGE',
5393
- })
5394
- }
5687
+ <div
5688
+ className="flex shrink-0 rounded-lg border border-stone-300 bg-stone-100/90 p-0.5 shadow-sm"
5689
+ role="group"
5690
+ aria-label="Line adds to total or reduces it"
5395
5691
  >
5396
- <option value="DISCOUNT">-</option>
5397
- <option value="CHARGE">+</option>
5398
- </select>
5692
+ <button
5693
+ type="button"
5694
+ className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
5695
+ sign === 1
5696
+ ? 'bg-white text-emerald-800 shadow-sm ring-1 ring-stone-200/80'
5697
+ : 'text-stone-500 hover:bg-stone-200/60 hover:text-stone-700'
5698
+ }`}
5699
+ aria-pressed={sign === 1}
5700
+ onClick={() =>
5701
+ setAdminCustomReceiptLines((prev) =>
5702
+ prev.map((l) => (l.id === adj.id ? { ...l, amountSign: 1 } : l))
5703
+ )
5704
+ }
5705
+ >
5706
+ Charge (+)
5707
+ </button>
5708
+ <button
5709
+ type="button"
5710
+ className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
5711
+ sign === -1
5712
+ ? 'bg-white text-red-800 shadow-sm ring-1 ring-stone-200/80'
5713
+ : 'text-stone-500 hover:bg-stone-200/60 hover:text-stone-700'
5714
+ }`}
5715
+ aria-pressed={sign === -1}
5716
+ onClick={() =>
5717
+ setAdminCustomReceiptLines((prev) =>
5718
+ prev.map((l) => (l.id === adj.id ? { ...l, amountSign: -1 } : l))
5719
+ )
5720
+ }
5721
+ >
5722
+ Credit (−)
5723
+ </button>
5724
+ </div>
5399
5725
  <input
5400
5726
  type="text"
5401
5727
  inputMode="decimal"
5402
- 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"
5728
+ 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"
5403
5729
  placeholder="0.00"
5730
+ aria-label="Amount"
5404
5731
  value={adj.amountInput}
5405
- onChange={(e) =>
5406
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5407
- amountInput: e.target.value,
5408
- })
5409
- }
5732
+ onChange={(e) => {
5733
+ const v = e.target.value.replace(/[^0-9.]/g, '');
5734
+ const parts = v.split('.');
5735
+ const normalized =
5736
+ parts.length <= 1 ? v : `${parts[0]}.${parts.slice(1).join('').replace(/\./g, '')}`;
5737
+ setAdminCustomReceiptLines((prev) =>
5738
+ prev.map((l) => (l.id === adj.id ? { ...l, amountInput: normalized } : l))
5739
+ );
5740
+ }}
5410
5741
  />
5742
+ <button
5743
+ type="button"
5744
+ className="admin-custom-receipt-remove ml-auto shrink-0 rounded-md text-stone-400 hover:bg-stone-100 hover:text-stone-700 sm:ml-0"
5745
+ aria-label="Remove this line"
5746
+ onClick={() =>
5747
+ setAdminCustomReceiptLines((prev) => prev.filter((l) => l.id !== adj.id))
5748
+ }
5749
+ >
5750
+ <span className="block text-xl leading-none" aria-hidden>
5751
+ ×
5752
+ </span>
5753
+ </button>
5411
5754
  </div>
5412
- </div>
5413
- ))}
5414
- <button
5415
- type="button"
5416
- className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5417
- onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5418
- >
5419
- + add line item
5420
- </button>
5755
+ );
5756
+ })}
5421
5757
  </div>
5422
- ) : showProviderPricingInlineEditor ? (
5423
- <button
5424
- type="button"
5425
- className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5426
- onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5427
- >
5428
- + add line item
5429
- </button>
5430
- ) : undefined
5758
+ </>
5759
+ ) : null
5431
5760
  }
5432
5761
  firstName={firstName}
5433
5762
  lastName={lastName}
@@ -146,6 +146,7 @@ export function CheckoutForm({
146
146
  <div className={styles.summaryWrapper}>
147
147
  {replacePriceSummary ? (
148
148
  <>
149
+ {extraBeforeSubtotal ? <div className="mb-4 min-w-0">{extraBeforeSubtotal}</div> : null}
149
150
  {replacePriceSummary}
150
151
  <div className="mt-4">{extraBetweenTaxAndTotal}</div>
151
152
  </>
@@ -147,6 +147,17 @@ export function PriceSummary({
147
147
 
148
148
  let subtotalShown = false;
149
149
 
150
+ /** When lines include a TAX row, subtotal is rendered inside the loop before that row — slot content must sit there too, not after all lines (else it ends up below tax). */
151
+ const firstTaxLineIndex = lines.findIndex(
152
+ (r) => r.kind === 'line' && String(r.type ?? '').toUpperCase() === 'TAX',
153
+ );
154
+ const embedSubtotalBeforeTaxInLines =
155
+ firstTaxLineIndex >= 0 && subtotal != null && subtotal > 0;
156
+ const extraSlotBeforeEmbeddedSubtotal =
157
+ embedSubtotalBeforeTaxInLines && extraBeforeSubtotal ? extraBeforeSubtotal : null;
158
+ const extraSlotBeforeCheckoutSubtotal =
159
+ !embedSubtotalBeforeTaxInLines && extraBeforeSubtotal ? extraBeforeSubtotal : null;
160
+
150
161
  return (
151
162
  <div className={`space-y-2 min-w-0 ${className}`}>
152
163
  {lines.map((row, index) => {
@@ -185,8 +196,11 @@ export function PriceSummary({
185
196
  const isTaxLine = type === 'TAX';
186
197
  const showSubtotalBeforeTax = isTaxLine && subtotal != null && subtotal > 0 && !subtotalShown;
187
198
  if (showSubtotalBeforeTax) subtotalShown = true;
199
+ const slotHere =
200
+ showSubtotalBeforeTax && index === firstTaxLineIndex ? extraSlotBeforeEmbeddedSubtotal : null;
188
201
  return (
189
202
  <div key={`${label}-${index}`}>
203
+ {slotHere}
190
204
  {showSubtotalBeforeTax && (
191
205
  <div className={`flex justify-between gap-3 min-w-0 ${textSize} ${subtotalRowClass}`}>
192
206
  <span className="text-stone-600 min-w-0 truncate">{t('booking.subtotal') || 'Subtotal'}</span>
@@ -233,7 +247,7 @@ export function PriceSummary({
233
247
  </div>
234
248
  );
235
249
  })}
236
- {extraBeforeSubtotal}
250
+ {extraSlotBeforeCheckoutSubtotal}
237
251
 
238
252
  {/* Checkout mode: subtotal/tax/discount not in lines (e.g. Stripe Review & pay modal) */}
239
253
  {subtotal != null && !subtotalShown && !hideSubtotal && (subtotal !== total || discountAmount > 0) && (
@@ -279,6 +279,42 @@
279
279
  box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2);
280
280
  }
281
281
 
282
+ /**
283
+ * Admin “custom receipt line” row: scoped preflight sets `input, button { padding: 0 }` with
284
+ * higher specificity than Tailwind padding utilities, so insets must be restored here.
285
+ */
286
+ .booking-flow-preflight .admin-custom-receipt-line input.admin-custom-receipt-input[type='text'] {
287
+ box-sizing: border-box;
288
+ padding: 0.625rem 0.875rem;
289
+ min-height: 2.75rem;
290
+ }
291
+ .booking-flow-preflight .admin-custom-receipt-line input.admin-custom-receipt-input-amount[type='text'] {
292
+ box-sizing: border-box;
293
+ padding: 0.5rem 0.75rem;
294
+ min-height: 2.75rem;
295
+ }
296
+ .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-segment {
297
+ box-sizing: border-box;
298
+ padding: 0.5rem 0.65rem;
299
+ min-height: 2.75rem;
300
+ }
301
+ @media (min-width: 360px) {
302
+ .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-segment {
303
+ padding: 0.5rem 0.75rem;
304
+ }
305
+ }
306
+ .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-remove {
307
+ box-sizing: border-box;
308
+ padding: 0.5rem;
309
+ min-height: 2.75rem;
310
+ min-width: 2.75rem;
311
+ }
312
+ .booking-flow-preflight .admin-custom-receipt-line-add {
313
+ box-sizing: border-box;
314
+ padding: 0.5rem 0.85rem;
315
+ min-height: 2.5rem;
316
+ }
317
+
282
318
  /* Labels */
283
319
  .booking-flow-preflight label {
284
320
  font-family: var(--booking-font-sans);
@@ -20,6 +20,7 @@ export type ProviderDashboardChangeBookingPayload = {
20
20
  pricingAdjustment?: {
21
21
  mode: 'AUTO' | 'MANUAL_LINES';
22
22
  lineOverrides?: Array<{ lineKey: string; amount: number; reason?: string }>;
23
+ /** Signed major-unit rows; may include admin custom receipt lines merged with provider inline adjustments. */
23
24
  additionalAdjustments?: Array<{ label: string; amount: number }>;
24
25
  } | null;
25
26
  capacitySeatCredit?: {
@@ -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,
@@ -855,6 +855,12 @@ export interface ChangeBookingQuoteRequest {
855
855
  newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
856
856
  /** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
857
857
  clientProposedTotal?: number;
858
+ /**
859
+ * Optional signed manual lines (admin/provider custom receipt rows). When present, the server should fold these
860
+ * amounts into pricing / stored receipt so totals align with the client (same semantics as provider dashboard
861
+ * `pricingAdjustment.additionalAdjustments`).
862
+ */
863
+ manualLineAdjustments?: Array<{ label: string; amount: number }>;
858
864
  /** Optional change-flow capacity hint so API can exclude current booking seats from sold counts. */
859
865
  capacitySeatCredit?: {
860
866
  enabled: boolean;
@@ -981,6 +987,11 @@ export interface ChangeBookingQuoteResponse {
981
987
  originalReceipt?: ChangeBookingQuoteReceipt;
982
988
  newReceipt?: ChangeBookingQuoteReceipt;
983
989
  priceDiff: number;
990
+ /**
991
+ * Present when the quote was requested with a **staff** JWT: signed (new total − previous receipt total) in major units.
992
+ * Negative = refund owed to the guest. Omitted for unauthenticated / customer-only quotes.
993
+ */
994
+ balanceDeltaMajorUnits?: number;
984
995
  currency?: string;
985
996
  canProceed?: boolean;
986
997
  reasonIfBlocked?: string;