@akinon/pz-masterpass-rest 1.126.7 → 1.126.8
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 +4 -1
- 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 +30 -1
- package/src/hooks/useMasterpassPayment.ts +67 -3
- 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-masterpass-rest",
|
|
3
|
-
"version": "1.126.
|
|
3
|
+
"version": "1.126.8",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"types": "src/index.d.ts",
|
|
6
6
|
"description": "Modern React-based Masterpass REST API integration package for ProjectZero e-commerce platform",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"docs",
|
|
11
11
|
"README.md"
|
|
12
12
|
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "jest"
|
|
15
|
+
},
|
|
13
16
|
"keywords": [
|
|
14
17
|
"masterpass",
|
|
15
18
|
"payment",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
5
|
+
|
|
6
|
+
import masterpassRestReducer from '../redux/reducer';
|
|
7
|
+
import { getPaymentPending, setPaymentPending } from '../utils/payment-utils';
|
|
8
|
+
|
|
9
|
+
const handleVerificationMock = jest.fn();
|
|
10
|
+
const accountAccessMock = jest.fn();
|
|
11
|
+
|
|
12
|
+
jest.mock('../utils/response-handler', () => ({
|
|
13
|
+
handleVerification: (...args: unknown[]) => handleVerificationMock(...args),
|
|
14
|
+
handleMasterpassResponse: jest.fn(async (response) => response)
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('../services/account', () => ({
|
|
18
|
+
AccountService: class {
|
|
19
|
+
accountAccess = accountAccessMock;
|
|
20
|
+
linkToMerchant = jest.fn();
|
|
21
|
+
removeCard = jest.fn();
|
|
22
|
+
addCard = jest.fn();
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock('./useMasterpassToken', () => ({
|
|
27
|
+
useMasterpassToken: () => ({
|
|
28
|
+
refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } }))
|
|
29
|
+
})
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Imported after the mocks so the hook picks them up.
|
|
33
|
+
import { useMasterpassAccount } from './useMasterpassAccount';
|
|
34
|
+
|
|
35
|
+
const renderAccountHook = (otpContext: 'account' | 'payment') => {
|
|
36
|
+
const store = configureStore({
|
|
37
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
38
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
store.dispatch({
|
|
42
|
+
type: 'masterpassRest/setMasterpassRestTokenData',
|
|
43
|
+
payload: {
|
|
44
|
+
MerchantId: 'merchant-1',
|
|
45
|
+
AccountKey: 'account-1',
|
|
46
|
+
UserId: 'user-1'
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
store.dispatch({
|
|
51
|
+
type: 'masterpassRest/updateModalState',
|
|
52
|
+
payload: { otpContext, showOTPModal: true }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
56
|
+
<Provider store={store}>{children}</Provider>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return { ...renderHook(() => useMasterpassAccount(), { wrapper }), store };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
accountAccessMock.mockResolvedValue({
|
|
64
|
+
statusCode: 200,
|
|
65
|
+
result: { cards: [] }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('verification during a payment', () => {
|
|
70
|
+
it('hands the verification result back so the caller can complete the order', async () => {
|
|
71
|
+
handleVerificationMock.mockResolvedValue({
|
|
72
|
+
success: true,
|
|
73
|
+
data: { result: { responseCode: '0000', token: 'verified-token' } }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const { result } = renderAccountHook('payment');
|
|
77
|
+
|
|
78
|
+
let outcome: any;
|
|
79
|
+
await act(async () => {
|
|
80
|
+
outcome = await result.current.handleOTPSubmit('123456');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(outcome).toEqual({
|
|
84
|
+
success: true,
|
|
85
|
+
isPaymentContext: true,
|
|
86
|
+
verificationData: { result: { responseCode: '0000', token: 'verified-token' } }
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not treat a refreshed card list as a completed payment', async () => {
|
|
91
|
+
handleVerificationMock.mockResolvedValue({
|
|
92
|
+
success: true,
|
|
93
|
+
data: { result: { responseCode: '0000', token: 'verified-token' } }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const { result } = renderAccountHook('payment');
|
|
97
|
+
accountAccessMock.mockClear();
|
|
98
|
+
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await result.current.handleOTPSubmit('123456');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Refreshing the account here used to be the only thing that happened, which
|
|
104
|
+
// left the order uncompleted on the backend.
|
|
105
|
+
expect(accountAccessMock).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('keeps the order number pending when verification sends the shopper to 3D', async () => {
|
|
109
|
+
setPaymentPending('ORDER-1');
|
|
110
|
+
handleVerificationMock.mockResolvedValue({
|
|
111
|
+
requires3D: true,
|
|
112
|
+
redirectUrl: 'https://bank.example/3d'
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const location = window.location;
|
|
116
|
+
delete (window as any).location;
|
|
117
|
+
(window as any).location = { ...location, href: '' };
|
|
118
|
+
|
|
119
|
+
const { result } = renderAccountHook('payment');
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.handleOTPSubmit('123456');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
126
|
+
|
|
127
|
+
(window as any).location = location;
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('verification during an account operation', () => {
|
|
132
|
+
it('refreshes the saved cards and reports plain success', async () => {
|
|
133
|
+
handleVerificationMock.mockResolvedValue({
|
|
134
|
+
success: true,
|
|
135
|
+
data: { result: { responseCode: '0000' } }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { result } = renderAccountHook('account');
|
|
139
|
+
accountAccessMock.mockClear();
|
|
140
|
+
|
|
141
|
+
let outcome: any;
|
|
142
|
+
await act(async () => {
|
|
143
|
+
outcome = await result.current.handleOTPSubmit('123456');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(outcome).toEqual({ success: true });
|
|
147
|
+
expect(accountAccessMock).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('leaves the pending record untouched on a 3D redirect', async () => {
|
|
151
|
+
setPaymentPending('ORDER-1');
|
|
152
|
+
const before = getPaymentPending()?.startedAt;
|
|
153
|
+
|
|
154
|
+
handleVerificationMock.mockResolvedValue({
|
|
155
|
+
requires3D: true,
|
|
156
|
+
redirectUrl: 'https://bank.example/3d'
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const location = window.location;
|
|
160
|
+
delete (window as any).location;
|
|
161
|
+
(window as any).location = { ...location, href: '' };
|
|
162
|
+
|
|
163
|
+
const { result } = renderAccountHook('account');
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await result.current.handleOTPSubmit('123456');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(getPaymentPending()?.startedAt).toBe(before);
|
|
170
|
+
|
|
171
|
+
(window as any).location = location;
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('the default verification context', () => {
|
|
176
|
+
it('is the account flow, so an unset context never skips the card refresh', async () => {
|
|
177
|
+
handleVerificationMock.mockResolvedValue({
|
|
178
|
+
success: true,
|
|
179
|
+
data: { result: {} }
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const store = configureStore({
|
|
183
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
184
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(store.getState().masterpassRest.modalState.otpContext).toBe('account');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
5
|
+
|
|
6
|
+
import masterpassRestReducer from '../redux/reducer';
|
|
7
|
+
import { getPaymentPending, setPaymentPending } from '../utils/payment-utils';
|
|
8
|
+
|
|
9
|
+
const prepareMock = jest.fn();
|
|
10
|
+
const finalizeMock = jest.fn();
|
|
11
|
+
const binNumberMock = jest.fn();
|
|
12
|
+
const installmentMock = jest.fn();
|
|
13
|
+
const processPaymentMock = jest.fn();
|
|
14
|
+
const directPaymentMock = jest.fn();
|
|
15
|
+
const handleResponseMock = jest.fn();
|
|
16
|
+
|
|
17
|
+
const asMutation = (fn: jest.Mock) => () =>
|
|
18
|
+
[(arg: unknown) => ({ unwrap: () => fn(arg) }), { isLoading: false }];
|
|
19
|
+
|
|
20
|
+
jest.mock('../redux/api', () => ({
|
|
21
|
+
usePrepareMasterpassOrderMutation: asMutation(prepareMock),
|
|
22
|
+
useFinalizeMasterpassOrderMutation: asMutation(finalizeMock),
|
|
23
|
+
useSetMasterpassRestBinNumberMutation: asMutation(binNumberMock),
|
|
24
|
+
useCheckoutMasterpassInstallmentMutation: asMutation(installmentMock),
|
|
25
|
+
useQueryMasterpassRewardsMutation: asMutation(jest.fn()),
|
|
26
|
+
useSelectMasterpassRewardsMutation: asMutation(jest.fn())
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock('../services/payment', () => ({
|
|
30
|
+
PaymentService: class {
|
|
31
|
+
processPayment = processPaymentMock;
|
|
32
|
+
directPayment = directPaymentMock;
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
jest.mock('../utils/response-handler', () => ({
|
|
37
|
+
handleMasterpassResponse: (...args: unknown[]) => handleResponseMock(...args)
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
jest.mock('./useMasterpassToken', () => ({
|
|
41
|
+
useMasterpassToken: () => ({
|
|
42
|
+
refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } }))
|
|
43
|
+
})
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Imported after the mocks so the hook picks them up.
|
|
47
|
+
import { useMasterpassPayment } from './useMasterpassPayment';
|
|
48
|
+
|
|
49
|
+
const PREPARED_ORDER = {
|
|
50
|
+
pre_order: { total_amount_with_interest: '100.00' },
|
|
51
|
+
context_list: [{ page_context: { order_no: 'ORDER-1', extras: {} } }]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const SELECTED_CARD = { cardAlias: 'my card', cardBin: '454671' };
|
|
55
|
+
const SELECTED_INSTALLMENT = { pk: 1, installment_count: 1 };
|
|
56
|
+
|
|
57
|
+
const NEW_CARD_FORM = {
|
|
58
|
+
cardNumber: '4546 7112 3456 7890',
|
|
59
|
+
cardholderName: 'Test User',
|
|
60
|
+
expiryDate: '12/30',
|
|
61
|
+
cvv: '123'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const renderPaymentHook = ({ withNewCardForm = false } = {}) => {
|
|
65
|
+
const store = configureStore({
|
|
66
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
67
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
store.dispatch({
|
|
71
|
+
type: 'masterpassRest/updatePaymentState',
|
|
72
|
+
payload: {
|
|
73
|
+
selectedCard: SELECTED_CARD,
|
|
74
|
+
selectedInstallment: SELECTED_INSTALLMENT,
|
|
75
|
+
cvc: '123',
|
|
76
|
+
useThreeD: true
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (withNewCardForm) {
|
|
81
|
+
store.dispatch({
|
|
82
|
+
type: 'masterpassRest/setNewCardFormData',
|
|
83
|
+
payload: NEW_CARD_FORM
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
88
|
+
<Provider store={store}>{children}</Provider>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { ...renderHook(() => useMasterpassPayment(), { wrapper }), store };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
prepareMock.mockResolvedValue(PREPARED_ORDER);
|
|
96
|
+
finalizeMock.mockResolvedValue({ errors: null });
|
|
97
|
+
processPaymentMock.mockResolvedValue({ success: true, data: { result: {} } });
|
|
98
|
+
directPaymentMock.mockResolvedValue({ success: true, data: { result: {} } });
|
|
99
|
+
handleResponseMock.mockResolvedValue({ success: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('order number lifecycle during payment', () => {
|
|
103
|
+
it('marks the order number pending as soon as it is bound to a transaction', async () => {
|
|
104
|
+
// The SDK call never resolves, so the assertion sees the state the user is
|
|
105
|
+
// in while the bank page is open.
|
|
106
|
+
processPaymentMock.mockImplementation(() => new Promise(() => {}));
|
|
107
|
+
|
|
108
|
+
const { result } = renderPaymentHook();
|
|
109
|
+
|
|
110
|
+
act(() => {
|
|
111
|
+
void result.current.processPayment();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('releases the order number once the backend has completed the order', async () => {
|
|
122
|
+
const { result } = renderPaymentHook();
|
|
123
|
+
|
|
124
|
+
await act(async () => {
|
|
125
|
+
await result.current.processPayment();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(finalizeMock).toHaveBeenCalled();
|
|
129
|
+
expect(getPaymentPending()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('keeps the order number pending while the shopper is redirected to 3D', async () => {
|
|
133
|
+
handleResponseMock.mockResolvedValue({
|
|
134
|
+
requires3D: true,
|
|
135
|
+
redirectUrl: 'https://bank.example/3d'
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// jsdom refuses real navigation; the assignment itself is not under test.
|
|
139
|
+
const location = window.location;
|
|
140
|
+
delete (window as any).location;
|
|
141
|
+
(window as any).location = { ...location, href: '' };
|
|
142
|
+
|
|
143
|
+
const { result } = renderPaymentHook();
|
|
144
|
+
|
|
145
|
+
let outcome: any;
|
|
146
|
+
await act(async () => {
|
|
147
|
+
outcome = await result.current.processPayment();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(outcome.requiresRedirect).toBe(true);
|
|
151
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
152
|
+
|
|
153
|
+
(window as any).location = location;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps the order number pending while a verification step is open', async () => {
|
|
157
|
+
handleResponseMock.mockResolvedValue({
|
|
158
|
+
success: false,
|
|
159
|
+
requiresOTP: true,
|
|
160
|
+
otpType: 'OTP'
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const { result } = renderPaymentHook();
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await result.current.processPayment();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('releases the order number when the payment is rejected outright', async () => {
|
|
173
|
+
handleResponseMock.mockResolvedValue({
|
|
174
|
+
success: false,
|
|
175
|
+
message: 'Insufficient funds'
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { result } = renderPaymentHook();
|
|
179
|
+
|
|
180
|
+
await act(async () => {
|
|
181
|
+
await result.current.processPayment();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(getPaymentPending()).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('releases the order number when the flow throws', async () => {
|
|
188
|
+
prepareMock.mockRejectedValue(new Error('network down'));
|
|
189
|
+
setPaymentPending('STALE-ORDER');
|
|
190
|
+
|
|
191
|
+
const { result } = renderPaymentHook();
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
await result.current.processPayment();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(getPaymentPending()).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('marks the order number pending on the new-card flow too', async () => {
|
|
201
|
+
directPaymentMock.mockImplementation(() => new Promise(() => {}));
|
|
202
|
+
|
|
203
|
+
const { result } = renderPaymentHook({ withNewCardForm: true });
|
|
204
|
+
|
|
205
|
+
act(() => {
|
|
206
|
+
void result.current.processDirectPayment();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('completing a payment after a verification step', () => {
|
|
218
|
+
it('sends the verification result to the backend and releases the order number', async () => {
|
|
219
|
+
setPaymentPending('ORDER-1');
|
|
220
|
+
|
|
221
|
+
const { result } = renderPaymentHook();
|
|
222
|
+
|
|
223
|
+
let outcome: any;
|
|
224
|
+
await act(async () => {
|
|
225
|
+
outcome = await result.current.finalizePaymentAfterVerification({
|
|
226
|
+
result: { responseCode: '0000', token: 'verified-token' }
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(finalizeMock).toHaveBeenCalledWith({
|
|
231
|
+
responseCode: '0000',
|
|
232
|
+
token: 'verified-token',
|
|
233
|
+
three_d_secure: true,
|
|
234
|
+
transactionType: 'PURCHASE_3D'
|
|
235
|
+
});
|
|
236
|
+
expect(outcome.success).toBe(true);
|
|
237
|
+
expect(getPaymentPending()).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('surfaces backend errors instead of reporting success', async () => {
|
|
241
|
+
finalizeMock.mockResolvedValue({ errors: { non_field_errors: 'ORDER_NO_MISMATCH' } });
|
|
242
|
+
|
|
243
|
+
const { result } = renderPaymentHook();
|
|
244
|
+
|
|
245
|
+
let outcome: any;
|
|
246
|
+
await act(async () => {
|
|
247
|
+
outcome = await result.current.finalizePaymentAfterVerification({
|
|
248
|
+
result: { responseCode: '0000', token: 'verified-token' }
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(outcome.success).toBe(false);
|
|
253
|
+
expect(outcome.message).toEqual({ non_field_errors: 'ORDER_NO_MISMATCH' });
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -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
|
|
@@ -190,6 +191,7 @@ export const useMasterpassAccount = () => {
|
|
|
190
191
|
dispatch(
|
|
191
192
|
updateModalState({
|
|
192
193
|
otpType: result.otpType,
|
|
194
|
+
otpContext: 'account',
|
|
193
195
|
verificationData: result.data,
|
|
194
196
|
showLinkModal: false,
|
|
195
197
|
showOTPModal: true
|
|
@@ -209,9 +211,22 @@ export const useMasterpassAccount = () => {
|
|
|
209
211
|
const handleOTPSubmit = useCallback(
|
|
210
212
|
async (otp: string, texts?: any) => {
|
|
211
213
|
const result = await handleVerification(otp, token, tokenData?.MerchantId);
|
|
214
|
+
const isPaymentContext = modalState.otpContext === 'payment';
|
|
212
215
|
|
|
213
216
|
if (result.success) {
|
|
214
217
|
dispatch(setShowOTPModal(false));
|
|
218
|
+
|
|
219
|
+
// A payment-context OTP still has an open Masterpass transaction. The
|
|
220
|
+
// caller has to complete it against the backend; refreshing the account
|
|
221
|
+
// here would leave the order unfinalized.
|
|
222
|
+
if (isPaymentContext) {
|
|
223
|
+
return {
|
|
224
|
+
success: true,
|
|
225
|
+
isPaymentContext: true,
|
|
226
|
+
verificationData: result.data
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
await refreshAccountData();
|
|
216
231
|
return { success: true };
|
|
217
232
|
} else if (result.sessionExpired) {
|
|
@@ -234,6 +249,13 @@ export const useMasterpassAccount = () => {
|
|
|
234
249
|
};
|
|
235
250
|
} else if (result.requires3D && result.redirectUrl) {
|
|
236
251
|
dispatch(setShowOTPModal(false));
|
|
252
|
+
|
|
253
|
+
// Leaving the tab for the bank restarts the wait, so refresh the record
|
|
254
|
+
// without losing the order number it was opened with.
|
|
255
|
+
if (isPaymentContext) {
|
|
256
|
+
setPaymentPending(getPaymentPending()?.orderNo ?? null);
|
|
257
|
+
}
|
|
258
|
+
|
|
237
259
|
window.location.href = result.redirectUrl;
|
|
238
260
|
return {
|
|
239
261
|
success: false,
|
|
@@ -261,7 +283,13 @@ export const useMasterpassAccount = () => {
|
|
|
261
283
|
};
|
|
262
284
|
}
|
|
263
285
|
},
|
|
264
|
-
[
|
|
286
|
+
[
|
|
287
|
+
dispatch,
|
|
288
|
+
refreshAccountData,
|
|
289
|
+
token,
|
|
290
|
+
tokenData?.MerchantId,
|
|
291
|
+
modalState.otpContext
|
|
292
|
+
]
|
|
265
293
|
);
|
|
266
294
|
|
|
267
295
|
const handleRemoveCard = useCallback(
|
|
@@ -372,6 +400,7 @@ export const useMasterpassAccount = () => {
|
|
|
372
400
|
dispatch(
|
|
373
401
|
updateModalState({
|
|
374
402
|
otpType: result.otpType,
|
|
403
|
+
otpContext: 'account',
|
|
375
404
|
verificationData: result.data,
|
|
376
405
|
showLinkModal: false,
|
|
377
406
|
showOTPModal: true
|
|
@@ -29,7 +29,9 @@ import { decodeMasterpassToken } from '../utils/wait-for-utils-ready';
|
|
|
29
29
|
import {
|
|
30
30
|
createPaymentRequest,
|
|
31
31
|
createDirectPaymentRequest,
|
|
32
|
-
getTransactionType
|
|
32
|
+
getTransactionType,
|
|
33
|
+
setPaymentPending,
|
|
34
|
+
clearPaymentPending
|
|
33
35
|
} from '../utils/payment-utils';
|
|
34
36
|
import { handleMasterpassResponse } from '../utils/response-handler';
|
|
35
37
|
import type { Installment, RewardItem } from '../types/payment.types';
|
|
@@ -252,13 +254,20 @@ export const useMasterpassPayment = (
|
|
|
252
254
|
|
|
253
255
|
dispatch(setOrderData(prepareResult));
|
|
254
256
|
|
|
257
|
+
const orderNo = prepareResult.context_list?.[0]?.page_context?.order_no;
|
|
258
|
+
|
|
259
|
+
// From here on the order number is bound to a Masterpass transaction.
|
|
260
|
+
// Regenerating it before that transaction settles makes the backend
|
|
261
|
+
// reject the completion with ORDER_NO_MISMATCH.
|
|
262
|
+
setPaymentPending(orderNo);
|
|
263
|
+
|
|
255
264
|
const paymentService = new PaymentService(activeToken, activeMerchantId);
|
|
256
265
|
const paymentRequest = createPaymentRequest({
|
|
257
266
|
cvc,
|
|
258
267
|
cardAlias: selectedCard.cardAlias,
|
|
259
268
|
accountKey: activeTokenData.AccountKey,
|
|
260
269
|
amount: prepareResult.pre_order?.total_amount_with_interest,
|
|
261
|
-
orderNo
|
|
270
|
+
orderNo,
|
|
262
271
|
currencyCode: currency,
|
|
263
272
|
installmentCount: selectedInstallment.installment_count,
|
|
264
273
|
authenticationMethod: activeTokenData.AuthenticationMethod,
|
|
@@ -288,6 +297,7 @@ export const useMasterpassPayment = (
|
|
|
288
297
|
}).unwrap();
|
|
289
298
|
|
|
290
299
|
dispatch(setOrderData(finalizeResult));
|
|
300
|
+
clearPaymentPending();
|
|
291
301
|
|
|
292
302
|
if (finalizeResult.errors) {
|
|
293
303
|
return { success: false, message: finalizeResult.errors };
|
|
@@ -297,6 +307,10 @@ export const useMasterpassPayment = (
|
|
|
297
307
|
return { success: true };
|
|
298
308
|
}
|
|
299
309
|
|
|
310
|
+
if (!response.requiresOTP) {
|
|
311
|
+
clearPaymentPending();
|
|
312
|
+
}
|
|
313
|
+
|
|
300
314
|
return {
|
|
301
315
|
success: response.success,
|
|
302
316
|
requiresOTP: response.requiresOTP,
|
|
@@ -304,6 +318,7 @@ export const useMasterpassPayment = (
|
|
|
304
318
|
message: response.message
|
|
305
319
|
};
|
|
306
320
|
} catch (error) {
|
|
321
|
+
clearPaymentPending();
|
|
307
322
|
return {
|
|
308
323
|
success: false,
|
|
309
324
|
message:
|
|
@@ -361,6 +376,13 @@ export const useMasterpassPayment = (
|
|
|
361
376
|
|
|
362
377
|
dispatch(setOrderData(prepareResult));
|
|
363
378
|
|
|
379
|
+
const orderNo = prepareResult.context_list?.[0]?.page_context?.order_no;
|
|
380
|
+
|
|
381
|
+
// From here on the order number is bound to a Masterpass transaction.
|
|
382
|
+
// Regenerating it before that transaction settles makes the backend
|
|
383
|
+
// reject the completion with ORDER_NO_MISMATCH.
|
|
384
|
+
setPaymentPending(orderNo);
|
|
385
|
+
|
|
364
386
|
const paymentService = new PaymentService(activeToken, activeMerchantId);
|
|
365
387
|
const directPaymentRequest = createDirectPaymentRequest({
|
|
366
388
|
cvc: newCardFormData.cvv,
|
|
@@ -370,7 +392,7 @@ export const useMasterpassPayment = (
|
|
|
370
392
|
expiryDate: newCardFormData.expiryDate,
|
|
371
393
|
accountKey: activeTokenData.AccountKey,
|
|
372
394
|
amount: prepareResult.pre_order?.total_amount_with_interest,
|
|
373
|
-
orderNo
|
|
395
|
+
orderNo,
|
|
374
396
|
currencyCode: currency,
|
|
375
397
|
installmentCount: selectedInstallment.installment_count,
|
|
376
398
|
authenticationMethod: activeTokenData.AuthenticationMethod,
|
|
@@ -402,6 +424,7 @@ export const useMasterpassPayment = (
|
|
|
402
424
|
}).unwrap();
|
|
403
425
|
|
|
404
426
|
dispatch(setOrderData(finalizeResult));
|
|
427
|
+
clearPaymentPending();
|
|
405
428
|
|
|
406
429
|
if (finalizeResult.errors) {
|
|
407
430
|
return { success: false, message: finalizeResult.errors };
|
|
@@ -411,6 +434,10 @@ export const useMasterpassPayment = (
|
|
|
411
434
|
return { success: true };
|
|
412
435
|
}
|
|
413
436
|
|
|
437
|
+
if (!response.requiresOTP) {
|
|
438
|
+
clearPaymentPending();
|
|
439
|
+
}
|
|
440
|
+
|
|
414
441
|
return {
|
|
415
442
|
success: response.success,
|
|
416
443
|
requiresOTP: response.requiresOTP,
|
|
@@ -418,6 +445,7 @@ export const useMasterpassPayment = (
|
|
|
418
445
|
message: response.message
|
|
419
446
|
};
|
|
420
447
|
} catch (error) {
|
|
448
|
+
clearPaymentPending();
|
|
421
449
|
return {
|
|
422
450
|
success: false,
|
|
423
451
|
message:
|
|
@@ -439,6 +467,41 @@ export const useMasterpassPayment = (
|
|
|
439
467
|
dispatch
|
|
440
468
|
]);
|
|
441
469
|
|
|
470
|
+
const finalizePaymentAfterVerification = useCallback(
|
|
471
|
+
async (verificationResponse: any): Promise<PaymentResult> => {
|
|
472
|
+
const { useThreeD } = paymentState;
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
const finalizeResult = await finalizeMasterpassOrder({
|
|
476
|
+
responseCode: verificationResponse?.result?.responseCode,
|
|
477
|
+
token: verificationResponse?.result?.token,
|
|
478
|
+
three_d_secure: useThreeD,
|
|
479
|
+
transactionType: getTransactionType(useThreeD)
|
|
480
|
+
}).unwrap();
|
|
481
|
+
|
|
482
|
+
dispatch(setOrderData(finalizeResult));
|
|
483
|
+
clearPaymentPending();
|
|
484
|
+
|
|
485
|
+
if (finalizeResult.errors) {
|
|
486
|
+
return { success: false, message: finalizeResult.errors };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
dispatch(setOrderCompleted(true));
|
|
490
|
+
return { success: true };
|
|
491
|
+
} catch (error) {
|
|
492
|
+
clearPaymentPending();
|
|
493
|
+
return {
|
|
494
|
+
success: false,
|
|
495
|
+
message:
|
|
496
|
+
error instanceof Error
|
|
497
|
+
? error.message
|
|
498
|
+
: 'An unexpected error occurred'
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
[paymentState, finalizeMasterpassOrder, dispatch]
|
|
503
|
+
);
|
|
504
|
+
|
|
442
505
|
return {
|
|
443
506
|
paymentState,
|
|
444
507
|
|
|
@@ -455,6 +518,7 @@ export const useMasterpassPayment = (
|
|
|
455
518
|
handleInstallmentSelect,
|
|
456
519
|
processPayment,
|
|
457
520
|
processDirectPayment,
|
|
521
|
+
finalizePaymentAfterVerification,
|
|
458
522
|
fetchRewardsForCard,
|
|
459
523
|
openRewardModal,
|
|
460
524
|
closeRewardModal,
|
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
|