@akinon/pz-masterpass-rest 2.0.56-rc.0 → 2.0.56
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/CHANGELOG.md +2 -6
- package/jest/__mocks__/akinon-next-components.js +26 -0
- package/jest/__mocks__/akinon-next-redux-hooks.js +6 -0
- package/jest/__mocks__/akinon-next-rtk.js +4 -0
- package/jest/__mocks__/akinon-next-utils.js +4 -0
- package/jest/__mocks__/hookform-resolvers.js +6 -0
- package/jest/__mocks__/host-store.js +16 -0
- package/jest/file-mock.js +1 -0
- package/jest/setup.ts +37 -0
- package/jest/style-mock.js +1 -0
- package/jest.config.js +46 -0
- package/package.json +5 -2
- package/src/components/credit-card-form.tsx +1 -1
- package/src/components/installment-list.tsx +1 -1
- package/src/components/otp-modal.tsx +4 -12
- package/src/hooks/use-masterpass-account-otp.test.tsx +189 -0
- package/src/hooks/use-masterpass-payment-pending.test.tsx +255 -0
- package/src/hooks/useMasterpassAccount.ts +36 -27
- package/src/hooks/useMasterpassPayment.ts +75 -11
- package/src/hooks/useMasterpassToken.ts +13 -50
- package/src/index.ts +0 -1
- package/src/redux/reducer.ts +2 -0
- package/src/types/custom-render.types.ts +4 -0
- package/src/types/payment.types.ts +3 -0
- package/src/utils/card-utils.ts +1 -1
- package/src/utils/payment-pending.test.ts +89 -0
- package/src/utils/payment-utils.ts +47 -0
- package/src/views/masterpass-rest-option.tsx +56 -6
- package/src/views/order-no-mismatch.test.tsx +434 -0
- package/src/views/payment-pending-guard.test.tsx +276 -0
- package/src/utils/wait-for-utils-ready.ts +0 -64
package/src/redux/reducer.ts
CHANGED
|
@@ -82,6 +82,7 @@ const initialState: MasterpassRestState = {
|
|
|
82
82
|
showRewardModal: false,
|
|
83
83
|
informationModalData: null,
|
|
84
84
|
otpType: 'OTP',
|
|
85
|
+
otpContext: 'account',
|
|
85
86
|
verificationData: null,
|
|
86
87
|
cardToDelete: null,
|
|
87
88
|
removingCardId: null
|
|
@@ -270,6 +271,7 @@ const masterpassRestSlice = createSlice({
|
|
|
270
271
|
showRewardModal: false,
|
|
271
272
|
informationModalData: null,
|
|
272
273
|
otpType: 'OTP',
|
|
274
|
+
otpContext: 'account',
|
|
273
275
|
verificationData: null,
|
|
274
276
|
cardToDelete: null,
|
|
275
277
|
removingCardId: null
|
|
@@ -114,6 +114,10 @@ export type MasterpassRestOptionRenderProps = {
|
|
|
114
114
|
cvc: string;
|
|
115
115
|
error: any;
|
|
116
116
|
|
|
117
|
+
/** True while a Masterpass transaction started on this tab has not settled. */
|
|
118
|
+
isPaymentPending: boolean;
|
|
119
|
+
dismissPaymentPending: () => void;
|
|
120
|
+
|
|
117
121
|
accountData: any;
|
|
118
122
|
accountStatus: any;
|
|
119
123
|
modalState: any;
|
|
@@ -102,6 +102,8 @@ export interface InformationModalData {
|
|
|
102
102
|
buttonText: string;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
export type OTPContext = 'account' | 'payment';
|
|
106
|
+
|
|
105
107
|
export interface ModalState {
|
|
106
108
|
showLinkModal: boolean;
|
|
107
109
|
showOTPModal: boolean;
|
|
@@ -110,6 +112,7 @@ export interface ModalState {
|
|
|
110
112
|
showRewardModal: boolean;
|
|
111
113
|
informationModalData: InformationModalData | null;
|
|
112
114
|
otpType: OTPType;
|
|
115
|
+
otpContext: OTPContext;
|
|
113
116
|
verificationData: any | null;
|
|
114
117
|
cardToDelete: any | null;
|
|
115
118
|
removingCardId: string | null;
|
package/src/utils/card-utils.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const formatCardNumber = (value: string): string => {
|
|
|
9
9
|
|
|
10
10
|
const formatted = cleanValue.replace(/(\d{4})(?=\d)/g, '$1 ');
|
|
11
11
|
|
|
12
|
-
return formatted.substring(0,
|
|
12
|
+
return formatted.substring(0, 23);
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export const formatExpiryDate = (value: string): string => {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
setPaymentPending,
|
|
3
|
+
getPaymentPending,
|
|
4
|
+
clearPaymentPending
|
|
5
|
+
} from './payment-utils';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'masterpass_rest_payment_pending';
|
|
8
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
describe('payment pending record', () => {
|
|
11
|
+
it('reports nothing pending when no payment has started', () => {
|
|
12
|
+
expect(getPaymentPending()).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('remembers the order number the transaction was opened with', () => {
|
|
16
|
+
setPaymentPending('ORDER-123');
|
|
17
|
+
|
|
18
|
+
expect(getPaymentPending()).toEqual({
|
|
19
|
+
orderNo: 'ORDER-123',
|
|
20
|
+
startedAt: expect.any(Number)
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('still marks a payment pending when no order number is known', () => {
|
|
25
|
+
setPaymentPending(null);
|
|
26
|
+
|
|
27
|
+
const record = getPaymentPending();
|
|
28
|
+
expect(record).not.toBeNull();
|
|
29
|
+
expect(record?.orderNo).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('stops reporting pending once cleared', () => {
|
|
33
|
+
setPaymentPending('ORDER-123');
|
|
34
|
+
clearPaymentPending();
|
|
35
|
+
|
|
36
|
+
expect(getPaymentPending()).toBeNull();
|
|
37
|
+
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps a record that is still inside the ttl', () => {
|
|
41
|
+
window.sessionStorage.setItem(
|
|
42
|
+
STORAGE_KEY,
|
|
43
|
+
JSON.stringify({ orderNo: 'ORDER-123', startedAt: Date.now() - TTL_MS + 60_000 })
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-123');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('expires and purges a record past the ttl so an abandoned tab recovers', () => {
|
|
50
|
+
window.sessionStorage.setItem(
|
|
51
|
+
STORAGE_KEY,
|
|
52
|
+
JSON.stringify({ orderNo: 'ORDER-123', startedAt: Date.now() - TTL_MS - 1 })
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(getPaymentPending()).toBeNull();
|
|
56
|
+
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('treats an unparseable record as not pending', () => {
|
|
60
|
+
window.sessionStorage.setItem(STORAGE_KEY, 'not-json');
|
|
61
|
+
|
|
62
|
+
expect(getPaymentPending()).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('treats a record without a timestamp as not pending', () => {
|
|
66
|
+
window.sessionStorage.setItem(
|
|
67
|
+
STORAGE_KEY,
|
|
68
|
+
JSON.stringify({ orderNo: 'ORDER-123' })
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(getPaymentPending()).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('degrades quietly when session storage is unavailable', () => {
|
|
75
|
+
const proto = Object.getPrototypeOf(window.sessionStorage);
|
|
76
|
+
const boom = () => {
|
|
77
|
+
throw new Error('storage disabled');
|
|
78
|
+
};
|
|
79
|
+
jest.spyOn(proto, 'setItem').mockImplementation(boom);
|
|
80
|
+
jest.spyOn(proto, 'getItem').mockImplementation(boom);
|
|
81
|
+
jest.spyOn(proto, 'removeItem').mockImplementation(boom);
|
|
82
|
+
|
|
83
|
+
expect(() => setPaymentPending('ORDER-123')).not.toThrow();
|
|
84
|
+
expect(getPaymentPending()).toBeNull();
|
|
85
|
+
expect(() => clearPaymentPending()).not.toThrow();
|
|
86
|
+
|
|
87
|
+
jest.restoreAllMocks();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -15,6 +15,53 @@ export const isTokenExpired = (
|
|
|
15
15
|
return Date.now() >= tokenData.exp * 1000 - bufferMs;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const PAYMENT_PENDING_KEY = 'masterpass_rest_payment_pending';
|
|
19
|
+
const PAYMENT_PENDING_TTL_MS = 30 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
export interface PaymentPendingRecord {
|
|
22
|
+
orderNo: string | null;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const setPaymentPending = (orderNo?: string | null): void => {
|
|
27
|
+
try {
|
|
28
|
+
window.sessionStorage.setItem(
|
|
29
|
+
PAYMENT_PENDING_KEY,
|
|
30
|
+
JSON.stringify({ orderNo: orderNo || null, startedAt: Date.now() })
|
|
31
|
+
);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// sessionStorage may be unavailable (private mode, blocked storage)
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const getPaymentPending = (): PaymentPendingRecord | null => {
|
|
38
|
+
try {
|
|
39
|
+
const raw = window.sessionStorage.getItem(PAYMENT_PENDING_KEY);
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
|
|
42
|
+
const parsed = JSON.parse(raw) as PaymentPendingRecord;
|
|
43
|
+
if (
|
|
44
|
+
!parsed?.startedAt ||
|
|
45
|
+
Date.now() - parsed.startedAt > PAYMENT_PENDING_TTL_MS
|
|
46
|
+
) {
|
|
47
|
+
window.sessionStorage.removeItem(PAYMENT_PENDING_KEY);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return parsed;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const clearPaymentPending = (): void => {
|
|
58
|
+
try {
|
|
59
|
+
window.sessionStorage.removeItem(PAYMENT_PENDING_KEY);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// sessionStorage may be unavailable (private mode, blocked storage)
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
18
65
|
export const enhance3DSecureUrl = (originalUrl: string): string => {
|
|
19
66
|
try {
|
|
20
67
|
const baseUrl = window.location.origin;
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
setLocale,
|
|
16
16
|
setCurrency
|
|
17
17
|
} from '../redux/reducer';
|
|
18
|
+
import {
|
|
19
|
+
getPaymentPending,
|
|
20
|
+
clearPaymentPending
|
|
21
|
+
} from '../utils/payment-utils';
|
|
18
22
|
import type { CreditCardFormData } from '../utils/validation-schemas';
|
|
19
23
|
import type {
|
|
20
24
|
MasterpassRestOptionCustomRender,
|
|
@@ -223,6 +227,17 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
223
227
|
const [cvc, setCvc] = useState<string>('');
|
|
224
228
|
const [isProcessingPayment, setIsProcessingPayment] = useState(false);
|
|
225
229
|
|
|
230
|
+
// Initialized during render (not in an effect) so consumers see the real
|
|
231
|
+
// value before their own mount effects run.
|
|
232
|
+
const [isPaymentPending, setIsPaymentPending] = useState<boolean>(() =>
|
|
233
|
+
typeof window === 'undefined' ? false : Boolean(getPaymentPending())
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const dismissPaymentPending = useCallback(() => {
|
|
237
|
+
clearPaymentPending();
|
|
238
|
+
setIsPaymentPending(false);
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
226
241
|
const [setMasterpassRestBinNumber] = useSetMasterpassRestBinNumberMutation();
|
|
227
242
|
|
|
228
243
|
const {
|
|
@@ -237,6 +252,7 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
237
252
|
handleInstallmentSelect,
|
|
238
253
|
processPayment,
|
|
239
254
|
processDirectPayment,
|
|
255
|
+
finalizePaymentAfterVerification,
|
|
240
256
|
openRewardModal,
|
|
241
257
|
closeRewardModal,
|
|
242
258
|
confirmRewards,
|
|
@@ -334,15 +350,20 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
334
350
|
|
|
335
351
|
useEffect(() => {
|
|
336
352
|
const error = getPosError();
|
|
353
|
+
|
|
337
354
|
if (error && Object.keys(error).length > 0) {
|
|
338
355
|
dispatch(setError(error));
|
|
356
|
+
|
|
357
|
+
// The completion request already ran and failed, so the order number it
|
|
358
|
+
// was bound to is settled and the flow may restart from scratch.
|
|
359
|
+
dismissPaymentPending();
|
|
339
360
|
}
|
|
340
361
|
|
|
341
362
|
return () => {
|
|
342
363
|
resetData();
|
|
343
364
|
dispatch(clearAllErrors());
|
|
344
365
|
};
|
|
345
|
-
}, [dispatch, resetData]);
|
|
366
|
+
}, [dispatch, resetData, dismissPaymentPending]);
|
|
346
367
|
|
|
347
368
|
const getErrorInfo = (error: any) => {
|
|
348
369
|
if (error && typeof error === 'object') {
|
|
@@ -427,7 +448,8 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
427
448
|
if (result.requiresOTP && result.otpType) {
|
|
428
449
|
updateModalState({
|
|
429
450
|
showOTPModal: true,
|
|
430
|
-
otpType: result.otpType
|
|
451
|
+
otpType: result.otpType,
|
|
452
|
+
otpContext: 'payment'
|
|
431
453
|
});
|
|
432
454
|
return;
|
|
433
455
|
}
|
|
@@ -453,12 +475,34 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
453
475
|
|
|
454
476
|
const result = await handleOTPSubmit(otp, mergedTexts);
|
|
455
477
|
|
|
478
|
+
// A payment-context OTP leaves the Masterpass transaction open; the backend
|
|
479
|
+
// only learns about it once the completion request is sent.
|
|
480
|
+
if (result?.success && result?.isPaymentContext) {
|
|
481
|
+
const finalizeResult = await finalizePaymentAfterVerification(
|
|
482
|
+
result.verificationData
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
if (!finalizeResult.success) {
|
|
486
|
+
dispatch(
|
|
487
|
+
setError(finalizeResult.message || mergedTexts.paymentFailedText)
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return finalizeResult;
|
|
492
|
+
}
|
|
493
|
+
|
|
456
494
|
return result;
|
|
457
|
-
}, [
|
|
495
|
+
}, [
|
|
496
|
+
handleOTPSubmit,
|
|
497
|
+
finalizePaymentAfterVerification,
|
|
498
|
+
dispatch,
|
|
499
|
+
mergedTexts
|
|
500
|
+
]);
|
|
458
501
|
|
|
459
502
|
const handleCardSelectWithErrorHandling = useCallback(async (card: any) => {
|
|
460
503
|
try {
|
|
461
504
|
dispatch(clearAllErrors());
|
|
505
|
+
dismissPaymentPending();
|
|
462
506
|
await handleCardSelect(card);
|
|
463
507
|
} catch (error) {
|
|
464
508
|
const errorMessage =
|
|
@@ -467,11 +511,12 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
467
511
|
: mergedTexts.failedToSelectCardText;
|
|
468
512
|
dispatch(setError(errorMessage));
|
|
469
513
|
}
|
|
470
|
-
}, [dispatch, handleCardSelect, mergedTexts.failedToSelectCardText]);
|
|
514
|
+
}, [dispatch, dismissPaymentPending, handleCardSelect, mergedTexts.failedToSelectCardText]);
|
|
471
515
|
|
|
472
516
|
const handleInstallmentSelectWithErrorHandling = useCallback(async (installment: any) => {
|
|
473
517
|
try {
|
|
474
518
|
dispatch(clearAllErrors());
|
|
519
|
+
dismissPaymentPending();
|
|
475
520
|
await handleInstallmentSelect(installment);
|
|
476
521
|
} catch (error) {
|
|
477
522
|
const errorMessage =
|
|
@@ -480,7 +525,7 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
480
525
|
: mergedTexts.failedToSelectInstallmentText;
|
|
481
526
|
dispatch(setError(errorMessage));
|
|
482
527
|
}
|
|
483
|
-
}, [dispatch, handleInstallmentSelect, mergedTexts.failedToSelectInstallmentText]);
|
|
528
|
+
}, [dispatch, dismissPaymentPending, handleInstallmentSelect, mergedTexts.failedToSelectInstallmentText]);
|
|
484
529
|
|
|
485
530
|
const handleConfirmRewards = useCallback(
|
|
486
531
|
async (selected: RewardItem[]) => {
|
|
@@ -500,6 +545,7 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
500
545
|
}, [updatePaymentState]);
|
|
501
546
|
|
|
502
547
|
const handlePaymentMethodChange = useCallback((method: 'stored_card' | 'new_card') => {
|
|
548
|
+
dismissPaymentPending();
|
|
503
549
|
setPaymentMethod(method);
|
|
504
550
|
updatePaymentState({
|
|
505
551
|
selectedCard: null,
|
|
@@ -511,7 +557,7 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
511
557
|
setCvc('');
|
|
512
558
|
dispatch(setLastCheckedBin(''));
|
|
513
559
|
dispatch(clearNewCardFormData());
|
|
514
|
-
}, [dispatch, updatePaymentState]);
|
|
560
|
+
}, [dispatch, dismissPaymentPending, updatePaymentState]);
|
|
515
561
|
|
|
516
562
|
const customRenderRef = useRef(customRender);
|
|
517
563
|
customRenderRef.current = customRender;
|
|
@@ -564,6 +610,8 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
564
610
|
shouldShowDirectForm,
|
|
565
611
|
cvc,
|
|
566
612
|
error,
|
|
613
|
+
isPaymentPending,
|
|
614
|
+
dismissPaymentPending,
|
|
567
615
|
|
|
568
616
|
accountData,
|
|
569
617
|
accountStatus,
|
|
@@ -606,6 +654,8 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
606
654
|
shouldShowDirectForm,
|
|
607
655
|
cvc,
|
|
608
656
|
error,
|
|
657
|
+
isPaymentPending,
|
|
658
|
+
dismissPaymentPending,
|
|
609
659
|
accountData,
|
|
610
660
|
accountStatus,
|
|
611
661
|
modalState,
|