Commit fb0a33875c2 for woocommerce
commit fb0a33875c242b8ca01452381c06829438afcbec
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Oct 23 13:12:27 2026 +0100
Reuse zero-total pending and failed orders in Store API checkout (#70132)
* Fix Store API checkout creating a new order for zero-total orders
A pending or failed order with a zero total, such as a fully discounted
subscription renewal, was never resumed from the session because
needs_payment() is false whenever the total is zero. Checkout then placed
a new order without the original order's data.
Also accept zero-total orders whose status is in the payable list.
Orders with a positive total still go through needs_payment().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add changelog entry for zero-total order reuse at checkout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Validate payable statuses filter result and cover renewal metadata in zero-total reuse test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/50328-reuse-zero-total-draft-orders b/plugins/woocommerce/changelog/50328-reuse-zero-total-draft-orders
new file mode 100644
index 00000000000..848bd23cff5
--- /dev/null
+++ b/plugins/woocommerce/changelog/50328-reuse-zero-total-draft-orders
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Block checkout now completes an existing zero-total pending or failed order instead of creating a new one.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 573b38260f2..6e576978659 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -2073,6 +2073,7 @@ The same filter WC_Order::needs_payment() applies. A status a site declares paya
### Source
- [StoreApi/Utilities/CheckoutTrait.php](../../../../../../src/StoreApi/Utilities/CheckoutTrait.php)
+- [StoreApi/Utilities/DraftOrderTrait.php](../../../../../../src/StoreApi/Utilities/DraftOrderTrait.php)
---
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php
index dedc60ae418..c9adc4fc440 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php
@@ -1,6 +1,8 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
+use Automattic\WooCommerce\Enums\OrderStatus;
+
/**
* DraftOrderTrait
*
@@ -65,6 +67,37 @@ trait DraftOrderTrait {
return true;
}
+ // needs_payment() is always false for a zero total, so zero-total orders are checked separately.
+ if ( $this->is_zero_total_order_awaiting_payment( $order_object ) && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
+ return true;
+ }
+
return false;
}
+
+ /**
+ * Whether a zero-total order is still awaiting payment, such as a fully discounted renewal order.
+ *
+ * @param \WC_Order $order_object Order object to check.
+ * @return bool
+ */
+ private function is_zero_total_order_awaiting_payment( \WC_Order $order_object ): bool {
+ if ( $order_object->get_total() > 0 ) {
+ return false;
+ }
+
+ /**
+ * Filter the valid order statuses for payment.
+ *
+ * The same filter WC_Order::needs_payment() applies, so a zero-total order in a status the site declares payable can be resumed.
+ *
+ * @since 11.4.0
+ *
+ * @param array $valid_order_statuses Array of valid order statuses for payment.
+ * @param \WC_Order $order Order object.
+ */
+ $payable_statuses = array_filter( (array) apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( OrderStatus::PENDING, OrderStatus::FAILED ), $order_object ) );
+
+ return $order_object->has_status( $payable_statuses );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 7a290f5079b..5c669586f35 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
use Automattic\WooCommerce\StoreApi\Routes\V1\Checkout as CheckoutRoute;
use Automattic\WooCommerce\StoreApi\Routes\V1\CheckoutOrder as CheckoutOrderRoute;
use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\Enums\OrderStatus;
@@ -3373,6 +3374,95 @@ class Checkout extends \WP_Test_REST_TestCase {
$this->assertEmpty( WC()->session->get( 'store_api_draft_order' ), 'Successful checkout should clear the draft order pointer when the cart is emptied.' );
}
+ /**
+ * @testdox Checkout places a zero-total pending or failed order from the session instead of creating a new one.
+ * @testWith ["pending"]
+ * ["failed"]
+ *
+ * @param string $status Status of the order in the session.
+ */
+ public function test_post_reuses_zero_total_order_from_session( string $status ): void {
+ $this->apply_full_discount_coupon();
+ $order = $this->create_session_order_from_cart( $status );
+ $order->update_meta_data( '_subscription_renewal', 123 );
+ $order->save();
+ $this->assertSame( 0.0, (float) $order->get_total(), 'The coupon must take the order to a zero total, which is the case this test covers.' );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertSame( $order->get_id(), (int) $response->get_data()['order_id'], 'Checkout must place the zero-total order from the session, not a new one without its data.' );
+
+ $placed_order = wc_get_order( $order->get_id() );
+ $this->assertTrue( $placed_order->is_paid(), 'The zero-total order from the session should be completed by checkout.' );
+ $this->assertSame( '123', (string) $placed_order->get_meta( '_subscription_renewal' ), 'Renewal metadata on the order from the session should be kept.' );
+ }
+
+ /**
+ * @testdox Checkout does not reuse a zero-total order from the session once it has moved past awaiting payment.
+ */
+ public function test_post_does_not_reuse_completed_zero_total_order_from_session(): void {
+ $this->apply_full_discount_coupon();
+ $order = $this->create_session_order_from_cart( OrderStatus::COMPLETED );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertNotSame( $order->get_id(), (int) $response->get_data()['order_id'], 'A completed order must not be placed again.' );
+ $this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::COMPLETED ), 'The completed order should be left as it was.' );
+ }
+
+ /**
+ * @testdox Checkout does not reuse a positive-total order from the session when woocommerce_order_needs_payment says it needs no payment.
+ */
+ public function test_post_respects_needs_payment_filter_for_positive_total_order_from_session(): void {
+ $order = $this->create_session_order_from_cart( OrderStatus::PENDING );
+ add_filter(
+ 'woocommerce_order_needs_payment',
+ function ( $needs_payment, $filtered_order ) use ( $order ) {
+ return $filtered_order->get_id() === $order->get_id() ? false : $needs_payment;
+ },
+ 10,
+ 2
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertNotSame( $order->get_id(), (int) $response->get_data()['order_id'], 'An order the filter says needs no payment must not be reused.' );
+ }
+
+ /**
+ * Apply a 100% coupon to the cart, taking its total to zero.
+ */
+ private function apply_full_discount_coupon(): void {
+ $coupon = new \WC_Coupon();
+ $coupon->set_code( 'full_discount_coupon' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 100 );
+ $coupon->save();
+
+ WC()->cart->apply_coupon( 'full_discount_coupon' );
+ }
+
+ /**
+ * Create an order from the cart and point the session at it, as Subscriptions does with a renewal order.
+ *
+ * @param string $status Order status.
+ * @return \WC_Order
+ */
+ private function create_session_order_from_cart( string $status ): \WC_Order {
+ WC()->cart->calculate_totals();
+
+ $order = ( new OrderController() )->create_order_from_cart();
+ $order->set_status( $status );
+ $order->save();
+
+ WC()->session->set( 'store_api_draft_order', $order->get_id() );
+
+ return $order;
+ }
+
/**
* @testdox Store API checkout stores the order awaiting payment before invoking the payment gateway.
*/