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

heddn created an issue. See original summary.

heddn’s picture

Status: Active » Needs review
StatusFileSize
new3.53 KB
joachim’s picture

+++ b/src/RecurringOrderManager.php
@@ -100,6 +101,13 @@ class RecurringOrderManager implements RecurringOrderManagerInterface {
+      throw new PaymentGatewayException(sprintf('Payment already processed for order #%s', $order->id()));

Nitpick: I think the standard for core is to use whatever the modern equivalent of format_text() is, so it's easier to read.

+++ b/src/Plugin/AdvancedQueue/JobType/RecurringOrderClose.php
@@ -106,6 +106,11 @@ class RecurringOrderClose extends JobTypeBase implements ContainerFactoryPluginI
+      return JobResult::failure($exception->getMessage());

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.

heddn’s picture

3.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.

heddn’s picture

This will want to swap out its current error logic for a Remaining Balance check on the order. On the roadmap for commerce 2.10 is a remaining balance field on the order.

^ 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.

bojanz’s picture

Title: Handle exceptions / error conditions in recurring order close and renew » RecurringOrderManager::closeOrder() should confirm that the order hasn't been paid yet
Status: Needs review » Needs work
+    catch (\Exception $exception) {
+      // Payment gateways can fail for other reasons than decline, let's handle
+      // these by reporting correctly.
+      return JobResult::failure($exception->getMessage());
+    }

Let's handle this in #2984454: RecurringOrderClose doesn't catch all exceptions.

   public function closeOrder(OrderInterface $order) {
+    // Guard against duplicate charging if recurring goes haywire.
+    $payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
+    $payments = $payment_storage->loadByProperties(['order_id' => $order->id()]);
+    if ($payments) {
+      throw new PaymentGatewayException(sprintf('Payment already processed for order #%s', $order->id()));
+    }

This needs to be replaced with an $order->isPaid() check, along with bumping the Commerce requirement to 2.10.

@@ -139,8 +147,8 @@ class RecurringOrderManager implements RecurringOrderManagerInterface {
     $subscriptions = $this->collectSubscriptions($order);
     /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription */
     $subscription = reset($subscriptions);
-    if ($subscription->getState()->value != 'active') {
-      // The subscription has ended.
+    if (!$subscription || $subscription->getState()->value != 'active') {
+      // The subscription does not exist or has ended.
       return NULL;
     }

This needs to be removed, it will be resolved in #2984703: RecurringOrderManager::renewOrder() needs to handle the case when no subscriptions exist anymore.

bojanz’s picture

Title: RecurringOrderManager::closeOrder() should confirm that the order hasn't been paid yet » Make RecurringOrderManager::closeOrder() reentrant

There'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.

bojanz’s picture

Look 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.

jonathanshaw’s picture

+++ b/src/RecurringOrderManager.php
@@ -100,6 +101,13 @@ class RecurringOrderManager implements RecurringOrderManagerInterface {
   public function closeOrder(OrderInterface $order) {
+    // Guard against duplicate charging if recurring goes haywire.
+    $payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
+    $payments = $payment_storage->loadByProperties(['order_id' => $order->id()]);
+    if ($payments) {
+      throw new PaymentGatewayException(sprintf('Payment already processed for order #%s', $order->id()));
+    }

Do we have steps to reproduce for this that could be used to create a test?

andyg5000’s picture

Priority: Normal » Major
Status: Needs work » Needs review
StatusFileSize
new1.11 KB

The 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.

jonathanshaw’s picture

Nice, but #10 doesn't address @bojanz's point in #8:

If the order was paid, we skip creating a payment. We should however ensure that the state is set to completed correctly.

penyaskito’s picture

The last submitted patch, 12: 2988470-closeOrder-not-twice-12.only-tests.patch, failed testing. View results

penyaskito’s picture

+++ b/src/RecurringOrderManager.php
@@ -146,21 +158,23 @@ class RecurringOrderManager implements RecurringOrderManagerInterface {
+    if (!$order->isPaid()) {

We shouldn't really need that as the method would return before anyway, but I guess it won't hurt?

jonathanshaw’s picture

Title: Make RecurringOrderManager::closeOrder() reentrant » Don't initiate payment of an already paid recurring order
Issue summary: View changes
Status: Needs review » Reviewed & tested by the community

#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.

  • amateescu committed 73a1a68 on 8.x-1.x authored by penyaskito
    Issue #2988470 by penyaskito, heddn, andyg5000, bojanz, jonathanshaw,...
amateescu’s picture

Status: Reviewed & tested by the community » Fixed

Looks good to me! Committed to 8.x-1.x, thanks everyone :)

jsacksick’s picture

Haven't reviewed the actual logic of the patch... But just one weird thing I noticed is this change to the .info.yml file:

  - commerce:commerce (>=8.x-2.13)

This seems weird as the composer.json requires at least 2.16

jsacksick’s picture

The .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))"

  • amateescu committed 3f15304 on 8.x-1.x
    Issue #2988470 followup: Revert .info.yml changes.
    
amateescu’s picture

Done!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.