@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 +1 -1
- package/src/components/booking/NewBookingFlow.tsx +5 -2
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -1
- package/src/components/booking/booking-flow-types.ts +3 -0
- package/src/components/booking/use-standard-booking-availability.ts +31 -9
- package/src/index.ts +5 -0
- package/src/lib/booking/partner-pricing-profile.ts +74 -0
- package/src/lib/booking/reservation-attempt.ts +138 -0
- package/src/lib/booking-api.ts +141 -44
- package/src/public-partners.ts +12 -1
- package/test/change-booking-helpers.test.ts +128 -0
- package/test/partner-pricing-profile.test.ts +46 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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}$/;
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
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 =
|
|
2532
|
-
const insufficientCapacity = isInsufficientCapacityApiError(
|
|
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:
|
|
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:
|
|
2665
|
+
apiErrorCode: failure.code ?? null,
|
|
2548
2666
|
},
|
|
2549
2667
|
});
|
|
2550
|
-
throw createUserError(endpoint, 'HTTP', debugMessage,
|
|
2668
|
+
throw createUserError(endpoint, 'HTTP', debugMessage, failure.code);
|
|
2551
2669
|
}
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
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:
|
|
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:
|
|
2688
|
+
apiErrorCode: failure.code ?? null,
|
|
2575
2689
|
},
|
|
2576
2690
|
});
|
|
2577
|
-
throw createUserError(endpoint, 'APP_ERROR_200', debugMessage,
|
|
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
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
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> {
|
package/src/public-partners.ts
CHANGED
|
@@ -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');
|