diff --git a/app/models/solidus_stripe/gateway.rb b/app/models/solidus_stripe/gateway.rb index a10a00ed..3fa4df6b 100644 --- a/app/models/solidus_stripe/gateway.rb +++ b/app/models/solidus_stripe/gateway.rb @@ -2,6 +2,7 @@ require 'stripe' require "solidus_stripe/money_to_stripe_amount_converter" +require "solidus_stripe/refunds_synchronizer" module SolidusStripe # @see https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=checkout#auth-and-capture @@ -78,6 +79,11 @@ def void(payment_intent_id, _options = {}) end # Refunds the provided amount on a previously captured transaction. + # + # Notice we're adding `solidus_skip_sync: 'true'` to the metadata to avoid a + # duplicated refund after the generated webhook event. See + # {RefundsSynchronizer}. + # # TODO: check this method params twice. def credit(amount_in_cents, payment_intent_id, options = {}) check_payment_intent_id(payment_intent_id) @@ -89,13 +95,16 @@ def credit(amount_in_cents, payment_intent_id, options = {}) Stripe::Refund.create( amount: to_stripe_amount(amount_in_cents, currency), payment_intent: payment_intent_id, + metadata: { + RefundsSynchronizer::SKIP_SYNC_METADATA_KEY => RefundsSynchronizer::SKIP_SYNC_METADATA_VALUE + } ) end build_payment_log( success: true, message: "PaymentIntent was refunded successfully", - response_code: payment_intent_id, + response_code: stripe_refund.id, data: stripe_refund, ) end diff --git a/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb b/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb index fbfca27a..8a70c958 100644 --- a/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb +++ b/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "solidus_stripe/money_to_stripe_amount_converter" +require "solidus_stripe/refunds_synchronizer" module SolidusStripe module Webhook @@ -9,59 +9,19 @@ class ChargeSubscriber include Omnes::Subscriber include MoneyToStripeAmountConverter - handle :"stripe.charge.refunded", with: :refund_payment + handle :"stripe.charge.refunded", with: :sync_refunds - # Refunds a payment. - # - # Creates a `Spree::Refund` for the payment associated with the - # webhook event. - # - # The event's `amount_refunded` field on Stripe contains the total amount - # refunded for the payment, including previous ones. We need to check that - # against the last total refunded amount on the payment to get the actual - # amount refunded by the current event. - # - # The `Spree::RefundReason` with `SolidusStripe::Config.refund_reason_name` - # as name is used as the created refund's reason. - # - # Notice that, at this point, we have no way to distinguish between - # multiple occurrences of the same event. + # Syncs Stripe refunds with Solidus refunds. # # @param event [SolidusStripe::Webhook::Event] - def refund_payment(event) - event.data.object.to_hash => { - amount_refunded: new_stripe_total, - payment_intent: payment_intent_id, - currency: - } - payment = Spree::Payment.find_by!(response_code: payment_intent_id) - - return if payment.fully_refunded? - - amount = refund_amount(new_stripe_total, currency, payment) - Spree::Refund.create!( - payment: payment, - amount: amount, - transaction_id: payment_intent_id, - reason: SolidusStripe::PaymentMethod.refund_reason - ).tap do - SolidusStripe::LogEntries.payment_log( - payment, - success: true, - message: "Payment was refunded after charge.refunded webhook (#{_1.money})" - ) - end - end - - private - - def refund_amount(new_stripe_total, currency, payment) - last_total = payment.refunds.sum(:amount) - - new_stripe_total - .then { to_solidus_amount(_1, currency) } - .then { _1 - solidus_decimal_to_subunit(last_total, currency) } - .then { solidus_subunit_to_decimal(_1, currency) } + # @see SolidusStripe::RefundsSynchronizer + def sync_refunds(event) + payment_method = event.spree_payment_method + payment_intent_id = event.data.object.payment_intent + + RefundsSynchronizer + .new(payment_method) + .call(payment_intent_id) end end end diff --git a/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb b/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb index ba1649c8..c5567b7a 100644 --- a/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb +++ b/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "solidus_stripe/money_to_stripe_amount_converter" +require "solidus_stripe/refunds_synchronizer" module SolidusStripe module Webhook @@ -18,10 +18,10 @@ class PaymentIntentSubscriber # Marks a Solidus payment associated to a Stripe payment intent as # completed, adding a log entry about the event. # - # In the case of a partial capture, a refund is created for the - # remaining amount and a log entry is added. + # In the case of a partial capture, it also synchronizes the refunds. # # @param event [SolidusStripe::Webhook::Event] + # @see SolidusStripe::RefundsSynchronizer def capture_payment(event) payment = extract_payment_from_event(event) return if payment.completed? @@ -34,10 +34,8 @@ def capture_payment(event) if stripe_amount == stripe_amount_received complete_payment(payment) else - payment.transaction do - complete_payment(payment) - refund_payment(payment, stripe_amount, stripe_amount_received, currency) - end + complete_payment(payment) + sync_refunds(event) end end @@ -97,26 +95,13 @@ def complete_payment(payment) end end - def refund_payment(payment, stripe_amount, stripe_amount_received, currency) - refunded_amount = decimal_amount(stripe_amount - stripe_amount_received, currency) - Spree::Refund.create!( - payment: payment, - amount: refunded_amount, - transaction_id: payment.response_code, - reason: SolidusStripe::PaymentMethod.refund_reason - ).tap do - SolidusStripe::LogEntries.payment_log( - payment, - success: true, - message: "Payment was refunded after payment_intent.succeeded webhook (#{_1.money})" - ) - end - end + def sync_refunds(event) + payment_method = event.spree_payment_method + payment_intent_id = event.data.object.id - def decimal_amount(stripe_amount, currency) - stripe_amount - .then { to_solidus_amount(_1, currency) } - .then { solidus_subunit_to_decimal(_1, currency) } + RefundsSynchronizer + .new(payment_method) + .call(payment_intent_id) end end end diff --git a/lib/solidus_stripe/refunds_synchronizer.rb b/lib/solidus_stripe/refunds_synchronizer.rb new file mode 100644 index 00000000..9bb5eacb --- /dev/null +++ b/lib/solidus_stripe/refunds_synchronizer.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "solidus_stripe/money_to_stripe_amount_converter" + +module SolidusStripe + # Synchronizes refunds from Stripe to Solidus. + # + # For our use case, Stripe has two ways to inform us about refunds initiated + # on their side: + # + # 1. The `charge.refunded` webhook event, which is triggered when a refund is + # explicitly created. + # 2. The `payment_intent.succeeded` webhook event, which is triggered when a + # payment intent is captured. If the payment intent is captured for less than + # the full amount, a refund is automatically created for the remaining amount. + # + # In both cases, Stripe doesn't tell us which refund was recently created, so + # we need to fetch all the refunds for the payment intent and check if any of + # them is missing on Solidus. We're using the `transaction_id` field on + # `Spree::Refund` to match refunds against Stripe refunds ids. We could think + # about only syncing the single refund not present on Solidus, but we need to + # acknowledge concurrent partial refunds. + # + # The `Spree::RefundReason` with `SolidusStripe::Config.refund_reason_name` + # as name is used as created refunds' reason. + # + # Besides, we need to account for refunds created from Solidus admin panel, + # which calls the Stripe API. In this case, we need to avoid syncing the + # refund back to Solidus on the subsequent webhook, otherwise we would end up + # with duplicate records. We're marking those refunds with a metadata field on + # Stripe, so we can filter them out (see {Gateway#credit}). + class RefundsSynchronizer + include MoneyToStripeAmountConverter + + # Metadata key used to mark refunds that shouldn't be synced back to Solidus. + # @return [Symbol] + SKIP_SYNC_METADATA_KEY = :solidus_skip_sync + + # Metadata value used to mark refunds that shouldn't be synced back to Solidus. + # @return [String] + SKIP_SYNC_METADATA_VALUE = 'true' + + # @param payment_method [SolidusStripe::PaymentMethod] + def initialize(payment_method) + @payment_method = payment_method + end + + # @param payment_intent_id [String] + def call(payment_intent_id) + payment = Spree::Payment.find_by!(response_code: payment_intent_id) + + stripe_refunds(payment_intent_id) + .select(&method(:stripe_refund_needs_sync?)) + .map( + &method(:create_refund).curry[payment] + ) + end + + private + + def stripe_refunds(payment_intent_id) + @payment_method.gateway.request do + Stripe::Refund.list(payment_intent: payment_intent_id).data + end + end + + def stripe_refund_needs_sync?(refund) + originated_outside_solidus = refund.metadata[SKIP_SYNC_METADATA_KEY] != SKIP_SYNC_METADATA_VALUE + not_already_synced = Spree::Refund.find_by(transaction_id: refund.id).nil? + + originated_outside_solidus && not_already_synced + end + + def create_refund(payment, refund) + Spree::Refund.create!( + payment: payment, + amount: refund_decimal_amount(refund), + transaction_id: refund.id, + reason: SolidusStripe::PaymentMethod.refund_reason + ).tap(&method(:log_refund).curry[payment]) + end + + def log_refund(payment, refund) + SolidusStripe::LogEntries.payment_log( + payment, + success: true, + message: "Payment was refunded after Stripe event (#{refund.money})" + ) + end + + def refund_decimal_amount(refund) + to_solidus_amount(refund.amount, refund.currency) + .then { |amount| solidus_subunit_to_decimal(amount, refund.currency) } + end + end +end diff --git a/spec/lib/solidus_stripe/refunds_synchronizer_spec.rb b/spec/lib/solidus_stripe/refunds_synchronizer_spec.rb new file mode 100644 index 00000000..e67ddb14 --- /dev/null +++ b/spec/lib/solidus_stripe/refunds_synchronizer_spec.rb @@ -0,0 +1,238 @@ +# frozen-string-literal: true + +require "solidus_stripe_spec_helper" +require "solidus_stripe/refunds_synchronizer" +require "solidus_stripe/seeds" + +RSpec.describe SolidusStripe::RefundsSynchronizer do + def mock_refund_list(payment_intent_id, refunds) + allow(Stripe::Refund).to receive(:list).with(payment_intent: payment_intent_id).and_return( + Stripe::ListObject.construct_from( + data: refunds + ) + ) + end + + describe "#call" do + let(:payment_method) { create(:stripe_payment_method) } + + it "creates missing refunds on Solidus" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + expect(payment.refunds.count).to eq(1) + end + + it "uses the stripe refund id as created Solidus refund transaction_id field" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + refund = payment.refunds.first + expect(refund.transaction_id).to eq("re_123") + end + + it "uses the stripe amount as created Solidus refund amount" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + refund = payment.refunds.first + expect(refund.amount).to eq(10) + end + + it "uses the configured reason for created Solidus refunds" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + refund = payment.refunds.first + expect(refund.reason).to eq(SolidusStripe::PaymentMethod.refund_reason) + end + + it "skips the creation of Solidus refunds with transaction_id matching some stripe refund id" do + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + create(:refund, amount: 10, payment: payment, transaction_id: "re_123") + + described_class.new(payment_method).call(payment_intent_id) + + expect(payment.refunds.count).to be(1) + end + + it "skips the creation of Solidus refunds when specified in their metadata" do + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: { + solidus_skip_sync: 'true' + } + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + expect(payment.refunds.count).to be(0) + end + + it "creates multiple Solidus refunds if needed" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 500, + currency: "usd", + metadata: {} + }, + { + id: "re_456", + amount: 500, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + expect(payment.refunds.count).to be(2) + end + + it "creates only the missing Solidus refunds when there're multiple Stripe refunds" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 500, + currency: "usd", + metadata: {} + }, + { + id: "re_456", + amount: 500, + currency: "usd", + metadata: {} + } + ]) + create(:refund, amount: 5, payment: payment, transaction_id: "re_123") + + described_class.new(payment_method).call(payment_intent_id) + + expect(payment.refunds.pluck(:transaction_id)).to contain_exactly("re_123", "re_456") + end + + it "adds a log entry for created Solidus refund" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + expect(payment.log_entries.count).to eq(1) + end + + it "sets created log entry as successful" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 1000, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + expect( + payment.log_entries.first.parsed_details.success? + ).to be(true) + end + + it "uses a meaningful message with the refunded amount in created log entry" do + SolidusStripe::Seeds.refund_reasons + payment_intent_id = "pi_123" + payment = create(:payment, response_code: payment_intent_id, amount: 10, payment_method: payment_method) + mock_refund_list(payment_intent_id, [ + { + id: "re_123", + amount: 500, + currency: "usd", + metadata: {} + } + ]) + + described_class.new(payment_method).call(payment_intent_id) + + expect( + payment.log_entries.first.parsed_details.message + ).to include("after Stripe event ($5.00)") + end + end +end diff --git a/spec/models/solidus_stripe/gateway_spec.rb b/spec/models/solidus_stripe/gateway_spec.rb index 3005f427..88937142 100644 --- a/spec/models/solidus_stripe/gateway_spec.rb +++ b/spec/models/solidus_stripe/gateway_spec.rb @@ -88,7 +88,7 @@ it 'refunds when provided an originator payment' do gateway = build(:stripe_payment_method).gateway payment = instance_double(Spree::Payment, response_code: 'pi_123', currency: "USD") - refund = instance_double(Stripe::Refund, to_json: '{foo: "re_123"}') + refund = Stripe::Refund.construct_from(id: "re_123") allow(Stripe::Refund).to receive(:create).and_return(refund) result = gateway.credit(123_45, 'pi_123', currency: 'USD', originator: instance_double( @@ -99,8 +99,9 @@ expect(Stripe::Refund).to have_received(:create).with( payment_intent: 'pi_123', amount: 123_45, + metadata: { solidus_skip_sync: 'true' } ) - expect(result.params).to eq("data" => '{foo: "re_123"}') + expect(result.params).to eq("data" => '{"id":"re_123"}') end it "raises if no payment_intent_id is given" do diff --git a/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb b/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb index ca3b8706..8c4df4f5 100644 --- a/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb +++ b/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb @@ -2,7 +2,7 @@ RSpec.describe SolidusStripe::WebhooksController, type: %i[request webhook_request] do describe "POST /create charge.refunded" do - it "creates a refund for the given payment" do + it "synchronizes refunds" do SolidusStripe::Seeds.refund_reasons payment_method = create(:stripe_payment_method) stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") @@ -11,8 +11,12 @@ payment_method: payment_method, response_code: stripe_payment_intent.id, state: "completed") - stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123", amount_refunded: 500, - currency: 'usd') + stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123") + allow(Stripe::Refund).to receive(:list).with(payment_intent: stripe_payment_intent.id).and_return( + Stripe::ListObject.construct_from( + data: [{ id: "re_123", amount: 1000, currency: "usd", metadata: {} }] + ) + ) context = SolidusStripe::Webhook::EventWithContextFactory.from_object( payment_method: payment_method, object: stripe_charge, diff --git a/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb b/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb index 6d1d5a06..99634e1e 100644 --- a/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb +++ b/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb @@ -3,8 +3,8 @@ require "solidus_stripe_spec_helper" RSpec.describe SolidusStripe::Webhook::ChargeSubscriber do - describe "#refund_payment" do - it "creates a refund for a given payment" do + describe "#sync_refunds" do + it "synchronizes refunds" do SolidusStripe::Seeds.refund_reasons payment_method = create(:stripe_payment_method) stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") @@ -13,95 +13,21 @@ payment_method: payment_method, response_code: stripe_payment_intent.id, state: "completed") - stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123", amount_refunded: 500, - currency: 'usd') + stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123") + allow(Stripe::Refund).to receive(:list).with(payment_intent: stripe_payment_intent.id).and_return( + Stripe::ListObject.construct_from( + data: [{ id: "re_123", amount: 1000, currency: "usd", metadata: {} }] + ) + ) event = SolidusStripe::Webhook::EventWithContextFactory.from_object( payment_method: payment_method, object: stripe_charge, type: "charge.refunded" ).solidus_stripe_object - described_class.new.refund_payment(event) + described_class.new.sync_refunds(event) - refund = payment.reload.refunds.last - expect(refund).not_to be(nil) - expect(refund.amount).to eq(5) - expect(refund.transaction_id).to eq("pi_123") - expect(refund.reason.name).to eq(SolidusStripe::Seeds::DEFAULT_STRIPE_REFUND_REASON_NAME) - end - - it "deduces previously refunded amount" do - SolidusStripe::Seeds.refund_reasons - payment_method = create(:stripe_payment_method) - stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") - payment = create(:payment, - amount: 10, - payment_method: payment_method, - response_code: stripe_payment_intent.id, - state: "completed") - create(:refund, payment: payment, amount: 5) - stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123", amount_refunded: 700, - currency: 'usd') - event = SolidusStripe::Webhook::EventWithContextFactory.from_object( - payment_method: payment_method, - object: stripe_charge, - type: "charge.refunded" - ).solidus_stripe_object - - described_class.new.refund_payment(event) - - refund = payment.reload.refunds.last - expect(refund.amount).to eq(2) - end - - it "adds a log entry to the payment" do - SolidusStripe::Seeds.refund_reasons - payment_method = create(:stripe_payment_method) - stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") - payment = create(:payment, - amount: 10, - payment_method: payment_method, - response_code: stripe_payment_intent.id, - state: "completed") - stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123", amount_refunded: 500, - currency: 'usd') - event = SolidusStripe::Webhook::EventWithContextFactory.from_object( - payment_method: payment_method, - object: stripe_charge, - type: "charge.refunded" - ).solidus_stripe_object - - described_class.new.refund_payment(event) - - details = payment.log_entries.last.parsed_details - expect(details.success?).to be(true) - expect( - details.message - ).to eq "Payment was refunded after charge.refunded webhook ($5.00)" - end - - it "does nothing if the payment is already totally refunded" do - SolidusStripe::Seeds.refund_reasons - payment_method = create(:stripe_payment_method) - stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123") - payment = create(:payment, - amount: 10, - payment_method: payment_method, - response_code: stripe_payment_intent.id, - state: "completed") - create(:refund, payment: payment, amount: 10) - stripe_charge = Stripe::Charge.construct_from(id: "ch_123", payment_intent: "pi_123", amount_refunded: 1000, - currency: 'usd') - event = SolidusStripe::Webhook::EventWithContextFactory.from_object( - payment_method: payment_method, - object: stripe_charge, - type: "charge.refunded" - ).solidus_stripe_object - - described_class.new.refund_payment(event) - - expect(payment.reload.refunds.count).to be(1) - expect(payment.log_entries.count).to be(0) + expect(payment.refunds.count).to be(1) end end end diff --git a/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb b/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb index ba9a25ad..ba8e2fe3 100644 --- a/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb +++ b/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb @@ -73,6 +73,11 @@ payment_method: payment_method, response_code: stripe_payment_intent.id, state: "pending") + allow(Stripe::Refund).to receive(:list).with(payment_intent: stripe_payment_intent.id).and_return( + Stripe::ListObject.construct_from( + data: [{ id: "re_123", amount: 700, currency: "usd", metadata: {} }] + ) + ) event = SolidusStripe::Webhook::EventWithContextFactory.from_object( payment_method: payment_method, object: stripe_payment_intent, @@ -84,7 +89,7 @@ expect(payment.reload.state).to eq "completed" end - it "creates a refund for the unreceived amount" do + it "synchronizes refunds" do SolidusStripe::Seeds.refund_reasons payment_method = create(:stripe_payment_method) stripe_payment_intent = Stripe::PaymentIntent.construct_from( @@ -98,6 +103,11 @@ payment_method: payment_method, response_code: stripe_payment_intent.id, state: "pending") + allow(Stripe::Refund).to receive(:list).with(payment_intent: stripe_payment_intent.id).and_return( + Stripe::ListObject.construct_from( + data: [{ id: "re_123", amount: 200, currency: "usd", metadata: {} }] + ) + ) event = SolidusStripe::Webhook::EventWithContextFactory.from_object( payment_method: payment_method, object: stripe_payment_intent, @@ -106,9 +116,7 @@ described_class.new.capture_payment(event) - expect(payment.reload.refunds.count).to eq(1) - refund = payment.refunds.last - expect(refund.amount).to eq(3) + expect(payment.refunds.count).to be(1) end it "adds a log entry for the captured payment" do @@ -125,34 +133,11 @@ payment_method: payment_method, response_code: stripe_payment_intent.id, state: "pending") - event = SolidusStripe::Webhook::EventWithContextFactory.from_object( - payment_method: payment_method, - object: stripe_payment_intent, - type: "payment_intent.succeeded" - ).solidus_stripe_object - - described_class.new.capture_payment(event) - - log_entries = payment.log_entries.map { [_1.parsed_details.success?, _1.parsed_details.message] } - expect( - log_entries - ).to include([true, "Capture was successful after payment_intent.succeeded webhook"]) - end - - it "adds a log entry for the created refund" do - SolidusStripe::Seeds.refund_reasons - payment_method = create(:stripe_payment_method) - stripe_payment_intent = Stripe::PaymentIntent.construct_from( - id: "pi_123", - amount: 1000, - amount_received: 700, - currency: "usd" + allow(Stripe::Refund).to receive(:list).with(payment_intent: stripe_payment_intent.id).and_return( + Stripe::ListObject.construct_from( + data: [{ id: "re_123", amount: 700, currency: "usd", metadata: {} }] + ) ) - payment = create(:payment, - amount: 10, - payment_method: payment_method, - response_code: stripe_payment_intent.id, - state: "pending") event = SolidusStripe::Webhook::EventWithContextFactory.from_object( payment_method: payment_method, object: stripe_payment_intent, @@ -164,7 +149,7 @@ log_entries = payment.log_entries.map { [_1.parsed_details.success?, _1.parsed_details.message] } expect( log_entries - ).to include([true, "Payment was refunded after payment_intent.succeeded webhook ($3.00)"]) + ).to include([true, "Capture was successful after payment_intent.succeeded webhook"]) end end