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' );