Commit 1f1533b5a34 for woocommerce

commit 1f1533b5a34742413ff0eb3bb97e93e3680f87a6
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Wed Nov 4 14:06:06 2026 -0300

    Stop reporting a payment when the order fails to save (#70606)

    * Stop payment_complete() and update_taxes() after a failed order write

    payment_complete() now returns false and holds woocommerce_payment_complete
    for the next save that writes the order. update_taxes() leaves the tax items
    to the order save, so a failed write does not change them.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Add changelog entry for stopping order hooks after a failed write

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Add the payment note only after a successful order write

    Move the payment note after the write, so a failed write leaves no
    "Payment complete" note on an unpaid order. When the write fails, the
    note and woocommerce_payment_complete wait for the next save, and they
    run only if the order still has the paid status.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Drop a failed payment_complete() hook when a retry reports the payment

    A failed write left the payment note and hook queued. A retry then ran
    them on its own save and again for itself, so both ran twice.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Clear a staged payment note before payment_complete() runs again

    A failed save keeps the staged note queued. A retry cleared only the
    staged action, so the old note was added next to the new one.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Fire woocommerce_payment_complete when the payment note throws

    The order is already saved as paid when the note runs, so a failing note
    listener must not stop the payment action. Log the note error instead.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Explain why stage_tax_items() no longer saves each tax item

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Log a failed payment note once and without a second note

    handle_exception() adds an order note, so a listener that throws on every
    note threw from the catch too. save() already logs and notes the failed
    write, so payment_complete() no longer reports it a second time.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Fire the deferred payment action for any paid status and catch its listeners

    A retry that moved the order to another paid status still reports the
    payment. A listener that throws on the retry is logged with the usual
    error note instead of escaping save() and dropping the other callbacks.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Check that a staged note survives a throwing payment listener on the retry

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooai-70552-stop-hooks-after-failed-save b/plugins/woocommerce/changelog/wooai-70552-stop-hooks-after-failed-save
new file mode 100644
index 00000000000..2fbafb07c1c
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-70552-stop-hooks-after-failed-save
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop reporting an order as paid, or changing its tax lines, when the order fails to save.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 72e0c38a298..2f81a10bb05 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -3041,8 +3041,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {

 	/**
 	 * Update the tax items of the order from the item taxes in memory, like update_taxes(), but do not save.
-	 * The next save() writes the changed tax items before it writes the order. update_taxes() runs this method
-	 * through write_through(), so it saves at the same points as it always did.
+	 * The next save() writes the changed tax items with the other items of the order, so a failed order write
+	 * leaves them unchanged. update_taxes() runs this method through write_through(), so it saves right away.
 	 *
 	 * @since 11.4.0
 	 * @return void
@@ -3053,7 +3053,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		$cart_taxes     = array();
 		$shipping_taxes = array();
 		$existing_taxes = $this->get_taxes();
-		$saved_rate_ids = array();
+		$kept_rate_ids  = array();

 		foreach ( $this->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) {
 			$taxes = $item->get_taxes();
@@ -3082,7 +3082,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		foreach ( $existing_taxes as $tax ) {
 			$tax_rate_id = $tax->get_rate_id();
 			// Remove taxes which no longer exist for cart/shipping.
-			if ( ( ! array_key_exists( $tax_rate_id, $cart_taxes ) && ! array_key_exists( $tax_rate_id, $shipping_taxes ) ) || in_array( $tax_rate_id, $saved_rate_ids, true ) ) {
+			if ( ( ! array_key_exists( $tax_rate_id, $cart_taxes ) && ! array_key_exists( $tax_rate_id, $shipping_taxes ) ) || in_array( $tax_rate_id, $kept_rate_ids, true ) ) {
 				$this->remove_item( $this->get_stored_item_key( $tax ) );
 				continue;
 			}
@@ -3097,11 +3097,11 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			$tax->set_tax_total( $cart_taxes[ $tax_rate_id ] ?? 0 );
 			$tax->set_shipping_tax_total( ! empty( $shipping_taxes[ $tax_rate_id ] ) ? $shipping_taxes[ $tax_rate_id ] : 0 );

-			$saved_rate_ids[] = $tax_rate_id;
-			$this->write_or_defer( 'save_tax_item_' . spl_object_id( $tax ), array( $tax, 'save' ) );
+			// Do not save the tax item here. save_items() writes it with the order, so a failed order write leaves it unchanged.
+			$kept_rate_ids[] = $tax_rate_id;
 		}

-		$new_rate_ids = wp_parse_id_list( array_diff( array_keys( $cart_taxes + $shipping_taxes ), $saved_rate_ids ) );
+		$new_rate_ids = wp_parse_id_list( array_diff( array_keys( $cart_taxes + $shipping_taxes ), $kept_rate_ids ) );

 		// New taxes.
 		foreach ( $new_rate_ids as $tax_rate_id ) {
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index f1ab3033dae..268c22fcb5b 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -141,7 +141,8 @@ class WC_Order extends WC_Abstract_Order {
 	 *
 	 * @uses self::stage_payment_complete()
 	 * @param string $transaction_id Optional transaction id to store in post meta.
-	 * @return bool success
+	 * @return bool False when the order is not saved yet, the order write fails or a listener throws. True otherwise,
+	 *              also when the order status does not let the payment complete.
 	 */
 	public function payment_complete( $transaction_id = '' ) {
 		if ( ! $this->get_id() ) { // Order must exist.
@@ -155,6 +156,9 @@ class WC_Order extends WC_Abstract_Order {
 				}
 			);
 		} catch ( Exception $e ) {
+			if ( $e === $this->get_last_save_error() ) {
+				return false;
+			}
 			/**
 			 * If there was an error completing the payment, log to a file and add an order note so the admin can take action.
 			 */
@@ -178,9 +182,10 @@ 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 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.
+	 * payment note and woocommerce_payment_complete to the next save(), which runs the first two before it
+	 * writes and adds the note 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. When that save fails, the method throws, and
+	 * the note and the action wait for the next save that writes the order with the same status or a paid one.
 	 *
 	 * 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, the method adds nothing for
@@ -194,6 +199,7 @@ class WC_Order extends WC_Abstract_Order {
 	 * @since 11.4.0
 	 * @param string $transaction_id Optional transaction ID to store.
 	 * @return bool True when the order has changes for save() to write.
+	 * @throws Exception When payment_complete() runs this method and the order write fails.
 	 */
 	public function stage_payment_complete( $transaction_id = '' ) {
 		$this->refuse_unstaged_override( 'payment_complete', 'stage_payment_complete' );
@@ -245,6 +251,9 @@ class WC_Order extends WC_Abstract_Order {
 			$this->defer_to_save( 'pre_payment_complete', $pre_payment_complete, array( $this->get_id(), $transaction_id ) );
 		}

+		$this->defer_to_save( 'payment_complete', null );
+		$this->defer_to_save( 'payment_complete_note', null );
+
 		if ( ! empty( $transaction_id ) ) {
 			$this->set_transaction_id( $transaction_id );
 		}
@@ -270,9 +279,52 @@ class WC_Order extends WC_Abstract_Order {

 		$this->set_status( $next_status, false );

-		$this->write_or_defer( 'payment_complete_note', array( $this, 'add_order_note' ), array( $payment_complete_note, false, false, array( 'note_group' => OrderNoteGroup::PAYMENT ) ) );
 		$this->save_if_writing_through();
-		$this->write_or_defer( 'payment_complete', 'do_action', array( 'woocommerce_payment_complete', $this->get_id(), $transaction_id ), true );
+
+		$add_note    = function () use ( $payment_complete_note ) {
+			try {
+				$this->add_order_note( $payment_complete_note, 0, false, array( 'note_group' => OrderNoteGroup::PAYMENT ) );
+			} catch ( Exception $e ) {
+				wc_get_logger()->error(
+					sprintf( 'Error adding the payment note for order #%d', $this->get_id() ),
+					array(
+						'order' => $this,
+						'error' => $e,
+					)
+				);
+			}
+		};
+		$action_args = array( 'woocommerce_payment_complete', $this->get_id(), $transaction_id );
+		$save_error  = $this->writing_through ? $this->get_last_save_error() : null;
+		if ( null !== $save_error ) {
+			$paid_status = $this->get_status();
+			$this->defer_to_save(
+				'payment_complete',
+				function () use ( $paid_status, $add_note, $action_args ) {
+					if ( ! $this->has_status( $paid_status ) && ! $this->is_paid() ) {
+						return;
+					}
+					$add_note();
+					try {
+						call_user_func_array( 'do_action', $action_args );
+					} catch ( Exception $e ) {
+						wc_get_logger()->error(
+							sprintf( 'Error completing payment for order #%d', $this->get_id() ),
+							array(
+								'order' => $this,
+								'error' => $e,
+							)
+						);
+						$this->add_order_note( __( 'Payment complete event failed.', 'woocommerce' ) . ' ' . $e->getMessage(), 0, false, array( 'note_group' => OrderNoteGroup::ERROR ) );
+					}
+				},
+				array(),
+				true
+			);
+			throw $save_error;
+		}
+		$this->write_or_defer( 'payment_complete_note', $add_note, array(), true );
+		$this->write_or_defer( 'payment_complete', 'do_action', $action_args, true );

 		return true;
 	}
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 1d21f494d38..9b99c8b4aba 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
@@ -1363,15 +1363,11 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
 	 * @param WC_Order $order Order to save.
 	 */
 	private function save_with_failed_write( WC_Order $order ): void {
-		$fail = static function () {
-			throw new Exception( 'Disk full.' );
-		};
-		add_action( 'woocommerce_before_order_object_save', $fail );
-		try {
-			$order->save();
-		} finally {
-			remove_action( 'woocommerce_before_order_object_save', $fail );
-		}
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				$order->save();
+			}
+		);
 		$this->assertInstanceOf( Exception::class, $order->get_last_save_error() );
 	}

@@ -1449,6 +1445,294 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
 		$this->assertSame( 1, did_action( 'woocommerce_order_status_pending_to_processing' ) - $transition );
 	}

+	/**
+	 * Run a callback while a listener makes every order write fail.
+	 *
+	 * @param callable $callback Callback to run.
+	 * @return mixed What the callback returns.
+	 */
+	private function with_failed_writes( callable $callback ) {
+		$fail = static function () {
+			throw new Exception( 'Disk full.' );
+		};
+		add_action( 'woocommerce_before_order_object_save', $fail );
+		try {
+			return $callback();
+		} finally {
+			remove_action( 'woocommerce_before_order_object_save', $fail );
+		}
+	}
+
+	/**
+	 * @testdox payment_complete() returns false and fires no payment or status hook when the order write fails.
+	 */
+	public function test_payment_complete_stops_when_the_write_fails(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid    = did_action( 'woocommerce_payment_complete' );
+		$changed = did_action( 'woocommerce_order_status_changed' );
+
+		$result = $this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+
+		$this->assertFalse( $result );
+		$this->assertSame( 0, did_action( 'woocommerce_payment_complete' ) - $paid );
+		$this->assertSame( 0, did_action( 'woocommerce_order_status_changed' ) - $changed );
+		$stored = wc_get_order( $order->get_id() );
+		$this->assertSame( OrderStatus::PENDING, $stored->get_status() );
+		$this->assertNull( $stored->get_date_paid() );
+	}
+
+	/**
+	 * @testdox A failed payment_complete() write adds one error note and no payment note.
+	 */
+	public function test_payment_complete_adds_one_error_note_when_the_write_fails(): void {
+		$order = new WC_Order();
+		$order->save();
+
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+
+		$notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+		$this->assertCount( 1, $notes );
+		$this->assertStringContainsString( 'Disk full.', $notes[0] );
+	}
+
+	/**
+	 * @testdox The retry after a failed payment_complete() fires woocommerce_payment_complete when the note listener throws.
+	 */
+	public function test_payment_complete_retry_fires_its_action_when_the_note_listener_throws(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid = did_action( 'woocommerce_payment_complete' );
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+		$throw = static function () {
+			throw new Exception( 'Note listener failed.' );
+		};
+		add_action( 'woocommerce_order_note_added', $throw );
+
+		try {
+			$order->save();
+		} finally {
+			remove_action( 'woocommerce_order_note_added', $throw );
+		}
+
+		$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $paid );
+	}
+
+	/**
+	 * @testdox The retry after a failed payment_complete() fires its action when the status moved to another paid status.
+	 */
+	public function test_payment_complete_retry_fires_its_action_after_a_move_to_another_paid_status(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid = did_action( 'woocommerce_payment_complete' );
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+		$this->assertSame( OrderStatus::COMPLETED, $order->get_status() );
+
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $paid );
+	}
+
+	/**
+	 * @testdox The retry after a failed payment_complete() logs a throwing payment listener and keeps the other callbacks.
+	 */
+	public function test_payment_complete_retry_logs_a_throwing_payment_listener(): void {
+		$order = new WC_Order();
+		$order->save();
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+		$order->stage_order_note( 'Staged after the failed call.' );
+		$throw = static function () {
+			throw new Exception( 'Payment listener failed.' );
+		};
+		add_action( 'woocommerce_payment_complete', $throw );
+
+		try {
+			$order->save();
+		} finally {
+			remove_action( 'woocommerce_payment_complete', $throw );
+		}
+
+		$notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+		$this->assertContains( 'Payment complete event failed. Payment listener failed.', $notes );
+		$this->assertContains( 'Staged after the failed call.', $notes );
+		$this->assertSame( OrderStatus::COMPLETED, wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox payment_complete() still fires woocommerce_payment_complete when adding the payment note throws.
+	 */
+	public function test_payment_complete_fires_its_action_when_the_payment_note_throws(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid  = did_action( 'woocommerce_payment_complete' );
+		$throw = static function ( $comment_id ) {
+			if ( 'Payment complete.' === get_comment( $comment_id )->comment_content ) {
+				throw new Exception( 'Note listener failed.' );
+			}
+		};
+		add_action( 'woocommerce_order_note_added', $throw );
+
+		$result = $order->payment_complete( '12345' );
+
+		remove_action( 'woocommerce_order_note_added', $throw );
+		$this->assertTrue( $result );
+		$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $paid );
+		$this->assertSame( OrderStatus::COMPLETED, wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox woocommerce_payment_complete fires once, when a save after a failed payment_complete() succeeds.
+	 */
+	public function test_payment_complete_fires_its_hook_after_a_failed_write_is_retried(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid = did_action( 'woocommerce_payment_complete' );
+
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+		$order->save();
+		$order->save();
+
+		$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $paid );
+		$this->assertSame( OrderStatus::COMPLETED, wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox A payment_complete() retry after a failed write fires its payment hook and note once.
+	 */
+	public function test_payment_complete_retry_after_a_failed_write_reports_the_payment_once(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid = did_action( 'woocommerce_payment_complete' );
+
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+		$order->set_status( OrderStatus::PENDING );
+		$this->assertTrue( $order->payment_complete( '12345' ) );
+
+		$this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $paid );
+		$notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+		$this->assertCount( 1, array_keys( $notes, 'Payment complete.', true ) );
+	}
+
+	/**
+	 * @testdox A payment_complete() retry after a failed staged payment adds only the note of the retry.
+	 */
+	public function test_payment_complete_retry_after_a_failed_staged_payment_adds_one_note(): void {
+		$order = new WC_Order();
+		$order->set_payment_method_title( 'Cheque' );
+		$order->save();
+
+		$order->stage_payment_complete( 'first' );
+		$this->with_failed_writes( array( $order, 'save' ) );
+		$order->set_status( OrderStatus::PENDING );
+		$this->assertTrue( $order->payment_complete( 'second' ) );
+
+		$notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+		$this->assertNotContains( 'Payment via Cheque (first).', $notes );
+		$this->assertCount( 1, array_keys( $notes, 'Payment via Cheque (second).', true ) );
+	}
+
+	/**
+	 * @testdox payment_complete() adds no payment note when the order write fails.
+	 */
+	public function test_payment_complete_adds_no_payment_note_when_the_write_fails(): void {
+		$order = new WC_Order();
+		$order->save();
+
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+
+		$notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+		$this->assertNotContains( 'Payment complete.', $notes );
+	}
+
+	/**
+	 * @testdox A save after a failed payment_complete() fires no payment hook when the status changed in between.
+	 */
+	public function test_payment_complete_drops_its_hook_when_the_status_changes_after_a_failed_write(): void {
+		$order = new WC_Order();
+		$order->save();
+		$paid = did_action( 'woocommerce_payment_complete' );
+
+		$this->with_failed_writes(
+			static function () use ( $order ) {
+				return $order->payment_complete( '12345' );
+			}
+		);
+		$order->set_status( OrderStatus::FAILED );
+		$order->save();
+
+		$this->assertSame( 0, did_action( 'woocommerce_payment_complete' ) - $paid );
+		$this->assertSame( OrderStatus::FAILED, wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox update_taxes() does not write the tax items when the order write fails.
+	 */
+	public function test_update_taxes_does_not_write_tax_items_when_the_write_fails(): void {
+		$rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country' => '',
+				'tax_rate'         => '10.0000',
+				'tax_rate_name'    => 'TAX',
+				'tax_rate_order'   => '1',
+			)
+		);
+		$order   = new WC_Order();
+		$product = new WC_Order_Item_Product();
+		$product->set_taxes(
+			array(
+				'total'    => array( $rate_id => '1' ),
+				'subtotal' => array( $rate_id => '1' ),
+			)
+		);
+		$order->add_item( $product );
+		$order->update_taxes();
+		$tax_item = current( $order->get_taxes() );
+		$this->assertSame( 1.0, (float) $tax_item->get_tax_total() );
+
+		$product->set_taxes(
+			array(
+				'total'    => array( $rate_id => '2' ),
+				'subtotal' => array( $rate_id => '2' ),
+			)
+		);
+		$this->with_failed_writes( array( $order, 'update_taxes' ) );
+
+		$this->assertSame( 1.0, (float) ( new WC_Order_Item_Tax( $tax_item->get_id() ) )->get_tax_total() );
+	}
+
 	/**
 	 * Test: a staged note is not added when the order write fails.
 	 */