Skip to content

Commit

Permalink
Avoid duplicated Solidus refunds
Browse files Browse the repository at this point in the history
Stripe doesn't guarantee that a webhook event will be delivered only once,
so we need to be prepared to handle duplicated incoming refund events. Just
creating a Solidus copy whenever we receive a refund event is not enough.

On top of that, Stripe won't identify the refund that triggered the event. Both
the `charge.refunded` and `payment_intent.succeeded` (for partial captures,
which generates a refund for the remaining amount under the hood) webhooks only give
us information to retrieve the list of all refunds associated with a charge or
payment intent.

Because of this, we are now syncing all the refunds associated with a payment
intent at every webhook event. We keep track of the refunds already present on
Solidus by leveraging the `transaction_id` field on `Spree::Refund`, copying the
Stripe refund id as value.

On top of that, 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. We're marking those refunds
with a metadata field on Stripe, so we can exclude them from the sync.

Closes #262
  • Loading branch information
waiting-for-dev committed Apr 14, 2023
1 parent d86549b commit cd6d5d3
Show file tree
Hide file tree
Showing 9 changed files with 401 additions and 199 deletions.
11 changes: 10 additions & 1 deletion app/models/solidus_stripe/gateway.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
62 changes: 11 additions & 51 deletions app/subscribers/solidus_stripe/webhook/charge_subscriber.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
37 changes: 11 additions & 26 deletions app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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?
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions lib/solidus_stripe/refunds_synchronizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 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)
(refund.metadata[SKIP_SYNC_METADATA_KEY] != SKIP_SYNC_METADATA_VALUE) &&
Spree::Refund.find_by(transaction_id: refund.id).nil?
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
Loading

0 comments on commit cd6d5d3

Please sign in to comment.