name of the option that was deleted. * * @return void */ public static function handle_scheduled_import_option_before_delete( $option_name ) { if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) { return; } self::handle_scheduled_import_option_change( get_option( self::SCHEDULED_IMPORT_OPTION, self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE ), self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE, ); } /** * Process pending orders in batch. * * This method queries for orders updated since the last cursor position * (compound cursor: date + ID) and imports them into the analytics tables. * * @internal * @param string|null $cursor_date Cursor date in 'Y-m-d H:i:s' format. Orders after this date will be processed. * @param int|null $cursor_id Cursor order ID. Combined with $cursor_date to form compound cursor. * @return void */ public static function process_pending_batch( $cursor_date = null, $cursor_id = null ) { $logger = wc_get_logger(); $context = array( 'source' => 'wc-analytics-order-import' ); if ( self::is_importing() ) { // No need to process if an import is already in progress. $logger->info( 'Import is already in progress, skipping batch import.', $context ); return; } // Load cursor position from options if not provided. // If the cursor date is not provided, use the last 24 hours as the default since `action_scheduler_ensure_recurring_actions` runs daily so 24 hours is enough. $default_cursor_date = gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) ); $cursor_date = $cursor_date ?? get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $default_cursor_date ); $cursor_id = $cursor_id ?? (int) get_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 ); // Validate cursor date. if ( ! $cursor_date || ! strtotime( $cursor_date ) ) { $logger->error( 'Invalid cursor date: ' . $cursor_date, $context ); $cursor_date = $default_cursor_date; } $batch_size = self::get_batch_size( self::PROCESS_PENDING_ORDERS_BATCH_ACTION ); $logger->info( sprintf( 'Starting batch import. Cursor: %s (ID: %d), batch size: %d', $cursor_date, $cursor_id, $batch_size ), $context ); $start_time = microtime( true ); // Get orders updated since the cursor position. $orders = self::get_orders_since( $cursor_date, $cursor_id, $batch_size ); if ( empty( $orders ) ) { $logger->info( 'No orders to process', $context ); // Update the cursor position to the start time of the batch so that the next batch will start from that point. update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', (int) $start_time ), false ); update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0, false ); return; } $orders_count = count( $orders ); $processed_count = 0; foreach ( $orders as $order ) { try { self::import( $order->id ); ++$processed_count; // Advance cursor after each successful import. Since orders are sorted by // date ASC, id ASC, we can simply overwrite with the current order's values. $cursor_date = $order->date_updated_gmt; $cursor_id = $order->id; } catch ( \Throwable $e ) { // Log the failure and advance the cursor past the failing order so that // it is skipped on the next run rather than blocking the entire pipeline. static::log_import_error( $order->id, $e, $context ); static::record_failed_order_import( $order->id ); $cursor_date = $order->date_updated_gmt; $cursor_id = $order->id; } } // Save the updated cursor position. update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $cursor_date, false ); update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, $cursor_id, false ); $elapsed_time = microtime( true ) - $start_time; $logger->info( sprintf( 'Batch import completed. Processed: %d/%d orders in %.2f seconds. Cursor: %s (ID: %d)', $processed_count, $orders_count, $elapsed_time, $cursor_date, $cursor_id ), $context ); // If we fetched a full batch, there might be more orders to process. // Use the fetched count rather than successful count so that skipped // failing orders do not suppress scheduling of the next batch. if ( $orders_count === $batch_size ) { $logger->info( 'Full batch processed, scheduling next batch', $context ); self::schedule_action( 'process_pending_batch', array( $cursor_date, $cursor_id ) ); } } /** * Get the import interval. * * @internal * @return int The import interval in seconds. */ public static function get_import_interval() { /** * Filter the analytics import interval. * * @since 10.4.0 * @param int $interval The import interval in seconds. Default is 12 hours. */ return apply_filters( 'woocommerce_analytics_import_interval', 12 * HOUR_IN_SECONDS ); } /** * Get orders updated since the specified cursor position. * * Uses a compound cursor (date + ID) to handle cases where multiple orders * have the same timestamp. This ensures we can paginate through orders reliably * even when batch_size < number of orders at the same timestamp. * * @internal * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format. * @param int $cursor_id Cursor order ID. * @param int $limit Number of orders to retrieve. * @return array Array of objects with 'id' and 'date_updated_gmt' properties. */ private static function get_orders_since( $cursor_date, $cursor_id, $limit ) { if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { return self::get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ); } else { return self::get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ); } } /** * Get orders from HPOS orders table updated since the specified cursor position. * * Query logic uses a compound cursor (date, ID) to handle pagination when multiple * orders share the same timestamp: * - WHERE date > cursor_date: Get orders with newer timestamps * - OR (date = cursor_date AND id > cursor_id): Continue processing same timestamp * * Example: With batch_size=100 and 1000 orders at '2024-01-01 10:00:00', * this processes them across 10 batches without infinite loops or duplicates. * * @internal * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format. * @param int $cursor_id Cursor order ID. * @param int $limit Number of orders to retrieve. * @return array Array of objects with 'id' and 'date_updated_gmt' properties. */ private static function get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ) { global $wpdb; $orders_table = OrdersTableDataStore::get_orders_table_name(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $wpdb->get_results( $wpdb->prepare( "SELECT id, date_updated_gmt FROM {$orders_table} WHERE type IN ('shop_order', 'shop_order_refund') AND status NOT IN ('wc-auto-draft', 'auto-draft') AND ( date_updated_gmt > %s OR (date_updated_gmt = %s AND id > %d) ) ORDER BY date_updated_gmt ASC, id ASC LIMIT %d", $cursor_date, $cursor_date, $cursor_id, $limit ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * Get orders from posts table updated since the specified cursor position. * * Uses the same compound cursor logic as get_orders_since_from_orders_table() * but queries the posts table instead of the HPOS orders table. * * @internal * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format. * @param int $cursor_id Cursor order ID. * @param int $limit Number of orders to retrieve. * @return array Array of objects with 'id' and 'date_updated_gmt' properties. */ private static function get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT ID as id, post_modified_gmt as date_updated_gmt FROM {$wpdb->posts} WHERE post_type IN ('shop_order', 'shop_order_refund') AND post_status NOT IN ('wc-auto-draft', 'auto-draft') AND ( post_modified_gmt > %s OR (post_modified_gmt = %s AND ID > %d) ) ORDER BY post_modified_gmt ASC, ID ASC LIMIT %d", $cursor_date, $cursor_date, $cursor_id, $limit ) ); } /** * Check if an order is a test order that should be excluded from analytics. * * For refunds, the parent order is checked instead, since refunds do not * carry the test mode metadata directly. * * @param \WC_Abstract_Order $order Order object. * @return bool * * @since 10.7.0 */ public static function is_test_order( $order ) { if ( ! $order instanceof \WC_Abstract_Order ) { return false; } // For refunds, check the parent order. $check_order = $order; if ( 'shop_order_refund' === $order->get_type() ) { $check_order = wc_get_order( $order->get_parent_id() ); if ( ! $check_order instanceof \WC_Abstract_Order ) { return false; } } $is_test = 'test' === $check_order->get_meta( '_wcpay_mode' ); /** * Filter whether an order is a test order excluded from analytics. * * Use this filter to customize test order detection beyond the default * WCPay test mode check, e.g., to exclude orders from other payment * gateways' test/sandbox modes. * * @param bool $is_test Whether the order is a test order. * @param \WC_Abstract_Order $order The order being checked (for refunds, this is the parent order). * * @since 10.7.0 */ return apply_filters( 'woocommerce_analytics_is_test_order', $is_test, $check_order ); } /** * Get the recorded failed order imports. * * @internal * @since 11.0.0 * @return array Array with 'ids' (int[]) and 'overflow' (int) keys. */ public static function get_failed_order_imports(): array { $value = get_option( self::FAILED_ORDER_IMPORTS_OPTION, array() ); if ( ! is_array( $value ) ) { $value = array(); } return array( 'ids' => isset( $value['ids'] ) && is_array( $value['ids'] ) ? array_map( 'absint', $value['ids'] ) : array(), 'overflow' => isset( $value['overflow'] ) ? absint( $value['overflow'] ) : 0, ); } /** * Record an order ID that failed analytics import. * * Deduplicates IDs. When the list exceeds FAILED_ORDER_IMPORTS_CAP, the * oldest ID is dropped and the overflow counter is incremented. * * @internal * @since 11.0.0 * @param int $order_id Order or refund ID that failed to import. * @return void */ public static function record_failed_order_import( $order_id ): void { $order_id = absint( $order_id ); if ( ! $order_id ) { return; } $failed = self::get_failed_order_imports(); if ( in_array( $order_id, $failed['ids'], true ) ) { return; } $failed['ids'][] = $order_id; $ids_count = count( $failed['ids'] ); while ( $ids_count > self::FAILED_ORDER_IMPORTS_CAP ) { array_shift( $failed['ids'] ); ++$failed['overflow']; --$ids_count; } update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false ); } /** * Remove an order ID from the failed imports list. * * Called after a successful import so the list always reflects orders * that have not been imported since their last failure. * * @internal * @since 11.0.0 * @param int $order_id Order or refund ID to remove. * @return void */ public static function clear_failed_order_import( $order_id ): void { $order_id = absint( $order_id ); if ( ! $order_id ) { return; } $failed = self::get_failed_order_imports(); $index = array_search( $order_id, $failed['ids'], true ); if ( false === $index ) { return; } unset( $failed['ids'][ $index ] ); $failed['ids'] = array_values( $failed['ids'] ); if ( empty( $failed['ids'] ) && 0 === $failed['overflow'] ) { delete_option( self::FAILED_ORDER_IMPORTS_OPTION ); return; } update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false ); } /** * Reset the failed order imports overflow counter. * * Called when a full (non-windowed) historical import starts, since that * import covers the orders whose IDs were dropped from the list. Windowed * imports must not reset the counter. * * @internal * @since 11.0.0 * @return void */ public static function reset_failed_order_imports_overflow(): void { $failed = self::get_failed_order_imports(); if ( 0 === $failed['overflow'] ) { return; } $failed['overflow'] = 0; if ( empty( $failed['ids'] ) ) { delete_option( self::FAILED_ORDER_IMPORTS_OPTION ); return; } update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false ); } /** * Delete a batch of orders. * * @internal * @param int $batch_size Number of items to delete. * @return void */ public static function delete( $batch_size ) { global $wpdb; $order_ids = $wpdb->get_col( $wpdb->prepare( "SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d", $batch_size ) ); foreach ( $order_ids as $order_id ) { OrdersStatsDataStore::delete_order( $order_id ); } } /** * Check whether scheduled import is enabled. * * Checks the scheduled import option value. * * @internal * @since 10.5.0 Introduced as a private method. * @since 11.0.0 Made public. * @return bool */ public static function is_scheduled_import_enabled(): bool { $value = get_option( self::SCHEDULED_IMPORT_OPTION, false ); if ( false !== $value ) { return 'yes' === $value; } // Fall back to the legacy option (pre-10.5.0) which used inverted semantics: // 'yes' meant immediate import (= not scheduled), 'no' meant scheduled. $legacy_value = get_option( self::LEGACY_IMMEDIATE_IMPORT_OPTION, false ); if ( false !== $legacy_value ) { return 'no' === $legacy_value; } // Neither option exists — use the default (not scheduled). return false; } }