Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/improve QR payments status check #2506

Merged
merged 4 commits into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gorgeous-cameras-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': patch
---

Improve the payment status check call for QR payments.
4 changes: 2 additions & 2 deletions packages/lib/src/components/internal/Await/Await.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 33 additions & 0 deletions packages/lib/src/components/internal/QRLoader/QRLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,6 +16,11 @@ describe('WeChat', () => {
});
});

afterEach(() => {
jest.clearAllTimers();
jest.restoreAllMocks();
});

describe('checkStatus', () => {
// Pending status
test('checkStatus processes a pending response', () => {
Expand Down Expand Up @@ -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);
});
});
});
});
60 changes: 32 additions & 28 deletions packages/lib/src/components/internal/QRLoader/QRLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
this.statusInterval();
}

public redirectToApp = url => {
componentWillUnmount() {
clearTimeout(this.timeoutId);
}

public redirectToApp = (url: string | 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);
}
}
// 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;

componentWillUnmount() {
clearInterval(this.interval);
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 => {
Expand All @@ -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 = {
Expand All @@ -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) {
Expand All @@ -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) => {
Expand Down
6 changes: 4 additions & 2 deletions packages/lib/src/core/Services/http.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +11,7 @@ interface HttpOptions {
method?: string;
path: string;
errorLevel?: ErrorLevel;
timeout?: number;
}

type ErrorLevel = 'silent' | 'info' | 'warn' | 'error' | 'fatal';
Expand All @@ -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,
Expand All @@ -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) })
};

Expand Down
6 changes: 4 additions & 2 deletions packages/lib/src/core/Services/payment-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
5 changes: 4 additions & 1 deletion packages/lib/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Loading