@ticketboothapp/booking 1.2.151 → 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.151",
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 },
@@ -57,6 +57,7 @@ export function AdminChangeCheckoutDialogs({
57
57
  currency={currency}
58
58
  loading={loading}
59
59
  error={error}
60
+ reservationExpiration={adminChoiceData?.reservationExpiration}
60
61
  onPayNow={onPayNow}
61
62
  onConfirmWithoutPayment={onConfirmWithoutPayment}
62
63
  onCancel={onAdminPaymentChoiceCancel}
@@ -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>
@@ -1,7 +1,13 @@
1
1
  'use client';
2
2
 
3
+ import { useEffect, useState } from 'react';
3
4
  import { formatCurrencyAmount } from '../../lib/currency';
4
5
  import type { Currency } from './CurrencySwitcher';
6
+ import {
7
+ formatReservationHoldTime,
8
+ reservationHoldSecondsRemaining,
9
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
10
+ } from './reservation-hold';
5
11
 
6
12
  interface AdminPaymentChoiceModalProps {
7
13
  open: boolean;
@@ -9,6 +15,7 @@ interface AdminPaymentChoiceModalProps {
9
15
  currency: Currency;
10
16
  loading: boolean;
11
17
  error: string;
18
+ reservationExpiration?: string;
12
19
  onPayNow: () => void;
13
20
  onConfirmWithoutPayment: () => void;
14
21
  onCancel: () => void;
@@ -27,14 +34,27 @@ export function AdminPaymentChoiceModal({
27
34
  currency,
28
35
  loading,
29
36
  error,
37
+ reservationExpiration,
30
38
  onPayNow,
31
39
  onConfirmWithoutPayment,
32
40
  onCancel,
33
41
  description,
34
42
  payNowLabel,
35
43
  }: AdminPaymentChoiceModalProps) {
44
+ const [nowMs, setNowMs] = useState(() => Date.now());
45
+
46
+ useEffect(() => {
47
+ if (!open || !reservationExpiration) return;
48
+ setNowMs(Date.now());
49
+ const interval = window.setInterval(() => setNowMs(Date.now()), 1000);
50
+ return () => window.clearInterval(interval);
51
+ }, [open, reservationExpiration]);
52
+
36
53
  if (!open) return null;
37
54
 
55
+ const secondsRemaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
56
+ const expired = secondsRemaining !== null && secondsRemaining <= 0;
57
+
38
58
  const modal = (
39
59
  <div
40
60
  className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
@@ -68,6 +88,32 @@ export function AdminPaymentChoiceModal({
68
88
  </div>
69
89
 
70
90
  <div className="p-6 flex flex-col gap-3 flex-1 min-h-0">
91
+ {secondsRemaining !== null ? (
92
+ <div
93
+ className={`rounded-lg border p-3 text-sm ${
94
+ expired
95
+ ? 'border-red-200 bg-red-50 text-red-800'
96
+ : 'border-emerald-200 bg-emerald-50 text-emerald-800'
97
+ }`}
98
+ role={expired ? 'alert' : 'status'}
99
+ aria-live="polite"
100
+ >
101
+ {expired ? (
102
+ <>
103
+ <p className="font-semibold">Reservation hold expired</p>
104
+ <p className="mt-1">{RESERVATION_HOLD_EXPIRED_MESSAGE}</p>
105
+ </>
106
+ ) : (
107
+ <>
108
+ <p className="font-semibold">
109
+ Reservation held for {formatReservationHoldTime(secondsRemaining)}
110
+ </p>
111
+ <p className="mt-1">Complete payment or confirm the booking before the hold expires.</p>
112
+ </>
113
+ )}
114
+ </div>
115
+ ) : null}
116
+
71
117
  {error ? (
72
118
  <p className="text-sm text-red-600" role="alert">
73
119
  {error}
@@ -77,7 +123,7 @@ export function AdminPaymentChoiceModal({
77
123
  <button
78
124
  type="button"
79
125
  onClick={onPayNow}
80
- disabled={loading}
126
+ disabled={loading || expired}
81
127
  className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
82
128
  >
83
129
  {loading ? 'Loading...' : `${payNowLabel ?? 'Pay now'} (${formatCurrencyAmount(totalAmount, currency)})`}
@@ -85,7 +131,7 @@ export function AdminPaymentChoiceModal({
85
131
  <button
86
132
  type="button"
87
133
  onClick={onConfirmWithoutPayment}
88
- disabled={loading}
134
+ disabled={loading || expired}
89
135
  className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
90
136
  >
91
137
  Confirm without payment
@@ -96,7 +142,7 @@ export function AdminPaymentChoiceModal({
96
142
  disabled={loading}
97
143
  className="w-full py-2 text-sm text-stone-500 hover:text-stone-700 font-medium disabled:opacity-50"
98
144
  >
99
- Cancel
145
+ {expired ? 'Close and restart checkout' : 'Cancel'}
100
146
  </button>
101
147
  </div>
102
148
  </div>
@@ -57,6 +57,7 @@ export function PrivateShuttleCheckoutDialogs({
57
57
  currency={currency}
58
58
  loading={loading}
59
59
  error={error}
60
+ reservationExpiration={adminChoiceData?.reservationExpiration}
60
61
  description={
61
62
  adminChoiceData?.isDepositPayment
62
63
  ? 'Pay the deposit now, or confirm without payment. The customer can pay the deposit or remaining balance from the Manage Booking page.'
@@ -57,6 +57,7 @@ export function StandardBookingCheckoutDialogs({
57
57
  currency={currency}
58
58
  loading={loading}
59
59
  error={error}
60
+ reservationExpiration={adminChoiceData?.reservationExpiration}
60
61
  onPayNow={onPayNow}
61
62
  onConfirmWithoutPayment={onConfirmWithoutPayment}
62
63
  onCancel={onAdminPaymentChoiceCancel}
@@ -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
+ }
@@ -47,6 +47,10 @@ import {
47
47
  type PrivateShuttleCheckoutModalData,
48
48
  } from './private-shuttle-payment-choice-runner';
49
49
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
50
+ import {
51
+ reservationHoldHasExpired,
52
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
53
+ } from './reservation-hold';
50
54
 
51
55
  export interface UsePrivateShuttleCheckoutControllerParams {
52
56
  selectedOption: string;
@@ -676,6 +680,10 @@ export function usePrivateShuttleCheckoutController({
676
680
  const handleConfirmWithoutPayment = async () => {
677
681
  const choice = adminChoiceData;
678
682
  if (!choice) return;
683
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
684
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
685
+ return;
686
+ }
679
687
  setLoading(true);
680
688
  setError('');
681
689
  try {
@@ -718,6 +726,10 @@ export function usePrivateShuttleCheckoutController({
718
726
  const handlePayNow = () => {
719
727
  const choice = adminChoiceData;
720
728
  if (!choice) return;
729
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
730
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
731
+ return;
732
+ }
721
733
  setShowAdminPaymentChoice(false);
722
734
  setCheckoutClientSecret(choice.clientSecret);
723
735
  setCheckoutModalData(buildPrivateShuttlePayNowCheckoutModalData(choice, lastName.trim()));
@@ -726,6 +738,7 @@ export function usePrivateShuttleCheckoutController({
726
738
  };
727
739
 
728
740
  const handleAdminPaymentChoiceCancel = () => {
741
+ cancelPendingReservation();
729
742
  setShowAdminPaymentChoice(false);
730
743
  setAdminChoiceData(null);
731
744
  setError('');
@@ -0,0 +1,27 @@
1
+ export const RESERVATION_HOLD_EXPIRED_MESSAGE =
2
+ 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.';
3
+
4
+ export function reservationHoldSecondsRemaining(
5
+ reservationExpiration: string | undefined,
6
+ nowMs: number = Date.now(),
7
+ ): number | null {
8
+ if (!reservationExpiration) return null;
9
+ const expirationMs = Date.parse(reservationExpiration);
10
+ if (!Number.isFinite(expirationMs)) return 0;
11
+ return Math.max(0, Math.ceil((expirationMs - nowMs) / 1000));
12
+ }
13
+
14
+ export function reservationHoldHasExpired(
15
+ reservationExpiration: string | undefined,
16
+ nowMs: number = Date.now(),
17
+ ): boolean {
18
+ const remaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
19
+ return remaining !== null && remaining <= 0;
20
+ }
21
+
22
+ export function formatReservationHoldTime(seconds: number): string {
23
+ const safeSeconds = Math.max(0, Math.floor(seconds));
24
+ const minutes = Math.floor(safeSeconds / 60);
25
+ const remainder = safeSeconds % 60;
26
+ return `${minutes}:${String(remainder).padStart(2, '0')}`;
27
+ }
@@ -42,6 +42,10 @@ import {
42
42
  type StandardCheckoutModalData,
43
43
  } from './standard-booking-payment-choice-runner';
44
44
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
45
+ import {
46
+ reservationHoldHasExpired,
47
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
48
+ } from './reservation-hold';
45
49
 
46
50
  export interface UseStandardBookingCheckoutControllerParams {
47
51
  selectedAvailability: Availability | null;
@@ -590,6 +594,10 @@ export function useStandardBookingCheckoutController({
590
594
  const handleConfirmWithoutPayment = async () => {
591
595
  const choice = adminChoiceData;
592
596
  if (!choice) return;
597
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
598
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
599
+ return;
600
+ }
593
601
  setLoading(true);
594
602
  setError('');
595
603
  try {
@@ -634,6 +642,10 @@ export function useStandardBookingCheckoutController({
634
642
  const handlePayNow = () => {
635
643
  const choice = adminChoiceData;
636
644
  if (!choice) return;
645
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
646
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
647
+ return;
648
+ }
637
649
  setShowAdminPaymentChoice(false);
638
650
  setCheckoutClientSecret(choice.clientSecret);
639
651
  setCheckoutModalData(buildStandardPayNowCheckoutModalData(choice, lastName.trim()));
@@ -642,6 +654,7 @@ export function useStandardBookingCheckoutController({
642
654
  };
643
655
 
644
656
  const handleAdminPaymentChoiceCancel = () => {
657
+ cancelPendingReservation();
645
658
  setShowAdminPaymentChoice(false);
646
659
  setAdminChoiceData(null);
647
660
  setError('');
@@ -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,
@@ -62,6 +64,10 @@ import {
62
64
  confirmFreeAdminCustomerChange,
63
65
  quoteAdminCustomerChangeForCheckout,
64
66
  } from './admin-change-customer-quote-runner';
67
+ import {
68
+ reservationHoldHasExpired,
69
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
70
+ } from './reservation-hold';
65
71
 
66
72
  type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
67
73
 
@@ -78,6 +84,9 @@ export interface AdminChangeLatestQuote {
78
84
  pricingVersion?: string;
79
85
  quotedTotal?: number;
80
86
  paymentCreditTotal?: number;
87
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
88
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
89
+ removedValueByOperationId?: Record<string, number>;
81
90
  serverDisplay?: { total: number; subtotal: number; tax: number };
82
91
  quotePreviousTotalCents?: number;
83
92
  quoteNewTotalCents?: number;
@@ -127,6 +136,9 @@ export interface UseAdminChangeCheckoutControllerParams {
127
136
  providerPricingOverrides: AdminChangeProviderLineOverride[];
128
137
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
129
138
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
139
+ refundDispositionsByOperationId: NonNullable<
140
+ ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
141
+ >;
130
142
  providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
131
143
  onSuccess?: ChangeBookingFlowProps['onSuccess'];
132
144
  totalPrice: number;
@@ -225,6 +237,7 @@ export function useAdminChangeCheckoutController({
225
237
  providerPricingOverrides,
226
238
  mergedProviderAdditionalAdjustments,
227
239
  adminStructuredAdjustments,
240
+ refundDispositionsByOperationId,
228
241
  providerApplyAuthoritativeReceipt,
229
242
  onSuccess,
230
243
  totalPrice,
@@ -323,6 +336,7 @@ export function useAdminChangeCheckoutController({
323
336
  providerPricingOverrides,
324
337
  mergedProviderAdditionalAdjustments,
325
338
  adminStructuredAdjustments,
339
+ refundDispositionsByOperationId,
326
340
  providerApplyAuthoritativeReceipt,
327
341
  previousPassengerCount: changeFlowInitialTicketCount,
328
342
  previousAvailabilityId: initialValues?.availabilityId ?? null,
@@ -690,6 +704,10 @@ export function useAdminChangeCheckoutController({
690
704
 
691
705
  const handleConfirmWithoutPayment = async () => {
692
706
  if (!adminChoiceData) return;
707
+ if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
708
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
709
+ return;
710
+ }
693
711
  setLoading(true);
694
712
  setError('');
695
713
  try {
@@ -771,6 +789,10 @@ export function useAdminChangeCheckoutController({
771
789
 
772
790
  const handlePayNow = () => {
773
791
  if (!adminChoiceData) return;
792
+ if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
793
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
794
+ return;
795
+ }
774
796
  setShowAdminPaymentChoice(false);
775
797
  setCheckoutClientSecret(adminChoiceData.clientSecret);
776
798
  setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
@@ -787,10 +809,11 @@ export function useAdminChangeCheckoutController({
787
809
  }, []);
788
810
 
789
811
  const handleAdminPaymentChoiceCancel = useCallback(() => {
812
+ cancelPendingReservation();
790
813
  setShowAdminPaymentChoice(false);
791
814
  setAdminChoiceData(null);
792
815
  setError('');
793
- }, [setError]);
816
+ }, [cancelPendingReservation, setError]);
794
817
 
795
818
  return {
796
819
  showCheckoutModal,
@@ -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
  }
@@ -68,6 +68,9 @@ function getUserFacingMessage(endpoint: string): string {
68
68
  return 'Unable to hold your booking right now. Please try again.';
69
69
  case '/checkout/payment-intent':
70
70
  return 'Unable to continue to payment right now. Please try again.';
71
+ case '/checkout/confirm-booking-without-payment':
72
+ case '/1/partner/confirm-booking-without-payment':
73
+ return 'Unable to confirm this booking right now. Please try again or restart checkout.';
71
74
  default:
72
75
  return 'Something went wrong while loading booking details. Please try again.';
73
76
  }
@@ -186,7 +189,11 @@ function createUserError(
186
189
  bookingApiErrorCode?: string
187
190
  ): BookingClientError {
188
191
  const supportCode = buildSupportCode(endpoint, errorClass);
189
- const userMessage = `${getUserFacingMessage(endpoint)} (${supportCode})`;
192
+ const userMessage = bookingApiErrorCode === 'RESERVATION_EXPIRED'
193
+ ? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
194
+ : bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
195
+ ? 'This reservation has already been completed or closed. Refresh before trying again.'
196
+ : `${getUserFacingMessage(endpoint)} (${supportCode})`;
190
197
  const error = new Error(userMessage) as BookingClientError;
191
198
  error.debugMessage = debugMessage;
192
199
  if (bookingApiErrorCode) error.bookingApiErrorCode = bookingApiErrorCode;
@@ -1239,6 +1246,22 @@ export interface CreatePaymentIntentResponse {
1239
1246
  pricingQuoteInputsHash?: string | null;
1240
1247
  }
1241
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
+
1242
1265
  export interface ChangeBookingQuoteRequest {
1243
1266
  bookingReference: string;
1244
1267
  lastName: string;
@@ -1268,6 +1291,8 @@ export interface ChangeBookingQuoteRequest {
1268
1291
  previousAvailabilityId?: string | null;
1269
1292
  previousReturnAvailabilityId?: string | null;
1270
1293
  } | null;
1294
+ /** Explicit admin treatment for server-classified removed paid value. Never defaulted by the client. */
1295
+ refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
1271
1296
  }
1272
1297
 
1273
1298
  /** FE-authored receipt payload for admin quote path (major units, booking currency). */
@@ -1309,6 +1334,9 @@ export interface AdminChangeBookingQuoteV2Data {
1309
1334
  canApply: boolean;
1310
1335
  reasonIfBlocked?: string | null;
1311
1336
  unsupportedFeatures?: string[];
1337
+ refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
1338
+ allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
1339
+ removedValueByOperationId?: Record<string, number>;
1312
1340
  }
1313
1341
 
1314
1342
  export interface ChangeBookingQuoteReceipt {
@@ -1445,6 +1473,12 @@ export interface ChangeBookingQuoteResponse {
1445
1473
  currency?: string;
1446
1474
  canProceed?: boolean;
1447
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>;
1448
1482
  /** When price check fails / disagrees: optional breakdown for UI line-vs-line comparison. */
1449
1483
  pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
1450
1484
  /** Optional BE debug: receipt-floor vs catalog ticket math (same-parent Rule A/B). */
@@ -1614,6 +1648,41 @@ export async function quoteChangeBookingAdminFeReceipt(
1614
1648
  data) as ChangeBookingQuoteResponse;
1615
1649
  }
1616
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
+
1617
1686
  /** Server-authoritative Pricing V2 quote used by the provider-dashboard amendment preview. */
1618
1687
  export async function quoteAdminChangeBookingV2(
1619
1688
  request: ChangeBookingQuoteRequest
@@ -1658,33 +1727,7 @@ export async function quoteAdminChangeBookingV2(
1658
1727
  ) {
1659
1728
  throw new Error('Invalid admin Pricing V2 quote response');
1660
1729
  }
1661
- const data = candidate as AdminChangeBookingQuoteV2Data;
1662
- const oldReceipt = data.oldReceipt;
1663
- const newQuote = data.newQuote ?? null;
1664
- const currency = newQuote?.currency ?? oldReceipt.currency ?? undefined;
1665
- return {
1666
- pricingQuote: newQuote,
1667
- quote: newQuote,
1668
- balanceDelta: data.balanceDelta ?? newQuote?.balanceDelta ?? undefined,
1669
- amountToCharge: data.amountToCharge ?? newQuote?.amountToCharge ?? undefined,
1670
- refundCandidate: data.refundCandidate ?? newQuote?.refundCandidate ?? undefined,
1671
- priceDiff: data.balanceDelta ?? newQuote?.balanceDelta ?? 0,
1672
- currency: currency ?? undefined,
1673
- canProceed: data.canApply,
1674
- reasonIfBlocked: data.reasonIfBlocked ?? undefined,
1675
- originalReceipt: {
1676
- subtotal: oldReceipt.grossSubtotal ?? undefined,
1677
- tax: oldReceipt.taxAmount ?? undefined,
1678
- total: oldReceipt.payableTotal ?? 0,
1679
- currency: oldReceipt.currency ?? undefined,
1680
- lineItems: oldReceipt.lines?.map((line) => ({
1681
- label: line.label ?? undefined,
1682
- amount: line.amount ?? undefined,
1683
- type: line.type ?? undefined,
1684
- quantity: line.quantity ?? undefined,
1685
- })),
1686
- },
1687
- };
1730
+ return mapAdminChangeBookingQuoteV2Data(candidate as AdminChangeBookingQuoteV2Data);
1688
1731
  }
1689
1732
 
1690
1733
  export async function createChangeBookingPaymentIntent(
@@ -2580,7 +2623,12 @@ export async function confirmBookingWithoutPayment(
2580
2623
  httpStatus: res.status,
2581
2624
  errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
2582
2625
  });
2583
- throw createUserError(endpoint, 'HTTP', debugMessage);
2626
+ throw createUserError(
2627
+ endpoint,
2628
+ 'HTTP',
2629
+ debugMessage,
2630
+ isApiErrorPayload(err) ? err.errorCode : undefined,
2631
+ );
2584
2632
  }
2585
2633
  const data = await parseJsonSafely(res);
2586
2634
  const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
@@ -2591,7 +2639,12 @@ export async function confirmBookingWithoutPayment(
2591
2639
  message: appError,
2592
2640
  errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
2593
2641
  });
2594
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
2642
+ throw createUserError(
2643
+ endpoint,
2644
+ 'APP_ERROR_200',
2645
+ appError,
2646
+ isApiErrorPayload(data) ? data.errorCode : undefined,
2647
+ );
2595
2648
  }
2596
2649
  logBookingSourceDebug('response', '/checkout/confirm-booking-without-payment', data);
2597
2650
  return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
@@ -2636,7 +2689,12 @@ export async function confirmPartnerBookingWithoutPayment(
2636
2689
  httpStatus: res.status,
2637
2690
  errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
2638
2691
  });
2639
- throw createUserError(endpoint, 'HTTP', debugMessage);
2692
+ throw createUserError(
2693
+ endpoint,
2694
+ 'HTTP',
2695
+ debugMessage,
2696
+ isApiErrorPayload(err) ? err.errorCode : undefined,
2697
+ );
2640
2698
  }
2641
2699
  const data = await parseJsonSafely(res);
2642
2700
  const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
@@ -2647,7 +2705,12 @@ export async function confirmPartnerBookingWithoutPayment(
2647
2705
  message: appError,
2648
2706
  errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
2649
2707
  });
2650
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
2708
+ throw createUserError(
2709
+ endpoint,
2710
+ 'APP_ERROR_200',
2711
+ appError,
2712
+ isApiErrorPayload(data) ? data.errorCode : undefined,
2713
+ );
2651
2714
  }
2652
2715
  logBookingSourceDebug('response', endpoint, data);
2653
2716
  return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
@@ -5,15 +5,31 @@ 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,
14
25
  resolveHydratedPrivateShuttleAvailability,
15
26
  } from '../src/components/booking/private-shuttle-availability';
16
27
  import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
28
+ import {
29
+ formatReservationHoldTime,
30
+ reservationHoldHasExpired,
31
+ reservationHoldSecondsRemaining,
32
+ } from '../src/components/booking/reservation-hold';
17
33
 
18
34
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
19
35
  return {
@@ -42,6 +58,23 @@ function test(name: string, fn: () => void): void {
42
58
  }
43
59
  }
44
60
 
61
+ test('admin payment choice countdown expires exactly at the reservation deadline', () => {
62
+ const expiration = '2026-07-16T15:46:44+00:00';
63
+ assert.equal(
64
+ reservationHoldSecondsRemaining(expiration, Date.parse('2026-07-16T15:46:34Z')),
65
+ 10,
66
+ );
67
+ assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:43.999Z')), false);
68
+ assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:44Z')), true);
69
+ assert.equal(formatReservationHoldTime(604), '10:04');
70
+ });
71
+
72
+ test('malformed reservation expiration fails closed', () => {
73
+ assert.equal(reservationHoldSecondsRemaining('not-a-date', Date.now()), 0);
74
+ assert.equal(reservationHoldHasExpired('not-a-date', Date.now()), true);
75
+ assert.equal(reservationHoldHasExpired(undefined, Date.now()), false);
76
+ });
77
+
45
78
  test('source attribution strips Stripe credentials and transient partner checkout identity', () => {
46
79
  const sanitized = sanitizeBookingSourceUrl(
47
80
  'https://staging.booking.viaviamorainelake.com/?partnerId=par_safe&agentId=agent_safe&agentName=Isadora+Buttonmoss&embed_manage=1&reservationRef=resRef_previous&lastName=Tauro&bookingDate=2026-07-16&payment_intent=pi_previous&payment_intent_client_secret=do_not_store&redirect_status=succeeded&tab=bookings',
@@ -105,6 +138,87 @@ test('admin quote response display state cannot invalidate its request identity'
105
138
  assert.equal(keyAfterResponse, keyBeforeResponse);
106
139
  });
107
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
+
108
222
  test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
109
223
  const selectedSummary = {
110
224
  availabilityId: 'a_private',