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

[RFC] Cancel payments refunds correctly #2111

Merged
merged 3 commits into from
Sep 20, 2017
Merged

Conversation

tvdeyen
Copy link
Member

@tvdeyen tvdeyen commented Jul 25, 2017

The problem

When canceling payments Solidus handles all response as a void response.

That assumption is unfortunate. Lots of payment providers only allow voiding payments if they are not settled yet. Braintree is such a provider. After a transaction has settled you cannot void anymore but have to refund instead.

The solidus_braintree and solidus_paypal_braintree integrations handle this by checking the status of the transaction first and refund if the transaction cannot be voided anymore. Although this seems correct gateway wise, it is not for Solidus.

For a refunded payment we need to have a Spree::Refund object. Handling every response as a void and not as refund - if it should - is wrong and leads to incorrect payment state.

Solution

A new method Spree::PaymentMethod#try_void

Spree::Payment#cancel! has changed to tell the payment method to try a void. If that succeeds we handle the response as a void - like we do currently -, but if that fails we create a refund with the full amount and a "Order canceled" reason. Spree::Refund already talks with the gateway and credits accordingly.

Fixes #2104

Copy link
Member

@kennyadsl kennyadsl left a comment

Choose a reason for hiding this comment

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

I just left a comment about naming a class but I'm fine with what has been done as well!

# @return [Class] a class instance that responds to `cancel!(payment)`
attr_writer :payment_canceller
def payment_canceller
@payment_canceller ||= Spree::Payment::Cancellation.new(
Copy link
Member

@kennyadsl kennyadsl Jul 27, 2017

Choose a reason for hiding this comment

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

I just have some doubt about the naming:

I expected this class to be named Spree::Payment::Canceller because it is the entity that performs the cancellation. Spree::Payment::Cancellation also makes sense but it's more an action, maybe I'd expect the cancellation to happen right when it is initialized, instead this will not perform anything until the cancel! method is called.

As discussed in private with @tvdeyen it is just a personal preference, both make sense, I just wanted to express mine 😸 .


# @param reason [String] (DEFAULT_REASON) -
# The reason used to create the Spree::RefundReason
def initialize(reason: DEFAULT_REASON)
Copy link
Member

Choose a reason for hiding this comment

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

Maybe this should not be an optional argument since we are passing it on initialization.

Copy link
Member Author

Choose a reason for hiding this comment

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

I want to be able to initialize without the reason given

if response = payment.payment_method.try_void(payment)
payment.send(:handle_void_response, response)
else
payment.refunds.create!(amount: payment.amount, reason: refund_reason)
Copy link
Contributor

Choose a reason for hiding this comment

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

Might need to use the credit_allowed method here instead of amount?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, Jordan. Fixed

Copy link
Contributor

@mamhoff mamhoff left a comment

Choose a reason for hiding this comment

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

I like that there's a test for canceling a partially refunded payment, and that that works well now. I understand this is a pretty sensitive area of Solidus, but I do think this should get in.

@tvdeyen
Copy link
Member Author

tvdeyen commented Aug 18, 2017

It is worth mentioning that a payment method that does not implement try_void yet will fall back to the current behavior. So, this is safe for existing stores.

@tvdeyen
Copy link
Member Author

tvdeyen commented Aug 23, 2017

@jordan-brough I fixed the refund amount. It now uses credit_allowed

@mamhoff
Copy link
Contributor

mamhoff commented Aug 30, 2017

I am very much for this, but would like a dedicated changelog entry detailing that this is happening and what it means.

#
# @param payment [Spree::Payment] - the payment that should be canceled
#
def cancel!(payment)
Copy link
Contributor

Choose a reason for hiding this comment

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

This should probably be just cancel (without the !)

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed

@@ -55,7 +55,7 @@ def void(_response_code, _credit_card, _options = {})
ActiveMerchant::Billing::Response.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345')
end

def cancel(_response_code)
def try_void(_payment)
Copy link
Contributor

Choose a reason for hiding this comment

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

A YARDoc comment would be helpful here, the return values are a little confusing:

  • ActiveMerchant::Billing::Response with true if the void succeeded
  • ActiveMerchant::Billing::Response with false if the void failed
  • Just false if it can't be voided at this time.

Copy link
Member Author

Choose a reason for hiding this comment

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

added

@tvdeyen
Copy link
Member Author

tvdeyen commented Aug 30, 2017

I am very much for this, but would like a dedicated changelog entry detailing that this is happening and what it means.

It means nothing to existing stores as all existing payment methods do not implement try_void yet. Besides a deprecation notice stores will not see any difference to current behaviour.

But I will add a note about the deprecation

@tvdeyen tvdeyen force-pushed the try-void branch 3 times, most recently from 71b4b84 to aa3ee8d Compare August 31, 2017 09:01
@tvdeyen
Copy link
Member Author

tvdeyen commented Aug 31, 2017

@mamhoff @jhawthorn addressed your feedback

This new class handles cancellations of payments called by order cancel.

This new class changes the way we cancel payments in that it asks the payment
method to try a void. If that succeeds it handles the void as always. But if
that fails - because the transaction is in a un-voidable state - it creates
a refund about the amount instead.
In order to get a proper payment state - with refunds created when void is not possible - I needed to update the store credit spec in that it now tests that the payment handles the refund, in contrare to the former directly credit approach. Latter is not supported anymore.
@tvdeyen tvdeyen merged commit 1bb33ea into solidusio:master Sep 20, 2017
@tvdeyen tvdeyen deleted the try-void branch September 20, 2017 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants