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.
 	 *