* * @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; } }