diff --git a/app/assets/javascripts/solidus_paypal_braintree/checkout.js b/app/assets/javascripts/solidus_paypal_braintree/checkout.js index bebba34f..a0e24df0 100644 --- a/app/assets/javascripts/solidus_paypal_braintree/checkout.js +++ b/app/assets/javascripts/solidus_paypal_braintree/checkout.js @@ -44,16 +44,38 @@ $(function() { var $nonce = $("#payment_method_nonce", $field); if ($nonce.length > 0 && $nonce.val() === "") { + var client = braintreeForm._merchantConfigurationOptions._solidusClient; + event.preventDefault(); disableSubmit(); braintreeForm.tokenize(function(error, payload) { if (error) { braintreeError(error); - } else { - $nonce.val(payload.nonce); + return; + } + + $nonce.val(payload.nonce); + + if (!client.useThreeDSecure) { $paymentForm.submit(); + return; + } + + threeDSecureOptions.nonce = payload.nonce; + threeDSecureOptions.bin = payload.details.bin; + threeDSecureOptions.onLookupComplete = function(data, next) { + next(); } + client._threeDSecureInstance.verifyCard(threeDSecureOptions, function(error, response) { + if (error === null && (!response.liabilityShiftPossible || response.liabilityShifted)) { + $nonce.val(response.nonce); + $paymentForm.submit(); + } else { + $nonce.val(''); + braintreeError(error || { code: 'THREEDS_AUTHENTICATION_FAILED' }); + } + }); }); } } diff --git a/app/assets/javascripts/solidus_paypal_braintree/client.js b/app/assets/javascripts/solidus_paypal_braintree/client.js index 723ed512..29956efb 100644 --- a/app/assets/javascripts/solidus_paypal_braintree/client.js +++ b/app/assets/javascripts/solidus_paypal_braintree/client.js @@ -57,10 +57,12 @@ SolidusPaypalBraintree.Client = function(config) { this.useDataCollector = config.useDataCollector; this.usePaypal = config.usePaypal; this.useApplepay = config.useApplepay; + this.useThreeDSecure = config.useThreeDSecure; this._braintreeInstance = null; this._dataCollectorInstance = null; this._paypalInstance = null; + this._threeDSecureInstance = null; }; /** @@ -71,18 +73,22 @@ SolidusPaypalBraintree.Client.prototype.initialize = function() { var initializationPromise = this._fetchToken(). then(this._createBraintreeInstance.bind(this)); - if(this.useDataCollector) { + if (this.useDataCollector) { initializationPromise = initializationPromise.then(this._createDataCollector.bind(this)); } - if(this.usePaypal) { + if (this.usePaypal) { initializationPromise = initializationPromise.then(this._createPaypal.bind(this)); } - if(this.useApplepay) { + if (this.useApplepay) { initializationPromise = initializationPromise.then(this._createApplepay.bind(this)); } + if (this.useThreeDSecure) { + initializationPromise = initializationPromise.then(this._createThreeDSecure.bind(this)); + } + return initializationPromise.then(this._invokeReadyCallback.bind(this)); }; @@ -186,3 +192,14 @@ SolidusPaypalBraintree.Client.prototype._createApplepay = function() { return applePayInstance; }.bind(this)); }; + +SolidusPaypalBraintree.Client.prototype._createThreeDSecure = function() { + return SolidusPaypalBraintree.PromiseShim.convertBraintreePromise(braintree.threeDSecure.create, [{ + client: this._braintreeInstance, + version: 2 + }]).then(function (threeDSecureInstance) { + this._threeDSecureInstance = threeDSecureInstance; + }.bind(this), function(error) { + console.log(error); + }); +}; diff --git a/app/assets/javascripts/solidus_paypal_braintree/hosted_form.js b/app/assets/javascripts/solidus_paypal_braintree/hosted_form.js index a356dbfa..31f5b6a3 100644 --- a/app/assets/javascripts/solidus_paypal_braintree/hosted_form.js +++ b/app/assets/javascripts/solidus_paypal_braintree/hosted_form.js @@ -4,7 +4,11 @@ SolidusPaypalBraintree.HostedForm = function(paymentMethodId) { }; SolidusPaypalBraintree.HostedForm.prototype.initialize = function() { - this.client = SolidusPaypalBraintree.createClient({paymentMethodId: this.paymentMethodId}); + this.client = SolidusPaypalBraintree.createClient({ + paymentMethodId: this.paymentMethodId, + useThreeDSecure: (typeof(window.threeDSecureOptions) !== 'undefined'), + }); + return this.client.initialize(). then(this._createHostedFields.bind(this)); }; @@ -15,6 +19,7 @@ SolidusPaypalBraintree.HostedForm.prototype._createHostedFields = function () { } var opts = { + _solidusClient: this.client, client: this.client.getBraintreeInstance(), fields: { diff --git a/app/controllers/solidus_paypal_braintree/checkout_controller_decorator.rb b/app/controllers/solidus_paypal_braintree/checkout_controller_decorator.rb new file mode 100644 index 00000000..a7eb616c --- /dev/null +++ b/app/controllers/solidus_paypal_braintree/checkout_controller_decorator.rb @@ -0,0 +1,10 @@ +module SolidusPaypalBraintree + module CheckoutControllerDecorator + + def self.prepended(base) + base.helper ::SolidusPaypalBraintree::BraintreeCheckoutHelper + end + + ::Spree::CheckoutController.prepend(self) + end +end diff --git a/app/helpers/solidus_paypal_braintree/braintree_checkout_helper.rb b/app/helpers/solidus_paypal_braintree/braintree_checkout_helper.rb new file mode 100644 index 00000000..52dd21ab --- /dev/null +++ b/app/helpers/solidus_paypal_braintree/braintree_checkout_helper.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module SolidusPaypalBraintree + module BraintreeCheckoutHelper + + def braintree_3ds_options_for(order) + ship_address = order.ship_address + bill_address = order.bill_address + + { + nonce: nil, # populated after tokenization + bin: nil, # populated after tokenization + onLookupComplete: nil, # populated after tokenization + amount: order.total, + email: order.email, + billingAddress: { + givenName: bill_address.firstname, + surname: bill_address.lastname, + phoneNumber: bill_address.phone, + streetAddress: bill_address.address1, + extendedAddress: bill_address.address2, + locality: bill_address.city, + region: bill_address.state&.name, + postalCode: bill_address.zipcode, + countryCodeAlpha2: bill_address.country&.iso, + }, + additionalInformation: { + shippingGivenName: ship_address.firstname, + shippingSurname: ship_address.lastname, + shippingPhone: ship_address.phone, + shippingAddress: { + streedAddress: ship_address.address1, + extendedAddress: ship_address.address2, + locality: ship_address.city, + region: ship_address.state&.name, + postalCode: ship_address.zipcode, + countryCodeAlpha2: ship_address.country&.iso, + } + } + } + end + + end +end diff --git a/app/views/spree/shared/_braintree_errors.html.erb b/app/views/spree/shared/_braintree_errors.html.erb index 690fd07a..791a39c7 100644 --- a/app/views/spree/shared/_braintree_errors.html.erb +++ b/app/views/spree/shared/_braintree_errors.html.erb @@ -8,4 +8,6 @@ BraintreeError.HOSTED_FIELDS_FIELD_DUPLICATE_IFRAME = "<%= I18n.t('solidus_paypal_braintree.errors.duplicate_iframe')%>" BraintreeError.HOSTED_FIELDS_TOKENIZATION_FAIL_ON_DUPLICATE = "<%= I18n.t('solidus_paypal_braintree.errors.fail_on_duplicate')%>" BraintreeError.HOSTED_FIELDS_TOKENIZATION_CVV_VERIFICATION_FAILED = "<%= I18n.t('solidus_paypal_braintree.errors.cvv_verification_failed')%>" + BraintreeError.THREEDS_AUTHENTICATION_FAILED = "<%= t('solidus_paypal_braintree.errors.threeds.authentication_failed') %>"; + BraintreeError.THREEDS_CARDINAL_SDK_ERROR = "<%= t('solidus_paypal_braintree.errors.threeds.authentication_failed') %>"; diff --git a/app/views/spree/shared/_braintree_hosted_fields.html.erb b/app/views/spree/shared/_braintree_hosted_fields.html.erb index 275f3250..29c6b10d 100644 --- a/app/views/spree/shared/_braintree_hosted_fields.html.erb +++ b/app/views/spree/shared/_braintree_hosted_fields.html.erb @@ -24,3 +24,9 @@ + +<% if current_store.braintree_configuration.three_d_secure? %> + +<% end -%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 89c1d0ca..c09e052f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -35,6 +35,8 @@ en: duplicate_iframe: "Duplicate Braintree iframe." fail_on_duplicate: "This payment method already exists in your vault." cvv_verification_failed: "CVV did not pass verification." + threeds: + authentication_failed: "3D Secure authentication failed. Please try again using a different payment method." payment_type: label: Payment Type apple_pay_card: Apple Pay diff --git a/spec/features/frontend/braintree_credit_card_checkout_spec.rb b/spec/features/frontend/braintree_credit_card_checkout_spec.rb index 4d87de48..cd9deaa7 100644 --- a/spec/features/frontend/braintree_credit_card_checkout_spec.rb +++ b/spec/features/frontend/braintree_credit_card_checkout_spec.rb @@ -4,11 +4,16 @@ shared_context "checkout setup" do let(:braintree) { new_gateway(active: true) } let!(:gateway) { create :payment_method } + let(:three_d_secure_enabled) { false } before(:each) do braintree.save! + create(:store, payment_methods: [gateway, braintree]).tap do |store| - store.braintree_configuration.update!(credit_card: true) + store.braintree_configuration.update!( + credit_card: true, + three_d_secure: three_d_secure_enabled + ) end if SolidusSupport.solidus_gem_version >= Gem::Version.new('2.6.0') @@ -44,27 +49,59 @@ cassette_name: 'checkout/valid_credit_card', match_requests_on: [:braintree_uri] } do + let(:card_number) { "4111111111111111" } + let(:card_expiration) { "01/#{Time.now.year+2}" } + let(:card_cvv) { "123" } include_context "checkout setup" - it "checks out successfully" do + before do within_frame("braintree-hosted-field-number") do - fill_in("credit-card-number", with: "4111111111111111") + fill_in("credit-card-number", with: card_number) end within_frame("braintree-hosted-field-expirationDate") do - fill_in("expiration", with: "02/2020") + fill_in("expiration", with: card_expiration) end within_frame("braintree-hosted-field-cvv") do - fill_in("cvv", with: "123") + fill_in("cvv", with: card_cvv) end click_button("Save and Continue") + end + + it "checks out successfully" do within("#order_details") do expect(page).to have_content("CONFIRM") end click_button("Place Order") expect(page).to have_content("Your order has been processed successfully") end + + context 'and having 3D secure enabled' do + let(:three_d_secure_enabled) { true } + let(:card_number) { "4000000000001091" } + + it 'checks out successfully' do + authenticate_3ds + + within("#order_details") do + expect(page).to have_content("CONFIRM") + end + + click_button("Place Order") + expect(page).to have_content("Your order has been processed successfully") + end + + context 'with 3ds authentication error' do + let(:card_number) { "4000000000001125" } + + it 'shows a 3ds authentication error' do + authenticate_3ds + expect(page).to have_content("3D Secure authentication failed. Please try again using a different payment method.") + end + end + + end end context "with invalid credit card data" do @@ -113,4 +150,11 @@ end end end + + def authenticate_3ds + within_frame("Cardinal-CCA-IFrame") do + fill_in("challengeDataEntry", with: "1234") + click_button("SUBMIT") + end + end end