Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect refunds that have already been performed #262

Closed
waiting-for-dev opened this issue Mar 30, 2023 · 5 comments · Fixed by #281
Closed

Detect refunds that have already been performed #262

waiting-for-dev opened this issue Mar 30, 2023 · 5 comments · Fixed by #281
Assignees
Labels
bug Something isn't working enhancement New feature or request

Comments

@waiting-for-dev
Copy link
Contributor

waiting-for-dev commented Mar 30, 2023

On #180, we're handling the charge.refunded webhook event to generate a Spree::Refund copy. However, we're not checking whether the incoming webhook has already been processed in the past.

Although we're skipping the processing when the associated payment has already been fully refunded, it might be the case that we handle twice a partial amount refund. As noted in a comment from the referenced issue, we can't use the transaction ARN as that's something not always available at the moment the event is created.

There're two ways to handle that:

  • Generally, through Be resilient against duplicated webhook events #188, making the system resilient against duplicated webhook events.
  • Specifically, as another layer of security, associating every created refund with the charge id in another database table.

Another scenario for a duplicated refund would be one that has been initiated by Solidus, calling the Stripe API and getting the corresponding webhook event.

@waiting-for-dev waiting-for-dev added the enhancement New feature or request label Mar 30, 2023
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a {Spree::Refund} for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
waiting-for-dev added a commit that referenced this issue Mar 30, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
@waiting-for-dev waiting-for-dev changed the title Detect incoming refund events that have already been performed Detect refunds that have already been performed Mar 30, 2023
waiting-for-dev added a commit that referenced this issue Mar 31, 2023
Stripe sends a `charge.refunded` [1] event when a refund is
performed. When that happens, we need to accordingly update our data by
creating a `Spree::Refund` for the given payment.

Notice that there are two ways of generating Solidus refunds: directly
or through an RMA (Return Merchandise Authorization). A refund generated
from Stripe can only integrate with the first kind, as there's no
reliable way to hook into the second flow.

Stripe and Solidus support partial refunds. The event's `refund_amount`
field we receive contains the sum of all the performed refunds on the
payment, so we need to deduce the previously refunded amount before
generating the new one.

Stripe refunds are required to be associated with a `Spree::RefundReason`, so
we're seeding a record with a generic `Stripe refund` reason name. The
name to fetch can be configured through the `#default_refund_reason_name`
preference.

As noted in #262, we're not checking yet if an incoming refund has already
been processed in the past.

[1] - https://stripe.com/docs/api/events/types#event_types-charge.refunded

Closes #180
@kennyadsl
Copy link
Member

We can probably explore saving the id of the Stripe refund on the Solidus Refund in the transaction_id field. When the webhook comes, we could check if there's an existing refund and skip creating it on our end if so.

@kennyadsl kennyadsl added the bug Something isn't working label Mar 31, 2023
@waiting-for-dev
Copy link
Contributor Author

That could work. We'd need to save it both when we create a refund from Solidus admin and when we create it from an incoming webhook.

@waiting-for-dev waiting-for-dev self-assigned this Apr 11, 2023
@waiting-for-dev
Copy link
Contributor Author

waiting-for-dev commented Apr 13, 2023

We can probably explore saving the id of the Stripe refund on the Solidus Refund in the transaction_id field.

The solution is trickier than expected. The problem is that Stripe doesn't add the created refund id on the event it sends when there's a refund (charge.refunded).

The best we can do is use the charge id to fetch all the refunds associated with it (Stripe::Refund.list(charge: charge_id) and create a refund on Solidus for all of the Stripe refunds that are not already present. That's to say, a charge.refunded event would handle all concurrent refunds: in the case of two partial refunds happening at the same time for a given order, the first charge.refunded event would trigger the creation of the two Solidus refunds, while the second one would do nothing.

Still, there's something else to consider. As we said in previous comments, a refund generated from Solidus will call the Stripe API and create a refund there, which in turn will publish the charge.refunded event. When we get the response from the Stripe API, we can update the Solidus refund transaction_id field with the Stripe refund id. However, there's no guarantee that, in the meantime, we haven't already processed the webhook (i.e., before updating the refund, therefore ending up with two records). We can workaround like this:

  • Before calling the Stripe API, use a value [[PENDING]] for the created Solidus refund transaction_id.
  • Once we get the response from the API, update transaction_id with the actual refund id.
  • Have the charge.refunded webhook handler described above:
    • Do nothing when we have a Solidus refund with [[PENDING]] transaction id, as we expect it to be populated by our gateway once it processes the Stripe API response.
    • Still, retry a number of times (ideally, until [[PENDING]] is updated) if there are other concurrent partial Stripe refunds waiting to be processed, as we can't know for sure which one corresponds to the pending one if they share the same amount.

@elia
Copy link
Member

elia commented Apr 13, 2023

@waiting-for-dev what if we combine the transaction_id with setting a "solidus id" in the stripe refund metadata?

  1. For solidus initiated refunds we can set the metadata at creation time
  2. For stripe initiated ones we can create the solidus counterpart and update the stripe refund's metadata field

This way if a webhook comes in with an already present metadata solidus id we can immediately match it to its counterpart, and if it's missing we'll know we need to create a Spree::Refund to match it.

@waiting-for-dev
Copy link
Contributor Author

Thanks for your feedback, @elia. That's a great suggestion. For Stripe-generated refunds, there's, in fact, no need to update their metadata fields in any way. Just checking that their id is already in our database as a transaction_id is enough. However, leveraging metadata for the refunds generated from Solidus avoids using the ugly [[PENDING]] hack. Instead of sending the Solidus id, which could become brittle in the face of database migrations, we can just use a skip_solidus_sync: "true" "boolean" flag. I have already tried it, and it works like a charm 🙌

waiting-for-dev added a commit that referenced this issue Apr 14, 2023
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
waiting-for-dev added a commit that referenced this issue Apr 14, 2023
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
waiting-for-dev added a commit that referenced this issue Apr 17, 2023
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants