@ticketboothapp/booking 1.2.152 → 1.2.153

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.152",
3
+ "version": "1.2.153",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -159,6 +159,12 @@ const checkoutPanelPropKeys = [
159
159
  'serverAmountDue',
160
160
  'serverUpdatedTotal',
161
161
  'serverQuoteError',
162
+ 'refundDecisionOperations',
163
+ 'allowedRefundDispositionsByOperationId',
164
+ 'removedValueByOperationId',
165
+ 'refundDispositionsByOperationId',
166
+ 'onRefundDispositionChange',
167
+ 'refundQuoteLoading',
162
168
  'isTaxIncludedInPrice',
163
169
  'taxRate',
164
170
  'currency',
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
4
4
  import {
5
+ type AdminAmendmentRefundDisposition,
5
6
  type Availability,
6
7
  type ReturnOption,
7
8
  } from '../../lib/booking-api';
@@ -54,6 +55,15 @@ import { useSeedPromoCodeInput } from './useSeedPromoCodeInput';
54
55
  import { useAdminChangeProductReset } from './useAdminChangeProductReset';
55
56
  import { useBookingQuantityCapTrim } from './useBookingQuantityCapTrim';
56
57
  import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
58
+ import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
59
+ import type { AdminReleaseRefundDisposition } from './admin-refund-disposition';
60
+
61
+ type AdminRefundDecisionContext = {
62
+ selectionKey: string;
63
+ operations: NonNullable<AdminChangeLatestQuote['refundDecisionOperations']>;
64
+ allowedByOperationId: NonNullable<AdminChangeLatestQuote['allowedRefundDispositionsByOperationId']>;
65
+ removedValueByOperationId: NonNullable<AdminChangeLatestQuote['removedValueByOperationId']>;
66
+ };
57
67
 
58
68
  /**
59
69
  * ## Pricing contract (customer self-serve)
@@ -229,6 +239,10 @@ export function AdminChangeBookingFlow({
229
239
  [isCustomerSelfServeChange, initialValues?.bookingItems, isAdmin],
230
240
  );
231
241
  const [latestChangeQuote, setLatestChangeQuote] = useState<AdminChangeLatestQuote | null>(null);
242
+ const [refundDecisionContext, setRefundDecisionContext] = useState<AdminRefundDecisionContext | null>(null);
243
+ const [refundDispositionsByOperationId, setRefundDispositionsByOperationId] = useState<
244
+ Record<string, AdminAmendmentRefundDisposition>
245
+ >({});
232
246
  const [changeQuoteLoading, setChangeQuoteLoading] = useState(false);
233
247
  const [changeQuoteFetchError, setChangeQuoteFetchError] = useState<string | null>(null);
234
248
  /** Dedupe quote calls while user input payload is unchanged. */
@@ -238,7 +252,8 @@ export function AdminChangeBookingFlow({
238
252
  suppressSelfServeCurrencyUi &&
239
253
  latestChangeQuote != null &&
240
254
  changeQuoteFetchError == null &&
241
- latestChangeQuote.canProceed !== false &&
255
+ (latestChangeQuote.canProceed !== false ||
256
+ (latestChangeQuote.refundDecisionOperations?.length ?? 0) > 0) &&
242
257
  latestChangeQuote.serverDisplay != null;
243
258
  const changeQuoteRequestSeq = useRef(0);
244
259
  const { activeOptions, activeOptionIdsKey, optionsMap } = useActiveProductOptions(product.options);
@@ -667,6 +682,72 @@ export function AdminChangeBookingFlow({
667
682
  changeFlowAmountDue,
668
683
  });
669
684
 
685
+ const refundDecisionSelectionKey = useMemo(() => buildAdminChangeQuoteRequestKey({
686
+ bookingReference: initialValues?.bookingReference,
687
+ lastName,
688
+ productId: product.productId,
689
+ selectedAvailability,
690
+ pickupLocationId,
691
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
692
+ quantities,
693
+ addOnSelections,
694
+ adminCustomReceiptLines,
695
+ useAdminFeAuthoritativeQuote,
696
+ }), [
697
+ initialValues?.bookingReference,
698
+ lastName,
699
+ product.productId,
700
+ selectedAvailability,
701
+ pickupLocationId,
702
+ selectedReturnOption?.returnAvailabilityId,
703
+ quantities,
704
+ addOnSelections,
705
+ adminCustomReceiptLines,
706
+ useAdminFeAuthoritativeQuote,
707
+ ]);
708
+ const responseRefundDecisionOperations = latestChangeQuote?.refundDecisionOperations ?? [];
709
+ useEffect(() => {
710
+ if (responseRefundDecisionOperations.length === 0) return;
711
+ setRefundDecisionContext({
712
+ selectionKey: refundDecisionSelectionKey,
713
+ operations: responseRefundDecisionOperations,
714
+ allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
715
+ removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
716
+ });
717
+ }, [
718
+ refundDecisionSelectionKey,
719
+ responseRefundDecisionOperations,
720
+ latestChangeQuote?.allowedRefundDispositionsByOperationId,
721
+ latestChangeQuote?.removedValueByOperationId,
722
+ ]);
723
+ const activeRefundDecisionContext = responseRefundDecisionOperations.length > 0
724
+ ? {
725
+ selectionKey: refundDecisionSelectionKey,
726
+ operations: responseRefundDecisionOperations,
727
+ allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
728
+ removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
729
+ }
730
+ : refundDecisionContext?.selectionKey === refundDecisionSelectionKey
731
+ ? refundDecisionContext
732
+ : null;
733
+ const activeRefundDispositionsByOperationId = useMemo(() => Object.fromEntries(
734
+ (activeRefundDecisionContext?.operations ?? []).flatMap((operation) => {
735
+ const disposition = refundDispositionsByOperationId[operation.operationId];
736
+ return disposition ? [[operation.operationId, disposition] as const] : [];
737
+ }),
738
+ ), [activeRefundDecisionContext?.operations, refundDispositionsByOperationId]);
739
+ const handleRefundDispositionChange = useCallback((
740
+ operationId: string,
741
+ disposition: AdminReleaseRefundDisposition | null,
742
+ ) => {
743
+ setRefundDispositionsByOperationId((current) => {
744
+ if (disposition) return { ...current, [operationId]: disposition };
745
+ const next = { ...current };
746
+ delete next[operationId];
747
+ return next;
748
+ });
749
+ }, []);
750
+
670
751
  const {
671
752
  missingRequiredReturnSelection,
672
753
  isChangeQuoteBlocked,
@@ -687,6 +768,7 @@ export function AdminChangeBookingFlow({
687
768
  quantities,
688
769
  addOnSelections,
689
770
  adminCustomReceiptLines,
771
+ refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
690
772
  useAdminFeAuthoritativeQuote,
691
773
  latestChangeQuote,
692
774
  setLatestChangeQuote,
@@ -870,6 +952,7 @@ export function AdminChangeBookingFlow({
870
952
  providerPricingOverrides,
871
953
  mergedProviderAdditionalAdjustments,
872
954
  adminStructuredAdjustments,
955
+ refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
873
956
  providerApplyAuthoritativeReceipt,
874
957
  onSuccess,
875
958
  totalPrice,
@@ -976,6 +1059,14 @@ export function AdminChangeBookingFlow({
976
1059
  serverAmountDue: latestChangeQuote?.serverPreview?.amountDue ?? null,
977
1060
  serverUpdatedTotal: latestChangeQuote?.serverPreview?.totalNewBooking ?? null,
978
1061
  serverQuoteError: changeQuoteFetchError,
1062
+ refundDecisionOperations: activeRefundDecisionContext?.operations ?? [],
1063
+ allowedRefundDispositionsByOperationId:
1064
+ activeRefundDecisionContext?.allowedByOperationId ?? {},
1065
+ removedValueByOperationId:
1066
+ activeRefundDecisionContext?.removedValueByOperationId ?? {},
1067
+ refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
1068
+ onRefundDispositionChange: handleRefundDispositionChange,
1069
+ refundQuoteLoading: changeQuoteLoading,
979
1070
  ...{ priceSummaryLinesIncludeTaxRow, isTaxIncludedInPrice, showProviderPricingInlineEditor },
980
1071
  taxRate: pricingConfig?.taxRate,
981
1072
  ...{ providerPricingUi, providerQuotedLines },
@@ -1,5 +1,10 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { Destination, PickupLocation } from '../../lib/booking-api';
2
+ import type {
3
+ AdminAmendmentOperationSnapshot,
4
+ AdminAmendmentRefundDisposition,
5
+ Destination,
6
+ PickupLocation,
7
+ } from '../../lib/booking-api';
3
8
  import { CheckoutForm } from './CheckoutForm';
4
9
  import type { ChangeBookingFlowProps } from './booking-flow-types';
5
10
  import type { Currency } from './CurrencySwitcher';
@@ -9,6 +14,7 @@ import type {
9
14
  ProviderDashboardPricingLine,
10
15
  } from './booking-flow-ui';
11
16
  import { AdminChangeReceiptComparison } from './AdminChangeReceiptComparison';
17
+ import type { AdminReleaseRefundDisposition } from './admin-refund-disposition';
12
18
  import {
13
19
  AdminChangePricingAdjustments,
14
20
  AdminChangePricingMessages,
@@ -34,6 +40,15 @@ export interface AdminChangeCheckoutPanelProps {
34
40
  serverAmountDue: number | null;
35
41
  serverUpdatedTotal: number | null;
36
42
  serverQuoteError?: string | null;
43
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
44
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
45
+ removedValueByOperationId?: Record<string, number>;
46
+ refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
47
+ onRefundDispositionChange?: (
48
+ operationId: string,
49
+ disposition: AdminReleaseRefundDisposition | null,
50
+ ) => void;
51
+ refundQuoteLoading?: boolean;
37
52
  isTaxIncludedInPrice: boolean;
38
53
  taxRate?: number;
39
54
  currency: Currency;
@@ -107,6 +122,12 @@ export function AdminChangeCheckoutPanel({
107
122
  serverAmountDue,
108
123
  serverUpdatedTotal,
109
124
  serverQuoteError,
125
+ refundDecisionOperations,
126
+ allowedRefundDispositionsByOperationId,
127
+ removedValueByOperationId,
128
+ refundDispositionsByOperationId,
129
+ onRefundDispositionChange,
130
+ refundQuoteLoading,
110
131
  isTaxIncludedInPrice,
111
132
  taxRate,
112
133
  currency,
@@ -194,6 +215,12 @@ export function AdminChangeCheckoutPanel({
194
215
  updatedTotal={serverUpdatedTotal}
195
216
  selectionChanged={hasEffectiveChangeSelection}
196
217
  quoteError={serverQuoteError}
218
+ refundDecisionOperations={refundDecisionOperations}
219
+ allowedRefundDispositionsByOperationId={allowedRefundDispositionsByOperationId}
220
+ removedValueByOperationId={removedValueByOperationId}
221
+ refundDispositionsByOperationId={refundDispositionsByOperationId}
222
+ onRefundDispositionChange={onRefundDispositionChange}
223
+ refundQuoteLoading={refundQuoteLoading}
197
224
  amountDueLabel={totalSummaryLabel}
198
225
  currency={currency}
199
226
  locale={locale}
@@ -1,10 +1,21 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type {
3
+ AdminAmendmentOperationSnapshot,
4
+ AdminAmendmentRefundDisposition,
5
+ } from '../../lib/booking-api';
2
6
  import { mapQuoteLineItemsToPriceSummaryLines } from '../../lib/booking/change-booking-server-preview';
3
7
  import { formatCurrencyAmount } from '../../lib/currency';
4
8
  import type { Locale } from '../../lib/booking/i18n/config';
5
9
  import type { ChangeBookingFlowProps } from './booking-flow-types';
6
10
  import type { Currency } from './CurrencySwitcher';
7
11
  import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
12
+ import {
13
+ adminRemovalOperationLabel,
14
+ noRefundRemovedValue,
15
+ refundDecisionsComplete,
16
+ releaseRefundDispositionOptions,
17
+ type AdminReleaseRefundDisposition,
18
+ } from './admin-refund-disposition';
8
19
 
9
20
  type TranslationFn = (key: string, params?: Record<string, string>) => string;
10
21
  type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
@@ -25,6 +36,15 @@ export interface AdminChangeReceiptComparisonProps {
25
36
  t: TranslationFn;
26
37
  taxRate?: number;
27
38
  adjustments?: ReactNode;
39
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
40
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
41
+ removedValueByOperationId?: Record<string, number>;
42
+ refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
43
+ onRefundDispositionChange?: (
44
+ operationId: string,
45
+ disposition: AdminReleaseRefundDisposition | null,
46
+ ) => void;
47
+ refundQuoteLoading?: boolean;
28
48
  }
29
49
 
30
50
  function normalizeLocale(locale: string): Locale {
@@ -65,11 +85,29 @@ export function AdminChangeReceiptComparison({
65
85
  t,
66
86
  taxRate,
67
87
  adjustments,
88
+ refundDecisionOperations = [],
89
+ allowedRefundDispositionsByOperationId = {},
90
+ removedValueByOperationId = {},
91
+ refundDispositionsByOperationId = {},
92
+ onRefundDispositionChange,
93
+ refundQuoteLoading = false,
68
94
  }: AdminChangeReceiptComparisonProps) {
69
95
  const displayLocale = normalizeLocale(locale);
70
96
  const existingLines = originalLines(originalReceipt);
71
97
  const originalCurrency = originalReceipt.currency ?? currency;
72
98
  const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
99
+ const hasRefundDecisions = refundDecisionOperations.length > 0;
100
+ const refundSelectionsComplete = refundDecisionsComplete(
101
+ refundDecisionOperations,
102
+ refundDispositionsByOperationId,
103
+ );
104
+ const settlementPending = hasRefundDecisions && (!refundSelectionsComplete || refundQuoteLoading);
105
+ const displayedAmountDue = settlementPending ? null : amountDue;
106
+ const removedWithoutRefund = noRefundRemovedValue(
107
+ refundDecisionOperations,
108
+ refundDispositionsByOperationId,
109
+ removedValueByOperationId,
110
+ );
73
111
  const amendmentTax = amendmentLines?.reduce(
74
112
  (sum, line) =>
75
113
  line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
@@ -104,14 +142,66 @@ export function AdminChangeReceiptComparison({
104
142
  ]
105
143
  : [];
106
144
  const amountDueClass =
107
- amountDue != null && amountDue < -0.005
145
+ displayedAmountDue != null && displayedAmountDue < -0.005
108
146
  ? 'text-emerald-700'
109
- : amountDue != null && amountDue > 0.005
147
+ : displayedAmountDue != null && displayedAmountDue > 0.005
110
148
  ? 'text-stone-900'
111
149
  : 'text-stone-700';
112
150
 
113
151
  return (
114
152
  <div className="min-w-0 space-y-3 overflow-visible">
153
+ {hasRefundDecisions ? (
154
+ <section className="rounded-lg border border-amber-300 bg-amber-50 p-3" aria-label="Refund treatment">
155
+ <div className="mb-3">
156
+ <h3 className="text-sm font-semibold text-stone-900">Choose refund treatment</h3>
157
+ <p className="mt-1 text-xs text-stone-600">
158
+ Removed paid value is never refunded automatically. Choose an explicit treatment, then the server will recalculate the settlement.
159
+ </p>
160
+ </div>
161
+ <div className="space-y-3">
162
+ {refundDecisionOperations.map((operation) => {
163
+ const options = releaseRefundDispositionOptions(
164
+ allowedRefundDispositionsByOperationId[operation.operationId],
165
+ );
166
+ const selectedValue = refundDispositionsByOperationId[operation.operationId];
167
+ const selected = options.find((option) => option.value === selectedValue);
168
+ const removedValue = removedValueByOperationId[operation.operationId];
169
+ return (
170
+ <div key={operation.operationId} className="rounded-md border border-amber-200 bg-white p-3">
171
+ <div className="mb-2 flex flex-wrap items-start justify-between gap-2 text-sm font-medium text-stone-900">
172
+ <span className="capitalize">{adminRemovalOperationLabel(operation)}</span>
173
+ {Number.isFinite(removedValue) ? (
174
+ <span>{formatCurrencyAmount(removedValue, currency, displayLocale)}</span>
175
+ ) : null}
176
+ </div>
177
+ <select
178
+ className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
179
+ value={selectedValue ?? ''}
180
+ onChange={(event) => {
181
+ const value = event.target.value;
182
+ onRefundDispositionChange?.(
183
+ operation.operationId,
184
+ value ? value as AdminReleaseRefundDisposition : null,
185
+ );
186
+ }}
187
+ aria-label={`Refund treatment for ${adminRemovalOperationLabel(operation)}`}
188
+ >
189
+ <option value="">Choose refund treatment…</option>
190
+ {options.map((option) => (
191
+ <option key={option.value} value={option.value}>{option.label}</option>
192
+ ))}
193
+ </select>
194
+ {selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
195
+ </div>
196
+ );
197
+ })}
198
+ </div>
199
+ {refundQuoteLoading && refundSelectionsComplete ? (
200
+ <p className="mt-3 text-xs font-medium text-amber-800">Recalculating settlement…</p>
201
+ ) : null}
202
+ </section>
203
+ ) : null}
204
+
115
205
  <div className="grid gap-4 md:grid-cols-2">
116
206
  <section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
117
207
  <div className="mb-3 flex items-center justify-between gap-3">
@@ -140,7 +230,7 @@ export function AdminChangeReceiptComparison({
140
230
  <p className="mt-0.5 text-xs text-stone-500">Server-priced amendment</p>
141
231
  </div>
142
232
  <span className="shrink-0 text-xs font-medium text-stone-500">
143
- {amountDue == null ? '—' : formatCurrencyAmount(amountDue, currency, displayLocale)}
233
+ {displayedAmountDue == null ? '—' : formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
144
234
  </span>
145
235
  </div>
146
236
  {!selectionChanged ? (
@@ -156,6 +246,18 @@ export function AdminChangeReceiptComparison({
156
246
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
157
247
  <p className="text-sm font-medium text-stone-700">Waiting for server price…</p>
158
248
  </div>
249
+ ) : settlementPending ? (
250
+ <div className="rounded-md border border-dashed border-amber-300 bg-white/70 px-3 py-4 text-center">
251
+ <p className="text-sm font-medium text-amber-900">Refund treatment required</p>
252
+ <p className="mt-1 text-xs text-stone-600">Choose how to handle the removed paid value above.</p>
253
+ </div>
254
+ ) : amendmentDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
255
+ <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
256
+ <p className="text-sm font-medium text-stone-800">No refund will be created</p>
257
+ <p className="mt-1 text-xs text-stone-600">
258
+ {formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
259
+ </p>
260
+ </div>
159
261
  ) : amendmentDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
160
262
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
161
263
  <p className="text-sm font-medium text-stone-700">No pricing changes</p>
@@ -196,7 +298,9 @@ export function AdminChangeReceiptComparison({
196
298
  <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
197
299
  <span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
198
300
  <span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
199
- {!selectionChanged || amountDue == null ? '—' : formatCurrencyAmount(amountDue, currency, displayLocale)}
301
+ {!selectionChanged || displayedAmountDue == null
302
+ ? '—'
303
+ : formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
200
304
  </span>
201
305
  </div>
202
306
  </div>
@@ -33,6 +33,9 @@ export interface BuildAdminChangeProviderPayloadParams {
33
33
  providerPricingOverrides: AdminChangeProviderLineOverride[];
34
34
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
35
35
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
36
+ refundDispositionsByOperationId: NonNullable<
37
+ ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
38
+ >;
36
39
  providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
37
40
  previousPassengerCount: number;
38
41
  previousAvailabilityId?: string | null;
@@ -54,6 +57,7 @@ export function buildAdminChangeProviderPayload({
54
57
  providerPricingOverrides,
55
58
  mergedProviderAdditionalAdjustments,
56
59
  adminStructuredAdjustments,
60
+ refundDispositionsByOperationId,
57
61
  providerApplyAuthoritativeReceipt,
58
62
  previousPassengerCount,
59
63
  previousAvailabilityId,
@@ -93,6 +97,7 @@ export function buildAdminChangeProviderPayload({
93
97
  : undefined,
94
98
  authoritativeReceipt: providerApplyAuthoritativeReceipt,
95
99
  structuredAdjustments: adminStructuredAdjustments,
100
+ refundDispositionsByOperationId,
96
101
  capacitySeatCredit: {
97
102
  enabled: true,
98
103
  previousPassengerCount,
@@ -11,6 +11,7 @@ interface AdminChangeQuoteRequestKeyInput {
11
11
  quantities: Record<string, number>;
12
12
  addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
13
13
  adminCustomReceiptLines: Array<{ label: string; amountInput: string; amountSign?: number }>;
14
+ refundDispositionsByOperationId?: Record<string, string>;
14
15
  useAdminFeAuthoritativeQuote: boolean;
15
16
  }
16
17
 
@@ -32,6 +33,9 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
32
33
  quantities: input.quantities,
33
34
  addOnSelections: input.addOnSelections,
34
35
  adminCustomReceiptLines: input.adminCustomReceiptLines,
36
+ refundDispositionsByOperationId: Object.fromEntries(
37
+ Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
38
+ ),
35
39
  useAdminFeAuthoritativeQuote: input.useAdminFeAuthoritativeQuote,
36
40
  });
37
41
  }
@@ -0,0 +1,62 @@
1
+ import type {
2
+ AdminAmendmentOperationSnapshot,
3
+ AdminAmendmentRefundDisposition,
4
+ } from '../../lib/booking-api';
5
+
6
+ export type AdminReleaseRefundDisposition = Exclude<
7
+ AdminAmendmentRefundDisposition,
8
+ 'REFUND_TO_ORIGINAL_PAYMENT'
9
+ >;
10
+
11
+ export type AdminRefundDispositionOption = {
12
+ value: AdminReleaseRefundDisposition;
13
+ label: string;
14
+ description: string;
15
+ };
16
+
17
+ const RELEASE_OPTIONS: AdminRefundDispositionOption[] = [
18
+ {
19
+ value: 'PENDING_REFUND',
20
+ label: 'Record a pending refund',
21
+ description: 'Save the change with a pending refund for a separate reviewed Stripe action.',
22
+ },
23
+ {
24
+ value: 'NO_REFUND',
25
+ label: 'No refund',
26
+ description: 'Remove the paid value without creating a refund or customer credit.',
27
+ },
28
+ ];
29
+
30
+ export function releaseRefundDispositionOptions(
31
+ allowed: readonly AdminAmendmentRefundDisposition[] | undefined,
32
+ ): AdminRefundDispositionOption[] {
33
+ if (!allowed) return RELEASE_OPTIONS;
34
+ return RELEASE_OPTIONS.filter((option) => allowed.includes(option.value));
35
+ }
36
+
37
+ export function adminRemovalOperationLabel(operation: AdminAmendmentOperationSnapshot): string {
38
+ const component = operation.componentKey.split(':').slice(1).join(':') || operation.componentKey;
39
+ const quantity = operation.quantityDelta ? ` (${Math.abs(operation.quantityDelta)})` : '';
40
+ return `${operation.type.replaceAll('_', ' ').toLowerCase()}: ${component}${quantity}`;
41
+ }
42
+
43
+ export function refundDecisionsComplete(
44
+ operations: readonly AdminAmendmentOperationSnapshot[],
45
+ selections: Readonly<Record<string, AdminAmendmentRefundDisposition>>,
46
+ ): boolean {
47
+ return operations.length > 0 && operations.every((operation) => selections[operation.operationId] != null);
48
+ }
49
+
50
+ export function noRefundRemovedValue(
51
+ operations: readonly AdminAmendmentOperationSnapshot[],
52
+ selections: Readonly<Record<string, AdminAmendmentRefundDisposition>>,
53
+ removedValueByOperationId: Readonly<Record<string, number>>,
54
+ ): number {
55
+ return operations.reduce(
56
+ (sum, operation) =>
57
+ selections[operation.operationId] === 'NO_REFUND'
58
+ ? sum + (removedValueByOperationId[operation.operationId] ?? 0)
59
+ : sum,
60
+ 0,
61
+ );
62
+ }
@@ -8,6 +8,8 @@ import {
8
8
  isInsufficientCapacityReserveError,
9
9
  reportReserveCapacityConflictClientContext,
10
10
  type AddOn,
11
+ type AdminAmendmentOperationSnapshot,
12
+ type AdminAmendmentRefundDisposition,
11
13
  type AdminFeAuthoritativeReceipt,
12
14
  type Availability,
13
15
  type ChangeBookingQuotePricingDriftDetail,
@@ -82,6 +84,9 @@ export interface AdminChangeLatestQuote {
82
84
  pricingVersion?: string;
83
85
  quotedTotal?: number;
84
86
  paymentCreditTotal?: number;
87
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
88
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
89
+ removedValueByOperationId?: Record<string, number>;
85
90
  serverDisplay?: { total: number; subtotal: number; tax: number };
86
91
  quotePreviousTotalCents?: number;
87
92
  quoteNewTotalCents?: number;
@@ -131,6 +136,9 @@ export interface UseAdminChangeCheckoutControllerParams {
131
136
  providerPricingOverrides: AdminChangeProviderLineOverride[];
132
137
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
133
138
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
139
+ refundDispositionsByOperationId: NonNullable<
140
+ ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
141
+ >;
134
142
  providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
135
143
  onSuccess?: ChangeBookingFlowProps['onSuccess'];
136
144
  totalPrice: number;
@@ -229,6 +237,7 @@ export function useAdminChangeCheckoutController({
229
237
  providerPricingOverrides,
230
238
  mergedProviderAdditionalAdjustments,
231
239
  adminStructuredAdjustments,
240
+ refundDispositionsByOperationId,
232
241
  providerApplyAuthoritativeReceipt,
233
242
  onSuccess,
234
243
  totalPrice,
@@ -327,6 +336,7 @@ export function useAdminChangeCheckoutController({
327
336
  providerPricingOverrides,
328
337
  mergedProviderAdditionalAdjustments,
329
338
  adminStructuredAdjustments,
339
+ refundDispositionsByOperationId,
330
340
  providerApplyAuthoritativeReceipt,
331
341
  previousPassengerCount: changeFlowInitialTicketCount,
332
342
  previousAvailabilityId: initialValues?.availabilityId ?? null,
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  quoteChangeBooking,
11
11
  quoteAdminChangeBookingV2,
12
+ type AdminAmendmentRefundDisposition,
12
13
  type AdminFeAuthoritativeReceipt,
13
14
  type Availability,
14
15
  type ItineraryDisplayStep,
@@ -51,6 +52,7 @@ export interface UseAdminChangeQuotePreviewParams {
51
52
  quantities: Record<string, number>;
52
53
  addOnSelections: AddOnSelection[];
53
54
  adminCustomReceiptLines: AdminCustomReceiptLine[];
55
+ refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
54
56
  useAdminFeAuthoritativeQuote: boolean;
55
57
  latestChangeQuote: AdminChangeLatestQuote | null;
56
58
  setLatestChangeQuote: Dispatch<SetStateAction<AdminChangeLatestQuote | null>>;
@@ -108,6 +110,7 @@ export function useAdminChangeQuotePreview({
108
110
  quantities,
109
111
  addOnSelections,
110
112
  adminCustomReceiptLines,
113
+ refundDispositionsByOperationId,
111
114
  useAdminFeAuthoritativeQuote,
112
115
  latestChangeQuote,
113
116
  setLatestChangeQuote,
@@ -151,6 +154,7 @@ export function useAdminChangeQuotePreview({
151
154
  !!lastName.trim();
152
155
 
153
156
  const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
157
+ const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
154
158
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
155
159
  bookingReference: initialValues?.bookingReference,
156
160
  lastName,
@@ -161,6 +165,7 @@ export function useAdminChangeQuotePreview({
161
165
  quantities,
162
166
  addOnSelections,
163
167
  adminCustomReceiptLines,
168
+ refundDispositionsByOperationId,
164
169
  useAdminFeAuthoritativeQuote,
165
170
  }), [
166
171
  initialValues?.bookingReference,
@@ -172,6 +177,7 @@ export function useAdminChangeQuotePreview({
172
177
  quantities,
173
178
  addOnSelections,
174
179
  adminCustomReceiptLines,
180
+ refundDispositionsByOperationId,
175
181
  useAdminFeAuthoritativeQuote,
176
182
  ]);
177
183
  const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
@@ -228,7 +234,9 @@ export function useAdminChangeQuotePreview({
228
234
  const checkoutFormError =
229
235
  (error || '') ||
230
236
  (missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
231
- (isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
237
+ (isCustomerSelfServeChange && isChangeQuoteBlocked && !refundDecisionRequired
238
+ ? (latestChangeQuote?.reasonIfBlocked ?? '')
239
+ : '') ||
232
240
  (isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
233
241
 
234
242
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
@@ -407,6 +415,9 @@ export function useAdminChangeQuotePreview({
407
415
  ...(adminCustomLinesAsAdditionalAdjustments.length > 0
408
416
  ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
409
417
  : {}),
418
+ ...(Object.keys(refundDispositionsByOperationId).length > 0
419
+ ? { refundDispositionsByOperationId }
420
+ : {}),
410
421
  clientProposedTotal: changeFlowNewBookingTotal,
411
422
  capacitySeatCredit: {
412
423
  enabled: true,
@@ -256,6 +256,9 @@ export interface ChangeQuoteUiSlice {
256
256
  pricingVersion?: string;
257
257
  quotedTotal?: number;
258
258
  paymentCreditTotal?: number;
259
+ refundDecisionOperations?: ChangeBookingQuoteResponse['refundDecisionOperations'];
260
+ allowedRefundDispositionsByOperationId?: ChangeBookingQuoteResponse['allowedRefundDispositionsByOperationId'];
261
+ removedValueByOperationId?: ChangeBookingQuoteResponse['removedValueByOperationId'];
259
262
  serverDisplay?: { total: number; subtotal: number; tax: number };
260
263
  }
261
264
 
@@ -291,6 +294,9 @@ export function sliceChangeQuoteForUi(
291
294
  pricingVersion: pricingQuote?.pricingVersion ?? undefined,
292
295
  quotedTotal: pricingQuote?.payableTotal ?? pricingQuote?.totalAmount ?? quote.proposed?.total ?? quote.newReceipt?.total,
293
296
  paymentCreditTotal: pricingQuote?.paymentCreditTotal ?? quote.paymentCreditTotal,
297
+ refundDecisionOperations: quote.refundDecisionOperations,
298
+ allowedRefundDispositionsByOperationId: quote.allowedRefundDispositionsByOperationId,
299
+ removedValueByOperationId: quote.removedValueByOperationId,
294
300
  ...(serverDisplay ? { serverDisplay } : {}),
295
301
  };
296
302
  }
@@ -1246,6 +1246,22 @@ export interface CreatePaymentIntentResponse {
1246
1246
  pricingQuoteInputsHash?: string | null;
1247
1247
  }
1248
1248
 
1249
+ export type AdminAmendmentRefundDisposition =
1250
+ | 'REFUND_TO_ORIGINAL_PAYMENT'
1251
+ | 'PENDING_REFUND'
1252
+ | 'NO_REFUND';
1253
+
1254
+ export interface AdminAmendmentOperationSnapshot {
1255
+ operationId: string;
1256
+ type: string;
1257
+ componentKey: string;
1258
+ quantityDelta?: number;
1259
+ oldIdentity?: string | null;
1260
+ targetIdentity?: string | null;
1261
+ changedFields?: string[];
1262
+ metadata?: Record<string, string>;
1263
+ }
1264
+
1249
1265
  export interface ChangeBookingQuoteRequest {
1250
1266
  bookingReference: string;
1251
1267
  lastName: string;
@@ -1275,6 +1291,8 @@ export interface ChangeBookingQuoteRequest {
1275
1291
  previousAvailabilityId?: string | null;
1276
1292
  previousReturnAvailabilityId?: string | null;
1277
1293
  } | null;
1294
+ /** Explicit admin treatment for server-classified removed paid value. Never defaulted by the client. */
1295
+ refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
1278
1296
  }
1279
1297
 
1280
1298
  /** FE-authored receipt payload for admin quote path (major units, booking currency). */
@@ -1316,6 +1334,9 @@ export interface AdminChangeBookingQuoteV2Data {
1316
1334
  canApply: boolean;
1317
1335
  reasonIfBlocked?: string | null;
1318
1336
  unsupportedFeatures?: string[];
1337
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
1338
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
1339
+ removedValueByOperationId?: Record<string, number>;
1319
1340
  }
1320
1341
 
1321
1342
  export interface ChangeBookingQuoteReceipt {
@@ -1452,6 +1473,12 @@ export interface ChangeBookingQuoteResponse {
1452
1473
  currency?: string;
1453
1474
  canProceed?: boolean;
1454
1475
  reasonIfBlocked?: string;
1476
+ /** Admin-only removal decisions required before this quote can be applied. */
1477
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
1478
+ /** Server-authorized choices. Release UI intentionally filters out immediate refund. */
1479
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
1480
+ /** Original paid basis plus allocated refundable tax for each removal operation. */
1481
+ removedValueByOperationId?: Record<string, number>;
1455
1482
  /** When price check fails / disagrees: optional breakdown for UI line-vs-line comparison. */
1456
1483
  pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
1457
1484
  /** Optional BE debug: receipt-floor vs catalog ticket math (same-parent Rule A/B). */
@@ -1621,6 +1648,41 @@ export async function quoteChangeBookingAdminFeReceipt(
1621
1648
  data) as ChangeBookingQuoteResponse;
1622
1649
  }
1623
1650
 
1651
+ export function mapAdminChangeBookingQuoteV2Data(
1652
+ data: AdminChangeBookingQuoteV2Data,
1653
+ ): ChangeBookingQuoteResponse {
1654
+ const oldReceipt = data.oldReceipt;
1655
+ const newQuote = data.newQuote ?? null;
1656
+ const currency = newQuote?.currency ?? oldReceipt.currency ?? undefined;
1657
+ return {
1658
+ pricingQuote: newQuote,
1659
+ quote: newQuote,
1660
+ balanceDelta: data.balanceDelta ?? newQuote?.balanceDelta ?? undefined,
1661
+ amountToCharge: data.amountToCharge ?? newQuote?.amountToCharge ?? undefined,
1662
+ refundCandidate: data.refundCandidate ?? newQuote?.refundCandidate ?? undefined,
1663
+ priceDiff: data.balanceDelta ?? newQuote?.balanceDelta ?? 0,
1664
+ currency: currency ?? undefined,
1665
+ canProceed: data.canApply,
1666
+ reasonIfBlocked: data.reasonIfBlocked ?? undefined,
1667
+ refundDecisionOperations: data.refundDecisionOperations ?? [],
1668
+ allowedRefundDispositionsByOperationId:
1669
+ data.allowedRefundDispositionsByOperationId ?? {},
1670
+ removedValueByOperationId: data.removedValueByOperationId ?? {},
1671
+ originalReceipt: {
1672
+ subtotal: oldReceipt.grossSubtotal ?? undefined,
1673
+ tax: oldReceipt.taxAmount ?? undefined,
1674
+ total: oldReceipt.payableTotal ?? 0,
1675
+ currency: oldReceipt.currency ?? undefined,
1676
+ lineItems: oldReceipt.lines?.map((line) => ({
1677
+ label: line.label ?? undefined,
1678
+ amount: line.amount ?? undefined,
1679
+ type: line.type ?? undefined,
1680
+ quantity: line.quantity ?? undefined,
1681
+ })),
1682
+ },
1683
+ };
1684
+ }
1685
+
1624
1686
  /** Server-authoritative Pricing V2 quote used by the provider-dashboard amendment preview. */
1625
1687
  export async function quoteAdminChangeBookingV2(
1626
1688
  request: ChangeBookingQuoteRequest
@@ -1665,33 +1727,7 @@ export async function quoteAdminChangeBookingV2(
1665
1727
  ) {
1666
1728
  throw new Error('Invalid admin Pricing V2 quote response');
1667
1729
  }
1668
- const data = candidate as AdminChangeBookingQuoteV2Data;
1669
- const oldReceipt = data.oldReceipt;
1670
- const newQuote = data.newQuote ?? null;
1671
- const currency = newQuote?.currency ?? oldReceipt.currency ?? undefined;
1672
- return {
1673
- pricingQuote: newQuote,
1674
- quote: newQuote,
1675
- balanceDelta: data.balanceDelta ?? newQuote?.balanceDelta ?? undefined,
1676
- amountToCharge: data.amountToCharge ?? newQuote?.amountToCharge ?? undefined,
1677
- refundCandidate: data.refundCandidate ?? newQuote?.refundCandidate ?? undefined,
1678
- priceDiff: data.balanceDelta ?? newQuote?.balanceDelta ?? 0,
1679
- currency: currency ?? undefined,
1680
- canProceed: data.canApply,
1681
- reasonIfBlocked: data.reasonIfBlocked ?? undefined,
1682
- originalReceipt: {
1683
- subtotal: oldReceipt.grossSubtotal ?? undefined,
1684
- tax: oldReceipt.taxAmount ?? undefined,
1685
- total: oldReceipt.payableTotal ?? 0,
1686
- currency: oldReceipt.currency ?? undefined,
1687
- lineItems: oldReceipt.lines?.map((line) => ({
1688
- label: line.label ?? undefined,
1689
- amount: line.amount ?? undefined,
1690
- type: line.type ?? undefined,
1691
- quantity: line.quantity ?? undefined,
1692
- })),
1693
- },
1694
- };
1730
+ return mapAdminChangeBookingQuoteV2Data(candidate as AdminChangeBookingQuoteV2Data);
1695
1731
  }
1696
1732
 
1697
1733
  export async function createChangeBookingPaymentIntent(
@@ -5,9 +5,20 @@ import {
5
5
  } from '../src/components/booking/change-booking-payment-modal-builders';
6
6
  import { evaluateChangeBookingQuoteForCheckout } from '../src/components/booking/change-booking-quote-guards';
7
7
  import { buildCheckoutModalSummaryFromPricingV2Quote } from '../src/components/booking/pricing-v2-checkout-summary';
8
- import type { ChangeQuoteUiSlice } from '../src/lib/booking/change-flow-pricing';
9
- import type { ChangeBookingQuoteResponse } from '../src/lib/booking-api';
8
+ import {
9
+ sliceChangeQuoteForUi,
10
+ type ChangeQuoteUiSlice,
11
+ } from '../src/lib/booking/change-flow-pricing';
12
+ import {
13
+ mapAdminChangeBookingQuoteV2Data,
14
+ type ChangeBookingQuoteResponse,
15
+ } from '../src/lib/booking-api';
10
16
  import { buildAdminChangeQuoteRequestKey } from '../src/components/booking/admin-change-quote-request-key';
17
+ import {
18
+ noRefundRemovedValue,
19
+ refundDecisionsComplete,
20
+ releaseRefundDispositionOptions,
21
+ } from '../src/components/booking/admin-refund-disposition';
11
22
  import {
12
23
  applyResourceAdjustments,
13
24
  privateShuttlePriceChanged,
@@ -127,6 +138,87 @@ test('admin quote response display state cannot invalidate its request identity'
127
138
  assert.equal(keyAfterResponse, keyBeforeResponse);
128
139
  });
129
140
 
141
+ test('admin removal quote preserves explicit release refund decisions for the preview', () => {
142
+ const operation = {
143
+ operationId: 'op_remove_adult',
144
+ type: 'REMOVE_QUANTITY',
145
+ componentKey: 'ticket:ADULT',
146
+ quantityDelta: -1,
147
+ };
148
+ const mapped = mapAdminChangeBookingQuoteV2Data({
149
+ oldReceipt: { currency: 'CAD', grossSubtotal: 357.46, taxAmount: 30.38, payableTotal: 387.84 },
150
+ newQuote: { currency: 'CAD', payableTotal: 193.92, balanceDelta: 0, amountToCharge: 0, refundCandidate: 0 },
151
+ canApply: false,
152
+ reasonIfBlocked: 'admin_refund_disposition_required',
153
+ refundDecisionOperations: [operation],
154
+ allowedRefundDispositionsByOperationId: {
155
+ [operation.operationId]: [
156
+ 'REFUND_TO_ORIGINAL_PAYMENT',
157
+ 'PENDING_REFUND',
158
+ 'NO_REFUND',
159
+ ],
160
+ },
161
+ removedValueByOperationId: { [operation.operationId]: 193.92 },
162
+ });
163
+ const slice = sliceChangeQuoteForUi(
164
+ mapped,
165
+ { total: 193.92, subtotal: 178.73, tax: 15.19 },
166
+ 'CAD',
167
+ );
168
+
169
+ assert.deepEqual(slice.refundDecisionOperations, [operation]);
170
+ assert.equal(slice.removedValueByOperationId?.[operation.operationId], 193.92);
171
+ assert.deepEqual(
172
+ releaseRefundDispositionOptions(
173
+ slice.allowedRefundDispositionsByOperationId?.[operation.operationId],
174
+ ).map((option) => option.value),
175
+ ['PENDING_REFUND', 'NO_REFUND'],
176
+ );
177
+ assert.equal(refundDecisionsComplete([operation], {}), false);
178
+ assert.equal(refundDecisionsComplete([operation], { [operation.operationId]: 'NO_REFUND' }), true);
179
+ assert.equal(
180
+ noRefundRemovedValue(
181
+ [operation],
182
+ { [operation.operationId]: 'NO_REFUND' },
183
+ { [operation.operationId]: 193.92 },
184
+ ),
185
+ 193.92,
186
+ );
187
+ });
188
+
189
+ test('changing admin refund treatment creates a distinct authoritative quote request', () => {
190
+ const base = {
191
+ bookingReference: 'DE8H04DX',
192
+ lastName: 'Tauro',
193
+ productId: 'p_1',
194
+ selectedAvailability: {
195
+ availabilityId: 'a_1',
196
+ productId: 'po_1',
197
+ dateTime: '2026-07-21T03:00:00-06:00',
198
+ vacancies: 10,
199
+ } as Parameters<typeof buildAdminChangeQuoteRequestKey>[0]['selectedAvailability'],
200
+ pickupLocationId: 'pickup_1',
201
+ returnAvailabilityId: 'return_1',
202
+ quantities: { ADULT: 1 },
203
+ addOnSelections: [],
204
+ adminCustomReceiptLines: [],
205
+ useAdminFeAuthoritativeQuote: true,
206
+ };
207
+
208
+ const undecided = buildAdminChangeQuoteRequestKey(base);
209
+ const pending = buildAdminChangeQuoteRequestKey({
210
+ ...base,
211
+ refundDispositionsByOperationId: { op_remove_adult: 'PENDING_REFUND' },
212
+ });
213
+ const noRefund = buildAdminChangeQuoteRequestKey({
214
+ ...base,
215
+ refundDispositionsByOperationId: { op_remove_adult: 'NO_REFUND' },
216
+ });
217
+
218
+ assert.notEqual(undecided, pending);
219
+ assert.notEqual(pending, noRefund);
220
+ });
221
+
130
222
  test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
131
223
  const selectedSummary = {
132
224
  availabilityId: 'a_private',