spree-contrib / spree_braintree_vzero

Official Braintree + PayPal integration gem for Spree Commerce
https://www.braintreepayments.com/
BSD 3-Clause "New" or "Revised" License
46 stars 59 forks source link

3DS 2.0 integration #208

Closed matteomelotti closed 4 years ago

matteomelotti commented 5 years ago

Hi, I would like to ask how to integrate Spree 3.0, spree_braintree_vzero stable-3.0 with the new standard 3DS 2.0

Thanks.

altescape commented 5 years ago

To add to this I would also like to know what's in the pipeline around the 3DS 2.0 update.

Many thanks

hefan commented 5 years ago

Isn't the new 3ds 2.0 with Javascript SDK v3 required from 14th September 2019 on? They keep sending mails regarding this.

https://developers.braintreepayments.com/guides/3d-secure/migration/javascript/v3 Not sure if the fallback to 3ds v1 will always work.

Will this extension cover this until then?

code-bunny commented 5 years ago

3ds 2.0 is now out and about live, any word on if we need to do anything with this gem or if it is automatically working as is?

hefan commented 5 years ago

i guess not, the extension uses the wrong, old braintree script (v2) for that.

https://github.com/spree-contrib/spree_braintree_vzero/blob/master/app/views/spree/checkout/payment/braintree_vzero/_payment.html.erb#L49

matteomelotti commented 5 years ago

I confirm that I implemented 3DS 2.0 keeping the gem but adding the new JS given from the Braintree guide https://developers.braintreepayments.com/guides/3d-secure/migration/javascript/v3 and the new logic for the client token and additional parameters

hefan commented 5 years ago

that sounds great. does it work with hosted fields also or only with the dropin solution? would you mind to share or doing a pull request? we implemented our own dropin solution using that example: https://github.com/braintree/braintree_rails_example. But it is worth more if it would be integrated in this gem.

matteomelotti commented 5 years ago

I used hosted fields and it works. Steps I did:

  1. updated /views/spree/checkout/_payment.html.erb and added, in the hosted section, the new fields requests in the guide (address, route, zip, etc) (following the hosting exaple for the form https://developers.braintreepayments.com/guides/3d-secure/client-side/javascript/v3

  2. updated the javascript in the /views/spree/checkout/payment/braintree_vzero/_payment.html.erb

    <script src="https://js.braintreegateway.com/web/3.50.1/js/client.min.js"></script>
    <script src="https://js.braintreegateway.com/web/3.50.1/js/hosted-fields.min.js"></script>
    <script src="https://js.braintreegateway.com/web/3.50.1/js/three-d-secure.min.js"></script>
  3. added in the same file this custom JS code:

    
    <script type="text/javascript">
    $('#order_payments_attributes__payment_method_id_<%= payment_method.id %>').click(function (e) {
    var threeDSecure;
    var checkoutFormId = "<%= payment_method.preferred_checkout_form_id %>"
    var formId = "#" + checkoutFormId;
    
    var clientToken = "<%= payment_method.client_token(@order) %>";
    var errorMessagesContainer = "<%= payment_method.preferred_error_messages_container_id %>";
    var checkout; 
    var hf, threeDS;
    var hostedFieldsContainer = document.getElementById('hosted-fields');
    var payBtn = document.getElementById('pay-btn');
    var nonceGroup = document.querySelector('.nonce-group');
    var nonceInput = document.querySelector('.nonce-group input');
    var nonceSpan = document.querySelector('.nonce-group span');
    var payGroup = document.querySelector('.pay-group');
    var billingFields = [
      'email',
      'billing-phone',
      'billing-given-name',
      'billing-surname',
      'billing-street-address',
      'billing-locality',
      'billing-postal-code',
      'billing-country-code'
    ].reduce(function (fields, fieldName) {
      var field = fields[fieldName] = {
        input: document.getElementById(fieldName),
        help: document.getElementById('help-' + fieldName)
      };
    
      field.input.addEventListener('focus', function() {
        clearFieldValidations(field);
      });
    
      return fields;
    }, {});
    
    function clearFieldValidations (field) {
      field.help.innerText = '';
      field.help.parentNode.classList.remove('has-error');
    }
    
    function validateBillingFields() {
      var isValid = true;
    
      Object.keys(billingFields).forEach(function (fieldName) {
        var fieldEmpty = false;
        var field = billingFields[fieldName];
    
        if (field.optional) {
          return;
        }
    
        fieldEmpty = field.input.value.trim() === '';
    
        if (fieldEmpty) {
          isValid = false;
          field.help.innerText = "<%= t(:required) %>";
          field.help.parentNode.classList.add('has-error');
        } else {
          clearFieldValidations(field);
        }
      });
    
      return isValid;
    }
    
    function start() {
      getClientToken();
    }
    
    function getClientToken() {
      onFetchClientToken(clientToken);
    }
    
    function setupComponents (clientToken) {
      return Promise.all([
        braintree.hostedFields.create({
          authorization: clientToken,
          styles: {
            input: {
              'font-size': '14px',
              'font-family': 'monospace'
            }
          },
          fields: {
            number: {
              selector: '#hf-number',
              placeholder: '4111 1111 1111 1111'
            },
            cvv: {
              selector: '#hf-cvv',
              placeholder: '123'
            },
            expirationDate: {
              selector: '#hf-date',
              placeholder: '12 / 34'
            }
          }
        }),
        braintree.threeDSecure.create({
          authorization: clientToken,
          version: 2
        })
      ]);
    }
    
    function onFetchClientToken(clientToken) {
      return setupComponents(clientToken).then(function(instances) { 
        hf = instances[0];
        threeDS = instances[1];
    
        setupForm();
      }).catch(function (err) {
         console.log('component error:', err);
      });
    }
    
    function setupForm() {
      enablePayNow();
    }
    
    function enablePayNow() {
      payBtn.value = "<%= t(:pay_now) %>";
      payBtn.removeAttribute('disabled');
    }
    
    function showNonce(payload, liabilityShift) {
      nonceSpan.textContent = "Liability shifted: " + liabilityShift;
      nonceInput.value = payload.nonce;
      payGroup.classList.add('hidden');
      hostedFieldsContainer.classList.add('hidden');
      payGroup.style.display = 'none';
      hostedFieldsContainer.style.display = 'none';
      nonceGroup.classList.remove('hidden');
    }
    
    function showErrors() {
      payGroup.classList.add('hidden');
      payGroup.style.display = 'none';
      hostedFieldsContainer.style.display = 'none';
      $('.credit-card-pay-errors').removeClass('hidden')
    }
    
    function showSuccess() {
      payGroup.classList.add('hidden');
      payGroup.style.display = 'none';
      hostedFieldsContainer.style.display = 'none';
      $('.credit-card-pay-success').removeClass('hidden')
    }
    
    payBtn.addEventListener('click', function(event) {
      payBtn.setAttribute('disabled', 'disabled');
      payBtn.value = "<%= t(:processing_credit_card) %>";
    
      var billingIsValid = validateBillingFields();
    
      if (!billingIsValid) {
        enablePayNow();
    
        return;
      }
    
      hf.tokenize().then(function (payload) {
        return threeDS.verifyCard({
          onLookupComplete: function (data, next) {
            next();
          },
          amount: "<%= @order.total %>",
          nonce: payload.nonce,
          bin: payload.details.bin,
          email: billingFields.email.input.value,
          billingAddress: {
            givenName: billingFields['billing-given-name'].input.value,
            surname: billingFields['billing-surname'].input.value,
            phoneNumber: billingFields['billing-phone'].input.value.replace(/[\(\)\s\-]/g, ''), // remove (), spaces, and - from phone number
            streetAddress: billingFields['billing-street-address'].input.value,
            locality: billingFields['billing-locality'].input.value,
            postalCode: billingFields['billing-postal-code'].input.value,
            countryCodeAlpha2: billingFields['billing-country-code'].input.value
          }
        })
      }).then(function (payload) {
        if (!payload.liabilityShifted) {
          console.log('Liability did not shift', payload);
          showErrors();
          return;
        }
    
        console.log('verification success:', payload);
        showSuccess();
        $(formId).append("<input type='hidden' name='braintree_last_two' value=" + payload.details.lastTwo + ">");
        $(formId).append("<input type='hidden' name='braintree_card_type' value=" + payload.details.cardType.replace(/\s/g, "") + ">");
        $(formId).append("<input type='hidden' name='order[payments_attributes][][braintree_nonce]' value=" + payload.nonce + ">");
        $(formId).append("<input type='hidden' name='payment_method_nonce' value=" + payload.nonce + ">");
        setTimeout(function () {
          $('.checkout-btn').attr("disabled", false)
        }, 200);
      }).catch(function (err) {
        enablePayNow();
        showErrors();
      });
    });
    
    start();
    });
    </script>


I think that's all.
hefan commented 5 years ago

Great. Thanks for sharing!! Maybe the maintainers of the gem would like to integrate it?

damianlegawiec commented 5 years ago

Hey @matteomelotti could you submit a PR with your changes? Thanks!