Merge branch 'INDA-330-pii-update' into 'main'
[ProtonMail-WebClient.git] / packages / payments / core / ensureTokenChargeable.ts
blob1689cd5877d6e01ea72774e2388163d23f612901
1 import { getTokenStatusV4, getTokenStatusV5 } from '@proton/shared/lib/api/payments';
2 import { wait } from '@proton/shared/lib/helpers/promise';
3 import { getHostname } from '@proton/shared/lib/helpers/url';
4 import { type Api } from '@proton/shared/lib/interfaces';
6 import { PAYMENT_TOKEN_STATUS } from './constants';
7 import type {
8     ChargebeeFetchedPaymentToken,
9     ChargebeeIframeEvents,
10     PaymentTokenResult,
11     RemoveEventListener,
12 } from './interface';
14 const { STATUS_PENDING, STATUS_CHARGEABLE, STATUS_FAILED, STATUS_CONSUMED, STATUS_NOT_SUPPORTED } =
15     PAYMENT_TOKEN_STATUS;
17 const DELAY_PULLING = 5000;
18 const DELAY_LISTENING = 1000;
20 export interface EnsureTokenChargeableTranslations {
21     processAbortedError: string;
22     paymentProcessCanceledError: string;
23     paymentProcessFailedError: string;
24     paymentProcessConsumedError: string;
25     paymentProcessNotSupportedError: string;
26     unknownPaymentTokenStatusError: string;
27     tabClosedError: string;
30 /**
31  * Recursive function to check token status
32  */
33 const pull = async ({
34     timer = 0,
35     Token,
36     api,
37     signal,
38     translations,
39 }: {
40     timer?: number;
41     Token: string;
42     api: Api;
43     signal: AbortSignal;
44     translations: EnsureTokenChargeableTranslations;
45 }): Promise<any> => {
46     if (signal.aborted) {
47         throw new Error(translations.processAbortedError);
48     }
50     if (timer > DELAY_PULLING * 30) {
51         throw new Error(translations.paymentProcessCanceledError);
52     }
54     const { Status } = await api({ ...getTokenStatusV4(Token), signal });
56     if (Status === STATUS_FAILED) {
57         throw new Error(translations.paymentProcessFailedError);
58     }
60     if (Status === STATUS_CONSUMED) {
61         throw new Error(translations.paymentProcessConsumedError);
62     }
64     if (Status === STATUS_NOT_SUPPORTED) {
65         throw new Error(translations.paymentProcessNotSupportedError);
66     }
68     if (Status === STATUS_CHARGEABLE) {
69         return;
70     }
72     if (Status === STATUS_PENDING) {
73         await wait(DELAY_PULLING);
74         return pull({ Token, api, timer: timer + DELAY_PULLING, signal, translations });
75     }
77     throw new Error(translations.unknownPaymentTokenStatusError);
80 export type EnsureTokenChargeableInputs = Pick<PaymentTokenResult, 'ApprovalURL' | 'ReturnHost' | 'Token'> & {
81     api: Api;
82     signal: AbortSignal;
85 /**
86  * Accepts the payment token as the input and processes it to make it chargeable.
87  * Currently initializes a new tab where user can confirm the payment (3DS or Paypal confirmation).
88  * After the verification tab is closed, the function checks the status of the token and resolves if it's chargeable.
89  * An additional purpose of this function is to abstract away the verification mechanism and thus stress out that
90  * alternative implementations are possible.
91  */
92 export const ensureTokenChargeable = (
93     { Token, api, ApprovalURL, ReturnHost, signal }: EnsureTokenChargeableInputs,
94     translations: EnsureTokenChargeableTranslations,
95     delayListening = DELAY_LISTENING
96 ) => {
97     const tab = window.open(ApprovalURL);
99     return new Promise<void>((resolve, reject) => {
100         let listen = false;
102         const reset = () => {
103             listen = false;
104             // eslint-disable-next-line @typescript-eslint/no-use-before-define
105             window.removeEventListener('message', onMessage, false);
106             // eslint-disable-next-line @typescript-eslint/no-use-before-define
107             signal.removeEventListener('abort', abort);
108         };
110         const listenTab = async (): Promise<any> => {
111             if (!listen) {
112                 return;
113             }
115             if (tab && tab.closed) {
116                 try {
117                     reset();
118                     const { Status } = await api({ ...getTokenStatusV4(Token), signal });
119                     if (Status === STATUS_CHARGEABLE) {
120                         return resolve();
121                     }
123                     throw new Error(translations.tabClosedError);
124                 } catch (error: any) {
125                     return reject({ ...error, tryAgain: true });
126                 }
127             }
129             await wait(delayListening);
130             return listenTab();
131         };
133         const onMessage = (event: MessageEvent) => {
134             if (getHostname(event.origin) !== ReturnHost) {
135                 return;
136             }
138             reset();
139             tab?.close?.();
141             const { cancel } = event.data;
143             if (cancel === '1') {
144                 return reject();
145             }
147             pull({ Token, api, signal, translations }).then(resolve).catch(reject);
148         };
150         const abort = () => {
151             reset();
152             tab?.close?.();
153             reject(new Error(translations.processAbortedError));
154         };
156         signal.addEventListener('abort', abort);
157         window.addEventListener('message', onMessage, false);
158         listen = true;
159         listenTab();
160     });
163 export function waitFor3ds(events: ChargebeeIframeEvents, tab: Window | null) {
164     const removeEventListeners: RemoveEventListener[] = [];
165     const threeDsChallengeSuccess = new Promise((resolve, reject) => {
166         const listenerSuccess = events.onThreeDsSuccess((data) => {
167             resolve(data);
168         });
170         // We don't provide any error data to the caller to avoid double-handling of the error event.
171         // The error message coming from the iframe must be handled in a centralized way.
172         const listenerError = events.onThreeDsFailure(() =>
173             reject({
174                 threeDsFailure: true,
175             })
176         );
178         const listenerSavedSuccess = events.onCardVeririfcationSuccess((data) => {
179             resolve(data.authorizedPaymentIntent);
180         });
182         const listenerSavedError = events.onCardVeririfcationFailure(() =>
183             reject({
184                 threeDsFailure: true,
185             })
186         );
188         removeEventListeners.push(listenerSuccess);
189         removeEventListeners.push(listenerError);
190         removeEventListeners.push(listenerSavedSuccess);
191         removeEventListeners.push(listenerSavedError);
193         const interval = setInterval(() => {
194             if (tab?.closed) {
195                 reject();
196                 clearInterval(interval);
197             }
198         }, 1000);
199     });
201     return threeDsChallengeSuccess.finally(() => {
202         removeEventListeners.forEach((removeEventListener) => removeEventListener());
203     });
206 export const ensureTokenChargeableV5 = async (
207     token: ChargebeeFetchedPaymentToken,
208     events: ChargebeeIframeEvents,
209     {
210         api,
211         signal,
212     }: {
213         api: Api;
214         signal: AbortSignal;
215     },
216     translations: EnsureTokenChargeableTranslations,
217     delayListening = DELAY_LISTENING
218 ) => {
219     let tab: Window | null = null;
220     if (!token.authorized) {
221         tab = window.open(token.approvalUrl);
222     }
223     const noNeedToAuthorize = token.authorized;
225     return new Promise<void>(async (resolve, reject) => {
226         let pollingActive = true;
228         const closeTab = () => {
229             tab?.close?.();
230         };
232         const reset = () => {
233             pollingActive = false;
234             // eslint-disable-next-line @typescript-eslint/no-use-before-define
235             signal?.removeEventListener('abort', abort);
236         };
238         const abort = () => {
239             reset();
240             closeTab();
241             reject(new Error(translations.processAbortedError));
242         };
244         const listenTab = async (): Promise<void> => {
245             if (!pollingActive) {
246                 return;
247             }
249             // tab && tab.closed means that there was approvalURl and now the tab is closed
250             // Another case if !tab && token.authorized means that there was no approvalUrl and the token is already
251             // authorized. For v5, we still to make sure that the token is chargeable.
252             if ((tab && tab.closed) || noNeedToAuthorize) {
253                 try {
254                     const { Status } = await api({ ...getTokenStatusV5(token.PaymentToken), signal });
255                     if (Status === PAYMENT_TOKEN_STATUS.STATUS_CHARGEABLE) {
256                         return resolve();
257                     }
259                     throw new Error(translations.tabClosedError);
260                 } catch (err: any) {
261                     const error: any = new Error(err);
262                     error.tryAgain = true;
263                     return reject(error);
264                 }
265             }
267             await wait(delayListening);
268             return listenTab();
269         };
271         waitFor3ds(events, tab)
272             .catch((error) => {
273                 if (error?.threeDsFailure) {
274                     reject();
275                 }
276             })
277             .finally(() => {
278                 closeTab();
279             });
281         signal?.addEventListener('abort', abort);
283         listenTab().catch(reject);
284     });