diff --git a/app/models/solidus_stripe/gateway.rb b/app/models/solidus_stripe/gateway.rb index b300a383..2d0d7a87 100644 --- a/app/models/solidus_stripe/gateway.rb +++ b/app/models/solidus_stripe/gateway.rb @@ -110,7 +110,7 @@ def purchase(amount_in_cents, source, options = {}) end # Voids a previously authorized transaction, releasing the funds that are on hold. - def void(payment_intent_id, _source, _options = {}) + def void(payment_intent_id, _options = {}) check_payment_intent_id(payment_intent_id) payment_intent = request do @@ -133,7 +133,7 @@ def void(payment_intent_id, _source, _options = {}) # Refunds the provided amount on a previously captured transaction. # TODO: check this method params twice. - def credit(amount_in_cents, _source, payment_intent_id, options = {}) + def credit(amount_in_cents, payment_intent_id, options = {}) check_payment_intent_id(payment_intent_id) payment = options[:originator].payment diff --git a/lib/solidus_stripe/testing_support/factories.rb b/lib/solidus_stripe/testing_support/factories.rb index 36b2112e..88ce5ed5 100644 --- a/lib/solidus_stripe/testing_support/factories.rb +++ b/lib/solidus_stripe/testing_support/factories.rb @@ -22,6 +22,49 @@ stripe_payment_method_id { "pm_#{SecureRandom.uuid.delete('-')}" } end + factory :stripe_payment, parent: :payment do + association :payment_method, factory: :stripe_payment_method + amount { order.outstanding_balance } + response_code { "pi_#{SecureRandom.uuid.delete('-')}" } + state { 'checkout' } + + transient do + stripe_payment_method_id { "pm_#{SecureRandom.uuid.delete('-')}" } + end + + source { + create( + :stripe_payment_source, + payment_method: payment_method, + stripe_payment_method_id: stripe_payment_method_id + ) + } + + trait :authorized do + state { 'pending' } + + after(:create) do |payment, _evaluator| + create( + :stripe_payment_log_entry, + :authorize, + source: payment + ) + end + end + + trait :captured do + state { 'completed' } + + after(:create) do |payment, _evaluator| + create( + :stripe_payment_log_entry, + :autocapture, + source: payment + ) + end + end + end + factory :stripe_payment_intent, class: 'SolidusStripe::PaymentIntent' do association :order association :payment_method, factory: :stripe_payment_method @@ -49,6 +92,55 @@ end end + factory :stripe_payment_log_entry, class: 'Spree::LogEntry' do + transient do + success { true } + message { nil } + response_code { source.response_code } + data { nil } + end + + source { create(:payment) } + + trait :authorize do + message { 'PaymentIntent was confirmed successfully' } + end + + trait :capture do + message { 'Payment captured successfully' } + end + + trait :autocapture do + message { 'PaymentIntent was confirmed and captured successfully' } + end + + trait :void do + message { 'PaymentIntent was canceled successfully' } + end + + trait :refund do + message { 'PaymentIntent was refunded successfully' } + end + + trait :fail do + success { false } + message { 'PaymentIntent operation failed with a generic error' } + end + + details { + YAML.safe_dump( + SolidusStripe::LogEntries.build_payment_log( + success: success, + message: message, + response_code: response_code, + data: data + ), + permitted_classes: Spree::LogEntry.permitted_classes, + aliases: Spree::Config.log_entry_allow_aliases + ) + } + end + factory :order_with_stripe_payment, parent: :order do transient do amount { 10 } diff --git a/spec/models/solidus_stripe/gateway_spec.rb b/spec/models/solidus_stripe/gateway_spec.rb index 94e6170a..8420a9b3 100644 --- a/spec/models/solidus_stripe/gateway_spec.rb +++ b/spec/models/solidus_stripe/gateway_spec.rb @@ -163,7 +163,7 @@ stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") allow(Stripe::PaymentIntent).to receive(:cancel).and_return(stripe_payment_intent) - result = gateway.void('pi_123', :source) + result = gateway.void('pi_123') expect(Stripe::PaymentIntent).to have_received(:cancel).with('pi_123') expect(result.params).to eq("data" => '{"id":"pi_123"}') @@ -173,7 +173,7 @@ payment_method = build(:stripe_payment_method) gateway = payment_method.gateway - expect { gateway.void(nil, :source) }.to raise_error( + expect { gateway.void(nil) }.to raise_error( ArgumentError, /missing payment_intent_id/ ) @@ -183,7 +183,7 @@ payment_method = build(:stripe_payment_method) gateway = payment_method.gateway - expect { gateway.void("invalid", :source) }.to raise_error( + expect { gateway.void("invalid") }.to raise_error( ArgumentError, /payment intent id has the wrong format/ ) @@ -197,7 +197,7 @@ refund = instance_double(Stripe::Refund, to_json: '{foo: "re_123"}') allow(Stripe::Refund).to receive(:create).and_return(refund) - result = gateway.credit(123_45, :source, 'pi_123', currency: 'USD', originator: instance_double( + result = gateway.credit(123_45, 'pi_123', currency: 'USD', originator: instance_double( Spree::Refund, payment: payment )) @@ -213,7 +213,7 @@ payment_method = build(:stripe_payment_method) gateway = payment_method.gateway - expect { gateway.credit(:amount, :source, nil) }.to raise_error( + expect { gateway.credit(:amount, nil) }.to raise_error( ArgumentError, /missing payment_intent_id/ ) @@ -223,7 +223,7 @@ payment_method = build(:stripe_payment_method) gateway = payment_method.gateway - expect { gateway.credit(:amount, :source, "invalid") }.to raise_error( + expect { gateway.credit(:amount, "invalid") }.to raise_error( ArgumentError, /payment intent id has the wrong format/ ) diff --git a/spec/support/solidus_stripe/backend_test_helper.rb b/spec/support/solidus_stripe/backend_test_helper.rb new file mode 100644 index 00000000..ba448520 --- /dev/null +++ b/spec/support/solidus_stripe/backend_test_helper.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +module SolidusStripe::BackendTestHelper + def payment_method + # Memoize the payment method id to avoid fetching it multiple times + @payment_method ||= create(:stripe_payment_method) + end + + def order + @order ||= create(:completed_order_with_totals, line_items_price: 50) + end + + # Stripe-related helper methods for creating and fetching Stripe objects + + def create_stripe_payment_method(card_number) + payment_method.gateway.request do + Stripe::PaymentMethod.create({ + type: 'card', + card: { + number: card_number, + exp_month: 12, + exp_year: (Time.zone.now.year + 1), + cvc: '123', + }, + }) + end + end + + def create_stripe_payment_intent(stripe_payment_method_id) + payment_method.gateway.request do + Stripe::PaymentIntent.create( + amount: (order.outstanding_balance * 100).to_i, + capture_method: 'manual', + confirm: true, + currency: 'usd', + payment_method: stripe_payment_method_id + ) + end + end + + def fetch_stripe_refund(payment) + payment_method.gateway.request do + Stripe::Refund.list(payment_intent: payment.response_code).data.first + end + end + + def fetch_stripe_payment_intent(payment) + payment_method.gateway.request do + Stripe::PaymentIntent.retrieve(payment.response_code) + end + end + + # Navigation helper methods for interacting with the admin panel + + def visit_payments_page + visit "/admin/orders/#{order.number}/payments" + end + + def visit_payment_page(payment) + visit_payments_page + click_on payment.number + end + + # Action helper methods for performing operations on payments + + def create_authorized_payment(opts = {}) + stripe_payment_method = create_stripe_payment_method(opts[:card_number] || '4242424242424242') + payment_intent = create_stripe_payment_intent(stripe_payment_method.id) + + create(:stripe_payment, + :authorized, + order: order, + response_code: payment_intent.id, + stripe_payment_method_id: stripe_payment_method.id, + payment_method: payment_method) + end + + def create_captured_payment(card_number: '4242424242424242') + payment = create_authorized_payment(card_number: card_number) + payment.capture! + payment + end + + def refund_payment + refund_reason = create :refund_reason + click_icon(:"mail-reply") # Refund icon style reference in solidus_backend + within '.new_refund' do + select refund_reason.name, from: 'Reason' + click_button 'Refund' + end + end + + def partially_refund_payment(refund_reason, amount) + click_icon(:"mail-reply") # Refund icon style reference in solidus_backend + within '.new_refund' do + select refund_reason.name, from: 'Reason' + fill_in 'Amount', with: amount + click_button 'Refund' + end + end + + def void_payment + click_icon(:void) + end + + def capture_payment + click_icon(:capture) + end + + def cancel_order + visit "/admin/orders/#{order.number}/edit" + accept_alert do + click_on 'Cancel' + end + end + + # Helper methods for checking expected outcomes and states + + def expects_page_to_display_successfully_captured_payment(payment) + expect(page).to have_content('Payment Updated') + expect(payment.reload.state).to eq('completed') + expect(payment.capture_events.first.amount).to eq(payment.amount) + end + + def expects_page_to_display_successfully_refunded_payment(payment) + expect(page).to have_content('Refund has been successfully created!') + expect(payment).to be_fully_refunded + end + + def expects_page_to_display_successfully_partially_refunded_payment(payment, amount) + expect(page).to have_content('Refund has been successfully created!') + expect(payment.reload.state).to eq('completed') + expect(payment.refunds.first.amount).to eq(amount) + end + + def expects_page_to_display_successfully_voided_payment(payment) + expect(page).to have_content('Payment Updated') + expect(payment.reload.state).to eq('void') + end + + def expects_page_to_display_successfully_canceled_order_payment(payment) + expect(page).to have_content('Order canceled') + expect(payment.reload.state).to eq('void') + end + + def expects_page_to_display_capture_fail_message(payment, message) + expect(page).to have_content(message) + expect(payment.reload.state).to eq('failed') + end + + def expects_page_to_display_payment(payment) + click_on 'Payments' + expect(page).to have_content(payment.number) + end + + def expects_page_to_display_log_messages(log_messages = []) + expect(page).to have_content('Log Entries') + log_messages.each do |log_message| + expect(page).to have_content(log_message) + end + end + + def expects_payment_to_be_refunded_on_stripe(payment, amount) + stripe_refund = fetch_stripe_refund(payment) + expect(stripe_refund.amount).to eq(amount * 100) + end + + def expects_payment_to_be_voided_on_stripe(payment) + stripe_payment_intent = fetch_stripe_payment_intent(payment) + expect(stripe_payment_intent.status).to eq('canceled') + end + + def expects_payment_to_have_correct_capture_amount_on_stripe(payment, amount) + stripe_payment_intent = fetch_stripe_payment_intent(payment) + expect(stripe_payment_intent.amount_received).to eq(amount * 100) + end +end diff --git a/spec/system/backend/solidus_stripe/orders/payments_spec.rb b/spec/system/backend/solidus_stripe/orders/payments_spec.rb new file mode 100644 index 00000000..ae714fe6 --- /dev/null +++ b/spec/system/backend/solidus_stripe/orders/payments_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'solidus_stripe_spec_helper' + +RSpec.describe 'SolidusStripe Orders Payments', :js do + include SolidusStripe::BackendTestHelper + stub_authorization! + + context 'with successful payment operations' do + it 'navigates to the payments page' do + payment = create_authorized_payment + visit_payments_page + + expects_page_to_display_payment(payment) + end + + it 'displays log entries for a payment' do + payment = create(:stripe_payment, :captured, order: order, payment_method: payment_method) + + visit_payment_page(payment) + + expects_page_to_display_log_messages(["PaymentIntent was confirmed and captured successfully"]) + end + + it 'captures an authorized payment successfully' do + payment = create_authorized_payment + visit_payments_page + + capture_payment + + expects_page_to_display_successfully_captured_payment(payment) + expects_payment_to_have_correct_capture_amount_on_stripe(payment, payment.amount) + end + + it 'voids a payment from an order' do + payment = create_authorized_payment + visit_payments_page + + void_payment + + expects_page_to_display_successfully_voided_payment(payment) + expects_payment_to_be_voided_on_stripe(payment) + end + + it 'refunds a payment from an order' do + payment = create_captured_payment + visit_payments_page + + refund_payment + + expects_page_to_display_successfully_refunded_payment(payment) + expects_payment_to_be_refunded_on_stripe(payment, payment.amount) + end + + it 'partially refunds a payment from an order' do + payment = create_captured_payment + visit_payments_page + + refund_reason = create(:refund_reason) + partial_refund_amount = 25 + partially_refund_payment(refund_reason, partial_refund_amount) + + expects_page_to_display_successfully_partially_refunded_payment(payment, partial_refund_amount) + expects_payment_to_be_refunded_on_stripe(payment, partial_refund_amount) + end + + it 'cancels an order with captured payment' do + payment = create_captured_payment + visit_payments_page + + cancel_order + + # https://github.com/solidusio/solidus/blob/ab59d6435239b50db79d73b9a974af057ad56b52/core/app/models/spree/payment_method.rb#L169-L181 + pending "needs to implement try_void method to handle voiding payments on order cancellation" + + expects_page_to_display_successfully_canceled_order_payment(payment) + expects_payment_to_be_voided_on_stripe(payment) + end + + it 'cancels an order with authorized payment' do + payment = create_authorized_payment + visit_payments_page + + cancel_order + + # https://github.com/solidusio/solidus/blob/ab59d6435239b50db79d73b9a974af057ad56b52/core/app/models/spree/payment_method.rb#L169-L181 + # Note that for this specific case, the test is pending also because there is an issue + # with locating the user who canceled the order. + pending "needs to implement try_void method to handle voiding payments on order cancellation" + + expects_page_to_display_successfully_canceled_order_payment(payment) + expects_payment_to_be_voided_on_stripe(payment) + end + end + + context 'with failed payment operations' do + it 'fails to capture a payment due to incomplete 3D Secure authentication' do + payment = create_authorized_payment(card_number: '4000000000003220') + visit_payments_page + + capture_payment + + expects_page_to_display_capture_fail_message(payment, + 'This PaymentIntent could not be captured because it has a status of requires_action.') + end + end +end