From b338cd90f5be634341647590014b9cf40772c913 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Thu, 23 Mar 2023 13:05:39 +0100 Subject: [PATCH 01/10] Remove support for setup intents and skipping the confirm page Co-Authored-By: Rainer Dema --- .../solidus_stripe/intents_controller.rb | 17 +- app/models/solidus_stripe/payment_method.rb | 25 +-- app/models/solidus_stripe/setup_intent.rb | 55 ------- ...154931_drop_solidus_stripe_setup_intent.rb | 13 ++ .../solidus_stripe_intent_controller.js | 11 +- .../views/checkouts/payment/_stripe.html.erb | 11 -- .../testing_support/factories.rb | 6 - .../solidus_stripe/payment_method_spec.rb | 30 ---- .../solidus_stripe/checkout_test_helper.rb | 45 +----- .../frontend/solidus_stripe/checkout_spec.rb | 150 +----------------- 10 files changed, 31 insertions(+), 332 deletions(-) delete mode 100644 app/models/solidus_stripe/setup_intent.rb create mode 100644 db/migrate/20230323154931_drop_solidus_stripe_setup_intent.rb diff --git a/app/controllers/solidus_stripe/intents_controller.rb b/app/controllers/solidus_stripe/intents_controller.rb index 5e8a669f..c0551239 100644 --- a/app/controllers/solidus_stripe/intents_controller.rb +++ b/app/controllers/solidus_stripe/intents_controller.rb @@ -25,12 +25,6 @@ def after_confirmation current_order.next! case - when params[:setup_intent] - intent = SolidusStripe::SetupIntent.find_by!( - payment_method: @payment_method, - order: current_order, - stripe_intent_id: params[:setup_intent], - ) when params[:payment_intent] intent = SolidusStripe::PaymentIntent.find_by!( payment_method: @payment_method, @@ -53,15 +47,8 @@ def after_confirmation data: intent.stripe_intent, ) - if @payment_method.skip_confirm_step? - flash.notice = t('spree.order_processed_successfully') - flash['order_completed'] = true - current_order.complete! - redirect_to main_app.token_order_path(current_order, current_order.guest_token) - else - flash[:notice] = t(".intent_status.#{intent.stripe_intent.status}") - redirect_to main_app.checkout_state_path(current_order.state) - end + flash[:notice] = t(".intent_status.#{intent.stripe_intent.status}") + redirect_to main_app.checkout_state_path(current_order.state) end private diff --git a/app/models/solidus_stripe/payment_method.rb b/app/models/solidus_stripe/payment_method.rb index 971dc3ef..5e96783a 100644 --- a/app/models/solidus_stripe/payment_method.rb +++ b/app/models/solidus_stripe/payment_method.rb @@ -5,8 +5,6 @@ class PaymentMethod < ::Spree::PaymentMethod preference :api_key, :string preference :publishable_key, :string preference :setup_future_usage, :string, default: '' - preference :stripe_intents_flow, :string, default: 'setup' - preference :skip_confirmation_for_payment_intent, :boolean, default: true # @attribute [rw] preferred_webhook_endpoint_signing_secret The webhook endpoint signing secret # for this payment method. @@ -15,7 +13,6 @@ class PaymentMethod < ::Spree::PaymentMethod validates :available_to_admin, inclusion: { in: [false] } validates :preferred_setup_future_usage, inclusion: { in: ['', 'on_session', 'off_session'] } - validates :preferred_stripe_intents_flow, inclusion: { in: ['payment', 'setup'] } has_one :webhook_endpoint, class_name: 'SolidusStripe::WebhookEndpoint', @@ -50,11 +47,6 @@ def gateway_class Gateway end - def skip_confirm_step? - preferred_stripe_intents_flow == 'payment' && - preferred_skip_confirmation_for_payment_intent - end - def payment_profiles_supported? # We actually support them, but not in the way expected by Solidus and its ActiveMerchant legacy. false @@ -62,27 +54,16 @@ def payment_profiles_supported? def intent_for_order(order) # TODO: See if we can move the intent creation out of the view - intent_class.retrieve_stripe_intent(payment_method: self, order: order) || - intent_class.create_stripe_intent(payment_method: self, order: order) - end - - def intent_class - case preferred_stripe_intents_flow - when 'setup' then SolidusStripe::SetupIntent - when 'payment' then SolidusStripe::PaymentIntent - end + SolidusStripe::PaymentIntent.retrieve_stripe_intent(payment_method: self, order: order) || + SolidusStripe::PaymentIntent.create_stripe_intent(payment_method: self, order: order) end - # Fetches the payment intent when available, falls back on the setup intent associated to the order. - # @api private # TODO: re-evaluate the need for this and think of ways to always go throught the intent classes. def self.intent_id_for_payment(payment) return unless payment payment.transaction_id || SolidusStripe::PaymentIntent.where( order: payment.order, payment_method: payment.payment_method - )&.pick(:stripe_intent_id) || SolidusStripe::SetupIntent.where( - order: payment.order, payment_method: payment.payment_method )&.pick(:stripe_intent_id) end @@ -92,8 +73,6 @@ def stripe_dashboard_url(intent_id) case intent_id when /^pi_/ "https://dashboard.stripe.com#{path_prefix}/payments/#{intent_id}" - when /^seti_/ - "https://dashboard.stripe.com#{path_prefix}/setup_intents/#{intent_id}" end end diff --git a/app/models/solidus_stripe/setup_intent.rb b/app/models/solidus_stripe/setup_intent.rb deleted file mode 100644 index c1ae9d57..00000000 --- a/app/models/solidus_stripe/setup_intent.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module SolidusStripe - class SetupIntent < Spree::Base - belongs_to :order, class_name: 'Spree::Order' - belongs_to :payment_method, class_name: 'SolidusStripe::PaymentMethod' - - def self.retrieve_stripe_intent(payment_method:, order:) - find_by(payment_method: payment_method, order: order)&.stripe_intent - end - - def self.create_stripe_intent(payment_method:, order:, stripe_intent_options: {}) - instance = new(payment_method: payment_method, order: order) - instance.create_stripe_intent(stripe_intent_options).tap { instance.update!(stripe_intent_id: _1.id) } - end - - def stripe_intent - payment_method.gateway.request do - Stripe::SetupIntent.retrieve(stripe_intent_id) - end - end - - def create_payment!(amount: order.total, add_to_wallet: false) - payment = order.payments.create!( - payment_method: payment_method, - amount: amount, - source: payment_method.payment_source_class.new( - stripe_payment_method_id: stripe_intent.payment_method, - payment_method: payment_method, - ) - ) - - if add_to_wallet && order.user - order.user.wallet.add payment.source - end - - payment - end - - def create_stripe_intent(stripe_intent_options) - stripe_customer_id = SolidusStripe::Customer.retrieve_or_create_stripe_customer_id( - payment_method: payment_method, - order: order - ) - - payment_method.gateway.request do - Stripe::SetupIntent.create({ - customer: stripe_customer_id, - usage: payment_method.preferred_setup_future_usage.presence, - metadata: { solidus_order_number: order.number }, - }.merge(stripe_intent_options)) - end - end - end -end diff --git a/db/migrate/20230323154931_drop_solidus_stripe_setup_intent.rb b/db/migrate/20230323154931_drop_solidus_stripe_setup_intent.rb new file mode 100644 index 00000000..e43f8729 --- /dev/null +++ b/db/migrate/20230323154931_drop_solidus_stripe_setup_intent.rb @@ -0,0 +1,13 @@ +class DropSolidusStripeSetupIntent < ActiveRecord::Migration[7.0] + def change + drop_table "solidus_stripe_setup_intents", force: :cascade do |t| + t.string "stripe_intent_id" + t.integer "order_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "payment_method_id", null: false + t.index ["order_id"], name: "index_solidus_stripe_setup_intents_on_order_id" + t.index ["payment_method_id"], name: "index_solidus_stripe_setup_intents_on_payment_method_id" + end + end +end diff --git a/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js index 3402b506..62c532fd 100644 --- a/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js +++ b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js @@ -7,7 +7,6 @@ export default class extends Controller { publishableKey: String, emailAddress: String, returnUrl: String, - flow: String, // For now we don't have a controller to interact with // and we can't use outlets, so we fallback on acquiring selectors. @@ -54,17 +53,9 @@ export default class extends Controller { } confirmIntent() { - let confirmMethod - - if (this.flowValue === "payment") confirmMethod = "confirmPayment" - if (this.flowValue === "setup") confirmMethod = "confirmSetup" - - if (!this.flowValue) - throw new Error("flowValue should be either 'payment' or 'setup'.") - // NOTE: confirming the intent will redirect the whole page and come back to // the `return_url` unless an immediate error gets in the way. - return this.stripe[confirmMethod]({ + return this.stripe.confirmPayment({ elements: this.elements, confirmParams: { return_url: this.returnUrlValue }, }) diff --git a/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb b/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb index c8c44d27..4093d083 100644 --- a/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb +++ b/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb @@ -7,7 +7,6 @@ data-solidus-stripe-intent-publishable-key-value="<%= payment_method.preferred_publishable_key %>" data-solidus-stripe-intent-email-address-value="<%= current_order.email %>" data-solidus-stripe-intent-return-url-value="<%= solidus_stripe.after_confirmation_url(payment_method) %>" - data-solidus-stripe-intent-flow-value="<%= payment_method.preferred_stripe_intents_flow %>" data-solidus-stripe-intent-radio-selector-value="#order_payments_attributes__payment_method_id_<%= payment_method.id %>" data-solidus-stripe-intent-submit-selector-value="#checkout_form_payment [type='submit']" data-action="submit@window->solidus-stripe-intent#handleSubmit" @@ -20,14 +19,4 @@
- - <% if payment_method.skip_confirm_step? %> -
- <%= render 'checkouts/terms_and_conditions' %> - -
- <% end %> diff --git a/lib/solidus_stripe/testing_support/factories.rb b/lib/solidus_stripe/testing_support/factories.rb index 88ce5ed5..5e751711 100644 --- a/lib/solidus_stripe/testing_support/factories.rb +++ b/lib/solidus_stripe/testing_support/factories.rb @@ -71,12 +71,6 @@ stripe_intent_id { "pm_#{SecureRandom.uuid.delete('-')}" } end - factory :stripe_setup_intent, class: 'SolidusStripe::SetupIntent' do - association :order - association :payment_method, factory: :stripe_payment_method - stripe_intent_id { "seti_#{SecureRandom.uuid.delete('-')}" } - end - factory :stripe_webhook_endpoint, class: 'SolidusStripe::WebhookEndpoint' do association :payment_method, factory: :stripe_payment_method slug { SecureRandom.hex(16) } diff --git a/spec/models/solidus_stripe/payment_method_spec.rb b/spec/models/solidus_stripe/payment_method_spec.rb index d83e3c0b..7e5a5579 100644 --- a/spec/models/solidus_stripe/payment_method_spec.rb +++ b/spec/models/solidus_stripe/payment_method_spec.rb @@ -45,22 +45,6 @@ end end - context 'when the order has a setup intent' do - it 'fetches the setup intent id' do - intent = create(:stripe_setup_intent) - payment = build(:payment, response_code: "pi_123", payment_method: intent.payment_method, order: intent.order) - - expect(described_class.intent_id_for_payment(payment)).to eq("pi_123") - end - - it 'fetches the setup intent id when the payment intent is not available' do - intent = create(:stripe_setup_intent, stripe_intent_id: 'seti_123') - payment = build(:payment, response_code: nil, payment_method: intent.payment_method, order: intent.order) - - expect(described_class.intent_id_for_payment(payment)).to eq("seti_123") - end - end - it 'returns nil without a payment' do expect(described_class.intent_id_for_payment(nil)).to eq(nil) end @@ -81,20 +65,6 @@ end end - context 'with a setup intent id' do - it 'generates a dashboard link' do - payment_method = build(:stripe_payment_method, preferred_test_mode: false) - - expect(payment_method.stripe_dashboard_url('seti_123')).to eq("https://dashboard.stripe.com/setup_intents/seti_123") - end - - it 'supports test mode' do - payment_method = build(:stripe_payment_method, preferred_test_mode: true) - - expect(payment_method.stripe_dashboard_url('seti_123')).to eq("https://dashboard.stripe.com/test/setup_intents/seti_123") - end - end - it 'returns nil with anything else' do payment_method = build(:stripe_payment_method) diff --git a/spec/support/solidus_stripe/checkout_test_helper.rb b/spec/support/solidus_stripe/checkout_test_helper.rb index ceb35cc5..da89a6f6 100644 --- a/spec/support/solidus_stripe/checkout_test_helper.rb +++ b/spec/support/solidus_stripe/checkout_test_helper.rb @@ -23,15 +23,8 @@ def assigns_guest_token(guest_token) # rubocop:enable RSpec/AnyInstance end - def creates_payment_method( - intents_flow: 'setup', - setup_future_usage: 'off_session', - skip_confirmation: false - ) - @payment_method = create(:stripe_payment_method, - preferred_stripe_intents_flow: intents_flow, - preferred_setup_future_usage: setup_future_usage, - preferred_skip_confirmation_for_payment_intent: skip_confirmation) + def creates_payment_method(setup_future_usage: 'off_session') + @payment_method = create(:stripe_payment_method, preferred_setup_future_usage: setup_future_usage) end def payment_method @@ -132,11 +125,10 @@ def finds_stripe_iframe # # However, it's important to note that this process may require an additional step, # (currently not fully supported), which is indicated by the "next_action" property - # of the Stripe PaymentIntent or SetupIntent object. + # of the Stripe PaymentIntent object. # # More information on this property can be found in the Stripe API documentation: # PaymentIntent objects : https://stripe.com/docs/api/payment_intents/object#payment_intent_object-next_action - # SetupIntent objects : https://stripe.com/docs/api/setup_intents/object#setup_intent_object-next_action def authorizes_3d_secure_payment(authenticate: true) finds_frame('body > div > iframe') do @@ -199,12 +191,7 @@ def confirms_order def completes_order checks_terms_of_service - if payment_method.skip_confirm_step? - submits_payment - else - confirms_order - end - + confirms_order expect(page).to have_content('Your order has been processed successfully') end @@ -222,15 +209,6 @@ def fails_the_payment # These are methods that are used specifically for testing the Stripe # checkout process. - def setup_intent_is_created_successfully - order = Spree::Order.last - intent = SolidusStripe::SetupIntent.retrieve_stripe_intent( - payment_method: payment_method, - order: order - ) - expect(intent.status).to eq('succeeded') - end - def payment_intent_is_created_with_required_capture order = Spree::Order.last intent = SolidusStripe::PaymentIntent.where( @@ -323,24 +301,13 @@ def declined_cards_at_confirm_are_notified fails_the_payment end - def successfully_creates_a_setup_intent(user: nil) - visits_payment_step(user: user) - chooses_new_stripe_payment - fills_stripe_form - submits_payment - expect(page).to have_content('Payment succeeded!') - setup_intent_is_created_successfully - end - def successfully_creates_a_payment_intent(user: nil) visits_payment_step(user: user) chooses_new_stripe_payment fills_stripe_form - unless payment_method.skip_confirm_step? - submits_payment - expect(page).to have_content('Payment successfully authorized!') - end + submits_payment + expect(page).to have_content('Payment successfully authorized!') completes_order payment_intent_is_created_with_required_capture diff --git a/spec/system/frontend/solidus_stripe/checkout_spec.rb b/spec/system/frontend/solidus_stripe/checkout_spec.rb index 12c1168c..2399cfe6 100644 --- a/spec/system/frontend/solidus_stripe/checkout_spec.rb +++ b/spec/system/frontend/solidus_stripe/checkout_spec.rb @@ -5,152 +5,16 @@ RSpec.describe 'SolidusStripe Checkout', :js do include SolidusStripe::CheckoutTestHelper - # To learn more about setup_future_usage in different contexts with Stripe Setup Intents: - # https://stripe.com/docs/payments/setup-intents#increasing-success-rate-by-specifying-usage - ['on_session', 'off_session'].each do |setup_future_usage| - context "with Stripe Setup Intents and setup_future_usage=#{setup_future_usage}" do - before do - creates_payment_method( - intents_flow: 'setup', - setup_future_usage: setup_future_usage - ) - end - - context 'with a registered user' do - it 'creates a setup intent and successfully processes payment' do - successfully_creates_a_setup_intent(user: create(:user)) - completes_order - payment_intent_is_created_with_required_capture - captures_last_valid_payment - end - - it "successfully reuses a previously saved card from the user's wallet" do - user = create(:user) - - successfully_creates_a_setup_intent(user: user) - completes_order - payment_intent_is_created_with_required_capture - captures_last_valid_payment - - visits_payment_step(user: user) - find_existing_payment_radio(user.wallet_payment_sources.first.id).choose - submits_payment - completes_order - payment_intent_is_created_with_required_capture - captures_last_valid_payment - end - - it 'creates a setup intent with a 3D Secure card and successfully processes payment' do - visits_payment_step(user: create(:user)) - chooses_new_stripe_payment - - # Fill in the Stripe payment form with a 3D Secure card - # This card requires authentication on all transactions, - # regardless of how the card is set up. - # See https://stripe.com/docs/testing#authentication-and-setup for more information on this card. - fills_stripe_form(number: '4000002760003184') - - # Submit the payment form and authorize the 3D Secure payment - submits_payment - authorizes_3d_secure_payment - - # Expect the setup intent to be created successfully and complete the order - setup_intent_is_created_successfully - completes_order - - # Note that, in case of a Setup Intent, even if it is authorized, the payment intent - # cannot be captured because a required action is needed most of the times. - payment_intent_is_created_with_required_action - - # A solution needs to be implemented to handle the required action for these cards. - pending 'These cards cannot be reused as the required action is not handled' - - payment_intent_is_created_with_required_capture - captures_last_valid_payment - end - end - - context 'with a guest user' do - it 'creates a setup intent and successfully processes payment' do - successfully_creates_a_setup_intent - completes_order - payment_intent_is_created_with_required_capture - captures_last_valid_payment - end - end - end - end - # To learn more about setup_future_usage in different contexts with Stripe Payment Intents: # https://stripe.com/docs/payments/payment-intents#future-usage ['', 'on_session', 'off_session'].each do |setup_future_usage| context "with Stripe Payment Intents and setup_future_usage=#{setup_future_usage}" do - let(:skip_confirm_step) { true } - - before do - creates_payment_method( - intents_flow: 'payment', - setup_future_usage: setup_future_usage, - skip_confirmation: skip_confirm_step - ) - end - - context 'with a registered user and skip_confirmation_for_payment_intent = true' do - it 'creates a payment intent and successfully processes payment' do - successfully_creates_a_payment_intent(user: create(:user)) - - captures_last_valid_payment - end - - it 'creates a payment intent and successfully processes payment with 3d secure card' do - visits_payment_step(user: create(:user)) - chooses_new_stripe_payment - - # Fill in the Stripe payment form with a 3D Secure card - # This card requires authentication on all transactions, regardless - # of how the card is set up. - # See https://stripe.com/docs/testing#authentication-and-setup for more information on this card. - fills_stripe_form(number: '4000002760003184') - - # Submit the payment form and authorize the 3D Secure payment - checks_terms_of_service - submits_payment - authorizes_3d_secure_payment - expect(page).to have_content('Your order has been processed successfully') - payment_intent_is_created_with_required_capture + before { creates_payment_method(setup_future_usage: setup_future_usage) } - captures_last_valid_payment - end - - # Payment sources that are not specified for future usage cannot be - # reused and are not added to the user's wallet - if setup_future_usage.present? - it "successfully reuses a previously saved card from the user's wallet" do - user = create(:user) - - successfully_creates_a_payment_intent(user: user) - captures_last_valid_payment - - visits_payment_step(user: user) - find_existing_payment_radio(user.wallet_payment_sources.first.id).choose - submits_payment - checks_terms_of_service - confirms_order - expect(page).to have_content('Your order has been processed successfully') - payment_intent_is_created_with_required_capture - captures_last_valid_payment - end - end - end - - context 'with a registered user and skip_confirmation_for_payment_intent = false' do - let(:skip_confirm_step) { false } + it 'creates a payment intent and successfully processes payment' do + successfully_creates_a_payment_intent(user: create(:user)) - it 'creates a payment intent and successfully processes payment' do - successfully_creates_a_payment_intent(user: create(:user)) - - captures_last_valid_payment - end + captures_last_valid_payment end context 'with a guest user' do @@ -184,9 +48,9 @@ end it 'reject transactions with cards declined at the confirm step and return an appropriate response' do - creates_payment_method( - intents_flow: 'setup' - ) + skip "Does this make sense with payment intent?" + + creates_payment_method visits_payment_step(user: create(:user)) chooses_new_stripe_payment From c10301e7bd844c3b103757c85f0391ce78794b0e Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Thu, 23 Mar 2023 16:32:28 +0100 Subject: [PATCH 02/10] Use the new deferred payment intent confirmation API Co-Authored-By: Rainer Dema --- .../solidus_stripe_intent_controller.js | 46 +++++++++++-------- .../views/checkouts/payment/_stripe.html.erb | 24 ++++++++-- lib/solidus_stripe/engine.rb | 2 + .../solidus_stripe/checkout_test_helper.rb | 26 +++++------ .../frontend/solidus_stripe/checkout_spec.rb | 3 ++ 5 files changed, 63 insertions(+), 38 deletions(-) diff --git a/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js index 62c532fd..0deb46b9 100644 --- a/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js +++ b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js @@ -5,8 +5,7 @@ export default class extends Controller { static values = { clientSecret: String, publishableKey: String, - emailAddress: String, - returnUrl: String, + paymentElementOptions: Object, // For now we don't have a controller to interact with // and we can't use outlets, so we fallback on acquiring selectors. @@ -14,7 +13,7 @@ export default class extends Controller { radioSelector: String, } - static targets = ["paymentElement", "message"] + static targets = ["paymentElement", "message", "paymentMethodInput"] get submitOutletElement() { return document.querySelector(this.submitSelectorValue) @@ -41,31 +40,38 @@ export default class extends Controller { this.setLoading(true) - const { error } = await this.confirmIntent() + // Trigger form validation and wallet collection + const { error: submitError } = await this.elements.submit() - if (error.type === "card_error" || error.type === "validation_error") { - this.messageTarget.textContent = error.message - } else { - this.messageTarget.textContent = "An unexpected error occurred." + if (submitError) { + this.messageTarget.textContent = submitError.message + this.setLoading(false) + return } - this.setLoading(false) - } - - confirmIntent() { - // NOTE: confirming the intent will redirect the whole page and come back to - // the `return_url` unless an immediate error gets in the way. - return this.stripe.confirmPayment({ + // Create the PaymentMethod using the details collected by the Payment Element + const { error, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements, - confirmParams: { return_url: this.returnUrlValue }, + params: { billing_details: {} }, }) + + if (error) { + if (error.type === "card_error" || error.type === "validation_error") { + this.messageTarget.textContent = error.message + } else { + this.messageTarget.textContent = "An unexpected error occurred." + } + this.setLoading(false) + return + } + + this.setLoading(false) + this.paymentMethodInputTarget.value = paymentMethod.id + this.paymentMethodInputTarget.form.submit() } setupPaymentElement() { - this.elements = this.stripe.elements({ - appearance: { theme: "stripe" }, - clientSecret: this.clientSecretValue, - }) + this.elements = this.stripe.elements(this.paymentElementOptionsValue) this.paymentElement = this.elements.create("payment", { layout: "tabs" }) this.paymentElementTarget.innerHTML = "" // Remove child nodes used for loading this.paymentElement.mount(this.paymentElementTarget) diff --git a/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb b/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb index 4093d083..c02091c1 100644 --- a/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb +++ b/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb @@ -1,16 +1,32 @@ -<% intent = payment_method.intent_for_order(current_order) %> +<% payment_element_options = { + mode: 'payment', + amount: SolidusStripe::MoneyToStripeAmountConverter.to_stripe_amount( + current_order.display_total.money.fractional, + current_order.currency, + ), + currency: current_order.currency.downcase, + paymentMethodCreation: 'manual', + # Fully customizable with appearance API. + # https://stripe.com/docs/elements/appearance-api + # appearance: {}, +} %>
+ +
<%= t('solidus_stripe.loading') %> diff --git a/lib/solidus_stripe/engine.rb b/lib/solidus_stripe/engine.rb index 8ded1ef5..072aa66e 100644 --- a/lib/solidus_stripe/engine.rb +++ b/lib/solidus_stripe/engine.rb @@ -12,6 +12,8 @@ class Engine < Rails::Engine initializer "solidus_stripe.add_payment_method", after: "spree.register.payment_methods" do |app| app.config.spree.payment_methods << 'SolidusStripe::PaymentMethod' + + ::Spree::PermittedAttributes.source_attributes.prepend :stripe_payment_method_id end initializer "solidus_stripe.pub_sub", after: "spree.core.pub_sub" do |app| diff --git a/spec/support/solidus_stripe/checkout_test_helper.rb b/spec/support/solidus_stripe/checkout_test_helper.rb index da89a6f6..9a269929 100644 --- a/spec/support/solidus_stripe/checkout_test_helper.rb +++ b/spec/support/solidus_stripe/checkout_test_helper.rb @@ -267,23 +267,22 @@ def incomplete_cards_are_notified end def declined_cards_at_intent_creation_are_notified - fills_in_stripe_country('United States') - + # https://stripe.com/docs/declines/codes [ - ['4000000000000002', 'Your card has been declined'], # Generic decline - ['4000000000009995', 'Your card has insufficient funds'], # Insufficient funds decline - ['4000000000009987', 'Your card has been declined'], # Lost card decline - ['4000000000009979', 'Your card has been declined'], # Stolen card decline - ['4000000000000069', 'Your card has expired'], # Expired card decline - ['4000000000000127', "Your card's security code is incorrect"], # Incorrect CVC decline - ['4000000000000119', 'An error occurred while processing your card'] # Processing error decline + ['4000000000000002', 'Your card was declined'], # Generic decline + ['4000000000009995', 'Your card has insufficient funds'], # Insufficient funds decline + ['4000000000009987', 'Your card was declined'], # Lost card decline + ['4000000000009979', 'Your card was declined'], # Stolen card decline + ['4000000000000069', 'Your card has expired'], # Expired card decline + ['4000000000000127', "Your card's security code is incorrect"], # Incorrect CVC decline + ['4000000000000119', 'An error occurred while processing your card'] # Processing error decline ].each do |number, text| - clears_stripe_form + fills_in_stripe_country('United States') fills_stripe_form(number: number) submits_payment - using_wait_time(15) do - expect(page).to have_content(text) - end + checks_terms_of_service + confirms_order + expect(page).to have_content(text, wait: 15) end end @@ -307,7 +306,6 @@ def successfully_creates_a_payment_intent(user: nil) fills_stripe_form submits_payment - expect(page).to have_content('Payment successfully authorized!') completes_order payment_intent_is_created_with_required_capture diff --git a/spec/system/frontend/solidus_stripe/checkout_spec.rb b/spec/system/frontend/solidus_stripe/checkout_spec.rb index 2399cfe6..7e756940 100644 --- a/spec/system/frontend/solidus_stripe/checkout_spec.rb +++ b/spec/system/frontend/solidus_stripe/checkout_spec.rb @@ -70,6 +70,9 @@ # https://stripe.com/docs/testing#authentication-and-setup fills_stripe_form(number: '4000002760003184') submits_payment + + pending "For this to pass we need to properly handle the 'requires_action' payment intent status" + authorizes_3d_secure_payment(authenticate: false) using_wait_time(15) do expect(page).to have_content('An unexpected error occurred') From e4ef0b9655558b6497b99d55c9d8db6238eef109 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Thu, 23 Mar 2023 17:36:34 +0100 Subject: [PATCH 03/10] Confirm payments just before completing the order Co-Authored-By: Rainer Dema --- .../solidus_stripe/intents_controller.rb | 50 +++++------- app/models/solidus_stripe/payment_intent.rb | 72 +++++++++++------ app/models/solidus_stripe/payment_method.rb | 6 -- config/locales/en.yml | 18 ++--- .../solidus_stripe_confirm_controller.js | 39 +++++++++ .../orders/payment_info/_stripe.html.erb | 22 +++++- .../solidus_stripe/intents_controller_spec.rb | 6 +- .../solidus_stripe/checkout_test_helper.rb | 37 ++++----- .../frontend/solidus_stripe/checkout_spec.rb | 79 ++++++++++++++----- 9 files changed, 209 insertions(+), 120 deletions(-) create mode 100644 lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js diff --git a/app/controllers/solidus_stripe/intents_controller.rb b/app/controllers/solidus_stripe/intents_controller.rb index c0551239..734aca02 100644 --- a/app/controllers/solidus_stripe/intents_controller.rb +++ b/app/controllers/solidus_stripe/intents_controller.rb @@ -8,47 +8,35 @@ class SolidusStripe::IntentsController < Spree::BaseController before_action :load_payment_method def after_confirmation - unless %w[confirm payment].include?(current_order.state.to_s) + unless params[:payment_intent] + return head :unprocessable_entity + end + + unless current_order.confirm? redirect_to main_app.checkout_state_path(current_order.state) return end - # Check if this is needed. This was added to be sure the order - # in in the right step. - current_order.state = :payment + intent = SolidusStripe::PaymentIntent.find_by!( + payment_method: @payment_method, + order: current_order, + stripe_intent_id: params[:payment_intent], + ) - # TODO: understand how to handle webhooks. At this stage, we might receive a webhook - # with the confirmation of the setup intent. We need to be sure we are not creating - # the payment twice. - # https://stripe.com/docs/payments/intents?intent=setup#setup-intent-webhooks + if intent.process_payment + flash.notice = t('spree.order_processed_successfully') - current_order.next! + flash['order_completed'] = true - case - when params[:payment_intent] - intent = SolidusStripe::PaymentIntent.find_by!( - payment_method: @payment_method, - order: current_order, - stripe_intent_id: params[:payment_intent], + redirect_to( + spree_current_user ? + main_app.order_path(current_order) : + main_app.token_order_path(current_order, current_order.guest_token) ) else - return head :unprocessable_entity + flash[:error] = params[:error_message] || t('spree.payment_processing_failed') + redirect_to(main_app.checkout_state_path(:payment)) end - - payment = intent.create_payment!( - amount: current_order.total, # TODO: double check, remove store credit? - add_to_wallet: true - ) - - SolidusStripe::LogEntries.payment_log( - payment, - success: true, - message: "Reached return URL", - data: intent.stripe_intent, - ) - - flash[:notice] = t(".intent_status.#{intent.stripe_intent.status}") - redirect_to main_app.checkout_state_path(current_order.state) end private diff --git a/app/models/solidus_stripe/payment_intent.rb b/app/models/solidus_stripe/payment_intent.rb index 37c9e6f0..e10eff1e 100644 --- a/app/models/solidus_stripe/payment_intent.rb +++ b/app/models/solidus_stripe/payment_intent.rb @@ -5,41 +5,64 @@ class PaymentIntent < ApplicationRecord belongs_to :order, class_name: 'Spree::Order' belongs_to :payment_method, class_name: 'SolidusStripe::PaymentMethod' - def self.retrieve_stripe_intent(payment_method:, order:) - find_by(payment_method: payment_method, order: order)&.stripe_intent - end + def self.prepare_for_payment(payment) + # Find or create the intent for the payment. + intent = + find_by(payment_method: payment.payment_method, order: payment.order) || + new(payment_method: payment.payment_method, order: payment.order) + .tap { _1.update!(stripe_intent_id: _1.create_stripe_intent.id) } - def self.create_stripe_intent(payment_method:, order:, stripe_intent_options: {}) - instance = new(payment_method: payment_method, order: order) - instance.create_stripe_intent(stripe_intent_options).tap { instance.update!(stripe_intent_id: _1.id) } - end + # Update the intent with the previously acquired payment method. + intent.payment_method.gateway.request { + Stripe::PaymentIntent.update(intent.stripe_intent_id, payment_method: payment.source.stripe_payment_method_id) + } - def stripe_intent - payment_method.gateway.request do - Stripe::PaymentIntent.retrieve(stripe_intent_id) - end + # Attach the payment intent to the payment. + payment.update!(response_code: intent.stripe_intent.id) + + intent end - def create_payment!(amount: order.total, add_to_wallet: false) - payment = order.payments.create!( - state: 'pending', + def process_payment + payment = order.payments.valid.find_by!( payment_method: payment_method, - amount: amount, response_code: stripe_intent.id, - source: payment_method.payment_source_class.new( - payment_method: payment_method, - ), ) - if add_to_wallet && order.user && stripe_intent.setup_future_usage.present? - payment.source.update!(stripe_payment_method_id: stripe_intent.payment_method) - order.user.wallet.add payment.source + payment.started_processing! + + case stripe_intent.status + when 'requires_capture' + payment.pend! unless payment.pending? + successful = true + else + payment.failure! + successful = false + end + + SolidusStripe::LogEntries.payment_log( + payment, + success: successful, + message: I18n.t("solidus_stripe.intent_status.#{stripe_intent.status}"), + data: stripe_intent, + ) + + if successful + order.complete! + else + order.payment_failed! end - payment + successful + end + + def stripe_intent + payment_method.gateway.request do + Stripe::PaymentIntent.retrieve(stripe_intent_id) + end end - def create_stripe_intent(stripe_intent_options) + def create_stripe_intent(**stripe_intent_options) stripe_customer_id = SolidusStripe::Customer.retrieve_or_create_stripe_customer_id( payment_method: payment_method, order: order @@ -56,7 +79,8 @@ def create_stripe_intent(stripe_intent_options) setup_future_usage: payment_method.preferred_setup_future_usage.presence, customer: stripe_customer_id, metadata: { solidus_order_number: order.number }, - }.merge(stripe_intent_options)) + **stripe_intent_options, + }) end end end diff --git a/app/models/solidus_stripe/payment_method.rb b/app/models/solidus_stripe/payment_method.rb index 5e96783a..fb003568 100644 --- a/app/models/solidus_stripe/payment_method.rb +++ b/app/models/solidus_stripe/payment_method.rb @@ -52,12 +52,6 @@ def payment_profiles_supported? false end - def intent_for_order(order) - # TODO: See if we can move the intent creation out of the view - SolidusStripe::PaymentIntent.retrieve_stripe_intent(payment_method: self, order: order) || - SolidusStripe::PaymentIntent.create_stripe_intent(payment_method: self, order: order) - end - # TODO: re-evaluate the need for this and think of ways to always go throught the intent classes. def self.intent_id_for_payment(payment) return unless payment diff --git a/config/locales/en.yml b/config/locales/en.yml index 0c523adf..a288b0df 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5,16 +5,14 @@ en: solidus_stripe: loading: "Loading..." - intents: - after_confirmation: - intent_status: &intent_status - canceled: "Your payment has been canceled." - processing: "Your payment is processing." - requires_action: "Some action is needed on your payment before proceeding." - requires_capture: "Payment successfully authorized!" - requires_confirmation: "Please confirm the payment in order to proceed." - requires_payment_method: "Your payment was not successful, please try again." - succeeded: "Payment succeeded!" + intent_status: &intent_status + canceled: "Your payment has been canceled." + processing: "Your payment is processing." + requires_action: "Some action is needed on your payment before proceeding." + requires_capture: "Payment successfully authorized!" + requires_confirmation: "Please confirm the payment in order to proceed." + requires_payment_method: "Your payment was not successful, please try again." + succeeded: "Payment succeeded!" activerecord: models: diff --git a/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js new file mode 100644 index 00000000..8ea7c8f2 --- /dev/null +++ b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus" +import { loadStripe } from "@stripe/stripe-js" + +export default class extends Controller { + static values = { + clientSecret: String, + publishableKey: String, + returnUrl: String, + errorBaseUrl: String, + } + + async connect() { + this.stripe = await loadStripe(this.publishableKeyValue) + } + + // action + async confirm(e) { + // Bail out if not on the confirm method form. + if (e.target !== this.element.form) return + + e.preventDefault() + + const { error } = await this.stripe.confirmPayment({ + clientSecret: this.clientSecretValue, + confirmParams: { return_url: this.returnUrlValue }, + }) + + if (error) { + // This point will only be reached if there is an immediate error when + // confirming the payment. Show error to your customer. + const messageParam = `error_message=${encodeURIComponent(error.message)}` + location.href = `${this.errorBaseUrlValue}&${messageParam}` + } else { + // Your customer will be redirected to your `return_url`. For some payment + // methods like iDEAL, your customer will be redirected to an intermediate + // site first to authorize the payment, then redirected to the `return_url`. + } + } +} diff --git a/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb b/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb index d948f79f..ca944d7f 100644 --- a/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb +++ b/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb @@ -1,4 +1,20 @@ -<% if stripe_payment_method = payment.source.stripe_payment_method %> - <%= render "checkouts/existing_payment/stripe/#{stripe_payment_method.type}", - stripe_payment_method: stripe_payment_method %> +<% stripe_payment_method = payment.source.stripe_payment_method %> +<%= render( + "checkouts/existing_payment/stripe/#{stripe_payment_method.type}", + stripe_payment_method: stripe_payment_method +) %> + +<% if current_order&.confirm? %> + <% stripe_intent = + SolidusStripe::PaymentIntent.prepare_for_payment(payment).stripe_intent %> + + <% end %> diff --git a/spec/requests/solidus_stripe/intents_controller_spec.rb b/spec/requests/solidus_stripe/intents_controller_spec.rb index aeb9fa69..34379f3d 100644 --- a/spec/requests/solidus_stripe/intents_controller_spec.rb +++ b/spec/requests/solidus_stripe/intents_controller_spec.rb @@ -2,7 +2,7 @@ RSpec.describe SolidusStripe::IntentsController, type: :request do describe "GET /after_confirmation" do - context 'when not provided a payment or setup intent' do + context 'when not provided a payment intent' do it 'responds with unprocessable entity' do payment_method = create(:stripe_payment_method) order = create(:order_ready_to_complete) @@ -14,13 +14,13 @@ end end - context 'when the order is not at "payment" or "confirm"' do + context 'when the order is not at "confirm"' do it 'redirects to the current order step' do payment_method = create(:stripe_payment_method) order = create(:order) sign_in order.user - get "/solidus_stripe/#{payment_method.id}/after_confirmation" + get "/solidus_stripe/#{payment_method.id}/after_confirmation?payment_intent=pi_123" expect(response).to redirect_to('/checkout/cart') end diff --git a/spec/support/solidus_stripe/checkout_test_helper.rb b/spec/support/solidus_stripe/checkout_test_helper.rb index 9a269929..b1ece2af 100644 --- a/spec/support/solidus_stripe/checkout_test_helper.rb +++ b/spec/support/solidus_stripe/checkout_test_helper.rb @@ -114,7 +114,7 @@ def clears_stripe_form def finds_stripe_iframe fieldset = find_payment_fieldset(payment_method.id) - expect(fieldset).to have_css('iframe') # trigger waiting if the frame is not yet there + expect(fieldset).to have_css('iframe', wait: 15) # trigger waiting if the frame is not yet there fieldset.find("iframe") end @@ -267,15 +267,20 @@ def incomplete_cards_are_notified end def declined_cards_at_intent_creation_are_notified - # https://stripe.com/docs/declines/codes [ - ['4000000000000002', 'Your card was declined'], # Generic decline - ['4000000000009995', 'Your card has insufficient funds'], # Insufficient funds decline - ['4000000000009987', 'Your card was declined'], # Lost card decline - ['4000000000009979', 'Your card was declined'], # Stolen card decline - ['4000000000000069', 'Your card has expired'], # Expired card decline - ['4000000000000127', "Your card's security code is incorrect"], # Incorrect CVC decline - ['4000000000000119', 'An error occurred while processing your card'] # Processing error decline + # Decline codes + # https://stripe.com/docs/declines/codes + ['4000000000000002', 'Your card has been declined.'], # Generic decline + ['4000000000009995', 'Your card has insufficient funds.'], # Insufficient funds decline + ['4000000000009987', 'Your card has been declined.'], # Lost card decline + ['4000000000009979', 'Your card has been declined.'], # Stolen card decline + ['4000000000000069', 'Your card has expired.'], # Expired card decline + ['4000000000000127', "Your card's security code is incorrect."], # Incorrect CVC decline + ['4000000000000119', 'An error occurred while processing your card.'], # Processing error decline + + # Fraudulent cards + # https://stripe.com/docs/testing#fraud-prevention + ['4100000000000019', 'Your card has been declined.'], # Always blocked ].each do |number, text| fills_in_stripe_country('United States') fills_stripe_form(number: number) @@ -286,20 +291,6 @@ def declined_cards_at_intent_creation_are_notified end end - def declined_cards_at_confirm_are_notified - fills_in_stripe_country('United States') - - clears_stripe_form - fills_stripe_form(number: '4100000000000019') - submits_payment - checks_terms_of_service - confirms_order - - expect(page).to have_content('Your card was declined') - moves_order_back_to_payment - fails_the_payment - end - def successfully_creates_a_payment_intent(user: nil) visits_payment_step(user: user) chooses_new_stripe_payment diff --git a/spec/system/frontend/solidus_stripe/checkout_spec.rb b/spec/system/frontend/solidus_stripe/checkout_spec.rb index 7e756940..4a5937f9 100644 --- a/spec/system/frontend/solidus_stripe/checkout_spec.rb +++ b/spec/system/frontend/solidus_stripe/checkout_spec.rb @@ -28,7 +28,7 @@ end context 'with declined cards' do - it 'reject transactions with cards declined at intent creation or invalid fields and return an appropriate response' do # rubocop:disable Metrics/LineLength + it 'reject transactions with cards declined at intent creation or invalid fields and return an appropriate response' do # rubocop:disable Layout/LineLength creates_payment_method visits_payment_step(user: create(:user)) chooses_new_stripe_payment @@ -47,16 +47,6 @@ declined_cards_at_intent_creation_are_notified end - it 'reject transactions with cards declined at the confirm step and return an appropriate response' do - skip "Does this make sense with payment intent?" - - creates_payment_method - visits_payment_step(user: create(:user)) - chooses_new_stripe_payment - - declined_cards_at_confirm_are_notified - end - context 'with 3D Secure cards' do it 'reject transaction with failed authentication and return an appropriate response' do creates_payment_method @@ -71,24 +61,73 @@ fills_stripe_form(number: '4000002760003184') submits_payment - pending "For this to pass we need to properly handle the 'requires_action' payment intent status" - + checks_terms_of_service + confirms_order authorizes_3d_secure_payment(authenticate: false) - using_wait_time(15) do - expect(page).to have_content('An unexpected error occurred') - end + + moves_order_back_to_payment + expect(page).to have_content( + "We are unable to authenticate your payment method. " \ + "Please choose a different payment method and try again.", + wait: 15 + ) + + fills_in_stripe_country('United States') + clears_stripe_form # This test script is using 3D Secure 2 authentication, which must be # completed for the payment to be successful. # Please refer to the Stripe documentation for more information: # https://stripe.com/docs/testing#three-ds-cards - clears_stripe_form fills_stripe_form(number: '4000000000003220') submits_payment + checks_terms_of_service + confirms_order authorizes_3d_secure_2_payment(authenticate: false) - using_wait_time(15) do - expect(page).to have_content('An unexpected error occurred') - end + + moves_order_back_to_payment + expect(page).to have_content( + "We are unable to authenticate your payment method. " \ + "Please choose a different payment method and try again.", + wait: 15 + ) + end + + it 'processes the transaction with successful authentication' do + user = create(:user) + + creates_payment_method + visits_payment_step(user: user) + chooses_new_stripe_payment + fills_in_stripe_country('United States') + + # This 3D Secure card requires authentication for all transactions, + # regardless of its setup. + # Please refer to the Stripe documentation for more information: + # https://stripe.com/docs/testing#authentication-and-setup + fills_stripe_form(number: '4000002760003184') + submits_payment + checks_terms_of_service + confirms_order + authorizes_3d_secure_payment(authenticate: true) + expect(page).to have_content('Your order has been processed successfully') + payment_intent_is_created_with_required_capture + + visits_payment_step(user: user) + chooses_new_stripe_payment + fills_in_stripe_country('United States') + + # This test script is using 3D Secure 2 authentication, which must be + # completed for the payment to be successful. + # Please refer to the Stripe documentation for more information: + # https://stripe.com/docs/testing#three-ds-cards + fills_stripe_form(number: '4000000000003220') + submits_payment + checks_terms_of_service + confirms_order + authorizes_3d_secure_2_payment(authenticate: true) + expect(page).to have_content('Your order has been processed successfully') + payment_intent_is_created_with_required_capture end end end From e2f16b36e4f6a6c40d57a2c034a8129b7e071cf4 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Thu, 30 Mar 2023 13:55:41 +0200 Subject: [PATCH 04/10] Memoize PaymentIntent#stripe_intent Since we're memoizing and the nature of the intent is pretty dynamic (e.g. it can change its status) we want to reset it whenever the record is reloaded. --- app/models/solidus_stripe/payment_intent.rb | 7 ++++++- .../solidus_stripe/payment_intent_spec.rb | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 spec/models/solidus_stripe/payment_intent_spec.rb diff --git a/app/models/solidus_stripe/payment_intent.rb b/app/models/solidus_stripe/payment_intent.rb index e10eff1e..cd755e39 100644 --- a/app/models/solidus_stripe/payment_intent.rb +++ b/app/models/solidus_stripe/payment_intent.rb @@ -57,11 +57,16 @@ def process_payment end def stripe_intent - payment_method.gateway.request do + @stripe_intent ||= payment_method.gateway.request do Stripe::PaymentIntent.retrieve(stripe_intent_id) end end + def reload(...) + @stripe_intent = nil + super + end + def create_stripe_intent(**stripe_intent_options) stripe_customer_id = SolidusStripe::Customer.retrieve_or_create_stripe_customer_id( payment_method: payment_method, diff --git a/spec/models/solidus_stripe/payment_intent_spec.rb b/spec/models/solidus_stripe/payment_intent_spec.rb new file mode 100644 index 00000000..2d6acefa --- /dev/null +++ b/spec/models/solidus_stripe/payment_intent_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'solidus_stripe_spec_helper' + +RSpec.describe SolidusStripe::PaymentIntent do + describe "#reload" do + it "reloads the stripe intent" do + intent = create(:stripe_payment_intent) + allow(Stripe::PaymentIntent).to receive(:retrieve) do + Stripe::PaymentIntent.construct_from(id: intent.stripe_intent_id) + end + + expect(intent.stripe_intent.object_id).to eq(intent.stripe_intent.object_id) + expect(intent.stripe_intent.object_id).not_to eq(intent.reload.stripe_intent.object_id) + end + end +end From e776b6dc4b623c3790100f7538be184fa7e5985b Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Wed, 29 Mar 2023 18:01:06 +0200 Subject: [PATCH 05/10] Re-introduce wallet logic to store `on_session` and `off_session` payment sources After a successful payment confirmation through a payment intent with `setup_future_usage` set to 'on_session' or 'off_session', the payment source (Stripe payment method) can be considered valid for future reuse. For more details, refer to the Stripe documentation: https://stripe.com/docs/api/payment_intents/update#update_payment_intent-setup_future_usage This commit reintroduces the functionality of adding the payment source to the user's wallet after a successful payment, so that the user can reuse it for future transactions without having to enter payment information again. --- app/models/solidus_stripe/payment_intent.rb | 1 + .../solidus_stripe/checkout_test_helper.rb | 4 +++ .../frontend/solidus_stripe/checkout_spec.rb | 35 ++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/models/solidus_stripe/payment_intent.rb b/app/models/solidus_stripe/payment_intent.rb index cd755e39..3b3c0590 100644 --- a/app/models/solidus_stripe/payment_intent.rb +++ b/app/models/solidus_stripe/payment_intent.rb @@ -49,6 +49,7 @@ def process_payment if successful order.complete! + order.user.wallet.add(payment.source) if order.user && stripe_intent.setup_future_usage.present? else order.payment_failed! end diff --git a/spec/support/solidus_stripe/checkout_test_helper.rb b/spec/support/solidus_stripe/checkout_test_helper.rb index b1ece2af..216668c9 100644 --- a/spec/support/solidus_stripe/checkout_test_helper.rb +++ b/spec/support/solidus_stripe/checkout_test_helper.rb @@ -204,6 +204,10 @@ def fails_the_payment expect(Spree::Payment.last.state).to eq('failed') end + def expects_page_to_not_display_wallet_payment_sources + expect(page).to have_no_selector("[name='order[wallet_payment_source_id]']") + end + # Test methods # # These are methods that are used specifically for testing the Stripe diff --git a/spec/system/frontend/solidus_stripe/checkout_spec.rb b/spec/system/frontend/solidus_stripe/checkout_spec.rb index 4a5937f9..d010da3c 100644 --- a/spec/system/frontend/solidus_stripe/checkout_spec.rb +++ b/spec/system/frontend/solidus_stripe/checkout_spec.rb @@ -27,6 +27,39 @@ end end + context 'with a registered user' do + ['on_session', 'off_session'].each do |setup_future_usage| + context "when setup_future_usage is set with '#{setup_future_usage}'" do + before { creates_payment_method(setup_future_usage: setup_future_usage) } + + it "successfully reuses a previously saved card from the user's wallet" do + user = create(:user) + successfully_creates_a_payment_intent(user: user) + + visits_payment_step(user: user) + find_existing_payment_radio(user.wallet_payment_sources.first.id).choose + submits_payment + completes_order + payment_intent_is_created_with_required_capture + captures_last_valid_payment + end + end + end + + context 'when setup_future_usage is not set' do + it 'requires the user to enter their payment information for each new transaction' do + creates_payment_method(setup_future_usage: '') + user = create(:user) + + successfully_creates_a_payment_intent(user: user) + + visits_payment_step(user: user) + + expects_page_to_not_display_wallet_payment_sources + end + end + end + context 'with declined cards' do it 'reject transactions with cards declined at intent creation or invalid fields and return an appropriate response' do # rubocop:disable Layout/LineLength creates_payment_method @@ -96,7 +129,7 @@ it 'processes the transaction with successful authentication' do user = create(:user) - creates_payment_method + creates_payment_method(setup_future_usage: '') visits_payment_step(user: user) chooses_new_stripe_payment fills_in_stripe_country('United States') From 4695f3cd0bd0be17d70ae99376e96d561b07b82a Mon Sep 17 00:00:00 2001 From: Rainer Dema Date: Tue, 28 Mar 2023 11:16:59 +0200 Subject: [PATCH 06/10] Remove unused gateway methods This commit removes the `authorize` and `purchase` methods as they are now handled client-side. Co-Authored-By: Elia Schito --- app/models/solidus_stripe/gateway.rb | 76 --------------- spec/models/solidus_stripe/gateway_spec.rb | 106 --------------------- 2 files changed, 182 deletions(-) diff --git a/app/models/solidus_stripe/gateway.rb b/app/models/solidus_stripe/gateway.rb index 2d0d7a87..07600016 100644 --- a/app/models/solidus_stripe/gateway.rb +++ b/app/models/solidus_stripe/gateway.rb @@ -29,38 +29,6 @@ def initialize(options) attr_reader :client - # Authorizes a certain amount on the provided payment source. - # - # We create and confirm the Stripe payment intent in two steps. That's to - # guarantee that we associate a Solidus payment on the creation, and we can - # fetch it after the webhook event is published on confirmation. - # - # The Stripe payment intent id is copied over the Solidus payment - # `response_code` field. - def authorize(amount_in_cents, source, options = {}) - check_given_amount_matches_payment_intent(amount_in_cents, options) - - stripe_payment_intent = create_confirmed_stripe_payment_intent( - source: source, - payment: options[:originator], - stripe_payment_intent_options: { - capture_method: "manual" - } - ) - build_payment_log( - success: true, - message: "PaymentIntent was confirmed successfully", - response_code: stripe_payment_intent.id, - data: stripe_payment_intent - ) - rescue Stripe::StripeError => e - build_payment_log( - success: false, - message: e.message, - data: e.response - ) - end - # Captures a certain amount from a previously authorized transaction. # # @see https://stripe.com/docs/api/payment_intents/capture#capture_payment_intent @@ -86,29 +54,6 @@ def capture(amount_in_cents, payment_intent_id, options = {}) ) end - # Authorizes and captures a certain amount on the provided payment source. - # - # See `#authorize` for how the confirmation step is performed. - # - # @todo add support for purchasing custom amounts - def purchase(amount_in_cents, source, options = {}) - check_given_amount_matches_payment_intent(amount_in_cents, options) - - stripe_payment_intent = create_confirmed_stripe_payment_intent( - source: source, - payment: options[:originator], - stripe_payment_intent_options: { - capture_method: "automatic" - } - ) - build_payment_log( - success: true, - message: "PaymentIntent was confirmed and captured successfully", - response_code: stripe_payment_intent.id, - data: stripe_payment_intent, - ) - end - # Voids a previously authorized transaction, releasing the funds that are on hold. def void(payment_intent_id, _options = {}) check_payment_intent_id(payment_intent_id) @@ -171,27 +116,6 @@ def request(&block) private - def create_confirmed_stripe_payment_intent(source:, payment:, stripe_payment_intent_options:) - stripe_payment_method = source.stripe_payment_method - - SolidusStripe::PaymentIntent.create_stripe_intent( - payment_method: source.payment_method, - order: payment.order, - stripe_intent_options: stripe_payment_intent_options.merge( - payment_method: stripe_payment_method.id, - customer: stripe_payment_method.customer, - confirm: false - ) - ).tap do - payment.update_column(:response_code, _1.id) # rubocop:disable Rails/SkipsModelValidations - confirm_stripe_payment_intent(_1.id) - end - end - - def confirm_stripe_payment_intent(stripe_payment_intent_id) - request { Stripe::PaymentIntent.confirm(stripe_payment_intent_id) } - end - def capture_stripe_payment_intent(stripe_payment_intent_id) request { Stripe::PaymentIntent.capture(stripe_payment_intent_id) } end diff --git a/spec/models/solidus_stripe/gateway_spec.rb b/spec/models/solidus_stripe/gateway_spec.rb index 8420a9b3..3005f427 100644 --- a/spec/models/solidus_stripe/gateway_spec.rb +++ b/spec/models/solidus_stripe/gateway_spec.rb @@ -3,69 +3,6 @@ require 'solidus_stripe_spec_helper' RSpec.describe SolidusStripe::Gateway do - describe '#authorize' do - it 'confirms the Stripe payment' do - stripe_payment_method = Stripe::PaymentMethod.construct_from(id: "pm_123", customer: "cus_123") - stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") - - payment_method = build(:stripe_payment_method) - source = build(:stripe_payment_source, stripe_payment_method_id: "pi_123", payment_method: payment_method) - gateway = payment_method.gateway - order = create(:order_with_stripe_payment, amount: 123.45, payment_method: payment_method) - payment = order.payments.last - - allow(source).to receive(:stripe_payment_method).and_return(stripe_payment_method) - allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_payment_intent) - allow(Stripe::PaymentIntent).to receive(:confirm).with("pi_123").and_return(stripe_payment_intent) - - result = gateway.authorize(123_45, source, currency: 'USD', originator: order.payments.first) - - expect(Stripe::PaymentIntent).to have_received(:create).with( - amount: 123_45, - currency: 'USD', - capture_method: 'manual', - confirm: false, - metadata: { solidus_order_number: order.number }, - customer: "cus_123", - payment_method: "pm_123", - setup_future_usage: nil - ) - expect(Stripe::PaymentIntent).to have_received(:confirm).with("pi_123") - expect(result.params).to eq("data" => '{"id":"pi_123"}') - expect(payment.reload.response_code).to eq("pi_123") - end - - it 'generates error response on failure' do - stripe_payment_method = Stripe::PaymentMethod.construct_from(id: "pm_123", customer: "cus_123") - stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") - - payment_method = build(:stripe_payment_method) - source = build(:stripe_payment_source, stripe_payment_method_id: "pi_123", payment_method: payment_method) - gateway = payment_method.gateway - order = create(:order_with_stripe_payment, amount: 123.45, payment_method: payment_method) - - allow(source).to receive(:stripe_payment_method).and_return(stripe_payment_method) - allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_payment_intent) - allow(Stripe::PaymentIntent).to receive(:confirm).with("pi_123").and_raise(Stripe::StripeError.new("auth error")) - - result = gateway.authorize(123_45, source, currency: 'USD', originator: order.payments.first) - - expect(Stripe::PaymentIntent).to have_received(:confirm).with("pi_123") - expect(result.success?).to be(false) - expect(result.message).to eq("auth error") - end - - it "raises if the given amount doesn't match the order total" do - payment_method = build(:stripe_payment_method) - gateway = payment_method.gateway - order = create(:order_with_stripe_payment, amount: 123.45, payment_method: payment_method) - - expect { gateway.authorize(10, :source, originator: order.payments.first ) }.to raise_error( - /custom amount is not supported/ - ) - end - end - describe '#capture' do it 'captures a pre-authorized Stripe payment' do stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") @@ -114,49 +51,6 @@ end end - describe '#purchase' do - it 'authorizes and captures in a single operation' do - stripe_payment_method = Stripe::PaymentMethod.construct_from(id: "pm_123", customer: "cus_123") - stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") - - payment_method = build(:stripe_payment_method) - source = build(:stripe_payment_source, stripe_payment_method_id: "pi_123", payment_method: payment_method) - gateway = payment_method.gateway - order = create(:order_with_stripe_payment, amount: 123.45, payment_method: payment_method) - payment = order.payments.last - - allow(source).to receive(:stripe_payment_method).and_return(stripe_payment_method) - allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_payment_intent) - allow(Stripe::PaymentIntent).to receive(:confirm).with("pi_123").and_return(stripe_payment_intent) - - result = gateway.purchase(123_45, source, currency: 'USD', originator: order.payments.first) - - expect(Stripe::PaymentIntent).to have_received(:create).with( - amount: 123_45, - currency: 'USD', - capture_method: 'automatic', - confirm: false, - metadata: { solidus_order_number: order.number }, - customer: "cus_123", - payment_method: "pm_123", - setup_future_usage: nil - ) - expect(Stripe::PaymentIntent).to have_received(:confirm).with("pi_123") - expect(result.params).to eq("data" => '{"id":"pi_123"}') - expect(payment.reload.response_code).to eq("pi_123") - end - - it "raises if the given amount doesn't match the order total" do - payment_method = build(:stripe_payment_method) - gateway = payment_method.gateway - order = create(:order_with_stripe_payment, amount: 123.45, payment_method: payment_method) - - expect { gateway.purchase(10, :source, originator: order.payments.first ) }.to raise_error( - /custom amount is not supported/ - ) - end - end - describe '#void' do it 'voids a payment that hasn not been captured yet' do gateway = build(:stripe_payment_method).gateway From 9cce00e5373a8d0caa28b6c4d359ea9e582f423b Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Tue, 28 Mar 2023 17:01:11 +0200 Subject: [PATCH 07/10] Rename the intent controller to be a payment controller It now doesn't handle intents anymore. --- ...js => solidus_stripe_payment_controller.js} | 0 .../views/checkouts/payment/_stripe.html.erb | 18 +++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) rename lib/generators/solidus_stripe/install/templates/app/javascript/controllers/{solidus_stripe_intent_controller.js => solidus_stripe_payment_controller.js} (100%) diff --git a/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js b/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_payment_controller.js similarity index 100% rename from lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_intent_controller.js rename to lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_payment_controller.js diff --git a/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb b/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb index c02091c1..8dccebf3 100644 --- a/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb +++ b/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb @@ -13,26 +13,26 @@
-
+
<%= t('solidus_stripe.loading') %>
-
+
From ee90182d9bc668c10896f4d94009b761e42a8d96 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Wed, 29 Mar 2023 13:08:19 +0200 Subject: [PATCH 08/10] Remove outdated comment This was fixed by https://github.com/solidusio/solidus_stripe/pull/213. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 6c81e818..3c8e7d70 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,6 @@ Please use [`solidus_stripe` v4 on the corresponding branch](https://github.com/ 🚧 **WARNING: WORK IN PROGRESS** 🚧 -> ⚠️ **WARNING** ⚠️ -> -> Please note that at the moment, solidus_stripe only supports integration with a single Stripe account. This means it is not suitable for use in a multi-seller marketplace environment. We are working to add support for multiple Stripe accounts as soon as possible. - # Solidus Stripe [![CircleCI](https://circleci.com/gh/solidusio/solidus_stripe.svg?style=shield)](https://circleci.com/gh/solidusio/solidus_stripe) From e3c0e29cae5133c1bfb1249045ea76d8dbf9fc30 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Wed, 29 Mar 2023 13:20:02 +0200 Subject: [PATCH 09/10] Consolidate warnings in the readme --- README.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3c8e7d70..7e06715f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,18 @@ -🚧 **WARNING: WORK IN PROGRESS** 🚧 +## 🚧 **WARNING** 🚧 Work In Progress You're looking at the source for `solidus_stripe` v5, which will only support the **starter frontend** but at the moment **it is not ready to be used**. Please use [`solidus_stripe` v4 on the corresponding branch](https://github.com/solidusio/solidus_stripe/tree/v4). -🚧 **WARNING: WORK IN PROGRESS** 🚧 +## 🚧 **WARNING** 🚧 Supporting `solidus_frontend` + +If you need support for `solidus_frontend` please add `< 5` as a version requirement in your gemfile: +`gem 'solidus_stripe', '< 5'` +or if your tracking the github version please switch to the `v4` branch: +`gem 'solidus_stripe', git: 'https://github.com/solidusio/solidus_stripe.git', branch: 'v4'` + +--- # Solidus Stripe @@ -22,17 +29,6 @@ Add solidus_stripe to your Gemfile: gem 'solidus_stripe' ``` -> ⚠️ **WARNING** ⚠️ -> -> If you need support for `solidus_frontend` please add `< 5` as a version requirement in your gemfile: -> -> `gem 'solidus_stripe', '< 5'` -> -> or if your tracking the github version please switch to the `v4` branch: -> -> `gem 'solidus_stripe', git: 'https://github.com/solidusio/solidus_stripe.git', branch: 'v4'` -> - Bundle your dependencies and run the installation generator: ```shell From 1cab1dc8ec20a45176fdac7e83779fdc731c7f95 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Wed, 29 Mar 2023 13:20:23 +0200 Subject: [PATCH 10/10] Cleanup the readme section about states and premature authorization --- README.md | 42 +++++++++++++----------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7e06715f..27511ef4 100644 --- a/README.md +++ b/README.md @@ -157,35 +157,19 @@ The most important difference is that on Stripe a failure is not a final state, In order to map these concepts SolidusStripe will match states in a slightly unexpected way, as shown below. -Reference: https://stripe.com/docs/payments/intents?intent=payment - -![image](https://user-images.githubusercontent.com/1051/217322027-f49081f5-0795-49f4-994e-285a9de5347c.png) - -### ⚠️ Warning: Authorization happens before the order is completed - -This setup implies a payment is authorized after the payment information is submitted to Stripe, although the order -still needs to be completed. That can become an issue if a customer abandons the checkout at the `confirm` step and -no action is taken to free up the money on the backend. - -In order to mitigate this issue, we suggest adapting the frontend by merging the *confirm* and *payment* steps: - -1. embed the agreement to the terms of service -2. add order summary to the payment step -3. apply the following patch - -```patch ---- a/templates/app/controllers/checkouts_controller.rb -+++ b/templates/app/controllers/checkouts_controller.rb -@@ -48,6 +48,8 @@ def redirect_on_failure - end - - def transition_forward -+ @order.next if @order.has_checkout_step?("payment") && @order.payment? -+ - if @order.can_complete? - @order.complete - else -``` +| Stripe PaymentIntent Status | Solidus Payment State | +| --------------------------- | --------------------- | +| requires_payment_method | checkout | +| requires_action | checkout | +| processing | checkout | +| requires_confirmation | checkout | +| requires_capture | pending | +| succeeded | completed | + +Reference: + +- https://stripe.com/docs/payments/intents?intent=payment +- https://github.com/solidusio/solidus/blob/master/core/lib/spree/core/state_machines/payment.rb ## Development