@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.
- package/CHANGE_BOOKING_BE_HANDOFF.md +16 -0
- package/README.md +9 -0
- package/docs/local-ticketbooth-provider-dashboard.md +60 -0
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +435 -106
- package/src/components/booking/CheckoutForm.tsx +1 -0
- package/src/components/booking/PriceSummary.tsx +15 -1
- package/src/components/booking/booking-flow.css +36 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +1 -0
- package/src/lib/booking/change-flow-pricing.ts +10 -5
- package/src/lib/booking-api.ts +11 -0
|
@@ -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
|
@@ -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(
|
|
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
|
-
|
|
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 :
|
|
3256
|
+
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotalForCheckout * (pricingConfig?.taxRate ?? 0);
|
|
3229
3257
|
const effectiveTax =
|
|
3230
3258
|
effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
3231
|
-
? (
|
|
3259
|
+
? (effectiveSubtotalForCheckout - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3232
3260
|
: taxOnSubtotal;
|
|
3233
|
-
const totalPrice =
|
|
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 ||
|
|
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
|
|
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:
|
|
3722
|
+
subtotal: effectiveSubtotalForCheckout,
|
|
3675
3723
|
tax: effectiveTax,
|
|
3676
3724
|
},
|
|
3677
3725
|
});
|
|
3678
|
-
|
|
3679
|
-
const
|
|
3680
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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 :
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
const
|
|
4557
|
-
|
|
4558
|
-
? Math.max(0,
|
|
4559
|
-
:
|
|
4560
|
-
|
|
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
|
|
4566
|
-
if (
|
|
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
|
|
4573
|
-
|
|
4574
|
-
|
|
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
|
-
?
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
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:
|
|
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:
|
|
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 ||
|
|
5164
|
+
providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
|
|
4958
5165
|
? {
|
|
4959
5166
|
mode: 'MANUAL_LINES',
|
|
4960
|
-
lineOverrides: providerPricingOverrides,
|
|
4961
|
-
|
|
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
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
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
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
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-
|
|
5379
|
-
placeholder="
|
|
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
|
-
|
|
5682
|
+
setAdminCustomReceiptLines((prev) =>
|
|
5683
|
+
prev.map((l) => (l.id === adj.id ? { ...l, label: e.target.value } : l))
|
|
5684
|
+
)
|
|
5383
5685
|
}
|
|
5384
5686
|
/>
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
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
|
-
<
|
|
5397
|
-
|
|
5398
|
-
|
|
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="
|
|
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
|
-
|
|
5407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5423
|
-
|
|
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
|
-
{
|
|
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 (
|
|
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
|
|
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 === '
|
|
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.
|
|
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,
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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;
|