IMP-189: Add recipientName to p2p BankCard (#283)

This commit is contained in:
Ildar Galeev 2024-03-12 19:07:29 +07:00 committed by GitHub
parent 80a3c8a611
commit 67746f9556
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 145 additions and 100 deletions

View File

@ -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);
};

View File

@ -7,6 +7,7 @@ export type DestinationBankCard = {
destinationType: 'BankCard';
pan: string;
bankName?: string;
recipientName?: string;
};
export type DestinationSBP = {

View File

@ -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';

View 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);
});
});

View 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;
};

View File

@ -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>
);

View File

@ -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 };
};

View File

@ -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 };
};

View File

@ -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 };
};

View File

@ -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ı:",

View File

@ -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": "নাম:",

View File

@ -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:",

View File

@ -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": "名前:",

View File

@ -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": "이름:",

View File

@ -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:",

View File

@ -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": "Получатель:",

View File

@ -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:",