@ticketboothapp/booking 1.2.181 → 1.2.182

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.181",
3
+ "version": "1.2.182",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -69,13 +69,14 @@ export function NewBookingFlow({
69
69
  bookingSourceAttribution,
70
70
  partnerPortalBooking = false,
71
71
  availabilityPricingProfileId,
72
+ availabilityPricingProfileOverrides,
72
73
  availabilityCancellationPolicyProfileId,
73
74
  }: NewBookingFlowProps) {
74
75
  const { env, analytics, catalog } = useBookingHost();
75
76
  const { t } = useTranslations();
76
77
  const { locale } = useLocale();
77
78
  const companyTimezone = useCompanyTimezone(); // Get timezone from context
78
- const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
79
+ const fallbackPricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
79
80
  const cancellationPolicyProfileIdForAvailabilities =
80
81
  (availabilityCancellationPolicyProfileId ?? '').trim() || null;
81
82
  const {
@@ -162,6 +163,7 @@ export function NewBookingFlow({
162
163
  setSelectedReturnOption,
163
164
  selectedDate,
164
165
  setSelectedDate,
166
+ pricingProfileIdForAvailabilities,
165
167
  loadingAvailabilities,
166
168
  isFetchingMoreAvailabilities,
167
169
  pricingConfig,
@@ -182,7 +184,8 @@ export function NewBookingFlow({
182
184
  isAdmin,
183
185
  companyTimezone,
184
186
  appliedPromoCode,
185
- pricingProfileIdForAvailabilities,
187
+ pricingProfileIdForAvailabilities: fallbackPricingProfileIdForAvailabilities,
188
+ pricingProfileOverridesForAvailabilities: availabilityPricingProfileOverrides,
186
189
  cancellationPolicyProfileIdForAvailabilities,
187
190
  bookingCutoffNow,
188
191
  bookingCutoffMinutes,
@@ -59,6 +59,10 @@ import {
59
59
  resolveInitialPrivateShuttlePassengerCount,
60
60
  } from './private-shuttle-passenger-count';
61
61
  import { privateShuttleHiddenLunchAddOnId } from './private-shuttle-lunch-visibility';
62
+ import {
63
+ resolvePartnerPricingProfileId,
64
+ type PartnerPricingProfileOverride,
65
+ } from '../../lib/booking/partner-pricing-profile';
62
66
 
63
67
  interface PrivateShuttleBookingFlowProps {
64
68
  product: Product;
@@ -85,6 +89,8 @@ interface PrivateShuttleBookingFlowProps {
85
89
  partnerPortalBooking?: boolean;
86
90
  /** When set (e.g. partner portal), get-availabilities requests this pricing profile from the API. */
87
91
  availabilityPricingProfileId?: string | null;
92
+ /** Month-specific partner profiles resolved against selectedDate. */
93
+ availabilityPricingProfileOverrides?: readonly PartnerPricingProfileOverride[];
88
94
  /** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
89
95
  availabilityCancellationPolicyProfileId?: string | null;
90
96
  initialValues?: {
@@ -117,6 +123,7 @@ export function PrivateShuttleBookingFlow({
117
123
  bookingSourceAttribution,
118
124
  partnerPortalBooking = false,
119
125
  availabilityPricingProfileId,
126
+ availabilityPricingProfileOverrides,
120
127
  availabilityCancellationPolicyProfileId,
121
128
  initialValues,
122
129
  initialBooking,
@@ -126,7 +133,7 @@ export function PrivateShuttleBookingFlow({
126
133
  const { t } = useTranslations();
127
134
  const { locale } = useLocale();
128
135
  const companyTimezone = useCompanyTimezone();
129
- const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
136
+ const fallbackPricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
130
137
  const cancellationPolicyProfileIdForAvailabilities =
131
138
  (availabilityCancellationPolicyProfileId ?? '').trim() || null;
132
139
  const {
@@ -145,6 +152,19 @@ export function PrivateShuttleBookingFlow({
145
152
  companyTimezone,
146
153
  )
147
154
  );
155
+ const pricingProfileIdForAvailabilities = useMemo(
156
+ () => resolvePartnerPricingProfileId(
157
+ fallbackPricingProfileIdForAvailabilities,
158
+ availabilityPricingProfileOverrides,
159
+ selectedDate || formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd'),
160
+ ),
161
+ [
162
+ availabilityPricingProfileOverrides,
163
+ companyTimezone,
164
+ fallbackPricingProfileIdForAvailabilities,
165
+ selectedDate,
166
+ ],
167
+ );
148
168
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
149
169
  const [selectedOption, setSelectedOption] = useState<string>('');
150
170
  const [selectedStartTime, setSelectedStartTime] = useState<string>('');
@@ -6,6 +6,7 @@ import type {
6
6
  Product,
7
7
  } from '../../lib/booking-api';
8
8
  import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
9
+ import type { PartnerPricingProfileOverride } from '../../lib/booking/partner-pricing-profile';
9
10
  import type { Currency } from './CurrencySwitcher';
10
11
  import type { PriceSummaryLine } from './PriceSummary';
11
12
  import type { BookingFlowUiOptions } from './booking-flow-ui';
@@ -129,6 +130,8 @@ export interface BookingFlowBaseProps {
129
130
  partnerPortalBooking?: boolean;
130
131
  /** When set (e.g. partner portal), get-availabilities requests this pricing profile from the API. */
131
132
  availabilityPricingProfileId?: string | null;
133
+ /** Month-specific partner pricing profiles resolved against the selected booking date. */
134
+ availabilityPricingProfileOverrides?: readonly PartnerPricingProfileOverride[];
132
135
  /** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
133
136
  availabilityCancellationPolicyProfileId?: string | null;
134
137
  /** Admin change-booking: available destination products for switching the booking to another product. */
@@ -40,6 +40,10 @@ import {
40
40
  shouldSyncSelectedAvailability,
41
41
  } from './standard-booking-availability';
42
42
  import { shouldRevalidateAvailabilityCache } from './availability-cache-policy';
43
+ import {
44
+ resolvePartnerPricingProfileId,
45
+ type PartnerPricingProfileOverride,
46
+ } from '../../lib/booking/partner-pricing-profile';
43
47
 
44
48
  interface UseStandardBookingAvailabilityParams {
45
49
  product: Product;
@@ -51,6 +55,7 @@ interface UseStandardBookingAvailabilityParams {
51
55
  companyTimezone: string;
52
56
  appliedPromoCode: string | null;
53
57
  pricingProfileIdForAvailabilities: string | null;
58
+ pricingProfileOverridesForAvailabilities?: readonly PartnerPricingProfileOverride[];
54
59
  cancellationPolicyProfileIdForAvailabilities: string | null;
55
60
  bookingCutoffNow: Date;
56
61
  bookingCutoffMinutes?: number | null;
@@ -152,7 +157,8 @@ export function useStandardBookingAvailability({
152
157
  isAdmin,
153
158
  companyTimezone,
154
159
  appliedPromoCode,
155
- pricingProfileIdForAvailabilities,
160
+ pricingProfileIdForAvailabilities: fallbackPricingProfileIdForAvailabilities,
161
+ pricingProfileOverridesForAvailabilities,
156
162
  cancellationPolicyProfileIdForAvailabilities,
157
163
  bookingCutoffNow,
158
164
  bookingCutoffMinutes,
@@ -179,6 +185,21 @@ export function useStandardBookingAvailability({
179
185
  const selectedDateHydrationInFlightKeyRef = useRef<string | null>(null);
180
186
  const lastVisibleRangeRef = useRef<DateRange | null>(null);
181
187
  const activeOptionsLength = activeOptions.length;
188
+ const pricingProfileResolutionDate = selectedDate || (visibleRange
189
+ ? formatInTimeZone(visibleRange.start, companyTimezone, 'yyyy-MM-dd')
190
+ : formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd'));
191
+ const pricingProfileIdForAvailabilities = useMemo(
192
+ () => resolvePartnerPricingProfileId(
193
+ fallbackPricingProfileIdForAvailabilities,
194
+ pricingProfileOverridesForAvailabilities,
195
+ pricingProfileResolutionDate,
196
+ ),
197
+ [
198
+ fallbackPricingProfileIdForAvailabilities,
199
+ pricingProfileOverridesForAvailabilities,
200
+ pricingProfileResolutionDate,
201
+ ],
202
+ );
182
203
 
183
204
  const applyPricingConfig = useCallback((next?: PricingConfig | null) => {
184
205
  setPricingConfig((prev) => {
@@ -415,6 +436,14 @@ export function useStandardBookingAvailability({
415
436
  }
416
437
  }, [companyTimezone, visibleRange]);
417
438
 
439
+ useEffect(() => {
440
+ fetchedRangesRef.current = [];
441
+ }, [
442
+ appliedPromoCode,
443
+ pricingProfileIdForAvailabilities,
444
+ cancellationPolicyProfileIdForAvailabilities,
445
+ ]);
446
+
418
447
  useEffect(() => {
419
448
  if (isPartialLaunch) {
420
449
  setLoadingAvailabilities(false);
@@ -605,14 +634,6 @@ export function useStandardBookingAvailability({
605
634
  visibleRange,
606
635
  ]);
607
636
 
608
- useEffect(() => {
609
- fetchedRangesRef.current = [];
610
- }, [
611
- appliedPromoCode,
612
- pricingProfileIdForAvailabilities,
613
- cancellationPolicyProfileIdForAvailabilities,
614
- ]);
615
-
616
637
  const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
617
638
  const lastRange = lastVisibleRangeRef.current;
618
639
  const rangeChanged =
@@ -797,6 +818,7 @@ export function useStandardBookingAvailability({
797
818
  selectedReturnOption,
798
819
  setSelectedReturnOption,
799
820
  selectedDate,
821
+ pricingProfileIdForAvailabilities,
800
822
  setSelectedDate,
801
823
  loadingAvailabilities,
802
824
  isFetchingMoreAvailabilities,
package/src/index.ts CHANGED
@@ -10,6 +10,11 @@ export {
10
10
  type PublicPartnerAgent,
11
11
  type PublicStaffPortalSignInOption,
12
12
  } from './public-partners';
13
+ export {
14
+ parsePartnerPricingProfileOverrides,
15
+ resolvePartnerPricingProfileId,
16
+ type PartnerPricingProfileOverride,
17
+ } from './lib/booking/partner-pricing-profile';
13
18
 
14
19
  /** Canonical Via Via booking UI — same modules as `@/components/booking/*` on the site. */
15
20
  export { BookingFlow } from './components/booking/BookingFlow';
@@ -0,0 +1,74 @@
1
+ export type PartnerPricingProfileOverride = {
2
+ pricingProfileId: string;
3
+ months?: number[];
4
+ startMonth?: number | null;
5
+ endMonth?: number | null;
6
+ };
7
+
8
+ function asRecord(value: unknown): Record<string, unknown> | null {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? (value as Record<string, unknown>)
11
+ : null;
12
+ }
13
+
14
+ function validMonth(value: unknown): number | null {
15
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 12
16
+ ? value
17
+ : null;
18
+ }
19
+
20
+ export function parsePartnerPricingProfileOverrides(
21
+ value: unknown,
22
+ ): PartnerPricingProfileOverride[] {
23
+ if (!Array.isArray(value)) return [];
24
+ return value.flatMap((row) => {
25
+ const record = asRecord(row);
26
+ const pricingProfileId =
27
+ typeof record?.pricingProfileId === 'string' ? record.pricingProfileId.trim() : '';
28
+ if (!pricingProfileId) return [];
29
+ const months = Array.isArray(record?.months)
30
+ ? record.months.map(validMonth).filter((month): month is number => month != null)
31
+ : [];
32
+ const startMonth = validMonth(record?.startMonth);
33
+ const endMonth = validMonth(record?.endMonth);
34
+ return [{
35
+ pricingProfileId,
36
+ ...(months.length > 0 ? { months } : {}),
37
+ ...(startMonth != null ? { startMonth } : {}),
38
+ ...(endMonth != null ? { endMonth } : {}),
39
+ }];
40
+ });
41
+ }
42
+
43
+ function bookingMonth(value: string | null | undefined): number | null {
44
+ const normalized = value?.trim();
45
+ if (!normalized) return null;
46
+ const match = /^(?:\d{4})-(\d{2})(?:-\d{2})?/.exec(normalized);
47
+ if (!match) return null;
48
+ const month = Number(match[1]);
49
+ return Number.isInteger(month) && month >= 1 && month <= 12 ? month : null;
50
+ }
51
+
52
+ function overrideMatchesMonth(override: PartnerPricingProfileOverride, month: number): boolean {
53
+ if (override.months?.length) return override.months.includes(month);
54
+ const start = override.startMonth;
55
+ const end = override.endMonth;
56
+ if (start == null || end == null) return false;
57
+ return start <= end ? month >= start && month <= end : month >= start || month <= end;
58
+ }
59
+
60
+ /** First matching seasonal override wins; the single profile remains the fallback. */
61
+ export function resolvePartnerPricingProfileId(
62
+ fallbackPricingProfileId: string | null | undefined,
63
+ overrides: readonly PartnerPricingProfileOverride[] | null | undefined,
64
+ bookingDate: string | null | undefined,
65
+ ): string | null {
66
+ const fallback = fallbackPricingProfileId?.trim() || null;
67
+ const month = bookingMonth(bookingDate);
68
+ if (month == null) return fallback;
69
+ const matchingProfile = overrides
70
+ ?.find((override) => overrideMatchesMonth(override, month))
71
+ ?.pricingProfileId
72
+ ?.trim();
73
+ return matchingProfile || fallback;
74
+ }
@@ -0,0 +1,138 @@
1
+ export interface ReservationAttemptStorage {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+
7
+ export interface ReservationAttemptContext<T extends object> {
8
+ idempotencyKey: string;
9
+ request: T & { idempotencyKey: string };
10
+ clear: () => void;
11
+ }
12
+
13
+ interface StoredReservationAttempt {
14
+ idempotencyKey: string;
15
+ createdAt: number;
16
+ }
17
+
18
+ export function reservationIdempotencyEnabled(
19
+ value: string | undefined = process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED
20
+ ): boolean {
21
+ return value?.trim().toLowerCase() === 'true';
22
+ }
23
+
24
+ export async function getOrCreateReservationAttempt<T extends object>(
25
+ request: T,
26
+ options: {
27
+ storage?: ReservationAttemptStorage | null;
28
+ now?: number;
29
+ createId?: () => string;
30
+ digest?: (value: string) => Promise<string>;
31
+ } = {}
32
+ ): Promise<ReservationAttemptContext<T>> {
33
+ const now = options.now ?? Date.now();
34
+ const requestWithoutKey = { ...request } as T & { idempotencyKey?: string };
35
+ delete requestWithoutKey.idempotencyKey;
36
+ const digest = options.digest ?? sha256Hex;
37
+ const fingerprint = await digest(stableStringify(requestWithoutKey));
38
+ const storageKey = `${STORAGE_PREFIX}${fingerprint}`;
39
+ const storage = options.storage === undefined ? browserSessionStorage() : options.storage;
40
+ const stored = readStoredAttempt(storage, storageKey, now);
41
+ const idempotencyKey = stored?.idempotencyKey ?? (options.createId ?? createAttemptId)();
42
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
43
+ throw new Error('Could not create a valid reservation attempt identifier.');
44
+ }
45
+ if (!stored) {
46
+ writeStoredAttempt(storage, storageKey, { idempotencyKey, createdAt: now });
47
+ }
48
+ return {
49
+ idempotencyKey,
50
+ request: { ...requestWithoutKey, idempotencyKey },
51
+ clear: () => storage?.removeItem(storageKey),
52
+ };
53
+ }
54
+
55
+ function browserSessionStorage(): ReservationAttemptStorage | null {
56
+ try {
57
+ return typeof window !== 'undefined' ? window.sessionStorage : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function readStoredAttempt(
64
+ storage: ReservationAttemptStorage | null,
65
+ key: string,
66
+ now: number
67
+ ): StoredReservationAttempt | null {
68
+ if (!storage) return null;
69
+ try {
70
+ const parsed = JSON.parse(storage.getItem(key) || '') as Partial<StoredReservationAttempt>;
71
+ if (
72
+ typeof parsed.idempotencyKey !== 'string' ||
73
+ !IDEMPOTENCY_KEY_PATTERN.test(parsed.idempotencyKey) ||
74
+ typeof parsed.createdAt !== 'number' ||
75
+ now - parsed.createdAt < 0 ||
76
+ now - parsed.createdAt > ATTEMPT_TTL_MS
77
+ ) {
78
+ storage.removeItem(key);
79
+ return null;
80
+ }
81
+ return { idempotencyKey: parsed.idempotencyKey, createdAt: parsed.createdAt };
82
+ } catch {
83
+ storage.removeItem(key);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function writeStoredAttempt(
89
+ storage: ReservationAttemptStorage | null,
90
+ key: string,
91
+ attempt: StoredReservationAttempt
92
+ ): void {
93
+ if (!storage) return;
94
+ try {
95
+ storage.setItem(key, JSON.stringify(attempt));
96
+ } catch {
97
+ // A storage failure reduces reload recovery but must not block a protected in-memory attempt.
98
+ }
99
+ }
100
+
101
+ function createAttemptId(): string {
102
+ const cryptoApi = globalThis.crypto;
103
+ if (typeof cryptoApi?.randomUUID === 'function') {
104
+ return `attempt_${cryptoApi.randomUUID().replaceAll('-', '')}`;
105
+ }
106
+ if (typeof cryptoApi?.getRandomValues === 'function') {
107
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
108
+ return `attempt_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`;
109
+ }
110
+ throw new Error('Secure random identifiers are unavailable in this browser.');
111
+ }
112
+
113
+ async function sha256Hex(value: string): Promise<string> {
114
+ if (!globalThis.crypto?.subtle) {
115
+ throw new Error('Secure request fingerprinting is unavailable in this browser.');
116
+ }
117
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
118
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
119
+ }
120
+
121
+ function stableStringify(value: unknown): string {
122
+ if (Array.isArray(value)) {
123
+ return `[${value.map(stableStringify).join(',')}]`;
124
+ }
125
+ if (value && typeof value === 'object') {
126
+ const entries = Object.entries(value as Record<string, unknown>)
127
+ .filter(([, child]) => child !== undefined)
128
+ .sort(([left], [right]) => left.localeCompare(right));
129
+ return `{${entries
130
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`)
131
+ .join(',')}}`;
132
+ }
133
+ return JSON.stringify(value);
134
+ }
135
+
136
+ const STORAGE_PREFIX = 'viavia:reservation-attempt:';
137
+ const ATTEMPT_TTL_MS = 30 * 60 * 1000;
138
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
@@ -22,6 +22,11 @@ import {
22
22
  sanitizeBookingSourceUrl,
23
23
  type BookingSourceMetadata,
24
24
  } from './booking/source-metadata';
25
+ import {
26
+ getOrCreateReservationAttempt,
27
+ reservationIdempotencyEnabled,
28
+ type ReservationAttemptContext,
29
+ } from './booking/reservation-attempt';
25
30
 
26
31
  const API_BASE = ENV.API_URL.replace(/\/$/, '');
27
32
  const BOOKING_READ_API_BASE = ENV.BOOKING_READ_API_URL.replace(/\/$/, '');
@@ -283,6 +288,8 @@ function createUserError(
283
288
  ? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
284
289
  : bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
285
290
  ? 'This reservation has already been completed or closed. Refresh before trying again.'
291
+ : bookingApiErrorCode === 'RESERVATION_OUTCOME_UNKNOWN'
292
+ ? 'We could not safely confirm whether your reservation was created. Please keep this page open and try recovery again; do not start another checkout yet.'
286
293
  : `${getUserFacingMessage(endpoint)} (${supportCode})`;
287
294
  const error = new Error(userMessage) as BookingClientError;
288
295
  error.debugMessage = debugMessage;
@@ -2320,6 +2327,8 @@ export interface ReserveRequest {
2320
2327
  source?: string;
2321
2328
  sourceMetadata?: BookingSourceMetadata;
2322
2329
  source_metadata?: BookingSourceMetadata;
2330
+ /** One key per logical hold attempt; generated and retained by createReservation when enabled. */
2331
+ idempotencyKey?: string;
2323
2332
  }
2324
2333
 
2325
2334
  /** Safe subset of reserve payload for telemetry (no free-text traveler fields). */
@@ -2497,9 +2506,107 @@ export interface ReserveResponse {
2497
2506
  currency?: string;
2498
2507
  }
2499
2508
 
2509
+ interface ReserveApiEnvelope {
2510
+ data?: Partial<ReserveResponse> & { state?: string; retryAfterMs?: number };
2511
+ errorCode?: string;
2512
+ errorMessage?: string;
2513
+ error?: string;
2514
+ message?: string;
2515
+ }
2516
+
2517
+ function parseReserveEnvelope(text: string): ReserveApiEnvelope {
2518
+ try {
2519
+ return JSON.parse(text) as ReserveApiEnvelope;
2520
+ } catch {
2521
+ return { errorMessage: text || 'Invalid response from server' };
2522
+ }
2523
+ }
2524
+
2525
+ function normalizedReserveResponse(data: ReserveApiEnvelope['data']): ReserveResponse | null {
2526
+ if (!data?.reservationReference) return null;
2527
+ const expiration = data.reservationExpiration ?? data.expiresAt;
2528
+ if (!expiration) return null;
2529
+ return {
2530
+ reservationReference: data.reservationReference,
2531
+ reservationExpiration: expiration,
2532
+ expiresAt: expiration,
2533
+ totalAmount: data.totalAmount,
2534
+ currency: data.currency,
2535
+ };
2536
+ }
2537
+
2538
+ function reserveEnvelopeError(payload: ReserveApiEnvelope, fallback: string): {
2539
+ code?: string;
2540
+ message: string;
2541
+ } {
2542
+ return {
2543
+ code: payload.errorCode,
2544
+ message: payload.errorMessage || payload.error || payload.message || fallback,
2545
+ };
2546
+ }
2547
+
2548
+ async function recoverReservationAttempt(
2549
+ reservePayload: ReserveRequest,
2550
+ attempt: ReservationAttemptContext<ReserveRequest>
2551
+ ): Promise<ReserveResponse> {
2552
+ const endpoint = '/1/reserve/status';
2553
+ const delays = [0, 250, 750, 1500];
2554
+ let lastMessage = 'The reservation outcome is still unknown.';
2555
+ for (const delay of delays) {
2556
+ if (delay > 0) {
2557
+ await new Promise((resolve) => setTimeout(resolve, delay));
2558
+ }
2559
+ let response: Response;
2560
+ try {
2561
+ response = await fetch(`${API_BASE}${endpoint}`, {
2562
+ method: 'POST',
2563
+ headers: getAuthHeaders(),
2564
+ body: JSON.stringify({ data: reservePayload }),
2565
+ });
2566
+ } catch (error) {
2567
+ lastMessage = error instanceof Error ? error.message : String(error);
2568
+ continue;
2569
+ }
2570
+ const payload = parseReserveEnvelope(await response.text());
2571
+ const recovered = normalizedReserveResponse(payload.data);
2572
+ if (response.ok && recovered) {
2573
+ attempt.clear();
2574
+ return recovered;
2575
+ }
2576
+ if (response.status === 202 || payload.data?.state === 'IN_PROGRESS') {
2577
+ lastMessage = 'The reservation is still being processed.';
2578
+ continue;
2579
+ }
2580
+ const failure = reserveEnvelopeError(payload, `Recovery failed with HTTP ${response.status}`);
2581
+ lastMessage = failure.message;
2582
+ if (response.status === 422) {
2583
+ attempt.clear();
2584
+ throw createUserError(endpoint, 'HTTP', failure.message, failure.code);
2585
+ }
2586
+ if (response.status === 400 || response.status === 409) {
2587
+ throw createUserError(endpoint, 'HTTP', failure.message, failure.code);
2588
+ }
2589
+ }
2590
+ reportClientFetchError({
2591
+ endpoint,
2592
+ errorClass: 'NETWORK',
2593
+ message: lastMessage,
2594
+ errorCode: 'RESERVATION_OUTCOME_UNKNOWN',
2595
+ metadata: {
2596
+ failureKind: 'RESERVATION_RECOVERY_EXHAUSTED',
2597
+ reserveRequest: summarizeReserveRequestForTelemetry(reservePayload),
2598
+ },
2599
+ });
2600
+ throw createUserError(endpoint, 'NETWORK', lastMessage, 'RESERVATION_OUTCOME_UNKNOWN');
2601
+ }
2602
+
2500
2603
  export async function createReservation(request: ReserveRequest): Promise<ReserveResponse> {
2501
2604
  const endpoint = '/1/reserve';
2502
- const reservePayload = withExplicitBookingSource(request);
2605
+ const baseReservePayload = withExplicitBookingSource(request);
2606
+ const attempt = reservationIdempotencyEnabled()
2607
+ ? await getOrCreateReservationAttempt(baseReservePayload)
2608
+ : null;
2609
+ const reservePayload = attempt?.request ?? baseReservePayload;
2503
2610
  let res: Response;
2504
2611
  try {
2505
2612
  res = await fetch(`${API_BASE}${endpoint}`, {
@@ -2509,6 +2616,9 @@ export async function createReservation(request: ReserveRequest): Promise<Reserv
2509
2616
  });
2510
2617
  } catch (err) {
2511
2618
  const debugMessage = err instanceof Error ? err.message : String(err);
2619
+ if (attempt) {
2620
+ return recoverReservationAttempt(reservePayload, attempt);
2621
+ }
2512
2622
  reportClientFetchError({
2513
2623
  endpoint,
2514
2624
  errorClass: 'NETWORK',
@@ -2521,15 +2631,23 @@ export async function createReservation(request: ReserveRequest): Promise<Reserv
2521
2631
  throw createUserError(endpoint, 'NETWORK', debugMessage);
2522
2632
  }
2523
2633
  const text = await res.text();
2634
+ const payload = parseReserveEnvelope(text);
2635
+ if (res.status === 202 && attempt) {
2636
+ return recoverReservationAttempt(reservePayload, attempt);
2637
+ }
2524
2638
  if (!res.ok) {
2525
- let err: { errorMessage?: string; error?: string; errorCode?: string };
2526
- try {
2527
- err = JSON.parse(text);
2528
- } catch {
2529
- err = { errorMessage: text || 'Failed to create reservation' };
2639
+ const failure = reserveEnvelopeError(payload, 'Failed to create reservation');
2640
+ const ambiguousTransportOutcome =
2641
+ failure.code === 'RESERVATION_OUTCOME_UNKNOWN' ||
2642
+ (res.status >= 500 && failure.code !== 'IDEMPOTENCY_UNAVAILABLE');
2643
+ if (attempt && ambiguousTransportOutcome) {
2644
+ return recoverReservationAttempt(reservePayload, attempt);
2645
+ }
2646
+ if (attempt && failure.code !== 'IDEMPOTENCY_CONFLICT') {
2647
+ attempt.clear();
2530
2648
  }
2531
- const debugMessage = err.errorMessage || err.error || 'Failed to create reservation';
2532
- const insufficientCapacity = isInsufficientCapacityApiError(err.errorCode, debugMessage);
2649
+ const debugMessage = failure.message;
2650
+ const insufficientCapacity = isInsufficientCapacityApiError(failure.code, debugMessage);
2533
2651
  reportClientFetchError({
2534
2652
  endpoint,
2535
2653
  errorClass: 'HTTP',
@@ -2537,66 +2655,45 @@ export async function createReservation(request: ReserveRequest): Promise<Reserv
2537
2655
  ? `[RESERVE_INSUFFICIENT_CAPACITY] ${debugMessage}`
2538
2656
  : debugMessage,
2539
2657
  httpStatus: res.status,
2540
- errorCode: err.errorCode,
2658
+ errorCode: failure.code,
2541
2659
  metadata: {
2542
2660
  failureKind: insufficientCapacity
2543
2661
  ? 'RESERVE_INSUFFICIENT_CAPACITY'
2544
2662
  : 'RESERVE_HTTP_ERROR',
2545
2663
  reserveRequest: summarizeReserveRequestForTelemetry(request),
2546
2664
  apiErrorMessage: debugMessage,
2547
- apiErrorCode: err.errorCode ?? null,
2665
+ apiErrorCode: failure.code ?? null,
2548
2666
  },
2549
2667
  });
2550
- throw createUserError(endpoint, 'HTTP', debugMessage, err.errorCode);
2668
+ throw createUserError(endpoint, 'HTTP', debugMessage, failure.code);
2551
2669
  }
2552
- let data: { data?: ReserveResponse; errorCode?: string; errorMessage?: string };
2553
- try {
2554
- data = JSON.parse(text);
2555
- } catch {
2556
- throw new Error('Invalid response from server');
2557
- }
2558
- if (data.errorCode || data.errorMessage) {
2559
- const debugMessage = data.errorMessage || 'Failed to create reservation';
2560
- const insufficientCapacity = isInsufficientCapacityApiError(data.errorCode, debugMessage);
2670
+ if (payload.errorCode || payload.errorMessage) {
2671
+ const failure = reserveEnvelopeError(payload, 'Failed to create reservation');
2672
+ attempt?.clear();
2673
+ const debugMessage = failure.message;
2674
+ const insufficientCapacity = isInsufficientCapacityApiError(failure.code, debugMessage);
2561
2675
  reportClientFetchError({
2562
2676
  endpoint,
2563
2677
  errorClass: 'APP_ERROR_200',
2564
2678
  message: insufficientCapacity
2565
2679
  ? `[RESERVE_INSUFFICIENT_CAPACITY] ${debugMessage}`
2566
2680
  : debugMessage,
2567
- errorCode: data.errorCode,
2681
+ errorCode: failure.code,
2568
2682
  metadata: {
2569
2683
  failureKind: insufficientCapacity
2570
2684
  ? 'RESERVE_INSUFFICIENT_CAPACITY'
2571
2685
  : 'RESERVE_APP_ERROR_200',
2572
2686
  reserveRequest: summarizeReserveRequestForTelemetry(request),
2573
2687
  apiErrorMessage: debugMessage,
2574
- apiErrorCode: data.errorCode ?? null,
2688
+ apiErrorCode: failure.code ?? null,
2575
2689
  },
2576
2690
  });
2577
- throw createUserError(endpoint, 'APP_ERROR_200', debugMessage, data.errorCode);
2578
- }
2579
- if (!data.data?.reservationReference) {
2580
- throw new Error('Invalid response: missing reservationReference');
2691
+ throw createUserError(endpoint, 'APP_ERROR_200', debugMessage, failure.code);
2581
2692
  }
2582
- const raw = data.data as {
2583
- reservationReference: string;
2584
- reservationExpiration?: string;
2585
- expiresAt?: string;
2586
- totalAmount?: number;
2587
- currency?: string;
2588
- };
2589
- const expiration = raw.reservationExpiration ?? raw.expiresAt;
2590
- if (!expiration) {
2591
- throw new Error('Invalid response: missing reservation hold expiration');
2592
- }
2593
- return {
2594
- reservationReference: raw.reservationReference,
2595
- reservationExpiration: expiration,
2596
- expiresAt: expiration,
2597
- totalAmount: raw.totalAmount,
2598
- currency: raw.currency,
2599
- };
2693
+ const normalized = normalizedReserveResponse(payload.data);
2694
+ if (!normalized) throw new Error('Invalid response: missing reservationReference or hold expiration');
2695
+ attempt?.clear();
2696
+ return normalized;
2600
2697
  }
2601
2698
 
2602
2699
  export async function cancelReservation(reservationReference: string): Promise<void> {
@@ -3,6 +3,11 @@
3
3
  * Uses NEXT_PUBLIC_* env from the consuming Next app at build time.
4
4
  */
5
5
 
6
+ import {
7
+ parsePartnerPricingProfileOverrides,
8
+ type PartnerPricingProfileOverride,
9
+ } from './lib/booking/partner-pricing-profile';
10
+
6
11
  function apiBase(): string {
7
12
  const u = process.env.NEXT_PUBLIC_API_URL;
8
13
  if (!u) {
@@ -51,6 +56,8 @@ export interface PublicPartner {
51
56
  anonymousBookAllowed?: boolean;
52
57
  /** B2B / partner pricing profile id when the API exposes it (optional). */
53
58
  pricingProfileId?: string;
59
+ /** Month-specific profiles; first matching rule wins and pricingProfileId is the fallback. */
60
+ pricingProfileOverrides?: PartnerPricingProfileOverride[];
54
61
  /** Partner cancellation-policy profile id when the API exposes it (optional). */
55
62
  cancellationPolicyProfileId?: string;
56
63
  }
@@ -67,7 +74,7 @@ export interface PublicStaffPortalSignInOption {
67
74
  displayName: string;
68
75
  }
69
76
 
70
- function mapPartnerApiRow(
77
+ export function mapPartnerApiRow(
71
78
  r: Record<string, unknown> & { partnerId: string; name: string },
72
79
  ): PublicPartner {
73
80
  const pickupLocationIds = parsePickupLocationIds(r);
@@ -83,6 +90,9 @@ function mapPartnerApiRow(
83
90
  ? cap.pricingProfileId
84
91
  : '';
85
92
  const pricingProfileId = rawProfile.trim() || undefined;
93
+ const pricingProfileOverrides = parsePartnerPricingProfileOverrides(
94
+ r.pricingProfileOverrides ?? cap?.pricingProfileOverrides,
95
+ );
86
96
  const rawCancellationProfile =
87
97
  typeof r.cancellationPolicyProfileId === 'string'
88
98
  ? r.cancellationPolicyProfileId
@@ -96,6 +106,7 @@ function mapPartnerApiRow(
96
106
  anonymousBookAllowed,
97
107
  ...(pickupLocationIds?.length ? { pickupLocationIds } : {}),
98
108
  ...(pricingProfileId ? { pricingProfileId } : {}),
109
+ ...(pricingProfileOverrides.length > 0 ? { pricingProfileOverrides } : {}),
99
110
  ...(cancellationPolicyProfileId ? { cancellationPolicyProfileId } : {}),
100
111
  };
101
112
  }
@@ -10,6 +10,7 @@ import {
10
10
  type ChangeQuoteUiSlice,
11
11
  } from '../src/lib/booking/change-flow-pricing';
12
12
  import {
13
+ createReservation,
13
14
  mapAdminChangeBookingQuoteV2Data,
14
15
  type Availability,
15
16
  type ChangeBookingQuoteResponse,
@@ -72,6 +73,11 @@ import {
72
73
  resolveHydratedPrivateShuttleAvailability,
73
74
  } from '../src/components/booking/private-shuttle-availability';
74
75
  import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
76
+ import {
77
+ getOrCreateReservationAttempt,
78
+ reservationIdempotencyEnabled,
79
+ type ReservationAttemptStorage,
80
+ } from '../src/lib/booking/reservation-attempt';
75
81
  import {
76
82
  formatReservationHoldTime,
77
83
  reservationHoldHasExpired,
@@ -2357,3 +2363,125 @@ test('private shuttle checkout receipt labels add-on variants and quantities', (
2357
2363
  { name: 'Croissants (Butter Croissant) \u00d7 2', totalAmount: 11.98 },
2358
2364
  ]);
2359
2365
  });
2366
+
2367
+ test('reservation attempt reuses one idempotency key for an identical retry', async () => {
2368
+ const values = new Map<string, string>();
2369
+ const storage: ReservationAttemptStorage = {
2370
+ getItem: (key) => values.get(key) ?? null,
2371
+ setItem: (key, value) => values.set(key, value),
2372
+ removeItem: (key) => values.delete(key),
2373
+ };
2374
+ const request = {
2375
+ productId: 'po_test123',
2376
+ dateTime: '2026-08-02T08:00:00-06:00',
2377
+ bookingItems: [{ category: 'ADULT', count: 2 }],
2378
+ };
2379
+ const options = {
2380
+ storage,
2381
+ now: 1_000,
2382
+ createId: () => 'attempt_1234567890abcdef',
2383
+ digest: async (value: string) => `digest-${value.length}`,
2384
+ };
2385
+
2386
+ const first = await getOrCreateReservationAttempt(request, options);
2387
+ const retry = await getOrCreateReservationAttempt(request, {
2388
+ ...options,
2389
+ now: 2_000,
2390
+ createId: () => 'attempt_should_not_be_used',
2391
+ });
2392
+
2393
+ assert.equal(first.idempotencyKey, 'attempt_1234567890abcdef');
2394
+ assert.equal(retry.idempotencyKey, first.idempotencyKey);
2395
+ assert.equal(retry.request.idempotencyKey, first.idempotencyKey);
2396
+ });
2397
+
2398
+ test('completed reservation attempt is cleared before a later logical attempt', async () => {
2399
+ const values = new Map<string, string>();
2400
+ const storage: ReservationAttemptStorage = {
2401
+ getItem: (key) => values.get(key) ?? null,
2402
+ setItem: (key, value) => values.set(key, value),
2403
+ removeItem: (key) => values.delete(key),
2404
+ };
2405
+ const request = { productId: 'po_test123', dateTime: '2026-08-02', bookingItems: [] };
2406
+ const digest = async () => 'same-request';
2407
+ const first = await getOrCreateReservationAttempt(request, {
2408
+ storage,
2409
+ digest,
2410
+ createId: () => 'attempt_1234567890abcdef',
2411
+ });
2412
+ first.clear();
2413
+ const later = await getOrCreateReservationAttempt(request, {
2414
+ storage,
2415
+ digest,
2416
+ createId: () => 'attempt_fedcba0987654321',
2417
+ });
2418
+
2419
+ assert.equal(later.idempotencyKey, 'attempt_fedcba0987654321');
2420
+ });
2421
+
2422
+ test('reservation idempotency requires an explicit frontend build flag', () => {
2423
+ assert.equal(reservationIdempotencyEnabled(undefined), false);
2424
+ assert.equal(reservationIdempotencyEnabled('TRUE'), true);
2425
+ });
2426
+
2427
+ test('lost reserve response is recovered without submitting a second reservation', async () => {
2428
+ const originalFetch = globalThis.fetch;
2429
+ const originalFlag = process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED;
2430
+ const calls: Array<{ url: string; body: { data?: { idempotencyKey?: string } } }> = [];
2431
+ let scenario: 'network-loss' | 'gateway-timeout' = 'network-loss';
2432
+ process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED = 'true';
2433
+ globalThis.fetch = async (input, init) => {
2434
+ const url = String(input);
2435
+ const body = JSON.parse(String(init?.body ?? '{}')) as { data?: { idempotencyKey?: string } };
2436
+ calls.push({ url, body });
2437
+ if (url.endsWith('/1/reserve')) {
2438
+ if (scenario === 'network-loss') {
2439
+ throw new Error('connection closed after request was sent');
2440
+ }
2441
+ return new Response('Gateway timeout', { status: 504 });
2442
+ }
2443
+ return new Response(
2444
+ JSON.stringify({
2445
+ data: {
2446
+ reservationReference:
2447
+ scenario === 'network-loss' ? 'resRef_RECOVERED' : 'resRef_TIMEOUT_RECOVERED',
2448
+ reservationExpiration: '2026-08-01T18:10:00Z',
2449
+ },
2450
+ }),
2451
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
2452
+ );
2453
+ };
2454
+
2455
+ try {
2456
+ const result = await createReservation({
2457
+ productId: 'po_test123',
2458
+ dateTime: '2026-08-02T08:00:00-06:00',
2459
+ bookingItems: [{ category: 'ADULT', count: 2 }],
2460
+ });
2461
+
2462
+ assert.equal(result.reservationReference, 'resRef_RECOVERED');
2463
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve')).length, 1);
2464
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve/status')).length, 1);
2465
+ assert.match(calls[0].body.data?.idempotencyKey ?? '', /^attempt_[a-f0-9]{32}$/);
2466
+ assert.equal(calls[1].body.data?.idempotencyKey, calls[0].body.data?.idempotencyKey);
2467
+
2468
+ scenario = 'gateway-timeout';
2469
+ calls.length = 0;
2470
+ const timeoutResult = await createReservation({
2471
+ productId: 'po_test123',
2472
+ dateTime: '2026-08-03T08:00:00-06:00',
2473
+ bookingItems: [{ category: 'ADULT', count: 2 }],
2474
+ });
2475
+
2476
+ assert.equal(timeoutResult.reservationReference, 'resRef_TIMEOUT_RECOVERED');
2477
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve')).length, 1);
2478
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve/status')).length, 1);
2479
+ } finally {
2480
+ globalThis.fetch = originalFetch;
2481
+ if (originalFlag === undefined) {
2482
+ delete process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED;
2483
+ } else {
2484
+ process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED = originalFlag;
2485
+ }
2486
+ }
2487
+ });
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { mapPartnerApiRow } from '../src/public-partners';
4
+ import {
5
+ parsePartnerPricingProfileOverrides,
6
+ resolvePartnerPricingProfileId,
7
+ } from '../src/lib/booking/partner-pricing-profile';
8
+
9
+ const overrides = parsePartnerPricingProfileOverrides([
10
+ {
11
+ pricingProfileId: 'pp_banff_adventures_july_aug_sept',
12
+ months: [7, 8, 9],
13
+ },
14
+ ]);
15
+
16
+ for (const bookingDate of ['2026-07-01', '2026-08-02', '2026-09-30']) {
17
+ assert.equal(
18
+ resolvePartnerPricingProfileId('pp_banff_adventures', overrides, bookingDate),
19
+ 'pp_banff_adventures_july_aug_sept',
20
+ );
21
+ }
22
+ for (const bookingDate of ['2026-06-30', '2026-10-01']) {
23
+ assert.equal(
24
+ resolvePartnerPricingProfileId('pp_banff_adventures', overrides, bookingDate),
25
+ 'pp_banff_adventures',
26
+ );
27
+ }
28
+
29
+ const mapped = mapPartnerApiRow({
30
+ partnerId: 'par_U4md9ZBsf3uR',
31
+ name: 'Banff Adventures',
32
+ capabilities: {
33
+ pricingProfileId: 'pp_banff_adventures',
34
+ pricingProfileOverrides: [
35
+ {
36
+ pricingProfileId: 'pp_banff_adventures_july_aug_sept',
37
+ months: [7, 8, 9],
38
+ },
39
+ ],
40
+ },
41
+ });
42
+
43
+ assert.equal(mapped.pricingProfileId, 'pp_banff_adventures');
44
+ assert.deepEqual(mapped.pricingProfileOverrides, overrides);
45
+
46
+ console.log('partner pricing profile tests passed');