@ticketboothapp/booking 1.2.151 → 1.2.152
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/AdminChangeCheckoutDialogs.tsx +1 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +49 -3
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +1 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +1 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +13 -0
- package/src/components/booking/reservation-hold.ts +27 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +13 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +14 -1
- package/src/lib/booking-api.ts +32 -5
- package/test/change-booking-helpers.test.ts +22 -0
package/package.json
CHANGED
|
@@ -57,6 +57,7 @@ export function AdminChangeCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
onPayNow={onPayNow}
|
|
61
62
|
onConfirmWithoutPayment={onConfirmWithoutPayment}
|
|
62
63
|
onCancel={onAdminPaymentChoiceCancel}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
3
4
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
4
5
|
import type { Currency } from './CurrencySwitcher';
|
|
6
|
+
import {
|
|
7
|
+
formatReservationHoldTime,
|
|
8
|
+
reservationHoldSecondsRemaining,
|
|
9
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
10
|
+
} from './reservation-hold';
|
|
5
11
|
|
|
6
12
|
interface AdminPaymentChoiceModalProps {
|
|
7
13
|
open: boolean;
|
|
@@ -9,6 +15,7 @@ interface AdminPaymentChoiceModalProps {
|
|
|
9
15
|
currency: Currency;
|
|
10
16
|
loading: boolean;
|
|
11
17
|
error: string;
|
|
18
|
+
reservationExpiration?: string;
|
|
12
19
|
onPayNow: () => void;
|
|
13
20
|
onConfirmWithoutPayment: () => void;
|
|
14
21
|
onCancel: () => void;
|
|
@@ -27,14 +34,27 @@ export function AdminPaymentChoiceModal({
|
|
|
27
34
|
currency,
|
|
28
35
|
loading,
|
|
29
36
|
error,
|
|
37
|
+
reservationExpiration,
|
|
30
38
|
onPayNow,
|
|
31
39
|
onConfirmWithoutPayment,
|
|
32
40
|
onCancel,
|
|
33
41
|
description,
|
|
34
42
|
payNowLabel,
|
|
35
43
|
}: AdminPaymentChoiceModalProps) {
|
|
44
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open || !reservationExpiration) return;
|
|
48
|
+
setNowMs(Date.now());
|
|
49
|
+
const interval = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
50
|
+
return () => window.clearInterval(interval);
|
|
51
|
+
}, [open, reservationExpiration]);
|
|
52
|
+
|
|
36
53
|
if (!open) return null;
|
|
37
54
|
|
|
55
|
+
const secondsRemaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
|
|
56
|
+
const expired = secondsRemaining !== null && secondsRemaining <= 0;
|
|
57
|
+
|
|
38
58
|
const modal = (
|
|
39
59
|
<div
|
|
40
60
|
className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
|
|
@@ -68,6 +88,32 @@ export function AdminPaymentChoiceModal({
|
|
|
68
88
|
</div>
|
|
69
89
|
|
|
70
90
|
<div className="p-6 flex flex-col gap-3 flex-1 min-h-0">
|
|
91
|
+
{secondsRemaining !== null ? (
|
|
92
|
+
<div
|
|
93
|
+
className={`rounded-lg border p-3 text-sm ${
|
|
94
|
+
expired
|
|
95
|
+
? 'border-red-200 bg-red-50 text-red-800'
|
|
96
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
|
97
|
+
}`}
|
|
98
|
+
role={expired ? 'alert' : 'status'}
|
|
99
|
+
aria-live="polite"
|
|
100
|
+
>
|
|
101
|
+
{expired ? (
|
|
102
|
+
<>
|
|
103
|
+
<p className="font-semibold">Reservation hold expired</p>
|
|
104
|
+
<p className="mt-1">{RESERVATION_HOLD_EXPIRED_MESSAGE}</p>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<p className="font-semibold">
|
|
109
|
+
Reservation held for {formatReservationHoldTime(secondsRemaining)}
|
|
110
|
+
</p>
|
|
111
|
+
<p className="mt-1">Complete payment or confirm the booking before the hold expires.</p>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
71
117
|
{error ? (
|
|
72
118
|
<p className="text-sm text-red-600" role="alert">
|
|
73
119
|
{error}
|
|
@@ -77,7 +123,7 @@ export function AdminPaymentChoiceModal({
|
|
|
77
123
|
<button
|
|
78
124
|
type="button"
|
|
79
125
|
onClick={onPayNow}
|
|
80
|
-
disabled={loading}
|
|
126
|
+
disabled={loading || expired}
|
|
81
127
|
className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
82
128
|
>
|
|
83
129
|
{loading ? 'Loading...' : `${payNowLabel ?? 'Pay now'} (${formatCurrencyAmount(totalAmount, currency)})`}
|
|
@@ -85,7 +131,7 @@ export function AdminPaymentChoiceModal({
|
|
|
85
131
|
<button
|
|
86
132
|
type="button"
|
|
87
133
|
onClick={onConfirmWithoutPayment}
|
|
88
|
-
disabled={loading}
|
|
134
|
+
disabled={loading || expired}
|
|
89
135
|
className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
90
136
|
>
|
|
91
137
|
Confirm without payment
|
|
@@ -96,7 +142,7 @@ export function AdminPaymentChoiceModal({
|
|
|
96
142
|
disabled={loading}
|
|
97
143
|
className="w-full py-2 text-sm text-stone-500 hover:text-stone-700 font-medium disabled:opacity-50"
|
|
98
144
|
>
|
|
99
|
-
Cancel
|
|
145
|
+
{expired ? 'Close and restart checkout' : 'Cancel'}
|
|
100
146
|
</button>
|
|
101
147
|
</div>
|
|
102
148
|
</div>
|
|
@@ -57,6 +57,7 @@ export function PrivateShuttleCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
description={
|
|
61
62
|
adminChoiceData?.isDepositPayment
|
|
62
63
|
? 'Pay the deposit now, or confirm without payment. The customer can pay the deposit or remaining balance from the Manage Booking page.'
|
|
@@ -57,6 +57,7 @@ export function StandardBookingCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
onPayNow={onPayNow}
|
|
61
62
|
onConfirmWithoutPayment={onConfirmWithoutPayment}
|
|
62
63
|
onCancel={onAdminPaymentChoiceCancel}
|
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
type PrivateShuttleCheckoutModalData,
|
|
48
48
|
} from './private-shuttle-payment-choice-runner';
|
|
49
49
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
50
|
+
import {
|
|
51
|
+
reservationHoldHasExpired,
|
|
52
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
53
|
+
} from './reservation-hold';
|
|
50
54
|
|
|
51
55
|
export interface UsePrivateShuttleCheckoutControllerParams {
|
|
52
56
|
selectedOption: string;
|
|
@@ -676,6 +680,10 @@ export function usePrivateShuttleCheckoutController({
|
|
|
676
680
|
const handleConfirmWithoutPayment = async () => {
|
|
677
681
|
const choice = adminChoiceData;
|
|
678
682
|
if (!choice) return;
|
|
683
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
684
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
679
687
|
setLoading(true);
|
|
680
688
|
setError('');
|
|
681
689
|
try {
|
|
@@ -718,6 +726,10 @@ export function usePrivateShuttleCheckoutController({
|
|
|
718
726
|
const handlePayNow = () => {
|
|
719
727
|
const choice = adminChoiceData;
|
|
720
728
|
if (!choice) return;
|
|
729
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
730
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
721
733
|
setShowAdminPaymentChoice(false);
|
|
722
734
|
setCheckoutClientSecret(choice.clientSecret);
|
|
723
735
|
setCheckoutModalData(buildPrivateShuttlePayNowCheckoutModalData(choice, lastName.trim()));
|
|
@@ -726,6 +738,7 @@ export function usePrivateShuttleCheckoutController({
|
|
|
726
738
|
};
|
|
727
739
|
|
|
728
740
|
const handleAdminPaymentChoiceCancel = () => {
|
|
741
|
+
cancelPendingReservation();
|
|
729
742
|
setShowAdminPaymentChoice(false);
|
|
730
743
|
setAdminChoiceData(null);
|
|
731
744
|
setError('');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const RESERVATION_HOLD_EXPIRED_MESSAGE =
|
|
2
|
+
'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.';
|
|
3
|
+
|
|
4
|
+
export function reservationHoldSecondsRemaining(
|
|
5
|
+
reservationExpiration: string | undefined,
|
|
6
|
+
nowMs: number = Date.now(),
|
|
7
|
+
): number | null {
|
|
8
|
+
if (!reservationExpiration) return null;
|
|
9
|
+
const expirationMs = Date.parse(reservationExpiration);
|
|
10
|
+
if (!Number.isFinite(expirationMs)) return 0;
|
|
11
|
+
return Math.max(0, Math.ceil((expirationMs - nowMs) / 1000));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function reservationHoldHasExpired(
|
|
15
|
+
reservationExpiration: string | undefined,
|
|
16
|
+
nowMs: number = Date.now(),
|
|
17
|
+
): boolean {
|
|
18
|
+
const remaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
|
|
19
|
+
return remaining !== null && remaining <= 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatReservationHoldTime(seconds: number): string {
|
|
23
|
+
const safeSeconds = Math.max(0, Math.floor(seconds));
|
|
24
|
+
const minutes = Math.floor(safeSeconds / 60);
|
|
25
|
+
const remainder = safeSeconds % 60;
|
|
26
|
+
return `${minutes}:${String(remainder).padStart(2, '0')}`;
|
|
27
|
+
}
|
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type StandardCheckoutModalData,
|
|
43
43
|
} from './standard-booking-payment-choice-runner';
|
|
44
44
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
45
|
+
import {
|
|
46
|
+
reservationHoldHasExpired,
|
|
47
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
48
|
+
} from './reservation-hold';
|
|
45
49
|
|
|
46
50
|
export interface UseStandardBookingCheckoutControllerParams {
|
|
47
51
|
selectedAvailability: Availability | null;
|
|
@@ -590,6 +594,10 @@ export function useStandardBookingCheckoutController({
|
|
|
590
594
|
const handleConfirmWithoutPayment = async () => {
|
|
591
595
|
const choice = adminChoiceData;
|
|
592
596
|
if (!choice) return;
|
|
597
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
598
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
593
601
|
setLoading(true);
|
|
594
602
|
setError('');
|
|
595
603
|
try {
|
|
@@ -634,6 +642,10 @@ export function useStandardBookingCheckoutController({
|
|
|
634
642
|
const handlePayNow = () => {
|
|
635
643
|
const choice = adminChoiceData;
|
|
636
644
|
if (!choice) return;
|
|
645
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
646
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
637
649
|
setShowAdminPaymentChoice(false);
|
|
638
650
|
setCheckoutClientSecret(choice.clientSecret);
|
|
639
651
|
setCheckoutModalData(buildStandardPayNowCheckoutModalData(choice, lastName.trim()));
|
|
@@ -642,6 +654,7 @@ export function useStandardBookingCheckoutController({
|
|
|
642
654
|
};
|
|
643
655
|
|
|
644
656
|
const handleAdminPaymentChoiceCancel = () => {
|
|
657
|
+
cancelPendingReservation();
|
|
645
658
|
setShowAdminPaymentChoice(false);
|
|
646
659
|
setAdminChoiceData(null);
|
|
647
660
|
setError('');
|
|
@@ -62,6 +62,10 @@ import {
|
|
|
62
62
|
confirmFreeAdminCustomerChange,
|
|
63
63
|
quoteAdminCustomerChangeForCheckout,
|
|
64
64
|
} from './admin-change-customer-quote-runner';
|
|
65
|
+
import {
|
|
66
|
+
reservationHoldHasExpired,
|
|
67
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
68
|
+
} from './reservation-hold';
|
|
65
69
|
|
|
66
70
|
type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
|
|
67
71
|
|
|
@@ -690,6 +694,10 @@ export function useAdminChangeCheckoutController({
|
|
|
690
694
|
|
|
691
695
|
const handleConfirmWithoutPayment = async () => {
|
|
692
696
|
if (!adminChoiceData) return;
|
|
697
|
+
if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
|
|
698
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
693
701
|
setLoading(true);
|
|
694
702
|
setError('');
|
|
695
703
|
try {
|
|
@@ -771,6 +779,10 @@ export function useAdminChangeCheckoutController({
|
|
|
771
779
|
|
|
772
780
|
const handlePayNow = () => {
|
|
773
781
|
if (!adminChoiceData) return;
|
|
782
|
+
if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
|
|
783
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
774
786
|
setShowAdminPaymentChoice(false);
|
|
775
787
|
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
776
788
|
setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
|
|
@@ -787,10 +799,11 @@ export function useAdminChangeCheckoutController({
|
|
|
787
799
|
}, []);
|
|
788
800
|
|
|
789
801
|
const handleAdminPaymentChoiceCancel = useCallback(() => {
|
|
802
|
+
cancelPendingReservation();
|
|
790
803
|
setShowAdminPaymentChoice(false);
|
|
791
804
|
setAdminChoiceData(null);
|
|
792
805
|
setError('');
|
|
793
|
-
}, [setError]);
|
|
806
|
+
}, [cancelPendingReservation, setError]);
|
|
794
807
|
|
|
795
808
|
return {
|
|
796
809
|
showCheckoutModal,
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -68,6 +68,9 @@ function getUserFacingMessage(endpoint: string): string {
|
|
|
68
68
|
return 'Unable to hold your booking right now. Please try again.';
|
|
69
69
|
case '/checkout/payment-intent':
|
|
70
70
|
return 'Unable to continue to payment right now. Please try again.';
|
|
71
|
+
case '/checkout/confirm-booking-without-payment':
|
|
72
|
+
case '/1/partner/confirm-booking-without-payment':
|
|
73
|
+
return 'Unable to confirm this booking right now. Please try again or restart checkout.';
|
|
71
74
|
default:
|
|
72
75
|
return 'Something went wrong while loading booking details. Please try again.';
|
|
73
76
|
}
|
|
@@ -186,7 +189,11 @@ function createUserError(
|
|
|
186
189
|
bookingApiErrorCode?: string
|
|
187
190
|
): BookingClientError {
|
|
188
191
|
const supportCode = buildSupportCode(endpoint, errorClass);
|
|
189
|
-
const userMessage =
|
|
192
|
+
const userMessage = bookingApiErrorCode === 'RESERVATION_EXPIRED'
|
|
193
|
+
? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
|
|
194
|
+
: bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
|
|
195
|
+
? 'This reservation has already been completed or closed. Refresh before trying again.'
|
|
196
|
+
: `${getUserFacingMessage(endpoint)} (${supportCode})`;
|
|
190
197
|
const error = new Error(userMessage) as BookingClientError;
|
|
191
198
|
error.debugMessage = debugMessage;
|
|
192
199
|
if (bookingApiErrorCode) error.bookingApiErrorCode = bookingApiErrorCode;
|
|
@@ -2580,7 +2587,12 @@ export async function confirmBookingWithoutPayment(
|
|
|
2580
2587
|
httpStatus: res.status,
|
|
2581
2588
|
errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2582
2589
|
});
|
|
2583
|
-
throw createUserError(
|
|
2590
|
+
throw createUserError(
|
|
2591
|
+
endpoint,
|
|
2592
|
+
'HTTP',
|
|
2593
|
+
debugMessage,
|
|
2594
|
+
isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2595
|
+
);
|
|
2584
2596
|
}
|
|
2585
2597
|
const data = await parseJsonSafely(res);
|
|
2586
2598
|
const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
|
|
@@ -2591,7 +2603,12 @@ export async function confirmBookingWithoutPayment(
|
|
|
2591
2603
|
message: appError,
|
|
2592
2604
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2593
2605
|
});
|
|
2594
|
-
throw createUserError(
|
|
2606
|
+
throw createUserError(
|
|
2607
|
+
endpoint,
|
|
2608
|
+
'APP_ERROR_200',
|
|
2609
|
+
appError,
|
|
2610
|
+
isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2611
|
+
);
|
|
2595
2612
|
}
|
|
2596
2613
|
logBookingSourceDebug('response', '/checkout/confirm-booking-without-payment', data);
|
|
2597
2614
|
return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
|
|
@@ -2636,7 +2653,12 @@ export async function confirmPartnerBookingWithoutPayment(
|
|
|
2636
2653
|
httpStatus: res.status,
|
|
2637
2654
|
errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2638
2655
|
});
|
|
2639
|
-
throw createUserError(
|
|
2656
|
+
throw createUserError(
|
|
2657
|
+
endpoint,
|
|
2658
|
+
'HTTP',
|
|
2659
|
+
debugMessage,
|
|
2660
|
+
isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2661
|
+
);
|
|
2640
2662
|
}
|
|
2641
2663
|
const data = await parseJsonSafely(res);
|
|
2642
2664
|
const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
|
|
@@ -2647,7 +2669,12 @@ export async function confirmPartnerBookingWithoutPayment(
|
|
|
2647
2669
|
message: appError,
|
|
2648
2670
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2649
2671
|
});
|
|
2650
|
-
throw createUserError(
|
|
2672
|
+
throw createUserError(
|
|
2673
|
+
endpoint,
|
|
2674
|
+
'APP_ERROR_200',
|
|
2675
|
+
appError,
|
|
2676
|
+
isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2677
|
+
);
|
|
2651
2678
|
}
|
|
2652
2679
|
logBookingSourceDebug('response', endpoint, data);
|
|
2653
2680
|
return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
|
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
resolveHydratedPrivateShuttleAvailability,
|
|
15
15
|
} from '../src/components/booking/private-shuttle-availability';
|
|
16
16
|
import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
|
|
17
|
+
import {
|
|
18
|
+
formatReservationHoldTime,
|
|
19
|
+
reservationHoldHasExpired,
|
|
20
|
+
reservationHoldSecondsRemaining,
|
|
21
|
+
} from '../src/components/booking/reservation-hold';
|
|
17
22
|
|
|
18
23
|
function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
|
|
19
24
|
return {
|
|
@@ -42,6 +47,23 @@ function test(name: string, fn: () => void): void {
|
|
|
42
47
|
}
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
test('admin payment choice countdown expires exactly at the reservation deadline', () => {
|
|
51
|
+
const expiration = '2026-07-16T15:46:44+00:00';
|
|
52
|
+
assert.equal(
|
|
53
|
+
reservationHoldSecondsRemaining(expiration, Date.parse('2026-07-16T15:46:34Z')),
|
|
54
|
+
10,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:43.999Z')), false);
|
|
57
|
+
assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:44Z')), true);
|
|
58
|
+
assert.equal(formatReservationHoldTime(604), '10:04');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('malformed reservation expiration fails closed', () => {
|
|
62
|
+
assert.equal(reservationHoldSecondsRemaining('not-a-date', Date.now()), 0);
|
|
63
|
+
assert.equal(reservationHoldHasExpired('not-a-date', Date.now()), true);
|
|
64
|
+
assert.equal(reservationHoldHasExpired(undefined, Date.now()), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
45
67
|
test('source attribution strips Stripe credentials and transient partner checkout identity', () => {
|
|
46
68
|
const sanitized = sanitizeBookingSourceUrl(
|
|
47
69
|
'https://staging.booking.viaviamorainelake.com/?partnerId=par_safe&agentId=agent_safe&agentName=Isadora+Buttonmoss&embed_manage=1&reservationRef=resRef_previous&lastName=Tauro&bookingDate=2026-07-16&payment_intent=pi_previous&payment_intent_client_secret=do_not_store&redirect_status=succeeded&tab=bookings',
|