Refactor/improve QR payments status check (#2506)

* refactor: improve the QR payments status check call, wait for the previous call to finish

* added changeset

* refactor: added the timeout for the default http calls and the status check call

* refactor: add unit test
This commit is contained in:
Yu Long
2024-01-09 10:06:45 +01:00
committed by GitHub
parent cafff78e0a
commit d358019078
7 changed files with 87 additions and 38 deletions

View File

@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': patch
---
Improve the payment status check call for QR payments.

View File

@@ -76,14 +76,14 @@ function Await(props: AwaitComponentProps) {
};
const checkStatus = (): void => {
const { paymentData, clientKey } = props;
const { paymentData, clientKey, throttleInterval } = props;
if (!hasCalledActionHandled) {
props.onActionHandled({ componentType: props.type, actionDescription: 'polling-started' });
setHasCalledActionHandled(true);
}
checkPaymentStatus(paymentData, clientKey, loadingContext)
checkPaymentStatus(paymentData, clientKey, loadingContext, throttleInterval)
.then(processResponse)
.catch(({ message, ...response }) => ({
type: 'network-error',

View File

@@ -5,6 +5,7 @@ import checkPaymentStatus from '../../../core/Services/payment-status';
import Language from '../../../language/Language';
jest.mock('../../../core/Services/payment-status');
jest.useFakeTimers();
const i18n = { get: key => key } as Language;
@@ -15,6 +16,11 @@ describe('WeChat', () => {
});
});
afterEach(() => {
jest.clearAllTimers();
jest.restoreAllMocks();
});
describe('checkStatus', () => {
// Pending status
test('checkStatus processes a pending response', () => {
@@ -84,5 +90,32 @@ describe('WeChat', () => {
expect(onErrorMock.mock.calls.length).toBe(1);
});
});
describe('statusInterval', () => {
let qrLoader;
beforeEach(() => {
const checkPaymentStatusValue = { payload: 'Ab02b4c0!', resultCode: 'pending', type: 'complete' };
(checkPaymentStatus as jest.Mock).mockResolvedValue(checkPaymentStatusValue);
});
test('should set a timeout recursively', async () => {
jest.spyOn(global, 'setTimeout');
qrLoader = new QRLoader({ delay: 1000 });
qrLoader.statusInterval();
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);
await jest.runOnlyPendingTimersAsync();
expect(setTimeout).toHaveBeenCalledTimes(2);
});
test('should change the delay to the throttledInterval if the timePassed exceeds the throttleTime', async () => {
jest.spyOn(global, 'setTimeout');
qrLoader = new QRLoader({ throttleTime: 0, throttledInterval: 2000, delay: 1000 });
qrLoader.statusInterval();
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 2000);
});
});
});
});

View File

@@ -19,7 +19,7 @@ import useAutoFocus from '../../../utils/useAutoFocus';
const QRCODE_URL = 'barcode.shtml?barcodeType=qrCode&fileType=png&data=';
class QRLoader extends Component<QRLoaderProps, QRLoaderState> {
private interval;
private timeoutId;
constructor(props) {
super(props);
@@ -46,35 +46,39 @@ class QRLoader extends Component<QRLoaderProps, QRLoaderState> {
introduction: 'wechatpay.scanqrcode'
};
// Retry until getting a complete response from the server or it times out\
// Changes interval time to 10 seconds after 1 minute (60 seconds)
public statusInterval = () => {
this.checkStatus();
this.setState({ timePassed: this.state.timePassed + this.props.delay });
if (this.state.timePassed >= this.props.throttleTime) {
this.setState({ delay: this.props.throttledInterval });
}
};
componentDidMount() {
this.interval = setInterval(this.statusInterval, this.state.delay);
}
public redirectToApp = url => {
window.location.assign(url);
};
componentDidUpdate(prevProps, prevState) {
if (prevState.delay !== this.state.delay) {
clearInterval(this.interval);
this.interval = setInterval(this.statusInterval, this.state.delay);
}
this.statusInterval();
}
componentWillUnmount() {
clearInterval(this.interval);
clearTimeout(this.timeoutId);
}
public redirectToApp = (url: string | URL) => {
window.location.assign(url);
};
// Retry until getting a complete response from the server, or it times out
public statusInterval = (responseTime = 0) => {
// If we are already in the final statuses, do not poll!
if (this.state.expired || this.state.completed) return;
this.setState(previous => ({ timePassed: previous.timePassed + this.props.delay + responseTime }));
// Changes interval time to 10 seconds after 1 minute (60 seconds)
const newDelay = this.state.timePassed >= this.props.throttleTime ? this.props.throttledInterval : this.state.delay;
this.pollStatus(newDelay);
};
private pollStatus(delay: number) {
clearTimeout(this.timeoutId);
this.timeoutId = setTimeout(async () => {
// Wait for previous status call to finish.
// Also taking the server response time into the consideration to calculate timePassed.
const start = performance.now();
await this.checkStatus();
const end = performance.now();
this.statusInterval(Math.round(end - start));
}, delay);
}
private onTick = (time): void => {
@@ -83,12 +87,12 @@ class QRLoader extends Component<QRLoaderProps, QRLoaderState> {
private onTimeUp = (): void => {
this.setState({ expired: true });
clearInterval(this.interval);
clearTimeout(this.timeoutId);
this.props.onError(new AdyenCheckoutError('ERROR', 'Payment Expired'));
};
private onComplete = (status: StatusObject): void => {
clearInterval(this.interval);
clearTimeout(this.timeoutId);
this.setState({ completed: true, loading: false });
const state = {
@@ -102,7 +106,7 @@ class QRLoader extends Component<QRLoaderProps, QRLoaderState> {
};
private onError = (status: StatusObject): void => {
clearInterval(this.interval);
clearTimeout(this.timeoutId);
this.setState({ expired: true, loading: false });
if (status.props.payload) {
@@ -120,9 +124,9 @@ class QRLoader extends Component<QRLoaderProps, QRLoaderState> {
};
private checkStatus = () => {
const { paymentData, clientKey, loadingContext } = this.props;
const { paymentData, clientKey, loadingContext, throttledInterval } = this.props;
return checkPaymentStatus(paymentData, clientKey, loadingContext)
return checkPaymentStatus(paymentData, clientKey, loadingContext, throttledInterval)
.then(processResponse)
.catch(response => ({ type: 'network-error', props: response }))
.then((status: StatusObject) => {

View File

@@ -1,5 +1,5 @@
import fetch from './fetch';
import { FALLBACK_CONTEXT } from '../config';
import { DEFAULT_HTTP_TIMEOUT, FALLBACK_CONTEXT } from '../config';
import AdyenCheckoutError from '../Errors/AdyenCheckoutError';
interface HttpOptions {
@@ -11,6 +11,7 @@ interface HttpOptions {
method?: string;
path: string;
errorLevel?: ErrorLevel;
timeout?: number;
}
type ErrorLevel = 'silent' | 'info' | 'warn' | 'error' | 'fatal';
@@ -27,7 +28,7 @@ function isAdyenErrorResponse(data: any): data is AdyenErrorResponse {
}
export function http<T>(options: HttpOptions, data?: any): Promise<T> {
const { headers = [], errorLevel = 'warn', loadingContext = FALLBACK_CONTEXT, method = 'GET', path } = options;
const { headers = [], errorLevel = 'warn', loadingContext = FALLBACK_CONTEXT, method = 'GET', path, timeout = DEFAULT_HTTP_TIMEOUT } = options;
const request: RequestInit = {
method,
@@ -41,6 +42,7 @@ export function http<T>(options: HttpOptions, data?: any): Promise<T> {
},
redirect: 'follow',
referrerPolicy: 'no-referrer-when-downgrade',
...(AbortSignal?.timeout && { signal: AbortSignal?.timeout(timeout) }),
...(data && { body: JSON.stringify(data) })
};

View File

@@ -5,16 +5,18 @@ import { httpPost } from './http';
* @param paymentData -
* @param clientKey -
* @param loadingContext -
* @param timeout - in milliseconds
* @returns a promise containing the response of the call
*/
export default function checkPaymentStatus(paymentData, clientKey, loadingContext) {
export default function checkPaymentStatus(paymentData, clientKey, loadingContext, timeout) {
if (!paymentData || !clientKey) {
throw new Error('Could not check the payment status');
}
const options = {
loadingContext,
path: `services/PaymentInitiation/v1/status?clientKey=${clientKey}`
path: `services/PaymentInitiation/v1/status?clientKey=${clientKey}`,
timeout
};
return httpPost(options, { paymentData });

View File

@@ -34,7 +34,10 @@ export const GENERIC_OPTIONS = [
'setStatusAutomatically'
];
export const DEFAULT_HTTP_TIMEOUT = 60000;
export default {
FALLBACK_CONTEXT,
GENERIC_OPTIONS
GENERIC_OPTIONS,
DEFAULT_HTTP_TIMEOUT
};