@ticketboothapp/booking 1.2.165 → 1.2.167

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +97 -32
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
  5. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +9 -2
  7. package/src/components/booking/NewBookingFlow.tsx +14 -2
  8. package/src/components/booking/PriceBreakdown.tsx +66 -15
  9. package/src/components/booking/PriceSummary.tsx +12 -0
  10. package/src/components/booking/PrivateShuttleBookingFlow.tsx +25 -1
  11. package/src/components/booking/admin-change-payment-choice-runner.ts +12 -0
  12. package/src/components/booking/admin-change-provider-payload.ts +9 -1
  13. package/src/components/booking/admin-change-quote-request-key.ts +6 -6
  14. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  15. package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
  16. package/src/components/booking/admin-refund-decision-context.ts +85 -0
  17. package/src/components/booking/availability-date-selection.ts +16 -0
  18. package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
  19. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  20. package/src/components/booking/useAdminChangeCheckoutController.ts +90 -11
  21. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
  22. package/src/components/booking/useAdminChangeQuotePreview.ts +47 -37
  23. package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
  24. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  25. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  26. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  27. package/src/lib/booking/booking-cutoffs.ts +22 -0
  28. package/src/lib/booking/change-booking-server-preview.ts +75 -5
  29. package/src/lib/booking-api.ts +12 -1
  30. package/test/change-booking-helpers.test.ts +466 -3
@@ -8,8 +8,8 @@ import {
8
8
  } from 'react';
9
9
  import {
10
10
  quoteChangeBooking,
11
- quoteAdminChangeBookingV2,
12
11
  type AdminAmendmentRefundDisposition,
12
+ type AdminPromoApplicationScope,
13
13
  type AdminFeAuthoritativeReceipt,
14
14
  type Availability,
15
15
  type ItineraryDisplayStep,
@@ -31,6 +31,10 @@ import {
31
31
  buildAdminChangeQuoteRequestKey,
32
32
  canReuseCompletedAdminChangeQuote,
33
33
  } from './admin-change-quote-request-key';
34
+ import {
35
+ buildAdminChangeV2QuoteRequest,
36
+ quoteAdminChangeV2ForUi,
37
+ } from './admin-change-v2-quote-request';
34
38
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
35
39
 
36
40
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
@@ -63,6 +67,8 @@ export interface UseAdminChangeQuotePreviewParams {
63
67
  quantities: Record<string, number>;
64
68
  addOnSelections: AddOnSelection[];
65
69
  cancellationPolicyId: string | null;
70
+ appliedPromoCode: string | null;
71
+ promoApplicationScope: AdminPromoApplicationScope;
66
72
  adminCustomReceiptLines: AdminCustomReceiptLine[];
67
73
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
68
74
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
@@ -122,6 +128,8 @@ export function useAdminChangeQuotePreview({
122
128
  quantities,
123
129
  addOnSelections,
124
130
  cancellationPolicyId,
131
+ appliedPromoCode,
132
+ promoApplicationScope,
125
133
  adminCustomReceiptLines,
126
134
  adminStructuredAdjustments,
127
135
  refundDispositionsByOperationId,
@@ -182,6 +190,8 @@ export function useAdminChangeQuotePreview({
182
190
  addOnSelections,
183
191
  adminCustomReceiptLines,
184
192
  cancellationPolicyId,
193
+ promoCode: appliedPromoCode,
194
+ promoApplicationScope,
185
195
  structuredAdjustments: adminStructuredAdjustments,
186
196
  refundDispositionsByOperationId,
187
197
  useAdminFeAuthoritativeQuote,
@@ -196,6 +206,8 @@ export function useAdminChangeQuotePreview({
196
206
  addOnSelections,
197
207
  adminCustomReceiptLines,
198
208
  cancellationPolicyId,
209
+ appliedPromoCode,
210
+ promoApplicationScope,
199
211
  adminStructuredAdjustments,
200
212
  refundDispositionsByOperationId,
201
213
  useAdminFeAuthoritativeQuote,
@@ -445,44 +457,44 @@ export function useAdminChangeQuotePreview({
445
457
  .filter(([, count]) => count > 0)
446
458
  .map(([category, count]) => ({ category, count }));
447
459
  try {
448
- const quoteRequestBase = {
460
+ const quoteRequestBase = buildAdminChangeV2QuoteRequest({
449
461
  bookingReference: bookingReferenceForQuote,
450
- lastName: lastName.trim(),
451
- newProductId: optionId,
452
- newParentProductId: product.productId,
453
- newDateTime: selectedAvailability.dateTime,
454
- newAvailabilityId: selectedAvailability.availabilityId || null,
455
- newPickupLocationId: pickupLocationId || null,
456
- newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
457
- newPassengerCounts: bookingItems,
458
- // Admin owns the complete target selection. [] intentionally clears all add-ons.
459
- newAddOnSelections: addOnSelections,
460
- cancellationPolicyId,
462
+ lastName,
463
+ parentProductId: product.productId,
464
+ optionId,
465
+ selectedAvailability,
466
+ pickupLocationId,
467
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
468
+ bookingItems,
469
+ addOnSelections,
470
+ cancellationPolicyId: cancellationPolicyId ?? null,
471
+ promoCode: appliedPromoCode,
472
+ promoApplicationScope,
461
473
  structuredAdjustments: adminStructuredAdjustments,
462
- ...(Object.keys(refundDispositionsByOperationId).length > 0
463
- ? { refundDispositionsByOperationId }
464
- : {}),
465
- clientProposedTotal: changeFlowNewBookingTotal,
466
- capacitySeatCredit: {
467
- enabled: true,
468
- previousPassengerCount: changeFlowInitialTicketCount,
469
- previousAvailabilityId: initialValues.availabilityId ?? null,
470
- previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
471
- },
472
- };
473
- const quote = useAdminFeAuthoritativeQuote
474
- ? await quoteAdminChangeBookingV2({
474
+ refundDispositionsByOperationId,
475
+ previousPassengerCount: changeFlowInitialTicketCount,
476
+ previousAvailabilityId: initialValues.availabilityId ?? null,
477
+ previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
478
+ });
479
+ const mergedQuote = useAdminFeAuthoritativeQuote
480
+ ? await quoteAdminChangeV2ForUi(
481
+ quoteRequestBase,
482
+ {
483
+ total: changeFlowNewBookingTotal,
484
+ subtotal: effectiveSubtotalForCheckout,
485
+ tax: effectiveTax,
486
+ },
487
+ currency,
488
+ )
489
+ : sliceAndMergeChangeQuoteForUi(await quoteChangeBooking({
475
490
  ...quoteRequestBase,
476
- // V2 is server-authoritative. Do not bind it to the outbound-only FE estimate.
477
- clientProposedTotal: undefined,
478
- })
479
- : await quoteChangeBooking(quoteRequestBase);
491
+ clientProposedTotal: changeFlowNewBookingTotal,
492
+ }), {
493
+ total: changeFlowNewBookingTotal,
494
+ subtotal: effectiveSubtotalForCheckout,
495
+ tax: effectiveTax,
496
+ }, currency).mergedQuote;
480
497
  if (seq !== changeQuoteRequestSeq.current) return;
481
- const mergedQuote = sliceAndMergeChangeQuoteForUi(quote, {
482
- total: changeFlowNewBookingTotal,
483
- subtotal: effectiveSubtotalForCheckout,
484
- tax: effectiveTax,
485
- }, currency).mergedQuote;
486
498
  // Commit the completed identity before publishing its result. This keeps a
487
499
  // render caused by setLatestChangeQuote from being interpreted as a new request.
488
500
  lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
@@ -509,8 +521,6 @@ export function useAdminChangeQuotePreview({
509
521
  }, [
510
522
  // Fetching is controlled by the canonical user-input identity. Do not add quote-derived
511
523
  // totals, receipt objects, or display lines here: a response must not trigger another quote.
512
- // Quote presence is intentionally included so a cleared transient quote can be restored by
513
- // refetching the same canonical input identity after a nested checkout is closed.
514
524
  isCustomerSelfServeChange,
515
525
  hasChangeSelection,
516
526
  missingRequiredReturnSelection,
@@ -29,6 +29,7 @@ export function useBookingAvailabilityAddOns({
29
29
  () => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null),
30
30
  [selectedAvailability],
31
31
  );
32
+ const availabilityDateTime = selectedAvailability?.dateTime?.trim() || null;
32
33
 
33
34
  useEffect(() => {
34
35
  if (!availabilityProductOptionId || !companyId) {
@@ -41,10 +42,14 @@ export function useBookingAvailabilityAddOns({
41
42
  onProductOptionChange?.();
42
43
  previousAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
43
44
  }
44
- getAddOns(companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
45
+ getAddOns(companyId, {
46
+ productOptionId: availabilityProductOptionId,
47
+ preCheckout: true,
48
+ dateTime: availabilityDateTime ?? undefined,
49
+ })
45
50
  .then(setAddOns)
46
51
  .catch(() => setAddOns([]));
47
- }, [availabilityProductOptionId, companyId, onProductOptionChange]);
52
+ }, [availabilityProductOptionId, availabilityDateTime, companyId, onProductOptionChange]);
48
53
 
49
54
  return {
50
55
  addOns,
@@ -27,6 +27,9 @@ export interface UseBookingPromoAndQuantityControllerParams {
27
27
  export interface UseBookingPromoAndQuantityControllerResult {
28
28
  handleQuantityChange: (category: string, delta: number) => void;
29
29
  handleApplyPromo: () => Promise<void>;
30
+ handleRemovePromo: () => void;
31
+ promoCodeError: string;
32
+ promoCodeValidating: boolean;
30
33
  }
31
34
 
32
35
  export function useBookingPromoAndQuantityController({
@@ -47,8 +50,8 @@ export function useBookingPromoAndQuantityController({
47
50
  t,
48
51
  setError,
49
52
  }: UseBookingPromoAndQuantityControllerParams): UseBookingPromoAndQuantityControllerResult {
50
- const [, setPromoCodeError] = useState('');
51
- const [, setPromoCodeValidating] = useState(false);
53
+ const [promoCodeError, setPromoCodeError] = useState('');
54
+ const [promoCodeValidating, setPromoCodeValidating] = useState(false);
52
55
  const promoAppliedSelectionKeyRef = useRef<string | null>(null);
53
56
  const promoValidateInFlightRef = useRef(false);
54
57
 
@@ -156,8 +159,19 @@ export function useBookingPromoAndQuantityController({
156
159
  return () => clearTimeout(timer);
157
160
  }, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
158
161
 
162
+ const handleRemovePromo = useCallback(() => {
163
+ promoAppliedSelectionKeyRef.current = null;
164
+ setAppliedPromoCode(null);
165
+ setPromoCodeError('');
166
+ setPromoCodeValidating(false);
167
+ clearFetchedAvailabilityRanges();
168
+ }, [clearFetchedAvailabilityRanges, setAppliedPromoCode]);
169
+
159
170
  return {
160
171
  handleQuantityChange,
161
172
  handleApplyPromo,
173
+ handleRemovePromo,
174
+ promoCodeError,
175
+ promoCodeValidating,
162
176
  };
163
177
  }
@@ -1,7 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, type RefObject } from 'react';
3
+ import { useCallback, useEffect, useRef, type RefObject } from 'react';
4
4
  import type { Availability, PickupLocation } from '../../lib/booking-api';
5
+ import {
6
+ findFirstDateWithBookableAvailability,
7
+ selectedDateHasVisibleAvailability,
8
+ } from './availability-date-selection';
5
9
 
6
10
  export interface UseChangeBookingAutoSelectionsOptions {
7
11
  autoSelectFirstAvailableDate?: boolean;
@@ -71,6 +75,55 @@ export function useChangeBookingAutoSelections({
71
75
  const hasAutoSelectedPartnerDateRef = useRef(false);
72
76
  const hasAutoSelectedPartnerPickupRef = useRef(false);
73
77
 
78
+ const isBookableForAutoSelection = useCallback(
79
+ (availability: Availability): boolean => {
80
+ if (isAdmin) return (availability.vacancies ?? 0) > 0;
81
+ if (isCustomerSelfServeChange && changeFlowInitialTicketCount > 0) {
82
+ return getCalendarEffectiveOutboundVacancies(availability) >= changeFlowInitialTicketCount;
83
+ }
84
+ return (availability.vacancies ?? 0) > 0;
85
+ },
86
+ [
87
+ changeFlowInitialTicketCount,
88
+ getCalendarEffectiveOutboundVacancies,
89
+ isAdmin,
90
+ isCustomerSelfServeChange,
91
+ ],
92
+ );
93
+
94
+ useEffect(() => {
95
+ if (!autoSelectFirstAvailableDate) return;
96
+ if (isPartialLaunch) return;
97
+ if (!selectedDate || dates.length === 0) return;
98
+ if (selectedDateHasVisibleAvailability(selectedDate, getRowsForDate)) return;
99
+
100
+ const first = findFirstDateWithBookableAvailability(
101
+ dates,
102
+ getRowsForDate,
103
+ isBookableForAutoSelection,
104
+ );
105
+ if (!first || first === selectedDate) return;
106
+
107
+ hasAutoSelectedPartnerDateRef.current = true;
108
+ setSelectedDate(first);
109
+ onDateSelect(first);
110
+ if (!suppressCalendarDateScroll) {
111
+ scrollAfterCalendarSelection(contentRef, useWindowScroll);
112
+ }
113
+ }, [
114
+ autoSelectFirstAvailableDate,
115
+ isPartialLaunch,
116
+ selectedDate,
117
+ dates,
118
+ getRowsForDate,
119
+ isBookableForAutoSelection,
120
+ setSelectedDate,
121
+ onDateSelect,
122
+ suppressCalendarDateScroll,
123
+ contentRef,
124
+ useWindowScroll,
125
+ ]);
126
+
74
127
  useEffect(() => {
75
128
  if (!autoSelectFirstAvailableDate) return;
76
129
  if (isPartialLaunch) return;
@@ -79,18 +132,11 @@ export function useChangeBookingAutoSelections({
79
132
  if (dates.length === 0) return;
80
133
  if (hasAutoSelectedPartnerDateRef.current) return;
81
134
 
82
- const firstWithInventory = dates.find((date) => {
83
- const rows = getRowsForDate(date);
84
- if (rows.length === 0) return false;
85
- if (isAdmin) return rows.some((availability) => (availability.vacancies ?? 0) > 0);
86
- if (isCustomerSelfServeChange && changeFlowInitialTicketCount > 0) {
87
- return rows.some(
88
- (availability) =>
89
- getCalendarEffectiveOutboundVacancies(availability) >= changeFlowInitialTicketCount,
90
- );
91
- }
92
- return rows.some((availability) => (availability.vacancies ?? 0) > 0);
93
- });
135
+ const firstWithInventory = findFirstDateWithBookableAvailability(
136
+ dates,
137
+ getRowsForDate,
138
+ isBookableForAutoSelection,
139
+ );
94
140
  const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
95
141
  if (!first) return;
96
142
 
@@ -107,10 +153,7 @@ export function useChangeBookingAutoSelections({
107
153
  selectedDate,
108
154
  dates,
109
155
  getRowsForDate,
110
- isAdmin,
111
- isCustomerSelfServeChange,
112
- changeFlowInitialTicketCount,
113
- getCalendarEffectiveOutboundVacancies,
156
+ isBookableForAutoSelection,
114
157
  setSelectedDate,
115
158
  onDateSelect,
116
159
  suppressCalendarDateScroll,
@@ -3,9 +3,14 @@ import { parseISO } from 'date-fns';
3
3
  import type { Availability, Product, ReturnOption } from '../../lib/booking-api';
4
4
  import {
5
5
  availabilityMatchesSelectedDate,
6
+ findMergedAvailabilityForSelection,
6
7
  parseAvailabilityDateTime,
7
8
  pickDefaultAvailabilityForTimes,
8
9
  } from './standard-booking-availability';
10
+ import {
11
+ findFirstDateWithBookableAvailability,
12
+ selectedDateHasVisibleAvailability,
13
+ } from './availability-date-selection';
9
14
 
10
15
  interface UseStandardBookingAutoSelectionsParams {
11
16
  selectedDate: string;
@@ -91,19 +96,22 @@ export function useStandardBookingAutoSelections({
91
96
 
92
97
  useEffect(() => {
93
98
  if (!selectedDate || timesForSelectedDateLength === 0) return;
99
+ const eligibleTimes = getPickupCutoffEligibleTimesForDate(selectedDate);
94
100
  if (
95
101
  selectedAvailability &&
96
- availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)
102
+ availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone) &&
103
+ findMergedAvailabilityForSelection(eligibleTimes, selectedAvailability)
97
104
  ) {
98
105
  return;
99
106
  }
100
- const toSelect = pickDefaultAvailabilityForTimes(
101
- getPickupCutoffEligibleTimesForDate(selectedDate),
102
- activeOptions,
103
- );
107
+ const toSelect = pickDefaultAvailabilityForTimes(eligibleTimes, activeOptions);
104
108
  if (toSelect) {
105
109
  setSelectedAvailability(toSelect);
110
+ setSelectedReturnOption(null);
106
111
  setError('');
112
+ } else if (selectedAvailability) {
113
+ setSelectedAvailability(null);
114
+ setSelectedReturnOption(null);
107
115
  }
108
116
  }, [
109
117
  selectedDate,
@@ -114,9 +122,47 @@ export function useStandardBookingAutoSelections({
114
122
  companyTimezone,
115
123
  getPickupCutoffEligibleTimesForDate,
116
124
  setSelectedAvailability,
125
+ setSelectedReturnOption,
117
126
  setError,
118
127
  ]);
119
128
 
129
+ useEffect(() => {
130
+ if (!autoSelectFirstAvailableDate) return;
131
+ if (isPartialLaunch) return;
132
+ if (!selectedDate || dates.length === 0) return;
133
+ if (selectedDateHasVisibleAvailability(selectedDate, getPickupCutoffEligibleTimesForDate)) return;
134
+
135
+ const first = findFirstDateWithBookableAvailability(
136
+ dates,
137
+ getPickupCutoffEligibleTimesForDate,
138
+ (availability) => (availability.vacancies ?? 0) > 0,
139
+ );
140
+ if (!first || first === selectedDate) return;
141
+
142
+ hasAutoSelectedPartnerDateRef.current = true;
143
+ setSelectedDate(first);
144
+ setSelectedReturnOption(null);
145
+ setSelectedAvailability(
146
+ pickDefaultAvailabilityForTimes(getPickupCutoffEligibleTimesForDate(first), activeOptions),
147
+ );
148
+ if (!suppressCalendarDateScroll) {
149
+ scrollAfterCalendarSelection(contentRef, useWindowScroll);
150
+ }
151
+ }, [
152
+ autoSelectFirstAvailableDate,
153
+ isPartialLaunch,
154
+ selectedDate,
155
+ dates,
156
+ getPickupCutoffEligibleTimesForDate,
157
+ activeOptions,
158
+ setSelectedAvailability,
159
+ setSelectedDate,
160
+ setSelectedReturnOption,
161
+ suppressCalendarDateScroll,
162
+ contentRef,
163
+ useWindowScroll,
164
+ ]);
165
+
120
166
  useEffect(() => {
121
167
  if (!selectedAvailability?.returnOptions?.length || selectedReturnOption) return;
122
168
 
@@ -162,8 +208,10 @@ export function useStandardBookingAutoSelections({
162
208
  if (dates.length === 0) return;
163
209
  if (hasAutoSelectedPartnerDateRef.current) return;
164
210
 
165
- const firstWithInventory = dates.find((date) =>
166
- getPickupCutoffEligibleTimesForDate(date).some((availability) => (availability.vacancies ?? 0) > 0),
211
+ const firstWithInventory = findFirstDateWithBookableAvailability(
212
+ dates,
213
+ getPickupCutoffEligibleTimesForDate,
214
+ (availability) => (availability.vacancies ?? 0) > 0,
167
215
  );
168
216
  const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
169
217
  if (!first) return;
@@ -55,6 +55,28 @@ export function filterAvailabilitiesAfterPublicBookingCutoff<T extends { dateTim
55
55
  );
56
56
  }
57
57
 
58
+ /**
59
+ * Admin surfaces intentionally ignore the public advance-booking cutoff, but an
60
+ * availability whose actual start time has passed is no longer bookable by the
61
+ * API. Keep that narrower rule separate so admin and public behavior cannot
62
+ * accidentally inherit one another's cutoff policy.
63
+ */
64
+ export function isAvailabilityAfterCurrentTime(
65
+ availability: { dateTime: string },
66
+ now: Date = new Date(),
67
+ ): boolean {
68
+ return isAvailabilityAfterPublicBookingCutoff(availability, now, 0);
69
+ }
70
+
71
+ export function filterAvailabilitiesAfterCurrentTime<T extends { dateTime: string }>(
72
+ availabilities: T[],
73
+ now: Date = new Date(),
74
+ ): T[] {
75
+ return availabilities.filter((availability) =>
76
+ isAvailabilityAfterCurrentTime(availability, now),
77
+ );
78
+ }
79
+
58
80
  export function isDateWithinPublicPickupUnknownCutoff(date: Date, now: Date = new Date()): boolean {
59
81
  return date.getTime() <= now.getTime() + PUBLIC_PICKUP_UNKNOWN_CUTOFF_MS;
60
82
  }
@@ -4,7 +4,10 @@
4
4
  *
5
5
  * See `CHANGE_BOOKING_BE_HANDOFF.md` in this package for fields the API should populate.
6
6
  */
7
- import type { ChangeBookingQuoteResponse } from '../booking-api';
7
+ import type {
8
+ AdminAmendmentOperationSnapshot,
9
+ ChangeBookingQuoteResponse,
10
+ } from '../booking-api';
8
11
  import type { PricingV2QuoteLineSnapshot } from '../booking-api';
9
12
  import type { PriceBasisSnapshot } from '../booking-api';
10
13
  import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
@@ -207,6 +210,7 @@ export function mapQuoteLineItemsToPriceSummaryLines(
207
210
  : type;
208
211
  out.push({
209
212
  kind: 'ticket',
213
+ operationId: isPricingV2Line(item) ? item.metadata?.operationId : undefined,
210
214
  category,
211
215
  qty: qty > 0 ? qty : 1,
212
216
  itemTotal: amount,
@@ -228,6 +232,7 @@ export function mapQuoteLineItemsToPriceSummaryLines(
228
232
  ) {
229
233
  out.push({
230
234
  kind: 'ticket',
235
+ operationId: isPricingV2Line(item) ? item.metadata?.operationId : undefined,
231
236
  category: lettersOnly,
232
237
  qty,
233
238
  itemTotal: amount,
@@ -249,6 +254,64 @@ export function mapQuoteLineItemsToPriceSummaryLines(
249
254
  return out;
250
255
  }
251
256
 
257
+ /** Clarify ticket deltas caused by moving a booking without changing receipt semantics. */
258
+ export function labelAdminAmendmentPriceSummaryLines(
259
+ lines: PriceSummaryLine[],
260
+ operations: readonly AdminAmendmentOperationSnapshot[],
261
+ originalLines: readonly PriceSummaryLine[] = [],
262
+ ): PriceSummaryLine[] {
263
+ const movedServiceOperationIds = new Set(
264
+ operations
265
+ .filter((operation) => operation.type.trim().toUpperCase() === 'MOVE_SERVICE')
266
+ .map((operation) => operation.operationId),
267
+ );
268
+ if (movedServiceOperationIds.size === 0) return lines;
269
+
270
+ const normalizedTicketCategory = (category: string) => category
271
+ .replace(/^refund\s*[·:-]\s*/i, '')
272
+ .replace(/\s*[—-]\s*new date price difference\s*$/i, '')
273
+ .trim()
274
+ .toUpperCase();
275
+
276
+ return lines.map((line) => {
277
+ if (
278
+ line.kind !== 'ticket' ||
279
+ !line.operationId ||
280
+ !movedServiceOperationIds.has(line.operationId) ||
281
+ line.category.toLowerCase().includes('new date price difference')
282
+ ) {
283
+ return line;
284
+ }
285
+ const originalLine = originalLines.find(
286
+ (candidate) => candidate.kind === 'ticket' &&
287
+ normalizedTicketCategory(candidate.category) === normalizedTicketCategory(line.category),
288
+ );
289
+ const previousUnitAmount = originalLine?.kind === 'ticket' && originalLine.qty > 0
290
+ ? roundMoney(originalLine.itemTotal / originalLine.qty)
291
+ : null;
292
+ const differenceUnitAmount = line.qty > 0
293
+ ? roundMoney(line.itemTotal / line.qty)
294
+ : null;
295
+ const ticketLabel = normalizedTicketCategory(line.category).toLowerCase();
296
+ return {
297
+ ...line,
298
+ category: `${line.category} — new date price difference`,
299
+ ...(previousUnitAmount != null && differenceUnitAmount != null
300
+ ? {
301
+ unitPriceComparison: {
302
+ previousLabel: `Existing ${ticketLabel} price`,
303
+ updatedLabel: `New-date ${ticketLabel} price`,
304
+ differenceLabel: `Difference per ${ticketLabel}`,
305
+ previousUnitAmount,
306
+ updatedUnitAmount: roundMoney(previousUnitAmount + differenceUnitAmount),
307
+ differenceUnitAmount,
308
+ },
309
+ }
310
+ : {}),
311
+ };
312
+ });
313
+ }
314
+
252
315
  function signedAmountDueFromQuote(quote: ChangeBookingQuoteResponse): number {
253
316
  const pricingQuote = quote.pricingQuote ?? quote.quote;
254
317
  // Prefer explicit settlement fields over balanceDelta. Receipt-total math and balanceDelta
@@ -280,10 +343,17 @@ export function buildChangeBookingServerPreview(
280
343
  const paymentCreditTotal = pricingQuote?.paymentCreditTotal ?? quote.paymentCreditTotal;
281
344
 
282
345
  const rawLines = lineItemsForSummary(quote);
283
- const priceSummaryLines =
284
- rawLines && rawLines.length > 0
285
- ? withPaymentCreditSummaryLine(mapQuoteLineItemsToPriceSummaryLines(rawLines), paymentCreditTotal)
286
- : withPaymentCreditSummaryLine([], paymentCreditTotal);
346
+ const mappedLines = rawLines && rawLines.length > 0
347
+ ? mapQuoteLineItemsToPriceSummaryLines(rawLines)
348
+ : [];
349
+ const priceSummaryLines = withPaymentCreditSummaryLine(
350
+ labelAdminAmendmentPriceSummaryLines(
351
+ mappedLines,
352
+ pricingQuote?.amendment?.operations ?? [],
353
+ mapQuoteLineItemsToPriceSummaryLines(quote.originalReceipt?.lineItems),
354
+ ),
355
+ paymentCreditTotal,
356
+ );
287
357
 
288
358
  const completeness: ChangeBookingPreviewCompleteness = priceSummaryLines.length > 0 ? 'full' : 'totals_only';
289
359
 
@@ -1102,7 +1102,7 @@ export async function getPromoDiscount(
1102
1102
 
1103
1103
  export async function getAddOns(
1104
1104
  companyId: string,
1105
- options?: { productOptionId?: string; preCheckout?: boolean }
1105
+ options?: { productOptionId?: string; preCheckout?: boolean; dateTime?: string }
1106
1106
  ): Promise<AddOn[]> {
1107
1107
  const params = new URLSearchParams({ companyId });
1108
1108
  if (options?.productOptionId) {
@@ -1110,6 +1110,7 @@ export async function getAddOns(
1110
1110
  if (po) params.set('productOptionId', po);
1111
1111
  }
1112
1112
  if (options?.preCheckout !== undefined) params.set('preCheckout', String(options.preCheckout));
1113
+ if (options?.dateTime?.trim()) params.set('dateTime', options.dateTime.trim());
1113
1114
  const res = await fetchBookingGetWithRetry(`${API_BASE}/1/add-ons?${params}`);
1114
1115
  if (!res.ok) {
1115
1116
  const err = await res.json();
@@ -1251,6 +1252,8 @@ export type AdminAmendmentRefundDisposition =
1251
1252
  | 'PENDING_REFUND'
1252
1253
  | 'NO_REFUND';
1253
1254
 
1255
+ export type AdminPromoApplicationScope = 'CHANGE_ONLY' | 'ENTIRE_BOOKING';
1256
+
1254
1257
  export interface AdminAmendmentOperationSnapshot {
1255
1258
  operationId: string;
1256
1259
  type: string;
@@ -1276,6 +1279,9 @@ export interface ChangeBookingQuoteRequest {
1276
1279
  newReturnAvailabilityId?: string | null;
1277
1280
  newPassengerCounts?: Array<{ category: string; count: number }>;
1278
1281
  newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
1282
+ /** Explicit promo intent for this amendment. Historical booking promos are never implied. */
1283
+ promoCode?: string | null;
1284
+ promoApplicationScope?: AdminPromoApplicationScope | null;
1279
1285
  /** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
1280
1286
  clientProposedTotal?: number;
1281
1287
  /**
@@ -1354,6 +1360,7 @@ export interface ChangeBookingQuoteReceipt {
1354
1360
  amount?: number;
1355
1361
  type?: string;
1356
1362
  quantity?: number;
1363
+ priceBasis?: PriceBasisSnapshot | null;
1357
1364
  }>;
1358
1365
  }
1359
1366
 
@@ -1704,6 +1711,7 @@ export function mapAdminChangeBookingQuoteV2Data(
1704
1711
  amount: line.amount ?? undefined,
1705
1712
  type: line.type ?? undefined,
1706
1713
  quantity: line.quantity ?? undefined,
1714
+ priceBasis: line.priceBasis ?? undefined,
1707
1715
  })),
1708
1716
  },
1709
1717
  };
@@ -2239,6 +2247,9 @@ export interface PricingV2QuoteSnapshot {
2239
2247
  bookingReference?: string | null;
2240
2248
  reservationReference?: string | null;
2241
2249
  relatedChangeIntentId?: string | null;
2250
+ amendment?: {
2251
+ operations?: AdminAmendmentOperationSnapshot[] | null;
2252
+ } | null;
2242
2253
  metadata?: Record<string, string> | null;
2243
2254
  [key: string]: unknown;
2244
2255
  }