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.
*/