Problem/Motivation
If a recurring order has been paid in some unforseen way, but not properly set to the 'completed' state, then RecurringOrderManager::closeOrder() will still automatically seek payment for it.
This was originally discovered by @heddn alongside various other edge cases, which have now been taken care of in other issues.
Original report:
"Make RecurringOrderManager::closeOrder() reentrant"
While working on #2984223: RecurringOrderManager assumes workflow, resulting in failures and duplicate payments, I uncovered several edge cases (read: error conditions) that aren't caught with the existing order close and renew logic.
Proposed resolution
In RecurringOrderManager::closeOrder() do not seek payment for already paid orders, simply mark them as paid.
Remaining tasks
None.
User interface changes
None.
API changes
None.
Data model changes
None.
Comments
Comment #2
heddnComment #3
joachim commentedNitpick: I think the standard for core is to use whatever the modern equivalent of format_text() is, so it's easier to read.
Do we not want dunning to happen in this case?
Also, though probably too complex for this issue, but the RecurringOrderRenew queue item will cheerfully carry on and create a new recurring order no matter what happens in RecurringOrderClose. Presumably the intention is that even if dunning happens, you want the next order created to cover the new billing period, and if the dunning fails, then the next order will fail too... I'll file a new issue for that; it could be I'm missing something in the code: #2991359: final dunning retry should clean up the recurring order.
Comment #4
heddn3.1: sprintf is used all over core in the context of exceptions. I think it is mainly because when you throw an exception you cannot be certain the state of various services and sprintf will always be available.
3.2: I don't think dunning is as useful for generic exceptions. These are usually system related and out of the control of the customer. Normal dunning is communication with the customer about their credit card being expired, etc. However, from what I can tell, most of the exceptions by the time they get here are out of the control of the customer. So we wouldn't want to initiate dunning. Or we'd face a false positive. There's nothing wrong with the customer's card data. It is usually a bad Stripe key or a bad authnet client_id, etc.
Comment #5
heddn^ is some notes from a call today with @bojanz. I might be misrepresenting what was decided and the conclusions, but I feel it is more important to jot it down somewhere, and get corrected, than hide the information.
Comment #6
bojanz commentedLet's handle this in #2984454: RecurringOrderClose doesn't catch all exceptions.
This needs to be replaced with an $order->isPaid() check, along with bumping the Commerce requirement to 2.10.
This needs to be removed, it will be resolved in #2984703: RecurringOrderManager::renewOrder() needs to handle the case when no subscriptions exist anymore.
Comment #7
bojanz commentedThere's no need to throw an error. If the order was paid, we skip creating a payment. We should however ensure that the state is set to completed correctly. In CS terms, we need to make the function reentrant.
Comment #8
bojanz commentedLook at this, but we need to land #3011667: Saving the order before its payment in PaymentGateway::onReturn() can cause data loss to Commerce first, and raise the recurring requirement to 2.13.
Comment #9
jonathanshawDo we have steps to reproduce for this that could be used to create a test?
Comment #10
andyg5000The exception stuff has landed, but we don't want to throw an exception as it will cancel out a successful order. Here's a simple patch that checks the order state or ->isPaid() to determine if the method should continue.
Comment #11
jonathanshawNice, but #10 doesn't address @bojanz's point in #8:
Comment #12
penyaskitoAdded fix for #11 and test.
Comment #14
penyaskitoWe shouldn't really need that as the method would return before anyway, but I guess it won't hurt?
Comment #15
jonathanshaw#14 I think it's good to have this, in case someone has added a state that isn't draft but can't be mark_paid. They really shouldn't, but you never know ... and just making the logic ultra-explicit is nice when we're taking money from customers.
Comment #17
amateescu commentedLooks good to me! Committed to 8.x-1.x, thanks everyone :)
Comment #18
jsacksick commentedHaven't reviewed the actual logic of the patch... But just one weird thing I noticed is this change to the .info.yml file:
This seems weird as the composer.json requires at least 2.16
Comment #19
jsacksick commentedThe .info.yml change should probably be reverted.
This is what I get when running Commerce dev:
"Commerce Recurring requires this module and version. Currently using Commerce version (Currently using Unresolved dependency [error]
Commerce (Version >=8.x-2.13 required))"
Comment #21
amateescu commentedDone!