@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.
@@ -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;
@@ -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, 19);
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
- }, [handleOTPSubmit, mergedTexts]);
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,