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() );
+	}
 }