UNPKG

braintree-web

Version:

A suite of tools for integrating Braintree in the browser

1,528 lines (1,353 loc) 58.4 kB
"use strict"; var analytics = require("../lib/analytics"); var assign = require("../lib/assign").assign; var isBrowserSupported = require("./shared/supports-venmo"); var browserDetection = require("./shared/browser-detection"); var constants = require("./shared/constants"); var errors = require("./shared/errors"); var querystring = require("../lib/querystring"); var isVerifiedDomain = require("../lib/is-verified-domain"); var methods = require("../lib/methods"); var convertMethodsToError = require("../lib/convert-methods-to-error"); var wrapPromise = require("@braintree/wrap-promise"); var BraintreeError = require("../lib/braintree-error"); var inIframe = require("../lib/in-iframe"); var ExtendedPromise = require("@braintree/extended-promise"); var getVenmoUrl = require("./shared/get-venmo-url"); var desktopWebLogin = require("./shared/web-login-backdrop"); var snakeCaseToCamelCase = require("../lib/snake-case-to-camel-case"); var urlParams = require("../lib/url-params"); // NEXT_MAJOR_VERSION the source code for this is actually in a // typescript repo called venmo-desktop, once the SDK is migrated // to typescript, we can move the TS files out of that separate // repo and into the web SDK properly var createVenmoDesktop = require("./external/"); var graphqlQueries = require("./external/queries"); var VERSION = "3.134.0"; var DEFAULT_MOBILE_POLLING_INTERVAL = 250; // 1/4 second var DEFAULT_MOBILE_EXPIRING_THRESHOLD = 300000; // 5 minutes ExtendedPromise.suppressUnhandledPromiseMessage = true; /** * Venmo tokenize payload. * @typedef {object} Venmo~tokenizePayload * @property {string} nonce The payment method nonce. * @property {string} type The payment method type, always `VenmoAccount`. * @property {object} details Additional Venmo account details. * @property {string} details.username The username of the Venmo account. * @property {string} details.paymentContextId The context ID of the Venmo payment. Only available when used with {@link https://braintree.github.io/braintree-web/current/module-braintree-web_venmo.html#.create|`paymentMethodUsage`}. */ /** * @class * @param {object} options The Venmo {@link module:braintree-web/venmo.create create} options. * @description <strong>Do not use this constructor directly. Use {@link module:braintree-web/venmo.create|braintree-web.venmo.create} instead.</strong> * @classdesc This class represents a Venmo component produced by {@link module:braintree-web/venmo.create|braintree-web/venmo.create}. Instances of this class have methods for tokenizing Venmo payments. */ // eslint-disable-next-line complexity function Venmo(options) { var self = this; this._allowDesktopWebLogin = options.allowDesktopWebLogin || false; this._mobileWebFallBack = options.mobileWebFallBack || false; this._createPromise = options.createPromise; this._allowNewBrowserTab = options.allowNewBrowserTab !== false; this._allowWebviews = options.allowWebviews !== false; this._allowDesktop = options.allowDesktop === true; this._useRedirectForIOS = options.useRedirectForIOS === true; this._profileId = options.profileId; this._displayName = options.displayName; this._deepLinkReturnUrl = options.deepLinkReturnUrl; this._ignoreHistoryChanges = options.ignoreHistoryChanges; this._paymentMethodUsage = (options.paymentMethodUsage || "").toUpperCase(); this._shouldUseLegacyFlow = !this._paymentMethodUsage; this._requireManualReturn = options.requireManualReturn === true; this._useDesktopQRFlow = this._allowDesktop && this._isDesktop() && !this._allowDesktopWebLogin; this._useAllowDesktopWebLogin = this._allowDesktopWebLogin && this._isDesktop(); this._cannotHaveReturnUrls = inIframe() || this._requireManualReturn; this._allowAndroidRecreation = options.allowAndroidRecreation !== false; this._allowNonDefaultBrowsers = options.allowNonDefaultBrowsers !== false; this._maxRetryCount = 3; this._collectCustomerBillingAddress = options.collectCustomerBillingAddress || false; this._collectCustomerShippingAddress = options.collectCustomerShippingAddress || false; this._cancelOnReturnToBrowser = options.cancelOnReturnToBrowser === true; this._isFinalAmount = options.isFinalAmount || false; this._lineItems = options.lineItems; this._subTotalAmount = options.subTotalAmount; this._discountAmount = options.discountAmount; this._taxAmount = options.taxAmount; this._shippingAmount = options.shippingAmount; this._totalAmount = options.totalAmount; this._cspNonce = (this._mobileWebFallBack || this._allowDesktopWebLogin) && (options.styleCspNonce || false); this._mobilePollingInterval = DEFAULT_MOBILE_POLLING_INTERVAL; this._mobilePollingExpiresThreshold = DEFAULT_MOBILE_EXPIRING_THRESHOLD; this._pollCount = 0; this._shouldCreateVenmoPaymentContext = this._cannotHaveReturnUrls || !this._shouldUseLegacyFlow; this._isIncognito = options._isIncognito; analytics.sendEvent( this._createPromise, "venmo.options.is-incognito." + String(Boolean(this._isIncognito)) ); analytics.sendEvent( this._createPromise, "venmo.desktop-flow.configured." + String(Boolean(this._allowDesktop)) ); // if the url has a tokenization result, that indicates // that it cannot be the desktop flow or the manual return // flow. If it's the hash change with paymentMethodUsage // flow, we want to skip creating a new payment context, since // there is already a pending payment context waiting to be // processed. For the hash change flow without paymentMethodUsage, // no further actions are needed. if (this.hasTokenizationResult()) { analytics.sendEvent( this._createPromise, "venmo.appswitch.return-in-new-tab" ); } else if (this._useDesktopQRFlow) { this._createPromise = this._createPromise.then(function (client) { var config = client.getConfiguration().gatewayConfiguration; return createVenmoDesktop({ url: config.assetsUrl + "/web/" + VERSION + "/html/venmo-desktop-frame.html", environment: config.environment === "production" ? "PRODUCTION" : "SANDBOX", profileId: self._profileId || config.payWithVenmo.merchantId, paymentMethodUsage: self._paymentMethodUsage, displayName: self._displayName, Promise: Promise, apiRequest: function (query, data) { return client .request({ api: "graphQLApi", data: { query: query, variables: data, }, }) .then(function (response) { return response.data; }); }, sendEvent: function (eventName) { analytics.sendEvent(self._createPromise, eventName); }, verifyDomain: isVerifiedDomain, }) .then(function (venmoDesktopInstance) { self._venmoDesktopInstance = venmoDesktopInstance; analytics.sendEvent( self._createPromise, "venmo.desktop-flow.presented" ); return client; }) .catch(function () { analytics.sendEvent( self._createPromise, "venmo.desktop-flow.setup-failed" ); self._useDesktopQRFlow = false; return client; }); }); } else if (this._shouldCreateVenmoPaymentContext) { this._createPromise = this._createPromise.then(function (client) { var paymentContextPromise, webLoginPromise; var analyticsCategory = self._cannotHaveReturnUrls ? "manual-return" : "mobile-payment-context"; var config = client.getConfiguration(); webLoginPromise = desktopWebLogin .setupDesktopWebLogin({ assetsUrl: config.gatewayConfiguration.assetsUrl, debug: config.isDebug, }) .then(function (frameServiceInstance) { self._frameServiceInstance = frameServiceInstance; }) .catch(function (desktopWebErr) { return desktopWebErr; }); self._mobilePollingContextEnvironment = config.gatewayConfiguration.environment.toUpperCase(); paymentContextPromise = self ._createVenmoPaymentContext(client) .then(function () { analytics.sendEvent( self._createPromise, "venmo." + analyticsCategory + ".presented" ); return client; }) .catch(function (err) { analytics.sendEvent( self._createPromise, "venmo." + analyticsCategory + ".setup-failed" ); return Promise.reject( new BraintreeError({ type: errors.VENMO_MOBILE_PAYMENT_CONTEXT_SETUP_FAILED.type, code: errors.VENMO_MOBILE_PAYMENT_CONTEXT_SETUP_FAILED.code, message: isValidationError(err) ? err.details.originalError[0].message : errors.VENMO_MOBILE_PAYMENT_CONTEXT_SETUP_FAILED.message, details: { originalError: err, }, }) ); }); return ExtendedPromise.all([webLoginPromise, paymentContextPromise]) .then(function (results) { var paymentContextResult = results[1]; // We only care about the returned value of the paymentContextPromise return Promise.resolve(paymentContextResult); }) .catch(function (promiseErr) { // ExtendedPromise.all returns just one error and it's either which fails first/at all. return Promise.reject(promiseErr); }); }); } } function isValidationError(err) { return ( err.details && err.details.originalError && err.details.originalError[0] && err.details.originalError[0].extensions && err.details.originalError[0].extensions.errorClass === "VALIDATION" && err.details.originalError[0].extensions.errorType === "user_error" ); } Venmo.prototype._createVenmoPaymentContext = function ( client, cancelIfTokenizationInProgress ) { var self = this; var promise, transactionDetails; var configuration = client.getConfiguration(); var venmoConfiguration = configuration.gatewayConfiguration.payWithVenmo; var transactionDetailsPresent = false; var customerClientChannel = self._useAllowDesktopWebLogin ? "NATIVE_WEB" : "MOBILE_WEB"; if (!this._shouldCreateVenmoPaymentContext) { return Promise.resolve(); } if (this._shouldUseLegacyFlow) { promise = client .request({ api: "graphQLApi", data: { query: graphqlQueries.LEGACY_CREATE_PAYMENT_CONTEXT_QUERY, variables: { input: { environment: this._mobilePollingContextEnvironment, intent: "PAY_FROM_APP", }, }, }, }) .then(function (response) { return response.data.createVenmoQRCodePaymentContext .venmoQRCodePaymentContext; }); } else { // Merchants are not allowed to collect user addresses unless ECD (Enriched Customer Data) is enabled on the BT Control Panel. if ( (this._collectCustomerBillingAddress || this._collectCustomerShippingAddress) && !venmoConfiguration.enrichedCustomerDataEnabled ) { return Promise.reject(new BraintreeError(errors.VENMO_ECD_DISABLED)); } if (this._lineItems) { this._lineItems.forEach(function (item) { item.unitTaxAmount = item.unitTaxAmount || "0"; }); } transactionDetails = { subTotalAmount: this._subTotalAmount, discountAmount: this._discountAmount, taxAmount: this._taxAmount, shippingAmount: this._shippingAmount, totalAmount: this._totalAmount, lineItems: this._lineItems, }; transactionDetailsPresent = Object.keys(transactionDetails).some( function (detail) { return transactionDetails[detail] !== undefined; } ); promise = client .request({ api: "graphQLApi", data: { query: graphqlQueries.CREATE_PAYMENT_CONTEXT_QUERY, variables: { input: { paymentMethodUsage: this._paymentMethodUsage, intent: "CONTINUE", customerClient: customerClientChannel, isFinalAmount: this._isFinalAmount, displayName: this._displayName, paysheetDetails: { collectCustomerBillingAddress: this._collectCustomerBillingAddress, collectCustomerShippingAddress: this._collectCustomerShippingAddress, transactionDetails: transactionDetailsPresent ? transactionDetails : undefined, }, }, }, }, }) .then(function (response) { return response.data.createVenmoPaymentContext.venmoPaymentContext; }); } return promise.then(function (context) { var expiredTime = new Date(context.expiresAt) - new Date(context.createdAt); var refreshIn = expiredTime * 0.6666; // prevents multiple setTimeouts from firing from separate calls // to create a payment context by canceling the previous one // if there is a pending call clearTimeout(self._refreshPaymentContextTimeout); self._refreshPaymentContextTimeout = setTimeout(function () { if (self._tokenizationInProgress) { return; } self._createVenmoPaymentContext(client, true); }, refreshIn); if (cancelIfTokenizationInProgress && self._tokenizationInProgress) { return; } self._venmoPaymentContextStatus = context.status; self._venmoPaymentContextId = context.id; }); }; Venmo.prototype._popupBridgeIsInstalled = function () { return window.popupBridge && typeof window.popupBridge.open === "function"; }; Venmo.prototype._venmoNativeAppIsInstalled = function () { return Boolean( window.popupBridge && ((window.parent && window.parent.popupBridge.isVenmoInstalled) || window.popupBridge.isVenmoInstalled) ); }; /** * Handle app switching when a deep link return URL is configured * @private * @param {string} url - The URL to redirect to * @returns {void} */ Venmo.prototype._handleDeepLinkAppSwitch = function (url) { if (isIosWebviewInDeepLinkReturnUrlFlow()) { this._handleIosWebviewDeepLink(url); } else if (this._popupBridgeIsInstalled()) { this._handlePopupBridgeAppSwitch(url); } else if (browserDetection.isAndroidWebview()) { analytics.sendEvent( this._createPromise, "venmo.appswitch.start.android-webview-redirect" ); if (inIframe()) { this._handleIFrameBreakout(url); } else { window.location.href = url; } } else { analytics.sendEvent(this._createPromise, "venmo.appswitch.start.webview"); this._venmoWindow = window.open(url); } }; /** * Handle app switching for iOS webview with deep link return URL * @private * @param {string} url - The URL to redirect to * @returns {void} */ Venmo.prototype._handleIosWebviewDeepLink = function (url) { analytics.sendEvent(this._createPromise, "venmo.appswitch.start.ios-webview"); if (inIframe()) { this._handleIosWebviewInIframe(url); } else if ( !this._venmoNativeAppIsInstalled() && this._popupBridgeIsInstalled() ) { this._handlePopupBridgeAppSwitch(url); } else { // Deep link URLs do not launch iOS apps from a webview when using window.open or PopupBridge.open window.location.href = url; } }; /** * Handle iOS webview app switching when in an iframe * @private * @param {string} url - The URL to redirect to * @returns {void} */ Venmo.prototype._handleIosWebviewInIframe = function (url) { if (this._venmoNativeAppIsInstalled()) { this._handleIFrameBreakout(url); } else if (this._popupBridgeIsInstalled()) { this._handlePopupBridgeAppSwitch(url); } else { this._handleIFrameBreakout(url); } }; /** * Handle app switching with PopupBridge * @private * @param {string} url - The URL to redirect to * @returns {void} */ Venmo.prototype._handlePopupBridgeAppSwitch = function (url) { analytics.sendEvent( this._createPromise, "venmo.appswitch.start.popup-bridge" ); window.popupBridge.open(url); }; /** * Handle app switching for standard browser environments * @private * @param {string} url - The URL to redirect to * @returns {void} */ Venmo.prototype._handleBrowserAppSwitch = function (url) { analytics.sendEvent(this._createPromise, "venmo.appswitch.start.browser"); if ( browserDetection.doesNotSupportWindowOpenInIos() || this._shouldUseRedirectStrategy() ) { window.location.href = url; } else if ( this._mobileWebFallBack && browserDetection.isAndroid() && browserDetection.isChrome() ) { // Android chrome needs to use window.location.href // to remain in the same tab as expected. // Chrome now defaults to opening a new tab. window.location.href = url; } else { this._venmoWindow = window.open(url); } }; /** * Handle breaking out of the iframe we're in * @private * @param {string} url - The URL to redirect to * @returns {void} */ Venmo.prototype._handleIFrameBreakout = function (url) { window.top.location.href = url; }; Venmo.prototype.appSwitch = function (url) { if (this._deepLinkReturnUrl) { this._handleDeepLinkAppSwitch(url); } else { this._handleBrowserAppSwitch(url); } }; Venmo.prototype.getUrl = function () { return this._createPromise.then( function (client) { var configuration = client.getConfiguration(); var params = {}; var currentUrl = this._deepLinkReturnUrl || window.location.href.replace(window.location.hash, ""); var venmoConfiguration = configuration.gatewayConfiguration.payWithVenmo; var analyticsMetadata = configuration.analyticsMetadata; var accessToken = venmoConfiguration.accessToken; var braintreeData = { _meta: { version: analyticsMetadata.sdkVersion, integration: analyticsMetadata.integration, platform: analyticsMetadata.platform, sessionId: analyticsMetadata.sessionId, }, }; this._isDebug = configuration.isDebug; this._assetsUrl = configuration.gatewayConfiguration.assetsUrl; currentUrl = currentUrl.replace(/#*$/, ""); if (this._venmoPaymentContextId) { if (this._shouldUseLegacyFlow) { // NEXT_MAJOR_VERSION stop adding the context id to the access token. // the context id is placed here for backwards compatiblity // with versions of the venmo app that did not support // pulling the resource id off of the query params accessToken += "|pcid:" + this._venmoPaymentContextId; } else { params.resource_id = this._venmoPaymentContextId; // eslint-disable-line camelcase } } if (this._shouldIncludeReturnUrls() || this._useAllowDesktopWebLogin) { if (this._useAllowDesktopWebLogin) { currentUrl = this._assetsUrl + "/web/" + VERSION + "/html/redirect-frame.html"; } params["x-success"] = currentUrl + "#venmoSuccess=1"; params["x-cancel"] = currentUrl + "#venmoCancel=1"; params["x-error"] = currentUrl + "#venmoError=1"; } else { params["x-success"] = "NOOP"; params["x-cancel"] = "NOOP"; params["x-error"] = "NOOP"; } if (!this._allowAndroidRecreation) { params.allowAndroidRecreation = 0; } else { params.allowAndroidRecreation = 1; } params.ua = window.navigator.userAgent; params.braintree_merchant_id = // eslint-disable-line camelcase this._profileId || venmoConfiguration.merchantId; params.braintree_access_token = accessToken; // eslint-disable-line camelcase params.braintree_environment = venmoConfiguration.environment; // eslint-disable-line camelcase params.braintree_sdk_data = btoa(JSON.stringify(braintreeData)); // eslint-disable-line camelcase return ( getVenmoUrl({ useAllowDesktopWebLogin: this._useAllowDesktopWebLogin, mobileWebFallBack: this._mobileWebFallBack, }) + "?" + querystring.stringify(params) ); }.bind(this) ); }; /** * Returns a boolean indicating whether the current browser supports Venmo as a payment method. Please note that iOS Chrome is not supported when the Venmo button is rendered in an iFrame. * * If `options.allowNewBrowserTab` is false when calling {@link module:braintree-web/venmo.create|venmo.create}, this method will return true only for browsers known to support returning from the Venmo app to the same browser tab. Currently, this is limited to iOS Safari and Android Chrome. * If `options.allowWebviews` is false when calling {@link module:braintree-web/venmo.create|venmo.create}, this method will return true only for mobile browsers that are not webviews. * @public * @returns {boolean} True if the current browser is supported, false if not. */ Venmo.prototype.isBrowserSupported = function () { return isBrowserSupported.isBrowserSupported({ allowNewBrowserTab: this._allowNewBrowserTab, allowWebviews: this._allowWebviews, allowDesktop: this._allowDesktop, allowDesktopWebLogin: this._allowDesktopWebLogin, allowNonDefaultBrowsers: this._allowNonDefaultBrowsers, }); }; /** * Returns a boolean indicating whether a Venmo tokenization result is ready to be processed immediately. * * This method should be called after initialization to see if the result of Venmo authorization is available. If it returns true, call {@link Venmo#tokenize|tokenize} immediately to process the results. * * @public * @returns {boolean} True if the results of Venmo payment authorization are available and ready to process. */ Venmo.prototype.hasTokenizationResult = function () { return this._hasTokenizationResult(); }; // a private version that lets us pass in a custom hash // when listening on a hashchange event Venmo.prototype._hasTokenizationResult = function (hash) { var params = getFragmentParameters(hash); var paramsFromUrl = urlParams.getUrlParams(); if (paramsFromUrl.resource_id) { this._venmoPaymentContextId = paramsFromUrl.resource_id; } else if (params.id) { this._venmoPaymentContextId = params.id; } return ( typeof (params.venmoSuccess || params.venmoError || params.venmoCancel) !== "undefined" ); }; Venmo.prototype._shouldIncludeReturnUrls = function () { // When the SDK is initialized in a non-default mobile browser (but not webviews // and not Android)), it is not possible to automatically return to the browser // from which the Venmo app was launched. When return URLs are omitted, // the Venmo app prompts the user to return manually. // // Note: webviews are excluded from this restriction because deep link return URLs are // specifically designed to work with webviews. // Note: Android is excluded from this restriction because it can always return to the // browser from which the Venmo app was launched. if ( isBrowserSupported.isNonDefaultBrowser() && !browserDetection.isWebview() && !browserDetection.isAndroid() ) { return false; } // When we do support non-default browsers, and a deep link // return url is passed, we should always respect it and // include the return urls so the venmo app can app switch back to it if (this._deepLinkReturnUrl) { return true; } // Cannot include return URLs if in iframe or manual return is required if (this._cannotHaveReturnUrls) { return false; } // Special case: iOS Safari in private mode should include return URLs // because it does allow app switching back to Safari if (this._isIncognito && browserDetection.isIosSafari()) { return true; } // For all other incognito/private modes, exclude return URLs if (this._isIncognito) { return false; } // Default case: include return URLs return true; }; Venmo.prototype._isDesktop = function () { return !(browserDetection.isIos() || browserDetection.isAndroid()); }; /** * Detects iOS Safari in iframe without Venmo app installed * @private * @returns {boolean} True if we're on iOS mobile web, in an iframe, and the Venmo app is not installed */ Venmo.prototype._isIOSIframeWithoutVenmoApp = function () { return ( browserDetection.isIos() && inIframe() && !this._venmoNativeAppIsInstalled() && !this._requireManualReturn // Respect explicit merchant override ); }; /** * Launches the Venmo flow and returns a nonce payload. * * If {@link Venmo#hasTokenizationResult|hasTokenizationResult} returns true, calling tokenize will immediately process and return the results without initiating the Venmo payment authorization flow. * * Only one Venmo flow can be active at a time. One way to achieve this is to disable your Venmo button while the flow is open. * @public * @param {object} [options] Options for tokenization. * @param {number} [options.processResultsDelay=500] The amount of time in milliseconds to delay processing the results. In most cases, this value should be left as the default. * @param {callback} [callback] The second argument, <code>data</code>, is a {@link Venmo~tokenizePayload|tokenizePayload}. If no callback is provided, the method will return a Promise that resolves with a {@link Venmo~tokenizePayload|tokenizePayload}. * @returns {(Promise|void)} Returns a promise if no callback is provided. * @example * button.addEventListener('click', function () { * // Disable the button so that we don't attempt to open multiple popups. * button.setAttribute('disabled', 'disabled'); * * // Because tokenize opens a new window, this must be called * // as a result of a user action, such as a button click. * venmoInstance.tokenize().then(function (payload) { * // Submit payload.nonce to your server * // Use payload.username to get the Venmo username and display any UI * }).catch(function (tokenizeError) { * // Handle flow errors or premature flow closure * switch (tokenizeErr.code) { * case 'VENMO_APP_CANCELED': * console.log('User canceled Venmo flow.'); * break; * case 'VENMO_CANCELED': * console.log('User canceled Venmo, or Venmo app is not available.'); * break; * default: * console.error('Error!', tokenizeErr); * } * }).then(function () { * button.removeAttribute('disabled'); * }); * }); */ Venmo.prototype.tokenize = function (options) { var self = this; var tokenizationPromise; options = options || {}; if (this._tokenizationInProgress === true) { return Promise.reject( new BraintreeError(errors.VENMO_TOKENIZATION_REQUEST_ACTIVE) ); } this._tokenizationInProgress = true; if (this._useDesktopQRFlow) { // for the desktop flow, we create a venmo payment // context and then present a qr code modal to the // customer and they will open up their venmo app // and scan it and approve the purchase on their // mobile device. The sdk will start polling // in order to determine when the status of the // payment context has updated and then pass the // resulting nonce back to the merchant. tokenizationPromise = this._tokenizeForDesktopQRFlow(options); } else if (this._useAllowDesktopWebLogin) { /** * For Desktop Web Login, we open a browser popup to allow for authorization. Once authorized, the redirect urls are used by Venmo, and we query the API for a payment context status update. * * - Payment context is created on initialization * - Popup is opened to Venmo login url. * - The payment is authorized or canceled, and the popup is closed * - Once the popup is closed, we query the API for a payment context status update * * This is an alternate, opt-in flow to be used the Desktop QR Flow is not desired for Pay with Venmo desktop experiences. */ tokenizationPromise = this._tokenizeWebLoginWithRedirect(options); } else if ( this._cannotHaveReturnUrls && !this._isIOSIframeWithoutVenmoApp() ) { // in the manual return strategy, we create the payment // context on initialization, then continually poll once // the app switch begins until we get a response indiciating // the payment context was approved by the customer on the // Venmo app. The payment context response also includes a // nonce. There are 2 cases where we use the manual return // strategy: // 1. the sdk is instantiated in an iframe, because // the venmo app is unable to redirect automatically // when that is the case so we rely on the customer // to do a manual redirect and continunally poll for // updates on the payment context to get the nonce // 2. same deal for when `requireManualReturn` is configured // NOTE: We exclude iOS iframe without Venmo app because it causes // network errors due to Apple's iframe cross-domain restrictions tokenizationPromise = this._tokenizeForMobileWithManualReturn(); } else { // the default mobile flow is to app switch to the // venmo app, and then have the venmo app switch // back to the page with the venmo nonce details // encoded into the hash portion of the url. If // `paymentMethodUsage` is provided when instantiating // the sdk, we also create a payment context and pass // the resource id to the Venmo app during the app switch. // Once we get a succesful return, we ping the payment // context query to get any additional data needed // to send back to the merchant. tokenizationPromise = this._tokenizeForMobileWithHashChangeListeners(options); } return tokenizationPromise .then(function (payload) { return self._createPromise .then(function (client) { return self._createVenmoPaymentContext(client); }) .then(function () { self._tokenizationInProgress = false; self._venmoWindow = null; return formatTokenizePayload(payload); }); }) .catch(function (err) { return self._createPromise .then(function (client) { // We create a new Payment Context because if the last one failed, then presumably we don't want to use it again. // On the first pass, we create the payment context at initialization, and since we used that first one we now need to create a new one // for the next time someone tries to tokenize. return self._createVenmoPaymentContext(client); }) .then(function () { self._tokenizationInProgress = false; self._venmoWindow = null; return Promise.reject(err); }); }); }; /** * Cancels the venmo tokenization process * * @public * @function Venmo~cancelTokenization * @returns {(Promise|void)} Returns a promise if no callback is provided. * @example * venmoTokenizeButton.addEventListener('click', function () { * venmoInstance.tokenize().then(function (payload) { * // handle payload * }).catch(function (err) { * if (err.code === 'VENMO_TOKENIZATION_CANCELED_BY_MERCHANT') { * // tokenization was canceled by calling cancelTokenization * } * }); * }); * * venmoCancelButton.addEventListener('click', function () { * // Hide the button when the venmo flow is not in progress * venmoCancelButton.style.display = "none"; * * venmoInstance.cancelTokenization().then(function () { * // done canceling the flow * }).catch(function (err) { * // should only get here if there is no tokenization in progress * }); * }); */ Venmo.prototype.cancelTokenization = function () { if (!this._tokenizationInProgress) { return Promise.reject( new BraintreeError(errors.VENMO_TOKENIZATION_REQUEST_NOT_ACTIVE) ); } this._removeVisibilityEventListener(); // important to reject the tokenization promise first // so the tokenize method rejects with this error // rather than a customer canceled error in the mobile // polling and desktop flows if (this._tokenizePromise) { this._tokenizePromise.reject( new BraintreeError(errors.VENMO_TOKENIZATION_CANCELED_BY_MERCHANT) ); } return Promise.all([ this._cancelMobilePaymentContext(), this._cancelVenmoDesktopContext(), ]); }; Venmo.prototype._tokenizeWebLoginWithRedirect = function () { var self = this; var webLoginOptions; analytics.sendEventPlus( self._createPromise, "venmo.tokenize.web-login.start", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); this._tokenizePromise = new ExtendedPromise(); return this.getUrl().then(function (url) { webLoginOptions = { checkForStatusChange: self._checkPaymentContextStatusAndProcessResult.bind(self), cancelTokenization: self.cancelTokenization.bind(self), frameServiceInstance: self._frameServiceInstance, venmoUrl: url, debug: self._isDebug, checkPaymentContextStatus: self._checkPaymentContextStatus.bind(self), }; if (self._cspNonce) { webLoginOptions = assign({}, webLoginOptions, { styleCspNonce: self._cspNonce, }); } desktopWebLogin .runWebLogin(webLoginOptions) .then(function (payload) { analytics.sendEventPlus( self._createPromise, "venmo.tokenize.web-login.success", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); self._tokenizePromise.resolve({ paymentMethodNonce: payload.paymentMethodId, username: payload.userName, payerInfo: payload.payerInfo, id: self._venmoPaymentContextId, }); }) .catch(function (err) { analytics.sendEventPlus( self._createPromise, "venmo.tokenize.web-login.failure", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); self._tokenizePromise.reject(err); }); return self._tokenizePromise; }); }; Venmo.prototype._queryPaymentContextStatus = function (id) { var self = this; return this._createPromise .then(function (client) { var query = self._shouldUseLegacyFlow ? graphqlQueries.LEGACY_VENMO_PAYMENT_CONTEXT_STATUS_QUERY : graphqlQueries.VENMO_PAYMENT_CONTEXT_STATUS_QUERY; return client.request({ api: "graphQLApi", data: { query: query, variables: { id: id, }, }, }); }) .then(function (response) { return response.data.node; }); }; /** * Queries the GraphQL API to get the payment context and process the status. Retries until there is an update to the payment context status. * @name Venmo~checkPaymentContextStatusAndProcessResult * @ignore * @param {number} retryCount The counter for tracking number of retries made against the API. * @returns {Promise} Returns a promise */ Venmo.prototype._checkPaymentContextStatusAndProcessResult = function ( retryCount ) { var self = this; return self._checkPaymentContextStatus().then(function (node) { var resultStatus = node.status; if (resultStatus !== self._venmoPaymentContextStatus) { self._venmoPaymentContextStatus = resultStatus; analytics.sendEventPlus( self._createPromise, "venmo.tokenize.web-login.status-change", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); switch (resultStatus) { case "APPROVED": return Promise.resolve(node); case "CANCELED": return Promise.reject( new BraintreeError(errors.VENMO_CUSTOMER_CANCELED) ); case "FAILED": return Promise.reject( new BraintreeError(errors.VENMO_TOKENIZATION_FAILED) ); default: } } return new Promise(function (resolve, reject) { if (retryCount < self._maxRetryCount) { retryCount++; self ._checkPaymentContextStatusAndProcessResult(retryCount) .then(resolve) .catch(reject); } else { reject(new BraintreeError(errors.VENMO_TOKENIZATION_FAILED)); } }); }); }; Venmo.prototype._checkPaymentContextStatus = function () { var self = this; return self ._queryPaymentContextStatus(self._venmoPaymentContextId) .catch(function (networkError) { return Promise.reject( new BraintreeError({ type: errors.VENMO_NETWORK_ERROR.type, code: errors.VENMO_NETWORK_ERROR.code, message: errors.VENMO_NETWORK_ERROR.message, details: networkError, }) ); }) .then(function (node) { return Promise.resolve(node); }); }; Venmo.prototype._validatePollingContext = function () { if (!this._venmoPaymentContextId) { return Promise.reject( new BraintreeError(errors.VENMO_MOBILE_POLLING_TOKENIZATION_NO_CONTEXT_ID) ); } if (Date.now() > this._mobilePollingContextExpiresIn) { return Promise.reject( new BraintreeError(errors.VENMO_MOBILE_POLLING_TOKENIZATION_TIMEOUT) ); } return null; }; Venmo.prototype._handleWindowClosure = function () { var self = this; if ( self._venmoWindow && self._venmoWindow.closed && self._venmoPaymentContextStatus === "CREATED" ) { analytics.sendEventPlus( self._createPromise, "venmo.appswitch.browser-window.closed", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); self ._cancelMobilePaymentContext() .then(function () { analytics.sendEventPlus( self._createPromise, "venmo.tokenize.manual-return.canceled", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); }) .catch(function (_err) { analytics.sendEventPlus( self._createPromise, "venmo.tokenize.manual-return.canceled.error", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); }); return Promise.reject( new BraintreeError(errors.VENMO_MOBILE_POLLING_TOKENIZATION_CANCELED) ); } return null; }; Venmo.prototype._handleCancelOnReturn = function () { var self = this; var minPollsBeforeCancel, params; if (!self._cancelOnReturnToBrowser) { return null; } minPollsBeforeCancel = Math.ceil( constants.DEFAULT_PROCESS_RESULTS_DELAY / self._mobilePollingInterval ); params = getFragmentParameters(); self._pollCount++; if ( typeof (params.venmoSuccess || params.venmoError || params.venmoCancel) === "undefined" && self._venmoPaymentContextStatus === "CREATED" && self._pollCount >= minPollsBeforeCancel ) { analytics.sendEventPlus( self._createPromise, "venmo.appswitch.cancel-on-return-to-browser", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); return self ._cancelMobilePaymentContext() .then(function () { analytics.sendEventPlus( self._createPromise, "venmo.appswitch.cancel-on-return-to-browser.success", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); }) .catch(function () { analytics.sendEventPlus( self._createPromise, "venmo.appswitch.cancel-on-return-to-browser.error", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); }) .finally(function () { self._pollCount = 0; }); } return null; }; Venmo.prototype._handleStatusChange = function (node) { var self = this; var newStatus = node.status; if (newStatus !== self._venmoPaymentContextStatus) { self._venmoPaymentContextStatus = newStatus; analytics.sendEventPlus( self._createPromise, "venmo.tokenize.manual-return.status-change." + newStatus.toLowerCase(), { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); switch (newStatus) { case "EXPIRED": case "FAILED": case "CANCELED": return Promise.reject( new BraintreeError( errors["VENMO_MOBILE_POLLING_TOKENIZATION_" + newStatus] ) ); case "APPROVED": return Promise.resolve(node); case "CREATED": case "SCANNED": default: // any other statuses are irrelevant to the polling // and can just be ignored } } return self._continuePolling(); }; Venmo.prototype._continuePolling = function () { var self = this; return new Promise(function (resolve, reject) { setTimeout(function () { self._pollForStatusChange().then(resolve).catch(reject); }, self._mobilePollingInterval); }); }; Venmo.prototype._queryAndProcessStatus = function () { var self = this; return this._queryPaymentContextStatus(this._venmoPaymentContextId) .catch(function (networkError) { return Promise.reject( new BraintreeError({ type: errors.VENMO_MOBILE_POLLING_TOKENIZATION_NETWORK_ERROR.type, code: errors.VENMO_MOBILE_POLLING_TOKENIZATION_NETWORK_ERROR.code, message: errors.VENMO_MOBILE_POLLING_TOKENIZATION_NETWORK_ERROR.message, details: { originalError: networkError, }, }) ); }) .then(function (node) { return self._handleStatusChange(node); }); }; Venmo.prototype._pollForStatusChange = function () { var validationError = this._validatePollingContext(); if (validationError) { return validationError; } var windowClosureResult = this._handleWindowClosure(); if (windowClosureResult) { return windowClosureResult; } // This function might cancel the payment context. We will still rely on // _queryAndProcessStatus to see that the status has changed to CANCELED. this._handleCancelOnReturn(); return this._queryAndProcessStatus(); }; Venmo.prototype._tokenizeForMobileWithManualReturn = function () { var self = this; analytics.sendEventPlus( this._createPromise, "venmo.tokenize.manual-return.start", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); this._mobilePollingContextExpiresIn = Date.now() + this._mobilePollingExpiresThreshold; this._tokenizePromise = new ExtendedPromise(); this._pollForStatusChange() .then(function (payload) { analytics.sendEventPlus( self._createPromise, "venmo.tokenize.manual-return.success", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); self._tokenizePromise.resolve({ paymentMethodNonce: payload.paymentMethodId, username: payload.userName, payerInfo: payload.payerInfo, id: self._venmoPaymentContextId, }); }) .catch(function (err) { analytics.sendEventPlus( self._createPromise, "venmo.tokenize.manual-return.failure", { context_id: self._venmoPaymentContextId, // eslint-disable-line camelcase } ); self._tokenizePromise.reject(err); }); return this.getUrl().then(function (url) { self.appSwitch(url); return self._tokenizePromise; }); }; Venmo.prototype._shouldUseRedirectStrategy = function () { if (!browserDetection.isIos()) { return false; } if (this._mobileWebFallBack === true) { return true; } return this._useRedirectForIOS; }; Venmo.prototype._tokenizeForMobileWithHashChangeListeners = function (options) { var self = this; var resultProcessingInProgress, visibilityChangeListenerTimeout; if (this.hasTokenizationResult()) { return this.processHashChangeFlowResults(); } analytics.sendEvent(this._createPromise, "venmo.tokenize.mobile.start"); this._tokenizePromise = new ExtendedPromise(); this._previousHash = window.location.hash; function completeFlow(hash) { var error; self .processHashChangeFlowResults(hash) .catch(function (err) { error = err; }) .then(function (res) { if ( !self._ignoreHistoryChanges && window.location.hash !== self._previousHash ) { window.location.hash = self._previousHash; } self._removeVisibilityEventListener(); if (error) { self._tokenizePromise.reject(error); } else { self._tokenizePromise.resolve(res); } delete self._tokenizePromise; }); } // The Venmo SDK app switches back with the results of the // tokenization encoded in the hash this._onHashChangeListener = function (e) { var hash = e.newURL.split("#")[1]; if (!self._hasTokenizationResult(hash)) { return; } resultProcessingInProgress = true; clearTimeout(visibilityChangeListenerTimeout); completeFlow(hash); }; window.addEventListener("hashchange", this._onHashChangeListener, false); // Subscribe to document visibility change events to detect when app switch // has returned. Acts as a fallback for the hashchange listener and catches // the cancel case via manual app switch back this._visibilityChangeListener = function () { var delay = options.processResultsDelay || constants.DEFAULT_PROCESS_RESULTS_DELAY; if (!window.document.hidden) { if (self._venmoWindow && !self._venmoWindow.closed) { self._venmoWindow.close(); } if (!resultProcessingInProgress) { visibilityChangeListenerTimeout = setTimeout(completeFlow, delay); } } }; return this.getUrl().then(function (url) { self.appSwitch(url); // Add a brief delay to ignore visibility change events that occur right before app switch setTimeout(function () { window.document.addEventListener( documentVisibilityChangeEventName(), self._visibilityChangeListener ); }, constants.DOCUMENT_VISIBILITY_CHANGE_EVENT_DELAY); return self._tokenizePromise; }); }; Venmo.prototype._tokenizeForDesktopQRFlow = function () { var self = this; analytics.sendEvent(this._createPromise, "venmo.tokenize.desktop.start"); this._tokenizePromise = new ExtendedPromise(); this._createPromise .then(function () { return self._venmoDesktopInstance.launchDesktopFlow(); }) .then(function (payload) { self._venmoDesktopInstance.hideDesktopFlow(); analytics.sendEvent( self._createPromise, "venmo.tokenize.desktop.success" ); self._tokenizePromise.resolve(payload); }) .catch(function (err) { analytics.sendEvent( self._createPromise, "venmo.tokenize.desktop.failure" ); if (self._venmoDesktopInstance) { self._venmoDesktopInstance.hideDesktopFlow(); } if (err && err.reason === "CUSTOMER_CANCELED") { self._tokenizePromise.reject( new BraintreeError(errors.VENMO_DESKTOP_CANCELED) ); return; } self._tokenizePromise.reject( new BraintreeError({ type: errors.VENMO_DESKTOP_UNKNOWN_ERROR.type, code: errors.VENMO_DESKTOP_UNKNOWN_ERROR.code, message: errors.VENMO_DESKTOP_UNKNOWN_ERROR.message, details: { originalError: err, }, }) ); }); return this._tokenizePromise; }; Venmo.prototype._cancelMobilePaymentContext = function () { var self = this; return this._createPromise.then(function (client) { var query; if (self._venmoPaymentContextId) { query = self._shouldUseLegacyFlow ? graphqlQueries.LEGACY_UPDATE_PAYMENT_CONTEXT_QUERY : graphqlQueries.UPDATE_PAYMENT_CONTEXT_QUERY; return client.request({ api: "graphQLApi", data: { query: query, variables: { input: { id: self._venmoPaymentContextId, status: "CANCELED", }, }, }, }); } return Promise.resolve(); }); }; Venmo.prototype._cancelVenmoDesktopContext = function () { var self = this; return this._createPromise.then(function () { if (self._venmoDesktopInstance) { self._venmoDesktopInstance.updateVenmoDesktopPaymentContext("CANCELED"); } return Promise.resolve(); }); }; /** * Cleanly tear down anything set up by {@link module:braintree-web/venmo.create|create}. * @public * @param {callback} [callback] Called once teardown is complete. No data is returned if teardown completes successfully. * @example * venmoInstance.teardown(); * @example <caption>With callback</caption> * venmoInstance.teardown(function () { * // teardown is complete * }); * @returns {(Promise|void)} Returns a promise if no callback is provided. */ Venmo.prototype.teardown = function () { var self = this; this._removeVisibilityEventListener(); return this._createPromise.then( function () { if (self._venmoDesktopInstance) { self._venmoDesktopInstance.teardown(); } clearTimeout(self._refreshPaymentContextTimeout); self._cancelMobilePaymentContext(); convertMethodsToError(this,