recurly/recurly-js

View on GitHub
lib/recurly/fraud.js

Summary

Maintainability
B
5 hrs
Test Coverage
import find from 'component-find';
import Emitter from 'component-emitter';
import dom from '../util/dom';
import errors from './errors';

const debug = require('debug')('recurly:fraud');

const FRAUDNET_PARAMS_ID = 'fnparams-dede7cc5-15fd-4c75-a9f4-36c430ee3a99';

export class Fraud extends Emitter {
  constructor (recurly) {
    debug('initializing fraud object');
    super();
    this.recurly = recurly;
    this.dataCollectorInitiated = false;
    this.attachedNodes = [];
    recurly.ready(this.getCollectorProfiles.bind(this));
    // make sure activateProfiles can be passed as a callback
    this.activateProfiles = this.activateProfiles.bind(this);
  }

  get shouldCollectKountData () {
    return !!this.recurly.config.fraud.kount.dataCollector;
  }

  get udfParams () {
    const udfData = this.recurly.config.fraud.kount.udf || {};

    return Object.keys(udfData).map(label => ({ label, value: udfData[label] }));
  }

  /**
   * gets the fraud params present including injected form fields
   * as well as grabbing litle session id if configured
   */
  params (data) {
    const { fraud: fraudConfig } = this.recurly.config;
    let fraudParams = [];
    debug('creating fraud params', data, fraudConfig);

    if (fraudConfig.kount.dataCollector && data.fraud_session_id) {
      fraudParams.push({
        processor: 'kount',
        session_id: data.fraud_session_id,
        udf: this.udfParams,
      });
    }

    if (fraudConfig.litle.sessionId) {
      fraudParams.push({
        processor: 'litle_threat_metrix',
        session_id: fraudConfig.litle.sessionId
      });
    }

    if (fraudConfig.braintree.deviceData) {
      fraudParams.push({
        processor: 'braintree',
        session_id: fraudConfig.braintree.deviceData
      });
    }

    const fraudnetProfile = this.profiles?.find(({ processor }) => processor === 'fraudnet');
    if (fraudnetProfile) {
      fraudParams.push({
        processor: 'fraudnet',
        session_id: fraudnetProfile.params.session_id,
      });
    }

    return fraudParams;
  }

  /**
   * requests the data collector form fields from the
   * Recurly server and injects them into the payment form
   */
  getCollectorProfiles () {
    if (this.dataCollectorInitiated) return;
    this.dataCollectorInitiated = true;

    this.recurly.request.get({
      route: '/risk/info',
      done: (error, response) => {
        debug('risk info', error, response);

        if (error) {
          debug('error detected', error);
          if (this.shouldCollectKountData) {
            this.emit(
              'error',
              errors('fraud-data-collector-request-failed', { error })
            );
          }

          return;
        }

        const { profiles } = response;
        debug('preparing to activate profiles', profiles);
        this.profiles = profiles;
        this.activateProfiles();
      },
    });
  }

  activateProfiles () {
    if (!this.profiles) {
      debug('no profiles available', this.profiles);
      return;
    }
    debug('setting profiles', this.profiles);

    this.profiles.forEach((profile) => {
      const { processor, params } = profile;

      if (processor === 'kount' && this.shouldCollectKountData) {
        debug('enabling kount profile', params);
        this.activateKountProfile(params);
      } else if (processor === 'fraudnet') {
        debug('enabling fraudnet profile');
        this.activateFraudnetProfile(params);
      }
    });
  }

  activateKountProfile (params) {
    debug('activating Kount profiles');
    const configuredForm = this.recurly.config.fraud.kount.form;
    const sessionIdInputElement = dom.createHiddenInput({
      'data-recurly': 'fraud_session_id',
      value: params.session_id,
      'type': 'hidden',
    });
    debug('kount configured form', configuredForm);

    const initialize = () => {
      if (!window.ka) {
        debug('kount integration failed, no SDK attached');
        return this.emit(
          'error',
          errors('fraud-data-collector-request-failed', {
            error: 'Kount SDK failed to load.',
          })
        );
      }
      debug('initializing kount SDK');
      // eslint-disable-next-line no-undef
      const client = new ka.ClientSDK();
      client.autoLoadEvents();
    };

    const scriptElement = document.createElement('script');
    scriptElement.setAttribute('src', params.script_url);
    scriptElement.onload = initialize;

    const initializerElement = document.createElement('div');
    initializerElement.className = 'kaxsdc';
    initializerElement.setAttribute('data-event', 'load');

    const form = dom.element(configuredForm) || this.getHostedFieldParentForm();

    if (form) {
      debug('form', form);
      const nodes = [
        sessionIdInputElement,
        scriptElement,
        initializerElement,
      ];

      nodes.forEach((node) => form.appendChild(node));
      this.attachedNodes = nodes;
      debug('nodes attached', this.attachedNodes, form);
    } else {
      debug('no form available, kount activation skipped', form);
    }

    this.emit('ready');
  }

  activateFraudnetProfile (params) {
    const iframe = document.createElement('iframe');
    iframe.setAttribute('id', 'fraudnet-iframe');
    iframe.setAttribute('style', 'display:none;');
    document.body.appendChild(iframe);

    const paramsScriptElement = document.createElement('script');
    paramsScriptElement.setAttribute('id', 'fraudnet-params');
    paramsScriptElement.setAttribute('type', 'application/json');
    paramsScriptElement.setAttribute('fncls', FRAUDNET_PARAMS_ID);
    paramsScriptElement.innerHTML = JSON.stringify({
      f: params.session_id,
      s: params.page_identifier,
      sandbox: params.sandbox,
    });

    const scriptElement = document.createElement('script');
    scriptElement.setAttribute('id', 'fraudnet-script');
    scriptElement.setAttribute('src', params.script_url);

    iframe.contentWindow.document.head.appendChild(paramsScriptElement);
    iframe.contentWindow.document.head.appendChild(scriptElement);
    this.attachedNodes.push(iframe);

    this.emit('ready');
  }

  /**
   * Attempts to discover a form element using the assumed configuration of
   * hosted fields as a basis for DOM tree upward traversal
   *
   * @return {HTMLFormElement}
   * @throws {RecurlyError} if a form cannot be discovered
   */
  getHostedFieldParentForm () {
    if (this._form) return this._form;
    const fields = this.recurly.config.fields;
    debug('form fields', fields);
    const selectors = Object.keys(fields)
      .map((name) => fields[name].selector)
      .filter(Boolean);
    let form;
    find(selectors, (selector) => {
      const node = dom.findNodeInParents(
        window.document.querySelector(selector),
        'form'
      );
      if (dom.element(node)) {
        debug('found form', node);
        form = node;
      }
    });

    if (form) {
      return (this._form = form);
    } else {
      const missingFormError = errors('fraud-data-collector-missing-form', {
        selectors,
      });
      debug('no form found, emitting error', missingFormError);
      this.emit('error', missingFormError);
    }
  }

  /**
   * Removes any attached data collectors
   */
  destroy () {
    this.attachedNodes.forEach((node) => {
      if (!node.parentElement) return;
      node.parentElement.removeChild(node);
    });
  }
}