Commit 7e5789d580f for woocommerce
commit 7e5789d580f5b7b5276588dfa2a211237ce5e157
Author: louwie17 <lourensschep@gmail.com>
Date: Tue May 12 16:12:42 2026 +0200
Analytics: re-add full refund fix tool to Status > Tools (#64143)
* Analytics: re-add full refund fix tool to Status > Tools
Re-adds the "Fix analytics full refund data" debug tool that was previously
reverted. The tool is shown only when the store has the old full refund data
flag set (woocommerce_analytics_uses_old_full_refund_data). The qualifying
DB check (whether affected orders exist) now runs only when the button is
clicked, not on every page load.
Closes WOOPLUG-6524.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Analytics: add Check button and batched processing to refund fix tool
- Add AJAX endpoint + inline JS to inject a "Check" button on the Status >
Tools page. Clicking it runs a LIMIT 1 query and reports whether the store
has orders that need fixing, without requiring the full fix to run first.
- Replace the single bulk query in run_full_refund_fix_data_tool() with a
cursor-based Action Scheduler loop (process_refund_fix_batch). Each
job processes up to 1,000 orders and schedules the next batch until all
affected rows have been queued for re-import.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Improve analytics full refund fix tool UX and query accuracy
* Fix lint warnings
* Fix PHPStan errors in Analytics refund fix methods
* Improve analytics refund fix tool UX and registration
* Rename refund fix tool action from remove to disable
* Address code review feedback on analytics refund fix tool
* Fix refund fix batch to work in both immediate and scheduled import modes
* Fix lint error
* Fix lint warnings in analytics refund fix tool
* Address code review feedback on analytics refund fix tool
* Address code review feedback on analytics refund fix tool
* Fix lint warning in analytics refund fix tool
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
diff --git a/plugins/woocommerce/changelog/wooplug-6524-incorrect-analytics-calculation-when-refunding-orders-via b/plugins/woocommerce/changelog/wooplug-6524-incorrect-analytics-calculation-when-refunding-orders-via
new file mode 100644
index 00000000000..f8940080a7d
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6524-incorrect-analytics-calculation-when-refunding-orders-via
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Re-add the analytics full refund fix tool to Status > Tools, shown only for stores with old refund data.
diff --git a/plugins/woocommerce/src/Internal/Admin/Analytics.php b/plugins/woocommerce/src/Internal/Admin/Analytics.php
index 5f7398173b4..edf3096abe0 100644
--- a/plugins/woocommerce/src/Internal/Admin/Analytics.php
+++ b/plugins/woocommerce/src/Internal/Admin/Analytics.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
+use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
@@ -24,6 +25,12 @@ class Analytics {
* Clear cache tool identifier.
*/
const CACHE_TOOL_ID = 'clear_woocommerce_analytics_cache';
+ /**
+ * Full refund fix data tool identifier.
+ *
+ * @since 10.8.0
+ */
+ const FULL_REFUND_FIX_DATA_TOOL_ID = 'fix_woocommerce_analytics_full_refund_data';
/**
* Class instance.
@@ -65,6 +72,16 @@ class Analytics {
add_action( 'admin_menu', array( $this, 'register_pages' ) );
add_filter( 'woocommerce_debug_tools', array( $this, 'register_cache_clear_tool' ) );
add_filter( 'woocommerce_debug_tools', array( $this, 'register_regenerate_order_fulfillment_status_tool' ), 12 );
+
+ // Always register the batch hook so in-flight jobs survive after the legacy
+ // flag is cleared (clearing happens before the first batch is queued).
+ add_action( 'woocommerce_analytics_refund_fix_batch', array( $this, 'process_refund_fix_batch' ) );
+
+ if ( $this->should_show_refund_fix_tool() ) {
+ add_filter( 'woocommerce_debug_tools', array( $this, 'register_full_refund_fix_data_tool' ) );
+ add_action( 'admin_footer', array( $this, 'output_refund_fix_tool_js' ) );
+ add_action( 'wp_ajax_woocommerce_check_refund_fix_needed', array( $this, 'ajax_check_refund_fix_needed' ) );
+ }
}
/**
@@ -181,6 +198,339 @@ class Analytics {
return $debug_tools;
}
+ /**
+ * Whether the full refund fix tool should be shown to the merchant.
+ *
+ * Returns true when the store still has legacy refund data OR when the fix was
+ * recently queued and the merchant has not yet dismissed the tool. New stores
+ * (where the option was never set) never see the tool.
+ *
+ * @since 10.8.0
+ *
+ * @return bool
+ */
+ private function should_show_refund_fix_tool(): bool {
+ return ! OrderUtil::uses_new_full_refund_data()
+ || 'yes' === get_option( 'woocommerce_analytics_show_old_refund_data_tool' );
+ }
+
+ /**
+ * Register the full refund fix data tool on the WooCommerce > Status > Tools page.
+ *
+ * The Fix button is disabled by default (via the PHP 'disabled' field). JS enables it
+ * only after a Check confirms there are affected orders to fix.
+ *
+ * @since 10.8.0
+ *
+ * @param array $debug_tools Available debug tool registrations.
+ * @return array Filtered debug tool registrations.
+ */
+ public function register_full_refund_fix_data_tool( $debug_tools ) {
+ $desc = __( 'This tool will fix the full refund data used in WooCommerce Analytics and re-import all the refunded historical data.', 'woocommerce' );
+
+ $disabled = true;
+
+ $debug_tools[ self::FULL_REFUND_FIX_DATA_TOOL_ID ] = array(
+ 'name' => __( 'Fix analytics full refund data', 'woocommerce' ),
+ 'button' => __( 'Fix', 'woocommerce' ),
+ 'desc' => $desc,
+ 'callback' => array( $this, 'run_full_refund_fix_data_tool' ),
+ 'disabled' => $disabled,
+ );
+
+ return $debug_tools;
+ }
+
+ /**
+ * Handles the Fix button submission for the full refund fix tool.
+ *
+ * When the "Disable tool" action is requested (i.e. the Check confirmed no affected
+ * orders), deletes the old-data flag so the tool no longer appears. Otherwise
+ * schedules the first batch job to re-import all affected refund orders.
+ *
+ * @since 10.8.0
+ *
+ * @return string Success message.
+ */
+ public function run_full_refund_fix_data_tool() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework.
+ if ( isset( $_GET['wc_refund_fix_action'] ) && 'disable' === sanitize_key( $_GET['wc_refund_fix_action'] ) ) {
+ delete_option( 'woocommerce_analytics_uses_old_full_refund_data' );
+ delete_option( 'woocommerce_analytics_show_old_refund_data_tool' );
+ return __( 'Tool dismissed.', 'woocommerce' );
+ }
+
+ $already_running = ! empty(
+ as_get_scheduled_actions(
+ array(
+ 'hook' => 'woocommerce_analytics_refund_fix_batch',
+ 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
+ 'per_page' => 1,
+ 'orderby' => 'none',
+ ),
+ 'ids'
+ )
+ );
+
+ if ( $already_running ) {
+ return __( 'A fix is already in progress, please check back later.', 'woocommerce' );
+ }
+
+ // Clear the legacy flag before queuing so that every batch job runs with
+ // the corrected full-refund import logic (uses_new_full_refund_data() → true).
+ // Set the show-tool option so the tool stays visible until the merchant dismisses it.
+ delete_option( 'woocommerce_analytics_uses_old_full_refund_data' );
+ update_option( 'woocommerce_analytics_show_old_refund_data_tool', 'yes' );
+
+ WC()->queue()->schedule_single(
+ time(),
+ 'woocommerce_analytics_refund_fix_batch',
+ array( 0 ),
+ 'wc-admin-data'
+ );
+
+ return __( 'Re-importing refunded orders in batches. Full refund data will be updated shortly.', 'woocommerce' );
+ }
+
+ /**
+ * Process one batch of refund orders for the analytics fix.
+ *
+ * Fetches up to 100 orders with incorrect refund stats (cursor-based so
+ * concurrent imports cannot shift the result window) and re-imports each
+ * directly. Schedules itself for the next cursor position when the batch is
+ * full, stopping automatically once no more rows are found.
+ *
+ * @since 10.8.0
+ *
+ * @param int $min_order_id Exclusive lower bound on order_id; 0 for the first batch.
+ * @return void
+ * @throws \Exception On database error so Action Scheduler marks the job as failed.
+ */
+ public function process_refund_fix_batch( $min_order_id = 0 ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $refunded_orders = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT order_stats.order_id
+ FROM {$wpdb->prefix}wc_order_stats AS order_stats
+ INNER JOIN {$wpdb->prefix}wc_order_stats AS parent_stats ON order_stats.parent_id = parent_stats.order_id
+ WHERE order_stats.total_sales < 0
+ AND order_stats.total_sales = order_stats.net_total
+ AND order_stats.total_sales != order_stats.shipping_total
+ AND order_stats.total_sales != order_stats.tax_total
+ AND (parent_stats.shipping_total > 0 OR parent_stats.tax_total > 0)
+ AND order_stats.order_id > %d
+ ORDER BY order_stats.order_id ASC
+ LIMIT 100",
+ $min_order_id
+ )
+ );
+
+ if ( ! $refunded_orders ) {
+ if ( $wpdb->last_error ) {
+ throw new \Exception( $wpdb->last_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+ return;
+ }
+
+ foreach ( $refunded_orders as $refunded_order ) {
+ OrdersScheduler::import( intval( $refunded_order->order_id ) );
+ }
+
+ if ( count( $refunded_orders ) >= 100 ) {
+ $last_order_id = intval( end( $refunded_orders )->order_id );
+ WC()->queue()->schedule_single(
+ time() + 5,
+ 'woocommerce_analytics_refund_fix_batch',
+ array( $last_order_id ),
+ 'wc-admin-data'
+ );
+ }
+ }
+
+ /**
+ * AJAX handler: checks whether the store has analytics order stats rows that
+ * look like unprocessed full refunds.
+ *
+ * @since 10.8.0
+ * @return void
+ */
+ public function ajax_check_refund_fix_needed(): void {
+ check_ajax_referer( 'woocommerce_refund_fix_check', 'nonce' );
+
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'woocommerce' ) ), 403 );
+ }
+
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $has_affected = $wpdb->get_var(
+ "SELECT order_stats.order_id
+ FROM {$wpdb->prefix}wc_order_stats AS order_stats
+ INNER JOIN {$wpdb->prefix}wc_order_stats AS parent_stats ON order_stats.parent_id = parent_stats.order_id
+ WHERE order_stats.total_sales < 0
+ AND order_stats.total_sales = order_stats.net_total
+ AND order_stats.total_sales != order_stats.shipping_total
+ AND order_stats.total_sales != order_stats.tax_total
+ AND (parent_stats.shipping_total > 0 OR parent_stats.tax_total > 0)
+ LIMIT 1"
+ );
+
+ if ( $wpdb->last_error ) {
+ wp_send_json_error(
+ array(
+ 'code' => 'db_error',
+ 'message' => $wpdb->last_error,
+ ),
+ 500
+ );
+ }
+
+ $fix_in_progress = ! empty(
+ as_get_scheduled_actions(
+ array(
+ 'hook' => 'woocommerce_analytics_refund_fix_batch',
+ 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
+ 'per_page' => 1,
+ 'orderby' => 'none',
+ ),
+ 'ids'
+ )
+ );
+
+ wp_send_json_success(
+ array(
+ 'needs_fix' => ! empty( $has_affected ),
+ 'fix_in_progress' => $fix_in_progress,
+ )
+ );
+ }
+
+ /**
+ * Output the inline script that injects a "Check" button into the full refund
+ * fix tool row on the WooCommerce > Status > Tools page.
+ *
+ * @since 10.8.0
+ * @return void
+ */
+ public function output_refund_fix_tool_js(): void {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework.
+ if ( ! isset( $_GET['page'], $_GET['tab'] ) || 'wc-status' !== $_GET['page'] || 'tools' !== $_GET['tab'] ) {
+ return;
+ }
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework.
+ if ( isset( $_GET['wc_refund_fix_action'] ) && 'disable' === sanitize_key( $_GET['wc_refund_fix_action'] ) ) {
+ return;
+ }
+
+ $tool_class = self::FULL_REFUND_FIX_DATA_TOOL_ID;
+ $nonce = wp_create_nonce( 'woocommerce_refund_fix_check' );
+ $ajax_url = admin_url( 'admin-ajax.php' );
+ $label_check = __( 'Check', 'woocommerce' );
+ $label_working = __( 'Checking…', 'woocommerce' );
+ $msg_needs_fix = __( 'Your store has orders that need fixing.', 'woocommerce' );
+ $msg_no_fix = __( 'No affected orders found.', 'woocommerce' );
+ $label_disable_tool = __( 'Disable tool', 'woocommerce' );
+ $msg_in_progress = __( 'A fix is already in progress, please check back later.', 'woocommerce' );
+ $msg_error = __( 'Check failed, please try again.', 'woocommerce' );
+ ?>
+ <script type="text/javascript">
+ ( function() {
+ const toolRow = document.querySelector( 'tr.<?php echo esc_js( $tool_class ); ?>' );
+ if ( ! toolRow ) {
+ return;
+ }
+ const actionCell = toolRow.querySelector( 'td.run-tool' );
+ if ( ! actionCell ) {
+ return;
+ }
+
+ const statusSpan = document.createElement( 'span' );
+ statusSpan.style.cssText = 'display:block;margin-top:6px;';
+ statusSpan.setAttribute( 'aria-live', 'polite' );
+ statusSpan.setAttribute( 'role', 'status' );
+
+ const checkBtn = document.createElement( 'button' );
+ checkBtn.type = 'button';
+ checkBtn.className = 'button button-secondary';
+ checkBtn.style.marginRight = '8px';
+ checkBtn.textContent = <?php echo wp_json_encode( $label_check ); ?>;
+
+ const fixBtn = actionCell.querySelector( 'input[type=submit]' );
+ const originalFixLabel = fixBtn ? fixBtn.value : '';
+ const toolForm = document.getElementById( 'form_<?php echo esc_js( $tool_class ); ?>' );
+
+ checkBtn.addEventListener( 'click', function() {
+ checkBtn.disabled = true;
+ checkBtn.textContent = <?php echo wp_json_encode( $label_working ); ?>;
+ statusSpan.textContent = '';
+ statusSpan.style.color = '';
+
+ const data = new FormData();
+ data.append( 'action', 'woocommerce_check_refund_fix_needed' );
+ data.append( 'nonce', <?php echo wp_json_encode( $nonce ); ?> );
+
+ fetch( <?php echo wp_json_encode( $ajax_url ); ?>, { method: 'POST', body: data } )
+ .then( function( r ) { return r.json(); } )
+ .then( function( json ) {
+ checkBtn.disabled = false;
+ checkBtn.textContent = <?php echo wp_json_encode( $label_check ); ?>;
+ if ( json.success ) {
+ if ( json.data.fix_in_progress ) {
+ statusSpan.textContent = <?php echo wp_json_encode( $msg_in_progress ); ?>;
+ statusSpan.style.color = '#1d2327';
+ } else if ( json.data.needs_fix ) {
+ statusSpan.textContent = <?php echo wp_json_encode( $msg_needs_fix ); ?>;
+ statusSpan.style.color = '#d63638';
+ if ( fixBtn ) {
+ fixBtn.value = originalFixLabel;
+ fixBtn.disabled = false;
+ }
+ const existingFlag = toolForm ? toolForm.querySelector( 'input[name="wc_refund_fix_action"]' ) : null;
+ if ( existingFlag ) {
+ existingFlag.parentNode.removeChild( existingFlag );
+ }
+ } else {
+ statusSpan.textContent = <?php echo wp_json_encode( $msg_no_fix ); ?>;
+ statusSpan.style.color = '#1d2327';
+ if ( fixBtn ) {
+ fixBtn.value = <?php echo wp_json_encode( $label_disable_tool ); ?>;
+ fixBtn.disabled = false;
+ }
+ if ( toolForm && ! toolForm.querySelector( 'input[name="wc_refund_fix_action"]' ) ) {
+ const flagInput = document.createElement( 'input' );
+ flagInput.type = 'hidden';
+ flagInput.name = 'wc_refund_fix_action';
+ flagInput.value = 'disable';
+ toolForm.appendChild( flagInput );
+ }
+ }
+ } else {
+ statusSpan.textContent = ( json.data && json.data.message ) ? json.data.message : <?php echo wp_json_encode( $msg_error ); ?>;
+ statusSpan.style.color = '#d63638';
+ }
+ } )
+ .catch( function() {
+ checkBtn.disabled = false;
+ checkBtn.textContent = <?php echo wp_json_encode( $label_check ); ?>;
+ statusSpan.textContent = <?php echo wp_json_encode( $msg_error ); ?>;
+ statusSpan.style.color = '#d63638';
+ } );
+ } );
+
+ if ( fixBtn ) {
+ actionCell.insertBefore( checkBtn, fixBtn );
+ } else {
+ actionCell.appendChild( checkBtn );
+ }
+ actionCell.appendChild( statusSpan );
+ } )();
+ </script>
+ <?php
+ }
+
/**
* Register the regenerate order fulfillment status tool on the WooCommerce > Status > Tools page.
*