masterpass/external/masterpass.js

'use strict';

var Promise = require('../../lib/promise');
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 (global.popupBridge && typeof global.popupBridge.getReturnUrlPrefix === 'function') {
    this._callbackUrl = global.popupBridge.getReturnUrlPrefix() + 'return';
  } else {
    this._callbackUrl = this._assetsUrl + '/html/masterpass-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 baed 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: global.location.protocol + '//' + global.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 (global.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 === 'FRAME_SERVICE_FRAME_OPEN_FAILED') {
        analytics.sendEvent(self._client, 'masterpass.tokenization.failed.to-open');
        reject(new BraintreeError(errors.MASTERPASS_POPUP_OPEN_FAILED));

        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 (global.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 (global.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);