Problem/Motivation

Both notification handlers complete the payment and then decide whether to save the order by reading $order->getBalance():

$payment->setState('completed')->save();
$order = $payment->getOrder();
if ($order->getBalance()->isZero() || $order->getBalance()->isNegative()) {
  $order->save();
}

That condition can never be true at that point. Payment::postSave() does not recalculate anything: it only registers the order with PaymentOrderUpdater::requestUpdate(), and the recalculation happens in PaymentOrderUpdater::destruct() at the end of the request. So total_paid is still the pre-payment value, the balance still reads as the full order total, and the branch is dead code.

On a production store this else branch logged "Payment completed but order X still has remaining balance" for 43 of 45 MB WAY notifications over three days.

That alone is harmless, because destruct() normally does the work. The damage comes from what the dead branch was written to protect against. While the customer confirms in the MB WAY app, the checkout request they left open is still alive and holding its own copy of the order. When destruct() runs, both that request and the updater save the order, and whichever loses is rejected with an OrderVersionMismatchException. When the loser is the deferred update, the order ends up with a completed payment while total_paid stays at zero.

Downstream, nothing recovers from that: the balance never settles, commerce_order.order.paid never fires, no state transition happens, and any subscriber on that event (ERP sync, notifications, fulfillment) never runs. The order silently reads as unpaid forever.

Measured on a production store over the last 7 days, on 43 MB WAY notifications:

OrderVersionMismatchException logged none
total_paid = 0 7 0
total_paid correct 2 34

All seven failures logged the exception at /checkout/{order}/payment. Historically the same store accumulated 370 such orders, at a rate between 0.1% and 3.4% of MB WAY payments per month.

The Multibanco gateway carries the same defect but has not been observed failing, because the customer pays hours or days later, with no checkout request left open to compete with. It is latent rather than harmless.

Steps to reproduce

  1. Place an order paid with MB WAY.
  2. Deliver the ifthenpay notification while the checkout request is still open (in practice, confirm in the MB WAY app within the seconds the checkout page is still being processed).
  3. Observe a payment in state completed on an order whose total_paid is 0 and whose balance equals the full total.

The included kernel tests reproduce it deterministically: they call onNotify() and assert on total_paid without invoking destruct(), which is exactly the guarantee the handler fails to provide.

Proposed resolution

Settle the balance inside the notification request instead of relying on destruct(), and retry when a concurrent save wins the race.

A new SettlesOrderBalanceTrait holds the shared logic and is used by both gateways. It reloads the order, calls PaymentOrderUpdater::updateOrder($order, TRUE), and retries up to three times on OrderVersionMismatchException. When it still cannot settle, it logs an error naming the order, so an unsettled balance becomes visible instead of silent.

Calling updateOrder() also clears the order from the updater's pending list, so destruct() does not repeat the work.

Remaining tasks

  • Review.
  • Sites already carrying affected orders need a one-off reconciliation of orders that hold a completed payment with total_paid at zero. Recalculating fires order.paid retroactively, so it should be done with notifications suppressed and scoped by date.

API changes

None. The new trait is additive and the gateways gain one injected service.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

introfini created an issue. See original summary.

introfini’s picture

Status: Active » Needs review

MR !8 targets 3.0.x.

Two kernel tests cover it, one per gateway. They call onNotify() and assert on total_paid without invoking destruct(), which is exactly the guarantee the handler was not providing. Both fail on 3.0.x with "Failed asserting that Price 0 USD matches expected 12 USD" and pass with the fix. Full suite: 55 tests, 1354 assertions. phpcs clean against Drupal and DrupalPractice.

The Multibanco change is the same defect, fixed pre-emptively: its onNotify() carries the identical balance check, and the only reason it has not been observed failing is that customers pay hours or days after checkout, so nothing is left holding a copy of the order to collide with.

  • introfini committed 07ede73e on 3.0.x
    Issue #3616514 by introfini: Settle the order balance in the...
introfini’s picture

Status: Needs review » Fixed

Merged to 3.0.x and released as 3.0.1.

Note for sites that were affected: the fix prevents new occurrences, it does not repair the orders that already went wrong. Look for orders holding a payment in state completed whose total_paid is zero. Recalculating those fires order.paid retroactively, so it should be done with notifications suppressed and scoped by date.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.