Commit b41b9646b6c for woocommerce
commit b41b9646b6c25f41e3063830d32710b68926da1e
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Wed Oct 7 22:32:34 2026 +0100
Prevent checkout from completing unpaid orders without payment (#69265)
* Fix checkout completing unpaid orders without payment
* Add changelog entry for unpaid checkout orders
* Remove resolved checkout PHPStan baseline entries
* Narrow unpaid checkout guard to cancelled orders
* Update changelog for cancelled order checkout fix
* Avoid redirect in checkout compatibility test
diff --git a/plugins/woocommerce/changelog/fix-unpaid-orders-completed-without-payment b/plugins/woocommerce/changelog/fix-unpaid-orders-completed-without-payment
new file mode 100644
index 00000000000..f3d0413cd0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-unpaid-orders-completed-without-payment
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent checkout retries from marking cancelled, unpaid orders as paid.
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index e7a1f17b843..56803eb4851 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -1201,10 +1201,21 @@ class WC_Checkout {
* Process an order that doesn't require payment.
*
* @since 3.0.0
+ * @throws Exception If the order is missing or a cancelled unpaid order would be marked paid.
* @param int $order_id Order ID.
*/
protected function process_order_without_payment( $order_id ) {
$order = wc_get_order( $order_id );
+
+ if ( ! $order instanceof WC_Order ) {
+ throw new Exception( esc_html__( 'Unable to process this order. Please try again.', 'woocommerce' ) );
+ }
+
+ // A cancelled order can reach this path after a stale cache read; never revive it without payment.
+ if ( 0 < $order->get_total() && ! $order->is_paid() && $order->has_status( OrderStatus::CANCELLED ) ) {
+ throw new Exception( esc_html__( 'This order cannot be completed without payment. Please try again.', 'woocommerce' ) );
+ }
+
$order->payment_complete();
wc_empty_cart();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a11a156932d..35a3d0897be 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10272,18 +10272,6 @@ parameters:
count: 1
path: includes/class-wc-checkout.php
- -
- message: '#^Cannot call method get_checkout_order_received_url\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
- identifier: method.nonObject
- count: 2
- path: includes/class-wc-checkout.php
-
- -
- message: '#^Cannot call method payment_complete\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/class-wc-checkout.php
-
-
message: '#^Class WP_Error referenced with incorrect case\: WP_ERROR\.$#'
identifier: class.nameCase
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index bc5400652f8..eddcc62157d 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -66,7 +66,7 @@ trait CheckoutTrait {
* Deliberately no recovery of the kind process_payment() does: nothing was charged, so a
* failure costs the shopper only a retry, and claiming success would be the worse outcome.
*
- * @throws RouteException If the order is missing.
+ * @throws RouteException If the order is missing or a cancelled unpaid order would be marked paid.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
@@ -74,6 +74,15 @@ trait CheckoutTrait {
private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
$order = $this->get_order_or_throw();
+ // A cancelled order can reach this path after a stale cache read; never revive it without payment.
+ if ( 0 < $order->get_total() && ! $order->is_paid() && $order->has_status( OrderStatus::CANCELLED ) ) {
+ throw new RouteException(
+ 'woocommerce_rest_checkout_payment_required',
+ esc_html__( 'This order cannot be completed without payment. Please try again.', 'woocommerce' ),
+ 400
+ );
+ }
+
$order->payment_complete();
// Mark the payment as successful.
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index de2b3f92be1..abc2a0d6387 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Tests\Checkout.
*/
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
/**
@@ -37,6 +38,10 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
public function validate_checkout( &$data, &$errors ) {
return parent::validate_checkout( $data, $errors );
}
+
+ public function process_order_without_payment( $order_id ) {
+ return parent::process_order_without_payment( $order_id );
+ }
};
// phpcs:enable Generic.CodeAnalysis, Squiz.Commenting
@@ -144,6 +149,55 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
$this->assertStringContainsString( 'This text should not save inside an anchor.', $content );
}
+ /**
+ * @testdox Checkout refuses to complete a cancelled unpaid non-zero order without payment.
+ */
+ public function test_process_order_without_payment_rejects_cancelled_unpaid_non_zero_order(): void {
+ $order = wc_create_order();
+ $order->set_total( 10 );
+ $order->set_status( OrderStatus::CANCELLED );
+ $order->save();
+
+ try {
+ $this->sut->process_order_without_payment( $order->get_id() );
+ $this->fail( 'Checkout should not complete a cancelled unpaid non-zero order without payment.' );
+ } catch ( Exception $exception ) {
+ $this->assertSame( 'This order cannot be completed without payment. Please try again.', $exception->getMessage() );
+ }
+
+ $reloaded_order = wc_get_order( $order->get_id() );
+ $this->assertSame( OrderStatus::CANCELLED, $reloaded_order->get_status(), 'The order status should not change.' );
+ $this->assertNull( $reloaded_order->get_date_paid(), 'The order should not be marked paid.' );
+ }
+
+ /**
+ * @testdox Checkout allows extensions to complete a pending non-zero order without payment.
+ */
+ public function test_process_order_without_payment_allows_pending_non_zero_order(): void {
+ $order = wc_create_order();
+ $order->set_total( 10 );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ add_filter(
+ 'woocommerce_checkout_no_payment_needed_redirect',
+ function () {
+ throw new RuntimeException( 'Stop the test before checkout sends its redirect.' );
+ }
+ );
+
+ try {
+ $this->sut->process_order_without_payment( $order->get_id() );
+ $this->fail( 'Checkout should reach its no-payment redirect.' );
+ } catch ( RuntimeException $exception ) {
+ $this->assertSame( 'Stop the test before checkout sends its redirect.', $exception->getMessage() );
+ }
+
+ $reloaded_order = wc_get_order( $order->get_id() );
+ $this->assertTrue( $reloaded_order->is_paid(), 'The intentionally payment-free order should be completed.' );
+ $this->assertNotNull( $reloaded_order->get_date_paid(), 'The intentionally payment-free order should have a paid date.' );
+ }
+
/**
* @testdox the customer notes can have linebreaks.
*/
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 fde487acd5b..1728190fa41 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -2613,6 +2613,48 @@ class Checkout extends \WP_Test_REST_TestCase {
}
}
+ /**
+ * @testdox Checkout refuses to complete a cancelled unpaid non-zero order.
+ */
+ public function test_checkout_rejects_cancelled_unpaid_non_zero_order(): void {
+ $order_id = 0;
+ add_action(
+ 'woocommerce_store_api_checkout_order_processed',
+ function ( \WC_Order $order ) use ( &$order_id ) {
+ $order->set_status( OrderStatus::CANCELLED );
+ $order->save();
+ $order_id = $order->get_id();
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertSame( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertSame( 'woocommerce_rest_checkout_payment_required', $response->get_data()['code'] );
+ $this->assertSame( 'This order cannot be completed without payment. Please try again.', $response->get_data()['message'] );
+ $this->assertGreaterThan( 0, $order_id, 'Checkout should have created an order before refusing to complete it.' );
+
+ $order = wc_get_order( $order_id );
+ $this->assertSame( OrderStatus::CANCELLED, $order->get_status(), 'The order should remain cancelled.' );
+ $this->assertNull( $order->get_date_paid(), 'The order should not be marked paid.' );
+ }
+
+ /**
+ * @testdox Checkout allows extensions to waive payment for a pending non-zero order.
+ */
+ public function test_checkout_allows_waived_payment_for_pending_non_zero_order(): void {
+ add_filter( 'woocommerce_order_needs_payment', '__return_false' );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertSame( 'success', $response->get_data()['payment_result']['payment_status'] );
+
+ $order = wc_get_order( $response->get_data()['order_id'] );
+ $this->assertTrue( $order->is_paid(), 'The intentionally payment-free order should be completed.' );
+ $this->assertNotNull( $order->get_date_paid(), 'The intentionally payment-free order should have a paid date.' );
+ }
+
/**
* Helper method to register custom order status.
*