diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index e953aa0202..77b598ad3c 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -412,6 +412,8 @@ Metrics/AbcSize: - app/services/create_order_cycle.rb - app/services/order_syncer.rb - app/services/subscription_validator.rb + - lib/active_merchant/billing/gateways/stripe_decorator.rb + - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb - lib/open_food_network/customers_report.rb @@ -505,6 +507,7 @@ Metrics/CyclomaticComplexity: - app/models/spree/product_decorator.rb - app/models/variant_override_set.rb - app/services/cart_service.rb + - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb - lib/open_food_network/enterprise_issue_validator.rb @@ -530,6 +533,7 @@ Metrics/PerceivedComplexity: - app/models/spree/ability_decorator.rb - app/models/spree/order_decorator.rb - app/models/spree/product_decorator.rb + - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb - lib/open_food_network/enterprise_issue_validator.rb @@ -599,6 +603,7 @@ Metrics/MethodLength: - app/serializers/api/cached_enterprise_serializer.rb - app/services/order_cycle_form.rb - engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb + - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb - lib/open_food_network/column_preference_defaults.rb @@ -662,6 +667,7 @@ Metrics/ClassLength: - app/serializers/api/enterprise_shopfront_serializer.rb - app/services/cart_service.rb - engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb + - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/open_food_network/bulk_coop_report.rb - lib/open_food_network/enterprise_fee_calculator.rb - lib/open_food_network/order_cycle_form_applicator.rb diff --git a/app/assets/javascripts/admin/payments/services/payment.js.coffee b/app/assets/javascripts/admin/payments/services/payment.js.coffee index f079137818..a87497226d 100644 --- a/app/assets/javascripts/admin/payments/services/payment.js.coffee +++ b/app/assets/javascripts/admin/payments/services/payment.js.coffee @@ -21,7 +21,7 @@ angular.module('admin.payments').factory 'Payment', (AdminStripeElements, curren year: @form_data.card_year verification_value: @form_data.card_verification_value } - when 'stripe' + when 'stripe', 'stripe_sca' angular.extend munged_payment.payment, { source_attributes: gateway_payment_profile_id: @form_data.token @@ -35,6 +35,8 @@ angular.module('admin.payments').factory 'Payment', (AdminStripeElements, curren purchase: -> if @paymentMethodType() == 'stripe' AdminStripeElements.requestToken(@form_data, @submit) + else if @paymentMethodType() == 'stripe_sca' + AdminStripeElements.createPaymentMethod(@form_data, @submit) else @submit() diff --git a/app/assets/javascripts/admin/payments/services/stripe_elements.js.coffee b/app/assets/javascripts/admin/payments/services/stripe_elements.js.coffee index 03971be228..9ecf2b1db1 100644 --- a/app/assets/javascripts/admin/payments/services/stripe_elements.js.coffee +++ b/app/assets/javascripts/admin/payments/services/stripe_elements.js.coffee @@ -5,7 +5,7 @@ angular.module("admin.payments").factory 'AdminStripeElements', ($rootScope, Sta stripe: null card: null - # New Stripe Elements method + # Create Token to be used with the Stripe Charges API requestToken: (secrets, submit) -> return unless @stripe? && @card? @@ -20,6 +20,21 @@ angular.module("admin.payments").factory 'AdminStripeElements', ($rootScope, Sta secrets.card = response.token.card submit() + # Create Payment Method to be used with the Stripe Payment Intents API + createPaymentMethod: (secrets, submit) -> + return unless @stripe? && @card? + + cardData = @makeCardData(secrets) + + @stripe.createPaymentMethod({ type: 'card', card: @card }, @card, cardData).then (response) => + if(response.error) + StatusMessage.display 'error', response.error.message + else + secrets.token = response.paymentMethod.id + secrets.cc_type = response.paymentMethod.card.brand + secrets.card = response.paymentMethod.card + submit() + # Maps the brand returned by Stripe to that required by activemerchant mapCC: (ccType) -> switch ccType diff --git a/app/assets/javascripts/admin/subscriptions/controllers/details_controller.js.coffee b/app/assets/javascripts/admin/subscriptions/controllers/details_controller.js.coffee index 55d2a46b42..cb331a62f3 100644 --- a/app/assets/javascripts/admin/subscriptions/controllers/details_controller.js.coffee +++ b/app/assets/javascripts/admin/subscriptions/controllers/details_controller.js.coffee @@ -16,7 +16,7 @@ angular.module("admin.subscriptions").controller "DetailsController", ($scope, $ return if !newValue? paymentMethod = ($scope.paymentMethods.filter (pm) -> pm.id == newValue)[0] return unless paymentMethod? - $scope.cardRequired = (paymentMethod.type == "Spree::Gateway::StripeConnect") + $scope.cardRequired = (paymentMethod.type == "Spree::Gateway::StripeConnect" || paymentMethod.type == "Spree::Gateway::StripeSCA") $scope.loadCustomer() if $scope.cardRequired && !$scope.customer $scope.loadCustomer = -> diff --git a/app/assets/javascripts/darkswarm/services/checkout.js.coffee b/app/assets/javascripts/darkswarm/services/checkout.js.coffee index 204c35ac66..83cf018365 100644 --- a/app/assets/javascripts/darkswarm/services/checkout.js.coffee +++ b/app/assets/javascripts/darkswarm/services/checkout.js.coffee @@ -7,6 +7,8 @@ Darkswarm.factory 'Checkout', ($injector, CurrentOrder, ShippingMethods, StripeE purchase: -> if @paymentMethod()?.method_type == 'stripe' && !@secrets.selected_card StripeElements.requestToken(@secrets, @submit) + else if @paymentMethod()?.method_type == 'stripe_sca' && !@secrets.selected_card + StripeElements.createPaymentMethod(@secrets, @submit) else @submit() @@ -59,7 +61,7 @@ Darkswarm.factory 'Checkout', ($injector, CurrentOrder, ShippingMethods, StripeE last_name: @order.bill_address.lastname } - if @paymentMethod()?.method_type == 'stripe' + if @paymentMethod()?.method_type == 'stripe' || @paymentMethod()?.method_type == 'stripe_sca' if @secrets.selected_card angular.extend munged_order, { existing_card_id: @secrets.selected_card diff --git a/app/assets/javascripts/darkswarm/services/stripe_elements.js.coffee b/app/assets/javascripts/darkswarm/services/stripe_elements.js.coffee index 32b0535251..acd220f092 100644 --- a/app/assets/javascripts/darkswarm/services/stripe_elements.js.coffee +++ b/app/assets/javascripts/darkswarm/services/stripe_elements.js.coffee @@ -1,12 +1,10 @@ Darkswarm.factory 'StripeElements', ($rootScope, Loading, RailsFlashLoader) -> new class StripeElements - # TODO: add locale here for translations of error messages etc. from Stripe - # These are both set from the StripeElements directive stripe: null card: null - # New Stripe Elements method + # Create Token to be used with the Stripe Charges API requestToken: (secrets, submit, loading_message = t("processing_payment")) -> return unless @stripe? && @card? @@ -23,6 +21,23 @@ Darkswarm.factory 'StripeElements', ($rootScope, Loading, RailsFlashLoader) -> secrets.card = response.token.card submit() + # Create Payment Method to be used with the Stripe Payment Intents API + createPaymentMethod: (secrets, submit, loading_message = t("processing_payment")) -> + return unless @stripe? && @card? + + Loading.message = loading_message + cardData = @makeCardData(secrets) + + @stripe.createPaymentMethod({ type: 'card', card: @card }, @card, cardData).then (response) => + if(response.error) + Loading.clear() + RailsFlashLoader.loadFlash({error: t("error") + ": #{response.error.message}"}) + else + secrets.token = response.paymentMethod.id + secrets.cc_type = response.paymentMethod.card.brand + secrets.card = response.paymentMethod.card + submit() + # Maps the brand returned by Stripe to that required by activemerchant mapCC: (ccType) -> switch ccType diff --git a/app/controllers/spree/admin/payment_methods_controller.rb b/app/controllers/spree/admin/payment_methods_controller.rb index 53bf22d06a..c13712b9a1 100644 --- a/app/controllers/spree/admin/payment_methods_controller.rb +++ b/app/controllers/spree/admin/payment_methods_controller.rb @@ -110,7 +110,7 @@ module Spree else Gateway.providers.reject{ |p| p.name.include? "Bogus" }.sort_by(&:name) end - @providers.reject!{ |p| p.name.ends_with? "StripeConnect" } unless show_stripe? + @providers.reject!{ |provider| stripe_provider?(provider) } unless show_stripe? @calculators = PaymentMethod.calculators.sort_by(&:name) end @@ -134,12 +134,12 @@ module Spree # current payment_method is already a Stripe method def show_stripe? Spree::Config.stripe_connect_enabled || - @payment_method.try(:type) == "Spree::Gateway::StripeConnect" + stripe_payment_method? end def restrict_stripe_account_change return unless @payment_method - return unless @payment_method.type == "Spree::Gateway::StripeConnect" + return unless stripe_payment_method? return unless @payment_method.preferred_enterprise_id.andand > 0 @stripe_account_holder = Enterprise.find(@payment_method.preferred_enterprise_id) @@ -147,6 +147,15 @@ module Spree params[:payment_method][:preferred_enterprise_id] = @stripe_account_holder.id end + + def stripe_payment_method? + ["Spree::Gateway::StripeConnect", + "Spree::Gateway::StripeSCA"].include? @payment_method.try(:type) + end + + def stripe_provider?(provider) + provider.name.ends_with?("StripeConnect", "StripeSCA") + end end end end diff --git a/app/controllers/spree/credit_cards_controller.rb b/app/controllers/spree/credit_cards_controller.rb index 79f60b4323..fbad74e943 100644 --- a/app/controllers/spree/credit_cards_controller.rb +++ b/app/controllers/spree/credit_cards_controller.rb @@ -10,10 +10,14 @@ module Spree render json: @credit_card, serializer: ::Api::CreditCardSerializer, status: :ok else message = t(:card_could_not_be_saved) - render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, error: message) } }, status: :bad_request + render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, + error: message) } }, + status: :bad_request end rescue Stripe::CardError => e - render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, error: e.message) } }, status: :bad_request + render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, + error: e.message) } }, + status: :bad_request end def update @@ -54,10 +58,23 @@ module Spree # Currently can only destroy the whole customer object def destroy_at_stripe - stripe_customer = Stripe::Customer.retrieve(@credit_card.gateway_customer_profile_id) + if @credit_card.payment_method && + @credit_card.payment_method.type == "Spree::Gateway::StripeSCA" + options = { stripe_account: stripe_account_id } + end + + stripe_customer = Stripe::Customer.retrieve(@credit_card.gateway_customer_profile_id, + options || {}) stripe_customer.delete if stripe_customer end + def stripe_account_id + StripeAccount. + find_by_enterprise_id(@credit_card.payment_method.preferred_enterprise_id). + andand. + stripe_user_id + end + def create_customer(token) Stripe::Customer.create(email: spree_current_user.email, source: token) end diff --git a/app/models/spree/gateway/stripe_connect.rb b/app/models/spree/gateway/stripe_connect.rb index 052423e5e0..d1a7a70c2b 100644 --- a/app/models/spree/gateway/stripe_connect.rb +++ b/app/models/spree/gateway/stripe_connect.rb @@ -9,12 +9,6 @@ module Spree attr_accessible :preferred_enterprise_id - CARD_TYPE_MAPPING = { - 'American Express' => 'american_express', - 'Diners Club' => 'diners_club', - 'Visa' => 'visa' - }.freeze - def method_type 'stripe' end @@ -77,11 +71,6 @@ module Spree [money, creditcard, options] end - def update_source!(source) - source.cc_type = CARD_TYPE_MAPPING[source.cc_type] if CARD_TYPE_MAPPING.include?(source.cc_type) - source - end - def token_from_card_profile_ids(creditcard) token_or_card_id = creditcard.gateway_payment_profile_id customer = creditcard.gateway_customer_profile_id diff --git a/app/models/spree/gateway/stripe_sca.rb b/app/models/spree/gateway/stripe_sca.rb new file mode 100644 index 0000000000..931b178475 --- /dev/null +++ b/app/models/spree/gateway/stripe_sca.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'stripe/profile_storer' +require 'active_merchant/billing/gateways/stripe_payment_intents' +require 'active_merchant/billing/gateways/stripe_decorator' + +module Spree + class Gateway + class StripeSCA < Gateway + preference :enterprise_id, :integer + + validate :ensure_enterprise_selected + + attr_accessible :preferred_enterprise_id + + def method_type + 'stripe_sca' + end + + def provider_class + ActiveMerchant::Billing::StripePaymentIntentsGateway + end + + def payment_profiles_supported? + true + end + + def stripe_account_id + StripeAccount.find_by_enterprise_id(preferred_enterprise_id).andand.stripe_user_id + end + + # NOTE: the name of this method is determined by Spree::Payment::Processing + def purchase(money, creditcard, gateway_options) + provider.purchase(*options_for_purchase_or_auth(money, creditcard, gateway_options)) + rescue Stripe::StripeError => e + # This will be an error caused by generating a stripe token + failed_activemerchant_billing_response(e.message) + end + + # NOTE: the name of this method is determined by Spree::Payment::Processing + def void(response_code, _creditcard, gateway_options) + gateway_options[:stripe_account] = stripe_account_id + provider.void(response_code, gateway_options) + end + + # NOTE: the name of this method is determined by Spree::Payment::Processing + def credit(money, _creditcard, response_code, gateway_options) + gateway_options[:stripe_account] = stripe_account_id + provider.refund(money, response_code, gateway_options) + end + + def create_profile(payment) + return unless payment.source.gateway_customer_profile_id.nil? + + profile_storer = Stripe::ProfileStorer.new(payment, provider, stripe_account_id) + profile_storer.create_customer_from_token + end + + private + + # In this gateway, what we call 'secret_key' is the 'login' + def options + options = super + options.merge(login: Stripe.api_key) + end + + def options_for_purchase_or_auth(money, creditcard, gateway_options) + options = {} + options[:description] = "Spree Order ID: #{gateway_options[:order_id]}" + options[:currency] = gateway_options[:currency] + options[:stripe_account] = stripe_account_id + + options[:customer] = creditcard.gateway_customer_profile_id + creditcard = creditcard.gateway_payment_profile_id + + [money, creditcard, options] + end + + def failed_activemerchant_billing_response(error_message) + ActiveMerchant::Billing::Response.new(false, error_message) + end + + def ensure_enterprise_selected + return if preferred_enterprise_id.andand.positive? + + errors.add(:stripe_account_owner, I18n.t(:error_required)) + end + end + end +end diff --git a/app/models/spree/payment_method_decorator.rb b/app/models/spree/payment_method_decorator.rb index f32944fdcb..a6d4b19c48 100644 --- a/app/models/spree/payment_method_decorator.rb +++ b/app/models/spree/payment_method_decorator.rb @@ -68,6 +68,8 @@ Spree::PaymentMethod.class_eval do "Pin Payments" when "Spree::Gateway::StripeConnect" "Stripe" + when "Spree::Gateway::StripeSCA" + "Stripe SCA" when "Spree::Gateway::PayPalExpress" "PayPal Express" else diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 443a6ef7a4..ca1fd676e8 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,5 +1,7 @@ class Subscription < ActiveRecord::Base - ALLOWED_PAYMENT_METHOD_TYPES = ["Spree::PaymentMethod::Check", "Spree::Gateway::StripeConnect"].freeze + ALLOWED_PAYMENT_METHOD_TYPES = ["Spree::PaymentMethod::Check", + "Spree::Gateway::StripeConnect", + "Spree::Gateway::StripeSCA"].freeze belongs_to :shop, class_name: 'Enterprise' belongs_to :customer diff --git a/app/serializers/api/admin/payment_method_serializer.rb b/app/serializers/api/admin/payment_method_serializer.rb index 3d66ddbc03..9862b81dcd 100644 --- a/app/serializers/api/admin/payment_method_serializer.rb +++ b/app/serializers/api/admin/payment_method_serializer.rb @@ -4,7 +4,8 @@ module Api delegate :serializable_hash, to: :method_serializer def method_serializer - if object.type == 'Spree::Gateway::StripeConnect' + if object.type == 'Spree::Gateway::StripeConnect' || + object.type == 'Spree::Gateway::StripeSCA' Api::Admin::PaymentMethod::StripeSerializer.new(object) else Api::Admin::PaymentMethod::BaseSerializer.new(object) diff --git a/app/services/subscription_validator.rb b/app/services/subscription_validator.rb index 8d9a678f9a..4cce8a3af3 100644 --- a/app/services/subscription_validator.rb +++ b/app/services/subscription_validator.rb @@ -82,13 +82,18 @@ class SubscriptionValidator def credit_card_ok? return unless customer && payment_method - return unless payment_method.type == "Spree::Gateway::StripeConnect" + return unless stripe_payment_method?(payment_method) return errors.add(:payment_method, :charges_not_allowed) unless customer.allow_charges return if customer.user.andand.default_card.present? errors.add(:payment_method, :no_default_card) end + def stripe_payment_method?(payment_method) + payment_method.type == "Spree::Gateway::StripeConnect" || + payment_method.type == "Spree::Gateway::StripeSCA" + end + def subscription_line_items_present? return if subscription_line_items.reject(&:marked_for_destruction?).any? diff --git a/app/views/checkout/_payment.html.haml b/app/views/checkout/_payment.html.haml index fdbd103203..928578158f 100644 --- a/app/views/checkout/_payment.html.haml +++ b/app/views/checkout/_payment.html.haml @@ -1,8 +1,3 @@ -- content_for :injection_data do - - if Stripe.publishable_key - :javascript - angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}")) - %fieldset#payment %ng-form{"ng-controller" => "PaymentCtrl", name: "payment"} diff --git a/app/views/spree/admin/payment_methods/_provider_settings.html.haml b/app/views/spree/admin/payment_methods/_provider_settings.html.haml index c64ad9f1d2..38f2ff06e9 100644 --- a/app/views/spree/admin/payment_methods/_provider_settings.html.haml +++ b/app/views/spree/admin/payment_methods/_provider_settings.html.haml @@ -1,6 +1,9 @@ += @payment_method - case @payment_method - when Spree::Gateway::StripeConnect = render 'stripe_connect' +- when Spree::Gateway::StripeSCA + = render 'stripe_connect' - else - if @payment_method.preferences.present? %fieldset.alpha.eleven.columns.no-border-bottom#gateway_fields diff --git a/app/views/spree/admin/payments/source_forms/_stripe_sca.html.haml b/app/views/spree/admin/payments/source_forms/_stripe_sca.html.haml new file mode 100644 index 0000000000..b8939ea4a7 --- /dev/null +++ b/app/views/spree/admin/payments/source_forms/_stripe_sca.html.haml @@ -0,0 +1,16 @@ +.stripe + %script{:src => "https://js.stripe.com/v3/", :type => "text/javascript"} + - if Stripe.publishable_key + :javascript + angular.module('admin.payments').value("stripeObject", Stripe("#{Stripe.publishable_key}", { stripeAccount: "#{StripeAccount.find_by_enterprise_id(payment_method.preferred_enterprise_id).andand.stripe_user_id}" })) + + .row + .three.columns + = label_tag :cardholder_name, t(:cardholder_name) + .six.columns + = text_field_tag :cardholder_name, nil, {size: 40, "ng-model" => 'form_data.name'} + .row + .three.columns + = label_tag :card_details, t(:card_details) + .six.columns + %stripe-elements diff --git a/app/views/spree/checkout/payment/_stripe.html.haml b/app/views/spree/checkout/payment/_stripe.html.haml index eace9f00a7..2929b90d3e 100644 --- a/app/views/spree/checkout/payment/_stripe.html.haml +++ b/app/views/spree/checkout/payment/_stripe.html.haml @@ -1,3 +1,8 @@ +- content_for :injection_data do + - if Stripe.publishable_key + :javascript + angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}")) + .row{ "ng-show" => "savedCreditCards.length > 0" } .small-12.columns %h6= t('.used_saved_card') diff --git a/app/views/spree/checkout/payment/_stripe_sca.html.haml b/app/views/spree/checkout/payment/_stripe_sca.html.haml new file mode 100644 index 0000000000..00ded42afe --- /dev/null +++ b/app/views/spree/checkout/payment/_stripe_sca.html.haml @@ -0,0 +1,22 @@ +- content_for :injection_data do + - if Stripe.publishable_key + :javascript + angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}", { stripeAccount: "#{StripeAccount.find_by_enterprise_id(payment_method.preferred_enterprise_id).andand.stripe_user_id}" })) + +.row{ "ng-show" => "savedCreditCards.length > 0" } + .small-12.columns + %h6= t('.used_saved_card') + %select{ name: "selected_card", required: false, ng: { model: "secrets.selected_card", options: "card.id as card.formatted for card in savedCreditCards" } } + %option{ value: "" }= "{{ secrets.selected_card ? '#{t('.enter_new_card')}' : '#{t('.choose_one')}' }}" + + %h6{ ng: { if: '!secrets.selected_card' } } + = t('.or_enter_new_card') + +%div{ ng: { if: '!secrets.selected_card' } } + %stripe-elements + + - if spree_current_user + .row + .small-12.columns.text-right + = check_box_tag 'secrets.save_requested_by_customer', '1', false, 'ng-model' => 'secrets.save_requested_by_customer' + = label_tag 'secrets.save_requested_by_customer', t('.remember_this_card') diff --git a/config/application.rb b/config/application.rb index 057d50ff2f..2799e52d2f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -92,6 +92,7 @@ module Openfoodnetwork app.config.spree.payment_methods << Spree::Gateway::Migs app.config.spree.payment_methods << Spree::Gateway::Pin app.config.spree.payment_methods << Spree::Gateway::StripeConnect + app.config.spree.payment_methods << Spree::Gateway::StripeSCA end # Settings in config/environments/* take precedence over those specified here. diff --git a/config/locales/en.yml b/config/locales/en.yml index 2393ee26d7..25af1defa2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3383,6 +3383,12 @@ See the %{link} to find out more about %{sitename}'s features and to start using used_saved_card: "Use a saved card:" or_enter_new_card: "Or, enter details for a new card:" remember_this_card: Remember this card? + stripe_sca: + choose_one: Choose one + enter_new_card: Enter details for a new card + used_saved_card: "Use a saved card:" + or_enter_new_card: "Or, enter details for a new card:" + remember_this_card: Remember this card? date_picker: format: ! '%Y-%m-%d' js_format: 'yy-mm-dd' diff --git a/lib/active_merchant/billing/gateways/stripe_decorator.rb b/lib/active_merchant/billing/gateways/stripe_decorator.rb new file mode 100644 index 0000000000..fcd23a6446 --- /dev/null +++ b/lib/active_merchant/billing/gateways/stripe_decorator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Here we bring commit 823faaeab0d6d3bd75ee037ec894ab7c9d95d3a9 from ActiveMerchant v1.98.0 +# This is needed to make StripePaymentIntents work correctly +# This can be removed once we upgrade to ActiveMerchant v1.98.0 +ActiveMerchant::Billing::StripeGateway.class_eval do + def authorization_from(success, url, method, response) + return response.fetch('error', {})['charge'] unless success + + if url == 'customers' + [response['id'], response.dig('sources', 'data').first&.dig('id')].join('|') + elsif method == :post && + (url.match(%r{customers/.*/cards}) || url.match(%r{payment_methods/.*/attach})) + [response['customer'], response['id']].join('|') + else + response['id'] + end + end +end diff --git a/lib/active_merchant/billing/gateways/stripe_payment_intents.rb b/lib/active_merchant/billing/gateways/stripe_payment_intents.rb new file mode 100644 index 0000000000..9c5c602c7a --- /dev/null +++ b/lib/active_merchant/billing/gateways/stripe_payment_intents.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +# Here we bring commit 823faaeab0d6d3bd75ee037ec894ab7c9d95d3a9 from ActiveMerchant v1.98.0 +# This class integrates with the new StripePaymentIntents API +# This can be removed once we upgrade to ActiveMerchant v1.98.0 +require 'active_support/core_ext/hash/slice' + +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + # This gateway uses the current Stripe + # {Payment Intents API}[https://stripe.com/docs/api/payment_intents]. + # For the legacy API, see the Stripe gateway + class StripePaymentIntentsGateway < StripeGateway + ALLOWED_METHOD_STATES = %w[automatic manual].freeze + ALLOWED_CANCELLATION_REASONS = %w[duplicate fraudulent requested_by_customer abandoned].freeze + CREATE_INTENT_ATTRIBUTES = + %i[description statement_descriptor receipt_email save_payment_method].freeze + CONFIRM_INTENT_ATTRIBUTES = + %i[receipt_email return_url save_payment_method setup_future_usage off_session].freeze + UPDATE_INTENT_ATTRIBUTES = + %i[description statement_descriptor receipt_email setup_future_usage].freeze + DEFAULT_API_VERSION = '2019-05-16' + + def create_intent(money, payment_method, options = {}) + post = {} + add_amount(post, money, options, true) + add_capture_method(post, options) + add_confirmation_method(post, options) + add_customer(post, options) + add_payment_method_token(post, payment_method, options) + add_metadata(post, options) + add_return_url(post, options) + add_connected_account(post, options) + add_shipping_address(post, options) + setup_future_usage(post, options) + + CREATE_INTENT_ATTRIBUTES.each do |attribute| + add_whitelisted_attribute(post, options, attribute) + end + + commit(:post, 'payment_intents', post, options) + end + + def show_intent(intent_id, options) + commit(:get, "payment_intents/#{intent_id}", nil, options) + end + + def confirm_intent(intent_id, payment_method, options = {}) + post = {} + add_payment_method_token(post, payment_method, options) + CONFIRM_INTENT_ATTRIBUTES.each do |attribute| + add_whitelisted_attribute(post, options, attribute) + end + + commit(:post, "payment_intents/#{intent_id}/confirm", post, options) + end + + def create_payment_method(payment_method, options = {}) + post = {} + post[:type] = 'card' + post[:card] = {} + post[:card][:number] = payment_method.number + post[:card][:exp_month] = payment_method.month + post[:card][:exp_year] = payment_method.year + post[:card][:cvc] = payment_method.verification_value if payment_method.verification_value + + commit(:post, 'payment_methods', post, options) + end + + def update_intent(money, intent_id, payment_method, options = {}) + post = {} + post[:amount] = money if money + + add_payment_method_token(post, payment_method, options) + add_payment_method_types(post, options) + add_customer(post, options) + add_metadata(post, options) + add_shipping_address(post, options) + add_connected_account(post, options) + + UPDATE_INTENT_ATTRIBUTES.each do |attribute| + add_whitelisted_attribute(post, options, attribute) + end + + commit(:post, "payment_intents/#{intent_id}", post, options) + end + + def authorize(money, payment_method, options = {}) + create_intent(money, + payment_method, + options.merge!(confirm: true, capture_method: 'manual')) + end + + def purchase(money, payment_method, options = {}) + create_intent(money, + payment_method, + options.merge!(confirm: true, capture_method: 'automatic')) + end + + def capture(money, intent_id, options = {}) + post = {} + post[:amount_to_capture] = money + add_connected_account(post, options) + commit(:post, "payment_intents/#{intent_id}/capture", post, options) + end + + def void(intent_id, options = {}) + post = {} + if ALLOWED_CANCELLATION_REASONS.include?(options[:cancellation_reason]) + post[:cancellation_reason] = options[:cancellation_reason] + end + commit(:post, "payment_intents/#{intent_id}/cancel", post, options) + end + + def refund(money, intent_id, options = {}) + intent = commit(:get, "payment_intents/#{intent_id}", nil, options) + charge_id = intent.params.dig('charges', 'data')[0].dig('id') + super(money, charge_id, options) + end + + # Note: Not all payment methods are currently supported by the + # {Payment Methods API}[https://stripe.com/docs/payments/payment-methods] + # Current implementation will create + # a PaymentMethod object if the method is a token or credit card + # All other types will default to legacy Stripe store + def store(payment_method, options = {}) + params = {} + post = {} + + # If customer option is provided, create a payment method and attach to customer id + # Otherwise, create a customer, then attach + # if payment_method.is_a?(StripePaymentToken) || + # payment_method.is_a?(ActiveMerchant::Billing::CreditCard) + add_payment_method_token(params, payment_method, options) + if options[:customer] + customer_id = options[:customer] + else + post[:validate] = options[:validate] unless options[:validate].nil? + post[:description] = options[:description] if options[:description] + post[:email] = options[:email] if options[:email] + customer = commit(:post, 'customers', post, options) + customer_id = customer.params['id'] + + # return the stripe response if expected customer id is not present + return customer if customer_id.nil? + end + commit(:post, + "payment_methods/#{params[:payment_method]}/attach", + { customer: customer_id }, options) + # else + # super(payment, options) + # end + end + + def unstore(identification, options = {}, deprecated_options = {}) + if identification.include?('pm_') + _, payment_method = identification.split('|') + commit(:post, "payment_methods/#{payment_method}/detach", nil, options) + else + super(identification, options, deprecated_options) + end + end + + private + + def add_whitelisted_attribute(post, options, attribute) + post[attribute] = options[attribute] if options[attribute] + post + end + + def add_capture_method(post, options) + capture_method = options[:capture_method].to_s + post[:capture_method] = capture_method if ALLOWED_METHOD_STATES.include?(capture_method) + post + end + + def add_confirmation_method(post, options) + confirmation_method = options[:confirmation_method].to_s + if ALLOWED_METHOD_STATES.include?(confirmation_method) + post[:confirmation_method] = confirmation_method + end + post + end + + def add_customer(post, options) + customer = options[:customer].to_s + post[:customer] = customer if customer.start_with?('cus_') + post + end + + def add_return_url(post, options) + return unless options[:confirm] + + post[:confirm] = options[:confirm] + post[:return_url] = options[:return_url] if options[:return_url] + post + end + + def add_payment_method_token(post, payment_method, options) + return if payment_method.nil? + + if payment_method.is_a?(ActiveMerchant::Billing::CreditCard) + p = create_payment_method(payment_method, options) + payment_method = p.params['id'] + end + + if payment_method.is_a?(StripePaymentToken) + post[:payment_method] = payment_method.payment_data['id'] + elsif payment_method.is_a?(String) + if payment_method.include?('|') + customer_id, payment_method_id = payment_method.split('|') + token = payment_method_id + post[:customer] = customer_id + else + token = payment_method + end + post[:payment_method] = token + end + end + + def add_payment_method_types(post, options) + payment_method_types = options[:payment_method_types] if options[:payment_method_types] + return if payment_method_types.nil? + + post[:payment_method_types] = Array(payment_method_types) + post + end + + def setup_future_usage(post, options = {}) + if %w(on_session off_session).include?(options[:setup_future_usage]) + post[:setup_future_usage] = options[:setup_future_usage] + end + if options[:off_session] && options[:confirm] == true + post[:off_session] = options[:off_session] + end + post + end + + def add_connected_account(post, options = {}) + return unless transfer_data = options[:transfer_data] + + post[:transfer_data] = {} + if transfer_data[:destination] + post[:transfer_data][:destination] = transfer_data[:destination] + end + post[:transfer_data][:amount] = transfer_data[:amount] if transfer_data[:amount] + post[:on_behalf_of] = options[:on_behalf_of] if options[:on_behalf_of] + post[:transfer_group] = options[:transfer_group] if options[:transfer_group] + post[:application_fee_amount] = options[:application_fee] if options[:application_fee] + post + end + + def add_shipping_address(post, options = {}) + return unless shipping = options[:shipping] + + post[:shipping] = {} + post[:shipping][:address] = {} + post[:shipping][:address][:line1] = shipping[:address][:line1] + post[:shipping][:address][:city] = shipping[:address][:city] if shipping[:address][:city] + if shipping[:address][:country] + post[:shipping][:address][:country] = shipping[:address][:country] + end + post[:shipping][:address][:line2] = shipping[:address][:line2] if shipping[:address][:line2] + if shipping[:address][:postal_code] + post[:shipping][:address][:postal_code] = shipping[:address][:postal_code] + end + post[:shipping][:address][:state] = shipping[:address][:state] if shipping[:address][:state] + + post[:shipping][:name] = shipping[:name] + post[:shipping][:carrier] = shipping[:carrier] if shipping[:carrier] + post[:shipping][:phone] = shipping[:phone] if shipping[:phone] + post[:shipping][:tracking_number] = shipping[:tracking_number] if shipping[:tracking_number] + post + end + end + end +end diff --git a/lib/open_food_network/subscription_payment_updater.rb b/lib/open_food_network/subscription_payment_updater.rb index 1c4d275db1..4a9c8fa144 100644 --- a/lib/open_food_network/subscription_payment_updater.rb +++ b/lib/open_food_network/subscription_payment_updater.rb @@ -30,7 +30,8 @@ module OpenFoodNetwork end def card_required? - payment.payment_method.is_a? Spree::Gateway::StripeConnect + [Spree::Gateway::StripeConnect, + Spree::Gateway::StripeSCA].include? payment.payment_method.class end def card_set? diff --git a/lib/stripe/profile_storer.rb b/lib/stripe/profile_storer.rb index 1b8928e884..1806a48bd6 100644 --- a/lib/stripe/profile_storer.rb +++ b/lib/stripe/profile_storer.rb @@ -4,9 +4,10 @@ module Stripe class ProfileStorer - def initialize(payment, provider) + def initialize(payment, provider, stripe_account_id = nil) @payment = payment @provider = provider + @stripe_account_id = stripe_account_id end def create_customer_from_token @@ -28,7 +29,13 @@ module Stripe email: @payment.order.email, login: Stripe.api_key, address: address_for(@payment) - } + }.merge(stripe_account_option) + end + + def stripe_account_option + return {} if @stripe_account_id.blank? + + { stripe_account: @stripe_account_id } end def address_for(payment) @@ -54,10 +61,22 @@ module Stripe def source_attrs_from(response) { - cc_type: @payment.source.cc_type, # side-effect of update_source! - gateway_customer_profile_id: response.params['id'], - gateway_payment_profile_id: response.params['default_source'] || response.params['default_card'] + cc_type: @payment.source.cc_type, + gateway_customer_profile_id: customer_profile_id(response), + gateway_payment_profile_id: payment_profile_id(response) } end + + def customer_profile_id(response) + response.params['customer'] || response.params['id'] + end + + def payment_profile_id(response) + if response.params['customer'] # Payment Intents API + response.params['id'] + else + response.params['default_source'] || response.params['default_card'] + end + end end end diff --git a/spec/controllers/spree/credit_cards_controller_spec.rb b/spec/controllers/spree/credit_cards_controller_spec.rb index e8996433f3..b9ed2ed77b 100644 --- a/spec/controllers/spree/credit_cards_controller_spec.rb +++ b/spec/controllers/spree/credit_cards_controller_spec.rb @@ -172,6 +172,22 @@ describe Spree::CreditCardsController, type: :controller do expect(response).to redirect_to spree.account_path(anchor: 'cards') end end + + context "where the payment method is StripeSCA" do + let(:stripe_payment_method) { create(:stripe_sca_payment_method) } + let!(:card) { create(:credit_card, gateway_customer_profile_id: 'cus_AZNMJ', payment_method: stripe_payment_method) } + + before do + stub_request(:delete, "https://api.stripe.com/v1/customers/cus_AZNMJ"). + to_return(status: 200, body: JSON.generate(deleted: true, id: "cus_AZNMJ")) + end + + it "the request to destroy the Stripe customer includes the stripe_account_id" do + expect(Stripe::Customer).to receive(:retrieve).with(card.gateway_customer_profile_id, { stripe_account: "abc123" }) + + expect{ delete :destroy, params }.to change(Spree::CreditCard, :count).by(-1) + end + end end end end diff --git a/spec/factories.rb b/spec/factories.rb index 4f9c6478f5..99b704de80 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -151,8 +151,15 @@ FactoryBot.define do preferred_enterprise_id { distributors.first.id } end + factory :stripe_sca_payment_method, class: Spree::Gateway::StripeSCA do + name 'StripeSCA' + environment 'test' + distributors { [FactoryBot.create(:stripe_account).enterprise] } + preferred_enterprise_id { distributors.first.id } + end + factory :stripe_account do - enterprise { FactoryBot.create :distributor_enterprise } + enterprise { FactoryBot.create(:distributor_enterprise) } stripe_user_id "abc123" stripe_publishable_key "xyz456" end diff --git a/spec/lib/stripe/profile_storer_spec.rb b/spec/lib/stripe/profile_storer_spec.rb new file mode 100644 index 0000000000..beec53feec --- /dev/null +++ b/spec/lib/stripe/profile_storer_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Stripe + describe ProfileStorer do + describe "create_customer_from_token" do + let(:payment) { create(:payment) } + let(:stripe_payment_method) { create(:stripe_payment_method) } + let(:stripe_account_id) { "12312" } + let(:profile_storer) { Stripe::ProfileStorer.new(payment, stripe_payment_method.provider) } + + let(:customer_id) { "cus_A123" } + let(:card_id) { "card_2342" } + let(:customer_response_mock) { { status: 200, body: customer_response_body } } + + before do + allow(Stripe).to receive(:api_key) { "sk_test_12345" } + + stub_request(:post, "https://api.stripe.com/v1/customers") + .with(basic_auth: ["sk_test_12345", ""], body: { email: payment.order.email }) + .to_return(customer_response_mock) + end + + context "when called from Stripe Connect" do + let(:customer_response_body) { + JSON.generate(id: customer_id, default_card: card_id, sources: { data: [{ id: "1" }] }) + } + + it "fetches the customer id and the card id from the correct response fields" do + profile_storer.create_customer_from_token + + expect(payment.source.gateway_customer_profile_id).to eq customer_id + expect(payment.source.gateway_payment_profile_id).to eq card_id + end + end + + context "when called from Stripe SCA" do + let(:customer_response_body) { + JSON.generate(customer: customer_id, id: card_id, sources: { data: [{ id: "1" }] }) + } + + it "fetches the customer id and the card id from the correct response fields" do + profile_storer.create_customer_from_token + + expect(payment.source.gateway_customer_profile_id).to eq customer_id + expect(payment.source.gateway_payment_profile_id).to eq card_id + end + end + end + end +end diff --git a/spec/requests/checkout/stripe_connect_spec.rb b/spec/requests/checkout/stripe_connect_spec.rb index a64bb44ab8..697664b806 100644 --- a/spec/requests/checkout/stripe_connect_spec.rb +++ b/spec/requests/checkout/stripe_connect_spec.rb @@ -7,16 +7,6 @@ describe "checking out an order with a Stripe Connect payment method", type: :re let!(:order_cycle) { create(:simple_order_cycle) } let!(:enterprise) { create(:distributor_enterprise) } - let!(:exchange) do - create( - :exchange, - order_cycle: order_cycle, - sender: order_cycle.coordinator, - receiver: enterprise, - incoming: false, - pickup_time: "Monday" - ) - end let!(:shipping_method) do create( :shipping_method, diff --git a/spec/requests/checkout/stripe_sca_spec.rb b/spec/requests/checkout/stripe_sca_spec.rb new file mode 100644 index 0000000000..dcdb0f4291 --- /dev/null +++ b/spec/requests/checkout/stripe_sca_spec.rb @@ -0,0 +1,281 @@ +require 'spec_helper' + +describe "checking out an order with a Stripe SCA payment method", type: :request do + include ShopWorkflow + include AuthenticationWorkflow + include OpenFoodNetwork::ApiHelper + + let!(:order_cycle) { create(:simple_order_cycle) } + let!(:enterprise) { create(:distributor_enterprise) } + let!(:shipping_method) do + create( + :shipping_method, + calculator: Spree::Calculator::FlatRate.new(preferred_amount: 0), + distributors: [enterprise] + ) + end + let!(:payment_method) { create(:stripe_sca_payment_method, distributors: [enterprise]) } + let!(:stripe_account) { create(:stripe_account, enterprise: enterprise) } + let!(:line_item) { create(:line_item, price: 12.34) } + let!(:order) { line_item.order } + let(:address) { create(:address) } + let(:stripe_payment_method) { "pm_123" } + let(:new_stripe_payment_method) { "new_pm_123" } + let(:customer_id) { "cus_A123" } + let(:payments_attributes) do + { + payment_method_id: payment_method.id, + source_attributes: { + gateway_payment_profile_id: stripe_payment_method, + cc_type: "visa", + last_digits: "4242", + month: 10, + year: 2025, + first_name: 'Jill', + last_name: 'Jeffreys' + } + } + end + let(:allowed_address_attributes) do + [ + "firstname", + "lastname", + "address1", + "address2", + "phone", + "city", + "zipcode", + "state_id", + "country_id" + ] + end + let(:params) do + { + format: :json, order: { + shipping_method_id: shipping_method.id, + payments_attributes: [payments_attributes], + bill_address_attributes: address.attributes.slice(*allowed_address_attributes), + ship_address_attributes: address.attributes.slice(*allowed_address_attributes) + } + } + end + let(:payment_intent_response_mock) do + { status: 200, body: JSON.generate(object: "payment_intent", amount: 2000, charges: { data: [{ id: "ch_1234", amount: 2000 }]}) } + end + + before do + order_cycle_distributed_variants = double(:order_cycle_distributed_variants) + allow(OrderCycleDistributedVariants).to receive(:new) { order_cycle_distributed_variants } + allow(order_cycle_distributed_variants).to receive(:distributes_order_variants?) { true } + + allow(Stripe).to receive(:api_key) { "sk_test_12345" } + order.update_attributes(distributor_id: enterprise.id, order_cycle_id: order_cycle.id) + order.reload.update_totals + set_order order + end + + context "when a new card is submitted" do + context "and the user doesn't request that the card is saved for later" do + before do + # Charges the card + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(basic_auth: ["sk_test_12345", ""], body: /#{stripe_payment_method}.*#{order.number}/) + .to_return(payment_intent_response_mock) + end + + context "and the paymeent intent request is successful" do + it "should process the payment without storing card details" do + put update_checkout_path, params + + expect(json_response["path"]).to eq spree.order_path(order) + expect(order.payments.completed.count).to be 1 + + card = order.payments.completed.first.source + + expect(card.gateway_customer_profile_id).to eq nil + expect(card.gateway_payment_profile_id).to eq stripe_payment_method + expect(card.cc_type).to eq "visa" + expect(card.last_digits).to eq "4242" + expect(card.first_name).to eq "Jill" + expect(card.last_name).to eq "Jeffreys" + end + end + + context "when the payment intent request returns an error message" do + let(:payment_intent_response_mock) do + { status: 402, body: JSON.generate(error: { message: "payment-intent-failure" }) } + end + + it "should not process the payment" do + put update_checkout_path, params + + expect(response.status).to be 400 + + expect(json_response["flash"]["error"]).to eq "payment-intent-failure" + expect(order.payments.completed.count).to be 0 + end + end + end + + context "and the customer requests that the card is saved for later" do + let(:payment_method_response_mock) do + { + status: 200, + body: JSON.generate(id: new_stripe_payment_method, customer: customer_id) + } + end + + let(:customer_response_mock) do + { + status: 200, + body: JSON.generate(id: customer_id, sources: { data: [{ id: "1" }] }) + } + end + + before do + source_attributes = params[:order][:payments_attributes][0][:source_attributes] + source_attributes[:save_requested_by_customer] = '1' + + # Saves the card against the user + stub_request(:post, "https://api.stripe.com/v1/customers") + .with(basic_auth: ["sk_test_12345", ""], body: { email: order.email }) + .to_return(customer_response_mock) + + # Requests a payment method from the newly saved card + stub_request(:post, "https://api.stripe.com/v1/payment_methods/#{stripe_payment_method}/attach") + .with(body: { customer: customer_id }) + .to_return(payment_method_response_mock) + + # Charges the card + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with( + basic_auth: ["sk_test_12345", ""], + body: /.*#{order.number}/ + ).to_return(payment_intent_response_mock) + end + + context "and the customer, payment_method and payment_intent requests are successful" do + it "should process the payment, and stores the card/customer details" do + put update_checkout_path, params + + expect(json_response["path"]).to eq spree.order_path(order) + expect(order.payments.completed.count).to be 1 + + card = order.payments.completed.first.source + + expect(card.gateway_customer_profile_id).to eq customer_id + expect(card.gateway_payment_profile_id).to eq new_stripe_payment_method + expect(card.cc_type).to eq "visa" + expect(card.last_digits).to eq "4242" + expect(card.first_name).to eq "Jill" + expect(card.last_name).to eq "Jeffreys" + end + end + + context "when the customer request returns an error message" do + let(:customer_response_mock) do + { status: 402, body: JSON.generate(error: { message: "customer-store-failure" }) } + end + + it "should not process the payment" do + put update_checkout_path, params + + expect(response.status).to be 400 + + expect(json_response["flash"]["error"]) + .to eq(I18n.t(:spree_gateway_error_flash_for_checkout, error: 'customer-store-failure')) + expect(order.payments.completed.count).to be 0 + end + end + + context "when the payment intent request returns an error message" do + let(:payment_intent_response_mock) do + { status: 402, body: JSON.generate(error: { message: "payment-intent-failure" }) } + end + + it "should not process the payment" do + put update_checkout_path, params + + expect(response.status).to be 400 + + expect(json_response["flash"]["error"]).to eq "payment-intent-failure" + expect(order.payments.completed.count).to be 0 + end + end + + context "when the payment_method request returns an error message" do + let(:payment_method_response_mock) do + { status: 402, body: JSON.generate(error: { message: "payment-method-failure" }) } + end + + it "should not process the payment" do + put update_checkout_path, params + + expect(response.status).to be 400 + + expect(json_response["flash"]["error"]).to include "payment-method-failure" + expect(order.payments.completed.count).to be 0 + end + end + end + end + + context "when an existing card is submitted" do + let(:credit_card) do + create( + :credit_card, + user_id: order.user_id, + gateway_payment_profile_id: stripe_payment_method, + gateway_customer_profile_id: customer_id, + last_digits: "4321", + cc_type: "master", + first_name: "Sammy", + last_name: "Signpost", + month: 11, year: 2026 + ) + end + + before do + params[:order][:existing_card_id] = credit_card.id + quick_login_as(order.user) + + # Charges the card + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(basic_auth: ["sk_test_12345", ""], body: %r{#{customer_id}.*#{stripe_payment_method}}) + .to_return(payment_intent_response_mock) + end + + context "and the payment intent and payment method requests are accepted" do + it "should process the payment, and keep the profile ids and other card details" do + put update_checkout_path, params + + expect(json_response["path"]).to eq spree.order_path(order) + expect(order.payments.completed.count).to be 1 + + card = order.payments.completed.first.source + + expect(card.gateway_customer_profile_id).to eq customer_id + expect(card.gateway_payment_profile_id).to eq stripe_payment_method + expect(card.cc_type).to eq "master" + expect(card.last_digits).to eq "4321" + expect(card.first_name).to eq "Sammy" + expect(card.last_name).to eq "Signpost" + end + end + + context "when the payment intent request returns an error message" do + let(:payment_intent_response_mock) do + { status: 402, body: JSON.generate(error: { message: "payment-intent-failure" }) } + end + + it "should not process the payment" do + put update_checkout_path, params + + expect(response.status).to be 400 + + expect(json_response["flash"]["error"]).to eq "payment-intent-failure" + expect(order.payments.completed.count).to be 0 + end + end + end +end