Commit 88db10e894a for woocommerce
commit 88db10e894a2c328099e07ae292602e70a21b08b
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Wed Nov 4 07:44:03 2026 -0300
Fire the staged payment and webhook hooks on save, not on stage (#70603)
* Fire the staged payment and webhook hooks on save
stage_payment_complete() fired woocommerce_pre_payment_complete and
cleared the session's order_awaiting_payment flag when it staged the
change. stage_delivery_result() fired
woocommerce_webhook_disabled_due_delivery_failures the same way. A
staged change can be refused and never saved, so both now wait for the
next save(). The saving methods still run them right away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* State the stage method contract in the write_or_defer() docblock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep staged payment and webhook hooks consistent across repeat calls
A refused second stage_payment_complete() removed the queued pre-payment
hook but left woocommerce_payment_complete queued. It now checks the
status before it queues anything. The webhook clears a staged disable
when log_delivery() fires the action, and save() skips the action when
the webhook is no longer disabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Let payment_complete replace a staged pre-payment hook
payment_complete() after stage_payment_complete() fired
woocommerce_pre_payment_complete twice: once right away and once from the
staged entry on save. It now goes through write_or_defer(), which removes
the staged entry, like the other saving methods do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooairr-694-defer-pre-payment-complete b/plugins/woocommerce/changelog/wooairr-694-defer-pre-payment-complete
new file mode 100644
index 00000000000..61ff067abae
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-694-defer-pre-payment-complete
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Fire the payment and webhook hooks of staged changes on save, not on stage; unreleased code only.
+
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index d32497c6622..1948c9155a6 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -490,6 +490,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Run a write or a hook right away when the stage method runs for its saving method, or leave it to the
* next save() otherwise.
*
+ * A stage method changes the order in memory only. Route its other side effects through this method. Only a
+ * hook that lets listeners read or change the order before the change may fire at stage time.
+ *
* @since 11.4.0
* @param string $key Name of the deferred entry.
* @param callable $callback Callback to run.
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index 0e897dc7f85..6ec17694c63 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -177,12 +177,14 @@ class WC_Order extends WC_Abstract_Order {
/**
* Make the in-memory changes of payment_complete(): the transaction ID, the paid date and the payment
- * complete status. Leave the payment note and woocommerce_payment_complete to the next save(), which adds
- * the note before it writes and fires the action after it writes. payment_complete() runs this method
- * through write_through(), so it adds the note, saves and fires the action right away, as it always did.
+ * complete status. Leave woocommerce_pre_payment_complete, the session's order_awaiting_payment flag, the
+ * payment note and woocommerce_payment_complete to the next save(), which runs the first three before it
+ * writes and fires the action after it writes. payment_complete() runs this method through write_through(),
+ * so it runs all of them and saves right away, as it always did.
*
* When the order status is not one that payment can complete, the order does not change, the
- * woocommerce_payment_complete_order_status_<status> action fires right away, and the method returns false.
+ * woocommerce_payment_complete_order_status_<status> action fires right away, the method adds nothing for
+ * the next save(), and it returns false.
*
* An order that is not saved yet does not change, and the method returns false.
*
@@ -200,17 +202,23 @@ class WC_Order extends WC_Abstract_Order {
return false;
}
- /**
- * Fires before the payment of an order is completed.
- *
- * @param int $order_id Order ID.
- * @param string $transaction_id Transaction ID passed to payment_complete().
- * @since 2.1.0
- */
- do_action( 'woocommerce_pre_payment_complete', $this->get_id(), $transaction_id );
+ $pre_payment_complete = static function ( $order_id, $transaction_id ) {
+ /**
+ * Fires before the payment of an order is completed.
+ *
+ * @param int $order_id Order ID.
+ * @param string $transaction_id Transaction ID passed to payment_complete().
+ * @since 2.1.0
+ */
+ do_action( 'woocommerce_pre_payment_complete', $order_id, $transaction_id );
+
+ if ( WC()->session ) {
+ WC()->session->set( 'order_awaiting_payment', false );
+ }
+ };
- if ( WC()->session ) {
- WC()->session->set( 'order_awaiting_payment', false );
+ if ( $this->writing_through ) {
+ $this->write_or_defer( 'pre_payment_complete', $pre_payment_complete, array( $this->get_id(), $transaction_id ) );
}
/**
@@ -233,6 +241,10 @@ class WC_Order extends WC_Abstract_Order {
return false;
}
+ if ( ! $this->writing_through ) {
+ $this->defer_to_save( 'pre_payment_complete', $pre_payment_complete, array( $this->get_id(), $transaction_id ) );
+ }
+
if ( ! empty( $transaction_id ) ) {
$this->set_transaction_id( $transaction_id );
}
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index a23add21287..f6364c498b8 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -36,6 +36,14 @@ class WC_Webhook extends WC_Legacy_Webhook {
*/
protected $processed = array();
+ /**
+ * Whether stage_delivery_result() disabled the webhook since the last save. The next save() fires
+ * woocommerce_webhook_disabled_due_delivery_failures after it writes.
+ *
+ * @var bool
+ */
+ private $disabled_due_delivery_failures = false;
+
/**
* Stores webhook data.
*
@@ -619,7 +627,9 @@ class WC_Webhook extends WC_Legacy_Webhook {
* then save().
*
* A success, which is a 2xx, 301 or 302 response code, resets the failure count. A failure adds one to
- * the failure count, and disables the webhook after more than 5 consecutive failures.
+ * the failure count, and disables the webhook after more than 5 consecutive failures. The next save()
+ * fires woocommerce_webhook_disabled_due_delivery_failures after it writes. log_delivery() runs this
+ * method through write_through(), so it fires the action right away, as it always did.
*
* @since 11.4.0
* @param int|string $response_code Response code, or the error code of a failed request.
@@ -639,12 +649,49 @@ class WC_Webhook extends WC_Legacy_Webhook {
if ( $failures > apply_filters( 'woocommerce_max_webhook_delivery_failures', 5 ) ) {
$this->set_status( 'disabled' );
- do_action( 'woocommerce_webhook_disabled_due_delivery_failures', $this->get_id() );
+ $this->disabled_due_delivery_failures = ! $this->writing_through;
+ if ( $this->writing_through ) {
+ $this->fire_disabled_due_delivery_failures();
+ }
} else {
$this->set_failure_count( ++$failures );
}
}
+ /**
+ * Save the webhook, then fire woocommerce_webhook_disabled_due_delivery_failures when
+ * stage_delivery_result() disabled it since the last save and it is still disabled.
+ *
+ * @since 11.4.0
+ * @return int Webhook ID.
+ */
+ public function save() {
+ $id = parent::save();
+
+ $disabled = $this->disabled_due_delivery_failures && 'disabled' === $this->get_status();
+ $this->disabled_due_delivery_failures = false;
+ if ( $disabled ) {
+ $this->fire_disabled_due_delivery_failures();
+ }
+
+ return $id;
+ }
+
+ /**
+ * Fire the action for a webhook that too many failed deliveries disabled.
+ *
+ * @since 11.4.0
+ */
+ private function fire_disabled_due_delivery_failures(): void {
+ /**
+ * Fires when a webhook is disabled after too many consecutive failed deliveries.
+ *
+ * @param int $webhook_id Webhook ID.
+ * @since 2.2.0
+ */
+ do_action( 'woocommerce_webhook_disabled_due_delivery_failures', $this->get_id() );
+ }
+
/**
* Get the delivery logs for this webhook.
*
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
index d755715db76..1d21f494d38 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
@@ -1184,9 +1184,13 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
$object = new WC_Order();
$object->save();
$fired = did_action( 'woocommerce_payment_complete' );
+ $pre = did_action( 'woocommerce_pre_payment_complete' );
$notes = count( wc_get_order_notes( array( 'order_id' => $object->get_id() ) ) );
+ WC()->session->set( 'order_awaiting_payment', $object->get_id() );
$this->assertTrue( $object->stage_payment_complete( '12345' ) );
+ $this->assertSame( 0, did_action( 'woocommerce_pre_payment_complete' ) - $pre );
+ $this->assertSame( $object->get_id(), WC()->session->get( 'order_awaiting_payment' ) );
$this->assertSame( OrderStatus::COMPLETED, $object->get_status() );
$this->assertSame( '12345', $object->get_transaction_id() );
$this->assertNotNull( $object->get_date_paid( 'edit' ) );
@@ -1198,6 +1202,8 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
$this->assertCount( $notes, wc_get_order_notes( array( 'order_id' => $object->get_id() ) ) );
$object->save();
+ $this->assertSame( 1, did_action( 'woocommerce_pre_payment_complete' ) - $pre );
+ $this->assertFalse( WC()->session->get( 'order_awaiting_payment' ) );
$this->assertSame( OrderStatus::COMPLETED, wc_get_order( $object->get_id() )->get_status() );
$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $fired );
$this->assertCount( $notes + 1, wc_get_order_notes( array( 'order_id' => $object->get_id() ) ) );
@@ -1206,6 +1212,57 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $fired );
}
+ /**
+ * Test: stage_payment_complete leaves nothing to the next save when the order status is not one that payment can complete.
+ */
+ public function test_stage_payment_complete_leaves_nothing_to_save_for_a_status_payment_cannot_complete() {
+ $object = new WC_Order();
+ $object->set_status( OrderStatus::COMPLETED );
+ $object->save();
+ $pre = did_action( 'woocommerce_pre_payment_complete' );
+ WC()->session->set( 'order_awaiting_payment', $object->get_id() );
+
+ $this->assertFalse( $object->stage_payment_complete( '12345' ) );
+ $object->save();
+
+ $this->assertSame( 0, did_action( 'woocommerce_pre_payment_complete' ) - $pre );
+ $this->assertSame( $object->get_id(), WC()->session->get( 'order_awaiting_payment' ) );
+ }
+
+ /**
+ * Test: a second stage_payment_complete that the status refuses keeps the pre-payment hook that the first one left to the save.
+ */
+ public function test_refused_second_stage_payment_complete_keeps_the_staged_pre_payment_hook() {
+ $object = new WC_Order();
+ $object->save();
+ $pre = did_action( 'woocommerce_pre_payment_complete' );
+ $fired = did_action( 'woocommerce_payment_complete' );
+ WC()->session->set( 'order_awaiting_payment', $object->get_id() );
+
+ $this->assertTrue( $object->stage_payment_complete( '12345' ) );
+ $this->assertFalse( $object->stage_payment_complete( '67890' ) );
+ $object->save();
+
+ $this->assertSame( 1, did_action( 'woocommerce_pre_payment_complete' ) - $pre );
+ $this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $fired );
+ $this->assertFalse( WC()->session->get( 'order_awaiting_payment' ) );
+ }
+
+ /**
+ * Test: payment_complete after stage_payment_complete replaces the staged pre-payment hook, so the hook fires once.
+ */
+ public function test_payment_complete_after_stage_payment_complete_fires_the_pre_payment_hook_once() {
+ $object = new WC_Order();
+ $object->save();
+ $pre = did_action( 'woocommerce_pre_payment_complete' );
+
+ $this->assertTrue( $object->stage_payment_complete( '12345' ) );
+ $object->payment_complete( '67890' );
+ $object->save();
+
+ $this->assertSame( 1, did_action( 'woocommerce_pre_payment_complete' ) - $pre );
+ }
+
/**
* Test: stage_payment_complete does not change an order that is not saved yet.
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
index 72e7af8aa47..8f6bd653779 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -939,4 +939,118 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
$webhook->save();
$this->assertSame( 0, wc_get_webhook( $webhook->get_id() )->get_failure_count() );
}
+
+ /**
+ * @testdox stage_delivery_result() leaves the disabled action to the next save, which fires it after the write.
+ */
+ public function test_stage_delivery_result_fires_the_disabled_action_after_the_write() {
+ $webhook = new WC_Webhook();
+ $webhook->set_status( 'active' );
+ $webhook->set_topic( 'order.created' );
+ $webhook->set_delivery_url( 'https://example.com/webhook' );
+ $webhook->set_user_id( 1 );
+ $webhook->set_failure_count( 6 );
+ $webhook->save();
+
+ $statuses = array();
+ $listener = static function ( $webhook_id ) use ( &$statuses ) {
+ $statuses[] = wc_get_webhook( $webhook_id )->get_status();
+ };
+ add_action( 'woocommerce_webhook_disabled_due_delivery_failures', $listener );
+
+ $webhook->stage_delivery_result( 500 );
+ $this->assertSame( 'disabled', $webhook->get_status() );
+ $this->assertSame( array(), $statuses );
+
+ $webhook->save();
+ $webhook->save();
+
+ remove_action( 'woocommerce_webhook_disabled_due_delivery_failures', $listener );
+ $this->assertSame( array( 'disabled' ), $statuses );
+ }
+
+ /**
+ * @testdox log_delivery() after a staged disable fires the disabled action once.
+ */
+ public function test_log_delivery_after_a_staged_disable_fires_the_disabled_action_once() {
+ $webhook = $this->create_failing_webhook();
+ $fired = did_action( 'woocommerce_webhook_disabled_due_delivery_failures' );
+
+ $webhook->stage_delivery_result( 500 );
+ $webhook->log_delivery( 1, $this->delivery_request(), new WP_Error( 'http_request_failed', 'Failed' ), 0.1 );
+ $webhook->save();
+
+ $this->assertSame( 1, did_action( 'woocommerce_webhook_disabled_due_delivery_failures' ) - $fired );
+ }
+
+ /**
+ * @testdox save() does not fire the disabled action when a status change replaced the staged disable.
+ */
+ public function test_save_skips_the_disabled_action_when_the_webhook_is_active_again() {
+ $webhook = $this->create_failing_webhook();
+ $fired = did_action( 'woocommerce_webhook_disabled_due_delivery_failures' );
+
+ $webhook->stage_delivery_result( 500 );
+ $webhook->set_status( 'active' );
+ $webhook->save();
+
+ $this->assertSame( 0, did_action( 'woocommerce_webhook_disabled_due_delivery_failures' ) - $fired );
+ }
+
+ /**
+ * Create a saved, active webhook that the next failed delivery disables.
+ *
+ * @return WC_Webhook
+ */
+ private function create_failing_webhook(): WC_Webhook {
+ $webhook = new WC_Webhook();
+ $webhook->set_status( 'active' );
+ $webhook->set_topic( 'order.created' );
+ $webhook->set_delivery_url( 'https://example.com/webhook' );
+ $webhook->set_user_id( 1 );
+ $webhook->set_failure_count( 6 );
+ $webhook->save();
+
+ return $webhook;
+ }
+
+ /**
+ * Build the request array that log_delivery() reads.
+ *
+ * @return array
+ */
+ private function delivery_request(): array {
+ return array(
+ 'method' => 'POST',
+ 'user-agent' => 'WooCommerce',
+ 'headers' => array(),
+ 'body' => '',
+ );
+ }
+
+ /**
+ * @testdox log_delivery() fires the disabled action right away, before it saves.
+ */
+ public function test_log_delivery_fires_the_disabled_action_once() {
+ $webhook = new WC_Webhook();
+ $webhook->set_status( 'active' );
+ $webhook->set_topic( 'order.created' );
+ $webhook->set_delivery_url( 'https://example.com/webhook' );
+ $webhook->set_user_id( 1 );
+ $webhook->set_failure_count( 6 );
+ $webhook->save();
+ $fired = did_action( 'woocommerce_webhook_disabled_due_delivery_failures' );
+
+ $request = array(
+ 'method' => 'POST',
+ 'user-agent' => 'WooCommerce',
+ 'headers' => array(),
+ 'body' => '',
+ );
+ $webhook->log_delivery( 1, $request, new WP_Error( 'http_request_failed', 'Failed' ), 0.1 );
+ $webhook->save();
+
+ $this->assertSame( 1, did_action( 'woocommerce_webhook_disabled_due_delivery_failures' ) - $fired );
+ $this->assertSame( 'disabled', wc_get_webhook( $webhook->get_id() )->get_status() );
+ }
}