id : ''; // Only preload endpoints on wc-admin pages. if ( 'woocommerce_page_wc-admin' === $screen_id ) { $endpoints['performanceIndicators'] = '/wc-analytics/reports/performance-indicators/allowed'; $endpoints['leaderboards'] = '/wc-analytics/leaderboards/allowed'; } return $endpoints; } /** * Adds fields so that we can store user preferences for the columns to display on a report. * * @param array $user_data_fields User data fields. * @return array */ public function add_user_data_fields( $user_data_fields ) { return array_merge( $user_data_fields, array( 'categories_report_columns', 'coupons_report_columns', 'customers_report_columns', 'orders_report_columns', 'products_report_columns', 'revenue_report_columns', 'taxes_report_columns', 'variations_report_columns', 'dashboard_sections', 'dashboard_chart_type', 'dashboard_chart_interval', 'dashboard_leaderboard_rows', 'order_attribution_install_banner_dismissed', 'scheduled_updates_promotion_notice_dismissed', ) ); } /** * Register the cache clearing tool on the WooCommerce > Status > Tools page. * * @param array $debug_tools Available debug tool registrations. * @return array Filtered debug tool registrations. */ public function register_cache_clear_tool( $debug_tools ) { $settings_url = add_query_arg( array( 'page' => 'wc-admin', 'path' => '/analytics/settings', ), get_admin_url( null, 'admin.php' ) ); $debug_tools[ self::CACHE_TOOL_ID ] = array( 'name' => __( 'Clear analytics cache', 'woocommerce' ), 'button' => __( 'Clear', 'woocommerce' ), 'desc' => sprintf( /* translators: 1: opening link tag, 2: closing tag */ __( 'This tool will reset the cached values used in WooCommerce Analytics. If numbers still look off, try %1$sReimporting Historical Data%2$s.', 'woocommerce' ), '', '' ), 'callback' => array( $this, 'run_clear_cache_tool' ), ); return $debug_tools; } /** * Whether the full refund fix tool should be shown to the merchant. * * Returns true when the store still has legacy refund data OR when the fix was * recently queued and the merchant has not yet dismissed the tool. New stores * (where the option was never set) never see the tool. * * @since 10.8.0 * * @return bool */ private static function should_show_refund_fix_tool(): bool { return ! OrderUtil::uses_new_full_refund_data() || 'yes' === get_option( 'woocommerce_analytics_show_old_refund_data_tool' ); } /** * Register the full refund fix data tool on the WooCommerce > Status > Tools page. * * The Fix button is disabled by default (via the PHP 'disabled' field). JS enables it * only after a Check confirms there are affected orders to fix. * * @since 10.8.0 * * @param array $debug_tools Available debug tool registrations. * @return array Filtered debug tool registrations. */ public function register_full_refund_fix_data_tool( $debug_tools ) { $desc = __( 'This tool will fix the full refund data used in WooCommerce Analytics and re-import all the refunded historical data.', 'woocommerce' ); $disabled = true; $debug_tools[ self::FULL_REFUND_FIX_DATA_TOOL_ID ] = array( 'name' => __( 'Fix analytics full refund data', 'woocommerce' ), 'button' => __( 'Fix', 'woocommerce' ), 'desc' => $desc, 'callback' => array( $this, 'run_full_refund_fix_data_tool' ), 'disabled' => $disabled, ); return $debug_tools; } /** * Handles the Fix button submission for the full refund fix tool. * * When the "Disable tool" action is requested (i.e. the Check confirmed no affected * orders), deletes the old-data flag so the tool no longer appears. Otherwise * schedules the first batch job to re-import all affected refund orders. * * @since 10.8.0 * * @return string Success message. */ public function run_full_refund_fix_data_tool() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework. if ( isset( $_GET['wc_refund_fix_action'] ) && 'disable' === sanitize_key( $_GET['wc_refund_fix_action'] ) ) { delete_option( 'woocommerce_analytics_uses_old_full_refund_data' ); delete_option( 'woocommerce_analytics_show_old_refund_data_tool' ); return __( 'Tool dismissed.', 'woocommerce' ); } $already_running = ! empty( as_get_scheduled_actions( array( 'hook' => 'woocommerce_analytics_refund_fix_batch', 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ), 'per_page' => 1, 'orderby' => 'none', ), 'ids' ) ); if ( $already_running ) { return __( 'A fix is already in progress, please check back later.', 'woocommerce' ); } // Clear the legacy flag before queuing so that every batch job runs with // the corrected full-refund import logic (uses_new_full_refund_data() → true). // Set the show-tool option so the tool stays visible until the merchant dismisses it. delete_option( 'woocommerce_analytics_uses_old_full_refund_data' ); update_option( 'woocommerce_analytics_show_old_refund_data_tool', 'yes' ); WC()->queue()->schedule_single( time(), 'woocommerce_analytics_refund_fix_batch', array( 0 ), 'wc-admin-data' ); return __( 'Re-importing refunded orders in batches. Full refund data will be updated shortly.', 'woocommerce' ); } /** * Process one batch of refund orders for the analytics fix. * * Fetches up to 100 orders with incorrect refund stats (cursor-based so * concurrent imports cannot shift the result window) and re-imports each * directly. Schedules itself for the next cursor position when the batch is * full, stopping automatically once no more rows are found. * * @since 10.8.0 * * @param int $min_order_id Exclusive lower bound on order_id; 0 for the first batch. * @return void * @throws \Exception On database error so Action Scheduler marks the job as failed. */ public function process_refund_fix_batch( $min_order_id = 0 ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $refunded_orders = $wpdb->get_results( $wpdb->prepare( "SELECT order_stats.order_id FROM {$wpdb->prefix}wc_order_stats AS order_stats INNER JOIN {$wpdb->prefix}wc_order_stats AS parent_stats ON order_stats.parent_id = parent_stats.order_id WHERE order_stats.total_sales < 0 AND order_stats.total_sales = order_stats.net_total AND order_stats.total_sales != order_stats.shipping_total AND order_stats.total_sales != order_stats.tax_total AND (parent_stats.shipping_total > 0 OR parent_stats.tax_total > 0) AND order_stats.order_id > %d ORDER BY order_stats.order_id ASC LIMIT 100", $min_order_id ) ); if ( ! $refunded_orders ) { if ( $wpdb->last_error ) { throw new \Exception( $wpdb->last_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return; } foreach ( $refunded_orders as $refunded_order ) { OrdersScheduler::import( intval( $refunded_order->order_id ) ); } if ( count( $refunded_orders ) >= 100 ) { $last_order_id = intval( end( $refunded_orders )->order_id ); WC()->queue()->schedule_single( time() + 5, 'woocommerce_analytics_refund_fix_batch', array( $last_order_id ), 'wc-admin-data' ); } } /** * AJAX handler: checks whether the store has analytics order stats rows that * look like unprocessed full refunds. * * @since 10.8.0 * @return void */ public function ajax_check_refund_fix_needed(): void { check_ajax_referer( 'woocommerce_refund_fix_check', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'woocommerce' ) ), 403 ); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $has_affected = $wpdb->get_var( "SELECT order_stats.order_id FROM {$wpdb->prefix}wc_order_stats AS order_stats INNER JOIN {$wpdb->prefix}wc_order_stats AS parent_stats ON order_stats.parent_id = parent_stats.order_id WHERE order_stats.total_sales < 0 AND order_stats.total_sales = order_stats.net_total AND order_stats.total_sales != order_stats.shipping_total AND order_stats.total_sales != order_stats.tax_total AND (parent_stats.shipping_total > 0 OR parent_stats.tax_total > 0) LIMIT 1" ); if ( $wpdb->last_error ) { wp_send_json_error( array( 'code' => 'db_error', 'message' => $wpdb->last_error, ), 500 ); } $fix_in_progress = ! empty( as_get_scheduled_actions( array( 'hook' => 'woocommerce_analytics_refund_fix_batch', 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ), 'per_page' => 1, 'orderby' => 'none', ), 'ids' ) ); wp_send_json_success( array( 'needs_fix' => ! empty( $has_affected ), 'fix_in_progress' => $fix_in_progress, ) ); } /** * Output the inline script that injects a "Check" button into the full refund * fix tool row on the WooCommerce > Status > Tools page. * * @since 10.8.0 * @return void */ public function output_refund_fix_tool_js(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework. if ( ! isset( $_GET['page'], $_GET['tab'] ) || 'wc-status' !== $_GET['page'] || 'tools' !== $_GET['tab'] ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce tools framework. if ( isset( $_GET['wc_refund_fix_action'] ) && 'disable' === sanitize_key( $_GET['wc_refund_fix_action'] ) ) { return; } $tool_class = self::FULL_REFUND_FIX_DATA_TOOL_ID; $nonce = wp_create_nonce( 'woocommerce_refund_fix_check' ); $ajax_url = admin_url( 'admin-ajax.php' ); $label_check = __( 'Check', 'woocommerce' ); $label_working = __( 'Checking…', 'woocommerce' ); $msg_needs_fix = __( 'Your store has orders that need fixing.', 'woocommerce' ); $msg_no_fix = __( 'No affected orders found.', 'woocommerce' ); $label_disable_tool = __( 'Disable tool', 'woocommerce' ); $msg_in_progress = __( 'A fix is already in progress, please check back later.', 'woocommerce' ); $msg_error = __( 'Check failed, please try again.', 'woocommerce' ); ?> Status > Tools page. * * @param array $debug_tools Available debug tool registrations. * @return array Filtered debug tool registrations. */ public function register_regenerate_order_fulfillment_status_tool( $debug_tools ) { // Check if the fulfillments feature is enabled. $container = wc_get_container(); $features_controller = $container->get( FeaturesController::class ); if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) { return $debug_tools; } // If the order fulfillment status has already been regenerated, don't register the tool again. if ( true === (bool) get_option( 'woocommerce_analytics_order_fulfillment_status_regenerated' ) ) { return $debug_tools; } $debug_tools['regenerate_order_fulfillment_status'] = array( 'name' => __( 'Regenerate order fulfillment status for Analytics', 'woocommerce' ), 'button' => __( 'Regenerate', 'woocommerce' ), 'desc' => __( 'This tool will regenerate the order fulfillment status for all orders and update the Analytics data using a direct SQL query.', 'woocommerce' ), 'callback' => array( $this, 'run_regenerate_order_fulfillment_status_tool' ), ); return $debug_tools; } /** * Regenerate order fulfillment status directly using SQL. * * @return string Success message or error message. */ public function run_regenerate_order_fulfillment_status_tool() { global $wpdb; // Check if the column exists, create it if not. if ( ! OrderStatsDataStore::has_fulfillment_status_column() ) { $create_column_result = OrderStatsDataStore::add_fulfillment_status_column(); if ( true !== $create_column_result ) { return sprintf( /* translators: %s: error message */ __( 'Failed to create fulfillment status column: %s', 'woocommerce' ), $create_column_result ); } } $order_stats_table = $wpdb->prefix . 'wc_order_stats'; // If HPOS is enabled, use the wc_orders_meta table, else use wp_postmeta. if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { $order_meta_table = OrdersTableDataStore::get_meta_table_name(); $order_meta_column = 'order_id'; } else { $order_meta_table = $wpdb->postmeta; $order_meta_column = 'post_id'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $updated = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and column names cannot be prepared. "UPDATE {$order_stats_table} os INNER JOIN {$order_meta_table} om ON os.order_id = om.{$order_meta_column} SET os.fulfillment_status = CASE WHEN om.meta_value = %s THEN NULL ELSE om.meta_value END WHERE om.meta_key = %s", 'no_fulfillments', '_fulfillment_status' ) ); if ( false === $updated ) { return __( 'Failed to update order fulfillment status. Please check the database logs for errors.', 'woocommerce' ); } // Mark as completed. update_option( 'woocommerce_analytics_order_fulfillment_status_regenerated', true, false ); return sprintf( /* translators: %d: number of orders updated */ __( 'Successfully updated fulfillment status for %d orders.', 'woocommerce' ), $updated ); } /** * Registers report pages. */ public function register_pages() { $report_pages = self::get_report_pages(); foreach ( $report_pages as $report_page ) { if ( ! is_null( $report_page ) ) { wc_admin_register_page( $report_page ); } } } /** * Get report pages. */ public static function get_report_pages() { $overview_page = array( 'id' => 'woocommerce-analytics', 'title' => __( 'Analytics', 'woocommerce' ), 'path' => '/analytics/overview', 'icon' => 'dashicons-chart-bar', 'position' => 57, // After WooCommerce & Product menu items. ); $report_pages = array( $overview_page, array( 'id' => 'woocommerce-analytics-overview', 'title' => __( 'Overview', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/overview', ), array( 'id' => 'woocommerce-analytics-products', 'title' => __( 'Products', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/products', ), array( 'id' => 'woocommerce-analytics-revenue', 'title' => __( 'Revenue', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/revenue', ), array( 'id' => 'woocommerce-analytics-orders', 'title' => __( 'Orders', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/orders', ), array( 'id' => 'woocommerce-analytics-variations', 'title' => __( 'Variations', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/variations', ), array( 'id' => 'woocommerce-analytics-categories', 'title' => __( 'Categories', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/categories', ), array( 'id' => 'woocommerce-analytics-coupons', 'title' => __( 'Coupons', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/coupons', ), array( 'id' => 'woocommerce-analytics-taxes', 'title' => __( 'Taxes', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/taxes', ), array( 'id' => 'woocommerce-analytics-downloads', 'title' => __( 'Downloads', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/downloads', ), 'yes' === get_option( 'woocommerce_manage_stock' ) ? array( 'id' => 'woocommerce-analytics-stock', 'title' => __( 'Stock', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/stock', ) : null, array( 'id' => 'woocommerce-analytics-customers', 'title' => __( 'Customers', 'woocommerce' ), 'parent' => 'woocommerce', 'path' => '/customers', ), array( 'id' => 'woocommerce-analytics-settings', 'title' => __( 'Settings', 'woocommerce' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/settings', ), ); /** * The analytics report items used in the menu. * * @since 6.4.0 */ return apply_filters( 'woocommerce_analytics_report_menu_items', $report_pages ); } /** * "Clear" analytics cache by invalidating it. */ public function run_clear_cache_tool() { Cache::invalidate(); return __( 'Analytics cache cleared.', 'woocommerce' ); } }