masterpass/external/masterpass.js

"use strict";

var frameService = require("../../lib/frame-service/external");
var BraintreeError = require("../../lib/braintree-error");
var errors = require("../shared/errors");
var VERSION = process.env.npm_package_version;
var methods = require("../../lib/methods");
var wrapPromise = require("@braintree/wrap-promise");
var analytics = require("../../lib/analytics");
var convertMethodsToError = require("../../lib/convert-methods-to-error");
var convertToBraintreeError = require("../../lib/convert-to-braintree-error");
var constants = require("../shared/constants");

var INTEGRATION_TIMEOUT_MS =
  require("../../lib/constants").INTEGRATION_TIMEOUT_MS;

/**
 * Masterpass Address object.
 * @typedef {object} Masterpass~Address
 * @property {string} countryCodeAlpha2 The customer's country code.
 * @property {string} extendedAddress The customer's extended address.
 * @property {string} locality The customer's locality.
 * @property {string} postalCode The customer's postal code.
 * @property {string} region The customer's region.
 * @property {string} streetAddress The customer's street address.
 */

/**
 * @typedef {object} Masterpass~tokenizePayload
 * @property {string} nonce The payment method nonce.
 * @property {string} description The human readable description.
 * @property {string} type The payment method type, always `MasterpassCard`.
 * @property {object} details Additional account details.
 * @property {string} details.cardType Type of card, ex: Visa, MasterCard.
 * @property {string} details.lastFour Last four digits of card number.
 * @property {string} details.lastTwo Last two digits of card number.
 * @property {object} contact The customer's contact information.
 * @property {string} contact.firstName The customer's first name.
 * @property {string} contact.lastName The customer's last name.
 * @property {string} contact.phoneNumber The customer's phone number.
 * @property {string} contact.emailAddress The customer's email address.
 * @property {Masterpass~Address} billingAddress The customer's billing address.
 * @property {Masterpass~Address} shippingAddress The customer's shipping address.
 * @property {object} binData Information about the card based on the bin.
 * @property {string} binData.commercial Possible values: 'Yes', 'No', 'Unknown'.
 * @property {string} binData.countryOfIssuance The country of issuance.
 * @property {string} binData.debit Possible values: 'Yes', 'No', 'Unknown'.
 * @property {string} binData.durbinRegulated Possible values: 'Yes', 'No', 'Unknown'.
 * @property {string} binData.healthcare Possible values: 'Yes', 'No', 'Unknown'.
 * @property {string} binData.issuingBank The issuing bank.
 * @property {string} binData.payroll Possible values: 'Yes', 'No', 'Unknown'.
 * @property {string} binData.prepaid Possible values: 'Yes', 'No', 'Unknown'.
 * @property {string} binData.productId The product id.
 */

/**
 * @class
 * @param {object} options see {@link module:braintree-web/masterpass.create|masterpass.create}
 * @description <strong>You cannot use this constructor directly. Use {@link module:braintree-web/masterpass.create|braintree.masterpass.create} instead.</strong>
 * @classdesc This class represents an Masterpass component. Instances of this class have methods for launching a new window to process a transaction with Masterpass.
 */
function Masterpass(options) {
  var configuration = options.client.getConfiguration();

  this._client = options.client;
  this._assetsUrl =
    configuration.gatewayConfiguration.assetsUrl + "/web/" + VERSION;
  this._isDebug = configuration.isDebug;
  this._authInProgress = false;
  if (
    window.popupBridge &&
    typeof window.popupBridge.getReturnUrlPrefix === "function"
  ) {
    this._callbackUrl = window.popupBridge.getReturnUrlPrefix() + "return";
  } else {
    this._callbackUrl =
      this._assetsUrl +
      "/html/redirect-frame" +
      (this._isDebug ? "" : ".min") +
      ".html";
  }
}

Masterpass.prototype._initialize = function () {
  var self = this;

  return new Promise(function (resolve) {
    var failureTimeout = setTimeout(function () {
      analytics.sendEvent(self._client, "masterpass.load.timed-out");
    }, INTEGRATION_TIMEOUT_MS);

    frameService.create(
      {
        name: constants.LANDING_FRAME_NAME,
        height: constants.POPUP_HEIGHT,
        width: constants.POPUP_WIDTH,
        dispatchFrameUrl:
          self._assetsUrl +
          "/html/dispatch-frame" +
          (self._isDebug ? "" : ".min") +
          ".html",
        openFrameUrl:
          self._assetsUrl +
          "/html/masterpass-landing-frame" +
          (self._isDebug ? "" : ".min") +
          ".html",
      },
      function (service) {
        self._frameService = service;
        clearTimeout(failureTimeout);
        analytics.sendEvent(self._client, "masterpass.load.succeeded");
        resolve(self);
      }
    );
  });
};

/**
 * Launches the Masterpass flow and returns a nonce payload. Only one Masterpass flow should be active at a time. One way to achieve this is to disable your Masterpass button while the flow is open.
 *
 * Braintree will apply these properties in `options.config`. Merchants should not override these values, except for advanced usage.
 *  - `environment`
 *  - `requestToken`
 *  - `callbackUrl`
 *  - `merchantCheckoutId`
 *  - `allowedCardTypes`
 *  - `version`
 *
 * @public
 * @param {object} options All options for initiating the Masterpass payment flow.
 * @param {string} options.currencyCode The currency code to process the payment.
 * @param {string} options.subtotal The amount to authorize for the transaction.
 * @param {object} [options.config] All configuration parameters accepted by Masterpass lightbox, except `function` data type. These options will override the values set by Braintree server. Please see {@link Masterpass Lightbox Parameters|https://developer.mastercard.com/page/masterpass-lightbox-parameters} for more information.
 * @param {object} [options.frameOptions] Used to configure the window that contains the Masterpass login.
 * @param {number} [options.frameOptions.width] Popup width to be used instead of default value (450px).
 * @param {number} [options.frameOptions.height] Popup height to be used instead of default value (660px).
 * @param {number} [options.frameOptions.top] The top position of the popup window to be used instead of default value, that is calculated based on provided height, and parent window size.
 * @param {number} [options.frameOptions.left] The left position to the popup window to be used instead of default value, that is calculated based on provided width, and parent window size.
 * @param {callback} [callback] The second argument, <code>data</code>, is a {@link Masterpass~tokenizePayload|tokenizePayload}. If no callback is provided, the method will return a Promise that resolves with a {@link Masterpass~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.
 *   masterpassInstance.tokenize({
 *     currencyCode: 'USD',
 *     subtotal: '10.00'
 *   }).then(function (payload) {
 *     button.removeAttribute('disabled');
 *     // Submit payload.nonce to your server
 *   }).catch(function (tokenizeError) {
 *     button.removeAttribute('disabled');
 *     // Handle flow errors or premature flow closure
 *
 *     switch (tokenizeErr.code) {
 *       case 'MASTERPASS_POPUP_CLOSED':
 *         console.error('Customer closed Masterpass popup.');
 *         break;
 *       case 'MASTERPASS_ACCOUNT_TOKENIZATION_FAILED':
 *         console.error('Masterpass tokenization failed. See details:', tokenizeErr.details);
 *         break;
 *       case 'MASTERPASS_FLOW_FAILED':
 *         console.error('Unable to initialize Masterpass flow. Are your options correct?', tokenizeErr.details);
 *         break;
 *       default:
 *         console.error('Error!', tokenizeErr);
 *     }
 *   });
 * });
 */
Masterpass.prototype.tokenize = function (options) {
  var self = this;

  if (!options || hasMissingOption(options)) {
    return Promise.reject(
      new BraintreeError(errors.MASTERPASS_TOKENIZE_MISSING_REQUIRED_OPTION)
    );
  }

  if (self._authInProgress) {
    return Promise.reject(
      new BraintreeError(errors.MASTERPASS_TOKENIZATION_ALREADY_IN_PROGRESS)
    );
  }

  return new Promise(function (resolve, reject) {
    self._navigateFrameToLoadingPage(options).catch(reject);
    // This MUST happen after _navigateFrameToLoadingPage for Metro browsers to work.
    self._frameService.open(
      options.frameOptions,
      self._createFrameOpenHandler(resolve, reject)
    );
  });
};

Masterpass.prototype._navigateFrameToLoadingPage = function (options) {
  var self = this;

  this._authInProgress = true;

  return this._client
    .request({
      method: "post",
      endpoint: "masterpass/request_token",
      data: {
        requestToken: {
          originUrl: window.location.protocol + "//" + window.location.hostname,
          subtotal: options.subtotal,
          currencyCode: options.currencyCode,
          callbackUrl: this._callbackUrl,
        },
      },
    })
    .then(function (response) {
      var redirectUrl =
        self._assetsUrl +
        "/html/masterpass-loading-frame" +
        (self._isDebug ? "" : ".min") +
        ".html?";
      var gatewayConfiguration =
        self._client.getConfiguration().gatewayConfiguration;
      var config = options.config || {};
      var queryParams;

      queryParams = {
        environment: gatewayConfiguration.environment,
        requestToken: response.requestToken,
        callbackUrl: self._callbackUrl,
        merchantCheckoutId: gatewayConfiguration.masterpass.merchantCheckoutId,
        allowedCardTypes: gatewayConfiguration.masterpass.supportedNetworks,
        version: constants.MASTERPASS_VERSION,
      };

      Object.keys(config).forEach(function (key) {
        if (typeof config[key] !== "function") {
          queryParams[key] = config[key];
        }
      });

      redirectUrl += Object.keys(queryParams)
        .map(function (key) {
          return key + "=" + queryParams[key];
        })
        .join("&");

      self._frameService.redirect(redirectUrl);
    })
    .catch(function (err) {
      var status = err.details && err.details.httpStatus;

      self._closeWindow();

      if (status === 422) {
        return Promise.reject(
          convertToBraintreeError(err, errors.MASTERPASS_INVALID_PAYMENT_OPTION)
        );
      }

      return Promise.reject(
        convertToBraintreeError(err, errors.MASTERPASS_FLOW_FAILED)
      );
    });
};

Masterpass.prototype._createFrameOpenHandler = function (resolve, reject) {
  var self = this;

  if (window.popupBridge) {
    return function (popupBridgeErr, payload) {
      self._authInProgress = false;

      if (popupBridgeErr) {
        analytics.sendEvent(
          self._client,
          "masterpass.tokenization.closed-popupbridge.by-user"
        );
        reject(
          convertToBraintreeError(
            popupBridgeErr,
            errors.MASTERPASS_POPUP_CLOSED
          )
        );

        return;
      } else if (!payload.queryItems) {
        analytics.sendEvent(
          self._client,
          "masterpass.tokenization.failed-popupbridge"
        );
        reject(new BraintreeError(errors.MASTERPASS_FLOW_FAILED));

        return;
      }

      self._tokenizeMasterpass(payload.queryItems).then(resolve).catch(reject);
    };
  }

  return function (frameServiceErr, payload) {
    if (frameServiceErr) {
      self._authInProgress = false;

      if (frameServiceErr.code === "FRAME_SERVICE_FRAME_CLOSED") {
        analytics.sendEvent(
          self._client,
          "masterpass.tokenization.closed.by-user"
        );
        reject(new BraintreeError(errors.MASTERPASS_POPUP_CLOSED));

        return;
      }

      if (
        frameServiceErr.code &&
        frameServiceErr.code.indexOf("FRAME_SERVICE_FRAME_OPEN_FAILED") > -1
      ) {
        analytics.sendEvent(
          self._client,
          "masterpass.tokenization.failed.to-open"
        );
        reject(
          new BraintreeError({
            code: errors.MASTERPASS_POPUP_OPEN_FAILED.code,
            type: errors.MASTERPASS_POPUP_OPEN_FAILED.type,
            message: errors.MASTERPASS_POPUP_OPEN_FAILED.message,
            details: {
              originalError: frameServiceErr,
            },
          })
        );

        return;
      }

      analytics.sendEvent(self._client, "masterpass.tokenization.failed");
      self._closeWindow();
      reject(
        convertToBraintreeError(frameServiceErr, errors.MASTERPASS_FLOW_FAILED)
      );

      return;
    }

    self._tokenizeMasterpass(payload).then(resolve).catch(reject);
  };
};

Masterpass.prototype._tokenizeMasterpass = function (payload) {
  var self = this;

  if (payload.mpstatus !== "success") {
    analytics.sendEvent(self._client, "masterpass.tokenization.closed.by-user");
    self._closeWindow();

    return Promise.reject(new BraintreeError(errors.MASTERPASS_POPUP_CLOSED));
  }

  if (isMissingRequiredPayload(payload)) {
    analytics.sendEvent(
      self._client,
      "masterpass.tokenization.closed.missing-payload"
    );
    self._closeWindow();

    return Promise.reject(
      new BraintreeError(errors.MASTERPASS_POPUP_MISSING_REQUIRED_PARAMETERS)
    );
  }

  return self._client
    .request({
      endpoint: "payment_methods/masterpass_cards",
      method: "post",
      data: {
        masterpassCard: {
          checkoutResourceUrl: payload.checkout_resource_url,
          requestToken: payload.oauth_token,
          verifierToken: payload.oauth_verifier,
        },
      },
    })
    .then(function (response) {
      self._closeWindow();
      if (window.popupBridge) {
        analytics.sendEvent(
          self._client,
          "masterpass.tokenization.success-popupbridge"
        );
      } else {
        analytics.sendEvent(self._client, "masterpass.tokenization.success");
      }

      return response.masterpassCards[0];
    })
    .catch(function (tokenizeErr) {
      self._closeWindow();
      if (window.popupBridge) {
        analytics.sendEvent(
          self._client,
          "masterpass.tokenization.failed-popupbridge"
        );
      } else {
        analytics.sendEvent(self._client, "masterpass.tokenization.failed");
      }

      return Promise.reject(
        convertToBraintreeError(
          tokenizeErr,
          errors.MASTERPASS_ACCOUNT_TOKENIZATION_FAILED
        )
      );
    });
};

function isMissingRequiredPayload(payload) {
  return [
    payload.oauth_verifier,
    payload.oauth_token,
    payload.checkout_resource_url,
  ].some(function (element) {
    return element == null || element === "null";
  });
}

Masterpass.prototype._closeWindow = function () {
  this._authInProgress = false;
  this._frameService.close();
};

/**
 * Cleanly tear down anything set up by {@link module:braintree-web/masterpass.create|create}.
 * @public
 * @param {callback} [callback] Called on completion. If no callback is provided, `teardown` returns a promise.
 * @example
 * masterpassInstance.teardown();
 * @example <caption>With callback</caption>
 * masterpassInstance.teardown(function () {
 *   // teardown is complete
 * });
 * @returns {(Promise|void)} Returns a promise if no callback is provided.
 */
Masterpass.prototype.teardown = function () {
  var self = this;

  return new Promise(function (resolve) {
    self._frameService.teardown();

    convertMethodsToError(self, methods(Masterpass.prototype));

    analytics.sendEvent(self._client, "masterpass.teardown-completed");

    resolve();
  });
};

function hasMissingOption(options) {
  var i, option;

  for (i = 0; i < constants.REQUIRED_OPTIONS_FOR_TOKENIZE.length; i++) {
    option = constants.REQUIRED_OPTIONS_FOR_TOKENIZE[i];

    if (!options.hasOwnProperty(option)) {
      return true;
    }
  }

  return false;
}

module.exports = wrapPromise.wrapPrototype(Masterpass);