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