unionpay/shared/unionpay.js

'use strict';

var analytics = require('../../lib/analytics');
var BraintreeError = require('../../lib/braintree-error');
var Bus = require('../../lib/bus');
var constants = require('./constants');
var useMin = require('../../lib/use-min');
var convertMethodsToError = require('../../lib/convert-methods-to-error');
var errors = require('./errors');
var events = constants.events;
var iFramer = require('@braintree/iframer');
var methods = require('../../lib/methods');
var VERSION = process.env.npm_package_version;
var uuid = require('../../lib/vendor/uuid');
var Promise = require('../../lib/promise');
var wrapPromise = require('@braintree/wrap-promise');

/**
 * @class
 * @param {object} options See {@link module:braintree-web/unionpay.create|unionpay.create}.
 * @description <strong>You cannot use this constructor directly. Use {@link module:braintree-web/unionpay.create|braintree-web.unionpay.create} instead.</strong>
 * @classdesc This class represents a UnionPay component. Instances of this class have methods for {@link UnionPay#fetchCapabilities fetching capabilities} of UnionPay cards, {@link UnionPay#enroll enrolling} a UnionPay card, and {@link UnionPay#tokenize tokenizing} a UnionPay card.
 */
function UnionPay(options) {
  this._options = options;
}

/**
 * @typedef {object} UnionPay~fetchCapabilitiesPayload
 * @property {boolean} isUnionPay Determines if this card is a UnionPay card.
 * @property {boolean} isDebit Determines if this card is a debit card. This property is only present if `isUnionPay` is `true`.
 * @property {object} unionPay UnionPay specific properties. This property is only present if `isUnionPay` is `true`.
 * @property {boolean} unionPay.supportsTwoStepAuthAndCapture Determines if the card allows for an authorization, but settling the transaction later.
 * @property {boolean} unionPay.isSupported Determines if Braintree can process this UnionPay card. When false, Braintree cannot process this card and the user should use a different card.
 */

/**
 * Fetches the capabilities of a card, including whether or not the SMS enrollment process is required.
 * @public
 * @param {object} options UnionPay {@link UnionPay#fetchCapabilities fetchCapabilities} options
 * @param {object} [options.card] The card from which to fetch capabilities. Note that this will only have one property, `number`. Required if you are not using the `hostedFields` option.
 * @param {string} options.card.number Card number.
 * @param {HostedFields} [options.hostedFields] The Hosted Fields instance used to collect card data. Required if you are not using the `card` option.
 * @param {callback} [callback] The second argument, <code>data</code>, is a {@link UnionPay#fetchCapabilitiesPayload fetchCapabilitiesPayload}. If no callback is provided, `fetchCapabilities` returns a promise that resolves with a {@link UnionPay#fetchCapabilitiesPayload fetchCapabilitiesPayload}.
 * @returns {Promise|void} Returns a promise if no callback is provided.
 */
UnionPay.prototype.fetchCapabilities = function (options) {
  var self = this;
  var client = this._options.client;
  var cardNumber = options.card ? options.card.number : null;
  var hostedFields = options.hostedFields;

  if (cardNumber && hostedFields) {
    return Promise.reject(new BraintreeError(errors.UNIONPAY_CARD_AND_HOSTED_FIELDS_INSTANCES));
  } else if (cardNumber) {
    return client.request({
      method: 'get',
      endpoint: 'payment_methods/credit_cards/capabilities',
      data: {
        _meta: {source: 'unionpay'},
        creditCard: {
          number: cardNumber
        }
      }
    }).then(function (response) {
      analytics.sendEvent(client, 'unionpay.capabilities-received');

      return response;
    }).catch(function (err) {
      var status = err.details && err.details.httpStatus;

      analytics.sendEvent(client, 'unionpay.capabilities-failed');

      if (status === 403) {
        return Promise.reject(err);
      }

      return Promise.reject(new BraintreeError({
        type: errors.UNIONPAY_FETCH_CAPABILITIES_NETWORK_ERROR.type,
        code: errors.UNIONPAY_FETCH_CAPABILITIES_NETWORK_ERROR.code,
        message: errors.UNIONPAY_FETCH_CAPABILITIES_NETWORK_ERROR.message,
        details: {
          originalError: err
        }
      }));
    });
  } else if (hostedFields) {
    if (!hostedFields._bus) {
      return Promise.reject(new BraintreeError(errors.UNIONPAY_HOSTED_FIELDS_INSTANCE_INVALID));
    }

    return new Promise(function (resolve, reject) {
      self._initializeHostedFields(function () {
        self._bus.emit(events.HOSTED_FIELDS_FETCH_CAPABILITIES, {hostedFields: hostedFields}, function (response) {
          if (response.err) {
            reject(new BraintreeError(response.err));

            return;
          }

          resolve(response.payload);
        });
      });
    });
  }

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

/**
 * @typedef {object} UnionPay~enrollPayload
 * @property {string} enrollmentId UnionPay enrollment ID. This value should be passed to `tokenize`.
 * @property {boolean} smsCodeRequired UnionPay `smsCodeRequired` flag.
 * </p><b>true</b> - the user will receive an SMS code that needs to be supplied for tokenization.
 * </p><b>false</b> - the card can be immediately tokenized.
 */

/**
 * Enrolls a UnionPay card. Use {@link UnionPay#fetchCapabilities|fetchCapabilities} to determine if the SMS enrollment process is required.
 * @public
 * @param {object} options UnionPay enrollment options:
 * @param {object} [options.card] The card to enroll. Required if you are not using the `hostedFields` option.
 * @param {string} options.card.number The card number.
 * @param {string} [options.card.expirationDate] The card's expiration date. May be in the form `MM/YY` or `MM/YYYY`. When defined `expirationMonth` and `expirationYear` are ignored.
 * @param {string} [options.card.expirationMonth] The card's expiration month. This should be used with the `expirationYear` parameter. When `expirationDate` is defined this parameter is ignored.
 * @param {string} [options.card.expirationYear] The card's expiration year. This should be used with the `expirationMonth` parameter. When `expirationDate` is defined this parameter is ignored.
 * @param {HostedFields} [options.hostedFields] The Hosted Fields instance used to collect card data. Required if you are not using the `card` option.
 * @param {object} options.mobile The mobile information collected from the customer.
 * @param {string} options.mobile.countryCode The country code of the customer's mobile phone number.
 * @param {string} options.mobile.number The customer's mobile phone number.
 * @param {callback} [callback] The second argument, <code>data</code>, is a {@link UnionPay~enrollPayload|enrollPayload}. If no callback is provided, `enroll` returns a promise that resolves with {@link UnionPay~enrollPayload|enrollPayload}.
 * @returns {void}
 */
UnionPay.prototype.enroll = function (options) {
  var self = this;
  var client = this._options.client;
  var card = options.card;
  var mobile = options.mobile;
  var hostedFields = options.hostedFields;
  var data;

  if (!mobile) {
    return Promise.reject(new BraintreeError(errors.UNIONPAY_MISSING_MOBILE_PHONE_DATA));
  }

  if (hostedFields) {
    if (!hostedFields._bus) {
      return Promise.reject(new BraintreeError(errors.UNIONPAY_HOSTED_FIELDS_INSTANCE_INVALID));
    } else if (card) {
      return Promise.reject(new BraintreeError(errors.UNIONPAY_CARD_AND_HOSTED_FIELDS_INSTANCES));
    }

    return new Promise(function (resolve, reject) {
      self._initializeHostedFields(function () {
        self._bus.emit(events.HOSTED_FIELDS_ENROLL, {hostedFields: hostedFields, mobile: mobile}, function (response) {
          if (response.err) {
            reject(new BraintreeError(response.err));

            return;
          }

          resolve(response.payload);
        });
      });
    });
  } else if (card && card.number) {
    data = {
      _meta: {source: 'unionpay'},
      unionPayEnrollment: {
        number: card.number,
        mobileCountryCode: mobile.countryCode,
        mobileNumber: mobile.number
      }
    };

    if (card.expirationDate) {
      data.unionPayEnrollment.expirationDate = card.expirationDate;
    } else if (card.expirationMonth || card.expirationYear) {
      if (card.expirationMonth && card.expirationYear) {
        data.unionPayEnrollment.expirationYear = card.expirationYear;
        data.unionPayEnrollment.expirationMonth = card.expirationMonth;
      } else {
        return Promise.reject(new BraintreeError(errors.UNIONPAY_EXPIRATION_DATE_INCOMPLETE));
      }
    }

    return client.request({
      method: 'post',
      endpoint: 'union_pay_enrollments',
      data: data
    }).then(function (response) {
      analytics.sendEvent(client, 'unionpay.enrollment-succeeded');

      return {
        enrollmentId: response.unionPayEnrollmentId,
        smsCodeRequired: response.smsCodeRequired
      };
    }).catch(function (err) {
      var error;
      var status = err.details && err.details.httpStatus;

      if (status === 403) {
        error = err;
      } else if (status < 500) {
        error = new BraintreeError(errors.UNIONPAY_ENROLLMENT_CUSTOMER_INPUT_INVALID);
        error.details = {originalError: err};
      } else {
        error = new BraintreeError(errors.UNIONPAY_ENROLLMENT_NETWORK_ERROR);
        error.details = {originalError: err};
      }

      analytics.sendEvent(client, 'unionpay.enrollment-failed');

      return Promise.reject(error);
    });
  }

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

/**
 * @typedef {object} UnionPay~tokenizePayload
 * @property {string} nonce The payment method nonce.
 * @property {string} type Always <code>CreditCard</code>.
 * @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 {string} description A human-readable description.
 */

/**
 * Tokenizes a UnionPay card and returns a nonce payload.
 * @public
 * @param {object} options UnionPay tokenization options:
 * @param {object} [options.card] The card to enroll. Required if you are not using the `hostedFields` option.
 * @param {string} options.card.number The card number.
 * @param {string} [options.card.expirationDate] The card's expiration date. May be in the form `MM/YY` or `MM/YYYY`. When defined `expirationMonth` and `expirationYear` are ignored.
 * @param {string} [options.card.expirationMonth] The card's expiration month. This should be used with the `expirationYear` parameter. When `expirationDate` is defined this parameter is ignored.
 * @param {string} [options.card.expirationYear] The card's expiration year. This should be used with the `expirationMonth` parameter. When `expirationDate` is defined this parameter is ignored.
 * @param {string} [options.card.cvv] The card's security number.
 * @param {HostedFields} [options.hostedFields] The Hosted Fields instance used to collect card data. Required if you are not using the `card` option.
 * @param {string} options.enrollmentId The enrollment ID from {@link UnionPay#enroll}.
 * @param {string} [options.smsCode] The SMS code received from the user if {@link UnionPay#enroll} payload have `smsCodeRequired`. if `smsCodeRequired` is false, smsCode should not be passed.
 * @param {callback} [callback] The second argument, <code>data</code>, is a {@link UnionPay~tokenizePayload|tokenizePayload}. If no callback is provided, `tokenize` returns a promise that resolves with a {@link UnionPay~tokenizePayload|tokenizePayload}.
 * @returns {Promise|void} Returns a promise if no callback is provided.
 */
UnionPay.prototype.tokenize = function (options) {
  var data;
  var self = this;
  var client = this._options.client;
  var card = options.card;
  var hostedFields = options.hostedFields;

  if (card && hostedFields) {
    return Promise.reject(new BraintreeError(errors.UNIONPAY_CARD_AND_HOSTED_FIELDS_INSTANCES));
  } else if (card) {
    data = {
      _meta: {source: 'unionpay'},
      creditCard: {
        number: options.card.number,
        options: {
          unionPayEnrollment: {
            id: options.enrollmentId
          }
        }
      }
    };

    if (options.smsCode) {
      data.creditCard.options.unionPayEnrollment.smsCode = options.smsCode;
    }

    if (card.expirationDate) {
      data.creditCard.expirationDate = card.expirationDate;
    } else if (card.expirationMonth && card.expirationYear) {
      data.creditCard.expirationYear = card.expirationYear;
      data.creditCard.expirationMonth = card.expirationMonth;
    }

    if (options.card.cvv) {
      data.creditCard.cvv = options.card.cvv;
    }

    return client.request({
      method: 'post',
      endpoint: 'payment_methods/credit_cards',
      data: data
    }).then(function (response) {
      var tokenizedCard = response.creditCards[0];

      delete tokenizedCard.consumed;
      delete tokenizedCard.threeDSecureInfo;

      analytics.sendEvent(client, 'unionpay.nonce-received');

      return tokenizedCard;
    }).catch(function (err) {
      var error;
      var status = err.details && err.details.httpStatus;

      analytics.sendEvent(client, 'unionpay.nonce-failed');

      if (status === 403) {
        error = err;
      } else if (status < 500) {
        error = new BraintreeError(errors.UNIONPAY_FAILED_TOKENIZATION);
        error.details = {originalError: err};
      } else {
        error = new BraintreeError(errors.UNIONPAY_TOKENIZATION_NETWORK_ERROR);
        error.details = {originalError: err};
      }

      return Promise.reject(error);
    });
  } else if (hostedFields) {
    if (!hostedFields._bus) {
      return Promise.reject(new BraintreeError(errors.UNIONPAY_HOSTED_FIELDS_INSTANCE_INVALID));
    }

    return new Promise(function (resolve, reject) {
      self._initializeHostedFields(function () {
        self._bus.emit(events.HOSTED_FIELDS_TOKENIZE, options, function (response) {
          if (response.err) {
            reject(new BraintreeError(response.err));

            return;
          }

          resolve(response.payload);
        });
      });
    });
  }

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

/**
 * Cleanly remove anything set up by {@link module:braintree-web/unionpay.create|create}. This only needs to be called when using UnionPay with Hosted Fields.
 * @public
 * @param {callback} [callback] Called on completion. If no callback is provided, returns a promise.
 * @example
 * unionpayInstance.teardown();
 * @returns {Promise|void} Returns a promise if no callback is provided.
 */
UnionPay.prototype.teardown = function () {
  if (this._bus) {
    this._hostedFieldsFrame.parentNode.removeChild(this._hostedFieldsFrame);
    this._bus.teardown();
  }

  convertMethodsToError(this, methods(UnionPay.prototype));

  return Promise.resolve();
};

UnionPay.prototype._initializeHostedFields = function (callback) {
  var assetsUrl, isDebug;
  var componentId = uuid();

  if (this._bus) {
    callback();

    return;
  }

  assetsUrl = this._options.client.getConfiguration().gatewayConfiguration.assetsUrl;
  isDebug = this._options.client.getConfiguration().isDebug;

  this._bus = new Bus({
    channel: componentId,
    merchantUrl: location.href
  });
  this._hostedFieldsFrame = iFramer({
    name: constants.HOSTED_FIELDS_FRAME_NAME + '_' + componentId,
    src: assetsUrl + '/web/' + VERSION + '/html/unionpay-hosted-fields-frame' + useMin(isDebug) + '.html',
    height: 0,
    width: 0
  });

  this._bus.on(Bus.events.CONFIGURATION_REQUEST, function (reply) {
    reply(this._options.client);

    callback();
  }.bind(this));

  document.body.appendChild(this._hostedFieldsFrame);
};

module.exports = wrapPromise.wrapPrototype(UnionPay);