Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines 95 to 102

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on letting the synchronizer do this and avoid coupling the metadata key/value?
In other words: "if you go through the synchronizer we can guarantee you won't get duplicates"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure to understand. The synchronizer takes refunds from Stripe and copies them to Solidus if not present, while the gateway is responsible for creating stuff from Solidus to Stripe. 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both SolidusStripe::PaymentIntent and SolidusStripe::Customer take charge of a specific part of the Stripe API, including creation. My suggestion is that maybe that should be the case for the RefundSynchronizer as well. Considering a smell this cross reference of metadata.

A less invasive alternative would be to simplify this part doing something like:

metadata: {
  **RefundsSynchronizer.stripe_metadata
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I think having API calls spread across the codebase is a bigger smell 🙂 Ideally, I'd like them to get closer to the gateway, wrapped within an adapter class, if that would be too much for it.

So I'll go with your latest suggestion, which I think best encapsulates details.


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
96 changes: 96 additions & 0 deletions lib/solidus_stripe/refunds_synchronizer.rb
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on restricting to the payment method? @payment_method.payments.find_by(…)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An id collision is probably impossible, and the query will be slightly slower, but that won't hurt.


stripe_refunds(payment_intent_id)
.select(&method(:stripe_refund_needs_sync?))
.map(
&method(:create_refund).curry[payment]
)
Comment on lines +54 to +56

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on just using a block here .map { create_refund(payment, _1) } for the sake of readability?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I understand it's not the most standard Ruby, although when you get used to it readability is even better. What I would love is Ruby to support something like:

.map(&create_refund(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])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, maybe .tap { log_refund(payment, _1) } would be more readable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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