mirror of
https://github.com/valitydev/checkout.git
synced 2024-11-06 10:35:20 +00:00
IMP-189: Add recipientName to p2p BankCard (#283)
This commit is contained in:
parent
80a3c8a611
commit
67746f9556
@ -1,4 +1,4 @@
|
||||
import { extractError, fetchApi } from '../../../common/utils';
|
||||
import { fetchApi } from '../../../common/utils';
|
||||
|
||||
export type CompleteInfo = {
|
||||
invoiceId: string;
|
||||
@ -7,10 +7,5 @@ export type CompleteInfo = {
|
||||
};
|
||||
|
||||
export const complete = async (capiEndpoint: string, accessToken: string, info: CompleteInfo): Promise<void> => {
|
||||
try {
|
||||
await fetchApi(capiEndpoint, accessToken, 'POST', 'p2p/payments/complete', info);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch complete. ${extractError(error)}`);
|
||||
throw new Error(`Failed to fetch complete. ${extractError(error)}`);
|
||||
}
|
||||
await fetchApi(capiEndpoint, accessToken, 'POST', 'p2p/payments/complete', info);
|
||||
};
|
||||
|
@ -7,6 +7,7 @@ export type DestinationBankCard = {
|
||||
destinationType: 'BankCard';
|
||||
pan: string;
|
||||
bankName?: string;
|
||||
recipientName?: string;
|
||||
};
|
||||
|
||||
export type DestinationSBP = {
|
||||
|
@ -25,6 +25,7 @@ export { isBoolean } from './isBoolean';
|
||||
export { getUrlParams } from './getUrlParams';
|
||||
export { countries } from './countries';
|
||||
export { isContainCardNumber } from './isContainCardNumber';
|
||||
export { withRetry } from './withRetry';
|
||||
|
||||
export type { URLParams } from './getUrlParams';
|
||||
export type { CountrySubdivision, Country } from './countries';
|
||||
|
42
src/common/utils/withRetry.test.ts
Normal file
42
src/common/utils/withRetry.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { withRetry } from './withRetry';
|
||||
|
||||
// Mock async function that simulates varying behavior
|
||||
const createMockAsyncFunction = (shouldSucceedAfterAttempts: number, result: string, error: Error): jest.Mock => {
|
||||
let attempts = 0;
|
||||
return jest.fn(() => {
|
||||
attempts++;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (attempts >= shouldSucceedAfterAttempts) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('withRetry', () => {
|
||||
test('should succeed on first attempt', async () => {
|
||||
const mockFn = createMockAsyncFunction(1, 'success', new Error('fail'));
|
||||
const retriedFn = withRetry(mockFn, 3, 10);
|
||||
|
||||
await expect(retriedFn()).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should fail after all retries', async () => {
|
||||
const mockFn = createMockAsyncFunction(5, 'success', new Error('fail')); // Succeeds after 5 attempts, but we only retry 3 times
|
||||
const retriedFn = withRetry(mockFn, 3, 10);
|
||||
|
||||
await expect(retriedFn()).rejects.toThrow('fail');
|
||||
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('should succeed after 2 retries', async () => {
|
||||
const mockFn = createMockAsyncFunction(3, 'success', new Error('fail')); // Succeeds on the third attempt
|
||||
const retriedFn = withRetry(mockFn, 3, 10);
|
||||
|
||||
await expect(retriedFn()).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
31
src/common/utils/withRetry.ts
Normal file
31
src/common/utils/withRetry.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { delay } from './delay';
|
||||
|
||||
/**
|
||||
* Creates a retry wrapper for any async function, allowing retries on failure.
|
||||
*
|
||||
* @param {Function} asyncFn - The asynchronous function to wrap with retry logic.
|
||||
* @param {number} maxRetries - The maximum number of retries.
|
||||
* @param {number} retryDelay - The initial delay between retries in milliseconds.
|
||||
* @returns {Function} - A new function that wraps the original async function with retry logic.
|
||||
*/
|
||||
export const withRetry =
|
||||
<T>(asyncFn: (...args: any[]) => Promise<T>, maxRetries = 3, retryDelay = 2000) =>
|
||||
async (...args: any[]): Promise<T> => {
|
||||
let lastError: any;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await asyncFn(...args);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// If this is the last attempt, throw the error immediately
|
||||
if (attempt === maxRetries - 1) {
|
||||
throw lastError;
|
||||
}
|
||||
await delay(retryDelay);
|
||||
// Increase retryDelay for exponential backoff
|
||||
retryDelay *= 2;
|
||||
}
|
||||
}
|
||||
// Technically unreachable, but needed to satisfy TypeScript about returning a promise or throwing an error
|
||||
throw lastError;
|
||||
};
|
@ -24,5 +24,11 @@ export const DestinationInfoBankCard = ({ locale, destination }: DestinationInfo
|
||||
<Value>{destination.bankName}</Value>
|
||||
</Row>
|
||||
)}
|
||||
{destination?.recipientName && (
|
||||
<Row>
|
||||
<Label>{locale['form.p2p.destination.bank.recipient']}</Label>
|
||||
<Value>{destination.recipientName}</Value>
|
||||
</Row>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
@ -1,31 +1,27 @@
|
||||
import { useCallback, useReducer } from 'react';
|
||||
|
||||
import { complete as completeApi } from '../../../common/backend/p2p';
|
||||
import { extractError, withRetry } from '../../../common/utils';
|
||||
|
||||
type State =
|
||||
| { status: 'PRISTINE' }
|
||||
| { status: 'LOADING' }
|
||||
| { status: 'SUCCESS' }
|
||||
| { status: 'FAILURE'; error: unknown };
|
||||
type State = { status: 'PRISTINE' | 'LOADING' | 'SUCCESS' | 'FAILURE' };
|
||||
|
||||
type Action = { type: 'COMPLETE_INIT' } | { type: 'COMPLETE_SUCCESS' } | { type: 'COMPLETE_FAILURE'; error: unknown };
|
||||
type Action = { type: 'FETCH_START' | 'FETCH_SUCCESS' | 'FETCH_FAILURE' };
|
||||
|
||||
const dataFetchReducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'COMPLETE_INIT':
|
||||
case 'FETCH_START':
|
||||
return {
|
||||
status: 'LOADING',
|
||||
};
|
||||
case 'COMPLETE_SUCCESS':
|
||||
case 'FETCH_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
status: 'SUCCESS',
|
||||
};
|
||||
case 'COMPLETE_FAILURE':
|
||||
case 'FETCH_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
status: 'FAILURE',
|
||||
error: action.error,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -35,21 +31,19 @@ export const useComplete = (capiEndpoint: string, accessToken: string, invoiceID
|
||||
status: 'PRISTINE',
|
||||
});
|
||||
|
||||
const complete = useCallback(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
dispatch({ type: 'COMPLETE_INIT' });
|
||||
await completeApi(capiEndpoint, accessToken, { invoiceId: invoiceID, paymentId: paymentID });
|
||||
dispatch({
|
||||
type: 'COMPLETE_SUCCESS',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('complete error', error);
|
||||
dispatch({ type: 'COMPLETE_FAILURE', error });
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
const complete = useCallback(async () => {
|
||||
try {
|
||||
dispatch({ type: 'FETCH_START' });
|
||||
const completeWithRetry = withRetry(completeApi);
|
||||
await completeWithRetry(capiEndpoint, accessToken, { invoiceId: invoiceID, paymentId: paymentID });
|
||||
dispatch({
|
||||
type: 'FETCH_SUCCESS',
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({ type: 'FETCH_FAILURE' });
|
||||
console.error(`Failed to p2p complete. ${extractError(error)}`);
|
||||
}
|
||||
}, [capiEndpoint, accessToken, invoiceID, paymentID]);
|
||||
|
||||
return { state, complete };
|
||||
};
|
||||
|
@ -1,36 +1,25 @@
|
||||
import { useCallback, useEffect, useReducer } from 'react';
|
||||
import { useCallback, useReducer, useRef } from 'react';
|
||||
|
||||
import { Destination, getDestinations as getApiDestinations } from '../../../common/backend/p2p';
|
||||
import { extractError } from '../../../common/utils';
|
||||
import { extractError, withRetry } from '../../../common/utils';
|
||||
|
||||
type State =
|
||||
| { status: 'PRISTINE' | 'LOADING' | 'FAILURE'; retryCount: number }
|
||||
| { status: 'SUCCESS'; data: Destination[]; retryCount: number };
|
||||
type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Destination[] };
|
||||
|
||||
type Action =
|
||||
| { type: 'FETCH_START' }
|
||||
| { type: 'FETCH_SUCCESS'; payload: Destination[] }
|
||||
| { type: 'FETCH_FAILURE' }
|
||||
| { type: 'RETRY' };
|
||||
type Action = { type: 'FETCH_START' } | { type: 'FETCH_SUCCESS'; payload: Destination[] } | { type: 'FETCH_FAILURE' };
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'FETCH_START':
|
||||
return { ...state, status: 'LOADING' };
|
||||
case 'FETCH_SUCCESS':
|
||||
return { status: 'SUCCESS', data: action.payload, retryCount: 0 };
|
||||
return { status: 'SUCCESS', data: action.payload };
|
||||
case 'FETCH_FAILURE':
|
||||
return { ...state, status: 'FAILURE', retryCount: state.retryCount };
|
||||
case 'RETRY':
|
||||
return { ...state, retryCount: state.retryCount + 1 };
|
||||
return { ...state, status: 'FAILURE' };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
const RETRY_TIMEOUT_MS = 3000;
|
||||
|
||||
export const useDestinations = (
|
||||
capiEndpoint: string,
|
||||
accessToken: string,
|
||||
@ -40,31 +29,32 @@ export const useDestinations = (
|
||||
) => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
status: 'PRISTINE',
|
||||
retryCount: 0,
|
||||
});
|
||||
// In React dev mode, calling getDestinations twice in quick succession can lead to a 500 error.
|
||||
const lastGatewayIDRef = useRef<string | null>(null);
|
||||
|
||||
const getDestinations = useCallback(async () => {
|
||||
if (lastGatewayIDRef.current === gatewayID) {
|
||||
return;
|
||||
}
|
||||
lastGatewayIDRef.current = gatewayID;
|
||||
|
||||
dispatch({ type: 'FETCH_START' });
|
||||
try {
|
||||
const destinations = await getApiDestinations(capiEndpoint, accessToken, invoiceID, paymentID, gatewayID);
|
||||
const getDestinationsWithRetry = withRetry(getApiDestinations);
|
||||
const destinations = await getDestinationsWithRetry(
|
||||
capiEndpoint,
|
||||
accessToken,
|
||||
invoiceID,
|
||||
paymentID,
|
||||
gatewayID,
|
||||
);
|
||||
dispatch({ type: 'FETCH_SUCCESS', payload: destinations });
|
||||
} catch (error) {
|
||||
if (state.retryCount < MAX_RETRY_ATTEMPTS) {
|
||||
dispatch({ type: 'RETRY' });
|
||||
}
|
||||
if (state.retryCount === MAX_RETRY_ATTEMPTS) {
|
||||
dispatch({ type: 'FETCH_FAILURE' });
|
||||
console.error(`Failed to fetch destinations. ${extractError(error)}`);
|
||||
}
|
||||
dispatch({ type: 'FETCH_FAILURE' });
|
||||
console.error(`Failed to fetch destinations. ${extractError(error)}`);
|
||||
}
|
||||
}, [capiEndpoint, accessToken, invoiceID, paymentID, gatewayID, state.retryCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'LOADING' && state.retryCount > 0 && state.retryCount < MAX_RETRY_ATTEMPTS) {
|
||||
const timer = setTimeout(getDestinations, RETRY_TIMEOUT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.status, state.retryCount, getDestinations]);
|
||||
}, [capiEndpoint, accessToken, invoiceID, paymentID, gatewayID]);
|
||||
|
||||
return { state, getDestinations };
|
||||
};
|
||||
|
@ -1,64 +1,41 @@
|
||||
import { useCallback, useEffect, useReducer } from 'react';
|
||||
import { useCallback, useReducer } from 'react';
|
||||
|
||||
import { Gateway, getGateways as getApiGateways } from '../../../common/backend/p2p';
|
||||
import { extractError } from '../../../common/utils';
|
||||
import { extractError, withRetry } from '../../../common/utils';
|
||||
|
||||
type State =
|
||||
| { status: 'PRISTINE' | 'LOADING' | 'FAILURE'; retryCount: number }
|
||||
| { status: 'SUCCESS'; data: Gateway[]; retryCount: number };
|
||||
type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Gateway[] };
|
||||
|
||||
type Action =
|
||||
| { type: 'FETCH_START' }
|
||||
| { type: 'FETCH_SUCCESS'; payload: Gateway[] }
|
||||
| { type: 'FETCH_FAILURE' }
|
||||
| { type: 'RETRY' };
|
||||
type Action = { type: 'FETCH_START' } | { type: 'FETCH_SUCCESS'; payload: Gateway[] } | { type: 'FETCH_FAILURE' };
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'FETCH_START':
|
||||
return { ...state, status: 'LOADING' };
|
||||
case 'FETCH_SUCCESS':
|
||||
return { status: 'SUCCESS', data: action.payload, retryCount: 0 };
|
||||
return { status: 'SUCCESS', data: action.payload };
|
||||
case 'FETCH_FAILURE':
|
||||
return { status: 'FAILURE', retryCount: state.retryCount };
|
||||
case 'RETRY':
|
||||
return { ...state, retryCount: state.retryCount + 1 };
|
||||
return { status: 'FAILURE' };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
const RETRY_TIMEOUT_MS = 3000;
|
||||
|
||||
export const useGateways = (capiEndpoint: string, accessToken: string, invoiceID: string, paymentID: string) => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
status: 'PRISTINE',
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
const getGateways = useCallback(async () => {
|
||||
dispatch({ type: 'FETCH_START' });
|
||||
try {
|
||||
const gateways = await getApiGateways(capiEndpoint, accessToken, invoiceID, paymentID);
|
||||
const getGatewaysWithRetry = withRetry(getApiGateways);
|
||||
const gateways = await getGatewaysWithRetry(capiEndpoint, accessToken, invoiceID, paymentID);
|
||||
dispatch({ type: 'FETCH_SUCCESS', payload: gateways });
|
||||
} catch (error) {
|
||||
if (state.retryCount < MAX_RETRY_ATTEMPTS) {
|
||||
dispatch({ type: 'RETRY' });
|
||||
}
|
||||
if (state.retryCount === MAX_RETRY_ATTEMPTS) {
|
||||
dispatch({ type: 'FETCH_FAILURE' });
|
||||
console.error(`Failed to fetch gateways. ${extractError(error)}`);
|
||||
}
|
||||
dispatch({ type: 'FETCH_FAILURE' });
|
||||
console.error(`Failed to fetch gateways. ${extractError(error)}`);
|
||||
}
|
||||
}, [capiEndpoint, accessToken, invoiceID, paymentID, state.retryCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'LOADING' && state.retryCount > 0 && state.retryCount < MAX_RETRY_ATTEMPTS) {
|
||||
const timer = setTimeout(getGateways, RETRY_TIMEOUT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.status, state.retryCount, getGateways]);
|
||||
}, [capiEndpoint, accessToken, invoiceID, paymentID]);
|
||||
|
||||
return { state, getGateways };
|
||||
};
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "Sifariş ödənişi:",
|
||||
"form.p2p.destination.bank.card.pan": "Bu karta:",
|
||||
"form.p2p.destination.bank.name": "Bankın adı:",
|
||||
"form.p2p.destination.bank.recipient": "Adı:",
|
||||
"form.p2p.destination.spb.bank.name": "Bankın adı:",
|
||||
"form.p2p.destination.spb.phone": "Telefon nömrəsi:",
|
||||
"form.p2p.destination.spb.recipient": "Adı:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "নিম্নলিখিত বিবরণ ব্যবহার করে ট্রান্সফার করুন:",
|
||||
"form.p2p.destination.bank.card.pan": "কার্ড নম্বর:",
|
||||
"form.p2p.destination.bank.name": "ব্যাংকের নাম:",
|
||||
"form.p2p.destination.bank.recipient": "নাম:",
|
||||
"form.p2p.destination.spb.bank.name": "ব্যাংকের নাম:",
|
||||
"form.p2p.destination.spb.phone": "ফোন নম্বর:",
|
||||
"form.p2p.destination.spb.recipient": "নাম:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "Make a transfer using the following details:",
|
||||
"form.p2p.destination.bank.card.pan": "Card number:",
|
||||
"form.p2p.destination.bank.name": "Bank name:",
|
||||
"form.p2p.destination.bank.recipient": "Name:",
|
||||
"form.p2p.destination.spb.bank.name": "Bank name:",
|
||||
"form.p2p.destination.spb.phone": "Phone number:",
|
||||
"form.p2p.destination.spb.recipient": "Name:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "以下の詳細を使用して送金してください:",
|
||||
"form.p2p.destination.bank.card.pan": "カード番号:",
|
||||
"form.p2p.destination.bank.name": "銀行名:",
|
||||
"form.p2p.destination.bank.recipient": "名前:",
|
||||
"form.p2p.destination.spb.bank.name": "銀行名:",
|
||||
"form.p2p.destination.spb.phone": "電話番号:",
|
||||
"form.p2p.destination.spb.recipient": "名前:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "다음 세부 정보를 사용하여 이체하세요:",
|
||||
"form.p2p.destination.bank.card.pan": "카드 번호:",
|
||||
"form.p2p.destination.bank.name": "은행 이름:",
|
||||
"form.p2p.destination.bank.recipient": "이름:",
|
||||
"form.p2p.destination.spb.bank.name": "은행 이름:",
|
||||
"form.p2p.destination.spb.phone": "전화번호:",
|
||||
"form.p2p.destination.spb.recipient": "이름:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "Realize a transferência usando os seguintes detalhes:",
|
||||
"form.p2p.destination.bank.card.pan": "Número do cartão:",
|
||||
"form.p2p.destination.bank.name": "Nome do banco:",
|
||||
"form.p2p.destination.bank.recipient": "Nome:",
|
||||
"form.p2p.destination.spb.bank.name": "Nome do banco:",
|
||||
"form.p2p.destination.spb.phone": "Número de telefone:",
|
||||
"form.p2p.destination.spb.recipient": "Nome:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "Осуществите перевод, используя следующие реквизиты:",
|
||||
"form.p2p.destination.bank.card.pan": "Номер карты:",
|
||||
"form.p2p.destination.bank.name": "Банк:",
|
||||
"form.p2p.destination.bank.recipient": "Получатель:",
|
||||
"form.p2p.destination.spb.bank.name": "Банк:",
|
||||
"form.p2p.destination.spb.phone": "Номер телефона:",
|
||||
"form.p2p.destination.spb.recipient": "Получатель:",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"form.p2p.destination.info": "Aşağıdaki detayları kullanarak transferi gerçekleştirin:",
|
||||
"form.p2p.destination.bank.card.pan": "Kart numarası:",
|
||||
"form.p2p.destination.bank.name": "Banka adı:",
|
||||
"form.p2p.destination.bank.recipient": "İsim:",
|
||||
"form.p2p.destination.spb.bank.name": "Banka adı:",
|
||||
"form.p2p.destination.spb.phone": "Telefon numarası:",
|
||||
"form.p2p.destination.spb.recipient": "İsim:",
|
||||
|
Loading…
Reference in New Issue
Block a user