@ticketboothapp/booking 1.2.167 → 1.2.168

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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +13 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +163 -145
  7. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  8. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  9. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  10. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  11. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  12. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  13. package/src/components/booking/PriceSummary.tsx +4 -0
  14. package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
  15. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  16. package/src/components/booking/admin-adjustment-components.ts +44 -0
  17. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  18. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  19. package/src/components/booking/admin-change-provider-payload.ts +3 -0
  20. package/src/components/booking/admin-change-quote-request-key.ts +27 -0
  21. package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
  22. package/src/components/booking/admin-refund-decision-context.ts +37 -0
  23. package/src/components/booking/admin-refund-disposition.ts +71 -2
  24. package/src/components/booking/booking-flow-types.ts +4 -0
  25. package/src/components/booking/booking-flow.css +5 -0
  26. package/src/components/booking/change-booking-error-message.ts +137 -0
  27. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  28. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  29. package/src/components/booking/change-booking-quote-state.ts +44 -1
  30. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  31. package/src/components/booking/private-shuttle-availability.ts +14 -0
  32. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  33. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  34. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  35. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  36. package/src/components/booking/provider-dashboard-change-booking.ts +16 -1
  37. package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
  38. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  39. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  40. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
  41. package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
  42. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  43. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  44. package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
  45. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  46. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  47. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  48. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  49. package/src/lib/booking/change-booking-server-preview.ts +26 -9
  50. package/src/lib/booking/change-flow-pricing.ts +12 -0
  51. package/src/lib/booking/i18n/messages/en.json +1 -0
  52. package/src/lib/booking/i18n/messages/fr.json +1 -0
  53. package/src/lib/booking-api.ts +64 -0
  54. package/test/change-booking-helpers.test.ts +853 -3
  55. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -3,6 +3,28 @@
3
3
  * Uses CSS module to avoid Tailwind purging/override issues in booking-flow.
4
4
  */
5
5
 
6
+ .searchInputWrapper {
7
+ position: relative;
8
+ }
9
+
10
+ .searchIcon {
11
+ position: absolute;
12
+ top: 50%;
13
+ left: 1rem;
14
+ z-index: 1;
15
+ width: 1.25rem;
16
+ height: 1.25rem;
17
+ color: #a8a29e;
18
+ pointer-events: none;
19
+ transform: translateY(-50%);
20
+ }
21
+
22
+ .searchIcon svg {
23
+ display: block;
24
+ width: 100%;
25
+ height: 100%;
26
+ }
27
+
6
28
  /* Filter pills - horizontal scroll on mobile, scrollbar hidden until scroll (overlay-style) */
7
29
  .filterPillsScroll {
8
30
  display: flex;
@@ -916,9 +916,9 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
916
916
  )}
917
917
 
918
918
  {/* Input with search icon inside - padding ensures text stays to the right of icon */}
919
- <div className="relative">
920
- <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-stone-400">
921
- <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
919
+ <div className={styles.searchInputWrapper}>
920
+ <div className={styles.searchIcon}>
921
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
922
922
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
923
923
  </svg>
924
924
  </div>
@@ -80,6 +80,8 @@ export interface PriceSummaryProps {
80
80
  hideSubtotal?: boolean;
81
81
  /** Overrides the final "Total" row label (e.g. change booking: amount due for the change) */
82
82
  totalLabel?: string;
83
+ /** Prefix a positive total with + when the row represents a signed change rather than an absolute total. */
84
+ showPositiveTotalSign?: boolean;
83
85
  /** Render after the total row (e.g. provider price delta + keep-original-price) */
84
86
  extraAfterTotal?: ReactNode;
85
87
  /** Optional map of editable line input values by line key. */
@@ -182,6 +184,7 @@ export function PriceSummary({
182
184
  depositMode,
183
185
  hideSubtotal = false,
184
186
  totalLabel,
187
+ showPositiveTotalSign = false,
185
188
  extraAfterTotal,
186
189
  lineAmountInputs,
187
190
  lineLabelInputs,
@@ -382,6 +385,7 @@ export function PriceSummary({
382
385
  (t('common.total') && t('common.total') !== 'common.total' ? t('common.total') : 'Total')}
383
386
  </span>
384
387
  <span className="flex-shrink-0 whitespace-nowrap font-semibold text-stone-900">
388
+ {showPositiveTotalSign && total > 0.005 ? '+' : ''}
385
389
  {formatCurrencyAmount(total, currency, locale)}
386
390
  </span>
387
391
  </div>
@@ -32,6 +32,7 @@ import { PrivateShuttleStartTimeSection } from './PrivateShuttleStartTimeSection
32
32
  import {
33
33
  getPrivateShuttleAvailabilityOptionId,
34
34
  privateShuttleBookingCutoffMessage,
35
+ privateShuttleInitialDateKey,
35
36
  privateShuttleStartDateTime,
36
37
  privateShuttleStartTimeAllowed,
37
38
  resolveHydratedPrivateShuttleAvailability,
@@ -51,6 +52,7 @@ import {
51
52
  } from './usePrivateShuttlePriceSummary';
52
53
  import { usePrivateShuttlePromoController } from './usePrivateShuttlePromoController';
53
54
  import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
55
+ import { resolvePrivateShuttleCancellationPolicyId } from './private-shuttle-cancellation-policy';
54
56
 
55
57
  interface PrivateShuttleBookingFlowProps {
56
58
  product: Product;
@@ -131,7 +133,12 @@ export function PrivateShuttleBookingFlow({
131
133
  const isAdmin = permissions.viewerRole === 'admin';
132
134
  const isProviderChangeMode = Boolean(initialBooking && onChangeBooking);
133
135
 
134
- const [selectedDate, setSelectedDate] = useState<string>('');
136
+ const [selectedDate, setSelectedDate] = useState<string>(() =>
137
+ privateShuttleInitialDateKey(
138
+ initialBooking?.dateTime ?? initialValues?.dateTime,
139
+ companyTimezone,
140
+ )
141
+ );
135
142
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
136
143
  const [selectedOption, setSelectedOption] = useState<string>('');
137
144
  const [selectedStartTime, setSelectedStartTime] = useState<string>('');
@@ -139,10 +146,16 @@ export function PrivateShuttleBookingFlow({
139
146
  const [passengerCount, setPassengerCount] = useState<number>(
140
147
  () => initialBooking?.privateShuttleDetails?.passengerCount ?? initialValues?.passengers ?? 1
141
148
  );
142
- const [email, setEmail] = useState('');
143
- const [firstName, setFirstName] = useState('');
144
- const [lastName, setLastName] = useState('');
145
- const [phoneNumber, setPhoneNumber] = useState('');
149
+ const [email, setEmail] = useState(() => initialBooking?.customer?.email?.trim() ?? '');
150
+ const [firstName, setFirstName] = useState(
151
+ () => initialBooking?.customer?.firstName?.trim() ?? ''
152
+ );
153
+ const [lastName, setLastName] = useState(
154
+ () => initialBooking?.customer?.lastName?.trim() ?? ''
155
+ );
156
+ const [phoneNumber, setPhoneNumber] = useState(
157
+ () => initialBooking?.customer?.phoneNumber?.trim() ?? ''
158
+ );
146
159
  const [pickupLocationId, setPickupLocationId] = useState<string | null>(
147
160
  () => initialBooking?.pickupLocationId ?? initialValues?.pickupLocationId ?? null
148
161
  );
@@ -544,14 +557,14 @@ export function PrivateShuttleBookingFlow({
544
557
  }, [isAdmin, isCustomTimeMode, selectedStartTime, suggestedStartTimes]);
545
558
 
546
559
  useEffect(() => {
547
- if (
548
- pricingConfig?.cancellationPolicies &&
549
- pricingConfig.cancellationPolicies.length > 0 &&
550
- !cancellationPolicyId
551
- ) {
552
- setCancellationPolicyId(pricingConfig.cancellationPolicies[0].id);
553
- }
554
- }, [pricingConfig?.cancellationPolicies, cancellationPolicyId]);
560
+ const policies = pricingConfig?.cancellationPolicies;
561
+ if (!policies?.length || forcedCancellationPolicy) return;
562
+ const resolved = resolvePrivateShuttleCancellationPolicyId(
563
+ cancellationPolicyId,
564
+ policies.map((policy) => policy.id),
565
+ );
566
+ if (resolved !== cancellationPolicyId) setCancellationPolicyId(resolved);
567
+ }, [pricingConfig?.cancellationPolicies, cancellationPolicyId, forcedCancellationPolicy]);
555
568
 
556
569
  const handleOptionSelect = (optionId: string) => {
557
570
  setSelectedOption(optionId);
@@ -808,6 +821,7 @@ export function PrivateShuttleBookingFlow({
808
821
  isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
809
822
  onDateSelect={handleDateSelect}
810
823
  timezone={companyTimezone}
824
+ syncVisibleWeekToSelectedDate={isProviderChangeMode || Boolean(initialValues?.dateTime)}
811
825
  displayMode="status"
812
826
  earliestDate={earliestAvailabilityDate}
813
827
  onVisibleRangeChange={handleVisibleRangeChange}
@@ -123,7 +123,8 @@ export function PrivateShuttleCheckoutSection({
123
123
  partnerDeferredInvoiceSubmitLabel,
124
124
  }: PrivateShuttleCheckoutSectionProps) {
125
125
  const amountDueToday = depositInfo ? depositInfo.depositAmount : totalPrice;
126
- const formattedAmountDueToday = formatCurrencyAmount(amountDueToday, currency, locale);
126
+ const displayedTotal = isProviderChangeMode ? totalPrice : amountDueToday;
127
+ const formattedDisplayedTotal = formatCurrencyAmount(displayedTotal, currency, locale);
127
128
  const hasCancellationPolicies = Boolean(cancellationPolicies?.length || forcedCancellationPolicy);
128
129
 
129
130
  return (
@@ -131,7 +132,7 @@ export function PrivateShuttleCheckoutSection({
131
132
  <div className={`${styles.section} mt-6`}>
132
133
  <PriceSummary
133
134
  lines={priceSummaryLines}
134
- total={amountDueToday}
135
+ total={displayedTotal}
135
136
  currency={currency}
136
137
  locale={locale}
137
138
  taxAmount={effectiveTaxAmount}
@@ -158,7 +159,7 @@ export function PrivateShuttleCheckoutSection({
158
159
  size="sm"
159
160
  t={t}
160
161
  depositMode={
161
- depositInfo
162
+ !isProviderChangeMode && depositInfo
162
163
  ? {
163
164
  totalLabel: t('booking.deposit') || 'Deposit',
164
165
  balanceAmount: depositInfo.balanceAmount,
@@ -166,9 +167,15 @@ export function PrivateShuttleCheckoutSection({
166
167
  }
167
168
  : undefined
168
169
  }
169
- hideSubtotal={!!depositInfo}
170
+ hideSubtotal={!isProviderChangeMode && !!depositInfo}
170
171
  />
171
- {depositInfo && (
172
+ {isProviderChangeMode ? (
173
+ <div className="mt-3 rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900">
174
+ This is the updated booking total. Payments already made stay with this booking and are
175
+ applied first; only the remaining balance will be recorded as owing.
176
+ </div>
177
+ ) : null}
178
+ {!isProviderChangeMode && depositInfo && (
172
179
  <div className="deposit-notice mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
173
180
  <p className="text-xs font-medium">
174
181
  {(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice'
@@ -190,51 +197,78 @@ export function PrivateShuttleCheckoutSection({
190
197
 
191
198
  <div className="border-t border-stone-200 pt-6 space-y-4">
192
199
  <div className="space-y-4">
193
- <div className={styles.contactGrid}>
194
- <div>
195
- <label className={styles.sectionLabel}>
196
- {t('booking.firstName')} <span className={styles.required}>*</span>
197
- </label>
198
- <input
199
- type="text"
200
- value={firstName}
201
- onChange={(e) => onFirstNameChange(e.target.value)}
202
- placeholder={t('booking.firstNamePlaceholder')}
203
- className={styles.input}
204
- />
200
+ {isProviderChangeMode ? (
201
+ <div className="rounded-lg border border-stone-200 bg-stone-50 p-4">
202
+ <p className="m-0 text-sm font-semibold text-stone-900">Existing booking contact</p>
203
+ <div className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
204
+ <div>
205
+ <span className="block text-xs font-medium text-stone-500">Name</span>
206
+ <span className="text-stone-900">
207
+ {[firstName, lastName].filter(Boolean).join(' ') || 'Not provided'}
208
+ </span>
209
+ </div>
210
+ <div>
211
+ <span className="block text-xs font-medium text-stone-500">Email</span>
212
+ <span className="text-stone-900">{email || 'Not provided'}</span>
213
+ </div>
214
+ <div>
215
+ <span className="block text-xs font-medium text-stone-500">Phone</span>
216
+ <span className="text-stone-900">{phoneNumber || 'Not provided'}</span>
217
+ </div>
218
+ </div>
219
+ <p className="mb-0 mt-3 text-xs text-stone-600">
220
+ These customer details stay unchanged on the existing booking.
221
+ </p>
205
222
  </div>
206
- <div>
207
- <label className={styles.sectionLabel}>
208
- {t('booking.lastName')} <span className={styles.required}>*</span>
209
- </label>
210
- <input
211
- type="text"
212
- value={lastName}
213
- onChange={(e) => onLastNameChange(e.target.value)}
214
- placeholder={t('booking.lastNamePlaceholder')}
215
- className={styles.input}
223
+ ) : (
224
+ <>
225
+ <div className={styles.contactGrid}>
226
+ <div>
227
+ <label className={styles.sectionLabel}>
228
+ {t('booking.firstName')} <span className={styles.required}>*</span>
229
+ </label>
230
+ <input
231
+ type="text"
232
+ value={firstName}
233
+ onChange={(e) => onFirstNameChange(e.target.value)}
234
+ placeholder={t('booking.firstNamePlaceholder')}
235
+ className={styles.input}
236
+ />
237
+ </div>
238
+ <div>
239
+ <label className={styles.sectionLabel}>
240
+ {t('booking.lastName')} <span className={styles.required}>*</span>
241
+ </label>
242
+ <input
243
+ type="text"
244
+ value={lastName}
245
+ onChange={(e) => onLastNameChange(e.target.value)}
246
+ placeholder={t('booking.lastNamePlaceholder')}
247
+ className={styles.input}
248
+ />
249
+ </div>
250
+ </div>
251
+ <div>
252
+ <label className={styles.sectionLabel}>
253
+ {t('common.emailForConfirmation')} <span className={styles.required}>*</span>
254
+ </label>
255
+ <input
256
+ type="email"
257
+ value={email}
258
+ onChange={(e) => onEmailChange(e.target.value)}
259
+ placeholder={t('common.emailPlaceholder')}
260
+ className={styles.input}
261
+ />
262
+ </div>
263
+ <CheckoutOptionalPhoneFields
264
+ idPrefix="shuttle"
265
+ labelClassName={styles.sectionLabel}
266
+ phoneNumber={phoneNumber}
267
+ onPhoneNumberChange={onPhoneNumberChange}
268
+ t={t}
216
269
  />
217
- </div>
218
- </div>
219
- <div>
220
- <label className={styles.sectionLabel}>
221
- {t('common.emailForConfirmation')} <span className={styles.required}>*</span>
222
- </label>
223
- <input
224
- type="email"
225
- value={email}
226
- onChange={(e) => onEmailChange(e.target.value)}
227
- placeholder={t('common.emailPlaceholder')}
228
- className={styles.input}
229
- />
230
- </div>
231
- <CheckoutOptionalPhoneFields
232
- idPrefix="shuttle"
233
- labelClassName={styles.sectionLabel}
234
- phoneNumber={phoneNumber}
235
- onPhoneNumberChange={onPhoneNumberChange}
236
- t={t}
237
- />
270
+ </>
271
+ )}
238
272
  {hasCancellationPolicies && (
239
273
  <div>
240
274
  <CancellationPolicySelector
@@ -250,10 +284,17 @@ export function PrivateShuttleCheckoutSection({
250
284
  onPolicySelect={onCancellationPolicySelect}
251
285
  forcedPolicy={forcedCancellationPolicy}
252
286
  rowSubtitle={
253
- <>
254
- <b>Your deposit today is 100% refundable</b> - only once we align on an itinerary
255
- that you approve, will your deposit be locked in.
256
- </>
287
+ isProviderChangeMode ? (
288
+ <>
289
+ This policy applies to the updated private booking. Payments already made stay
290
+ on the existing booking.
291
+ </>
292
+ ) : (
293
+ <>
294
+ <b>Your deposit today is 100% refundable</b> - only once we align on an itinerary
295
+ that you approve, will your deposit be locked in.
296
+ </>
297
+ )
257
298
  }
258
299
  />
259
300
  </div>
@@ -327,8 +368,8 @@ export function PrivateShuttleCheckoutSection({
327
368
  ? 'Processing...'
328
369
  : (t('booking.creatingReservation') || 'Creating reservation...')
329
370
  : isProviderChangeMode
330
- ? `Change booking (${formattedAmountDueToday})`
331
- : `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formattedAmountDueToday})`}
371
+ ? `Change booking (updated total ${formattedDisplayedTotal})`
372
+ : `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formattedDisplayedTotal})`}
332
373
  </button>
333
374
  <p className={styles.secureNote}>
334
375
  {t('booking.securePayment') || 'Secure payment powered by Stripe'}
@@ -0,0 +1,44 @@
1
+ import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
2
+
3
+ export type AdminAdjustmentComponentOption = {
4
+ componentId: string;
5
+ label: string;
6
+ amount: number;
7
+ type: string;
8
+ };
9
+
10
+ const NON_SELECTABLE_TYPES = new Set([
11
+ 'TAX',
12
+ 'ROUNDING',
13
+ 'SYSTEM',
14
+ 'PROMO_CODE',
15
+ 'GIFT_CARD',
16
+ 'DEAL',
17
+ 'DISCOUNT',
18
+ 'BOOKING_CHANGE',
19
+ ]);
20
+
21
+ export function selectableAdminAdjustmentComponentsFromQuote(
22
+ quote: ChangeBookingQuoteResponse,
23
+ ): AdminAdjustmentComponentOption[] {
24
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
25
+ const lines = pricingQuote?.amendment?.updatedReceipt?.lineItems ?? [];
26
+ const byId = new Map<string, AdminAdjustmentComponentOption>();
27
+
28
+ lines.forEach((line) => {
29
+ const componentId = line.identity?.componentId?.trim() || line.reference?.trim();
30
+ const type = line.type?.trim().toUpperCase() || 'ITEM';
31
+ const amount = Number(line.amount ?? 0);
32
+ if (!componentId || !Number.isFinite(amount) || amount <= 0 || NON_SELECTABLE_TYPES.has(type)) {
33
+ return;
34
+ }
35
+ byId.set(componentId, {
36
+ componentId,
37
+ label: line.label?.trim() || type,
38
+ amount,
39
+ type,
40
+ });
41
+ });
42
+
43
+ return [...byId.values()];
44
+ }
@@ -0,0 +1,50 @@
1
+ import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
2
+
3
+ export type AdminAdjustmentTaxBehavior = AdminCustomReceiptLine['taxBehavior'];
4
+
5
+ export type AdminAdjustmentTaxBehaviorOption = {
6
+ value: AdminAdjustmentTaxBehavior;
7
+ label: string;
8
+ };
9
+
10
+ const CHARGE_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
11
+ { value: 'TAXABLE', label: 'Taxable' },
12
+ { value: 'NON_TAXABLE', label: 'Non-taxable' },
13
+ ];
14
+
15
+ const CREDIT_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
16
+ { value: 'PRE_TAX_DISCOUNT', label: 'Pre-tax discount' },
17
+ { value: 'POST_TAX_DISCOUNT', label: 'Post-tax discount' },
18
+ ];
19
+
20
+ export function adminAdjustmentTaxBehaviorOptions(
21
+ amountSign: AdminCustomReceiptLine['amountSign'],
22
+ ): readonly AdminAdjustmentTaxBehaviorOption[] {
23
+ return amountSign === -1 ? CREDIT_OPTIONS : CHARGE_OPTIONS;
24
+ }
25
+
26
+ export function normalizeAdminAdjustmentTaxBehavior(
27
+ amountSign: AdminCustomReceiptLine['amountSign'],
28
+ current: AdminAdjustmentTaxBehavior,
29
+ ): AdminAdjustmentTaxBehavior {
30
+ const options = adminAdjustmentTaxBehaviorOptions(amountSign);
31
+ return options.some((option) => option.value === current)
32
+ ? current
33
+ : options[0].value;
34
+ }
35
+
36
+ export type AdminAdjustmentScope = AdminCustomReceiptLine['scope'];
37
+
38
+ /**
39
+ * New custom lines start as charges against added value. A credit against that
40
+ * scope has no basis until the amendment also adds priced value, so switching
41
+ * direction defaults to the existing active tickets instead.
42
+ */
43
+ export function normalizeAdminAdjustmentScopeForSign(
44
+ amountSign: AdminCustomReceiptLine['amountSign'],
45
+ current: AdminAdjustmentScope,
46
+ ): AdminAdjustmentScope {
47
+ return amountSign === -1 && current === 'POSITIVE_AMENDMENT_VALUE'
48
+ ? 'CURRENT_ACTIVE_TICKETS'
49
+ : current;
50
+ }
@@ -66,6 +66,16 @@ export function buildChangeBookingMinimumQuantities({
66
66
  return minimums;
67
67
  }
68
68
 
69
+ export function shouldLockExistingAddOnQuantities({
70
+ isCustomerSelfServeChange,
71
+ isAdmin,
72
+ }: {
73
+ isCustomerSelfServeChange: boolean;
74
+ isAdmin: boolean;
75
+ }): boolean {
76
+ return isCustomerSelfServeChange && !isAdmin;
77
+ }
78
+
69
79
  export function resolveInitialChangeProductOptionId({
70
80
  initialProductId,
71
81
  initialProductOptionId,
@@ -39,6 +39,7 @@ export interface BuildAdminChangeProviderPayloadParams {
39
39
  providerPricingOverrides: AdminChangeProviderLineOverride[];
40
40
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
41
41
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
42
+ adminReverseAdjustmentIds?: string[];
42
43
  refundDispositionsByOperationId: NonNullable<
43
44
  ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
44
45
  >;
@@ -67,6 +68,7 @@ export function buildAdminChangeProviderPayload({
67
68
  providerPricingOverrides,
68
69
  mergedProviderAdditionalAdjustments,
69
70
  adminStructuredAdjustments,
71
+ adminReverseAdjustmentIds = [],
70
72
  refundDispositionsByOperationId,
71
73
  pricingV2QuoteId,
72
74
  pricingV2QuotedTotal,
@@ -114,6 +116,7 @@ export function buildAdminChangeProviderPayload({
114
116
  : undefined,
115
117
  authoritativeReceipt: providerApplyAuthoritativeReceipt,
116
118
  structuredAdjustments: adminStructuredAdjustments,
119
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
117
120
  refundDispositionsByOperationId,
118
121
  pricingV2QuoteId: pricingV2QuoteId ?? null,
119
122
  pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
@@ -15,10 +15,36 @@ interface AdminChangeQuoteRequestKeyInput {
15
15
  promoCode?: string | null;
16
16
  promoApplicationScope?: AdminPromoApplicationScope | null;
17
17
  structuredAdjustments?: unknown[];
18
+ reverseAdjustmentIds?: string[];
18
19
  refundDispositionsByOperationId?: Record<string, string>;
19
20
  useAdminFeAuthoritativeQuote: boolean;
20
21
  }
21
22
 
23
+ interface AdminChangeQuoteRequestGateInput {
24
+ isCustomerSelfServeChange: boolean;
25
+ hasEffectiveChangeSelection: boolean;
26
+ hasSelectedAvailability: boolean;
27
+ bookingReference?: string | null;
28
+ lastName: string;
29
+ missingRequiredReturnSelection: boolean;
30
+ }
31
+
32
+ /**
33
+ * Whether the current admin inputs are complete enough to request an authoritative quote.
34
+ * `hasEffectiveChangeSelection` deliberately includes adjustment-only changes, even when
35
+ * product, tickets, return, pickup, and itinerary are otherwise unchanged.
36
+ */
37
+ export function canRequestAdminChangeQuote(input: AdminChangeQuoteRequestGateInput): boolean {
38
+ return Boolean(
39
+ input.isCustomerSelfServeChange &&
40
+ input.hasEffectiveChangeSelection &&
41
+ input.hasSelectedAvailability &&
42
+ input.bookingReference?.trim() &&
43
+ input.lastName.trim() &&
44
+ !input.missingRequiredReturnSelection,
45
+ );
46
+ }
47
+
22
48
  /**
23
49
  * Identity of a price request. This must contain user-controlled quote inputs only.
24
50
  * Server/display-derived receipt state must never invalidate the response that produced it.
@@ -41,6 +67,7 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
41
67
  promoCode: input.promoCode?.trim().toUpperCase() ?? null,
42
68
  promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
43
69
  structuredAdjustments: input.structuredAdjustments ?? [],
70
+ reverseAdjustmentIds: [...(input.reverseAdjustmentIds ?? [])].sort(),
44
71
  refundDispositionsByOperationId: Object.fromEntries(
45
72
  Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
46
73
  ),
@@ -14,7 +14,7 @@ import {
14
14
 
15
15
  export type AdminChangeV2QuoteRequest = ChangeBookingQuoteRequest & {
16
16
  cancellationPolicyId?: string | null;
17
- structuredAdjustments?: unknown[];
17
+ includeSelectableAdjustmentComponents?: boolean;
18
18
  };
19
19
 
20
20
  export interface BuildAdminChangeV2QuoteRequestParams {
@@ -30,7 +30,9 @@ export interface BuildAdminChangeV2QuoteRequestParams {
30
30
  cancellationPolicyId: string | null;
31
31
  promoCode?: string | null;
32
32
  promoApplicationScope?: AdminPromoApplicationScope;
33
- structuredAdjustments: unknown[];
33
+ structuredAdjustments: NonNullable<ChangeBookingQuoteRequest['structuredAdjustments']>;
34
+ reverseAdjustmentIds?: string[];
35
+ includeSelectableAdjustmentComponents?: boolean;
34
36
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
35
37
  previousPassengerCount: number;
36
38
  previousAvailabilityId: string | null;
@@ -56,6 +58,8 @@ export function buildAdminChangeV2QuoteRequest({
56
58
  promoCode,
57
59
  promoApplicationScope = 'CHANGE_ONLY',
58
60
  structuredAdjustments,
61
+ reverseAdjustmentIds = [],
62
+ includeSelectableAdjustmentComponents = false,
59
63
  refundDispositionsByOperationId,
60
64
  previousPassengerCount,
61
65
  previousAvailabilityId,
@@ -77,6 +81,10 @@ export function buildAdminChangeV2QuoteRequest({
77
81
  promoCode: promoCode?.trim() || null,
78
82
  promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
79
83
  structuredAdjustments,
84
+ reverseAdjustmentIds,
85
+ ...(includeSelectableAdjustmentComponents
86
+ ? { includeSelectableAdjustmentComponents: true }
87
+ : {}),
80
88
  ...(Object.keys(refundDispositionsByOperationId).length > 0
81
89
  ? { refundDispositionsByOperationId }
82
90
  : {}),
@@ -83,3 +83,40 @@ export function selectRefundDispositionsForOperations(
83
83
  ),
84
84
  );
85
85
  }
86
+
87
+ /**
88
+ * Staff removals default to a pending refund. The refund remains a separate,
89
+ * reviewed action; this only removes the required choice from the change form.
90
+ */
91
+ export function defaultPendingRefundDispositions(
92
+ operations: readonly AdminAmendmentOperationSnapshot[],
93
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
94
+ ): Record<string, AdminAmendmentRefundDisposition> {
95
+ if (operations.length === 0) return dispositionsByOperationId;
96
+
97
+ return operations.reduce<Record<string, AdminAmendmentRefundDisposition>>(
98
+ (next, operation) => {
99
+ next[operation.operationId] = 'PENDING_REFUND';
100
+ return next;
101
+ },
102
+ { ...dispositionsByOperationId },
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Omitting a disposition for a return-price treatment operation tells the server
108
+ * to retain the original paid return-price floor.
109
+ */
110
+ export function defaultKeepOriginalReturnPriceDispositions(
111
+ operations: readonly AdminAmendmentOperationSnapshot[],
112
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
113
+ ): Record<string, AdminAmendmentRefundDisposition> {
114
+ if (operations.length === 0) return dispositionsByOperationId;
115
+
116
+ const returnOperationIds = new Set(operations.map((operation) => operation.operationId));
117
+ return Object.fromEntries(
118
+ Object.entries(dispositionsByOperationId).filter(
119
+ ([operationId]) => !returnOperationIds.has(operationId),
120
+ ),
121
+ );
122
+ }