self::record( 'created', $id ) ), 30 ); add_action( 'woocommerce_new_product_variation', $g( static fn( $id ) => self::record( 'created', $id ) ), 30 ); add_action( 'woocommerce_update_product', $g( static fn( $id ) => self::record( 'updated', $id ) ), 30 ); add_action( 'woocommerce_update_product_variation', $g( static fn( $id ) => self::record( 'updated', $id ) ), 30 ); // Direct stock updates (orders, wc_update_product_stock) do not run save(), so they need their own hooks. $stock = $g( static function ( $product ) { if ( is_object( $product ) && method_exists( $product, 'get_id' ) ) { self::record( 'stock', $product->get_id() ); } } ); add_action( 'woocommerce_product_set_stock', $stock, 30 ); add_action( 'woocommerce_variation_set_stock', $stock, 30 ); add_action( 'woocommerce_product_set_stock_status', $g( static fn( $id ) => self::record( 'stock', $id ) ), 30 ); add_action( 'woocommerce_variation_set_stock_status', $g( static fn( $id ) => self::record( 'stock', $id ) ), 30 ); $deleted = $g( static function ( $post_id ) { $type = get_post_type( (int) $post_id ); if ( 'product' === $type || 'product_variation' === $type ) { self::record( 'deleted', $post_id ); } } ); add_action( 'wp_trash_post', $deleted, 30 ); add_action( 'before_delete_post', $deleted, 30 ); add_action( 'woocommerce_delete_product', $g( static fn( $id ) => self::record( 'deleted', $id ) ), 30 ); add_action( 'woocommerce_trash_product', $g( static fn( $id ) => self::record( 'deleted', $id ) ), 30 ); add_action( 'woocommerce_delete_product_variation', $g( static fn( $id ) => self::record( 'deleted', $id ) ), 30 ); add_action( 'untrashed_post', $g( static fn( $id ) => in_array( get_post_type( (int) $id ), array( 'product', 'product_variation' ), true ) ? self::record( 'updated', $id ) : null ), 30 ); } public static function queue(): array { $q = get_option( self::QUEUE_OPTION, array() ); return is_array( $q ) ? array_values( $q ) : array(); } public static function state(): array { $s = get_option( self::STATE_OPTION, array() ); return is_array( $s ) ? $s : array(); } /** Queue one event (deduplicated on type + product_id, newest time wins) and schedule a flush. */ public static function record( string $type, $product_id ): void { $product_id = (int) $product_id; if ( $product_id <= 0 || ! in_array( $type, self::TYPES, true ) || ! self::enabled() ) { return; } $q = self::queue(); $key = $type . ':' . $product_id; $q = array_values( array_filter( $q, static fn( $e ) => is_array( $e ) && ( $e['type'] . ':' . $e['product_id'] ) !== $key ) ); $q[] = array( 'type' => $type, 'product_id' => $product_id, 'at' => Util::iso(), ); if ( count( $q ) > self::MAX_QUEUE ) { $q = array_slice( $q, -self::MAX_QUEUE ); // Cap: drop the oldest. The twice-daily reconcile catches up. } update_option( self::QUEUE_OPTION, $q, false ); self::schedule(); } private static function schedule( int $not_before = 0 ): void { if ( wp_next_scheduled( self::CRON ) ) { return; } $state = self::state(); $when = max( time() + self::BATCH_DELAY, (int) ( $state['last_sent_ts'] ?? 0 ) + self::MIN_INTERVAL, (int) ( $state['retry_at'] ?? 0 ), $not_before ); wp_schedule_single_event( $when, self::CRON ); } /** Cron: send one batch. At most once a minute; exponential backoff on failure. */ public static function flush(): void { $q = self::queue(); if ( ! $q ) { return; } if ( ! self::enabled() ) { self::clear(); return; } $state = self::state(); $now = time(); $next = max( (int) ( $state['last_sent_ts'] ?? 0 ) + self::MIN_INTERVAL, (int) ( $state['retry_at'] ?? 0 ) ); if ( $next > $now ) { wp_clear_scheduled_hook( self::CRON ); wp_schedule_single_event( $next, self::CRON ); return; } $batch = array_slice( $q, 0, self::MAX_BATCH ); $res = Connect::signed_request( 'POST', '/v1/events', array( 'site_id' => Connect::site_id(), 'events' => $batch, ), 10 ); $state['last_attempt'] = Util::iso( $now ); if ( is_wp_error( $res ) ) { $fails = (int) ( $state['failures'] ?? 0 ) + 1; $delay = (int) min( self::MAX_BACKOFF, self::MIN_INTERVAL * ( 2 ** min( $fails, 12 ) ) ); $state['failures'] = $fails; $state['retry_at'] = $now + $delay; $state['last_status'] = 'error: ' . $res->get_error_code(); update_option( self::STATE_OPTION, $state, false ); wp_clear_scheduled_hook( self::CRON ); wp_schedule_single_event( $now + $delay, self::CRON ); return; } // Remove what was sent; events queued meanwhile stay (compare the exact entries). $sent = array_map( 'wp_json_encode', $batch ); $remain = array_values( array_filter( self::queue(), static fn( $e ) => ! in_array( wp_json_encode( $e ), $sent, true ) ) ); update_option( self::QUEUE_OPTION, $remain, false ); $state['failures'] = 0; $state['retry_at'] = 0; $state['last_sent'] = Util::iso( $now ); $state['last_sent_ts'] = $now; $state['last_count'] = count( $batch ); $state['last_status'] = (string) wp_remote_retrieve_response_code( $res ); update_option( self::STATE_OPTION, $state, false ); if ( $remain ) { wp_clear_scheduled_hook( self::CRON ); wp_schedule_single_event( $now + self::MIN_INTERVAL, self::CRON ); } } public static function clear(): void { delete_option( self::QUEUE_OPTION ); delete_option( self::STATE_OPTION ); wp_clear_scheduled_hook( self::CRON ); } }