us-bank-account/us-bank-account.js

"use strict";

var BraintreeError = require("../lib/braintree-error");
var constants = require("./constants");
var errors = require("./errors");
var sharedErrors = require("../lib/errors");
var analytics = require("../lib/analytics");
var once = require("../lib/once");
var convertMethodsToError = require("../lib/convert-methods-to-error");
var methods = require("../lib/methods");
var wrapPromise = require("@braintree/wrap-promise");

var TOKENIZE_BANK_DETAILS_MUTATION = createGraphQLMutation("UsBankAccount");
var TOKENIZE_BANK_LOGIN_MUTATION = createGraphQLMutation("UsBankLogin");

/**
 * @typedef {object} USBankAccount~tokenizePayload
 * @property {string} nonce The payment method nonce.
 * @property {string} type The payment method type, always `us_bank_account`.
 * @property {object} details Additional account details. Currently empty.
 */

/**
 * @class
 * @param {object} options See {@link module:braintree-web/us-bank-account.create|us-bank-account.create}.
 * @classdesc This class represents a US Bank Account component. Instances of this class can tokenize raw bank details or present a bank login. <strong>You cannot use this constructor directly. Use {@link module:braintree-web/us-bank-account.create|braintree.us-bank-account.create} instead.</strong>
 */
function USBankAccount(options) {
  this._client = options.client;

  this._isTokenizingBankLogin = false;

  analytics.sendEvent(this._client, "usbankaccount.initialized");
}

/**
 * Tokenizes bank information to return a payment method nonce. You can tokenize bank details by providing information like account and routing numbers. You can also tokenize with a bank login UI that prompts the customer to log into their bank account.
 * @public
 * @param {object} options All tokenization options for the US Bank Account component.
 * @param {string} options.mandateText A string for proof of customer authorization. For example, `'I authorize Braintree to debit my bank account on behalf of My Online Store.'`.
 * @param {object} [options.bankDetails] Bank detail information (such as account and routing numbers). `bankDetails` or `bankLogin` option must be provided.
 * @param {string} options.bankDetails.routingNumber The customer's bank routing number, such as `'307075259'`.
 * @param {string} options.bankDetails.accountNumber The customer's bank account number, such as `'999999999'`.
 * @param {string} options.bankDetails.accountType The customer's bank account type. Must be `'checking'` or `'savings'`.
 * @param {string} options.bankDetails.ownershipType The customer's bank account ownership type. Must be `'personal'` or `'business'`.
 * @param {string} [options.bankDetails.firstName] The customer's first name. Required when account ownership type is `personal`.
 * @param {string} [options.bankDetails.lastName] The customer's last name. Required when account ownership type is `personal`.
 * @param {string} [options.bankDetails.businessName] The customer's business name. Required when account ownership type is `business`.
 * @param {object} options.bankDetails.billingAddress The customer's billing address.
 * @param {string} options.bankDetails.billingAddress.streetAddress The street address for the customer's billing address, such as `'123 Fake St'`.
 * @param {string} [options.bankDetails.billingAddress.extendedAddress] The extended street address for the customer's billing address, such as `'Apartment B'`.
 * @param {string} options.bankDetails.billingAddress.locality The locality for the customer's billing address. This is typically a city, such as `'San Francisco'`.
 * @param {string} options.bankDetails.billingAddress.region The region for the customer's billing address. This is typically a state, such as `'CA'`.
 * @param {string} options.bankDetails.billingAddress.postalCode The postal code for the customer's billing address. This is typically a ZIP code, such as `'94119'`.
 * @param {object} [options.bankLogin] Bank login information. `bankLogin` or `bankDetails` option must be provided.
 * @param {string} options.bankLogin.displayName Display name for the bank login UI, such as `'My Store'`.
 * @param {string} options.bankLogin.ownershipType The customer's bank account ownership type. Must be `'personal'` or `'business'`.
 * @param {string} [options.bankLogin.firstName] The customer's first name. Required when account ownership type is `personal`.
 * @param {string} [options.bankLogin.lastName] The customer's last name. Required when account ownership type is `personal`.
 * @param {string} [options.bankLogin.businessName] The customer's business name. Required when account ownership type is `business`.
 * @param {object} options.bankLogin.billingAddress The customer's billing address.
 * @param {string} options.bankLogin.billingAddress.streetAddress The street address for the customer's billing address, such as `'123 Fake St'`.
 * @param {string} [options.bankLogin.billingAddress.extendedAddress] The extended street address for the customer's billing address, such as `'Apartment B'`.
 * @param {string} options.bankLogin.billingAddress.locality The locality for the customer's billing address. This is typically a city, such as `'San Francisco'`.
 * @param {string} options.bankLogin.billingAddress.region The region for the customer's billing address. This is typically a state, such as `'CA'`.
 * @param {string} options.bankLogin.billingAddress.postalCode The postal code for the customer's billing address. This is typically a ZIP code, such as `'94119'`.
 * @param {callback} [callback] The second argument, <code>data</code>, is a {@link USBankAccount~tokenizePayload|tokenizePayload}. If no callback is provided, `tokenize` returns a promise that resolves with {@link USBankAccount~tokenizePayload|tokenizePayload}.
 * @returns {(Promise|void)} Returns a promise if no callback is provided.
 * @example
 * <caption>Tokenizing raw bank details</caption>
 * var routingNumberInput = document.querySelector('input[name="routing-number"]');
 * var accountNumberInput = document.querySelector('input[name="account-number"]');
 * var accountTypeInput = document.querySelector('input[name="account-type"]:checked');
 * var ownershipTypeInput = document.querySelector('input[name="ownership-type"]:checked');
 * var firstNameInput = document.querySelector('input[name="first-name"]');
 * var lastNameInput = document.querySelector('input[name="last-name"]');
 * var businessNameInput = document.querySelector('input[name="business-name"]');
 * var billingAddressStreetInput = document.querySelector('input[name="street-address"]');
 * var billingAddressExtendedInput = document.querySelector('input[name="extended-address"]');
 * var billingAddressLocalityInput = document.querySelector('input[name="locality"]');
 * var billingAddressRegionSelect = document.querySelector('select[name="region"]');
 * var billingAddressPostalInput = document.querySelector('input[name="postal-code"]');
 *
 * submitButton.addEventListener('click', function (event) {
 *   var bankDetails = {
 *     routingNumber: routingNumberInput.value,
 *     accountNumber: accountNumberInput.value,
 *     accountType: accountTypeInput.value,
 *     ownershipType: ownershipTypeInput.value,
 *     billingAddress: {
 *       streetAddress: billingAddressStreetInput.value,
 *       extendedAddress: billingAddressExtendedInput.value,
 *       locality: billingAddressLocalityInput.value,
 *       region: billingAddressRegionSelect.value,
 *       postalCode: billingAddressPostalInput.value
 *     }
 *   };
 *
 *   if (bankDetails.ownershipType === 'personal') {
 *     bankDetails.firstName = firstNameInput.value;
 *     bankDetails.lastName = lastNameInput.value;
 *   } else {
 *     bankDetails.businessName = businessNameInput.value;
 *   }
 *
 *   event.preventDefault();
 *
 *   usBankAccountInstance.tokenize({
 *     bankDetails: bankDetails,
 *     mandateText: 'I authorize Braintree to debit my bank account on behalf of My Online Store.'
 *   }, function (tokenizeErr, tokenizedPayload) {
 *     if (tokenizeErr) {
 *       console.error('There was an error tokenizing the bank details.');
 *       return;
 *     }
 *
 *     // Send tokenizePayload.nonce to your server here!
 *   });
 * });
 * @example
 * <caption>Tokenizing with bank login UI</caption>
 * var ownershipTypeInput = document.querySelector('input[name="ownership-type"]:checked');
 * var firstNameInput = document.querySelector('input[name="first-name"]');
 * var lastNameInput = document.querySelector('input[name="last-name"]');
 * var businessNameInput = document.querySelector('input[name="business-name"]');
 * var billingAddressStreetInput = document.querySelector('input[name="street-address"]');
 * var billingAddressExtendedInput = document.querySelector('input[name="extended-address"]');
 * var billingAddressLocalityInput = document.querySelector('input[name="locality"]');
 * var billingAddressRegionSelect = document.querySelector('select[name="region"]');
 * var billingAddressPostalInput = document.querySelector('input[name="postal-code"]');
 *
 * bankLoginButton.addEventListener('click', function (event) {
 *   var bankLogin = {
 *     displayName: 'My Online Store',
 *     ownershipType: ownershipTypeInput.value,
 *     billingAddress: {
 *       streetAddress: billingAddressStreetInput.value,
 *       extendedAddress: billingAddressExtendedInput.value,
 *       locality: billingAddressLocalityInput.value,
 *       region: billingAddressRegionSelect.value,
 *       postalCode: billingAddressPostalInput.value
 *     }
 *   }
 *   event.preventDefault();
 *
 *   if (bankLogin.ownershipType === 'personal') {
 *     bankLogin.firstName = firstNameInput.value;
 *     bankLogin.lastName = lastNameInput.value;
 *   } else {
 *     bankLogin.businessName = businessNameInput.value;
 *   }
 *
 *   usBankAccountInstance.tokenize({
 *     bankLogin: bankLogin,
 *     mandateText: 'I authorize Braintree to debit my bank account on behalf of My Online Store.'
 *   }, function (tokenizeErr, tokenizedPayload) {
 *     if (tokenizeErr) {
 *       console.error('There was an error tokenizing the bank details.');
 *       return;
 *     }
 *
 *     // Send tokenizePayload.nonce to your server here!
 *   });
 * });
 */
USBankAccount.prototype.tokenize = function (options) {
  options = options || {};

  if (!options.mandateText) {
    return Promise.reject(
      new BraintreeError({
        type: errors.US_BANK_ACCOUNT_OPTION_REQUIRED.type,
        code: errors.US_BANK_ACCOUNT_OPTION_REQUIRED.code,
        message: "mandateText property is required.",
      })
    );
  }

  if (options.bankDetails && options.bankLogin) {
    return Promise.reject(
      new BraintreeError({
        type: errors.US_BANK_ACCOUNT_MUTUALLY_EXCLUSIVE_OPTIONS.type,
        code: errors.US_BANK_ACCOUNT_MUTUALLY_EXCLUSIVE_OPTIONS.code,
        message:
          "tokenize must be called with bankDetails or bankLogin, not both.",
      })
    );
  } else if (options.bankDetails) {
    return this._tokenizeBankDetails(options);
  } else if (options.bankLogin) {
    return this._tokenizeBankLogin(options);
  }

  return Promise.reject(
    new BraintreeError({
      type: errors.US_BANK_ACCOUNT_OPTION_REQUIRED.type,
      code: errors.US_BANK_ACCOUNT_OPTION_REQUIRED.code,
      message: "tokenize must be called with bankDetails or bankLogin.",
    })
  );
};

USBankAccount.prototype._tokenizeBankDetails = function (options) {
  var client = this._client;
  var bankDetails = options.bankDetails;
  var data = {
    achMandate: options.mandateText,
    routingNumber: bankDetails.routingNumber,
    accountNumber: bankDetails.accountNumber,
    accountType: bankDetails.accountType.toUpperCase(),
    billingAddress: formatBillingAddressForGraphQL(
      bankDetails.billingAddress || {}
    ),
  };

  formatDataForOwnershipType(data, bankDetails);

  return client
    .request({
      api: "graphQLApi",
      data: {
        query: TOKENIZE_BANK_DETAILS_MUTATION,
        variables: {
          input: {
            usBankAccount: data,
          },
        },
      },
    })
    .then(function (response) {
      analytics.sendEvent(
        client,
        "usbankaccount.bankdetails.tokenization.succeeded"
      );

      return Promise.resolve(
        formatTokenizeResponseFromGraphQL(response, "tokenizeUsBankAccount")
      );
    })
    .catch(function (err) {
      var error = errorFrom(err);

      analytics.sendEvent(
        client,
        "usbankaccount.bankdetails.tokenization.failed"
      );

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

USBankAccount.prototype._tokenizeBankLogin = function (options) {
  var self = this;
  var client = this._client;
  var gatewayConfiguration = client.getConfiguration().gatewayConfiguration;
  var isProduction = gatewayConfiguration.environment === "production";
  var plaidConfig = gatewayConfiguration.usBankAccount.plaid;

  if (!options.bankLogin.displayName) {
    return Promise.reject(
      new BraintreeError({
        type: errors.US_BANK_ACCOUNT_OPTION_REQUIRED.type,
        code: errors.US_BANK_ACCOUNT_OPTION_REQUIRED.code,
        message: "displayName property is required when using bankLogin.",
      })
    );
  }

  if (!plaidConfig) {
    return Promise.reject(
      new BraintreeError(errors.US_BANK_ACCOUNT_BANK_LOGIN_NOT_ENABLED)
    );
  }

  if (this._isTokenizingBankLogin) {
    return Promise.reject(
      new BraintreeError(errors.US_BANK_ACCOUNT_LOGIN_REQUEST_ACTIVE)
    );
  }
  this._isTokenizingBankLogin = true;

  return new Promise(function (resolve, reject) {
    self._loadPlaid(function (plaidLoadErr, plaid) {
      if (plaidLoadErr) {
        reject(plaidLoadErr);

        return;
      }

      plaid
        .create({
          clientName: options.bankLogin.displayName,
          apiVersion: "v2",
          env: isProduction ? "production" : "sandbox",
          key: plaidConfig.publicKey,
          product: "auth",
          selectAccount: true,
          onExit: function () {
            self._isTokenizingBankLogin = false;

            analytics.sendEvent(
              client,
              "usbankaccount.banklogin.tokenization.closed.by-user"
            );

            reject(new BraintreeError(errors.US_BANK_ACCOUNT_LOGIN_CLOSED));
          },
          onSuccess: function (publicToken, metadata) {
            var bankLogin = options.bankLogin;
            var data = {
              publicToken: publicToken,
              accountId: isProduction
                ? metadata.account_id
                : "plaid_account_id",
              accountType: metadata.account.subtype.toUpperCase(),
              achMandate: options.mandateText,
              billingAddress: formatBillingAddressForGraphQL(
                bankLogin.billingAddress || {}
              ),
            };

            formatDataForOwnershipType(data, bankLogin);

            client
              .request({
                api: "graphQLApi",
                data: {
                  query: TOKENIZE_BANK_LOGIN_MUTATION,
                  variables: {
                    input: {
                      usBankLogin: data,
                    },
                  },
                },
              })
              .then(function (response) {
                self._isTokenizingBankLogin = false;

                analytics.sendEvent(
                  client,
                  "usbankaccount.banklogin.tokenization.succeeded"
                );

                resolve(
                  formatTokenizeResponseFromGraphQL(
                    response,
                    "tokenizeUsBankLogin"
                  )
                );
              })
              .catch(function (tokenizeErr) {
                var error;

                self._isTokenizingBankLogin = false;
                error = errorFrom(tokenizeErr);

                analytics.sendEvent(
                  client,
                  "usbankaccount.banklogin.tokenization.failed"
                );

                reject(error);
              });
          },
        })
        .open();

      analytics.sendEvent(
        client,
        "usbankaccount.banklogin.tokenization.started"
      );
    });
  });
};

function errorFrom(err) {
  var error;
  var status = err.details && err.details.httpStatus;

  if (status === 401) {
    error = new BraintreeError(sharedErrors.BRAINTREE_API_ACCESS_RESTRICTED);
  } else if (status < 500) {
    error = new BraintreeError(errors.US_BANK_ACCOUNT_FAILED_TOKENIZATION);
  } else {
    error = new BraintreeError(
      errors.US_BANK_ACCOUNT_TOKENIZATION_NETWORK_ERROR
    );
  }
  error.details = { originalError: err };

  return error;
}

function formatTokenizeResponseFromGraphQL(response, type) {
  var data = response.data[type].paymentMethod;
  var last4 = data.details.last4;
  var description = "US bank account ending in - " + last4;

  return {
    nonce: data.id,
    details: {},
    description: description,
    type: "us_bank_account",
  };
}

USBankAccount.prototype._loadPlaid = function (callback) {
  var existingScript, script;

  callback = once(callback);

  if (window.Plaid) {
    callback(null, window.Plaid);

    return;
  }

  existingScript = document.querySelector(
    'script[src="' + constants.PLAID_LINK_JS + '"]'
  );

  if (existingScript) {
    addLoadListeners(existingScript, callback);
  } else {
    script = document.createElement("script");

    script.src = constants.PLAID_LINK_JS;
    script.async = true;

    addLoadListeners(script, callback);

    document.body.appendChild(script);

    this._plaidScript = script;
  }
};

function addLoadListeners(script, callback) {
  function loadHandler() {
    var readyState = this.readyState; // eslint-disable-line no-invalid-this

    if (!readyState || readyState === "loaded" || readyState === "complete") {
      removeLoadListeners();
      callback(null, window.Plaid);
    }
  }

  function errorHandler() {
    script.parentNode.removeChild(script);

    callback(new BraintreeError(errors.US_BANK_ACCOUNT_LOGIN_LOAD_FAILED));
  }

  function removeLoadListeners() {
    script.removeEventListener("error", errorHandler);
    script.removeEventListener("load", loadHandler);
    script.removeEventListener("readystatechange", loadHandler);
  }

  script.addEventListener("error", errorHandler);
  script.addEventListener("load", loadHandler);
  script.addEventListener("readystatechange", loadHandler);
}

function formatBillingAddressForGraphQL(address) {
  return {
    streetAddress: address.streetAddress,
    extendedAddress: address.extendedAddress,
    city: address.locality,
    state: address.region,
    zipCode: address.postalCode,
  };
}

function formatDataForOwnershipType(data, details) {
  if (details.ownershipType === "personal") {
    data.individualOwner = {
      firstName: details.firstName,
      lastName: details.lastName,
    };
  } else if (details.ownershipType === "business") {
    data.businessOwner = {
      businessName: details.businessName,
    };
  }
}

function createGraphQLMutation(type) {
  return (
    "" +
    "mutation Tokenize" +
    type +
    "($input: Tokenize" +
    type +
    "Input!) {" +
    "  tokenize" +
    type +
    "(input: $input) {" +
    "    paymentMethod {" +
    "      id" +
    "      details {" +
    "        ... on UsBankAccountDetails {" +
    "          last4" +
    "        }" +
    "      }" +
    "    }" +
    "  }" +
    "}"
  );
}

/**
 * Cleanly tear down anything set up by {@link module:braintree-web/us-bank-account.create|create}.
 * @public
 * @param {callback} [callback] Called once teardown is complete. No data is returned if teardown completes successfully.
 * @example
 * usBankAccountInstance.teardown();
 * @example <caption>With callback</caption>
 * usBankAccountInstance.teardown(function () {
 *   // teardown is complete
 * });
 * @returns {(Promise|void)} Returns a promise if no callback is provided.
 */
USBankAccount.prototype.teardown = function () {
  if (this._plaidScript) {
    document.body.removeChild(this._plaidScript);
  }

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

  return Promise.resolve();
};

module.exports = wrapPromise.wrapPrototype(USBankAccount);