diff --git a/changelog.txt b/changelog.txt index 95260389e0c..61fc636b34d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,8 @@ *** WooPayments Changelog *** += 10.5.1 - 2026-02-11 = +* Fix - Cache the affected orders check for the canceled auth fee remediation note to avoid an expensive query on every admin page load + = 10.5.0 - 2026-02-05 = * Add - Add "Other" and "Booking/Reservation" product type support for dispute evidence (feature flag gated) * Add - Add ability to specify preferred communications email. diff --git a/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php b/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php index 26eec56f0a3..38fa1c31c2d 100644 --- a/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php +++ b/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php @@ -48,6 +48,22 @@ class WC_Payments_Remediate_Canceled_Auth_Fees { */ const DRY_RUN_ACTION_HOOK = 'wcpay_remediate_canceled_authorization_fees_dry_run'; + /** + * Action Scheduler hook for the async affected orders check. + */ + const CHECK_AFFECTED_ORDERS_HOOK = 'wcpay_check_affected_auth_fee_orders'; + + /** + * Option key for tracking the affected orders check state. + * + * Possible values: + * - false (option doesn't exist): not yet checked. + * - 'scheduled': async check is scheduled or running. + * - 'has_affected_orders': affected orders were found. + * - 'no_affected_orders': no affected orders found. + */ + const CHECK_STATE_OPTION_KEY = 'wcpay_has_affected_auth_fee_orders'; + /** * Option key for tracking dry run mode. */ @@ -98,6 +114,7 @@ public function __construct() { public function init(): void { add_action( self::ACTION_HOOK, [ $this, 'process_batch' ] ); add_action( self::DRY_RUN_ACTION_HOOK, [ $this, 'process_batch_dry_run' ] ); + add_action( self::CHECK_AFFECTED_ORDERS_HOOK, [ $this, 'check_and_cache_affected_orders' ] ); } /** @@ -294,35 +311,33 @@ private function get_affected_orders_hpos( int $limit ): array { $orders_table = $wpdb->prefix . 'wc_orders'; $meta_table = $wpdb->prefix . 'wc_orders_meta'; - // Build the SQL query to find orders with canceled intent status that have either: - // 1. Incorrect fee metadata (_wcpay_transaction_fee or _wcpay_net), OR - // 2. Refund objects (which shouldn't exist for never-captured authorizations), OR - // 3. Incorrect order status of 'wc-refunded' (should be 'wc-cancelled'). - $sql = " - SELECT DISTINCT o.id - FROM {$orders_table} o - INNER JOIN {$meta_table} pm_status ON o.id = pm_status.order_id - LEFT JOIN {$meta_table} pm_fee ON o.id = pm_fee.order_id - AND pm_fee.meta_key IN ('_wcpay_transaction_fee', '_wcpay_net') - LEFT JOIN {$orders_table} refunds ON o.id = refunds.parent_order_id - AND refunds.type = 'shop_order_refund' - WHERE o.type = 'shop_order' - AND o.date_created_gmt >= %s - AND pm_status.meta_key = '_intention_status' - AND pm_status.meta_value = %s - AND (pm_fee.order_id IS NOT NULL OR refunds.id IS NOT NULL OR o.status = 'wc-refunded') - "; - - $params = [ self::BUG_START_DATE, Intent_Status::CANCELED ]; + $sql = "SELECT orders.id + FROM {$orders_table} orders + INNER JOIN {$meta_table} status_meta ON orders.id = status_meta.order_id AND status_meta.meta_key = '_intention_status' AND status_meta.meta_value = %s + LEFT JOIN {$meta_table} fees_meta ON orders.id = fees_meta.order_id AND fees_meta.meta_key = '_wcpay_transaction_fee' + WHERE orders.type = 'shop_order' + AND orders.date_created_gmt >= %s + AND ( + -- Refunded with or without a refund. + orders.status = 'wc-refunded' + + -- Cancelled with fees. + OR ( + orders.status = 'wc-cancelled' + AND fees_meta.order_id IS NOT NULL + ) + )"; + + $params = [ Intent_Status::CANCELED, self::BUG_START_DATE ]; // Add offset based on last order ID. if ( $last_order_id > 0 ) { - $sql .= ' AND o.id > %d'; + $sql .= ' AND orders.id > %d'; $params[] = $last_order_id; } // Add ordering and limit. - $sql .= ' ORDER BY o.id ASC LIMIT %d'; + $sql .= ' ORDER BY orders.id ASC LIMIT %d'; $params[] = $limit; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared @@ -346,31 +361,33 @@ private function get_affected_orders_cpt( int $limit ): array { // 1. Incorrect fee metadata (_wcpay_transaction_fee or _wcpay_net), OR // 2. Refund objects (which shouldn't exist for never-captured authorizations), OR // 3. Incorrect order status of 'wc-refunded' (should be 'wc-cancelled'). - $sql = " - SELECT DISTINCT p.ID - FROM {$wpdb->posts} p - INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id - LEFT JOIN {$wpdb->postmeta} pm_fee ON p.ID = pm_fee.post_id - AND pm_fee.meta_key IN ('_wcpay_transaction_fee', '_wcpay_net') - LEFT JOIN {$wpdb->posts} refunds ON p.ID = refunds.post_parent - AND refunds.post_type = 'shop_order_refund' - WHERE p.post_type IN ('shop_order', 'shop_order_placeholder') - AND p.post_date >= %s - AND pm_status.meta_key = '_intention_status' - AND pm_status.meta_value = %s - AND (pm_fee.post_id IS NOT NULL OR refunds.ID IS NOT NULL OR p.post_status = 'wc-refunded') - "; - - $params = [ self::BUG_START_DATE, Intent_Status::CANCELED ]; + $sql = "SELECT orders.ID + FROM {$wpdb->posts} orders + INNER JOIN {$wpdb->postmeta} status_meta ON orders.ID = status_meta.post_id AND status_meta.meta_key = '_intention_status' AND status_meta.meta_value = %s + LEFT JOIN {$wpdb->postmeta} fees_meta ON orders.ID = fees_meta.post_id AND fees_meta.meta_key = '_wcpay_transaction_fee' + WHERE orders.post_type IN ('shop_order', 'shop_order_placeholder') + AND orders.post_date >= %s + AND ( + -- Refunded with or without a refund. + orders.post_status = 'wc-refunded' + + -- Cancelled with fees + OR ( + orders.post_status = 'wc-cancelled' + AND fees_meta.post_id IS NOT NULL + ) + )"; + + $params = [ Intent_Status::CANCELED, self::BUG_START_DATE ]; // Add offset based on last order ID. if ( $last_order_id > 0 ) { - $sql .= ' AND p.ID > %d'; + $sql .= ' AND orders.ID > %d'; $params[] = $last_order_id; } // Add ordering and limit. - $sql .= ' ORDER BY p.ID ASC LIMIT %d'; + $sql .= ' ORDER BY orders.ID ASC LIMIT %d'; $params[] = $limit; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared @@ -427,6 +444,9 @@ public function process_batch(): void { return; } + // This can affect the order transitions by unnecessarily reaching out to Stripe. + remove_action( 'woocommerce_order_status_cancelled', [ WC_Payments::get_order_service(), 'cancel_authorizations_on_order_status_change' ] ); + $start_time = microtime( true ); $batch_size = $this->get_batch_size(); $orders = $this->get_affected_orders( $batch_size ); @@ -885,4 +905,21 @@ public function has_affected_orders(): bool { $orders = $this->get_affected_orders( 1 ); return ! empty( $orders ); } + + /** + * Run the affected orders query and cache the result. + * + * Called by Action Scheduler in a separate request. + * + * @return void + */ + public function check_and_cache_affected_orders(): void { + $result = $this->has_affected_orders(); + + update_option( + self::CHECK_STATE_OPTION_KEY, + $result ? 'has_affected_orders' : 'no_affected_orders', + true + ); + } } diff --git a/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php b/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php index 86ff8047008..7f91afa67cd 100644 --- a/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php +++ b/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php @@ -37,6 +37,8 @@ class WC_Payments_Notes_Canceled_Auth_Remediation { * @return bool */ public static function can_be_added() { + include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; + // Don't show if remediation is already complete. if ( 'completed' === get_option( 'wcpay_fee_remediation_status', '' ) ) { return false; @@ -88,12 +90,47 @@ public static function get_note() { /** * Check if there are orders that need remediation. * + * Uses a state machine backed by an option to avoid running the expensive + * query inline. On the first call, schedules an async Action Scheduler job + * and returns false. The note will be added on a subsequent admin_init + * once the async check completes. + * * @return bool */ private static function has_affected_orders() { - include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; - $remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); - return $remediation->has_affected_orders(); + $state = get_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY ); + + if ( false === $state ) { + self::schedule_check(); + return false; + } + + if ( 'has_affected_orders' === $state ) { + return true; + } + + // 'scheduled', 'no_affected_orders', or any unexpected value. + return false; + } + + /** + * Schedule the async affected orders check via Action Scheduler. + * + * @return void + */ + private static function schedule_check() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + return; + } + + update_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY, 'scheduled', true ); + + as_schedule_single_action( + time() + 10, + WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_AFFECTED_ORDERS_HOOK, + [], + 'woocommerce-payments' + ); } /** @@ -106,7 +143,6 @@ private static function is_remediation_running() { return false; } - include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; return as_has_scheduled_action( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK ); } } diff --git a/package-lock.json b/package-lock.json index dc85d15f330..2370638672c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "10.5.0", + "version": "10.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "10.5.0", + "version": "10.5.1", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index e46267dde64..8d9f9378ab2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "10.5.0", + "version": "10.5.1", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", diff --git a/readme.txt b/readme.txt index 404cf2abfc1..17b3aced599 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: woocommerce payments, apple pay, credit card, google pay, payment, payment Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 7.3 -Stable tag: 10.5.0 +Stable tag: 10.5.1 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -87,6 +87,10 @@ You can read our Terms of Service and other policies [here](https://woocommerce. == Changelog == += 10.5.1 - 2026-02-11 = +* Fix - Cache the affected orders check for the canceled auth fee remediation note to avoid an expensive query on every admin page load + + = 10.5.0 - 2026-02-05 = * Add - Add "Other" and "Booking/Reservation" product type support for dispute evidence (feature flag gated) * Add - Add ability to specify preferred communications email. diff --git a/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php b/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php index cf2f06a00be..98138701f3c 100644 --- a/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php +++ b/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php @@ -102,9 +102,10 @@ public function test_increment_stat_updates_counter() { } public function test_get_affected_orders_returns_canceled_orders_with_fees() { - // Create order with canceled intent and fees. + // Create order with canceled intent, cancelled status, and fees. $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order->save(); @@ -153,10 +154,12 @@ public function test_get_affected_orders_excludes_orders_without_fees_or_refunds $this->assertCount( 0, $orders ); } - public function test_get_affected_orders_finds_orders_with_refunds_but_no_fees() { - // Create order with canceled intent and refund, but no fee metadata. + public function test_get_affected_orders_excludes_cancelled_orders_with_refunds_but_no_fees() { + // Create cancelled order with canceled intent and refund, but no fee metadata. + // The new query only matches cancelled orders when they have fee metadata. $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->save(); @@ -171,8 +174,7 @@ public function test_get_affected_orders_finds_orders_with_refunds_but_no_fees() $orders = $this->remediation->get_affected_orders( 10 ); - $this->assertCount( 1, $orders ); - $this->assertEquals( $order->get_id(), $orders[0]->get_id() ); + $this->assertCount( 0, $orders ); } public function test_get_affected_orders_respects_batch_size() { @@ -180,6 +182,7 @@ public function test_get_affected_orders_respects_batch_size() { for ( $i = 0; $i < 5; $i++ ) { $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order->save(); @@ -194,18 +197,21 @@ public function test_get_affected_orders_uses_offset_from_last_order_id() { // Create 3 affected orders. $order1 = WC_Helper_Order::create_order(); $order1->set_date_created( '2023-05-01' ); + $order1->set_status( 'cancelled' ); $order1->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order1->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order1->save(); $order2 = WC_Helper_Order::create_order(); $order2->set_date_created( '2023-05-02' ); + $order2->set_status( 'cancelled' ); $order2->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order2->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order2->save(); $order3 = WC_Helper_Order::create_order(); $order3->set_date_created( '2023-05-03' ); + $order3->set_status( 'cancelled' ); $order3->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order3->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order3->save(); @@ -465,6 +471,7 @@ public function test_process_batch_remediates_affected_orders() { for ( $i = 0; $i < 3; $i++ ) { $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order->save(); @@ -481,12 +488,14 @@ public function test_process_batch_remediates_affected_orders() { public function test_process_batch_updates_last_order_id() { $order1 = WC_Helper_Order::create_order(); $order1->set_date_created( '2023-05-01' ); + $order1->set_status( 'cancelled' ); $order1->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order1->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order1->save(); $order2 = WC_Helper_Order::create_order(); $order2->set_date_created( '2023-05-02' ); + $order2->set_status( 'cancelled' ); $order2->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order2->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order2->save(); @@ -508,6 +517,7 @@ public function test_process_batch_marks_complete_when_no_orders() { public function test_process_batch_increments_error_count_on_failure() { $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order->save(); @@ -540,6 +550,7 @@ public function test_has_affected_orders_returns_true_when_orders_exist() { // Create an affected order. $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order->save(); @@ -584,9 +595,10 @@ public function test_get_affected_orders_uses_cpt_when_hpos_disabled() { $mock_remediation->method( 'is_hpos_enabled' )->willReturn( false ); - // Create order with canceled intent and fees. + // Create order with canceled intent, cancelled status, and fees. $order = WC_Helper_Order::create_order(); $order->set_date_created( '2023-05-01' ); + $order->set_status( 'cancelled' ); $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); $order->save(); diff --git a/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php b/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php index e36557a5149..fd6a81b4e58 100644 --- a/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php +++ b/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php @@ -14,10 +14,12 @@ class WC_Payments_Notes_Canceled_Auth_Remediation_Test extends WCPAY_UnitTestCas */ public function set_up() { parent::set_up(); + require_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; require_once WCPAY_ABSPATH . 'includes/notes/class-wc-payments-notes-canceled-auth-remediation.php'; // Clean up any existing options. delete_option( 'wcpay_fee_remediation_status' ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY ); } /** @@ -25,6 +27,12 @@ public function set_up() { */ public function tear_down() { delete_option( 'wcpay_fee_remediation_status' ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY ); + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_AFFECTED_ORDERS_HOOK ); + } + parent::tear_down(); } @@ -58,4 +66,71 @@ public function test_can_be_added_returns_false_when_complete() { $this->assertFalse( $result ); } + + /** + * Tests that can_be_added returns false when no affected orders were found. + */ + public function test_can_be_added_returns_false_when_no_affected_orders() { + update_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY, 'no_affected_orders' ); + + $result = WC_Payments_Notes_Canceled_Auth_Remediation::can_be_added(); + + $this->assertFalse( $result ); + } + + /** + * Tests that can_be_added returns false while async check is scheduled. + */ + public function test_can_be_added_returns_false_when_scheduled() { + update_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY, 'scheduled' ); + + $result = WC_Payments_Notes_Canceled_Auth_Remediation::can_be_added(); + + $this->assertFalse( $result ); + } + + /** + * Tests that when no state exists, can_be_added schedules the async check and returns false. + */ + public function test_can_be_added_schedules_check_when_no_state() { + $result = WC_Payments_Notes_Canceled_Auth_Remediation::can_be_added(); + + $this->assertFalse( $result ); + $this->assertEquals( + 'scheduled', + get_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY ) + ); + $this->assertTrue( + as_has_scheduled_action( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_AFFECTED_ORDERS_HOOK ) + ); + } + + /** + * Tests that the async callback caches the query result. + */ + public function test_check_and_cache_sets_no_affected_orders() { + $remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); + $remediation->check_and_cache_affected_orders(); + + // No affected orders in the test DB. + $this->assertEquals( + 'no_affected_orders', + get_option( WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_STATE_OPTION_KEY ) + ); + } + + /** + * Tests that init registers the Action Scheduler hook. + */ + public function test_init_registers_check_hook() { + $remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); + $remediation->init(); + + $this->assertNotFalse( + has_action( + WC_Payments_Remediate_Canceled_Auth_Fees::CHECK_AFFECTED_ORDERS_HOOK, + [ $remediation, 'check_and_cache_affected_orders' ] + ) + ); + } } diff --git a/woocommerce-payments.php b/woocommerce-payments.php index 6456b944713..14f5ded1a25 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -11,7 +11,7 @@ * WC tested up to: 10.5.0 * Requires at least: 6.0 * Requires PHP: 7.4 - * Version: 10.5.0 + * Version: 10.5.1 * Requires Plugins: woocommerce * * @package WooCommerce\Payments