diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index 7fda4bb730..4b1c3662d0 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -845,7 +845,6 @@ Metrics/ModuleLength: - spec/models/spree/variant_spec.rb - spec/services/permissions/order_spec.rb - spec/services/variant_units/option_value_namer_spec.rb - - spec/support/request/stripe_helper.rb Metrics/ParameterLists: Max: 5 diff --git a/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb b/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb index 1b97a6fcd2..24decb1b9b 100644 --- a/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb +++ b/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb @@ -5,6 +5,7 @@ require 'spree/core/gateway_error' describe Spree::Admin::PaymentsController, type: :controller do include StripeHelper + include StripeStubs let!(:shop) { create(:enterprise) } let!(:user) { shop.owner } diff --git a/spec/features/admin/payments_stripe_spec.rb b/spec/features/admin/payments_stripe_spec.rb index b296d0fc32..1a3d6047bf 100644 --- a/spec/features/admin/payments_stripe_spec.rb +++ b/spec/features/admin/payments_stripe_spec.rb @@ -8,6 +8,7 @@ feature ' ' do include AuthenticationHelper include StripeHelper + include StripeStubs let!(:order) { create(:completed_order_with_fees) } let!(:stripe_payment_method) do diff --git a/spec/features/consumer/shopping/checkout_stripe_spec.rb b/spec/features/consumer/shopping/checkout_stripe_spec.rb index 6c99489013..e3a32718aa 100644 --- a/spec/features/consumer/shopping/checkout_stripe_spec.rb +++ b/spec/features/consumer/shopping/checkout_stripe_spec.rb @@ -7,6 +7,7 @@ feature "Check out with Stripe", js: true do include ShopWorkflow include CheckoutHelper include StripeHelper + include StripeStubs let(:distributor) { create(:distributor_enterprise) } let!(:order_cycle) { create(:simple_order_cycle, distributors: [distributor], variants: [variant]) } @@ -90,12 +91,12 @@ feature "Check out with Stripe", js: true do let!(:shipping_method) { create(:shipping_method) } let(:error_message) { "Card was declined: insufficient funds." } - context "with guest checkout" do - before do - stub_payment_intent_get_request - stub_payment_methods_post_request - end + before do + stub_payment_intent_get_request + stub_payment_methods_post_request + end + context "with guest checkout" do context "when the card is accepted" do before do stub_payment_intents_post_request order: order @@ -126,7 +127,7 @@ feature "Check out with Stripe", js: true do end end - context "when the card needs extra SCA authorization", js: true do + context "when the card needs extra SCA authorization" do before do stripe_redirect_url = checkout_path(payment_intent: "pi_123") stub_payment_intents_post_request_with_redirect order: order, @@ -194,5 +195,49 @@ feature "Check out with Stripe", js: true do end end end + + context "with a logged in user" do + let(:user) { order.user } + + before do + login_as user + end + + context "saving a card and re-using it" do + before do + stub_payment_methods_post_request request: { payment_method: "pm_123", customer: "cus_A123" }, response: { pm_id: "pm_123" } + stub_payment_intents_post_request order: order + stub_successful_capture_request order: order + stub_customers_post_request email: user.email + stub_payment_method_attach_request + end + + it "allows saving a card and re-using it" do + checkout_with_stripe guest_checkout: false, remember_card: true + + expect(page).to have_content "Confirmed" + expect(order.reload.completed?).to eq true + expect(order.payments.first.state).to eq "completed" + + # Verify card has been saved with correct stripe IDs + user_credit_card = order.reload.user.credit_cards.first + expect(user_credit_card.gateway_payment_profile_id).to eq "pm_123" + expect(user_credit_card.gateway_customer_profile_id).to eq "cus_A123" + + # Prepare a second order + new_order = create(:order, user: user, order_cycle: order_cycle, distributor: distributor, bill_address_id: nil, ship_address_id: nil) + set_order(new_order) + add_product_to_cart(new_order, product, quantity: 10) + + # Checkout with saved card + visit checkout_path + choose free_shipping.name + choose stripe_sca_payment_method.name + expect(page).to have_content "Use a saved card" + expect(page).to have_select 'selected_card', selected: "Visa x-4242 Exp:10/2050" + place_order + end + end + end end end diff --git a/spec/lib/stripe/credit_card_cloner_spec.rb b/spec/lib/stripe/credit_card_cloner_spec.rb index 40854560d6..432d707676 100644 --- a/spec/lib/stripe/credit_card_cloner_spec.rb +++ b/spec/lib/stripe/credit_card_cloner_spec.rb @@ -6,6 +6,8 @@ require 'stripe/credit_card_cloner' module Stripe describe CreditCardCloner do describe "#clone" do + include StripeStubs + let(:cloner) { Stripe::CreditCardCloner.new } let(:customer_id) { "cus_A123" } @@ -13,7 +15,6 @@ module Stripe let(:new_customer_id) { "cus_A456" } let(:new_payment_method_id) { "pm_456" } let(:stripe_account_id) { "acct_456" } - let(:customer_response_mock) { { status: 200, body: customer_response_body } } let(:payment_method_response_mock) { { status: 200, body: payment_method_response_body } } let(:credit_card) { create(:credit_card, user: create(:user)) } @@ -21,18 +22,13 @@ module Stripe let(:payment_method_response_body) { JSON.generate(id: new_payment_method_id) } - let(:customer_response_body) { - JSON.generate(id: new_customer_id) - } before do allow(Stripe).to receive(:api_key) { "sk_test_12345" } - stub_request(:post, "https://api.stripe.com/v1/customers") - .with(body: { email: credit_card.user.email }, - headers: { 'Stripe-Account' => stripe_account_id }) - .to_return(customer_response_mock) - + stub_customers_post_request email: credit_card.user.email, + response: { customer_id: new_customer_id }, + stripe_account_header: true stub_request(:post, "https://api.stripe.com/v1/payment_methods/#{new_payment_method_id}/attach") .with(body: { customer: new_customer_id }, diff --git a/spec/support/request/stripe_helper.rb b/spec/support/request/stripe_helper.rb index ef71652b1a..ef552a0586 100644 --- a/spec/support/request/stripe_helper.rb +++ b/spec/support/request/stripe_helper.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true module StripeHelper - def checkout_with_stripe + def checkout_with_stripe(guest_checkout: true, remember_card: false) visit checkout_path - checkout_as_guest - + checkout_as_guest if guest_checkout fill_out_form( free_shipping.name, stripe_sca_payment_method.name, save_default_addresses: false ) fill_out_card_details + check "Remember this card?" if remember_card place_order end @@ -34,100 +34,4 @@ module StripeHelper allow(Stripe).to receive(:publishable_key) { "pk_test_12345" } Spree::Config.set(stripe_connect_enabled: true) end - - def stub_payment_intents_post_request(order:, response: {}, stripe_account_header: true) - stub = stub_request(:post, "https://api.stripe.com/v1/payment_intents") - .with(basic_auth: ["sk_test_12345", ""], body: /.*#{order.number}/) - stub = stub.with(headers: { 'Stripe-Account' => 'abc123' }) if stripe_account_header - stub.to_return(payment_intent_authorize_response_mock(response)) - end - - def stub_payment_intents_post_request_with_redirect(order:, redirect_url:) - stub_request(:post, "https://api.stripe.com/v1/payment_intents") - .with(basic_auth: ["sk_test_12345", ""], body: /.*#{order.number}/) - .to_return(payment_intent_redirect_response_mock(redirect_url)) - end - - def stub_payment_intent_get_request(response: {}, stripe_account_header: true) - stub = stub_request(:get, "https://api.stripe.com/v1/payment_intents/pi_123") - stub = stub.with(headers: { 'Stripe-Account' => 'abc123' }) if stripe_account_header - stub.to_return(payment_intent_authorize_response_mock(response)) - end - - def stub_payment_methods_post_request(response: {}) - stub_request(:post, "https://api.stripe.com/v1/payment_methods") - .with(body: { payment_method: "pm_123" }, - headers: { 'Stripe-Account' => 'abc123' }) - .to_return(hub_payment_method_response_mock(response)) - end - - def stub_successful_capture_request(order:, response: {}) - stub_capture_request(order, payment_successful_capture_mock(response)) - end - - def stub_failed_capture_request(order:, response: {}) - stub_capture_request(order, payment_failed_capture_mock(response)) - end - - def stub_capture_request(order, response_mock) - stub_request(:post, "https://api.stripe.com/v1/payment_intents/pi_123/capture") - .with(body: { amount_to_capture: Spree::Money.new(order.total).cents }, - headers: { 'Stripe-Account' => 'abc123' }) - .to_return(response_mock) - end - - def stub_refund_request - stub_request(:post, "https://api.stripe.com/v1/charges/ch_1234/refunds") - .with(body: { amount: 2000, expand: ["charge"] }, - headers: { 'Stripe-Account' => 'abc123' }) - .to_return(payment_successful_refund_mock) - end - - private - - def payment_intent_authorize_response_mock(options) - { status: options[:code] || 200, - body: JSON.generate(id: "pi_123", - object: "payment_intent", - amount: 2000, - amount_received: 2000, - status: options[:intent_status] || "requires_capture", - last_payment_error: nil, - charges: { data: [{ id: "ch_1234", amount: 2000 }] }) } - end - - def payment_intent_redirect_response_mock(redirect_url) - { status: 200, body: JSON.generate(id: "pi_123", - object: "payment_intent", - next_source_action: { - type: "authorize_with_url", - authorize_with_url: { url: redirect_url } - }, - status: "requires_source_action") } - end - - def payment_successful_capture_mock(options) - { status: options[:code] || 200, - body: JSON.generate(object: "payment_intent", - amount: 2000, - charges: { data: [{ id: "ch_1234", amount: 2000 }] }) } - end - - def payment_failed_capture_mock(options) - { status: options[:code] || 402, - body: JSON.generate(error: { message: - options[:message] || "payment-method-failure" }) } - end - - def hub_payment_method_response_mock(options) - { status: options[:code] || 200, - body: JSON.generate(id: "pm_456", customer: "cus_A123") } - end - - def payment_successful_refund_mock - { status: 200, - body: JSON.generate(object: "refund", - amount: 2000, - charge: "ch_1234") } - end end diff --git a/spec/support/request/stripe_stubs.rb b/spec/support/request/stripe_stubs.rb new file mode 100644 index 0000000000..909604b9ae --- /dev/null +++ b/spec/support/request/stripe_stubs.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module StripeStubs + def stub_payment_intents_post_request(order:, response: {}, stripe_account_header: true) + stub = stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(basic_auth: ["sk_test_12345", ""], body: /.*#{order.number}/) + stub = stub.with(headers: { 'Stripe-Account' => 'abc123' }) if stripe_account_header + stub.to_return(payment_intent_authorize_response_mock(response)) + end + + def stub_payment_intents_post_request_with_redirect(order:, redirect_url:) + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(basic_auth: ["sk_test_12345", ""], body: /.*#{order.number}/) + .to_return(payment_intent_redirect_response_mock(redirect_url)) + end + + def stub_payment_intent_get_request(response: {}, stripe_account_header: true) + stub = stub_request(:get, "https://api.stripe.com/v1/payment_intents/pi_123") + stub = stub.with(headers: { 'Stripe-Account' => 'abc123' }) if stripe_account_header + stub.to_return(payment_intent_authorize_response_mock(response)) + end + + def stub_payment_methods_post_request(request: { payment_method: "pm_123" }, response: {}) + stub_request(:post, "https://api.stripe.com/v1/payment_methods") + .with(body: request, + headers: { 'Stripe-Account' => 'abc123' }) + .to_return(hub_payment_method_response_mock(response)) + end + + # Attaches the payment method to the customer in the hub's stripe account + def stub_payment_method_attach_request + stub_request(:post, + "https://api.stripe.com/v1/payment_methods/pm_123/attach") + .with(body: { customer: "cus_A123" }) + .to_return(hub_payment_method_response_mock({ pm_id: "pm_123" })) + end + + # Stubs the customers call to both the main stripe account and the connected account + def stub_customers_post_request(email:, response: {}, stripe_account_header: false) + stub = stub_request(:post, "https://api.stripe.com/v1/customers") + .with(body: { email: email }) + stub = stub.with(headers: { 'Stripe-Account' => 'acct_456' }) if stripe_account_header + stub.to_return(customers_response_mock(response)) + end + + def stub_successful_capture_request(order:, response: {}) + stub_capture_request(order, payment_successful_capture_mock(response)) + end + + def stub_failed_capture_request(order:, response: {}) + stub_capture_request(order, payment_failed_capture_mock(response)) + end + + def stub_capture_request(order, response_mock) + stub_request(:post, "https://api.stripe.com/v1/payment_intents/pi_123/capture") + .with(body: { amount_to_capture: Spree::Money.new(order.total).cents }, + headers: { 'Stripe-Account' => 'abc123' }) + .to_return(response_mock) + end + + def stub_refund_request + stub_request(:post, "https://api.stripe.com/v1/charges/ch_1234/refunds") + .with(body: { amount: 2000, expand: ["charge"] }, + headers: { 'Stripe-Account' => 'abc123' }) + .to_return(payment_successful_refund_mock) + end + + private + + def payment_intent_authorize_response_mock(options) + { status: options[:code] || 200, + body: JSON.generate(id: "pi_123", + object: "payment_intent", + amount: 2000, + amount_received: 2000, + status: options[:intent_status] || "requires_capture", + last_payment_error: nil, + charges: { data: [{ id: "ch_1234", amount: 2000 }] }) } + end + + def payment_intent_redirect_response_mock(redirect_url) + { status: 200, body: JSON.generate(id: "pi_123", + object: "payment_intent", + next_source_action: { + type: "authorize_with_url", + authorize_with_url: { url: redirect_url } + }, + status: "requires_source_action") } + end + + def payment_successful_capture_mock(options) + { status: options[:code] || 200, + body: JSON.generate(object: "payment_intent", + amount: 2000, + charges: { data: [{ id: "ch_1234", amount: 2000 }] }) } + end + + def payment_failed_capture_mock(options) + { status: options[:code] || 402, + body: JSON.generate(error: { message: + options[:message] || "payment-method-failure" }) } + end + + def hub_payment_method_response_mock(options) + { status: options[:code] || 200, + body: JSON.generate(id: options[:pm_id] || "pm_456", customer: "cus_A123") } + end + + def customers_response_mock(options) + customer_id = options[:customer_id] || "cus_A123" + { status: 200, + body: JSON.generate(id: customer_id, + sources: { data: [id: customer_id] }) } + end + + def payment_successful_refund_mock + { status: 200, + body: JSON.generate(object: "refund", + amount: 2000, + charge: "ch_1234") } + end +end