PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.1
WooCommerce v10.9.1
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Admin / Analytics.php
woocommerce / src / Internal / Admin Last commit date
Agentic 4 months ago BlockTemplates 4 weeks ago EmailImprovements 1 year ago EmailPreview 4 weeks ago Emails 1 year ago ImportExport 1 year ago Logging 1 year ago Marketing 2 years ago Notes 4 weeks ago Onboarding 5 months ago Orders 4 weeks ago ProductForm 2 years ago ProductReviews 4 weeks ago RemoteFreeExtensions 4 weeks ago Schedulers 4 weeks ago Settings 2 weeks ago Suggestions 4 weeks ago WCPayPromotion 10 months ago ActivityPanels.php 3 years ago Analytics.php 4 weeks ago CategoryLookup.php 4 years ago Coupons.php 1 year ago CouponsMovedTrait.php 5 months ago CustomerEffortScoreTracks.php 7 months ago Events.php 2 months ago FeaturePlugin.php 4 months ago Homescreen.php 1 month ago Loader.php 4 weeks ago Marketing.php 1 year ago Marketplace.php 5 months ago MobileAppBanner.php 4 years ago OrderMilestoneEasterEgg.php 4 weeks ago RemoteInboxNotifications.php 3 years ago Settings.php 2 weeks ago ShippingLabelBanner.php 1 year ago ShippingLabelBannerDisplayRules.php 1 year ago SiteHealth.php 3 years ago Survey.php 4 years ago SystemStatusReport.php 1 year ago Translations.php 1 year ago WCAdminAssets.php 2 weeks ago WCAdminSharedSettings.php 1 year ago WCAdminUser.php 9 months ago WcPayWelcomePage.php 1 year ago
Analytics.php
742 lines
1 <?php
2 /**
3 * WooCommerce Analytics.
4 */
5
6 namespace Automattic\WooCommerce\Internal\Admin;
7
8 use Automattic\WooCommerce\Admin\API\Reports\Cache;
9 use Automattic\WooCommerce\Utilities\OrderUtil;
10 use Automattic\WooCommerce\Admin\Features\Features;
11 use Automattic\WooCommerce\Internal\Features\FeaturesController;
12 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
13 use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
14 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
15
16 /**
17 * Contains backend logic for the Analytics feature.
18 */
19 class Analytics {
20 /**
21 * Option name used to toggle this feature.
22 */
23 const TOGGLE_OPTION_NAME = 'woocommerce_analytics_enabled';
24 /**
25 * Clear cache tool identifier.
26 */
27 const CACHE_TOOL_ID = 'clear_woocommerce_analytics_cache';
28 /**
29 * Full refund fix data tool identifier.
30 *
31 * @since 10.8.0
32 */
33 const FULL_REFUND_FIX_DATA_TOOL_ID = 'fix_woocommerce_analytics_full_refund_data';
34
35 /**
36 * Class instance.
37 *
38 * @var Analytics instance
39 */
40 protected static $instance = null;
41
42 /**
43 * Determines if the feature has been toggled on or off.
44 *
45 * @var boolean
46 */
47 protected static $is_updated = false;
48
49 /**
50 * Get class instance.
51 */
52 public static function get_instance() {
53 if ( ! self::$instance ) {
54 self::$instance = new self();
55 }
56 return self::$instance;
57 }
58
59 /**
60 * Hook into WooCommerce.
61 */
62 public function __construct() {
63 add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
64 add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
65
66 if ( ! Features::is_enabled( 'analytics' ) ) {
67 return;
68 }
69
70 add_filter( 'woocommerce_component_settings_preload_endpoints', array( $this, 'add_preload_endpoints' ) );
71 add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
72 add_action( 'admin_menu', array( $this, 'register_pages' ) );
73 add_filter( 'woocommerce_debug_tools', array( $this, 'register_cache_clear_tool' ) );
74 add_filter( 'woocommerce_debug_tools', array( $this, 'register_regenerate_order_fulfillment_status_tool' ), 12 );
75
76 // Always register the batch hook so in-flight jobs survive after the legacy
77 // flag is cleared (clearing happens before the first batch is queued).
78 add_action( 'woocommerce_analytics_refund_fix_batch', array( $this, 'process_refund_fix_batch' ) );
79
80 if ( $this->should_show_refund_fix_tool() ) {
81 add_filter( 'woocommerce_debug_tools', array( $this, 'register_full_refund_fix_data_tool' ) );
82 add_action( 'admin_footer', array( $this, 'output_refund_fix_tool_js' ) );
83 add_action( 'wp_ajax_woocommerce_check_refund_fix_needed', array( $this, 'ajax_check_refund_fix_needed' ) );
84 }
85 }
86
87 /**
88 * Add the feature toggle to the features settings.
89 *
90 * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
91 *
92 * @param array $features Feature sections.
93 * @return array
94 */
95 public static function add_feature_toggle( $features ) {
96 return $features;
97 }
98
99 /**
100 * Reloads the page when the option is toggled to make sure all Analytics features are loaded.
101 *
102 * @param string $old_value Old value.
103 * @param string $value New value.
104 */
105 public static function reload_page_on_toggle( $old_value, $value ) {
106 if ( $old_value === $value ) {
107 return;
108 }
109
110 self::$is_updated = true;
111 }
112
113 /**
114 * Reload the page if the setting has been updated.
115 */
116 public static function maybe_reload_page() {
117 if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) {
118 return;
119 }
120
121 wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) );
122 exit();
123 }
124
125 /**
126 * Preload data from the countries endpoint.
127 *
128 * @param array $endpoints Array of preloaded endpoints.
129 * @return array
130 */
131 public function add_preload_endpoints( $endpoints ) {
132 $screen_id = ( function_exists( 'get_current_screen' ) && get_current_screen() ) ? get_current_screen()->id : '';
133
134 // Only preload endpoints on wc-admin pages.
135 if ( 'woocommerce_page_wc-admin' === $screen_id ) {
136 $endpoints['performanceIndicators'] = '/wc-analytics/reports/performance-indicators/allowed';
137 $endpoints['leaderboards'] = '/wc-analytics/leaderboards/allowed';
138 }
139
140 return $endpoints;
141 }
142
143 /**
144 * Adds fields so that we can store user preferences for the columns to display on a report.
145 *
146 * @param array $user_data_fields User data fields.
147 * @return array
148 */
149 public function add_user_data_fields( $user_data_fields ) {
150 return array_merge(
151 $user_data_fields,
152 array(
153 'categories_report_columns',
154 'coupons_report_columns',
155 'customers_report_columns',
156 'orders_report_columns',
157 'products_report_columns',
158 'revenue_report_columns',
159 'taxes_report_columns',
160 'variations_report_columns',
161 'dashboard_sections',
162 'dashboard_chart_type',
163 'dashboard_chart_interval',
164 'dashboard_leaderboard_rows',
165 'order_attribution_install_banner_dismissed',
166 'scheduled_updates_promotion_notice_dismissed',
167 )
168 );
169 }
170
171 /**
172 * Register the cache clearing tool on the WooCommerce > Status > Tools page.
173 *
174 * @param array $debug_tools Available debug tool registrations.
175 * @return array Filtered debug tool registrations.
176 */
177 public function register_cache_clear_tool( $debug_tools ) {
178 $settings_url = add_query_arg(
179 array(
180 'page' => 'wc-admin',
181 'path' => '/analytics/settings',
182 ),
183 get_admin_url( null, 'admin.php' )
184 );
185
186 $debug_tools[ self::CACHE_TOOL_ID ] = array(
187 'name' => __( 'Clear analytics cache', 'woocommerce' ),
188 'button' => __( 'Clear', 'woocommerce' ),
189 'desc' => sprintf(
190 /* translators: 1: opening link tag, 2: closing tag */
191 __( 'This tool will reset the cached values used in WooCommerce Analytics. If numbers still look off, try %1$sReimporting Historical Data%2$s.', 'woocommerce' ),
192 '<a href="' . esc_url( $settings_url ) . '">',
193 '</a>'
194 ),
195 'callback' => array( $this, 'run_clear_cache_tool' ),
196 );
197
198 return $debug_tools;
199 }
200
201 /**
202 * Whether the full refund fix tool should be shown to the merchant.
203 *
204 * Returns true when the store still has legacy refund data OR when the fix was
205 * recently queued and the merchant has not yet dismissed the tool. New stores
206 * (where the option was never set) never see the tool.
207 *
208 * @since 10.8.0
209 *
210 * @return bool
211 */
212 private function should_show_refund_fix_tool(): bool {
213 return ! OrderUtil::uses_new_full_refund_data()
214 || 'yes' === get_option( 'woocommerce_analytics_show_old_refund_data_tool' );
215 }
216
217 /**
218 * Register the full refund fix data tool on the WooCommerce > Status > Tools page.
219 *
220 * The Fix button is disabled by default (via the PHP 'disabled' field). JS enables it
221 * only after a Check confirms there are affected orders to fix.
222 *
223 * @since 10.8.0
224 *
225 * @param array $debug_tools Available debug tool registrations.
226 * @return array Filtered debug tool registrations.
227 */
228 public function register_full_refund_fix_data_tool( $debug_tools ) {
229 $desc = __( 'This tool will fix the full refund data used in WooCommerce Analytics and re-import all the refunded historical data.', 'woocommerce' );
230
231 $disabled = true;
232
233 $debug_tools[ self::FULL_REFUND_FIX_DATA_TOOL_ID ] = array(
234 'name' => __( 'Fix analytics full refund data', 'woocommerce' ),
235 'button' => __( 'Fix', 'woocommerce' ),
236 'desc' => $desc,
237 'callback' => array( $this, 'run_full_refund_fix_data_tool' ),
238 'disabled' => $disabled,
239 );
240
241 return $debug_tools;
242 }
243
244 /**
245 * Handles the Fix button submission for the full refund fix tool.
246 *
247 * When the "Disable tool" action is requested (i.e. the Check confirmed no affected
248 * orders), deletes the old-data flag so the tool no longer appears. Otherwise
249 * schedules the first batch job to re-import all affected refund orders.
250 *
251 * @since 10.8.0
252 *
253 * @return string Success message.
254 */
255 public function run_full_refund_fix_data_tool() {
256 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework.
257 if ( isset( $_GET['wc_refund_fix_action'] ) && 'disable' === sanitize_key( $_GET['wc_refund_fix_action'] ) ) {
258 delete_option( 'woocommerce_analytics_uses_old_full_refund_data' );
259 delete_option( 'woocommerce_analytics_show_old_refund_data_tool' );
260 return __( 'Tool dismissed.', 'woocommerce' );
261 }
262
263 $already_running = ! empty(
264 as_get_scheduled_actions(
265 array(
266 'hook' => 'woocommerce_analytics_refund_fix_batch',
267 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
268 'per_page' => 1,
269 'orderby' => 'none',
270 ),
271 'ids'
272 )
273 );
274
275 if ( $already_running ) {
276 return __( 'A fix is already in progress, please check back later.', 'woocommerce' );
277 }
278
279 // Clear the legacy flag before queuing so that every batch job runs with
280 // the corrected full-refund import logic (uses_new_full_refund_data() → true).
281 // Set the show-tool option so the tool stays visible until the merchant dismisses it.
282 delete_option( 'woocommerce_analytics_uses_old_full_refund_data' );
283 update_option( 'woocommerce_analytics_show_old_refund_data_tool', 'yes' );
284
285 WC()->queue()->schedule_single(
286 time(),
287 'woocommerce_analytics_refund_fix_batch',
288 array( 0 ),
289 'wc-admin-data'
290 );
291
292 return __( 'Re-importing refunded orders in batches. Full refund data will be updated shortly.', 'woocommerce' );
293 }
294
295 /**
296 * Process one batch of refund orders for the analytics fix.
297 *
298 * Fetches up to 100 orders with incorrect refund stats (cursor-based so
299 * concurrent imports cannot shift the result window) and re-imports each
300 * directly. Schedules itself for the next cursor position when the batch is
301 * full, stopping automatically once no more rows are found.
302 *
303 * @since 10.8.0
304 *
305 * @param int $min_order_id Exclusive lower bound on order_id; 0 for the first batch.
306 * @return void
307 * @throws \Exception On database error so Action Scheduler marks the job as failed.
308 */
309 public function process_refund_fix_batch( $min_order_id = 0 ): void {
310 global $wpdb;
311
312 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
313 $refunded_orders = $wpdb->get_results(
314 $wpdb->prepare(
315 "SELECT order_stats.order_id
316 FROM {$wpdb->prefix}wc_order_stats AS order_stats
317 INNER JOIN {$wpdb->prefix}wc_order_stats AS parent_stats ON order_stats.parent_id = parent_stats.order_id
318 WHERE order_stats.total_sales < 0
319 AND order_stats.total_sales = order_stats.net_total
320 AND order_stats.total_sales != order_stats.shipping_total
321 AND order_stats.total_sales != order_stats.tax_total
322 AND (parent_stats.shipping_total > 0 OR parent_stats.tax_total > 0)
323 AND order_stats.order_id > %d
324 ORDER BY order_stats.order_id ASC
325 LIMIT 100",
326 $min_order_id
327 )
328 );
329
330 if ( ! $refunded_orders ) {
331 if ( $wpdb->last_error ) {
332 throw new \Exception( $wpdb->last_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
333 }
334 return;
335 }
336
337 foreach ( $refunded_orders as $refunded_order ) {
338 OrdersScheduler::import( intval( $refunded_order->order_id ) );
339 }
340
341 if ( count( $refunded_orders ) >= 100 ) {
342 $last_order_id = intval( end( $refunded_orders )->order_id );
343 WC()->queue()->schedule_single(
344 time() + 5,
345 'woocommerce_analytics_refund_fix_batch',
346 array( $last_order_id ),
347 'wc-admin-data'
348 );
349 }
350 }
351
352 /**
353 * AJAX handler: checks whether the store has analytics order stats rows that
354 * look like unprocessed full refunds.
355 *
356 * @since 10.8.0
357 * @return void
358 */
359 public function ajax_check_refund_fix_needed(): void {
360 check_ajax_referer( 'woocommerce_refund_fix_check', 'nonce' );
361
362 if ( ! current_user_can( 'manage_woocommerce' ) ) {
363 wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'woocommerce' ) ), 403 );
364 }
365
366 global $wpdb;
367
368 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
369 $has_affected = $wpdb->get_var(
370 "SELECT order_stats.order_id
371 FROM {$wpdb->prefix}wc_order_stats AS order_stats
372 INNER JOIN {$wpdb->prefix}wc_order_stats AS parent_stats ON order_stats.parent_id = parent_stats.order_id
373 WHERE order_stats.total_sales < 0
374 AND order_stats.total_sales = order_stats.net_total
375 AND order_stats.total_sales != order_stats.shipping_total
376 AND order_stats.total_sales != order_stats.tax_total
377 AND (parent_stats.shipping_total > 0 OR parent_stats.tax_total > 0)
378 LIMIT 1"
379 );
380
381 if ( $wpdb->last_error ) {
382 wp_send_json_error(
383 array(
384 'code' => 'db_error',
385 'message' => $wpdb->last_error,
386 ),
387 500
388 );
389 }
390
391 $fix_in_progress = ! empty(
392 as_get_scheduled_actions(
393 array(
394 'hook' => 'woocommerce_analytics_refund_fix_batch',
395 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
396 'per_page' => 1,
397 'orderby' => 'none',
398 ),
399 'ids'
400 )
401 );
402
403 wp_send_json_success(
404 array(
405 'needs_fix' => ! empty( $has_affected ),
406 'fix_in_progress' => $fix_in_progress,
407 )
408 );
409 }
410
411 /**
412 * Output the inline script that injects a "Check" button into the full refund
413 * fix tool row on the WooCommerce > Status > Tools page.
414 *
415 * @since 10.8.0
416 * @return void
417 */
418 public function output_refund_fix_tool_js(): void {
419 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework.
420 if ( ! isset( $_GET['page'], $_GET['tab'] ) || 'wc-status' !== $_GET['page'] || 'tools' !== $_GET['tab'] ) {
421 return;
422 }
423 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework.
424 if ( isset( $_GET['wc_refund_fix_action'] ) && 'disable' === sanitize_key( $_GET['wc_refund_fix_action'] ) ) {
425 return;
426 }
427
428 $tool_class = self::FULL_REFUND_FIX_DATA_TOOL_ID;
429 $nonce = wp_create_nonce( 'woocommerce_refund_fix_check' );
430 $ajax_url = admin_url( 'admin-ajax.php' );
431 $label_check = __( 'Check', 'woocommerce' );
432 $label_working = __( 'Checking…', 'woocommerce' );
433 $msg_needs_fix = __( 'Your store has orders that need fixing.', 'woocommerce' );
434 $msg_no_fix = __( 'No affected orders found.', 'woocommerce' );
435 $label_disable_tool = __( 'Disable tool', 'woocommerce' );
436 $msg_in_progress = __( 'A fix is already in progress, please check back later.', 'woocommerce' );
437 $msg_error = __( 'Check failed, please try again.', 'woocommerce' );
438 ?>
439 <script type="text/javascript">
440 ( function() {
441 const toolRow = document.querySelector( 'tr.<?php echo esc_js( $tool_class ); ?>' );
442 if ( ! toolRow ) {
443 return;
444 }
445 const actionCell = toolRow.querySelector( 'td.run-tool' );
446 if ( ! actionCell ) {
447 return;
448 }
449
450 const statusSpan = document.createElement( 'span' );
451 statusSpan.style.cssText = 'display:block;margin-top:6px;';
452 statusSpan.setAttribute( 'aria-live', 'polite' );
453 statusSpan.setAttribute( 'role', 'status' );
454
455 const checkBtn = document.createElement( 'button' );
456 checkBtn.type = 'button';
457 checkBtn.className = 'button button-secondary';
458 checkBtn.style.marginRight = '8px';
459 checkBtn.textContent = <?php echo wp_json_encode( $label_check ); ?>;
460
461 const fixBtn = actionCell.querySelector( 'input[type=submit]' );
462 const originalFixLabel = fixBtn ? fixBtn.value : '';
463 const toolForm = document.getElementById( 'form_<?php echo esc_js( $tool_class ); ?>' );
464
465 checkBtn.addEventListener( 'click', function() {
466 checkBtn.disabled = true;
467 checkBtn.textContent = <?php echo wp_json_encode( $label_working ); ?>;
468 statusSpan.textContent = '';
469 statusSpan.style.color = '';
470
471 const data = new FormData();
472 data.append( 'action', 'woocommerce_check_refund_fix_needed' );
473 data.append( 'nonce', <?php echo wp_json_encode( $nonce ); ?> );
474
475 fetch( <?php echo wp_json_encode( $ajax_url ); ?>, { method: 'POST', body: data } )
476 .then( function( r ) { return r.json(); } )
477 .then( function( json ) {
478 checkBtn.disabled = false;
479 checkBtn.textContent = <?php echo wp_json_encode( $label_check ); ?>;
480 if ( json.success ) {
481 if ( json.data.fix_in_progress ) {
482 statusSpan.textContent = <?php echo wp_json_encode( $msg_in_progress ); ?>;
483 statusSpan.style.color = '#1d2327';
484 } else if ( json.data.needs_fix ) {
485 statusSpan.textContent = <?php echo wp_json_encode( $msg_needs_fix ); ?>;
486 statusSpan.style.color = '#d63638';
487 if ( fixBtn ) {
488 fixBtn.value = originalFixLabel;
489 fixBtn.disabled = false;
490 }
491 const existingFlag = toolForm ? toolForm.querySelector( 'input[name="wc_refund_fix_action"]' ) : null;
492 if ( existingFlag ) {
493 existingFlag.parentNode.removeChild( existingFlag );
494 }
495 } else {
496 statusSpan.textContent = <?php echo wp_json_encode( $msg_no_fix ); ?>;
497 statusSpan.style.color = '#1d2327';
498 if ( fixBtn ) {
499 fixBtn.value = <?php echo wp_json_encode( $label_disable_tool ); ?>;
500 fixBtn.disabled = false;
501 }
502 if ( toolForm && ! toolForm.querySelector( 'input[name="wc_refund_fix_action"]' ) ) {
503 const flagInput = document.createElement( 'input' );
504 flagInput.type = 'hidden';
505 flagInput.name = 'wc_refund_fix_action';
506 flagInput.value = 'disable';
507 toolForm.appendChild( flagInput );
508 }
509 }
510 } else {
511 statusSpan.textContent = ( json.data && json.data.message ) ? json.data.message : <?php echo wp_json_encode( $msg_error ); ?>;
512 statusSpan.style.color = '#d63638';
513 }
514 } )
515 .catch( function() {
516 checkBtn.disabled = false;
517 checkBtn.textContent = <?php echo wp_json_encode( $label_check ); ?>;
518 statusSpan.textContent = <?php echo wp_json_encode( $msg_error ); ?>;
519 statusSpan.style.color = '#d63638';
520 } );
521 } );
522
523 if ( fixBtn ) {
524 actionCell.insertBefore( checkBtn, fixBtn );
525 } else {
526 actionCell.appendChild( checkBtn );
527 }
528 actionCell.appendChild( statusSpan );
529 } )();
530 </script>
531 <?php
532 }
533
534 /**
535 * Register the regenerate order fulfillment status tool on the WooCommerce > Status > Tools page.
536 *
537 * @param array $debug_tools Available debug tool registrations.
538 * @return array Filtered debug tool registrations.
539 */
540 public function register_regenerate_order_fulfillment_status_tool( $debug_tools ) {
541 // Check if the fulfillments feature is enabled.
542 $container = wc_get_container();
543 $features_controller = $container->get( FeaturesController::class );
544
545 if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) {
546 return $debug_tools;
547 }
548
549 // If the order fulfillment status has already been regenerated, don't register the tool again.
550 if ( true === (bool) get_option( 'woocommerce_analytics_order_fulfillment_status_regenerated' ) ) {
551 return $debug_tools;
552 }
553
554 $debug_tools['regenerate_order_fulfillment_status'] = array(
555 'name' => __( 'Regenerate order fulfillment status for Analytics', 'woocommerce' ),
556 'button' => __( 'Regenerate', 'woocommerce' ),
557 'desc' => __( 'This tool will regenerate the order fulfillment status for all orders and update the Analytics data using a direct SQL query.', 'woocommerce' ),
558 'callback' => array( $this, 'run_regenerate_order_fulfillment_status_tool' ),
559 );
560
561 return $debug_tools;
562 }
563
564 /**
565 * Regenerate order fulfillment status directly using SQL.
566 *
567 * @return string Success message or error message.
568 */
569 public function run_regenerate_order_fulfillment_status_tool() {
570 global $wpdb;
571
572 // Check if the column exists, create it if not.
573 if ( ! OrderStatsDataStore::has_fulfillment_status_column() ) {
574 $create_column_result = OrderStatsDataStore::add_fulfillment_status_column();
575
576 if ( true !== $create_column_result ) {
577 return sprintf(
578 /* translators: %s: error message */
579 __( 'Failed to create fulfillment status column: %s', 'woocommerce' ),
580 $create_column_result
581 );
582 }
583 }
584
585 $order_stats_table = $wpdb->prefix . 'wc_order_stats';
586
587 // If HPOS is enabled, use the wc_orders_meta table, else use wp_postmeta.
588 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
589 $order_meta_table = OrdersTableDataStore::get_meta_table_name();
590 $order_meta_column = 'order_id';
591 } else {
592 $order_meta_table = $wpdb->postmeta;
593 $order_meta_column = 'post_id';
594 }
595
596 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
597 $updated = $wpdb->query(
598 $wpdb->prepare(
599 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and column names cannot be prepared.
600 "UPDATE {$order_stats_table} os INNER JOIN {$order_meta_table} om ON os.order_id = om.{$order_meta_column}
601 SET os.fulfillment_status = CASE
602 WHEN om.meta_value = %s THEN NULL
603 ELSE om.meta_value
604 END
605 WHERE om.meta_key = %s",
606 'no_fulfillments',
607 '_fulfillment_status'
608 )
609 );
610
611 if ( false === $updated ) {
612 return __( 'Failed to update order fulfillment status. Please check the database logs for errors.', 'woocommerce' );
613 }
614
615 // Mark as completed.
616 update_option( 'woocommerce_analytics_order_fulfillment_status_regenerated', true, false );
617
618 return sprintf(
619 /* translators: %d: number of orders updated */
620 __( 'Successfully updated fulfillment status for %d orders.', 'woocommerce' ),
621 $updated
622 );
623 }
624
625 /**
626 * Registers report pages.
627 */
628 public function register_pages() {
629 $report_pages = self::get_report_pages();
630 foreach ( $report_pages as $report_page ) {
631 if ( ! is_null( $report_page ) ) {
632 wc_admin_register_page( $report_page );
633 }
634 }
635 }
636
637 /**
638 * Get report pages.
639 */
640 public static function get_report_pages() {
641 $overview_page = array(
642 'id' => 'woocommerce-analytics',
643 'title' => __( 'Analytics', 'woocommerce' ),
644 'path' => '/analytics/overview',
645 'icon' => 'dashicons-chart-bar',
646 'position' => 57, // After WooCommerce & Product menu items.
647 );
648
649 $report_pages = array(
650 $overview_page,
651 array(
652 'id' => 'woocommerce-analytics-overview',
653 'title' => __( 'Overview', 'woocommerce' ),
654 'parent' => 'woocommerce-analytics',
655 'path' => '/analytics/overview',
656 ),
657 array(
658 'id' => 'woocommerce-analytics-products',
659 'title' => __( 'Products', 'woocommerce' ),
660 'parent' => 'woocommerce-analytics',
661 'path' => '/analytics/products',
662 ),
663 array(
664 'id' => 'woocommerce-analytics-revenue',
665 'title' => __( 'Revenue', 'woocommerce' ),
666 'parent' => 'woocommerce-analytics',
667 'path' => '/analytics/revenue',
668 ),
669 array(
670 'id' => 'woocommerce-analytics-orders',
671 'title' => __( 'Orders', 'woocommerce' ),
672 'parent' => 'woocommerce-analytics',
673 'path' => '/analytics/orders',
674 ),
675 array(
676 'id' => 'woocommerce-analytics-variations',
677 'title' => __( 'Variations', 'woocommerce' ),
678 'parent' => 'woocommerce-analytics',
679 'path' => '/analytics/variations',
680 ),
681 array(
682 'id' => 'woocommerce-analytics-categories',
683 'title' => __( 'Categories', 'woocommerce' ),
684 'parent' => 'woocommerce-analytics',
685 'path' => '/analytics/categories',
686 ),
687 array(
688 'id' => 'woocommerce-analytics-coupons',
689 'title' => __( 'Coupons', 'woocommerce' ),
690 'parent' => 'woocommerce-analytics',
691 'path' => '/analytics/coupons',
692 ),
693 array(
694 'id' => 'woocommerce-analytics-taxes',
695 'title' => __( 'Taxes', 'woocommerce' ),
696 'parent' => 'woocommerce-analytics',
697 'path' => '/analytics/taxes',
698 ),
699 array(
700 'id' => 'woocommerce-analytics-downloads',
701 'title' => __( 'Downloads', 'woocommerce' ),
702 'parent' => 'woocommerce-analytics',
703 'path' => '/analytics/downloads',
704 ),
705 'yes' === get_option( 'woocommerce_manage_stock' ) ? array(
706 'id' => 'woocommerce-analytics-stock',
707 'title' => __( 'Stock', 'woocommerce' ),
708 'parent' => 'woocommerce-analytics',
709 'path' => '/analytics/stock',
710 ) : null,
711 array(
712 'id' => 'woocommerce-analytics-customers',
713 'title' => __( 'Customers', 'woocommerce' ),
714 'parent' => 'woocommerce',
715 'path' => '/customers',
716 ),
717 array(
718 'id' => 'woocommerce-analytics-settings',
719 'title' => __( 'Settings', 'woocommerce' ),
720 'parent' => 'woocommerce-analytics',
721 'path' => '/analytics/settings',
722 ),
723 );
724
725 /**
726 * The analytics report items used in the menu.
727 *
728 * @since 6.4.0
729 */
730 return apply_filters( 'woocommerce_analytics_report_menu_items', $report_pages );
731 }
732
733 /**
734 * "Clear" analytics cache by invalidating it.
735 */
736 public function run_clear_cache_tool() {
737 Cache::invalidate();
738
739 return __( 'Analytics cache cleared.', 'woocommerce' );
740 }
741 }
742