SumOfUs/Champaign

View on GitHub
app/javascript/components/Payment/Payment.js

Summary

Maintainability
F
4 days
Test Coverage
import $ from 'jquery';
import React, { Component } from 'react';
import { FormattedMessage, FormattedHTMLMessage } from 'react-intl';
import { connect } from 'react-redux';
import braintreeClient from 'braintree-web/client';
import braintree from 'braintree-web';
import dataCollector from 'braintree-web/data-collector';
import { isEmpty, snakeCase } from 'lodash';
import ee from '../../shared/pub_sub';
import captcha from '../../shared/recaptcha';

import PayPal from '../Braintree/PayPal';
import BraintreeCardFields from '../Braintree/BraintreeCardFields';
import PaymentTypeSelection from './PaymentTypeSelection';
import { ProcessLocalPayment } from './ProcessLocalPayment';
import WelcomeMember from '../WelcomeMember/WelcomeMember';
import DonateButton from '../DonateButton';
import Checkbox from '../Checkbox/Checkbox';
import ShowIf from '../ShowIf';
import ReCaptchaBranding from '../ReCaptchaBranding';
import { resetMember } from '../../state/member/reducer';
import Cookie from 'js-cookie';
import CurrencyAmount from '../CurrencyAmount';
import WeeklyDonationFinePrint from '../WeeklyDonationFinePrint';

import {
  changeStep,
  setRecurring,
  setStoreInVault,
  setPaymentType,
} from '../../state/fundraiser/actions';
import ExpressDonation from '../ExpressDonation/ExpressDonation';
import { isDirectDebitSupported } from '../../util/directDebitDecider';
import { getErrorsByCode } from '../../util/getBraintreeErrorMessages';

// Styles
import './Payment.css';

const BRAINTREE_TOKEN_URL =
  process.env.BRAINTREE_TOKEN_URL || '/api/payment/braintree/token';
const LOCAL_PAYMENT_PROVIDERS = ['ideal', 'giropay'];

export class Payment extends Component {
  static title = (<FormattedMessage id="payment" defaultMessage="payment" />);

  constructor(props) {
    super(props);
    this.state = {
      client: null,
      localPaymentInstance: null,
      deviceData: {},
      loading: true,
      submitting: false,
      expressHidden: false,
      recurringDonor: false,
      recurringDefault: null,
      pageDefault: null,
      onlyRecurring: false,
      akid: null,
      source: null,
      initializing: {
        gocardless: false,
        paypal: true,
        card: true,
      },
      errors: window.champaign.oneClickErrorCode?.length
        ? getErrorsByCode(window.champaign.oneClickErrorCode)
        : [],
      waitingForGoCardless: false,
    };
  }

  componentDidMount() {
    const urlInfo = window.champaign.personalization.urlParams;
    const donor_status = window.champaign.personalization.member?.donor_status;
    const pageDefault =
      window.champaign.plugins.fundraiser?.default?.config.recurring_default;
    const normalizedRecurringDefault = urlInfo.recurring_default || pageDefault;

    this.setState({
      recurringDonor: donor_status === 'recurring_donor',
      akid: urlInfo.akid,
      source: urlInfo.source,
      onlyRecurring: normalizedRecurringDefault === 'only_recurring',
      recurringDefault: normalizedRecurringDefault,
      pageDefault,
    });

    if (this.props.localPaymentTypes.length > 0)
      this.props.setPaymentType(this.props.localPaymentTypes[0]);
    this.getBraintreeToken();
    this.bindGlobalEvents();
    // set default payment type for existing user
    this.setDefaultPaymentType();
  }

  // set default payment as DirectDebit / paypal when the
  // user follows external link like email
  setDefaultPaymentType = () => {
    const urlInfo = window.champaign.personalization.urlParams;
    const country = this.props.fundraiser.form.country;
    const showDirectDebit = isDirectDebitSupported({ country: country });
    const lang = window.champaign.page.language_code;

    if (urlInfo.akid && this.props.fundraiser.recurring && lang == 'de') {
      if (showDirectDebit) {
        this.selectPaymentType('gocardless');
      } else {
        // this is done since PAYPAL doesnt support ARS currency as of now
        const paymentType = this.props.currency === 'ARS' ? 'card' : 'paypal';
        this.selectPaymentType(paymentType);
      }
    }
  };

  getBraintreeToken = () => {
    $.get(
      BRAINTREE_TOKEN_URL + `?merchantAccountId=${this.props.merchantAccountId}`
    )
      .done(data => {
        braintreeClient.create(
          { authorization: data.token },
          (error, client) => {
            braintree.localPayment.create(
              {
                client: client,
                merchantAccountId: this.props.merchantAccountId,
              },
              (localPaymentErr, localPaymentInstance) => {
                this.setState({
                  localPaymentInstance,
                });
              }
            );
            // todo: handle err?
            dataCollector.create(
              {
                client,
                kount: true,
                paypal: true,
              },
              (err, collectorInst) => {
                if (err) {
                  return this.setState({ client, loading: false });
                }

                const deviceData = collectorInst.deviceData;
                this.setState({
                  client,
                  deviceData: JSON.parse(deviceData),
                  loading: false,
                });
              }
            );
          }
        );
      })
      .fail(failure => {
        console.warn('could not fetch Braintree token');
      });
  };

  bindGlobalEvents() {
    ee.on('fundraiser:actions:make_payment', this.makePayment);
    // set default payment type for new user
    ee.on('fundraiser:form:success', this.setDefaultPaymentType);
  }

  componentDidUpdate(prevProps) {
    if (this.props.merchantAccountId !== prevProps.merchantAccountId) {
      this.getBraintreeToken();
    }
    if (
      this.props.currency === 'ARS' &&
      this.props.currentPaymentType === 'paypal'
    ) {
      this.selectPaymentType('card');
    }
    ee.emit('sidebar:height_change');
  }

  selectPaymentType(paymentType) {
    if (window.screen.height < 650) {
      const bar = document.getElementsByClassName('fundraiser-bar__content')[0];
      bar.scrollTo(0, 201);
    }
    this.props.setPaymentType(paymentType);
  }

  resetMember() {
    this.props.resetMember();
  }

  paymentInitialized(name) {
    this.setState({
      initializing: { ...this.state.initializing, [name]: false },
    });
  }

  loading(paymentType) {
    const loading = this.state.loading;
    if (paymentType) {
      return loading || this.state.initializing[paymentType];
    } else {
      return loading;
    }
  }

  disableSubmit() {
    return (
      this.loading(this.props.currentPaymentType) ||
      !this.props.currentPaymentType ||
      !this.props.fundraiser.donationAmount
    );
  }

  getMemberName(member, formValues) {
    if (member) {
      return `${member.fullName}:`;
    } else if (formValues && formValues.member) {
      return `${formValues.member.name}:`;
    } else {
      return null;
    }
  }

  removeRecurringPropertyListener() {
    ee.removeListener('fundraiser:change_recurring', this.makePayment, this);
  }

  onClickHandle(e) {
    const isRecurring = e.currentTarget.name === 'recurring';
    this.props.setRecurring(isRecurring);

    let donationType;
    if (isRecurring) {
      donationType = this.props.weekly ? 'weekly' : 'monthly';
    } else {
      donationType = 'one_time';
    }
    const label = `user_clicks_${donationType}_donation_button`;
    const event = `fundraiser:set_${donationType}`;

    ee.emit(event, label);
    ee.on('fundraiser:change_recurring', this.makePayment, this);
  }

  getFinalDonationAmount = () => {
    return (
      (!this.state.recurringDonor && this.props.weekly
        ? this.props.fundraiser.donationAmount * 4
        : this.props.fundraiser.donationAmount) || 0
    );
  };

  // this should actually be a selector (a fn that returns a slice of state)
  donationData() {
    const {
      fundraiser: {
        donationAmount,
        currency,
        recurring,
        storeInVault,
        form,
        formValues,
      },
      extraActionFields,
    } = this.props;

    return {
      amount: this.getFinalDonationAmount(),
      currency: currency,
      recurring: recurring,
      store_in_vault: storeInVault,
      user: {
        ...formValues,
        ...form,
      },
      extra_action_fields: extraActionFields,
    };
  }

  delegate() {
    const delegate = this.refs[this.props.currentPaymentType];

    if (delegate && delegate.submit) {
      return delegate;
    } else if (delegate && delegate.getWrappedInstance().submit) {
      return delegate.getWrappedInstance();
    }

    return null;
  }

  submitGoCardless() {
    const payload = {
      ...this.donationData(),
      device_data: this.state.deviceData,
      provider: 'GC',
      source: window.champaign.personalization.urlParams.source,
    };
    const url = `/api/go_cardless/pages/${
      this.props.page.id
    }/start_flow?${$.param(payload)}`;
    window.open(url);

    this.emitTransactionSubmitted();

    if (!this.state.waitingForGoCardless) {
      window.addEventListener('message', this.waitForGoCardless.bind(this));
      this.setState({ waitingForGoCardless: true });
    }
  }

  waitForGoCardless(event) {
    if (typeof event.data === 'object') {
      if (event.data.event === 'follow_up:loaded') {
        event.source.close();
        ee.emit('direct_debit:donated');
        this.onSuccess({});
      } else if (event.data.event === 'donation:error') {
        const messages = event.data.errors.map(({ message }) => message);
        this.onError(messages);
        event.source.close();
      }
    }
  }

  emitTransactionSubmitted() {
    const userId =
      window.champaign.personalization.member.id || Cookie.get('__bpmx');
    const eventPayload = {
      user_id: userId,
      page_id: this.props.page.id,
      value: this.props.fundraiser.donationAmount,
      currency: this.props.fundraiser.currency,
      content_category: this.props.currentPaymentType,
      recurring: this.props.fundraiser.recurring,
    };
    if (typeof window.fbq === 'function') {
      window.fbq('track', 'AddPaymentInfo', eventPayload);
    }

    ee.emit(
      'fundraiser:transaction_submitted',
      eventPayload,
      this.props.formData
    );
  }

  makePayment = event => {
    if (this.props.currentPaymentType === 'gocardless') {
      this.submitGoCardless();
      return;
    }
    const delegate = this.delegate();
    this.props.setSubmitting(true);
    if (delegate && delegate.submit) {
      delegate.submit().then(
        success => this.submit(success),
        reason => this.onError(reason)
      );
    } else {
      this.submit();
    }
    this.removeRecurringPropertyListener();
  };

  submit = async data => {
    const localPayment = LOCAL_PAYMENT_PROVIDERS.includes(
      this.props.currentPaymentType
    );

    if (localPayment) {
      const nonce = await ProcessLocalPayment({
        localPaymentInstance: this.state.localPaymentInstance,
        data: this.donationData(),
        pageId: this.props.page.id,
        paymentType: this.props.currentPaymentType,
      });
      data = { nonce };
    }

    const recaptcha_action = `donate/${this.props.page.id}`;
    const recaptcha_token = await captcha.execute({ action: recaptcha_action });

    const payload = {
      ...this.donationData(),
      payment_method_nonce: data.nonce,
      device_data: this.state.deviceData,
      source: window.champaign.personalization.urlParams.source,
      recaptcha_token,
      recaptcha_action,
      ...(data.threeDSecureInfo?.threeDSecureAuthenticationId && {
        authenticationId: data.threeDSecureInfo.threeDSecureAuthenticationId,
      }),
    };

    this.emitTransactionSubmitted();

    $.post(
      `/api/payment/braintree/pages/${this.props.page.id}/transaction`,
      payload
    ).then(this.onSuccess, this.onBraintreeError);
  };

  onSuccess = data => {
    if (typeof window.fbq === 'function') {
      const userId =
        window.champaign.personalization.member.id || Cookie.get('__bpmx');
      window.fbq('track', 'Purchase', {
        user_id: userId,
        page_id: this.props.page.id,
        value: this.props.fundraiser.donationAmount,
        currency: this.props.fundraiser.currency,
        content_name: this.props.page.title,
        content_ids: [this.props.page.id],
        content_type: 'product',
        product_catalog_id: 445876772724152,
        donation_type: this.props.fundraiser.recurring
          ? 'recurring'
          : 'not_recurring',
      });
    }

    let donationType;
    if (this.props.fundraiser.recurring) {
      donationType = this.props.weekly ? 'weekly' : 'monthly';
    } else {
      donationType = 'one_time';
    }

    const label = `successful_${donationType}_donation_submitted`;
    const event = `fundraiser:${donationType}_transaction_submitted`;

    ee.emit(event, label);

    const emitTransactionSuccess = () => {
      ee.emit('fundraiser:transaction_success', data, this.props.formData);
    };

    const { original, forced } =
      window.champaign.plugins?.fundraiser?.default?.config?.fundraiser
        ?.forcedDonateLayout || {};
    const emitForcedLayoutSuccess = () => {
      ee.emit(`${event}_forced_layout`, {
        label: `${snakeCase(original)}_template_used_scroll_to_donate`,
        amount: this.props.fundraiser.donationAmount,
      });
    };

    if (
      typeof window.mixpanel !== 'undefined' &&
      this.props.fundraiser.storeInVault
    ) {
      window.mixpanel.track('donation-made', {
        event_label: 'saved-payment-info',
        event_source: 'fa_fundraising',
      });
    }
    emitTransactionSuccess();

    if (forced === true) {
      emitForcedLayoutSuccess();
    }

    this.setState({ errors: [] });
  };

  onError = reason => {
    const errorParsed =
      reason && reason.responseText && JSON.parse(reason.responseText);
    const fundraiserBar = document.getElementsByClassName(
      'fundraiser-bar__content'
    )[0];
    if (
      (errorParsed && errorParsed.success === false) ||
      !isEmpty(this.state.errors)
    ) {
      setTimeout(() => {
        fundraiserBar.scrollTo(0, 0);
      }, 500);
    }
    ee.emit('fundraiser:transaction_error', reason, this.props.formData);
    if (reason.code === '3DS') {
      const errors = [<FormattedMessage id="fundraiser.unknown_error" />];
      this.setState({ errors });
    }
    this.props.setSubmitting(false);
  };

  onBraintreeError = response => {
    let errors;
    if (
      response.status === 422 &&
      response.responseJSON &&
      response.responseJSON.errors
    ) {
      errors = response.responseJSON.errors.map(function(error) {
        if (error.code) {
          return getErrorsByCode(error.code);
        } else {
          return error.message;
        }
      });
    } else {
      errors = [<FormattedMessage id="fundraiser.unknown_error" />];
    }
    this.setState({ errors: errors });
    this.onError(response);
  };

  isExpressHidden() {
    return this.state.expressHidden || this.props.disableSavedPayments;
  }

  //  Recurring Donor can see only One off donation button
  //  Recurring Donor cannot have multiple subscriptions.
  //  So a member who becomes a recurring_donor via subscribing
  //  any page cannot see a monthly donation button at any circumstance
  //  again. Instead he can see One time donation button alone
  //  A non recurring donor can see
  //   - only monthly payment button for 'only_recurring' page
  //   - else both buttons should be displayed
  //   - he cannot see monthly donation button when the url has akid & source=fwd

  showMonthlyButton() {
    if (
      this.state.recurringDonor ||
      LOCAL_PAYMENT_PROVIDERS.includes(this.props.currentPaymentType)
    ) {
      return false;
    } else {
      if (this.state.recurringDefault === 'only_one_off') {
        return false;
      }
      return true;
    }
  }

  showOneOffButton() {
    if (this.state.recurringDonor) {
      return true;
    } else {
      if (this.state.recurringDefault === 'only_one_off') {
        return true;
      }

      if (this.state.recurringDefault === 'only_recurring') {
        return false;
      }
      return true;
    }
  }

  render() {
    const {
      member,
      onlyRecurring,
      recurringDonor,
      formData,
      fundraiser: {
        currency,
        donationAmount,
        currentPaymentType,
        recurring,
        storeInVault,
      },
    } = this.props;
    return (
      <div className="Payment section">
        <ShowIf condition={!isEmpty(this.state.errors)}>
          <div className="fundraiser-bar__errors">
            <div className="fundraiser-bar__error-intro">
              <span className="fa fa-exclamation-triangle" />
              <FormattedMessage
                id="fundraiser.error_intro"
                defaultMessage="Unable to process donation!"
              />
            </div>
            {this.state.errors.map((e, i) => {
              return (
                <div key={i} className="fundraiser-bar__error-detail">
                  {e}
                </div>
              );
            })}
          </div>
        </ShowIf>
        {!this.props.disableFormReveal && (
          <WelcomeMember
            member={member}
            resetMember={() => this.resetMember()}
          />
        )}

        <ExpressDonation
          setSubmitting={s => this.props.setSubmitting(s)}
          hidden={this.isExpressHidden()}
          showOneOffButton={this.showOneOffButton()}
          showMonthlyButton={this.showMonthlyButton()}
          getFinalDonationAmount={this.getFinalDonationAmount()}
          weekly={this.props.weekly}
          data={{
            src: this.state.src,
            akid: this.state.akid,
            recurringDefault: this.state.recurringDefault,
            onlyRecurring: this.state.onlyRecurring,
            recurringDonor: this.state.recurringDonor,
          }}
          onHide={() => this.setState({ expressHidden: true })}
        />

        <ShowIf condition={this.isExpressHidden()}>
          <PaymentTypeSelection
            disabled={this.state.loading}
            onChange={p => this.selectPaymentType(p)}
          />
          <PayPal
            ref="paypal"
            amount={donationAmount}
            currency={currency}
            client={this.state.client}
            vault={recurring || storeInVault}
            onInit={() => this.paymentInitialized('paypal')}
          />

          <BraintreeCardFields
            ref="card"
            client={this.state.client}
            recurring={recurring}
            isActive={currentPaymentType === 'card'}
            onInit={() => this.paymentInitialized('card')}
            amount={this.getFinalDonationAmount()}
          />

          {currentPaymentType === 'gocardless' && (
            <div className="PaymentMethod__guidance">
              <FormattedMessage
                id={'fundraiser.payment_methods.ready_for_gocardless'}
              />
            </div>
          )}
          {!LOCAL_PAYMENT_PROVIDERS.includes(this.props.currentPaymentType) && (
            <Checkbox
              className="Payment__config"
              checked={storeInVault}
              onChange={e =>
                this.props.setStoreInVault(e.currentTarget.checked)
              }
            >
              <FormattedMessage
                id="fundraiser.store_in_vault"
                defaultMessage="Securely store my payment information"
              />
            </Checkbox>
          )}
          <div className="payment-message">
            <br />
            {this.showMonthlyButton() && (
              <FormattedMessage
                id={'fundraiser.make_monthly_donation'}
                defaultMessage={`{name} a {duration} donation will support our movement to plan ahead, so we can more effectively take on the biggest corporations that threaten people and planet.`}
                values={{
                  name: this.getMemberName(member, formData),
                  duration: this.props.weekly ? 'weekly' : 'monthly',
                }}
              />
            )}

            <div className="PaymentMethod__complete-donation">
              <FormattedMessage
                id={'fundraiser.complete_donation'}
                defaultMessage={`Complete your {amount} donation`}
                values={{
                  amount: (
                    <CurrencyAmount
                      amount={donationAmount || 0}
                      currency={currency}
                    />
                  ),
                }}
              />
            </div>
          </div>

          {currentPaymentType === 'paypal' && (
            <div className="PaymentMethod__guidance">
              <FormattedMessage
                id={'fundraiser.payment_methods.ready_for_paypal'}
              />
            </div>
          )}

          <>
            <ShowIf condition={this.showMonthlyButton()}>
              <DonateButton
                currency={currency}
                amount={donationAmount || 0}
                submitting={this.state.submitting}
                name="recurring"
                recurring={true}
                recurringDonor={this.state.recurringDonor}
                weekly={this.props.weekly}
                disabled={this.disableSubmit()}
                onClick={e => this.onClickHandle(e)}
                theme={'primary'}
              />
            </ShowIf>

            <ShowIf condition={this.showOneOffButton()}>
              <DonateButton
                currency={currency}
                amount={donationAmount || 0}
                submitting={this.state.submitting}
                name="one_time"
                recurring={false}
                recurringDonor={this.state.recurringDonor}
                disabled={this.disableSubmit()}
                onClick={e => this.onClickHandle(e)}
                theme={this.showMonthlyButton() ? 'secondary' : 'primary'}
              />
            </ShowIf>
          </>
        </ShowIf>

        <div className="Payment__fine-print">
          {this.props.weekly && this.showMonthlyButton() && (
            <WeeklyDonationFinePrint className="ReCaptchaBranding mb-10" />
          )}
          <FormattedHTMLMessage
            className="Payment__fine-print"
            id="fundraiser.fine_print"
            defaultMessage={`
              SumOfUs is a registered 501(c)4 non-profit incorporated in Washington, DC, United
              States. Contributions or gifts to SumOfUs are not tax deductible.
              For further information, please contact info@sumofus.org.
            `}
          />
          <ReCaptchaBranding />
        </div>

        {this.props.showDirectDebit && (
          <div className="Payment__direct-debit-logo">
            <img src={require('./dd_logo_landscape.png')} alt="DIRECT Debit" />
          </div>
        )}
      </div>
    );
  }
}

const mapStateToProps = state => ({
  weekly:
    window.champaign.personalization.urlParams.weekly == 'true' ? true : false,
  disableSavedPayments:
    state.fundraiser.disableSavedPayments || state.paymentMethods.length === 0,
  defaultPaymentType: state.fundraiser.directDebitOnly ? 'gocardless' : 'card',
  showDirectDebit: state.fundraiser.showDirectDebit,
  localPaymentTypes: state.fundraiser.localPaymentTypes,
  currentPaymentType: state.fundraiser.directDebitOnly
    ? 'gocardless'
    : state.fundraiser.currentPaymentType,
  fundraiser: state.fundraiser,
  paymentMethods: state.paymentMethods,
  member: state.member,
  onlyRecurring: state.fundraiser.recurringDefault === 'only_recurring',
  formData: {
    storeInVault: state.fundraiser.storeInVault,
    member: {
      ...state.fundraiser.formValues,
      ...state.fundraiser.form,
    },
  },
  extraActionFields: state.extraActionFields,
  currency: state.fundraiser.currency,
  merchantAccountId: state.fundraiser.merchantAccountId,
});

const mapDispatchToProps = dispatch => ({
  resetMember: () => dispatch(resetMember()),
  changeStep: step => dispatch(changeStep(step)),
  setRecurring: value => dispatch(setRecurring(value)),
  setStoreInVault: value => dispatch(setStoreInVault(value)),
  setPaymentType: value => dispatch(setPaymentType(value)),
});

export default connect(mapStateToProps, mapDispatchToProps)(Payment);