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';
8 ChargebeeFetchedPaymentToken,
14 const { STATUS_PENDING, STATUS_CHARGEABLE, STATUS_FAILED, STATUS_CONSUMED, STATUS_NOT_SUPPORTED } =
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;
31 * Recursive function to check token status
44 translations: EnsureTokenChargeableTranslations;
47 throw new Error(translations.processAbortedError);
50 if (timer > DELAY_PULLING * 30) {
51 throw new Error(translations.paymentProcessCanceledError);
54 const { Status } = await api({ ...getTokenStatusV4(Token), signal });
56 if (Status === STATUS_FAILED) {
57 throw new Error(translations.paymentProcessFailedError);
60 if (Status === STATUS_CONSUMED) {
61 throw new Error(translations.paymentProcessConsumedError);
64 if (Status === STATUS_NOT_SUPPORTED) {
65 throw new Error(translations.paymentProcessNotSupportedError);
68 if (Status === STATUS_CHARGEABLE) {
72 if (Status === STATUS_PENDING) {
73 await wait(DELAY_PULLING);
74 return pull({ Token, api, timer: timer + DELAY_PULLING, signal, translations });
77 throw new Error(translations.unknownPaymentTokenStatusError);
80 export type EnsureTokenChargeableInputs = Pick<PaymentTokenResult, 'ApprovalURL' | 'ReturnHost' | 'Token'> & {
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.
92 export const ensureTokenChargeable = (
93 { Token, api, ApprovalURL, ReturnHost, signal }: EnsureTokenChargeableInputs,
94 translations: EnsureTokenChargeableTranslations,
95 delayListening = DELAY_LISTENING
97 const tab = window.open(ApprovalURL);
99 return new Promise<void>((resolve, reject) => {
102 const reset = () => {
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);
110 const listenTab = async (): Promise<any> => {
115 if (tab && tab.closed) {
118 const { Status } = await api({ ...getTokenStatusV4(Token), signal });
119 if (Status === STATUS_CHARGEABLE) {
123 throw new Error(translations.tabClosedError);
124 } catch (error: any) {
125 return reject({ ...error, tryAgain: true });
129 await wait(delayListening);
133 const onMessage = (event: MessageEvent) => {
134 if (getHostname(event.origin) !== ReturnHost) {
141 const { cancel } = event.data;
143 if (cancel === '1') {
147 pull({ Token, api, signal, translations }).then(resolve).catch(reject);
150 const abort = () => {
153 reject(new Error(translations.processAbortedError));
156 signal.addEventListener('abort', abort);
157 window.addEventListener('message', onMessage, false);
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) => {
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(() =>
174 threeDsFailure: true,
178 const listenerSavedSuccess = events.onCardVeririfcationSuccess((data) => {
179 resolve(data.authorizedPaymentIntent);
182 const listenerSavedError = events.onCardVeririfcationFailure(() =>
184 threeDsFailure: true,
188 removeEventListeners.push(listenerSuccess);
189 removeEventListeners.push(listenerError);
190 removeEventListeners.push(listenerSavedSuccess);
191 removeEventListeners.push(listenerSavedError);
193 const interval = setInterval(() => {
196 clearInterval(interval);
201 return threeDsChallengeSuccess.finally(() => {
202 removeEventListeners.forEach((removeEventListener) => removeEventListener());
206 export const ensureTokenChargeableV5 = async (
207 token: ChargebeeFetchedPaymentToken,
208 events: ChargebeeIframeEvents,
216 translations: EnsureTokenChargeableTranslations,
217 delayListening = DELAY_LISTENING
219 let tab: Window | null = null;
220 if (!token.authorized) {
221 tab = window.open(token.approvalUrl);
223 const noNeedToAuthorize = token.authorized;
225 return new Promise<void>(async (resolve, reject) => {
226 let pollingActive = true;
228 const closeTab = () => {
232 const reset = () => {
233 pollingActive = false;
234 // eslint-disable-next-line @typescript-eslint/no-use-before-define
235 signal?.removeEventListener('abort', abort);
238 const abort = () => {
241 reject(new Error(translations.processAbortedError));
244 const listenTab = async (): Promise<void> => {
245 if (!pollingActive) {
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) {
254 const { Status } = await api({ ...getTokenStatusV5(token.PaymentToken), signal });
255 if (Status === PAYMENT_TOKEN_STATUS.STATUS_CHARGEABLE) {
259 throw new Error(translations.tabClosedError);
261 const error: any = new Error(err);
262 error.tryAgain = true;
263 return reject(error);
267 await wait(delayListening);
271 waitFor3ds(events, tab)
273 if (error?.threeDsFailure) {
281 signal?.addEventListener('abort', abort);
283 listenTab().catch(reject);