Commit ebda2ec61d1 for woocommerce
commit ebda2ec61d13ef7342abb4bc0bc3b5b094340efd
Author: louwie17 <lourensschep@gmail.com>
Date: Wed Sep 16 14:15:17 2026 +0200
fix: repair Analytics refund double-count from partial-then-full refunds (#66505)
* fix: repair Analytics refund double-count from partial-then-full refunds
Historical rows in wc_order_stats written before the write-path fix in
PR #66320 double-count a partial refund when it was followed by a full
refund, overstating the Revenue → Returns metric. This surfaces and
repairs those already-written rows on existing stores.
- One-time, versioned (11.1.0 DB upgrade) background scan detects affected
orders via a windowed parent_id query on wc_order_stats, storing a count
only. Gated on OrderUtil::uses_new_full_refund_data().
- Merchant notice in Analytics → Settings → Import historical data shows the
count with a "Re-import affected orders" action; self-heals to zero.
- New wc-analytics REST fields on imports/status plus a guarded
imports/fix-refund-double-counting route to schedule the fix batch.
- Extracts a shared ImportWarningNotice presentational component reused by
the existing failed-imports notice.
Introduced by write-path bug fixed in PR #66320.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVFWvAmofbKDtMjJ1Rk2o1
* Rework refund double-count repair batching per review
- Replace fixed 500k parent_id windows with keyset LIMIT batches in the
scan and fix jobs, bounding per-batch work and dropping the
MAX(order_id) termination queries; batch size is filterable for tests.
- Drop the unused persisted cursor from the scan state option.
- Only clear the scan state on full-history (not windowed) re-imports.
- Skip the Action Scheduler in-progress lookup in the status endpoint
until the scan has completed with a non-zero count.
- Reject fix requests when the completed scan found nothing to fix.
- Extract shared is_batch_pending_or_running()/schedule_batch() helpers
and dedupe the frontend getErrorMessage/status fetch into shared
modules so both notices issue a single status GET.
* Bound refund double-count batch aggregation to parent_id windows
LIMIT alone cannot bound the batch cost: it applies after GROUP BY/HAVING,
and wc_order_stats has no index starting with parent_id, so each keyset
batch re-aggregated every row past the cursor into a temp table. Cap each
batch to a 500k parent_id window so the aggregation work is bounded, while
keeping the LIMIT as the per-action processing cap. No group straddles a
window because all of a parent's refund rows share one parent_id. Batches
advance to the next window on a short result and finish only past
MAX(order_id) (an O(1) primary-key lookup).
Addresses review feedback on the scan query cost in PR #66505.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVFWvAmofbKDtMjJ1Rk2o1
* Make the refund double-count scan self-healing
The DB upgrade scheduled the scan fire-and-forget: if the schedule call
failed or a batch died, the state option stuck at complete:false forever
and the merchant notice was silently suppressed.
The 11.1.0 upgrade now persists the incomplete state before scheduling,
making the option's existence a durable "a scan is owed" marker. The
imports/status endpoint (polled on every analytics settings page load)
lazily reschedules a scan that never ran or died mid-way — capped at 5
total attempts at least an hour apart, skipped while a scan is already
queued or a historical import is rewriting the rows being scanned, and
deduplicated via Action Scheduler's unique flag since two settings-page
components poll the endpoint concurrently. Batch DB failures are now
logged to the wc-analytics-order-import source before being rethrown.
Addresses review feedback on fire-and-forget scheduling and the missing
retry path in PR #66505.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVFWvAmofbKDtMjJ1Rk2o1
* Reset instead of delete refund double-count state on full re-import
The scan state was deleted the moment a full non-skip historical
re-import started. If that import stalled or partially failed, the
double-counted rows remained but the one-time scan never reran, hiding
the merchant notice forever.
Reset the state to incomplete with a fresh retry budget instead: the
notice hides while the import runs (the frontend only renders on a
complete scan), the self-heal path is blocked by its is-importing gate
until the import finishes, and the next settings visit then schedules a
verification scan against the repaired table. A successful import
verifies to zero and the notice stays gone; a failed one resurfaces it.
Addresses review feedback on clearing state before a re-import succeeds
in PR #66505.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVFWvAmofbKDtMjJ1Rk2o1
* Move refund double-count repair to a Status > Tools tool
Replace the automatic 11.1.0 DB-upgrade scan with a merchant-triggered
"Fix double-counted refunds in Analytics" tool, per review on PR #66505.
- Drop the DB upgrade, the self-healing scan, the imports/status REST
fields and fix route, and the settings-page notice (the frontend is back
to trunk).
- One "Check and fix" Action Scheduler job finds affected parents by
walking wc_order_stats by primary key and reaching refunds through the
order table's existing parent index (HPOS parent_order_id, CPT
post_parent). No full scans and no schema change; EXPLAIN on MariaDB 12
and MySQL 8 shows ~1.7k index reads per 50k-ID batch versus ~1M rows
scanned per window before.
- Re-import up to 100 parents per batch, re-check them afterwards and
count repairs that did not stick as unresolved instead of assuming success.
- Fence batches with a run ID so duplicate clicks, stale workers and a full
historical re-import (which cancels the run) cannot corrupt the state.
- Show the tool only on new-refund-data stores first installed before
11.1.0, and add an inbox note pointing to it until the tool has run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9y3nkpyq8u93NU7bTMbBW
* Read fresh fix state and tighten the detection tolerance
- Re-read the tool state from the database before fencing checks and writes,
so a batch cannot overwrite a cancellation or a newer run saved by another
request while it was re-importing orders.
- Compare refund totals with half the smallest currency unit instead of
0.01, so a double-counted one-cent partial refund is detected.
Addresses Codex review findings on the reworked tool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9y3nkpyq8u93NU7bTMbBW
* Fence the final batch write and cap the tolerance precision
- Check and write the same fresh copy of the fix state at the end of a
batch, so a batch of an older run can never complete or add to the
counters of a newer run started while it was re-importing orders.
- Cap the tolerance at 5 decimals: the %f placeholder keeps 6, so stores
with 6+ price decimals got a zero tolerance and floating-point noise
flagged correctly refunded orders.
Addresses the second Codex review round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9y3nkpyq8u93NU7bTMbBW
* Clear the missing-option cache and support up to 8 price decimals
- Also clear the 'notoptions' cache when re-reading the fix state, so a
request that cached the option as missing still sees a run created by
another request instead of dropping the batch.
- Format the detection tolerance with 10 decimals and cap it at 8 price
decimals instead of 5, so a double-counted smallest-unit refund is still
found on 6-8 decimal stores.
Addresses the completed second Codex review round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9y3nkpyq8u93NU7bTMbBW
* Link the inbox note straight to the tool row
The note's action opened Status > Tools at the top of a long list, so the
tool was easy to miss. Give each tool row an id (tool_{tool id}, matching
the view's existing form_/selector_ ids), point the note at the refund
tool's row, and keep a linked row clear of the fixed admin bar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9y3nkpyq8u93NU7bTMbBW
* Record Tracks events for the refund double-count tool
The inbox note's view, click and dismiss are already tracked by core
(inbox_note_view, inbox_action_click, inbox_action_dismiss). Add:
- wcadmin_analytics_refund_double_count_tool_run: every button press on
the tool, with outcome (started, dismissed, refused_running,
refused_full_refund_fix) and the run status before it.
- wcadmin_analytics_refund_double_count_fix_finished: when a run completes
or a full historical re-import cancels it, with fixed and unresolved
counts, batches, duration, highest order ID and the database engine.
Both carry order_storage (hpos or cpt). The run state gains started_at
and a batch counter for these. Events go through a recorder that tests
can replace, as WC_Tracks skips PHPUnit users.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9y3nkpyq8u93NU7bTMbBW
* Fail the refund fix run when the highest order ID cannot be read
A failed MAX(order_id) query read as 0, so the run covered an empty range,
finished immediately and told the merchant that no affected orders were
found. Throw the database error instead, the way the detection query
already does, so no run state is written and the tool still offers to run.
The Status > Tools controller catches it and reports it to the merchant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
* Cancel the refund fix when a full re-import omits the days argument
The guard compared $days to false, but the Import historical data UI omits
`days` for "All", so the REST route passes null and the guard returned
early. A full re-import then left the fix running alongside it. Read the
value with is_int(), the way OrdersScheduler reads the same argument.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
* Keep the refund fix batch going when re-importing one order throws
OrdersScheduler::import() has no exception boundary, so one order that
threw left the batch before it recorded its counters or scheduled the next
range, and the tool stayed on "Checking and fixing" for good. Catch per
order, count the failure as unresolved, and carry on with the rest.
The unresolved log now names each order and why it stayed unresolved,
since a thrown import and an order that is still over-refunded after a
successful re-import are different problems for whoever reads the log.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
* Assert the refund fix Tracks events without a test-only setter
set_refund_double_count_event_recorder() was public API on Analytics that
existed only so the tests could capture Tracks events. WC_Tracks skips
PHPUnit users only when one is logged in, which this suite never does, so
the events reach WC_Tracks_Footer_Pixel and WC_Unit_Test_Case's own
get_tracks_events() reads them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
* Make the refund fix batch size constants private
They are only read as the defaults of
woocommerce_analytics_refund_double_count_batch_size and _range_size, and
those filters are the supported way to change them, so the constants do
not need to be part of the class's public surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
* Keep the database error in the message the merchant sees
wc_get_logger()->error() runs its own query through the database log
handler, which clears $wpdb->last_error before the throw read it again.
The exception reached the Status > Tools controller with an empty message,
so the merchant was told an error happened but not what it was. Read the
error into a local first. Found while testing the tool on wp-env.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
* Make two refund fix regression tests pin what they claim to
Both passed against the unfixed code, for reasons unrelated to the fix:
- The thrown-import test threw from wc_get_order(), before the re-import
could repair anything, so the verification query flagged the order on its
own and the merge of failed imports into the unresolved set was never
exercised. It now also throws from
woocommerce_order_scheduler_after_import_order, after the row is
repaired, where only the merge can catch it.
- The database-error test used the spy logger, which records in memory and
never runs a query, so $wpdb->last_error survived the log call either
way. It now stands in a logger that runs a real query while logging.
Both were re-checked to fail against the unfixed code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GgreF7tGrgMbJtn2wfGPh
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-analytics-refund-double-count-notice b/plugins/woocommerce/changelog/fix-analytics-refund-double-count-notice
new file mode 100644
index 00000000000..2e9d42a122a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-analytics-refund-double-count-notice
@@ -0,0 +1,5 @@
+Significance: minor
+Type: fix
+Comment: Introduced by write-path bug fixed in PR #66320.
+
+Analytics: add a Status > Tools tool, plus an inbox note pointing to it, that finds and re-imports orders whose partial-then-full refunds were counted twice in the Revenue report's returns.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 12a9ea51214..945c9258492 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -1360,6 +1360,11 @@ table.wc_status_table--tools {
th {
padding: 2em;
}
+
+ // Keep a row linked to with #tool_{id} clear of the fixed admin bar.
+ tr {
+ scroll-margin-block-start: 50px;
+ }
}
.taxonomy-product_cat {
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php b/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php
index 11f78eab4f4..bc269837266 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php
@@ -32,7 +32,7 @@ foreach ( $tools as $action_name => $tool ) {
$row_classes[] = 'requires-refresh';
}
?>
- <tr class="<?php echo esc_attr( implode( ' ', $row_classes ) ); ?>" data-tool-action="<?php echo esc_attr( $action_name ); ?>">
+ <tr id="<?php echo esc_attr( 'tool_' . $action_name ); ?>" class="<?php echo esc_attr( implode( ' ', $row_classes ) ); ?>" data-tool-action="<?php echo esc_attr( $action_name ); ?>">
<th>
<strong class="name"><?php echo esc_html( $tool['name'] ); ?></strong>
<p class="description">
diff --git a/plugins/woocommerce/src/Internal/Admin/Analytics.php b/plugins/woocommerce/src/Internal/Admin/Analytics.php
index 07b7bdc2cc1..62ce6b10534 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\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
+use Automattic\WooCommerce\Internal\Admin\Notes\RefundDoubleCountToolNotice;
use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
@@ -32,6 +33,48 @@ class Analytics {
*/
const FULL_REFUND_FIX_DATA_TOOL_ID = 'fix_woocommerce_analytics_full_refund_data';
+ /**
+ * Double-counted refunds fix tool identifier.
+ *
+ * @since 11.2.0
+ */
+ const REFUND_DOUBLE_COUNT_TOOL_ID = 'fix_woocommerce_analytics_refund_double_count';
+
+ /**
+ * Option holding the state of the double-counted refunds fix tool.
+ *
+ * @since 11.2.0
+ */
+ const REFUND_DOUBLE_COUNT_OPTION = 'woocommerce_analytics_refund_double_count';
+
+ /**
+ * Action Scheduler hook for a double-counted refunds fix batch.
+ *
+ * @since 11.2.0
+ */
+ const REFUND_DOUBLE_COUNT_FIX_HOOK = 'woocommerce_analytics_refund_double_count_fix_batch';
+
+ /**
+ * Maximum number of affected parent orders re-imported per batch.
+ *
+ * @since 11.2.0
+ */
+ private const REFUND_DOUBLE_COUNT_BATCH_SIZE = 100;
+
+ /**
+ * Number of order IDs checked per batch.
+ *
+ * @since 11.2.0
+ */
+ private const REFUND_DOUBLE_COUNT_RANGE_SIZE = 50000;
+
+ /**
+ * Fix run statuses stored in the tool state.
+ */
+ private const REFUND_DOUBLE_COUNT_STATUS_RUNNING = 'running';
+ private const REFUND_DOUBLE_COUNT_STATUS_COMPLETE = 'complete';
+ private const REFUND_DOUBLE_COUNT_STATUS_CANCELLED = 'cancelled';
+
/**
* Class instance.
*
@@ -77,6 +120,11 @@ class Analytics {
// flag is cleared (clearing happens before the first batch is queued).
add_action( 'woocommerce_analytics_refund_fix_batch', array( $this, 'process_refund_fix_batch' ) );
+ // Merchant-triggered repair of refunds double-counted before #66320.
+ add_filter( 'woocommerce_debug_tools', array( $this, 'register_refund_double_count_tool' ) );
+ add_action( self::REFUND_DOUBLE_COUNT_FIX_HOOK, array( $this, 'process_refund_double_count_fix_batch' ), 10, 2 );
+ add_action( 'woocommerce_analytics_regenerate_init', array( $this, 'maybe_cancel_refund_double_count_fix_on_regenerate' ), 10, 2 );
+
if ( self::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' ) );
@@ -260,19 +308,7 @@ class Analytics {
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 ) {
+ if ( self::is_batch_pending_or_running( 'woocommerce_analytics_refund_fix_batch' ) ) {
return __( 'A fix is already in progress, please check back later.', 'woocommerce' );
}
@@ -282,12 +318,7 @@ class Analytics {
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'
- );
+ self::schedule_batch( 'woocommerce_analytics_refund_fix_batch', array( 0 ) );
return __( 'Re-importing refunded orders in batches. Full refund data will be updated shortly.', 'woocommerce' );
}
@@ -340,13 +371,586 @@ class Analytics {
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'
+ self::schedule_batch( 'woocommerce_analytics_refund_fix_batch', array( $last_order_id ), 5 );
+ }
+ }
+
+ /**
+ * Whether a store may have refunds double-counted by the bug fixed in #66320.
+ *
+ * Only stores that use the new full refund data and ran WooCommerce before 11.1.0,
+ * where the write path was fixed, can have affected rows.
+ *
+ * @internal
+ *
+ * @return bool
+ */
+ public static function is_refund_double_count_tool_applicable(): bool {
+ if ( ! OrderUtil::uses_new_full_refund_data() || OrderUtil::unknown_orders_data_store_in_use() ) {
+ return false;
+ }
+
+ if ( self::get_refund_double_count_state()['dismissed'] ) {
+ return false;
+ }
+
+ // Installs older than 9.2.0 never recorded their initial version.
+ $initial_version = get_option( \WC_Install::INITIAL_INSTALLED_VERSION );
+
+ return ! is_string( $initial_version ) || '' === $initial_version || version_compare( $initial_version, '11.1.0', '<' );
+ }
+
+ /**
+ * Get the state of the double-counted refunds fix tool, with defaults applied.
+ *
+ * @internal
+ *
+ * @return array{run_id: string, status: string, max_order_id: int, fixed: int, unresolved: int, batches: int, started_at: int, completed_at: int, dismissed: bool}
+ */
+ public static function get_refund_double_count_state(): array {
+ $state = get_option( self::REFUND_DOUBLE_COUNT_OPTION );
+ $state = is_array( $state ) ? $state : array();
+
+ return array(
+ 'run_id' => is_string( $state['run_id'] ?? null ) ? $state['run_id'] : '',
+ 'status' => is_string( $state['status'] ?? null ) ? $state['status'] : '',
+ 'max_order_id' => absint( $state['max_order_id'] ?? 0 ),
+ 'fixed' => absint( $state['fixed'] ?? 0 ),
+ 'unresolved' => absint( $state['unresolved'] ?? 0 ),
+ 'batches' => absint( $state['batches'] ?? 0 ),
+ 'started_at' => absint( $state['started_at'] ?? 0 ),
+ 'completed_at' => absint( $state['completed_at'] ?? 0 ),
+ 'dismissed' => ! empty( $state['dismissed'] ),
+ );
+ }
+
+ /**
+ * Get the tool state from the database rather than the request cache, so that
+ * a new run or a cancellation saved by another request is seen.
+ *
+ * @return array Tool state, as get_refund_double_count_state() returns it.
+ */
+ private static function get_fresh_refund_double_count_state(): array {
+ // Clear 'notoptions' too, or a request that cached the option as missing never sees it once created.
+ wp_cache_delete( self::REFUND_DOUBLE_COUNT_OPTION, 'options' );
+ wp_cache_delete( 'notoptions', 'options' );
+
+ return self::get_refund_double_count_state();
+ }
+
+ /**
+ * Merge changes into the stored double-counted refunds fix tool state.
+ *
+ * @param array $changes State keys to overwrite.
+ * @return void
+ */
+ private static function update_refund_double_count_state( array $changes ): void {
+ update_option( self::REFUND_DOUBLE_COUNT_OPTION, array_merge( self::get_fresh_refund_double_count_state(), $changes ), false );
+ }
+
+ /**
+ * Whether a fix run is in progress. A run whose actions all died counts as not running.
+ *
+ * @param array $state Tool state from get_refund_double_count_state().
+ * @return bool
+ */
+ private static function is_refund_double_count_fix_running( array $state ): bool {
+ return self::REFUND_DOUBLE_COUNT_STATUS_RUNNING === $state['status']
+ && self::is_batch_pending_or_running( self::REFUND_DOUBLE_COUNT_FIX_HOOK );
+ }
+
+ /**
+ * Whether the given run is the current, still running fix run.
+ *
+ * @phpstan-impure
+ *
+ * @param string $run_id Run ID passed to a batch.
+ * @return bool
+ */
+ private static function is_current_refund_double_count_run( string $run_id ): bool {
+ return self::is_running_state_of( self::get_fresh_refund_double_count_state(), $run_id );
+ }
+
+ /**
+ * Whether the tool state belongs to the given run and that run is still running.
+ *
+ * @param array $state Tool state from get_refund_double_count_state().
+ * @param string $run_id Run ID passed to a batch.
+ * @return bool
+ */
+ private static function is_running_state_of( array $state, string $run_id ): bool {
+ return '' !== $run_id && $run_id === $state['run_id'] && self::REFUND_DOUBLE_COUNT_STATUS_RUNNING === $state['status'];
+ }
+
+ /**
+ * Register the double-counted refunds fix tool on the WooCommerce > Status > Tools page.
+ *
+ * The button and status text follow the state of the current or last run.
+ *
+ * @internal
+ *
+ * @param array $debug_tools Available debug tool registrations.
+ * @return array Filtered debug tool registrations.
+ */
+ public function register_refund_double_count_tool( $debug_tools ) {
+ if ( ! is_array( $debug_tools ) || ! self::is_refund_double_count_tool_applicable() ) {
+ return $debug_tools;
+ }
+
+ $state = self::get_refund_double_count_state();
+ $button = __( 'Check and fix', 'woocommerce' );
+ $disabled = false;
+ $status_text = '';
+
+ if ( self::is_refund_double_count_fix_running( $state ) ) {
+ $button = __( 'Checking and fixing…', 'woocommerce' );
+ $disabled = true;
+ $status_text = sprintf(
+ /* translators: %d: number of orders fixed so far. */
+ _n( '%d order fixed so far.', '%d orders fixed so far.', $state['fixed'], 'woocommerce' ),
+ $state['fixed']
+ );
+ } elseif ( self::REFUND_DOUBLE_COUNT_STATUS_COMPLETE === $state['status'] && $state['unresolved'] > 0 ) {
+ $status_text = sprintf(
+ /* translators: %d: number of orders that could not be fixed. */
+ _n( '%d order could not be fixed. See the wc-analytics-order-import log for details.', '%d orders could not be fixed. See the wc-analytics-order-import log for details.', $state['unresolved'], 'woocommerce' ),
+ $state['unresolved']
+ );
+ } elseif ( self::REFUND_DOUBLE_COUNT_STATUS_COMPLETE === $state['status'] ) {
+ $button = __( 'Dismiss', 'woocommerce' );
+ $status_text = 0 === $state['fixed']
+ ? __( 'No affected orders were found.', 'woocommerce' )
+ : sprintf(
+ /* translators: 1: number of orders fixed, 2: date the fix finished. */
+ _n( 'Fixed %1$d order on %2$s.', 'Fixed %1$d orders on %2$s.', $state['fixed'], 'woocommerce' ),
+ $state['fixed'],
+ wp_date( wc_date_format(), $state['completed_at'] )
+ );
+ } elseif ( '' !== $state['status'] ) {
+ $status_text = __( 'The previous run did not finish.', 'woocommerce' );
+ }
+
+ $debug_tools[ self::REFUND_DOUBLE_COUNT_TOOL_ID ] = array(
+ 'name' => __( 'Fix double-counted refunds in Analytics', 'woocommerce' ),
+ 'button' => $button,
+ 'desc' => __( 'This tool finds orders where a partial refund followed by a full refund was counted twice in the Analytics returns, and re-imports them. Only orders refunded before WooCommerce 11.1 can be affected.', 'woocommerce' ),
+ 'status_text' => esc_html( $status_text ),
+ 'callback' => array( $this, 'run_refund_double_count_tool' ),
+ 'disabled' => $disabled,
+ 'requires_refresh' => true,
+ );
+
+ return $debug_tools;
+ }
+
+ /**
+ * Handle the double-counted refunds fix tool button.
+ *
+ * Starts a fix run, or dismisses the tool once a run finished with nothing left to fix.
+ * Also reachable through the system status tools REST API, so it re-checks the state.
+ *
+ * @internal
+ *
+ * @return string Result message.
+ * @throws \Exception On database error. The tools controller reports it to the merchant.
+ */
+ public function run_refund_double_count_tool() {
+ $state = self::get_refund_double_count_state();
+ $previous_status = '' === $state['status'] ? 'none' : $state['status'];
+
+ if ( self::is_refund_double_count_fix_running( $state ) ) {
+ self::record_refund_double_count_tool_run( 'refused_running', $previous_status );
+ return __( 'A fix is already in progress, please check back later.', 'woocommerce' );
+ }
+
+ if ( self::REFUND_DOUBLE_COUNT_STATUS_COMPLETE === $state['status'] && 0 === $state['unresolved'] ) {
+ self::update_refund_double_count_state( array( 'dismissed' => true ) );
+ self::record_refund_double_count_tool_run( 'dismissed', $previous_status );
+ return __( 'Tool dismissed.', 'woocommerce' );
+ }
+
+ if ( self::is_batch_pending_or_running( 'woocommerce_analytics_refund_fix_batch' ) ) {
+ self::record_refund_double_count_tool_run( 'refused_full_refund_fix', $previous_status );
+ return __( 'The full refund data fix is still running. Please try again once it has finished.', 'woocommerce' );
+ }
+
+ $run_id = wp_generate_uuid4();
+
+ self::update_refund_double_count_state(
+ array(
+ 'run_id' => $run_id,
+ 'status' => self::REFUND_DOUBLE_COUNT_STATUS_RUNNING,
+ 'max_order_id' => self::get_max_order_stats_id(),
+ 'fixed' => 0,
+ 'unresolved' => 0,
+ 'batches' => 0,
+ 'started_at' => time(),
+ 'completed_at' => 0,
+ )
+ );
+ self::schedule_batch( self::REFUND_DOUBLE_COUNT_FIX_HOOK, array( 0, $run_id ) );
+ RefundDoubleCountToolNotice::delete_if_not_applicable();
+ self::record_refund_double_count_tool_run( 'started', $previous_status );
+
+ return __( 'Checking for affected orders and fixing them in the background. Reload this page to see the progress.', 'woocommerce' );
+ }
+
+ /**
+ * Whether an Action Scheduler job for the given batch hook is currently
+ * pending or running.
+ *
+ * Detected live from Action Scheduler so a batch that dies never leaves a
+ * stuck "in progress" flag behind.
+ *
+ * @param string $hook Action Scheduler hook name.
+ * @return bool
+ */
+ private static function is_batch_pending_or_running( string $hook ): bool {
+ return ! empty(
+ as_get_scheduled_actions(
+ array(
+ 'hook' => $hook,
+ 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
+ 'per_page' => 1,
+ 'orderby' => 'none',
+ ),
+ 'ids'
+ )
+ );
+ }
+
+ /**
+ * Schedule a single batch job in the wc-admin-data group.
+ *
+ * @param string $hook Action Scheduler hook name.
+ * @param array $args Arguments passed to the batch.
+ * @param int $delay Seconds to wait before the batch may run.
+ * @return void
+ */
+ private static function schedule_batch( string $hook, array $args, int $delay = 0 ): void {
+ WC()->queue()->schedule_single(
+ time() + $delay,
+ $hook,
+ $args,
+ 'wc-admin-data'
+ );
+ }
+
+ /**
+ * Maximum number of affected parent orders re-imported per fix batch.
+ *
+ * @return int
+ */
+ private static function get_refund_double_count_batch_size(): int {
+ /**
+ * Filters the maximum number of parent orders re-imported per double-counted refunds fix batch.
+ *
+ * @since 11.2.0
+ *
+ * @param int $batch_size Maximum parent orders per batch.
+ */
+ return max( 1, (int) apply_filters( 'woocommerce_analytics_refund_double_count_batch_size', self::REFUND_DOUBLE_COUNT_BATCH_SIZE ) );
+ }
+
+ /**
+ * Number of order IDs checked per fix batch.
+ *
+ * @return int
+ */
+ private static function get_refund_double_count_range_size(): int {
+ /**
+ * Filters the number of order IDs checked per double-counted refunds fix batch.
+ *
+ * @since 11.2.0
+ *
+ * @param int $range_size Order IDs per batch.
+ */
+ return max( 1, (int) apply_filters( 'woocommerce_analytics_refund_double_count_range_size', self::REFUND_DOUBLE_COUNT_RANGE_SIZE ) );
+ }
+
+ /**
+ * Highest order_id present in the order stats table (0 when empty).
+ *
+ * @return int
+ * @throws \Exception On database error, so a failed query never becomes a run that
+ * covers no orders and reports that nothing was found.
+ */
+ private static function get_max_order_stats_id(): int {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $max_order_id = $wpdb->get_var( "SELECT MAX(order_id) FROM {$wpdb->prefix}wc_order_stats" );
+
+ // Read the error before logging: the database log handler runs its own query, which clears it.
+ $db_error = $wpdb->last_error;
+ if ( $db_error ) {
+ wc_get_logger()->error(
+ sprintf( 'Highest order stats ID query failed: %s', $db_error ),
+ array( 'source' => 'wc-analytics-order-import' )
+ );
+ throw new \Exception( $db_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ return intval( $max_order_id );
+ }
+
+ /**
+ * Get the parent orders whose refund rows add up to more than the order itself,
+ * the sign of a partial refund followed by a double-counted full refund (#66320).
+ *
+ * Refunds are found through the parent index of the active order table, so the
+ * query only reads the refunds of the parent orders that match the condition.
+ *
+ * @param string $parent_condition Prepared SQL condition on the parent stats row, aliased `o`.
+ * @param int $limit Maximum number of IDs to return; 0 for no limit.
+ * @return int[] Parent order IDs in ascending order.
+ * @throws \Exception On database error.
+ */
+ private static function get_refund_double_counted_parent_ids( string $parent_condition, int $limit = 0 ): array {
+ global $wpdb;
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ $id_column = 'id';
+ $parent_column = 'parent_order_id';
+ $type_column = 'type';
+ } else {
+ $orders_table = $wpdb->posts;
+ $id_column = 'ID';
+ $parent_column = 'post_parent';
+ $type_column = 'post_type';
+ }
+
+ $stats_table = $wpdb->prefix . 'wc_order_stats';
+ $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
+ // Half the smallest currency unit absorbs floating-point noise but still catches a double-counted
+ // smallest-unit refund. Capped at 8 decimals, beyond which DOUBLE sums get too noisy to compare.
+ $tolerance_sql = sprintf( '%.10F', 0.5 / ( 10 ** min( wc_get_price_decimals(), 8 ) ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table and column names are hardcoded; the condition is prepared by the caller.
+ $parent_ids = $wpdb->get_col(
+ "SELECT o.order_id
+ FROM {$stats_table} AS o
+ INNER JOIN {$orders_table} AS refund ON refund.{$parent_column} = o.order_id AND refund.{$type_column} = 'shop_order_refund'
+ INNER JOIN {$stats_table} AS r ON r.order_id = refund.{$id_column}
+ WHERE {$parent_condition} AND o.parent_id = 0
+ GROUP BY o.order_id
+ HAVING COUNT(*) > 1
+ AND ABS( SUM( r.net_total + r.tax_total + r.shipping_total ) ) > MAX( o.net_total + o.tax_total + o.shipping_total ) + {$tolerance_sql}
+ ORDER BY o.order_id ASC
+ {$limit_sql}"
+ );
+ // phpcs:enable
+
+ // Read the error before logging: the database log handler runs its own query, which clears it.
+ $db_error = $wpdb->last_error;
+ if ( $db_error ) {
+ wc_get_logger()->error(
+ sprintf( 'Double-counted refunds query failed: %s', $db_error ),
+ array( 'source' => 'wc-analytics-order-import' )
);
+ throw new \Exception( $db_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
+
+ return array_map( 'intval', $parent_ids );
+ }
+
+ /**
+ * Process one batch of the double-counted refunds fix.
+ *
+ * Re-imports the affected parent orders in the next range of order IDs, checks they
+ * were repaired, and schedules the next batch until the highest order ID recorded when
+ * the run started is covered. Batches of an older or cancelled run do nothing.
+ *
+ * @internal
+ *
+ * @param int $cursor Exclusive lower bound on the parent order ID.
+ * @param string $run_id ID of the run that scheduled the batch.
+ * @return void
+ * @throws \Exception On database error so Action Scheduler marks the action as failed.
+ */
+ public function process_refund_double_count_fix_batch( $cursor = 0, $run_id = '' ): void {
+ global $wpdb;
+
+ $cursor = absint( $cursor );
+ $run_id = is_string( $run_id ) ? $run_id : '';
+
+ if ( ! self::is_current_refund_double_count_run( $run_id ) ) {
+ return;
+ }
+
+ $max_order_id = self::get_refund_double_count_state()['max_order_id'];
+ $batch_size = self::get_refund_double_count_batch_size();
+ $range_end = min( $cursor + self::get_refund_double_count_range_size(), $max_order_id );
+
+ $parent_ids = self::get_refund_double_counted_parent_ids(
+ $wpdb->prepare( 'o.order_id > %d AND o.order_id <= %d', $cursor, $range_end ),
+ $batch_size
+ );
+
+ // One order that throws must not abandon the run: the batch would never record its
+ // counters or schedule the next range, leaving the tool stuck on "Checking and fixing".
+ $failed_ids = array();
+ foreach ( $parent_ids as $parent_id ) {
+ try {
+ OrdersScheduler::import( $parent_id );
+ } catch ( \Throwable $throwable ) {
+ $failed_ids[ $parent_id ] = $throwable->getMessage();
+ }
+ }
+
+ // OrdersScheduler::import() skips some orders silently, so check the result.
+ $unfixed_ids = array();
+ if ( $parent_ids ) {
+ $placeholders = implode( ', ', array_fill( 0, count( $parent_ids ), '%d' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Only %d placeholders are interpolated.
+ $unfixed_ids = self::get_refund_double_counted_parent_ids( $wpdb->prepare( "o.order_id IN ( {$placeholders} )", $parent_ids ) );
+ }
+
+ $unfixed_ids = array_values( array_unique( array_merge( $unfixed_ids, array_keys( $failed_ids ) ) ) );
+
+ foreach ( $unfixed_ids as $unfixed_id ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'Could not fix the double-counted refunds of order %1$d: %2$s',
+ $unfixed_id,
+ $failed_ids[ $unfixed_id ] ?? 'the order still records more refunds than its own total after re-importing it'
+ ),
+ array( 'source' => 'wc-analytics-order-import' )
+ );
+ }
+
+ // A newer run or a cancellation may have happened while the orders were re-imported. Check and
+ // write the same fresh copy of the state, so this batch never writes into another run's state.
+ $state = self::get_fresh_refund_double_count_state();
+ if ( ! self::is_running_state_of( $state, $run_id ) ) {
+ return;
+ }
+
+ $next_cursor = count( $parent_ids ) >= $batch_size ? (int) end( $parent_ids ) : $range_end;
+ $is_done = $next_cursor >= $max_order_id;
+ $changes = array(
+ 'fixed' => $state['fixed'] + count( $parent_ids ) - count( $unfixed_ids ),
+ 'unresolved' => $state['unresolved'] + count( $unfixed_ids ),
+ 'batches' => $state['batches'] + 1,
+ );
+
+ if ( $is_done ) {
+ $changes['status'] = self::REFUND_DOUBLE_COUNT_STATUS_COMPLETE;
+ $changes['completed_at'] = time();
+ }
+
+ update_option( self::REFUND_DOUBLE_COUNT_OPTION, array_merge( $state, $changes ), false );
+
+ if ( $is_done ) {
+ self::record_refund_double_count_fix_finished( array_merge( $state, $changes ), self::REFUND_DOUBLE_COUNT_STATUS_COMPLETE );
+ } else {
+ self::schedule_batch( self::REFUND_DOUBLE_COUNT_FIX_HOOK, array( $next_cursor, $run_id ), 5 );
+ }
+ }
+
+ /**
+ * Cancel a running double-counted refunds fix when a full historical import starts,
+ * since that import re-imports every affected order anyway.
+ *
+ * @internal
+ *
+ * @param int|bool $days Number of days to import; anything that is not an integer covers the full history.
+ * @param bool $skip_existing Whether the import skips already imported orders.
+ * @return void
+ */
+ public function maybe_cancel_refund_double_count_fix_on_regenerate( $days, $skip_existing ): void {
+ // The Import historical data UI omits `days` for "All", so REST passes null rather than false.
+ // OrdersScheduler reads the same value with is_int(), so match it.
+ if ( is_int( $days ) || $skip_existing ) {
+ return;
+ }
+
+ $state = self::get_fresh_refund_double_count_state();
+ if ( self::REFUND_DOUBLE_COUNT_STATUS_RUNNING !== $state['status'] ) {
+ return;
+ }
+
+ self::update_refund_double_count_state( array( 'status' => self::REFUND_DOUBLE_COUNT_STATUS_CANCELLED ) );
+ as_unschedule_all_actions( self::REFUND_DOUBLE_COUNT_FIX_HOOK );
+ self::record_refund_double_count_fix_finished( $state, self::REFUND_DOUBLE_COUNT_STATUS_CANCELLED );
+ }
+
+ /**
+ * Record a click on the fix tool's button and what it did.
+ *
+ * @param string $outcome One of started, dismissed, refused_running or refused_full_refund_fix.
+ * @param string $previous_status Run status before the click, or 'none'.
+ * @return void
+ */
+ private static function record_refund_double_count_tool_run( string $outcome, string $previous_status ): void {
+ self::record_refund_double_count_event(
+ 'tool_run',
+ array(
+ 'outcome' => $outcome,
+ 'previous_status' => $previous_status,
+ )
+ );
+ }
+
+ /**
+ * Record the end of a fix run.
+ *
+ * @param array $state Tool state at the end of the run.
+ * @param string $result complete or cancelled.
+ * @return void
+ */
+ private static function record_refund_double_count_fix_finished( array $state, string $result ): void {
+ self::record_refund_double_count_event(
+ 'fix_finished',
+ array(
+ 'result' => $result,
+ 'fixed_count' => $state['fixed'],
+ 'unresolved_count' => $state['unresolved'],
+ 'batches' => $state['batches'],
+ 'duration_seconds' => $state['started_at'] > 0 ? max( 0, time() - $state['started_at'] ) : 0,
+ 'max_order_id' => $state['max_order_id'],
+ 'db_engine' => self::get_db_engine(),
+ )
+ );
+ }
+
+ /**
+ * Send one of the fix tool's Tracks events. Telemetry failures never stop the fix.
+ *
+ * @param string $name Event name after the analytics_refund_double_count_ prefix.
+ * @param array $properties Event properties.
+ * @return void
+ */
+ private static function record_refund_double_count_event( string $name, array $properties ): void {
+ $properties['order_storage'] = OrderUtil::custom_orders_table_usage_is_enabled() ? 'hpos' : 'cpt';
+ $event_name = 'analytics_refund_double_count_' . $name;
+
+ try {
+ if ( function_exists( 'wc_admin_record_tracks_event' ) ) {
+ wc_admin_record_tracks_event( $event_name, $properties );
+ }
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ }
+ }
+
+ /**
+ * Database engine and major.minor version, e.g. mariadb-10.11 or mysql-8.0.
+ *
+ * @return string
+ */
+ private static function get_db_engine(): string {
+ global $wpdb;
+
+ $server_info = (string) $wpdb->db_server_info();
+
+ if ( false !== stripos( $server_info, 'mariadb' ) ) {
+ // Older MariaDB servers report a "5.5.5-" prefix before the real version.
+ return preg_match( '/(\d+\.\d+)\.\d+-MariaDB/i', $server_info, $matches ) ? 'mariadb-' . $matches[1] : 'mariadb';
+ }
+
+ return preg_match( '/^(\d+\.\d+)/', $server_info, $matches ) ? 'mysql-' . $matches[1] : 'unknown';
}
/**
@@ -388,17 +992,7 @@ class Analytics {
);
}
- $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'
- )
- );
+ $fix_in_progress = self::is_batch_pending_or_running( 'woocommerce_analytics_refund_fix_batch' );
wp_send_json_success(
array(
diff --git a/plugins/woocommerce/src/Internal/Admin/Events.php b/plugins/woocommerce/src/Internal/Admin/Events.php
index 1217d773e18..a209f56eaa3 100644
--- a/plugins/woocommerce/src/Internal/Admin/Events.php
+++ b/plugins/woocommerce/src/Internal/Admin/Events.php
@@ -31,6 +31,7 @@ use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsRemindMeLater;
use Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile;
use Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore;
use Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts;
+use Automattic\WooCommerce\Internal\Admin\Notes\RefundDoubleCountToolNotice;
use Automattic\WooCommerce\Internal\Admin\Notes\ScheduledUpdatesPromotion;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
@@ -88,6 +89,7 @@ class Events {
PerformanceOnMobile::class,
PersonalizeStore::class,
RealTimeOrderAlerts::class,
+ RefundDoubleCountToolNotice::class,
ScheduledUpdatesPromotion::class,
TrackingOptIn::class,
WooCommercePayments::class,
@@ -202,6 +204,7 @@ class Events {
PaymentsRemindMeLater::delete_if_not_applicable();
PaymentsMoreInfoNeeded::delete_if_not_applicable();
FullRefundFixDataToolNotice::delete_if_not_applicable();
+ RefundDoubleCountToolNotice::delete_if_not_applicable();
}
/**
diff --git a/plugins/woocommerce/src/Internal/Admin/Notes/RefundDoubleCountToolNotice.php b/plugins/woocommerce/src/Internal/Admin/Notes/RefundDoubleCountToolNotice.php
new file mode 100644
index 00000000000..15688e50b54
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Notes/RefundDoubleCountToolNotice.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ * WooCommerce Admin Double-Counted Refunds Tool Notice Provider.
+ *
+ * Adds a note to the merchant's inbox pointing to the double-counted refunds fix
+ * tool on the WooCommerce > Status > Tools page.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\Notes;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\NoteTraits;
+use Automattic\WooCommerce\Internal\Admin\Analytics;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * RefundDoubleCountToolNotice
+ *
+ * @internal
+ * @since 11.2.0
+ */
+class RefundDoubleCountToolNotice {
+ /**
+ * Note traits.
+ */
+ use NoteTraits;
+
+ /**
+ * Name of the note for use in the database.
+ */
+ const NOTE_NAME = 'wc-admin-refund-double-count-tool';
+
+ /**
+ * Should this note exist?
+ *
+ * The note only nudges merchants who have not run the tool yet.
+ *
+ * @return bool
+ */
+ public static function is_applicable() {
+ if ( ! FeaturesUtil::feature_is_enabled( 'analytics' ) ) {
+ return false;
+ }
+
+ return Analytics::is_refund_double_count_tool_applicable()
+ && '' === Analytics::get_refund_double_count_state()['status'];
+ }
+
+ /**
+ * Get the note.
+ *
+ * @return Note|null
+ */
+ public static function get_note() {
+ if ( ! self::is_applicable() ) {
+ return null;
+ }
+
+ $note = new Note();
+
+ $note->set_title( __( 'Check your refunds in Analytics', 'woocommerce' ) );
+ $note->set_content(
+ __( 'Orders that received a partial refund followed by a full refund before WooCommerce 11.1 may show higher returns in your Analytics reports than they should. Use the double-counted refunds tool on the Status page to check for affected orders and fix them.', 'woocommerce' )
+ );
+ $note->set_content_data( (object) array() );
+ $note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
+ $note->set_name( self::NOTE_NAME );
+ $note->set_source( 'woocommerce-admin' );
+
+ $note->add_action(
+ 'refund-double-count-tool_view',
+ __( 'Check refunds', 'woocommerce' ),
+ admin_url( 'admin.php?page=wc-status&tab=tools#tool_' . Analytics::REFUND_DOUBLE_COUNT_TOOL_ID ),
+ Note::E_WC_ADMIN_NOTE_UNACTIONED,
+ true
+ );
+
+ return $note;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/AnalyticsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/AnalyticsTest.php
new file mode 100644
index 00000000000..35f0b07be24
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/AnalyticsTest.php
@@ -0,0 +1,828 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Admin\Analytics;
+use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Helper_Order;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the double-counted refunds fix tool in the Analytics class.
+ *
+ * Orders are real orders, so the detection query runs against the active order
+ * storage; the suite runs with HPOS both enabled and disabled.
+ */
+class AnalyticsTest extends WC_Unit_Test_Case {
+
+ use LoggerSpyTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var Analytics
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = Analytics::get_instance();
+ update_option( 'woocommerce_analytics_uses_old_full_refund_data', 'no' );
+ update_option( \WC_Install::INITIAL_INSTALLED_VERSION, '10.5.0' );
+
+ update_option( 'woocommerce_allow_tracking', 'yes' );
+ $this->clear_tracks_events();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ $this->clear_tracks_events();
+ // Action Scheduler actions are not covered by the options rollback.
+ as_unschedule_all_actions( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK );
+ as_unschedule_all_actions( 'woocommerce_analytics_refund_fix_batch' );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * Create a $50 order, refund it with the given amounts, and import it into the order stats.
+ *
+ * @param float[] $refund_amounts Refund amounts, in the order they are created.
+ * @return WC_Order
+ */
+ private function create_refunded_order( array $refund_amounts ): WC_Order {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ foreach ( $refund_amounts as $amount ) {
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => $amount,
+ )
+ );
+ }
+
+ OrdersScheduler::import( $order->get_id() );
+
+ return wc_get_order( $order->get_id() );
+ }
+
+ /**
+ * Rewrite the order's latest refund stats row to record the whole order total,
+ * ignoring earlier refunds, as the bug fixed in #66320 did.
+ *
+ * @param WC_Order $order Order with at least two refunds.
+ */
+ private function double_count_latest_refund( WC_Order $order ): void {
+ global $wpdb;
+
+ $stats_table = $wpdb->prefix . 'wc_order_stats';
+ $latest_refund_id = max( array_map( fn( $refund ) => $refund->get_id(), $order->get_refunds() ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+ $parent = $wpdb->get_row( $wpdb->prepare( "SELECT net_total, tax_total, shipping_total FROM {$stats_table} WHERE order_id = %d", $order->get_id() ) );
+ $wpdb->update(
+ $stats_table,
+ array(
+ 'net_total' => -1 * $parent->net_total,
+ 'tax_total' => -1 * $parent->tax_total,
+ 'shipping_total' => -1 * $parent->shipping_total,
+ ),
+ array( 'order_id' => $latest_refund_id )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Sum of the refund stats rows of an order.
+ *
+ * @param WC_Order $order Order.
+ * @return float
+ */
+ private function get_refunds_total( WC_Order $order ): float {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ return (float) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT SUM( net_total + tax_total + shipping_total ) FROM {$wpdb->prefix}wc_order_stats WHERE parent_id = %d",
+ $order->get_id()
+ )
+ );
+ }
+
+ /**
+ * Highest order ID in the order stats table.
+ *
+ * @return int
+ */
+ private function get_max_order_stats_id(): int {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ return (int) $wpdb->get_var( "SELECT MAX(order_id) FROM {$wpdb->prefix}wc_order_stats" );
+ }
+
+ /**
+ * Start a fix run through the tool and run its batches until none is left.
+ *
+ * @return int Number of batches run.
+ */
+ private function run_fix(): int {
+ $this->sut->run_refund_double_count_tool();
+
+ $batches = 0;
+ while ( $batches < 50 ) {
+ $actions = as_get_scheduled_actions(
+ array(
+ 'hook' => Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK,
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ 'per_page' => 1,
+ )
+ );
+ if ( empty( $actions ) ) {
+ break;
+ }
+
+ $args = reset( $actions )->get_args();
+ as_unschedule_action( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK, $args, 'wc-admin-data' );
+ $this->sut->process_refund_double_count_fix_batch( ...$args );
+ ++$batches;
+ }
+
+ return $batches;
+ }
+
+ /**
+ * Set the fix batch and range sizes.
+ *
+ * @param int $batch_size Parent orders re-imported per batch.
+ * @param int $range_size Order IDs checked per batch.
+ */
+ private function set_batch_sizes( int $batch_size, int $range_size ): void {
+ add_filter(
+ 'woocommerce_analytics_refund_double_count_batch_size',
+ function () use ( $batch_size ) {
+ return $batch_size;
+ }
+ );
+ add_filter(
+ 'woocommerce_analytics_refund_double_count_range_size',
+ function () use ( $range_size ) {
+ return $range_size;
+ }
+ );
+ }
+
+ /**
+ * Properties of the recorded fix tool events with the given name, oldest first.
+ *
+ * @param string $event_name Event name without the wcadmin_ prefix.
+ * @return array[]
+ */
+ private function get_events( string $event_name ): array {
+ return array_map(
+ fn( $event ) => get_object_vars( $event ),
+ $this->get_tracks_events( 'wcadmin_' . $event_name )
+ );
+ }
+
+ /**
+ * Get the tool registration, or null when the tool is not registered.
+ *
+ * @return array|null
+ */
+ private function get_tool(): ?array {
+ $tools = $this->sut->register_refund_double_count_tool( array() );
+
+ return $tools[ Analytics::REFUND_DOUBLE_COUNT_TOOL_ID ] ?? null;
+ }
+
+ /**
+ * @testdox Fixes only orders whose refunds add up to more than the order, leaving other refund patterns alone.
+ */
+ public function test_fix_repairs_only_double_counted_orders(): void {
+ $double_counted = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $double_counted );
+ $one_cent_double_counted = $this->create_refunded_order( array( 0.01, 49.99 ) );
+ $this->double_count_latest_refund( $one_cent_double_counted );
+ $correct_partial_then_full = $this->create_refunded_order( array( 20, 30 ) );
+ $single_full = $this->create_refunded_order( array( 50 ) );
+ $partials_under_total = $this->create_refunded_order( array( 10, 10 ) );
+
+ $this->assertEqualsWithDelta( -70.0, $this->get_refunds_total( $double_counted ), 0.001, 'The fixture should over-refund the order' );
+
+ $this->run_fix();
+
+ $state = Analytics::get_refund_double_count_state();
+ $this->assertSame( 'complete', $state['status'] );
+ $this->assertSame( 2, $state['fixed'], 'Only the two double-counted orders should be fixed' );
+ $this->assertSame( 0, $state['unresolved'] );
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $double_counted ), 0.001, 'The re-import should correct the refund rows' );
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $one_cent_double_counted ), 0.001, 'A double-counted one-cent refund should be fixed too' );
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $correct_partial_then_full ), 0.001 );
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $single_full ), 0.001 );
+ $this->assertEqualsWithDelta( -20.0, $this->get_refunds_total( $partials_under_total ), 0.001 );
+
+ $finished = $this->get_events( 'analytics_refund_double_count_fix_finished' );
+ $this->assertCount( 1, $finished, 'The end of the run should be recorded once' );
+ $this->assertSame( 'complete', $finished[0]['result'] );
+ $this->assertSame( 2, $finished[0]['fixed_count'] );
+ $this->assertSame( 0, $finished[0]['unresolved_count'] );
+ $this->assertSame( 1, $finished[0]['batches'] );
+ $this->assertSame( $state['max_order_id'], $finished[0]['max_order_id'] );
+ $this->assertGreaterThanOrEqual( 0, $finished[0]['duration_seconds'] );
+ $this->assertMatchesRegularExpression( '/^(mysql|mariadb)(-\d+\.\d+)?$|^unknown$/', $finished[0]['db_engine'] );
+ $this->assertContains( $finished[0]['order_storage'], array( 'hpos', 'cpt' ) );
+ }
+
+ /**
+ * @testdox Does not flag refunds that match the order total apart from floating-point noise, even with many price decimals.
+ */
+ public function test_fix_ignores_floating_point_noise_with_many_price_decimals(): void {
+ global $wpdb;
+
+ add_filter(
+ 'wc_get_price_decimals',
+ function () {
+ return 8;
+ }
+ );
+ $order = $this->create_refunded_order( array( 0.1, 49.7, 0.2 ) );
+
+ // Summed in refund ID order, -0.1 + -49.7 + -0.2 is -50.00000000000001 in floating point.
+ $amounts = array( -0.1, -49.7, -0.2 );
+ $ids = array_map( fn( $refund ) => $refund->get_id(), $order->get_refunds() );
+ sort( $ids );
+ foreach ( $ids as $index => $refund_id ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->update(
+ $wpdb->prefix . 'wc_order_stats',
+ array(
+ 'net_total' => $amounts[ $index ],
+ 'tax_total' => 0,
+ 'shipping_total' => 0,
+ ),
+ array( 'order_id' => $refund_id )
+ );
+ }
+
+ $this->run_fix();
+
+ $state = Analytics::get_refund_double_count_state();
+ $this->assertSame( 0, $state['fixed'], 'A correctly refunded order should not be re-imported' );
+ $this->assertSame( 0, $state['unresolved'] );
+ }
+
+ /**
+ * @testdox Detects a double-counted refund of the smallest currency unit on a store with six price decimals.
+ */
+ public function test_fix_detects_a_smallest_unit_double_count_with_six_price_decimals(): void {
+ global $wpdb;
+
+ add_filter(
+ 'wc_get_price_decimals',
+ function () {
+ return 6;
+ }
+ );
+ $order = $this->create_refunded_order( array( 0.01, 49.99 ) );
+
+ // A partial refund of 0.000001 followed by a full refund that recorded the whole $50 order total.
+ $amounts = array( -0.000001, -50 );
+ $ids = array_map( fn( $refund ) => $refund->get_id(), $order->get_refunds() );
+ sort( $ids );
+ foreach ( $ids as $index => $refund_id ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->update(
+ $wpdb->prefix . 'wc_order_stats',
+ array(
+ 'net_total' => $amounts[ $index ],
+ 'tax_total' => 0,
+ 'shipping_total' => 0,
+ ),
+ array( 'order_id' => $refund_id )
+ );
+ }
+
+ $this->run_fix();
+
+ $this->assertSame( 1, Analytics::get_refund_double_count_state()['fixed'] );
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $order ), 0.000001 );
+ }
+
+ /**
+ * @testdox Pages through full batches and order ID ranges until the highest order ID is covered.
+ */
+ public function test_fix_pages_through_batches_and_ranges(): void {
+ $first = $this->create_refunded_order( array( 20, 30 ) );
+ $second = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $first );
+ $this->double_count_latest_refund( $second );
+ $this->set_batch_sizes( 1, 1000000 );
+
+ $batches = $this->run_fix();
+
+ $this->assertSame( 3, $batches, 'Two full batches of one order, then a final batch that finds nothing' );
+ $this->assertSame( 2, Analytics::get_refund_double_count_state()['fixed'] );
+ $this->assertSame( 3, $this->get_events( 'analytics_refund_double_count_fix_finished' )[0]['batches'] );
+
+ delete_option( Analytics::REFUND_DOUBLE_COUNT_OPTION );
+ $this->double_count_latest_refund( $first );
+ $this->set_batch_sizes( 100, (int) ceil( $this->get_max_order_stats_id() / 4 ) );
+
+ $batches = $this->run_fix();
+
+ $this->assertSame( 4, $batches, 'Each quarter of the order ID space should take one batch' );
+ $this->assertSame( 'complete', Analytics::get_refund_double_count_state()['status'] );
+ $this->assertSame( 1, Analytics::get_refund_double_count_state()['fixed'] );
+ }
+
+ /**
+ * @testdox Counts an order the re-import could not repair as unresolved instead of fixed.
+ */
+ public function test_fix_counts_orders_it_could_not_repair(): void {
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $order );
+ add_filter( 'woocommerce_analytics_is_test_order', '__return_true' );
+
+ $this->run_fix();
+
+ $state = Analytics::get_refund_double_count_state();
+ $this->assertSame( 'complete', $state['status'] );
+ $this->assertSame( 0, $state['fixed'] );
+ $this->assertSame( 1, $state['unresolved'] );
+ $this->assertSame( 'Check and fix', $this->get_tool()['button'], 'The tool should offer another run instead of Dismiss' );
+ $this->assertSame( 1, $this->get_events( 'analytics_refund_double_count_fix_finished' )[0]['unresolved_count'] );
+ }
+
+ /**
+ * An order that throws used to abandon the whole batch, so the run never recorded its
+ * counters or scheduled the next range and the tool stayed on "Checking and fixing".
+ *
+ * @testdox Keeps going when re-importing one order throws, and counts it as unresolved.
+ */
+ public function test_fix_continues_when_importing_an_order_throws(): void {
+ $throwing = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $throwing );
+ $healthy = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $healthy );
+
+ $throwing_id = $throwing->get_id();
+ $explode = function ( $classname, $order_type, $order_id ) use ( $throwing_id ) {
+ if ( (int) $order_id === $throwing_id ) {
+ throw new \RuntimeException( 'Order storage is unavailable' );
+ }
+
+ return $classname;
+ };
+ add_filter( 'woocommerce_order_class', $explode, 10, 3 );
+
+ try {
+ $batches = $this->run_fix();
+ } finally {
+ remove_filter( 'woocommerce_order_class', $explode, 10 );
+ }
+
+ $this->assertSame( 1, $batches, 'The batch should finish rather than abandon the run' );
+
+ $state = Analytics::get_refund_double_count_state();
+ $this->assertSame( 'complete', $state['status'] );
+ $this->assertSame( 1, $state['fixed'] );
+ $this->assertSame( 1, $state['unresolved'] );
+
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $healthy ), 0.001, 'The order after the failing one should still be fixed' );
+ $this->assertLogged(
+ 'warning',
+ sprintf( 'Could not fix the double-counted refunds of order %d: Order storage is unavailable', $throwing_id ),
+ array( 'source' => 'wc-analytics-order-import' )
+ );
+ }
+
+ /**
+ * A throw after the row was already repaired is only caught by merging failed imports into
+ * the unresolved set: the verification query on its own sees a healthy order and would
+ * count it as fixed.
+ *
+ * @testdox Counts an order as unresolved when its import throws after repairing the row.
+ */
+ public function test_fix_counts_an_order_unresolved_when_its_import_throws_after_repairing_it(): void {
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $order_id = $order->get_id();
+ $this->double_count_latest_refund( $order );
+
+ $explode = function ( $imported_id ) use ( $order_id ) {
+ if ( (int) $imported_id === $order_id ) {
+ throw new \RuntimeException( 'The order was repaired but the import did not finish' );
+ }
+ };
+ add_action( 'woocommerce_order_scheduler_after_import_order', $explode );
+
+ try {
+ $this->run_fix();
+ } finally {
+ remove_action( 'woocommerce_order_scheduler_after_import_order', $explode );
+ }
+
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $order ), 0.001, 'The row is repaired before the throw' );
+
+ $state = Analytics::get_refund_double_count_state();
+ $this->assertSame( 'complete', $state['status'] );
+ $this->assertSame( 0, $state['fixed'], 'An order whose import threw is not a fixed order' );
+ $this->assertSame( 1, $state['unresolved'] );
+ $this->assertLogged(
+ 'warning',
+ sprintf( 'Could not fix the double-counted refunds of order %d: The order was repaired but the import did not finish', $order_id ),
+ array( 'source' => 'wc-analytics-order-import' )
+ );
+ }
+
+ /**
+ * @testdox A batch from an older run does nothing.
+ */
+ public function test_batch_of_an_older_run_does_nothing(): void {
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $order );
+ $this->sut->run_refund_double_count_tool();
+ as_unschedule_all_actions( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK );
+
+ $this->sut->process_refund_double_count_fix_batch( 0, 'an-older-run' );
+
+ $this->assertEqualsWithDelta( -70.0, $this->get_refunds_total( $order ), 0.001, 'The stale batch should not re-import anything' );
+ $this->assertSame( 0, Analytics::get_refund_double_count_state()['fixed'] );
+ $this->assertFalse( as_has_scheduled_action( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK ) );
+ }
+
+ /**
+ * @testdox A batch does not overwrite a cancellation that another request saved while it was re-importing orders.
+ */
+ public function test_batch_respects_a_cancellation_saved_by_another_request(): void {
+ global $wpdb;
+
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $order );
+ $this->sut->run_refund_double_count_tool();
+ $run_id = Analytics::get_refund_double_count_state()['run_id'];
+ as_unschedule_all_actions( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK );
+
+ // Write straight to the table, as another request would, leaving this request's option cache stale.
+ $cancelled = array_merge( Analytics::get_refund_double_count_state(), array( 'status' => 'cancelled' ) );
+ add_action(
+ 'woocommerce_analytics_update_order_stats',
+ function () use ( $wpdb, $cancelled ) {
+ $wpdb->update( $wpdb->options, array( 'option_value' => maybe_serialize( $cancelled ) ), array( 'option_name' => Analytics::REFUND_DOUBLE_COUNT_OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ }
+ );
+
+ $this->sut->process_refund_double_count_fix_batch( 0, $run_id );
+
+ wp_cache_delete( Analytics::REFUND_DOUBLE_COUNT_OPTION, 'options' );
+ $this->assertSame( 'cancelled', Analytics::get_refund_double_count_state()['status'] );
+ $this->assertFalse( as_has_scheduled_action( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK ), 'The cancelled run should not schedule another batch' );
+ }
+
+ /**
+ * @testdox A batch of an older run does not write into a newer run started while it was re-importing orders.
+ */
+ public function test_batch_does_not_write_into_a_newer_run(): void {
+ global $wpdb;
+
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $order );
+ $this->sut->run_refund_double_count_tool();
+ $old_run_id = Analytics::get_refund_double_count_state()['run_id'];
+ as_unschedule_all_actions( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK );
+
+ $newer_run = array_merge(
+ Analytics::get_refund_double_count_state(),
+ array(
+ 'run_id' => 'newer-run',
+ 'fixed' => 0,
+ )
+ );
+ add_action(
+ 'woocommerce_analytics_update_order_stats',
+ function () use ( $wpdb, $newer_run ) {
+ $wpdb->update( $wpdb->options, array( 'option_value' => maybe_serialize( $newer_run ) ), array( 'option_name' => Analytics::REFUND_DOUBLE_COUNT_OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ }
+ );
+
+ $this->sut->process_refund_double_count_fix_batch( 0, $old_run_id );
+
+ wp_cache_delete( Analytics::REFUND_DOUBLE_COUNT_OPTION, 'options' );
+ $state = Analytics::get_refund_double_count_state();
+ $this->assertSame( 'newer-run', $state['run_id'] );
+ $this->assertSame( 'running', $state['status'], 'The older batch must not complete the newer run' );
+ $this->assertSame( 0, $state['fixed'], 'The older batch must not add to the newer run counters' );
+ }
+
+ /**
+ * @testdox A batch sees a run that another request created after this request cached the option as missing.
+ */
+ public function test_batch_sees_a_run_created_after_the_option_was_cached_as_missing(): void {
+ global $wpdb;
+
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $order );
+ delete_option( Analytics::REFUND_DOUBLE_COUNT_OPTION );
+ get_option( Analytics::REFUND_DOUBLE_COUNT_OPTION );
+
+ // Create the run straight in the table, as another request would.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->insert(
+ $wpdb->options,
+ array(
+ 'option_name' => Analytics::REFUND_DOUBLE_COUNT_OPTION,
+ 'option_value' => maybe_serialize(
+ array(
+ 'run_id' => 'run',
+ 'status' => 'running',
+ 'max_order_id' => $this->get_max_order_stats_id(),
+ )
+ ),
+ 'autoload' => 'no',
+ )
+ );
+
+ $this->sut->process_refund_double_count_fix_batch( 0, 'run' );
+
+ $this->assertEqualsWithDelta( -50.0, $this->get_refunds_total( $order ), 0.001, 'The batch should fix the order instead of treating the run as gone' );
+ }
+
+ /**
+ * A full import reaches the hook with null rather than false when it comes from the
+ * Import historical data UI, which omits `days` for "All".
+ *
+ * @testdox A full historical import cancels a running fix; windowed or skip-existing imports do not.
+ * @testWith [false, false, "cancelled"]
+ * [null, false, "cancelled"]
+ * [30, false, "running"]
+ * [0, false, "running"]
+ * [false, true, "running"]
+ *
+ * @param int|bool|null $days Days to import; anything that is not an integer means the full history.
+ * @param bool $skip_existing Whether the import skips existing orders.
+ * @param string $expected Expected run status.
+ */
+ public function test_regenerate_cancels_running_fix_only_for_full_reimport( $days, bool $skip_existing, string $expected ): void {
+ $this->sut->run_refund_double_count_tool();
+
+ $this->sut->maybe_cancel_refund_double_count_fix_on_regenerate( $days, $skip_existing );
+
+ $this->assertSame( $expected, Analytics::get_refund_double_count_state()['status'] );
+ $this->assertSame( 'running' === $expected, as_has_scheduled_action( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK ) );
+
+ $finished = $this->get_events( 'analytics_refund_double_count_fix_finished' );
+ if ( 'cancelled' === $expected ) {
+ $this->assertCount( 1, $finished );
+ $this->assertSame( 'cancelled', $finished[0]['result'] );
+ $this->assertSame( 0, $finished[0]['batches'] );
+ } else {
+ $this->assertCount( 0, $finished, 'A run that keeps going has not finished' );
+ }
+ }
+
+ /**
+ * @testdox Registers the tool only for stores installed before 11.1.0.
+ * @testWith [null, true]
+ * ["", true]
+ * ["10.2.0", true]
+ * ["11.1.0-dev", true]
+ * ["11.1.0", false]
+ * ["11.2.0", false]
+ *
+ * @param string|null $initial_version Initial installed version, or null when not recorded.
+ * @param bool $expected Whether the tool is registered.
+ */
+ public function test_tool_visibility_depends_on_initial_installed_version( ?string $initial_version, bool $expected ): void {
+ if ( null === $initial_version ) {
+ delete_option( \WC_Install::INITIAL_INSTALLED_VERSION );
+ } else {
+ update_option( \WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+ }
+
+ $this->assertSame( $expected, null !== $this->get_tool() );
+ }
+
+ /**
+ * @testdox Does not register the tool for stores that still use the old full refund data.
+ */
+ public function test_tool_is_hidden_for_old_refund_data_stores(): void {
+ update_option( 'woocommerce_analytics_uses_old_full_refund_data', 'yes' );
+
+ $this->assertNull( $this->get_tool() );
+ }
+
+ /**
+ * @testdox Shows the button and status that match the run state.
+ * @dataProvider provide_tool_states
+ *
+ * @param array $state Stored tool state.
+ * @param bool $pending_action Whether a fix batch is pending.
+ * @param string $expected_button Expected button label.
+ * @param bool $expected_off Expected disabled flag.
+ * @param string $expected_status Expected status text fragment.
+ */
+ public function test_tool_reflects_run_state( array $state, bool $pending_action, string $expected_button, bool $expected_off, string $expected_status ): void {
+ update_option( Analytics::REFUND_DOUBLE_COUNT_OPTION, $state );
+ if ( $pending_action ) {
+ as_schedule_single_action( time() + 60, Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK, array( 0, 'run' ), 'wc-admin-data' );
+ }
+
+ $tool = $this->get_tool();
+
+ $this->assertSame( $expected_button, $tool['button'] );
+ $this->assertSame( $expected_off, $tool['disabled'] );
+ $this->assertStringContainsString( $expected_status, $tool['status_text'] );
+ }
+
+ /**
+ * Tool states for test_tool_reflects_run_state.
+ *
+ * @return array
+ */
+ public function provide_tool_states(): array {
+ return array(
+ 'never run' => array( array(), false, 'Check and fix', false, '' ),
+ 'running' => array(
+ array(
+ 'run_id' => 'run',
+ 'status' => 'running',
+ 'fixed' => 3,
+ ),
+ true,
+ 'Checking and fixing…',
+ true,
+ '3 orders fixed so far.',
+ ),
+ 'died mid-run' => array(
+ array(
+ 'run_id' => 'run',
+ 'status' => 'running',
+ ),
+ false,
+ 'Check and fix',
+ false,
+ 'The previous run did not finish.',
+ ),
+ 'nothing found' => array( array( 'status' => 'complete' ), false, 'Dismiss', false, 'No affected orders were found.' ),
+ 'fixed some' => array(
+ array(
+ 'status' => 'complete',
+ 'fixed' => 2,
+ 'completed_at' => time(),
+ ),
+ false,
+ 'Dismiss',
+ false,
+ 'Fixed 2 orders on',
+ ),
+ 'left unresolved' => array(
+ array(
+ 'status' => 'complete',
+ 'unresolved' => 1,
+ ),
+ false,
+ 'Check and fix',
+ false,
+ '1 order could not be fixed.',
+ ),
+ );
+ }
+
+ /**
+ * @testdox Refuses to start a run while another run or the full refund data fix is in progress.
+ * @testWith ["woocommerce_analytics_refund_double_count_fix_batch", "A fix is already in progress", "refused_running"]
+ * ["woocommerce_analytics_refund_fix_batch", "full refund data fix is still running", "refused_full_refund_fix"]
+ *
+ * @param string $pending_hook Hook of the pending action.
+ * @param string $expected_message Expected message fragment.
+ * @param string $expected_outcome Expected tool_run outcome.
+ */
+ public function test_tool_refuses_to_start_while_busy( string $pending_hook, string $expected_message, string $expected_outcome ): void {
+ update_option(
+ Analytics::REFUND_DOUBLE_COUNT_OPTION,
+ array(
+ 'run_id' => 'current',
+ 'status' => 'running',
+ )
+ );
+ as_schedule_single_action( time() + 60, $pending_hook, array(), 'wc-admin-data' );
+
+ $message = $this->sut->run_refund_double_count_tool();
+
+ $this->assertStringContainsString( $expected_message, $message );
+ $this->assertSame( 'current', Analytics::get_refund_double_count_state()['run_id'], 'No new run should start' );
+ $this->assertSame(
+ array(
+ array(
+ 'outcome' => $expected_outcome,
+ 'previous_status' => 'running',
+ ),
+ ),
+ array_map(
+ fn( $properties ) => array_intersect_key( $properties, array_flip( array( 'outcome', 'previous_status' ) ) ),
+ $this->get_events( 'analytics_refund_double_count_tool_run' )
+ )
+ );
+ }
+
+ /**
+ * @testdox Dismisses the tool after a run that left nothing to fix.
+ */
+ public function test_tool_can_be_dismissed_after_a_clean_run(): void {
+ $this->run_fix();
+
+ $this->sut->run_refund_double_count_tool();
+
+ $this->assertTrue( Analytics::get_refund_double_count_state()['dismissed'] );
+ $this->assertNull( $this->get_tool() );
+
+ $tool_runs = $this->get_events( 'analytics_refund_double_count_tool_run' );
+ $this->assertSame( array( 'started', 'dismissed' ), array_column( $tool_runs, 'outcome' ) );
+ $this->assertSame( array( 'none', 'complete' ), array_column( $tool_runs, 'previous_status' ) );
+ }
+
+ /**
+ * A failed highest-order-ID query used to read as 0, so the run covered no orders and
+ * told the merchant that nothing was affected.
+ *
+ * @testdox Refuses to start a run when the highest order ID cannot be read.
+ */
+ public function test_tool_does_not_start_a_run_when_the_max_order_id_query_fails(): void {
+ global $wpdb;
+
+ $order = $this->create_refunded_order( array( 20, 30 ) );
+ $this->double_count_latest_refund( $order );
+
+ $break_max_query = function ( $query ) {
+ if ( false !== strpos( $query, 'SELECT MAX(order_id)' ) ) {
+ return 'SELECT MAX(order_id) FROM a_table_that_does_not_exist';
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $break_max_query );
+
+ // The real database log handler runs its own query while logging, which resets
+ // $wpdb->last_error. Stand in for it so the error has to be read before logging.
+ $logged = array();
+ $logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+ $logger->method( 'error' )->willReturnCallback(
+ function ( $message ) use ( &$logged ) {
+ global $wpdb;
+ $wpdb->get_var( 'SELECT 1' );
+ $logged[] = $message;
+ }
+ );
+ $use_clearing_logger = fn() => $logger;
+ add_filter( 'woocommerce_logging_class', $use_clearing_logger, 20 );
+
+ $thrown = null;
+ $suppress = $wpdb->suppress_errors( true );
+ try {
+ $this->sut->run_refund_double_count_tool();
+ } catch ( \Exception $exception ) {
+ $thrown = $exception;
+ } finally {
+ $wpdb->suppress_errors( $suppress );
+ remove_filter( 'woocommerce_logging_class', $use_clearing_logger, 20 );
+ remove_filter( 'query', $break_max_query );
+ }
+
+ $this->assertNotNull( $thrown, 'A failed highest-order-ID query should throw rather than start an empty run' );
+ // The Tools controller puts this message in front of the merchant, so it cannot be empty.
+ $this->assertStringContainsString( 'a_table_that_does_not_exist', $thrown->getMessage() );
+ $this->assertCount( 1, $logged );
+ $this->assertStringContainsString( 'Highest order stats ID query failed', $logged[0] );
+ $this->assertStringContainsString( 'a_table_that_does_not_exist', $logged[0] );
+ $this->assertSame( '', Analytics::get_refund_double_count_state()['status'], 'No run should be recorded' );
+ $this->assertSame( array(), $this->get_events( 'analytics_refund_double_count_tool_run' ) );
+ $this->assertEmpty(
+ as_get_scheduled_actions(
+ array(
+ 'hook' => Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK,
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ 'per_page' => 1,
+ )
+ ),
+ 'No batch should be scheduled'
+ );
+
+ // The tool still offers to run, rather than reporting that nothing was found.
+ $this->assertStringContainsString( 'Check and fix', $this->get_tool()['button'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Notes/RefundDoubleCountToolNoticeTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Notes/RefundDoubleCountToolNoticeTest.php
new file mode 100644
index 00000000000..8ac6537cce8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Notes/RefundDoubleCountToolNoticeTest.php
@@ -0,0 +1,76 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Notes;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Internal\Admin\Analytics;
+use Automattic\WooCommerce\Internal\Admin\Notes\RefundDoubleCountToolNotice;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the RefundDoubleCountToolNotice class.
+ */
+class RefundDoubleCountToolNoticeTest extends WC_Unit_Test_Case {
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ update_option( 'woocommerce_analytics_uses_old_full_refund_data', 'no' );
+ update_option( \WC_Install::INITIAL_INSTALLED_VERSION, '10.5.0' );
+ }
+
+ /**
+ * @testdox Applies to stores installed before 11.1.0 that have not run the tool yet.
+ */
+ public function test_is_applicable_before_the_tool_has_run(): void {
+ $this->assertTrue( RefundDoubleCountToolNotice::is_applicable() );
+ $note = RefundDoubleCountToolNotice::get_note();
+ $this->assertInstanceOf( Note::class, $note );
+ $this->assertStringEndsWith( '#tool_' . Analytics::REFUND_DOUBLE_COUNT_TOOL_ID, $note->get_actions()[0]->query, 'The action should scroll to the tool row' );
+ }
+
+ /**
+ * @testdox Does not apply once a run has started, or when the tool does not apply to the store.
+ * @testWith ["run started"]
+ * ["installed on 11.1.0"]
+ * ["old refund data"]
+ * ["analytics disabled"]
+ *
+ * @param string $scenario Scenario name.
+ */
+ public function test_is_not_applicable( string $scenario ): void {
+ switch ( $scenario ) {
+ case 'run started':
+ update_option( Analytics::REFUND_DOUBLE_COUNT_OPTION, array( 'status' => 'running' ) );
+ break;
+ case 'installed on 11.1.0':
+ update_option( \WC_Install::INITIAL_INSTALLED_VERSION, '11.1.0' );
+ break;
+ case 'old refund data':
+ update_option( 'woocommerce_analytics_uses_old_full_refund_data', 'yes' );
+ break;
+ case 'analytics disabled':
+ update_option( 'woocommerce_analytics_enabled', 'no' );
+ break;
+ }
+
+ $this->assertFalse( RefundDoubleCountToolNotice::is_applicable() );
+ $this->assertNull( RefundDoubleCountToolNotice::get_note() );
+ }
+
+ /**
+ * @testdox Starting a run from the tool removes the note.
+ */
+ public function test_starting_a_run_deletes_the_note(): void {
+ RefundDoubleCountToolNotice::possibly_add_note();
+ $this->assertTrue( RefundDoubleCountToolNotice::note_exists(), 'The note should be added first' );
+
+ Analytics::get_instance()->run_refund_double_count_tool();
+ as_unschedule_all_actions( Analytics::REFUND_DOUBLE_COUNT_FIX_HOOK );
+
+ $this->assertFalse( RefundDoubleCountToolNotice::note_exists() );
+ }
+}