@akinon/pz-masterpass-rest 2.0.56-beta.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.
@@ -19,6 +19,7 @@ import {
19
19
  handleMasterpassResponse,
20
20
  handleVerification
21
21
  } from '../utils/response-handler';
22
+ import { getPaymentPending, setPaymentPending } from '../utils/payment-utils';
22
23
  import type {
23
24
  AccountAccessSuccessResponse,
24
25
  CardModel
@@ -186,6 +187,7 @@ export const useMasterpassAccount = () => {
186
187
  dispatch(
187
188
  updateModalState({
188
189
  otpType: result.otpType,
190
+ otpContext: 'account',
189
191
  verificationData: result.data,
190
192
  showLinkModal: false,
191
193
  showOTPModal: true
@@ -205,9 +207,22 @@ export const useMasterpassAccount = () => {
205
207
  const handleOTPSubmit = useCallback(
206
208
  async (otp: string, texts?: any) => {
207
209
  const result = await handleVerification(otp, token, tokenData?.MerchantId);
210
+ const isPaymentContext = modalState.otpContext === 'payment';
208
211
 
209
212
  if (result.success) {
210
213
  dispatch(setShowOTPModal(false));
214
+
215
+ // A payment-context OTP still has an open Masterpass transaction. The
216
+ // caller has to complete it against the backend; refreshing the account
217
+ // here would leave the order unfinalized.
218
+ if (isPaymentContext) {
219
+ return {
220
+ success: true,
221
+ isPaymentContext: true,
222
+ verificationData: result.data
223
+ };
224
+ }
225
+
211
226
  await refreshAccountData();
212
227
  return { success: true };
213
228
  } else if (result.sessionExpired) {
@@ -230,6 +245,13 @@ export const useMasterpassAccount = () => {
230
245
  };
231
246
  } else if (result.requires3D && result.redirectUrl) {
232
247
  dispatch(setShowOTPModal(false));
248
+
249
+ // Leaving the tab for the bank restarts the wait, so refresh the record
250
+ // without losing the order number it was opened with.
251
+ if (isPaymentContext) {
252
+ setPaymentPending(getPaymentPending()?.orderNo ?? null);
253
+ }
254
+
233
255
  window.location.href = result.redirectUrl;
234
256
  return {
235
257
  success: false,
@@ -257,7 +279,13 @@ export const useMasterpassAccount = () => {
257
279
  };
258
280
  }
259
281
  },
260
- [dispatch, refreshAccountData, token, tokenData?.MerchantId]
282
+ [
283
+ dispatch,
284
+ refreshAccountData,
285
+ token,
286
+ tokenData?.MerchantId,
287
+ modalState.otpContext
288
+ ]
261
289
  );
262
290
 
263
291
  const handleRemoveCard = useCallback(
@@ -368,6 +396,7 @@ export const useMasterpassAccount = () => {
368
396
  dispatch(
369
397
  updateModalState({
370
398
  otpType: result.otpType,
399
+ otpContext: 'account',
371
400
  verificationData: result.data,
372
401
  showLinkModal: false,
373
402
  showOTPModal: true
@@ -28,7 +28,9 @@ import { PaymentService } from '../services/payment';
28
28
  import {
29
29
  createPaymentRequest,
30
30
  createDirectPaymentRequest,
31
- getTransactionType
31
+ getTransactionType,
32
+ setPaymentPending,
33
+ clearPaymentPending
32
34
  } from '../utils/payment-utils';
33
35
  import { handleMasterpassResponse } from '../utils/response-handler';
34
36
  import type { Installment, RewardItem } from '../types/payment.types';
@@ -248,13 +250,20 @@ export const useMasterpassPayment = (
248
250
 
249
251
  dispatch(setOrderData(prepareResult));
250
252
 
253
+ const orderNo = prepareResult.context_list?.[0]?.page_context?.order_no;
254
+
255
+ // From here on the order number is bound to a Masterpass transaction.
256
+ // Regenerating it before that transaction settles makes the backend
257
+ // reject the completion with ORDER_NO_MISMATCH.
258
+ setPaymentPending(orderNo);
259
+
251
260
  const paymentService = new PaymentService(activeToken, activeMerchantId);
252
261
  const paymentRequest = createPaymentRequest({
253
262
  cvc,
254
263
  cardAlias: selectedCard.cardAlias,
255
264
  accountKey: activeTokenData.AccountKey,
256
265
  amount: prepareResult.pre_order?.total_amount_with_interest,
257
- orderNo: prepareResult.context_list?.[0]?.page_context?.order_no,
266
+ orderNo,
258
267
  currencyCode: currency,
259
268
  installmentCount: selectedInstallment.installment_count,
260
269
  authenticationMethod: activeTokenData.AuthenticationMethod,
@@ -284,6 +293,7 @@ export const useMasterpassPayment = (
284
293
  }).unwrap();
285
294
 
286
295
  dispatch(setOrderData(finalizeResult));
296
+ clearPaymentPending();
287
297
 
288
298
  if (finalizeResult.errors) {
289
299
  return { success: false, message: finalizeResult.errors };
@@ -293,6 +303,10 @@ export const useMasterpassPayment = (
293
303
  return { success: true };
294
304
  }
295
305
 
306
+ if (!response.requiresOTP) {
307
+ clearPaymentPending();
308
+ }
309
+
296
310
  return {
297
311
  success: response.success,
298
312
  requiresOTP: response.requiresOTP,
@@ -300,6 +314,7 @@ export const useMasterpassPayment = (
300
314
  message: response.message
301
315
  };
302
316
  } catch (error) {
317
+ clearPaymentPending();
303
318
  return {
304
319
  success: false,
305
320
  message:
@@ -357,6 +372,13 @@ export const useMasterpassPayment = (
357
372
 
358
373
  dispatch(setOrderData(prepareResult));
359
374
 
375
+ const orderNo = prepareResult.context_list?.[0]?.page_context?.order_no;
376
+
377
+ // From here on the order number is bound to a Masterpass transaction.
378
+ // Regenerating it before that transaction settles makes the backend
379
+ // reject the completion with ORDER_NO_MISMATCH.
380
+ setPaymentPending(orderNo);
381
+
360
382
  const paymentService = new PaymentService(activeToken, activeMerchantId);
361
383
  const directPaymentRequest = createDirectPaymentRequest({
362
384
  cvc: newCardFormData.cvv,
@@ -366,7 +388,7 @@ export const useMasterpassPayment = (
366
388
  expiryDate: newCardFormData.expiryDate,
367
389
  accountKey: activeTokenData.AccountKey,
368
390
  amount: prepareResult.pre_order?.total_amount_with_interest,
369
- orderNo: prepareResult.context_list?.[0]?.page_context?.order_no,
391
+ orderNo,
370
392
  currencyCode: currency,
371
393
  installmentCount: selectedInstallment.installment_count,
372
394
  authenticationMethod: activeTokenData.AuthenticationMethod,
@@ -398,6 +420,7 @@ export const useMasterpassPayment = (
398
420
  }).unwrap();
399
421
 
400
422
  dispatch(setOrderData(finalizeResult));
423
+ clearPaymentPending();
401
424
 
402
425
  if (finalizeResult.errors) {
403
426
  return { success: false, message: finalizeResult.errors };
@@ -407,6 +430,10 @@ export const useMasterpassPayment = (
407
430
  return { success: true };
408
431
  }
409
432
 
433
+ if (!response.requiresOTP) {
434
+ clearPaymentPending();
435
+ }
436
+
410
437
  return {
411
438
  success: response.success,
412
439
  requiresOTP: response.requiresOTP,
@@ -414,6 +441,7 @@ export const useMasterpassPayment = (
414
441
  message: response.message
415
442
  };
416
443
  } catch (error) {
444
+ clearPaymentPending();
417
445
  return {
418
446
  success: false,
419
447
  message:
@@ -435,6 +463,41 @@ export const useMasterpassPayment = (
435
463
  dispatch
436
464
  ]);
437
465
 
466
+ const finalizePaymentAfterVerification = useCallback(
467
+ async (verificationResponse: any): Promise<PaymentResult> => {
468
+ const { useThreeD } = paymentState;
469
+
470
+ try {
471
+ const finalizeResult = await finalizeMasterpassOrder({
472
+ responseCode: verificationResponse?.result?.responseCode,
473
+ token: verificationResponse?.result?.token,
474
+ three_d_secure: useThreeD,
475
+ transactionType: getTransactionType(useThreeD)
476
+ }).unwrap();
477
+
478
+ dispatch(setOrderData(finalizeResult));
479
+ clearPaymentPending();
480
+
481
+ if (finalizeResult.errors) {
482
+ return { success: false, message: finalizeResult.errors };
483
+ }
484
+
485
+ dispatch(setOrderCompleted(true));
486
+ return { success: true };
487
+ } catch (error) {
488
+ clearPaymentPending();
489
+ return {
490
+ success: false,
491
+ message:
492
+ error instanceof Error
493
+ ? error.message
494
+ : 'An unexpected error occurred'
495
+ };
496
+ }
497
+ },
498
+ [paymentState, finalizeMasterpassOrder, dispatch]
499
+ );
500
+
438
501
  return {
439
502
  paymentState,
440
503
 
@@ -451,6 +514,7 @@ export const useMasterpassPayment = (
451
514
  handleInstallmentSelect,
452
515
  processPayment,
453
516
  processDirectPayment,
517
+ finalizePaymentAfterVerification,
454
518
  fetchRewardsForCard,
455
519
  openRewardModal,
456
520
  closeRewardModal,
@@ -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;
@@ -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,