Commit f3b4a4ee845 for woocommerce

commit f3b4a4ee8453d3ae69c43d71ab8ef1b24ca28139
Author: Neil Carlo Sucuangco <necafasu@gmail.com>
Date:   Fri Apr 17 05:23:59 2026 +0800

    Add checkout validation to payment_complete() to prevent unauthorized… (#62843)

    * Add checkout validation to payment_complete() to prevent unauthorized order completion

    * improve messages, add created_via filter, fix version tags

    * adjustments

    * Fix review issues: version tags, docblocks, test cleanup

    - Update since tags from 10.6.0 to 10.8.0 across hooks and tests
    - Fix param docblocks to use $order instead of $this
    - Add docblock for woocommerce_payment_complete_blocked action
    - Add cart_hash to OrderHelper test factory to avoid latent failures
    - Add cross-reference comment between cancel_unpaid and payment_complete allowlists
    - Replace remove_all_filters/actions with specific callback removal in tests
    - Clarify cart_hash_message always-empty invariant with inline comment

    * chore: add changelog entry for checkout evidence validation

    * fix: lint errors in test closures

    * fix: remaining lint issues in checkout evidence validation

    * fix: align callback variable with surrounding assignments

    * fix: update PHPStan baseline for new add_order_note call

    * fix: pass int 0 instead of false for is_customer_note param

    ---------

    Co-authored-by: Brandon Kraft <public@brandonkraft.com>

diff --git a/plugins/woocommerce/changelog/fix-62761-payment-complete-checkout-validation b/plugins/woocommerce/changelog/fix-62761-payment-complete-checkout-validation
new file mode 100644
index 00000000000..13385bfeaf9
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-62761-payment-complete-checkout-validation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Add checkout evidence validation to prevent unintended order completion for orders without checkout context
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index 4a011e4c26e..68201403a46 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -132,7 +132,7 @@ class WC_Order extends WC_Abstract_Order {
 	 * Finally, record the date of payment.
 	 *
 	 * @param string $transaction_id Optional transaction id to store in post meta.
-	 * @return bool success
+	 * @return bool True on success, false if order missing or blocked for lack of checkout evidence.
 	 */
 	public function payment_complete( $transaction_id = '' ) {
 		if ( ! $this->get_id() ) { // Order must exist.
@@ -140,6 +140,84 @@ class WC_Order extends WC_Abstract_Order {
 		}

 		try {
+			// Validate before pre_payment_complete so blocked orders do not trigger side effects.
+			$created_via           = $this->get_created_via();
+			$cart_hash             = $this->get_cart_hash();
+			$has_checkout_evidence = false;
+
+			/**
+			 * Allowed created_via values for checkout evidence.
+			 *
+			 * @param array    $allowed_created_via_values Allowed created_via values.
+			 * @param WC_Order $order                     Order object.
+			 * @since 10.8.0
+			 */
+			$allowed_created_via_values = apply_filters( 'woocommerce_payment_complete_allowed_created_via_values', array( 'checkout', 'store-api', 'rest-api', 'admin', 'pos-rest-api' ), $this );
+
+			if ( ! empty( $created_via ) && in_array( $created_via, $allowed_created_via_values, true ) ) {
+				$has_checkout_evidence = true;
+			}
+
+			if ( ! empty( $cart_hash ) ) {
+				$has_checkout_evidence = true;
+			}
+
+			if ( ! $has_checkout_evidence ) {
+				/**
+				 * Allow payment completion without checkout evidence.
+				 *
+				 * @param bool     $allow_payment_complete Whether to allow.
+				 * @param WC_Order $order                  Order object.
+				 * @param string   $transaction_id         Transaction ID.
+				 * @since 10.8.0
+				 */
+				$allow_without_checkout_evidence = apply_filters( 'woocommerce_allow_payment_complete_without_checkout_evidence', false, $this, $transaction_id );
+
+				if ( ! $allow_without_checkout_evidence ) {
+					$logger = wc_get_logger();
+					$logger->error(
+						sprintf(
+							'Payment completion blocked for order #%d: Order lacks checkout session evidence (created_via: %s, cart_hash: empty)',
+							$this->get_id(),
+							$created_via ? $created_via : 'empty'
+						),
+						array(
+							'order_id'       => $this->get_id(),
+							'created_via'    => $created_via,
+							'cart_hash'      => 'empty',
+							'payment_method' => $this->get_payment_method(),
+						)
+					);
+					/* translators: %s: created_via value set on the order */
+					$created_via_message = empty( $created_via ) ? __( 'No created_via reference', 'woocommerce' ) : sprintf( __( 'Unexpected created_via value: %s', 'woocommerce' ), esc_html( $created_via ) );
+					$cart_hash_message   = __( 'No cart_hash', 'woocommerce' ); // Always empty inside this guard.
+
+					$this->add_order_note(
+						sprintf(
+							/* translators: %1$s: created_via message, %2$s: cart_hash message */
+							__( 'Payment completion blocked: Order lacks checkout session evidence (%1$s, %2$s).', 'woocommerce' ),
+							$created_via_message,
+							$cart_hash_message
+						),
+						0,
+						false,
+						array( 'note_group' => OrderNoteGroup::ERROR )
+					);
+
+					/**
+					 * Fires when payment completion is blocked due to missing checkout evidence.
+					 *
+					 * @param int    $order_id    Order ID.
+					 * @param string $created_via The order's created_via value.
+					 * @param string $cart_hash   The order's cart_hash value.
+					 * @since 10.8.0
+					 */
+					do_action( 'woocommerce_payment_complete_blocked', $this->get_id(), $created_via, $cart_hash );
+
+					return false;
+				}
+			}
+
 			do_action( 'woocommerce_pre_payment_complete', $this->get_id(), $transaction_id );

 			if ( WC()->session ) {
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 5ad099b157a..1045250f755 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -1137,6 +1137,8 @@ function wc_cancel_unpaid_orders() {
 			 *                                 Default is true for orders created via 'checkout'
 			 *                                 or 'store-api', false otherwise.
 			 * @param WC_Order $order          The unpaid order object.
+			 *
+			 * @see WC_Order::payment_complete() Checkout evidence allowlist.
 			 */
 			if ( apply_filters( 'woocommerce_cancel_unpaid_order', in_array( $order->get_created_via(), array( 'checkout', 'store-api' ), true ), $order ) ) {
 				$order->update_status( OrderStatus::CANCELLED, __( 'Unpaid order cancelled - time limit reached.', 'woocommerce' ) );
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 463bc7e99bc..7dc54c9ba52 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
@@ -1000,6 +1000,9 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
 		$object = new WC_Order();
 		$this->assertFalse( $object->payment_complete() );
 		$object->save();
+		// Set created_via to indicate legitimate checkout session.
+		$object->set_created_via( 'checkout' );
+		$object->save();
 		$this->assertTrue( $object->payment_complete( '12345' ) );
 		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
 		$this->assertEquals( '12345', $object->get_transaction_id() );
@@ -1014,6 +1017,8 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
 	public function test_payment_complete_error() {
 		$object = new WC_Order();
 		$object->save();
+		$object->set_created_via( 'checkout' );
+		$object->save();

 		add_action( 'woocommerce_payment_complete', array( $this, 'throwAnException' ) );

@@ -1030,6 +1035,221 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
 		remove_action( 'woocommerce_payment_complete', array( $this, 'throwAnException' ) );
 	}

+	/**
+	 * Test: payment_complete blocks orders without checkout evidence
+	 *
+	 * @testdox payment_complete blocks order when no created_via or cart_hash and fires payment_complete_blocked
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_blocks_orders_without_checkout_evidence() {
+		$object = new WC_Order();
+		$object->save();
+		// No created_via or cart_hash. Payment should be blocked.
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$blocked_action_fired = false;
+		$blocked_action_args  = array();
+		$blocked_callback     = function ( $order_id, $created_via, $cart_hash ) use ( &$blocked_action_fired, &$blocked_action_args ) {
+			$blocked_action_fired = true;
+			$blocked_action_args  = array( $order_id, $created_via, $cart_hash );
+		};
+		add_action( 'woocommerce_payment_complete_blocked', $blocked_callback, 10, 3 );
+
+		$this->assertFalse( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::PENDING, $object->get_status() );
+
+		$this->assertTrue( $blocked_action_fired );
+		$this->assertEquals( $object->get_id(), $blocked_action_args[0] );
+		$this->assertEquals( '', $blocked_action_args[1] );
+		$this->assertEquals( '', $blocked_action_args[2] );
+
+		remove_action( 'woocommerce_payment_complete_blocked', $blocked_callback, 10 );
+
+		// Confirm blocked-payment order note exists.
+		$notes        = wc_get_order_notes(
+			array(
+				'order_id' => $object->get_id(),
+			)
+		);
+		$blocked_note = null;
+		foreach ( $notes as $note ) {
+			if ( strpos( $note->content, 'Payment completion blocked' ) !== false ) {
+				$blocked_note = $note;
+				break;
+			}
+		}
+		$this->assertNotNull( $blocked_note );
+	}
+
+	/**
+	 * Test: payment_complete does not fire woocommerce_pre_payment_complete when blocked
+	 *
+	 * @testdox pre_payment_complete does not fire when payment is blocked for lack of checkout evidence
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_does_not_fire_pre_payment_complete_when_blocked() {
+		$object = new WC_Order();
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$pre_payment_complete_fired = false;
+		$callback                   = function () use ( &$pre_payment_complete_fired ) {
+			$pre_payment_complete_fired = true;
+		};
+		add_action( 'woocommerce_pre_payment_complete', $callback, 10, 0 );
+
+		$object->payment_complete( '12345' );
+
+		$this->assertFalse( $pre_payment_complete_fired );
+
+		remove_action( 'woocommerce_pre_payment_complete', $callback, 10 );
+	}
+
+	/**
+	 * Test: payment_complete allows orders with created_via checkout
+	 *
+	 * @testdox payment_complete allows order with created_via checkout
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_orders_with_created_via_checkout() {
+		$object = new WC_Order();
+		$object->set_created_via( 'checkout' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+	}
+
+	/**
+	 * Test: payment_complete allows orders with created_via store-api
+	 *
+	 * @testdox payment_complete allows order with created_via store-api
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_orders_with_created_via_store_api() {
+		$object = new WC_Order();
+		$object->set_created_via( 'store-api' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+	}
+
+	/**
+	 * Test: payment_complete allows orders with created_via rest-api
+	 *
+	 * @testdox payment_complete allows order with created_via rest-api
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_orders_with_created_via_rest_api() {
+		$object = new WC_Order();
+		$object->set_created_via( 'rest-api' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+	}
+
+	/**
+	 * Test: payment_complete allows orders with created_via admin
+	 *
+	 * @testdox payment_complete allows order with created_via admin
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_orders_with_created_via_admin() {
+		$object = new WC_Order();
+		$object->set_created_via( 'admin' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+	}
+
+	/**
+	 * Test: payment_complete allows orders with created_via pos-rest-api
+	 *
+	 * @testdox payment_complete allows order with created_via pos-rest-api
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_orders_with_created_via_pos_rest_api() {
+		$object = new WC_Order();
+		$object->set_created_via( 'pos-rest-api' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+	}
+
+	/**
+	 * Test: payment_complete allows orders with cart_hash
+	 *
+	 * @testdox payment_complete allows order with cart_hash
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_orders_with_cart_hash() {
+		$object = new WC_Order();
+		$object->set_cart_hash( 'test-cart-hash-123' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+	}
+
+	/**
+	 * Test: payment_complete allows bypass via filter
+	 *
+	 * @testdox payment_complete allows bypass via woocommerce_allow_payment_complete_without_checkout_evidence
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_bypass_via_filter() {
+		$object = new WC_Order();
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		// Allow this order via bypass filter.
+		$bypass_callback = function ( $allow, $order ) use ( $object ) {
+			return $order->get_id() === $object->get_id();
+		};
+		add_filter( 'woocommerce_allow_payment_complete_without_checkout_evidence', $bypass_callback, 10, 2 );
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+
+		remove_filter( 'woocommerce_allow_payment_complete_without_checkout_evidence', $bypass_callback, 10 );
+	}
+
+	/**
+	 * Test: payment_complete allows custom created_via values via filter
+	 *
+	 * @testdox payment_complete allows custom created_via via woocommerce_payment_complete_allowed_created_via_values
+	 * @since 10.8.0
+	 */
+	public function test_payment_complete_allows_custom_created_via_via_filter() {
+		$object = new WC_Order();
+		$object->set_created_via( 'custom-integration' );
+		$object->set_status( OrderStatus::PENDING );
+		$object->save();
+
+		// Allow custom created_via via filter.
+		$created_via_callback = function ( $allowed_values ) {
+			$allowed_values[] = 'custom-integration';
+			return $allowed_values;
+		};
+		add_filter( 'woocommerce_payment_complete_allowed_created_via_values', $created_via_callback, 10, 1 );
+
+		$this->assertTrue( $object->payment_complete( '12345' ) );
+		$this->assertEquals( OrderStatus::COMPLETED, $object->get_status() );
+
+		remove_filter( 'woocommerce_payment_complete_allowed_created_via_values', $created_via_callback, 10 );
+	}
+
 	/**
 	 * Test: get_formatted_order_total
 	 */
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php
index a755a2f18e8..82ce45b301a 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Helpers/OrderHelper.php
@@ -305,6 +305,7 @@ class OrderHelper {

 		$order->set_status( OrderStatus::PENDING );
 		$order->set_created_via( 'unit-tests' );
+		$order->set_cart_hash( '1234' );
 		$order->set_currency( 'COP' );
 		$order->set_customer_ip_address( '127.0.0.1' );