(string) $installed[$basename]['Name'], 'version' => (string) $installed[$basename]['Version'], ); } usort( $plugins, static fn( $a, $b ) => strcasecmp( $a['name'], $b['name'] ) ); $tables = array(); // The free build's own tables; the paid build lists its others in their old places (marketplace tables while marketplaces are on). $suffixes = array( 'storesuit_events', 'storesuit_money_keys', 'storesuit_order_index', 'storesuit_dashboard_facts', 'storesuit_mail_log', 'storesuit_stock_alerts' ); foreach ( $suffixes as $suffix ) { $name = $wpdb->prefix . $suffix; $tables[$suffix] = ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $name ) ) ) === $name ? 'ready' : 'missing' ); } $errors = array(); if ( $tables['storesuit_mail_log'] === 'ready' ) { $since = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); $errors['failed_emails_30d'] = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status=%s AND created>=%s', MailLog::table(), 'failed', $since ) ); } return array( 'active_plugins' => $plugins, 'php_settings' => array( 'memory_limit' => (string) ini_get( 'memory_limit' ), 'max_execution_time' => (int) ini_get( 'max_execution_time' ), 'upload_max_filesize' => (string) ini_get( 'upload_max_filesize' ), ), 'woocommerce_settings' => array( 'currency' => get_woocommerce_currency(), 'base_country' => WC()->countries->get_base_country(), 'prices_include_tax' => get_option( 'woocommerce_prices_include_tax' ) === 'yes', ), 'tables' => $tables, 'recent_errors' => $errors, ); } /** The front-end hooks a plugin uses to print into the storefront: the page head and foot, styles and scripts, shop and product pages. */ private const STOREFRONT_HOOKS = array( 'wp_head', 'wp_footer', 'wp_body_open', 'wp_enqueue_scripts', 'woocommerce_before_main_content', 'woocommerce_after_main_content', 'woocommerce_before_single_product', 'woocommerce_before_single_product_summary', 'woocommerce_single_product_summary', 'woocommerce_after_single_product_summary', 'woocommerce_after_single_product', 'woocommerce_before_shop_loop', 'woocommerce_after_shop_loop', 'woocommerce_before_shop_loop_item', 'woocommerce_after_shop_loop_item', 'woocommerce_before_cart', 'woocommerce_after_cart', 'woocommerce_before_checkout_form', 'woocommerce_after_checkout_form' ); public const STOREFRONT_KNOWN = 'storesuit_storefront_plugins_known'; /** When wp-cron.php last ran (Unix times, oldest first, at most 10), not autoloaded: how often background jobs really run. */ public const CRON_RUNS = 'storesuit_cron_runs'; /** What the last background check of the pages of results and feeds found (kept a day), and the event that checks. */ public const PAGING = 'storesuit_health_paging'; public const PAGING_EVENT = 'storesuit_health_paging_check'; /** The count of address_leftovers(), read at most hourly. */ private const ADDRESS_LEFTOVERS = 'storesuit_health_address_leftovers'; /** * A server cron that calls wp-cron.php every 5 minutes leaves Action Scheduler's work (stock, emails, warm-up) 30 * minutes behind (a live store, 27 Oct 2026). Each scheduler run is noted: one option write, never on a visit; runs * closer than 20 seconds (parallel calls) count once. */ public static function hooks() : void { if ( wp_doing_cron() ) { add_action( 'init', array(__CLASS__, 'cron_seen'), 1 ); add_action( 'init', static function () { self::jobs_release_dead(); }, 30 ); } add_action( self::PAGING_EVENT, array(__CLASS__, 'paging_check') ); // Action Scheduler's own timeout marked a job failed: noted with the runner that started it (jobs_died()). add_action( 'action_scheduler_failed_action', static function ( $id ) { self::job_died( (int) $id, 'scheduler' ); }, 10, 1 ); } /** @param mixed $now a timestamp; as an 'init' callback WordPress passes '' (a typed ?int fataled every cron run). */ public static function cron_seen( $now = null ) : void { $now = ( is_int( $now ) ? $now : time() ); $runs = self::cron_runs(); if ( $runs && $now - (int) end( $runs ) < 20 ) { return; } $runs[] = $now; update_option( self::CRON_RUNS, array_slice( $runs, -10 ), false ); } /** @return int[] */ private static function cron_runs() : array { $runs = get_option( self::CRON_RUNS, array() ); return ( is_array( $runs ) ? array_values( array_filter( array_map( 'intval', $runs ), static fn( int $t ): bool => $t > 0 ) ) : array() ); } /** The average seconds between scheduler runs (null before 4 runs are known); a wait since the last run longer than that counts too (a cron that stopped). */ public static function cron_gap( ?int $now = null ) : ?int { $runs = self::cron_runs(); $count = count( $runs ); if ( $count < 4 ) { return null; } $first = $runs[0]; $last = (int) end( $runs ); $gap = ($last - $first) / ($count - 1); $now = $now ?? time(); if ( $now - $last > $gap ) { $gap = ($now - $first) / $count; } return (int) round( $gap ); } /** No background run for this long while a job waits is a stop, not a slow cron (Denetim AYR-1, 28 Oct 2026). */ public const CRON_STOP_AFTER = 15 * MINUTE_IN_SECONDS; /** * Seconds since WordPress last ran its background jobs when they have stopped, else null: a run was seen before * (cron_seen()), none for 15 minutes, and a scheduled event has waited past its time as long (a fatal error during * the scheduler's run stopped a live store's for 80 minutes). A run just started (WordPress's doing_cron lock, under * a minute old) is given its minute. Read on admin screens; the cron cannot report its own stop. * @param mixed $now a timestamp, or null for now. */ public static function cron_stopped( $now = null ) : ?int { $now = ( is_int( $now ) ? $now : time() ); $runs = self::cron_runs(); if ( !$runs ) { return null; } $last = (int) end( $runs ); if ( $now - $last <= self::CRON_STOP_AFTER ) { return null; } $crons = ( function_exists( '_get_cron_array' ) ? _get_cron_array() : array() ); $due = ( is_array( $crons ) && $crons ? (int) min( array_map( 'intval', array_keys( $crons ) ) ) : 0 ); if ( $due <= 0 || $now - $due <= self::CRON_STOP_AFTER ) { return null; } if ( (float) get_transient( 'doing_cron' ) > $now - MINUTE_IN_SECONDS ) { return null; } return $now - $last; } /** What a hosting panel's cron runs every minute: fetch wp-cron.php, or run it with PHP. @return string[] */ public static function cron_commands() : array { $address = site_url( 'wp-cron.php?doing_wp_cron' ); return array('wget -q -O - "' . $address . '" >/dev/null 2>&1', 'curl -s "' . $address . '" >/dev/null 2>&1', 'php -q ' . ABSPATH . 'wp-cron.php >/dev/null 2>&1'); } /** A pending job this many seconds past its time is overdue (the queue is not keeping up). */ private const OVERDUE_AFTER = 15 * MINUTE_IN_SECONDS; /** Action Scheduler's own table of jobs (its database store), or '' when it is not there. */ private static function jobs_table() : string { global $wpdb; $table = ( isset( $wpdb->actionscheduler_actions ) ? (string) $wpdb->actionscheduler_actions : $wpdb->prefix . 'actionscheduler_actions' ); return ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table ? $table : '' ); } /** How long a job may stay in progress before Action Scheduler itself calls it failed (its failure period). */ private static function jobs_timeout() : int { return max( 60, (int) apply_filters( 'action_scheduler_failure_period', 5 * MINUTE_IN_SECONDS ) ); } /** * The whole store's background jobs (every plugin's, not only StoreRuler's), read from Action Scheduler's tables: * jobs still in progress past the failure period, and pending jobs more than 15 minutes late, each kind by hook and * group with the plugin whose code runs it (a live store, 27 Oct 2026: stock and mails 30 minutes behind, nobody saw why). * @return array{available:bool,stuck:int,overdue:int,jobs:array} */ public static function scheduled_jobs( int $kinds = 8 ) : array { global $wpdb; $table = self::jobs_table(); if ( $table === '' ) { return array( 'available' => false, 'stuck' => 0, 'overdue' => 0, 'jobs' => array(), ); } $groups = ( isset( $wpdb->actionscheduler_groups ) ? (string) $wpdb->actionscheduler_groups : $wpdb->prefix . 'actionscheduler_groups' ); $now = time(); $stuck_before = gmdate( 'Y-m-d H:i:s', $now - self::jobs_timeout() ); $late_before = gmdate( 'Y-m-d H:i:s', $now - self::OVERDUE_AFTER ); $out = array( 'available' => true, 'stuck' => 0, 'overdue' => 0, 'jobs' => array(), ); // Stuck: in progress since before the failure period; late: pending since before the late mark. One literal query each. $found = array( 'stuck' => $wpdb->get_results( $wpdb->prepare( "SELECT a.hook, COALESCE(g.slug,'') AS grp, COUNT(*) AS n, MIN(a.last_attempt_gmt) AS since, MIN(a.action_id) AS first FROM %i a LEFT JOIN %i g ON g.group_id=a.group_id WHERE a.status=%s AND a.last_attempt_gmt<%s AND a.last_attempt_gmt>%s GROUP BY a.hook, grp ORDER BY n DESC", $table, $groups, 'in-progress', $stuck_before, '1970-01-02 00:00:00' ), ARRAY_A ), 'overdue' => $wpdb->get_results( $wpdb->prepare( "SELECT a.hook, COALESCE(g.slug,'') AS grp, COUNT(*) AS n, MIN(a.scheduled_date_gmt) AS since FROM %i a LEFT JOIN %i g ON g.group_id=a.group_id WHERE a.status=%s AND a.scheduled_date_gmt<%s AND a.scheduled_date_gmt>%s GROUP BY a.hook, grp ORDER BY n DESC", $table, $groups, 'pending', $late_before, '1970-01-02 00:00:00' ), ARRAY_A ), ); foreach ( $found as $kind => $rows ) { foreach ( (array) $rows as $row ) { $out[$kind] += (int) $row['n']; $out['jobs'][] = array( 'kind' => $kind, 'hook' => (string) $row['hook'], 'group' => (string) $row['grp'], 'plugin' => self::hook_owner( (string) $row['hook'] ), 'count' => (int) $row['n'], 'since' => (string) $row['since'], ) + (( $kind === 'stuck' ? array( 'via' => ( self::job_started_via( (int) $row['first'] ) ?: 'unknown' ), ) : array() )); } } usort( $out['jobs'], static fn( array $a, array $b ): int => array($a['kind'] !== 'stuck', -$a['count']) <=> array($b['kind'] !== 'stuck', -$b['count']) ); $out['jobs'] = array_slice( $out['jobs'], 0, $kinds ); return $out; } /** The ids of the store's jobs stuck in progress past the failure period (scheduled_jobs()'s "stuck"), at most 200. @return int[] */ public static function stuck_job_ids() : array { global $wpdb; $table = self::jobs_table(); if ( $table === '' ) { return array(); } return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM %i WHERE status='in-progress' AND last_attempt_gmt<%s AND last_attempt_gmt>%s ORDER BY action_id LIMIT 200", $table, gmdate( 'Y-m-d H:i:s', time() - self::jobs_timeout() ), '1970-01-02 00:00:00' ) ) ); } /** The plugin (or theme) whose code runs this hook, by its name; '' when no callback is registered in this request. */ private static function hook_owner( string $hook ) : string { global $wp_filter; if ( empty( $wp_filter[$hook] ) || !$wp_filter[$hook] instanceof \WP_Hook ) { return ''; } $plugins = wp_normalize_path( WP_PLUGIN_DIR ) . '/'; $mu = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/'; $themes = wp_normalize_path( get_theme_root() ) . '/'; foreach ( $wp_filter[$hook]->callbacks as $callbacks ) { foreach ( $callbacks as $callback ) { $file = self::callback_file( $callback['function'] ?? null ); if ( $file === '' ) { continue; } if ( str_starts_with( $file, wp_normalize_path( STORESUIT_DIR ) ) ) { return 'StoreRuler'; } if ( str_starts_with( $file, $plugins ) ) { $part = strtok( substr( $file, strlen( $plugins ) ), '/' ); if ( !function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( get_plugins() as $basename => $info ) { if ( strtok( $basename, '/' ) === $part ) { return wp_strip_all_tags( (string) $info['Name'] ); } } return $part; } if ( str_starts_with( $file, $mu ) ) { return 'MU: ' . strtok( substr( $file, strlen( $mu ) ), '/' ); } if ( str_starts_with( $file, $themes ) ) { return I18n::format( 'Theme: %s', (string) strtok( substr( $file, strlen( $themes ) ), '/' ) ); } } } return ''; } /** * Health: jobs left in progress past the failure period are marked failed (Action Scheduler's own state, with a log * line on each), so they stop blocking their queue and can be run again from Tools › Scheduled Actions. Confirmed first. */ public static function jobs_fail_stuck() : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $table = self::jobs_table(); if ( $table === '' || !class_exists( '\\ActionScheduler' ) ) { throw new \RuntimeException(I18n::t( 'Action Scheduler is not available on this site.' )); } $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT action_id FROM %i WHERE status=%s AND last_attempt_gmt<%s AND last_attempt_gmt>%s ORDER BY action_id LIMIT 500', $table, 'in-progress', gmdate( 'Y-m-d H:i:s', time() - self::jobs_timeout() ), '1970-01-02 00:00:00' ) ) ); $store = \ActionScheduler::store(); $logger = \ActionScheduler::logger(); $done = 0; foreach ( $ids as $id ) { try { $store->mark_failure( $id ); $logger->log( $id, 'Marked as failed by StoreRuler Health: it stayed in progress past the failure period.' ); ++$done; } catch ( \Throwable $e ) { unset($e); } } Store::audit( 'settings', 0, 'health_jobs_fail_stuck', array( 'failed' => $done, ) ); return array( 'failed' => $done, ); } /** Jobs whose run stopped half way (released below, or failed by Action Scheduler's own timeout), at most 50 of the last 7 days, not autoloaded. */ public const JOBS_DIED = 'storesuit_jobs_died'; /** When StoreRuler's own cron tick last looked for them (at most every 5 minutes). */ public const JOBS_LOOKED = 'storesuit_jobs_looked'; /** The runner names Action Scheduler writes in a job's "started via" log line (never translated), and what Health calls them. */ private const JOB_STARTS = array( 'Async Request' => 'async', 'WP Cron' => 'cron', 'WP CLI' => 'cli', 'Admin List Table' => 'admin', 'StoreRuler' => 'storeruler', ); /** * On StoreRuler's own cron tick (every WP-Cron run, at most every 5 minutes, before Action Scheduler's queue runs): a * job in progress past the failure period that has written nothing to its log since then lost its process, so it is * marked failed and its claim released (a live store, 28 Oct 2026: jobs started by the async runner died when LiteSpeed * closed the request, each holding the queue's only claim for about 10 minutes). One audit entry a tick that found any. * @param mixed $now a timestamp; as an 'init' callback WordPress passes ''. * @return array{failed:int,claims:int} */ public static function jobs_release_dead( $now = null ) : array { global $wpdb; $now = ( is_int( $now ) ? $now : time() ); $none = array( 'failed' => 0, 'claims' => 0, ); if ( $now - (int) get_option( self::JOBS_LOOKED, 0 ) < 5 * MINUTE_IN_SECONDS ) { return $none; } // Not saved, not looked: otherwise every cron request repeats the look while the database refuses writes. if ( !update_option( self::JOBS_LOOKED, $now, false ) ) { return $none; } $table = self::jobs_table(); $logs = self::job_logs_table(); if ( $table === '' || $logs === '' || !class_exists( '\\ActionScheduler' ) ) { return $none; } $cutoff = gmdate( 'Y-m-d H:i:s', $now - self::jobs_timeout() ); $dead = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT a.action_id, a.claim_id FROM %i a WHERE a.status=%s AND a.last_attempt_gmt<%s AND a.last_attempt_gmt>%s AND NOT EXISTS ( SELECT 1 FROM %i l WHERE l.action_id=a.action_id AND l.log_date_gmt>=%s ) ORDER BY a.action_id LIMIT 100', $table, 'in-progress', $cutoff, '1970-01-02 00:00:00', $logs, $cutoff ), ARRAY_A ); if ( !$dead ) { return $none; } $store = \ActionScheduler::store(); $logger = \ActionScheduler::logger(); $failed = 0; $claims = array(); $via = array(); foreach ( $dead as $row ) { $id = (int) $row['action_id']; $start = self::job_started_via( $id ); try { $store->mark_failure( $id ); $logger->log( $id, sprintf( 'Marked as failed by StoreRuler: its run stopped (started via %s) and wrote nothing for %d minutes.', ( $start === '' ? 'unknown' : array_search( $start, self::JOB_STARTS, true ) ), (int) floor( self::jobs_timeout() / 60 ) ) ); } catch ( \Throwable $e ) { unset($e); continue; } ++$failed; $via[( $start ?: 'unknown' )] = ($via[( $start ?: 'unknown' )] ?? 0) + 1; self::job_died( $id, 'storeruler', $start, $now ); if ( (int) $row['claim_id'] > 0 ) { $claims[(int) $row['claim_id']] = true; } } // A claim is released once none of its jobs still runs: its waiting jobs can be claimed again at once. $released = 0; if ( ($store instanceof \ActionScheduler_DBStore || $store instanceof \ActionScheduler_HybridStore) && class_exists( '\\ActionScheduler_ActionClaim' ) ) { foreach ( array_keys( $claims ) as $claim ) { if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE claim_id=%d AND status=%s', $table, $claim, 'in-progress' ) ) > 0 ) { continue; } try { $store->release_claim( new \ActionScheduler_ActionClaim((string) $claim, array()) ); ++$released; } catch ( \Throwable $e ) { unset($e); } } } Store::audit( 'settings', 0, 'health_jobs_released', array( 'failed' => $failed, 'claims' => $released, 'via' => $via, ), 0 ); return array( 'failed' => $failed, 'claims' => $released, ); } /** Action Scheduler's log table, or '' when it is not there. */ private static function job_logs_table() : string { global $wpdb; $table = ( isset( $wpdb->actionscheduler_logs ) ? (string) $wpdb->actionscheduler_logs : $wpdb->prefix . 'actionscheduler_logs' ); return ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table ? $table : '' ); } /** Which runner started a job's last run (a JOB_STARTS value), read from its "started via" log line; '' when none says. */ public static function job_started_via( int $id ) : string { global $wpdb; $logs = self::job_logs_table(); if ( $logs === '' ) { return ''; } foreach ( (array) $wpdb->get_col( $wpdb->prepare( 'SELECT message FROM %i WHERE action_id=%d ORDER BY log_id DESC LIMIT 10', $logs, $id ) ) as $message ) { if ( str_starts_with( (string) $message, 'Marked as failed by StoreRuler' ) ) { continue; } foreach ( self::JOB_STARTS as $name => $key ) { if ( str_contains( (string) $message, $name ) ) { return $key; } } } return ''; } /** * Notes a job whose run stopped: its hook, group, the runner that started it and who marked it failed ('storeruler' on * the tick above, 'scheduler' when Action Scheduler's own timeout did). One option write; never on a shopper's request. * @param mixed $now a timestamp, or null for now. */ public static function job_died( int $id, string $by, ?string $start = null, $now = null ) : void { global $wpdb; $table = self::jobs_table(); if ( $id <= 0 || $table === '' ) { return; } $groups = ( isset( $wpdb->actionscheduler_groups ) ? (string) $wpdb->actionscheduler_groups : $wpdb->prefix . 'actionscheduler_groups' ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT a.hook, COALESCE(g.slug,'') AS grp FROM %i a LEFT JOIN %i g ON g.group_id=a.group_id WHERE a.action_id=%d", $table, $groups, $id ), ARRAY_A ); if ( !$row ) { return; } $now = ( is_int( $now ) ? $now : time() ); $died = array_values( array_filter( self::jobs_died_list(), static fn( array $d ): bool => (int) $d['t'] > $now - 7 * DAY_IN_SECONDS && (int) $d['id'] !== $id ) ); $died[] = array( 'id' => $id, 't' => $now, 'hook' => (string) $row['hook'], 'group' => (string) $row['grp'], 'via' => $start ?? self::job_started_via( $id ), 'by' => ( $by === 'storeruler' ? 'storeruler' : 'scheduler' ), ); update_option( self::JOBS_DIED, array_slice( $died, -50 ), false ); } /** @return array */ private static function jobs_died_list() : array { $died = get_option( self::JOBS_DIED, array() ); return ( is_array( $died ) ? array_values( array_filter( $died, static fn( $d ): bool => is_array( $d ) && isset( $d['id'], $d['t'], $d['hook'] ) ) ) : array() ); } /** * The jobs whose run stopped in the last 7 days, each kind by hook with the plugin that runs it and the runners that * started them, and how many of all of them the async runner (admin-ajax) started. * @return array{count:int,async:int,kinds:array,last:string}>} */ public static function jobs_died( ?int $now = null ) : array { $now = $now ?? time(); $kinds = array(); $count = 0; $async = 0; foreach ( self::jobs_died_list() as $d ) { if ( (int) $d['t'] <= $now - 7 * DAY_IN_SECONDS ) { continue; } ++$count; $via = ( (string) ($d['via'] ?? '') ?: 'unknown' ); if ( $via === 'async' ) { ++$async; } $key = $d['hook'] . '|' . ($d['group'] ?? ''); $kinds[$key] = $kinds[$key] ?? array( 'hook' => (string) $d['hook'], 'group' => (string) ($d['group'] ?? ''), 'plugin' => self::hook_owner( (string) $d['hook'] ), 'count' => 0, 'via' => array(), 'last' => '', ); ++$kinds[$key]['count']; $kinds[$key]['via'][$via] = ($kinds[$key]['via'][$via] ?? 0) + 1; $kinds[$key]['last'] = gmdate( 'Y-m-d H:i:s', (int) $d['t'] ); } $kinds = array_values( $kinds ); usort( $kinds, static fn( array $a, array $b ): int => $b['count'] <=> $a['count'] ); return array( 'count' => $count, 'async' => $async, 'kinds' => array_slice( $kinds, 0, 8 ), ); } /** * Plugins and must-use plugins with a callback on a storefront hook, read from WordPress's hook table in the Health * request (admin side, never on a shopper's request; nothing is fetched). WordPress, WooCommerce, the theme and * StoreRuler itself are left out. A plugin that adds its storefront hooks only outside the admin is not seen. * @return array */ /** * Plugins whose storefront additions are tags, caches, SEO, payment or email, never blocks or styles of an old theme (admin * audit B7, 28 Oct 2026: Site Kit, LiteSpeed, WP Mail SMTP and Hostinger's were listed as unknown): known from the start. */ private const HARMLESS = array( 'google-site-kit', 'google-listings-and-ads', 'facebook-for-woocommerce', 'pixelyoursite', 'litespeed-cache', 'wp-rocket', 'w3-total-cache', 'wp-super-cache', 'autoptimize', 'wp-mail-smtp', 'fluent-smtp', 'post-smtp', 'easy-wp-smtp', 'hostinger', 'wordpress-seo', 'seo-by-rank-math', 'all-in-one-seo-pack', 'wp-seopress', 'jetpack', 'woocommerce-payments', 'woocommerce-gateway-stripe', 'woocommerce-paypal-payments', 'paytr', 'iyzico', 'cookie-law-info', 'complianz-gdpr', 'wordfence' ); /** Active plugins that connect the store to an outside service (payments, Meta, Google, email, a marketplace), by name. @return string[] */ public static function integration_plugins() : array { if ( !function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } $all = get_plugins(); $out = array(); foreach ( $active as $basename ) { $folder = strtok( (string) $basename, '/' ); if ( !isset( $all[$basename] ) || !preg_match( '/^(?:paytr|iyzico|param-|woocommerce-payments|woocommerce-gateway-stripe|woocommerce-paypal-payments|facebook-for-woocommerce|google-site-kit|google-listings-and-ads|wp-mail-smtp|fluent-smtp|post-smtp|easy-wp-smtp)|trendyol|hepsiburada|n11|ciceksepeti|pazarama/', (string) $folder ) ) { continue; } $out[] = wp_strip_all_tags( (string) $all[$basename]['Name'] ); } return array_values( array_unique( $out ) ); } private static function harmless( string $folder ) : bool { foreach ( self::HARMLESS as $slug ) { if ( $folder === $slug || str_starts_with( $folder, $slug . '-' ) ) { return true; } } return false; } public static function storefront_plugins() : array { global $wp_filter; $plugins = wp_normalize_path( WP_PLUGIN_DIR ) . '/'; $mu = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/'; $own = wp_normalize_path( STORESUIT_DIR ); $known = (array) get_option( self::STOREFRONT_KNOWN, array() ); $found = array(); $names = null; foreach ( self::STOREFRONT_HOOKS as $hook ) { if ( empty( $wp_filter[$hook] ) || !$wp_filter[$hook] instanceof \WP_Hook ) { continue; } foreach ( $wp_filter[$hook]->callbacks as $callbacks ) { foreach ( $callbacks as $callback ) { $file = self::callback_file( $callback['function'] ?? null ); if ( $file === '' || str_starts_with( $file, $own ) ) { continue; } if ( str_starts_with( $file, $mu ) ) { $part = strtok( substr( $file, strlen( $mu ) ), '/' ); $key = 'mu:' . $part; $is_mu = true; } elseif ( str_starts_with( $file, $plugins ) ) { $part = strtok( substr( $file, strlen( $plugins ) ), '/' ); if ( $part === 'woocommerce' ) { continue; } $key = 'plugin:' . $part; $is_mu = false; } else { continue; } // WordPress, the theme if ( !isset( $found[$key] ) ) { if ( $is_mu ) { $data = ( is_file( $mu . $part ) ? get_file_data( $mu . $part, array( 'name' => 'Plugin Name', ) ) : array() ); $name = ( ($data['name'] ?? '') !== '' ? $data['name'] : $part ); } else { if ( $names === null ) { if ( !function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $names = array(); foreach ( get_plugins() as $basename => $info ) { $names[strtok( $basename, '/' )] = (string) $info['Name']; } } $name = $names[$part] ?? $part; } $found[$key] = array( 'key' => $key, 'name' => (( $is_mu ? 'MU: ' : '' )) . wp_strip_all_tags( $name ), 'mu' => $is_mu, 'hooks' => array(), 'known' => in_array( $key, $known, true ) || !$is_mu && self::harmless( $part ), ); } $found[$key]['hooks'][$hook] = $hook; } } } foreach ( $found as &$plugin ) { $plugin['hooks'] = array_values( $plugin['hooks'] ); } unset($plugin); // Not yet known first, must-use plugins (never switched off from the Plugins screen) before ordinary ones, then by name. uasort( $found, static fn( array $a, array $b ): int => array($a['known'], !$a['mu'], mb_strtolower( $a['name'] )) <=> array($b['known'], !$b['mu'], mb_strtolower( $b['name'] )) ); return array_values( $found ); } /** The file a hook callback is defined in, or '' when it cannot be told. */ private static function callback_file( $fn ) : string { try { if ( is_string( $fn ) && str_contains( $fn, '::' ) ) { $fn = explode( '::', $fn, 2 ); } if ( $fn instanceof \Closure || is_string( $fn ) && function_exists( $fn ) ) { $r = new \ReflectionFunction($fn); } elseif ( is_array( $fn ) && count( $fn ) === 2 && (is_object( $fn[0] ) || is_string( $fn[0] )) && is_string( $fn[1] ) && method_exists( $fn[0], $fn[1] ) ) { $r = new \ReflectionMethod($fn[0], $fn[1]); } elseif ( is_object( $fn ) && method_exists( $fn, '__invoke' ) ) { $r = new \ReflectionMethod($fn, '__invoke'); } else { return ''; } $file = $r->getFileName(); return ( $file ? wp_normalize_path( $file ) : '' ); } catch ( \Throwable $e ) { return ''; } } /** Health: the merchant knows this plugin's storefront additions (or no longer does). */ public static function storefront_known( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); $key = sanitize_text_field( (string) ($data['key'] ?? '') ); if ( !preg_match( '/^(mu|plugin):[A-Za-z0-9._\\-]{1,200}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a plugin from the list.' )); } $known = array_values( array_filter( (array) get_option( self::STOREFRONT_KNOWN, array() ), 'is_string' ) ); $known = ( empty( $data['forget'] ) ? array_values( array_unique( array_merge( $known, array($key) ) ) ) : array_values( array_diff( $known, array($key) ) ) ); update_option( self::STOREFRONT_KNOWN, $known, false ); return array( 'known' => $known, ); } /** * Products and options whose price in WooCommerce's lookup table (the price filter and price sorting read it) differs * from their own price, or that have no row there: an import that writes prices straight into the database leaves * the table behind (Almira tour, 27 Oct 2026: 95 products sorted and filtered as free). Products with options are * judged by their options (the product's own price rows are all of theirs). */ public static function stale_lookup() : int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i p INNER JOIN %i pm ON pm.post_id = p.ID AND pm.meta_key = '_price' AND pm.meta_value <> '' LEFT JOIN %i l ON l.product_id = p.ID WHERE p.post_type IN ( 'product', 'product_variation' ) AND p.post_status = 'publish' AND NOT EXISTS ( SELECT 1 FROM %i p2 WHERE p2.post_id = p.ID AND p2.meta_key = '_price' AND p2.meta_id <> pm.meta_id ) AND ( l.product_id IS NULL OR ABS( l.min_price - CAST( pm.meta_value AS DECIMAL(19,4) ) ) > 0.009 )", $wpdb->posts, $wpdb->postmeta, $wpdb->wc_product_meta_lookup, $wpdb->postmeta ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one admin count, nothing user-supplied. } /** Health: WooCommerce rebuilds its product lookup tables in the background (its own tool, WooCommerce › Status › Tools). */ public static function lookup_rebuild() : array { Store::require_cap( 'manage_woocommerce' ); if ( !function_exists( 'wc_update_product_lookup_tables' ) ) { throw new \RuntimeException(I18n::t( 'Rebuild the product lookup tables under WooCommerce › Status › Tools.' )); } if ( !function_exists( 'wc_update_product_lookup_tables_is_running' ) || !wc_update_product_lookup_tables_is_running() ) { wc_update_product_lookup_tables(); } Store::audit( 'settings', 0, 'health_lookup_rebuild', array( 'stale' => self::stale_lookup(), ) ); return array( 'running' => true, ); } /** Published products with options shown "In stock" while none of their published options can be bought. @return int[] */ public static function soldout_variables( int $limit = 500 ) : array { global $wpdb; return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM %i p INNER JOIN %i s ON s.post_id = p.ID AND s.meta_key = '_stock_status' AND s.meta_value = 'instock' WHERE p.post_type = 'product' AND p.post_status = 'publish' AND EXISTS ( SELECT 1 FROM %i c WHERE c.post_parent = p.ID AND c.post_type = 'product_variation' AND c.post_status = 'publish' ) AND NOT EXISTS ( SELECT 1 FROM %i c INNER JOIN %i cs ON cs.post_id = c.ID AND cs.meta_key = '_stock_status' WHERE c.post_parent = p.ID AND c.post_type = 'product_variation' AND c.post_status = 'publish' AND cs.meta_value IN ( 'instock', 'onbackorder' ) ) LIMIT %d", $wpdb->posts, $wpdb->postmeta, $wpdb->posts, $wpdb->posts, $wpdb->postmeta, $limit ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin read. } /** Health: those products take their stock status from their options again (WooCommerce's own sync), up to 200 at a time. */ public static function variable_stock_sync() : array { Store::require_cap( 'manage_woocommerce' ); // First the products that keep a stock of their own beside their options' (the usual cause): it is switched off. $own = VariableStock::repair( 200 ); $synced = 0; foreach ( self::soldout_variables( 200 ) as $id ) { $product = wc_get_product( $id ); if ( !$product instanceof \WC_Product_Variable ) { continue; } \WC_Product_Variable::sync( $product ); $fresh = wc_get_product( $id ); if ( $fresh && !$fresh->is_in_stock() ) { $synced++; } } $left = count( self::soldout_variables() ) + count( VariableStock::conflicts() ); Store::audit( 'settings', 0, 'health_variable_stock_sync', array( 'own_stock_off' => $own, 'synced' => $synced, 'left' => $left, ) ); return array( 'own_stock_off' => $own, 'synced' => $synced, 'left' => $left, ); } /** * Flat rates a shopper pays more for than the merchant entered: product prices include tax, but WooCommerce takes a * taxable shipping cost as before tax and adds the tax on top (Almira tour: 99,90 entered, 109,89 charged). The * store's own location's rates. @return array */ public static function shipping_tax_gaps() : array { if ( !function_exists( 'wc_tax_enabled' ) || !wc_tax_enabled() || !wc_prices_include_tax() || !class_exists( '\\WC_Shipping_Zones' ) ) { return array(); } $c = WC()->countries; $class = (string) get_option( 'woocommerce_shipping_tax_class', 'inherit' ); $rates = \WC_Tax::find_shipping_rates( array( 'country' => $c->get_base_country(), 'state' => $c->get_base_state(), 'postcode' => $c->get_base_postcode(), 'city' => $c->get_base_city(), 'tax_class' => ( $class === 'inherit' ? '' : $class ), ) ); if ( !$rates ) { return array(); } $zones = \WC_Shipping_Zones::get_zones(); $rest = new \WC_Shipping_Zone(0); $zones[] = array( 'zone_name' => $rest->get_zone_name(), 'shipping_methods' => $rest->get_shipping_methods( true ), ); $out = array(); foreach ( $zones as $zone ) { foreach ( (array) ($zone['shipping_methods'] ?? array()) as $method ) { if ( !is_object( $method ) || $method->id !== 'flat_rate' || isset( $method->enabled ) && $method->enabled !== 'yes' || $method->get_option( 'tax_status' ) !== 'taxable' ) { continue; } $cost = str_replace( ',', '.', trim( (string) $method->get_option( 'cost' ) ) ); if ( !is_numeric( $cost ) || (float) $cost <= 0 ) { continue; } $tax = (float) array_sum( \WC_Tax::calc_shipping_tax( (float) $cost, $rates ) ); if ( $tax <= 0 ) { continue; } // Entered before tax on purpose (admin audit B6, 28 Oct 2026: 109,09 entered for 120,00 charged): an odd amount that // comes to a price-like one with the tax. A price-like amount that tax turns into another (100 to 120) is still asked about. if ( self::price_like( (float) $cost + $tax ) && !self::price_like( (float) $cost ) ) { continue; } $out[] = array( 'title' => (string) $method->get_title(), 'zone' => (string) ($zone['zone_name'] ?? ''), 'cost' => (float) $cost, 'shown' => (float) $cost + $tax, 'net' => round( (float) $cost * (float) $cost / ((float) $cost + $tax), wc_get_price_decimals() ), ); } } return $out; } /** * Published blog posts whose cover (featured image) three or more posts share, and those whose cover is a product's photo * (a product's main image, or an image uploaded to a product). @return array{reused:int,product:int,example:string} */ public static function blog_covers() : array { global $wpdb; $shared = ( $wpdb->get_results( $wpdb->prepare( "SELECT m.meta_value AS image, COUNT(*) AS n, MIN(p.ID) AS post FROM %i m INNER JOIN %i p ON p.ID=m.post_id WHERE m.meta_key='_thumbnail_id' AND m.meta_value NOT IN ('','0') AND p.post_type='post' AND p.post_status='publish' GROUP BY m.meta_value HAVING n>=%d ORDER BY n DESC", $wpdb->postmeta, $wpdb->posts, 3 ), ARRAY_A ) ?: array() ); $product = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM %i m INNER JOIN %i p ON p.ID=m.post_id INNER JOIN %i a ON a.ID=m.meta_value WHERE m.meta_key='_thumbnail_id' AND p.post_type='post' AND p.post_status='publish' AND ( EXISTS (SELECT 1 FROM %i pr WHERE pr.ID=a.post_parent AND pr.post_type='product') OR EXISTS (SELECT 1 FROM %i pm INNER JOIN %i pr ON pr.ID=pm.post_id WHERE pm.meta_key='_thumbnail_id' AND pm.meta_value=m.meta_value AND pr.post_type IN ('product','product_variation')) )", $wpdb->postmeta, $wpdb->posts, $wpdb->posts, $wpdb->posts, $wpdb->postmeta, $wpdb->posts ) ); return array( 'reused' => (int) array_sum( array_column( $shared, 'n' ) ), 'product' => $product, 'example' => ( $shared ? html_entity_decode( get_the_title( (int) $shared[0]['post'] ), ENT_QUOTES, 'UTF-8' ) : '' ), ); } /** * Pages of results and feeds that do not open (a live store, 28 Oct 2026: page 2 of the blog and of the shop, and the * feed, were sent to the home page): the second page of the blog, the shop and the biggest category while they have * one, and the site feed, each asked for once without following a redirect; who redirected is the response's * X-Redirect-By. Runs only as its own wp-cron event, never on a visit, and at most once a day: each answer waits 5 seconds * at most, and the result is kept a day even when nothing answered. * @return array */ public static function paging_check() : array { global $wp_rewrite; $urls = array(); if ( $wp_rewrite instanceof \WP_Rewrite && $wp_rewrite->using_permalinks() ) { $second = static fn( string $base ): string => user_trailingslashit( trailingslashit( $base ) . $wp_rewrite->pagination_base . '/2', 'paged' ); $blog = (int) get_option( 'page_for_posts' ); $base = ( get_option( 'show_on_front' ) === 'page' ? ( $blog ? (string) get_permalink( $blog ) : '' ) : home_url( '/' ) ); if ( $base !== '' && (int) wp_count_posts( 'post' )->publish > max( 1, (int) get_option( 'posts_per_page', 10 ) ) ) { $urls[] = $second( $base ); } if ( function_exists( 'wc_get_page_id' ) && function_exists( 'wc_get_default_products_per_row' ) ) { $per = max( 1, (int) apply_filters( 'loop_shop_per_page', wc_get_default_products_per_row() * wc_get_default_product_rows_per_page() ) ); $shop = (int) wc_get_page_id( 'shop' ); if ( $shop > 0 && (int) wp_count_posts( 'product' )->publish > $per ) { $urls[] = $second( (string) get_permalink( $shop ) ); } $top = get_terms( array( 'taxonomy' => 'product_cat', 'orderby' => 'count', 'order' => 'DESC', 'number' => 1, 'hide_empty' => true, ) ); if ( is_array( $top ) && $top && (int) $top[0]->count > $per ) { $link = get_term_link( $top[0] ); if ( is_string( $link ) ) { $urls[] = $second( $link ); } } } } $urls[] = (string) get_feed_link(); $header = static function ( $response, string $name ) : string { $value = wp_remote_retrieve_header( $response, $name ); return (string) (( is_array( $value ) ? end( $value ) : $value )); }; // Only as the scheduled event, so no visitor's request ever waits for it. if ( !wp_doing_cron() && !(defined( 'WP_CLI' ) && WP_CLI) && !apply_filters( 'storesuit_health_paging_now', false ) ) { return array(); } $broken = array(); $answered = 0; foreach ( array_values( array_unique( array_filter( $urls ) ) ) as $url ) { $response = wp_remote_get( $url, array( 'timeout' => 5, 'redirection' => 0, 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), 'user-agent' => 'StoreRuler page check', ) ); if ( is_wp_error( $response ) ) { continue; } ++$answered; $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code === 200 ) { continue; } $broken[] = array( 'url' => $url, 'code' => $code, 'to' => esc_url_raw( $header( $response, 'location' ) ), 'by' => sanitize_text_field( $header( $response, 'x-redirect-by' ) ), ); } set_transient( self::PAGING, array( 'at' => time(), 'broken' => $broken, 'answered' => $answered, ), DAY_IN_SECONDS ); return $broken; } /** * Published pages the previous theme filled (a live store, 28 Oct 2026: its /favoriler/ page showed nothing): the page * template they name is not in the active theme (nor one a plugin adds) and they have no content of their own, so they * open empty. The pages the store or WordPress works with are left out. At most 50. * @return array id => page */ public static function leftover_pages() : array { global $wpdb; $rows = ( $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title, p.post_content, m.meta_value AS template FROM %i p INNER JOIN %i m ON m.post_id=p.ID AND m.meta_key='_wp_page_template' WHERE p.post_type='page' AND p.post_status='publish' AND m.meta_value NOT IN ('','default') ORDER BY p.ID LIMIT %d", $wpdb->posts, $wpdb->postmeta, 500 ), ARRAY_A ) ?: array() ); if ( !$rows ) { return array(); } $known = wp_get_theme()->get_page_templates( null, 'page' ); $block = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(); $kept = array((int) get_option( 'page_on_front' ), (int) get_option( 'page_for_posts' ), (int) get_option( 'wp_page_for_privacy_policy' )); foreach ( array( 'shop', 'cart', 'checkout', 'myaccount', 'terms' ) as $page ) { $kept[] = (int) get_option( 'woocommerce_' . $page . '_page_id' ); } $found = array(); foreach ( $rows as $row ) { $id = (int) $row['ID']; $file = (string) $row['template']; if ( in_array( $id, $kept, true ) || !self::template_missing( $file, $known, $block ) ) { continue; } $text = (string) preg_replace( '//s', '', (string) $row['post_content'] ); if ( preg_match( '/<(?:img|iframe|video|audio|object|embed|form|svg)\\b|\\[/i', $text ) || (string) preg_replace( '/[\\s\\x{00A0}]+/u', '', html_entity_decode( wp_strip_all_tags( $text ), ENT_QUOTES, 'UTF-8' ) ) !== '' ) { continue; } $path = (string) wp_parse_url( (string) get_permalink( $id ), PHP_URL_PATH ); $found[$id] = array( 'id' => $id, 'title' => html_entity_decode( get_the_title( $id ), ENT_QUOTES, 'UTF-8' ), 'path' => $path, 'template' => $file, 'theme' => self::rule_answers( $path ), 'edit' => (string) get_edit_post_link( $id, 'raw' ), ); if ( count( $found ) >= 50 ) { break; } } return $found; } /** Whether a page template file is neither in the active theme (classic or block) nor one a plugin adds. */ private static function template_missing( string $file, array $known, bool $block ) : bool { return !isset( $known[$file] ) && !(validate_file( $file ) === 0 && locate_template( $file ) !== '') && !($block && get_block_template( get_stylesheet() . '//' . $file )); } /** * Pages with content that still name a template the active theme lacks (a live store, 28 Oct 2026: page 3196 kept * page-favoriler.php from the old theme). They show with the default template, but WordPress refuses every save of * them ("invalid page template"): the editor shows an error, wp-cli exits 1. Published, draft, pending, private and * scheduled pages; the empty published ones are leftover_pages(). At most 50. * @return array id => page */ public static function missing_templates() : array { global $wpdb; $rows = ( $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_status, m.meta_value AS template FROM %i p INNER JOIN %i m ON m.post_id=p.ID AND m.meta_key='_wp_page_template' WHERE p.post_type='page' AND p.post_status IN ('publish','draft','pending','private','future') AND m.meta_value NOT IN ('','default') ORDER BY p.ID LIMIT %d", $wpdb->posts, $wpdb->postmeta, 500 ), ARRAY_A ) ?: array() ); if ( !$rows ) { return array(); } $known = wp_get_theme()->get_page_templates( null, 'page' ); $block = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(); $empty = self::leftover_pages(); $found = array(); foreach ( $rows as $row ) { $id = (int) $row['ID']; $file = (string) $row['template']; if ( isset( $empty[$id] ) || !self::template_missing( $file, $known, $block ) ) { continue; } $found[$id] = array( 'id' => $id, 'title' => html_entity_decode( get_the_title( $id ), ENT_QUOTES, 'UTF-8' ), 'status' => (string) $row['post_status'], 'template' => $file, 'edit' => (string) get_edit_post_link( $id, 'raw' ), ); if ( count( $found ) >= 50 ) { break; } } return $found; } /** * Health's one click on those pages (confirmed): the default template, for one page or all of them. The template it named * is kept beside it (_storesuit_template_before) and in the activity log, so the previous theme's choice can be put back. */ public static function template_default( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); if ( empty( $data['confirm'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm the change first.' )); } $list = self::missing_templates(); $id = absint( $data['id'] ?? 0 ); $pages = ( !empty( $data['all'] ) ? $list : (( isset( $list[$id] ) ? array( $id => $list[$id], ) : array() )) ); $pages = array_filter( $pages, static fn( array $p ): bool => current_user_can( 'edit_page', $p['id'] ) ); if ( !$pages ) { throw new \InvalidArgumentException(I18n::t( 'This page no longer names a missing template, or you may not edit it. Read the checks again.' )); } foreach ( $pages as $page ) { update_post_meta( $page['id'], '_storesuit_template_before', $page['template'] ); update_post_meta( $page['id'], '_wp_page_template', 'default' ); clean_post_cache( $page['id'] ); Store::audit( 'settings', $page['id'], 'health_template_default', array( 'template' => $page['template'], ) ); } return array( 'ids' => array_keys( $pages ), 'count' => count( $pages ), ); } /** * Address rows (_billing_*, _shipping_*: names, e-mail, phone) left in wp_postmeta by orders that are gone (denetim R4-2): on * an id with no post at all, or on an HPOS placeholder post whose order no longer exists. WooCommerce's legacy set_address() * wrote them under HPOS, as other plugins and the HPOS sync did before. A live order's rows (a post, or a placeholder with * its order) are never counted. Read at most hourly; null when the database cannot say. */ public static function address_leftovers( bool $fresh = false ) : ?array { global $wpdb; $cached = ( $fresh ? false : get_transient( self::ADDRESS_LEFTOVERS ) ); if ( is_array( $cached ) ) { return $cached; } [$billing, $shipping] = self::address_keys(); $orders = self::hpos_orders_table(); $rows = array($wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS rows_found, COUNT(DISTINCT m.post_id) AS ids_found FROM %i m LEFT JOIN %i p ON p.ID=m.post_id WHERE p.ID IS NULL AND (m.meta_key LIKE %s OR m.meta_key LIKE %s)', $wpdb->postmeta, $wpdb->posts, $billing, $shipping ), ARRAY_A )); if ( $orders !== '' ) { $rows[] = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS rows_found, COUNT(DISTINCT m.post_id) AS ids_found FROM %i m INNER JOIN %i p ON p.ID=m.post_id AND p.post_type='shop_order_placehold' LEFT JOIN %i o ON o.id=p.ID WHERE o.id IS NULL AND (m.meta_key LIKE %s OR m.meta_key LIKE %s)", $wpdb->postmeta, $wpdb->posts, $orders, $billing, $shipping ), ARRAY_A ); } $found = array( 'rows' => 0, 'ids' => 0, ); foreach ( $rows as $row ) { if ( !is_array( $row ) ) { return null; } $found['rows'] += (int) $row['rows_found']; $found['ids'] += (int) $row['ids_found']; } set_transient( self::ADDRESS_LEFTOVERS, $found, HOUR_IN_SECONDS ); return $found; } /** The LIKE patterns of the address meta keys: _billing_* and _shipping_*. */ private static function address_keys() : array { global $wpdb; return array($wpdb->esc_like( '_billing_' ) . '%', $wpdb->esc_like( '_shipping_' ) . '%'); } /** WooCommerce's HPOS order table, or '' when this store has none. */ private static function hpos_orders_table() : string { global $wpdb; $orders = $wpdb->prefix . 'wc_orders'; return ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $orders ) ) ) === $orders ? $orders : '' ); } /** * Health's cleanup of those rows, only on the owner's confirmation: deleted in steps of 2,000 while the request lasts, the * count before and after kept in the activity log (never the values). What is left, if any, is counted again for the card. */ /** * The "on hold" and "processing" customer e-mails' subject when both are on and it is the same (as their settings read), else * null. Only while bank transfer or cheque payments are on: their orders wait on hold and then mail again when paid (a desk * order no longer sends "on hold"). */ public static function same_mail_subjects() : ?array { if ( !function_exists( 'WC' ) || !WC()->mailer() ) { return null; } $waits = false; foreach ( array('bacs', 'cheque') as $gateway ) { $settings = get_option( 'woocommerce_' . $gateway . '_settings', array() ); $waits = $waits || is_array( $settings ) && ($settings['enabled'] ?? 'no') === 'yes'; } if ( !$waits ) { return null; } $emails = WC()->mailer()->get_emails(); $hold = $emails['WC_Email_Customer_On_Hold_Order'] ?? null; $paid = $emails['WC_Email_Customer_Processing_Order'] ?? null; // The settings themselves: is_enabled() also asks the per-order filters about whichever order the e-mail last went out for. if ( !$hold instanceof \WC_Email || !$paid instanceof \WC_Email || $hold->get_option( 'enabled', 'yes' ) !== 'yes' || $paid->get_option( 'enabled', 'yes' ) !== 'yes' ) { return null; } $subject = static fn( \WC_Email $e ): string => trim( (string) (( $e->get_option( 'subject', '' ) ?: $e->get_default_subject() )) ); return ( $subject( $hold ) !== '' && mb_strtolower( $subject( $hold ) ) === mb_strtolower( $subject( $paid ) ) ? array( 'subject' => $subject( $hold ), ) : null ); } /** The "on hold" e-mail's own subject and heading, with WooCommerce's placeholders. */ private static function mail_on_hold_text() : array { return array( 'subject' => I18n::t( 'Your {site_title} order is waiting for payment confirmation' ), 'heading' => I18n::t( 'We have received your order' ), ); } /** Gives the "on hold" customer e-mail a subject and heading of its own (denetim R3-5), on confirmation; recorded with the old ones. */ public static function mail_subjects_fix( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); if ( empty( $data['confirm'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm the new subject first.' )); } if ( !self::same_mail_subjects() ) { return array( 'changed' => false, ); } $settings = get_option( 'woocommerce_customer_on_hold_order_settings', array() ); $settings = ( is_array( $settings ) ? $settings : array() ); $before = array( 'subject' => (string) ($settings['subject'] ?? ''), 'heading' => (string) ($settings['heading'] ?? ''), ); $settings = array_merge( $settings, self::mail_on_hold_text() ); update_option( 'woocommerce_customer_on_hold_order_settings', $settings ); Store::audit( 'settings', 0, 'mail_subjects_distinct', array( 'before' => $before, 'after' => self::mail_on_hold_text(), ) ); return array( 'changed' => true, ); } public static function address_leftovers_clean( array $data ) : array { global $wpdb; Store::require_cap( 'manage_options' ); if ( empty( $data['confirm'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm the change first.' )); } $before = self::address_leftovers( true ); $removed = 0; $until = microtime( true ) + 20; [$billing, $shipping] = self::address_keys(); $orders = self::hpos_orders_table(); do { $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT m.meta_id FROM %i m LEFT JOIN %i p ON p.ID=m.post_id WHERE p.ID IS NULL AND (m.meta_key LIKE %s OR m.meta_key LIKE %s) LIMIT %d', $wpdb->postmeta, $wpdb->posts, $billing, $shipping, 2000 ) ) ); if ( $orders !== '' && count( $ids ) < 2000 ) { $ids = array_merge( $ids, array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT m.meta_id FROM %i m INNER JOIN %i p ON p.ID=m.post_id AND p.post_type='shop_order_placehold' LEFT JOIN %i o ON o.id=p.ID WHERE o.id IS NULL AND (m.meta_key LIKE %s OR m.meta_key LIKE %s) LIMIT %d", $wpdb->postmeta, $wpdb->posts, $orders, $billing, $shipping, 2000 - count( $ids ) ) ) ) ); } if ( $ids ) { $gone = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array($wpdb->postmeta), $ids ) ) ); if ( $gone === false ) { break; } $removed += (int) $gone; } } while ( $ids && microtime( true ) < $until ); $after = self::address_leftovers( true ); Store::audit( 'settings', 0, 'health_address_leftovers', array( 'before' => $before, 'removed' => $removed, 'after' => $after, ) ); return array( 'removed' => $removed, 'left' => (int) ($after['rows'] ?? 0), ); } /** * Whether a rewrite rule of the theme or a plugin answers this address before WordPress looks for a page there (the * StoreRuler theme's favourites page, /favorilerim/ in Turkish): then a redirect from it would never answer, as redirects * answer only addresses that are not found. Read as WordPress matches a request: the first rule that matches wins; one * that asks for a page, post or attachment by its address is WordPress's own. */ private static function rule_answers( string $path ) : bool { global $wp_rewrite; if ( !$wp_rewrite instanceof \WP_Rewrite || !$wp_rewrite->using_permalinks() ) { return false; } $home = trim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' ); $request = trim( $path, '/' ); if ( $home !== '' && str_starts_with( $request, $home . '/' ) ) { $request = substr( $request, strlen( $home ) + 1 ); } foreach ( (array) $wp_rewrite->wp_rewrite_rules() as $match => $query ) { if ( !preg_match( '#^' . $match . '#', $request ) && !preg_match( '#^' . $match . '#', urldecode( $request ) ) ) { continue; } return !preg_match( '/[?&](?:pagename|page_id|name|p|attachment|attachment_id)=/', '?' . ltrim( (string) preg_replace( '/^index\\.php/', '', (string) $query ), '?' ) ); } return false; } /** * Health's action on one of those pages (owner's choice, confirmed): "draft" sets it to draft; "redirect" also sends its * address with a 301 to the page the owner names. The redirect is kept first: it answers once the address is not found. */ public static function leftover_page( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); $id = absint( $data['id'] ?? 0 ); $operation = (string) ($data['operation'] ?? ''); if ( !in_array( $operation, array('draft', 'redirect'), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid action.' )); } if ( empty( $data['confirm'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm the change first.' )); } $page = ( $id && current_user_can( 'edit_page', $id ) ? self::leftover_pages()[$id] ?? null : null ); if ( !$page ) { throw new \InvalidArgumentException(I18n::t( 'This page no longer opens empty, or you may not edit it. Read the checks again.' )); } $from = (string) get_permalink( $id ); $to = ''; if ( $operation === 'redirect' ) { if ( $page['theme'] ) { throw new \InvalidArgumentException(I18n::t( 'The theme shows its own page at this address, so a redirect would never answer. Set this page to draft.' )); } if ( !get_option( 'permalink_structure' ) ) { throw new \InvalidArgumentException(I18n::t( 'With plain permalinks a page has no address of its own to redirect. Set this page to draft.' )); } $to = Redirects::add( $from, (string) ($data['target'] ?? ''), 301, 'health' )['target'] ?? ''; } // An empty page_template leaves the template it names as it is: WordPress refuses to save a page whose template the // active theme lacks, and the previous theme would use it again if it came back. $done = wp_update_post( array( 'ID' => $id, 'post_status' => 'draft', 'page_template' => '', ), true ); if ( is_wp_error( $done ) || !$done ) { throw new \RuntimeException(I18n::t( 'The page could not be set to draft.' )); } Store::audit( 'settings', $id, 'health_page_' . $operation, array( 'from' => $from, 'to' => $to, 'template' => $page['template'], ) ); return array( 'id' => $id, 'status' => 'draft', 'from' => $from, 'redirect' => $to, ); } /** The one time zone of the store's country (WooCommerce's base country: Europe/Istanbul for Turkey), or '' where it has several. */ public static function country_zone() : string { $country = ( function_exists( 'WC' ) && WC()->countries ? (string) WC()->countries->get_base_country() : '' ); if ( !preg_match( '/^[A-Z]{2}$/', $country ) ) { return ''; } $zones = \DateTimeZone::listIdentifiers( \DateTimeZone::PER_COUNTRY, $country ); return ( count( $zones ) === 1 ? (string) $zones[0] : '' ); } /** Health's fix: WordPress's time zone set to the store country's one, while the store clock is still WordPress's default. */ public static function timezone_set() : array { Store::require_cap( 'manage_options' ); $zone = self::country_zone(); if ( $zone === '' || '' !== (string) get_option( 'timezone_string', '' ) || 0.0 !== (float) get_option( 'gmt_offset', 0 ) ) { throw new \InvalidArgumentException(I18n::t( 'The store time zone is already chosen, or the store country has more than one. Choose it under Settings › General.' )); } update_option( 'timezone_string', $zone ); Store::audit( 'settings', 0, 'health_timezone_set', array( 'zone' => $zone, ) ); return array( 'zone' => $zone, ); } /** An amount as a price is set: whole, or ending in ,50 ,90 ,95 or ,99 once rounded to the cent. */ private static function price_like( float $amount ) : bool { $cents = (int) round( $amount * 100 ) % 100; return in_array( $cents, array( 0, 50, 90, 95, 99 ), true ); } /** A zone offers free shipping from a minimum next to paid rates, and WooCommerce still lists the paid ones once it applies. */ private static function free_beside_paid() : bool { if ( get_option( 'woocommerce_shipping_hide_rates_when_free', 'no' ) === 'yes' || !class_exists( '\\WC_Shipping_Zones' ) ) { return false; } $zones = \WC_Shipping_Zones::get_zones(); $zones[] = array( 'shipping_methods' => ( new \WC_Shipping_Zone(0) )->get_shipping_methods( true ), ); foreach ( $zones as $zone ) { $free = false; $paid = false; foreach ( (array) ($zone['shipping_methods'] ?? array()) as $method ) { if ( !is_object( $method ) || isset( $method->enabled ) && $method->enabled !== 'yes' ) { continue; } if ( $method->id === 'free_shipping' && in_array( (string) $method->get_option( 'requires' ), array('min_amount', 'either'), true ) ) { $free = true; } if ( $method->id === 'flat_rate' ) { $paid = true; } } if ( $free && $paid ) { return true; } } return false; } /** * Plugins and must-use plugins that write a Product JSON-LD of their own beside WooCommerce's (which the SEO plugin and * StoreRuler complete): search engines then read two descriptions of one product, whose prices, stock or return days * may disagree (Almira tour: two old must-use plugins promised 30-day returns). Read from their files; nothing runs. * @return string[] Their names. */ public static function product_schema_sources() : array { global $wp_filter; $mu = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/'; $plugins = wp_normalize_path( WP_PLUGIN_DIR ) . '/'; $own = wp_normalize_path( STORESUIT_DIR ); $skip = array( 'woocommerce', 'wordpress-seo', 'wordpress-seo-premium', 'seo-by-rank-math', 'seo-by-rank-math-pro', 'all-in-one-seo-pack', 'all-in-one-seo-pack-pro', 'wp-seopress', 'wp-seopress-pro' ); $files = array(); foreach ( (array) glob( $mu . '*.php' ) as $file ) { $files[wp_normalize_path( (string) $file )] = true; } foreach ( array( 'wp_head', 'wp_footer', 'woocommerce_single_product_summary', 'woocommerce_after_single_product_summary', 'woocommerce_after_single_product' ) as $hook ) { if ( empty( $wp_filter[$hook] ) || !$wp_filter[$hook] instanceof \WP_Hook ) { continue; } foreach ( $wp_filter[$hook]->callbacks as $callbacks ) { foreach ( $callbacks as $callback ) { $file = self::callback_file( $callback['function'] ?? null ); if ( $file !== '' && (str_starts_with( $file, $mu ) || str_starts_with( $file, $plugins )) ) { $files[$file] = true; } } } } $found = array(); $names = null; foreach ( array_keys( $files ) as $file ) { if ( str_starts_with( $file, $own ) || !is_readable( $file ) || (int) filesize( $file ) > 2097152 ) { continue; } $is_mu = str_starts_with( $file, $mu ); $part = (string) strtok( substr( $file, strlen( ( $is_mu ? $mu : $plugins ) ) ), '/' ); if ( !$is_mu && in_array( $part, $skip, true ) ) { continue; } $source = (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local plugin file, read only. if ( stripos( $source, 'ld+json' ) === false || !preg_match( '/[\'"]@type[\'"]\\s*(?:=>|:)\\s*[\'"]Product[\'"]/', $source ) ) { continue; } if ( $is_mu ) { $data = get_file_data( $mu . $part, array( 'name' => 'Plugin Name', ) ); $found[] = 'MU: ' . wp_strip_all_tags( ( ($data['name'] ?? '') !== '' ? $data['name'] : $part ) ); } else { if ( $names === null ) { if ( !function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $names = array(); foreach ( get_plugins() as $basename => $info ) { $names[strtok( $basename, '/' )] = (string) $info['Name']; } } $found[] = wp_strip_all_tags( $names[$part] ?? $part ); } } return array_values( array_unique( $found ) ); } /** WordPress's privacy policy page still holds the template WordPress writes: its "Suggested text" guide carries this class in every language. */ private static function privacy_template( int $id ) : bool { $content = (string) get_post_field( 'post_content', $id ); return $content !== '' && (str_contains( $content, 'privacy-policy-tutorial' ) || str_contains( $content, 'Suggested text:' )); } /** Published policy and contract pages that still show a placeholder for the seller's details ("Adres bilgisi", "[telefon]", "XXXX"). @return array id => title */ private static function placeholder_pages() : array { $ids = array((int) get_option( 'wp_page_for_privacy_policy', 0 ), (int) wc_terms_and_conditions_page_id(), (int) get_option( 'woocommerce_refund_returns_page_id', 0 )); $kvkk = CheckoutLegal::kvkk_page(); if ( $kvkk ) { $ids[] = $kvkk['id']; } foreach ( CheckoutLegal::pages() as $page ) { $ids[] = (int) ($page['id'] ?? 0); } $found = array(); foreach ( array_unique( array_filter( $ids ) ) as $id ) { if ( get_post_status( $id ) !== 'publish' ) { continue; } $text = wp_strip_all_tags( (string) get_post_field( 'post_content', $id ) ); if ( preg_match( '/(?:adres|telefon|e-?posta|unvan|ünvan|vergi)\\s*:\\s*(?:adres|telefon|e-?posta|unvan|ünvan|vergi)\\s+bilgisi|^\\s*(?:adres|telefon|e-?posta|unvan|ünvan)\\s+bilgisi\\s*$|\\[[^\\]\\n]{0,40}(?:adres|telefon|unvan|ünvan|şirket|firma|e-?posta|address|phone|company)[^\\]\\n]{0,40}\\]|X{5,}/imu', $text ) ) { $found[$id] = html_entity_decode( get_the_title( $id ), ENT_QUOTES, 'UTF-8' ); } } return $found; } /** * The database stopped taking writes, or is getting big. A live store (4 Nov 2026): at about 3 GB the host took INSERT and * UPDATE away from the database user, and nothing in the panel said so. Hosts' limits differ and cannot be read, so size is * a warning from 2 GB (filter storeruler_database_warn_mb) or when the daily sizes say 3 GB is under two weeks away * (Housekeeping). Null when all is well or the size cannot be read. */ public static function database_room() : ?array { global $wpdb; if ( Housekeeping::writes_refused() ) { return array( 'status' => 'blocked', 'summary' => I18n::t( 'The database refuses new records.' ), 'detail' => I18n::t( 'New orders, stock changes and settings cannot be saved. This usually means the database reached the hosting limit: ask your host to give the database user its write permissions back.' ), ); } $largest = $wpdb->get_results( $wpdb->prepare( 'SELECT table_name AS t, (data_length+index_length)/1048576 AS mb FROM information_schema.tables WHERE table_schema=%s ORDER BY data_length+index_length DESC', DB_NAME ), ARRAY_A ); $total = (int) round( array_sum( array_column( (array) $largest, 'mb' ) ) ); if ( !$largest ) { return null; } $table = (string) $largest[0]['t']; $table_mb = number_format_i18n( (int) round( (float) $largest[0]['mb'] ) ); $left = Housekeeping::days_left( $total ); if ( $total < (int) apply_filters( 'storeruler_database_warn_mb', 2048 ) ) { if ( $left === null ) { return null; } return array( 'status' => 'attention', 'summary' => I18n::format( 'The database is growing about %s MB a day.', number_format_i18n( (int) round( Housekeeping::pace() ) ) ), 'detail' => I18n::format( 'At this pace it reaches 3 GB in about %1$s days, where many hosts stop accepting new records. It uses %2$s MB now. The largest table is %3$s (%4$s MB).', number_format_i18n( $left ), number_format_i18n( $total ), $table, $table_mb ), ); } return array( 'status' => 'attention', 'summary' => I18n::format( 'The database uses %s MB.', number_format_i18n( $total ) ), 'detail' => I18n::format( 'Many hosts stop accepting new records when a database reaches their limit, often 3 GB. The largest table is %1$s (%2$s MB). Check your plan\'s limit before it is reached.', $table, $table_mb ), ); } private static function check( string $id, string $title, string $status, string $summary, string $detail, string $url = '', string $action = '' ) : array { return array_filter( array( 'id' => $id, 'title' => $title, 'status' => $status, 'summary' => $summary, 'detail' => $detail, 'url' => $url, 'action' => $action, ), static fn( $value ) => '' !== $value ); } public static function read() : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $checks = array(); $required_tables = array($wpdb->prefix . 'storesuit_events', Store::keys_table(), OrderIndex::table()); $missing_tables = array(); foreach ( $required_tables as $table ) { if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) { $missing_tables[] = $table; } } $schemas_ready = get_option( 'storesuit_schema' ) === '2' && get_option( 'storesuit_money_schema' ) === '1' && get_option( 'storesuit_order_index_schema' ) === '3' && !$missing_tables; // The free build's words; collections, sales recorded by hand and returns are the paid build's (0.63). $records_ready = I18n::t( 'Order and activity history tables are ready.' ); $records_blocked = I18n::t( 'Reactivate the plugin before changing orders. If the problem continues, share the support summary.' ); $queues_ready = I18n::t( 'New, preparing, and awaiting-payment queues are available.' ); $checks[] = self::check( 'records', I18n::t( 'Safe record storage' ), ( $schemas_ready ? 'ready' : 'blocked' ), ( $schemas_ready ? $records_ready : I18n::t( 'StoreRuler record storage is incomplete.' ) ), ( $schemas_ready ? I18n::t( 'StoreRuler keeps a history and stops the same money or stock step from being saved twice.' ) : $records_blocked ), admin_url( 'plugins.php' ), ( $schemas_ready ? '' : I18n::t( 'Open plugins' ) ) ); $index_ready = get_option( 'storesuit_order_index_ready' ) === '1'; $checks[] = self::check( 'orders', I18n::t( 'Order search and work queues' ), ( $index_ready ? 'ready' : 'attention' ), ( $index_ready ? I18n::t( 'Order search and the order lists are up to date.' ) : I18n::t( 'The order list is waiting to be prepared.' ) ), ( $index_ready ? $queues_ready : I18n::t( 'Open Sales. StoreRuler prepares orders in batches without changing the real order records.' ) ), admin_url( 'admin.php?page=storesuit-orders' ), ( $index_ready ? '' : I18n::t( 'Prepare orders' ) ) ); $report_schema = get_option( 'storesuit_dashboard_facts_schema' ) === '1'; $report_ready = $report_schema && get_option( 'storesuit_dashboard_facts_ready' ) === '1'; $checks[] = self::check( 'business_overview', I18n::t( 'Business overview' ), ( $report_ready ? 'ready' : (( $report_schema ? 'attention' : 'blocked' )) ), ( $report_ready ? I18n::t( 'Sales overview is ready.' ) : I18n::t( 'Sales overview is being prepared.' ) ), ( $report_schema ? I18n::t( 'StoreRuler checks recent orders in small batches. Real orders and payments are not changed.' ) : I18n::t( 'The sales overview storage is unavailable. Ask your host to check the database.' ) ), // The sales overview's figures are on Today (view=today); the logo's page is Home since 0.62. admin_url( 'admin.php?page=storesuit&view=today' ), ( $report_ready ? '' : I18n::t( 'Open Today' ) ) ); $checkout_id = wc_get_page_id( 'checkout' ); $checkout_ready = $checkout_id > 0 && get_post_status( $checkout_id ) === 'publish'; // Links to pay online for orders recorded by hand are the paid build's; the free texts speak of the checkout itself. $checkout_detail = ( $checkout_ready ? I18n::t( 'Shoppers place their orders and choose how to pay on this page.' ) : I18n::t( 'Choose a published checkout page in WooCommerce advanced settings so shoppers can place orders.' ) ); // Turkish carriers deliver and send their SMS by the recipient's phone; the block checkout leaves it optional by default. $phone_missing = $checkout_ready && Shipping::ships_to_turkey() && !Shipping::checkout_phone_required(); $block_checkout = $checkout_ready && has_block( 'woocommerce/checkout', get_post( $checkout_id ) ); if ( $phone_missing ) { $checkout_detail = I18n::t( 'Turkish carriers need the recipient\'s phone number to deliver.' ) . ' ' . (( $block_checkout ? I18n::t( 'Edit the checkout page and set the phone number to required in the Checkout block\'s settings.' ) : I18n::t( 'Set the phone number to required under Appearance › Customize › WooCommerce › Checkout.' ) )); } $check = self::check( 'checkout', I18n::t( 'Checkout page' ), ( $checkout_ready ? ( $phone_missing ? 'attention' : 'ready' ) : 'blocked' ), ( $checkout_ready ? ( $phone_missing ? I18n::t( 'The checkout does not require a phone number.' ) : I18n::t( 'The WooCommerce checkout page is published.' ) ) : I18n::t( 'The WooCommerce checkout page is missing or not published.' ) ), $checkout_detail, ( $phone_missing ? ( $block_checkout ? admin_url( 'post.php?post=' . $checkout_id . '&action=edit' ) : admin_url( 'customize.php?autofocus[section]=woocommerce_checkout' ) ) : admin_url( 'admin.php?page=wc-settings&tab=advanced' ) ), ( $checkout_ready ? ( $phone_missing ? ( $block_checkout ? I18n::t( 'Edit the checkout page' ) : I18n::t( 'Open the Customizer' ) ) : '' ) : I18n::t( 'Open page settings' ) ) ); // "Specific countries" with none chosen ships nowhere: every checkout says no shipping method was found. A previous // theme may have filled the list in code and hidden it until the theme changed (owner, 26 Oct 2026, a live store). if ( $checkout_ready && wc_shipping_enabled() && !WC()->countries->get_shipping_countries() ) { $check = self::check( 'checkout', I18n::t( 'Checkout page' ), 'blocked', I18n::t( 'The store ships to no country: the checkout finds no shipping method.' ), I18n::t( 'In WooCommerce › Settings › General, the shipping locations are set to specific countries but none is chosen. Add the countries you ship to.' ), admin_url( 'admin.php?page=wc-settings&tab=general' ), I18n::t( 'Open shipping locations' ) ); } $checks[] = $check; $online = array(); foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) { if ( $gateway->enabled === 'yes' && !in_array( $gateway->id, array('bacs', 'cheque', 'cod'), true ) && $gateway->supports( 'products' ) ) { $online[] = $gateway->get_title(); } } // A store paid by bank transfer, cash on delivery or cheque works without a card method: then card payments are an option, // not something to do first (KIT-5). Without any payment method at all it stays a point to review. $offline = false; foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) { if ( $gateway->enabled === 'yes' && in_array( $gateway->id, array('bacs', 'cheque', 'cod'), true ) ) { $offline = true; } } $optional = !$online && $offline; $payments_title = ( $optional ? I18n::t( 'Online payments (optional)' ) : I18n::t( 'Online payments' ) ); $payments_detail = ( $online ? I18n::t( 'Shoppers can pay at checkout with one of your store\'s active methods.' ) : I18n::t( 'Bank transfer, cash, and cash on delivery continue to work. To take card payments at checkout, install and enable an online card method.' ) ); $checks[] = self::check( 'payments', $payments_title, ( $online ? 'ready' : (( $optional ? 'info' : 'attention' )) ), ( $online ? I18n::n( '%s online payment method is enabled.', '%s online payment methods are enabled.', count( $online ) ) : I18n::t( 'No active online card payment method was found.' ) ), $payments_detail, admin_url( 'admin.php?page=wc-settings&tab=checkout' ), ( $online ? '' : I18n::t( 'Open payment methods' ) ) ) + (( $optional ? array( 'optional' => true, ) : array() )); $product_health = Products::health(); // The free build has no product cost (owner, Q5): that count is 0 there, and its texts leave the cost out. $incomplete = (int) $product_health['missing_price'] + (int) ($product_health['missing_cost'] ?? 0) + (int) $product_health['missing_sku']; $catalog_complete = I18n::t( 'No products appear to be missing a sale price or SKU.' ); $catalog_counts = I18n::format( 'Missing price: %s · Missing product code (SKU): %s · Out of stock: %s', (int) $product_health['missing_price'], (int) $product_health['missing_sku'], (int) $product_health['out_of_stock'] ); // The action opens the product list on its filter for the first gap (the list's health= filter, one at a time). $gap = array_key_first( array_filter( array( 'missing_price' => (int) $product_health['missing_price'], 'missing_cost' => (int) ($product_health['missing_cost'] ?? 0), 'missing_sku' => (int) $product_health['missing_sku'], ) ) ); $checks[] = self::check( 'catalog', I18n::t( 'Product record completeness' ), ( $incomplete ? 'attention' : 'ready' ), ( $incomplete ? I18n::n( '%s gap in product details.', '%s gaps in product details; one product can count more than once.', $incomplete ) : $catalog_complete ), $catalog_counts, admin_url( 'admin.php?page=storesuit-products' . (( $gap ? '&health=' . $gap : '' )) ), ( $incomplete ? I18n::t( 'Open incomplete products' ) : '' ) ); // Data that an import or an old plugin wrote around WooCommerce (Almira tour, 27 Oct 2026): the price filter's table, // products with options all sold out but shown in stock, shipping charged above what was entered, a second Product schema. $stale = self::stale_lookup(); $rebuilding = function_exists( 'wc_update_product_lookup_tables_is_running' ) && wc_update_product_lookup_tables_is_running(); $checks[] = self::check( 'price_lookup', I18n::t( 'Price filter and sorting' ), ( $stale && !$rebuilding ? 'attention' : (( $rebuilding ? 'info' : 'ready' )) ), ( $rebuilding ? I18n::t( 'WooCommerce is rebuilding its product lookup tables.' ) : (( $stale ? I18n::n( '%s product sorts and filters by a price it no longer has.', '%s products sort and filter by a price they no longer have.', $stale ) : I18n::t( 'Price sorting and the price filter read the products\' current prices.' ) )) ), ( $stale ? I18n::t( 'Prices written straight into the database (by an import or a sync plugin) skip WooCommerce\'s lookup table: "lowest price first" and the price filter then show these products in the wrong place. Rebuild the table; WooCommerce does it in the background.' ) : I18n::t( 'WooCommerce keeps a lookup table of prices for sorting and filtering.' ) ) ) + (( $stale && !$rebuilding ? array( 'fix' => array( 'route' => 'health_lookup_rebuild', 'label' => I18n::t( 'Rebuild the lookup table' ), ), ) : array() )); $soldout = count( self::soldout_variables() ); $own_stock = count( VariableStock::conflicts() ); $checks[] = self::check( 'variable_stock', I18n::t( 'Products with options in stock' ), ( $soldout || $own_stock ? 'attention' : 'ready' ), ( $soldout ? I18n::n( '%s product shows "In stock" though all its options are sold out.', '%s products show "In stock" though all their options are sold out.', $soldout ) : (( $own_stock ? I18n::n( '%s product with options keeps a stock of its own that none of its options uses.', '%s products with options keep a stock of their own that none of their options uses.', $own_stock ) : I18n::t( 'Products with options follow their options\' stock.' ) )) ), ( $soldout || $own_stock ? (( $own_stock ? I18n::t( 'Each of their options keeps its own stock, so the product\'s own figure is never sold down and keeps the product "In stock". Syncing switches the product\'s own stock off (the old figure is noted) and takes the status from its options.' ) . ' ' : '' )) . I18n::t( 'Shoppers, the "In stock only" filter and search engines see them as available. Sync them: each takes its stock status from its options again.' ) : I18n::t( 'A product with options is in stock while one of its options is.' ) ) ) + (( $soldout || $own_stock ? array( 'fix' => array( 'route' => 'health_variable_stock_sync', 'label' => I18n::t( 'Sync stock status' ), ), ) : array() )); $gaps = self::shipping_tax_gaps(); $hide_free = self::free_beside_paid(); $money = static fn( float $amount ): string => html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, 'UTF-8' ); $checks[] = self::check( 'shipping_costs', I18n::t( 'Shipping costs shoppers see' ), ( $gaps ? 'attention' : (( $hide_free ? 'info' : 'ready' )) ), ( $gaps ? implode( ' ', array_map( static fn( array $g ): string => I18n::format( '"%1$s" (%2$s) is entered as %3$s; shoppers pay %4$s.', $g['title'], $g['zone'], $money( $g['cost'] ), $money( $g['shown'] ) ), $gaps ) ) : (( $hide_free ? I18n::t( 'Above the free-shipping minimum, the paid rates are still listed next to free shipping.' ) : I18n::t( 'Shipping costs are charged as entered.' ) )) ), ( $gaps ? I18n::format( 'Product prices include tax, but WooCommerce takes a taxable shipping cost as before tax and adds the tax on top. To charge %1$s, enter %2$s as the cost, or set the rate\'s tax status to None.', $money( $gaps[0]['cost'] ), wc_format_localized_price( (string) $gaps[0]['net'] ) ) : (( $hide_free ? I18n::t( 'StoreRuler picks free shipping once the cart reaches the minimum. To show it alone, turn on "Hide shipping rates when free shipping is available" in WooCommerce\'s shipping options.' ) : I18n::t( 'With prices including tax, a taxable shipping cost is entered before tax.' ) )) ), ( $gaps || $hide_free ? admin_url( 'admin.php?page=wc-settings&tab=shipping' . (( $gaps ? '' : '§ion=options' )) ) : '' ), ( $gaps || $hide_free ? I18n::t( 'Open shipping settings' ) : '' ) ); $schemas = self::product_schema_sources(); $checks[] = self::check( 'product_schema', I18n::t( 'Product data for search engines' ), ( $schemas ? 'attention' : 'ready' ), ( $schemas ? I18n::format( 'Another plugin also writes a Product schema: %s.', implode( ', ', $schemas ) ) : I18n::t( 'Each product page describes its product to search engines once.' ) ), ( $schemas ? I18n::t( 'Search engines then read two descriptions of one product, and their price, stock or return days may disagree with the shop. Keep WooCommerce\'s (your SEO plugin and StoreRuler complete it) and switch the other one off.' ) : I18n::t( 'WooCommerce writes it; your SEO plugin and StoreRuler complete it.' ) ), ( $schemas ? admin_url( 'plugins.php' . (( str_starts_with( $schemas[0], 'MU: ' ) ? '?plugin_status=mustuse' : '' )) ) : '' ), ( $schemas ? I18n::t( 'Check plugins' ) : '' ) ); // Stock and purchasing work: paid build. $alerts = array(); $store_address = trim( implode( ' ', array_filter( array(get_option( 'woocommerce_store_address', '' ), get_option( 'woocommerce_store_city', '' ), get_option( 'woocommerce_store_postcode', '' )) ) ) ); $store_ready = '' !== $store_address && '' !== get_woocommerce_currency() && '' !== WC()->countries->get_base_country(); $checks[] = self::check( 'store', I18n::t( 'Store basics' ), ( $store_ready ? 'ready' : 'attention' ), ( $store_ready ? I18n::t( 'Address, country, and currency are configured.' ) : I18n::t( 'The store address, country, or currency is missing.' ) ), ( $store_ready ? I18n::t( 'Shipping, tax and printouts use these details.' ) : I18n::t( 'Complete WooCommerce general settings before calculating sales and shipping.' ) ), admin_url( 'admin.php?page=wc-settings' ), ( $store_ready ? '' : I18n::t( 'Open store settings' ) ) ); // WordPress starts on UTC+0 with no city. A store left there shows its customers dispatch cut-offs ("ships today, // 4 h left"), delivery dates and campaign ends hours off, and closes its days at the wrong hour. $clock_default = '' === (string) get_option( 'timezone_string', '' ) && 0.0 === (float) get_option( 'gmt_offset', 0 ); // The store country's one time zone is offered in one step (admin audit A6: a Turkish store left on UTC showed two days at once). $zone = ( $clock_default ? self::country_zone() : '' ); $checks[] = self::check( 'timezone', I18n::t( 'Store time zone' ), ( $clock_default ? 'attention' : 'ready' ), ( $clock_default ? I18n::t( 'The store clock is UTC+0, WordPress\'s default; no city is chosen.' ) : I18n::format( 'Store time zone: %s', wp_timezone_string() ) ), (( $clock_default ? I18n::t( 'Choose your city under Settings › General › Timezone.' ) . ' ' : '' )) . I18n::t( 'Dispatch cut-offs, delivery dates, campaign end times and daily reports follow this clock.' ), ( $clock_default ? admin_url( 'options-general.php#timezone_string' ) : '' ), ( $clock_default ? I18n::t( 'Open general settings' ) : '' ) ) + (( $zone !== '' && current_user_can( 'manage_options' ) ? array( 'fix' => array( 'route' => 'health_timezone_set', 'label' => I18n::format( 'Use %s', $zone ), 'confirm' => I18n::format( 'Set the store time zone to %s? Times already recorded keep their moment; every screen shows them in this time from now on.', $zone ), ), ) : array() )); // Shoppers expect these pages before they pay, and many countries require them for online sales. $pages = array( I18n::t( 'Privacy policy' ) => (int) get_option( 'wp_page_for_privacy_policy', 0 ), I18n::t( 'Terms and conditions' ) => (int) wc_terms_and_conditions_page_id(), I18n::t( 'Refund and returns policy' ) => (int) get_option( 'woocommerce_refund_returns_page_id', 0 ), ); $missing_pages = array_keys( array_filter( $pages, static fn( int $id ) => !$id || get_post_status( $id ) !== 'publish' ) ); // A Turkish store's checkout writes the pre-information form and the distance sales contract from the seller details. $seller_missing = !$missing_pages && CheckoutLegal::turkish() && CheckoutLegal::wants_generated() && !CheckoutLegal::configured(); // The contract records are kept for 3 years (Mesafeli Sözleşmeler Yönetmeliği m.20); WooCommerce may anonymise sooner. $retention = CheckoutLegal::retention_days(); $retention_short = !$missing_pages && !$seller_missing && CheckoutLegal::pages() && $retention > 0 && $retention < 3 * 365; // A placeholder left for the seller's details, and WordPress's unedited privacy template (ChatGPT's audit of a live store, 27 Oct 2026). $placeholders = ( $missing_pages ? array() : self::placeholder_pages() ); $privacy_id = (int) get_option( 'wp_page_for_privacy_policy', 0 ); $privacy_template = !$missing_pages && $privacy_id && self::privacy_template( $privacy_id ); if ( $missing_pages ) { $legal = array( 'attention', I18n::format( 'Missing or unpublished: %s.', implode( ', ', $missing_pages ) ), I18n::t( 'Publish these pages and choose them in WordPress privacy settings and WooCommerce advanced settings, so the checkout can link them.' ), admin_url( ( in_array( I18n::t( 'Privacy policy' ), $missing_pages, true ) ? 'options-privacy.php' : 'admin.php?page=wc-settings&tab=advanced' ) ), I18n::t( 'Open page settings' ) ); } elseif ( $placeholders ) { $legal = array( 'attention', I18n::format( 'Placeholders are left in: %s.', implode( ', ', $placeholders ) ), I18n::t( 'Where a page still says "Adres bilgisi", "Telefon bilgisi", shows a bracket such as [address] or a row of X, write the seller\'s real details.' ), admin_url( 'post.php?post=' . (int) array_key_first( $placeholders ) . '&action=edit' ), I18n::t( 'Edit the page' ) ); } elseif ( $privacy_template ) { $kvkk = CheckoutLegal::kvkk_page(); $legal = array( 'attention', I18n::t( 'The privacy policy page is WordPress\'s unedited template.' ), ( $kvkk && $kvkk['id'] !== $privacy_id ? I18n::format( 'The checkout links it as your privacy policy. Choose your own notice (%s) under Settings › Privacy, or write the page.', $kvkk['title'] ) : I18n::t( 'The checkout links it as your privacy policy. Write your own text, or choose your own notice under Settings › Privacy.' ) ), admin_url( 'options-privacy.php' ), I18n::t( 'Open privacy settings' ) ); } elseif ( $seller_missing ) { $legal = array( 'attention', I18n::t( 'The contracts at checkout need the seller details.' ), I18n::t( 'Enter the seller\'s title, MERSİS or tax number, address, phone, e-mail, the carrier for returns and the dispatch time under Settings › General › Seller and contract details.' ), admin_url( 'admin.php?page=storesuit-settings#tab-general' ), I18n::t( 'Open seller details' ) ); } elseif ( $retention_short ) { $legal = array( 'attention', I18n::format( 'Completed orders are anonymised after %s days; contract records must be kept for 3 years.', $retention ), I18n::t( 'Set "Retain completed orders" under WooCommerce › Settings › Accounts & Privacy to at least 3 years, or leave it empty. The contract record of each order stays when an order is anonymised, but its addresses and details do not.' ), admin_url( 'admin.php?page=wc-settings&tab=account' ), I18n::t( 'Open privacy settings' ) ); } elseif ( CheckoutLegal::generated() && !CheckoutLegal::disclaimer() ) { // The written contracts are on, but nobody confirmed that they are drafts for the store to check. $legal = array( 'info', I18n::t( 'The contract texts are drafts: confirm that you have read the notice.' ), I18n::t( 'StoreRuler\'s contract texts are a general draft, not legal advice. Read the notice under Settings › General › Seller and contract details and confirm it.' ), admin_url( 'admin.php?page=storesuit-settings#tab-general' ), I18n::t( 'Open seller details' ) ); } else { $legal = array( 'ready', I18n::t( 'Privacy, terms and returns pages are published.' ), I18n::t( 'The checkout can link your policies.' ), admin_url( 'admin.php?page=wc-settings&tab=advanced' ), '' ); } $checks[] = self::check( 'legal', I18n::t( 'Store policies' ), $legal[0], $legal[1], $legal[2], $legal[3], $legal[4] ); // An order through the store's own checkout (classic or block). One made in the admin or by the sample data says // nothing about the checkout, and no order record shows that emails and stock were checked (Codex HEALTH-01). // One that went through: a draft (the block checkout makes one when the page opens, before WooCommerce 10.8), an unpaid // or a failed attempt is not it; a cancelled test order is (the check asks to cancel it). Second look H1. $went_through = array_values( array_diff( array_keys( wc_get_order_statuses() ), array('wc-pending', 'wc-failed', 'wc-checkout-draft') ) ); $has_order = (bool) wc_get_orders( array( 'limit' => 1, 'return' => 'ids', 'type' => 'shop_order', 'status' => $went_through, 'created_via' => array('checkout', 'store-api'), ) ); $checks[] = self::check( 'first_order', I18n::t( 'Test order' ), ( $has_order ? 'ready' : 'info' ), ( $has_order ? I18n::t( 'The store has taken at least one order through its checkout.' ) : I18n::t( 'No order has come through the checkout yet.' ) ), ( $has_order ? I18n::t( 'An order came through the checkout. If you have not yet, check that order\'s emails and stock once.' ) : I18n::t( 'Place a test order from the shop as a customer would, then check the order emails and stock. Cancel it afterwards.' ) ), ( $has_order ? '' : home_url( '/' ) ), ( $has_order ? '' : I18n::t( 'Open the shop' ) ) ) + (( $has_order ? array() : array( 'later' => true, ) )); $cron_disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; // With WP-Cron off only the server cron runs the scheduler: how often it really does (cron_seen()). $gap = ( $cron_disabled ? self::cron_gap() : null ); $every_minute = $gap !== null && $gap <= 150; // The paid build adds its background wake-up state to this check (same id; extra fields for its card). $background = apply_filters( 'storesuit_health_background', self::check( 'background', I18n::t( 'Background jobs' ), ( $cron_disabled && !$every_minute ? 'attention' : 'ready' ), ( $every_minute ? I18n::t( 'A server cron runs the background jobs about once a minute.' ) : (( $cron_disabled ? I18n::t( 'WordPress\'s built-in scheduler is disabled.' ) : I18n::t( 'The WordPress scheduler is enabled.' ) )) ), ( $every_minute ? I18n::t( 'WordPress\'s built-in scheduler is disabled, as it should be with a server cron.' ) : (( $cron_disabled ? I18n::t( 'This is normal if your host runs a real scheduled job. Otherwise email and background work may be delayed; confirm with your hosting provider.' ) : I18n::t( 'WooCommerce email and maintenance jobs can run as the site receives visits.' ) )) ), admin_url( 'tools.php?page=action-scheduler' ), ( $cron_disabled ? I18n::t( 'Check scheduled jobs' ) : '' ) ) ); // A slow server cron is said whatever else the card shows, unless the wake-up service already runs the jobs. if ( $gap !== null && !$every_minute && is_array( $background ) && empty( $background['wake']['connected'] ) ) { $background = array_merge( $background, array( 'status' => 'attention', 'summary' => I18n::format( 'Background jobs run only every %s minutes; set the server cron to every minute.', max( 3, (int) round( $gap / 60 ) ) ), 'detail' => I18n::t( 'Stock updates, emails and other background work wait until the next run. In your hosting panel, set the cron job that calls wp-cron.php to every minute (* * * * *) with one of these commands. Your host may need the full path of php, such as /usr/bin/php.' ), 'commands' => self::cron_commands(), ) ); } // A scheduler that stopped altogether (Denetim AYR-1): said first and loudest, whatever else the card says. $stopped = self::cron_stopped(); if ( $stopped !== null && is_array( $background ) ) { $background = array_merge( $background, array( 'status' => 'blocked', 'summary' => I18n::format( 'Background jobs have stopped: WordPress has run none for %s minutes.', (int) floor( $stopped / 60 ) ), 'detail' => I18n::t( 'Emails, stock updates and scheduled work are waiting. A plugin\'s fatal error during the scheduler\'s run, or a server cron that no longer calls wp-cron.php, stops them. Look for a fatal error in the PHP error log, and check that the hosting panel\'s cron job still runs one of these commands every minute.' ), 'commands' => self::cron_commands(), ) ); } $checks[] = $background; // Every plugin's background jobs in Action Scheduler: stuck in progress, or pending far past their time. $jobs = self::scheduled_jobs(); if ( $jobs['available'] ) { $late = $jobs['stuck'] || $jobs['overdue']; // Jobs that stopped half way this week (jobs_died()): three or more ask to look; most from the async runner points at the cure. $died = self::jobs_died(); $dying = $died['count'] >= 3; $async = $died['count'] > 0 && $died['async'] * 2 > $died['count']; $detail = ( $jobs['stuck'] ? I18n::t( 'A job stuck in progress keeps its queue waiting, usually after a timeout or a fatal error in the plugin that runs it. Mark them as failed to free the queue, then check that plugin; failed jobs can be run again from Scheduled Actions.' ) : (( $jobs['overdue'] ? I18n::t( 'Late jobs mean the queue runs too rarely or too slowly: check that the server cron runs every minute, and the plugin with the most late jobs.' ) : (( $dying ? ( $async ? I18n::t( 'Most of them were started by an async request (admin-ajax.php), which the server closes half way when the page that asked for it is gone. StoreRuler marks such jobs failed and frees the queue within minutes; keeping them running is a server setting.' ) : I18n::t( 'StoreRuler marks jobs that stopped half way as failed and frees their queue within minutes. Check the plugin with the most of them, and the PHP error log.' ) ) : I18n::t( 'Every plugin\'s jobs in Action Scheduler are read here, not only StoreRuler\'s.' ) )) )) ); $check = self::check( 'scheduled_jobs', I18n::t( 'Scheduled jobs' ), ( $late || $dying ? 'attention' : 'ready' ), ( $late ? I18n::format( '%1$s jobs are stuck in progress and %2$s are more than 15 minutes late.', $jobs['stuck'], $jobs['overdue'] ) : (( $dying ? I18n::format( '%1$s jobs stopped half way in the last 7 days; %2$s of them were started by an async request.', $died['count'], $died['async'] ) : I18n::t( 'No scheduled job is stuck or late.' ) )) ), $detail, ( $late || $dying ? admin_url( 'admin.php?page=wc-status&tab=action-scheduler&status=' . (( $jobs['stuck'] ? 'in-progress' : (( $late ? 'pending' : 'failed' )) )) ) : '' ), ( $late || $dying ? I18n::t( 'Open scheduled actions' ) : '' ) ) + array( 'jobs' => $jobs['jobs'], ) + (( $jobs['stuck'] ? array( 'fix' => array( 'route' => 'health_jobs_fail_stuck', 'label' => I18n::t( 'Mark stuck jobs as failed' ), 'confirm' => I18n::format( 'Mark %s jobs stuck in progress as failed? They stop where they are; you can run them again from Scheduled Actions.', $jobs['stuck'] ), ), ) : array() )); if ( $died['count'] > 0 ) { $check['died'] = $died['kinds']; } if ( $jobs['stuck'] || $died['count'] > 0 ) { $check['started'] = array( 'async' => I18n::t( 'an async request (admin-ajax.php)' ), 'cron' => 'WP-Cron', 'cli' => 'WP-CLI', 'admin' => I18n::t( 'the Scheduled Actions screen' ), 'storeruler' => 'StoreRuler', 'unknown' => I18n::t( 'not recorded' ), ); } $checks[] = $check; } // Plugins (and must-use plugins) that print into the storefront next to the theme: an old theme's helpers keep adding // blocks and styles to the new one, and the merchant only notices by looking (owner, 27 Oct 2026, a live store). $additions = self::storefront_plugins(); $unknown = array_values( array_filter( $additions, static fn( array $p ): bool => !$p['known'] ) ); $checks[] = self::check( 'storefront_plugins', I18n::t( 'Plugins that add to the storefront' ), ( $unknown ? 'attention' : 'ready' ), ( $unknown ? I18n::format( '%1$s plugins add to the storefront pages: %2$s.', count( $unknown ), implode( ', ', array_column( $unknown, 'name' ) ) ) : (( $additions ? I18n::t( 'Every plugin that adds to the storefront is marked as known.' ) : I18n::t( 'No other plugin adds to the storefront pages.' ) )) ), ( $unknown ? I18n::t( 'They print blocks, styles or scripts on the store\'s pages next to the theme. One left from an old theme can change the design: deactivate it. Mark the ones you know and want.' ) : I18n::t( 'Only plugins whose additions run on every request are seen here.' ) ), ( $unknown ? admin_url( 'plugins.php' ) : '' ), ( $unknown ? I18n::t( 'Check plugins' ) : '' ) ) + array( 'items' => $additions, ); // Kinds of address that now return 404, those the previous theme served first (owner, 27 Oct 2026: a third of a // live store's search visits went to its old theme's own addresses). The miss report and the switch snapshot only. $routes = Redirects::lost_routes(); $previous = get_option( Redirects::PREVIOUS_ROUTES ); $checks[] = self::check( 'old_routes', I18n::t( 'Addresses that stopped working' ), ( $routes ? 'attention' : 'ready' ), ( $routes ? I18n::format( 'Visitors still ask for addresses that now return "not found": %s.', implode( ', ', array_map( static fn( array $r ): string => I18n::format( '%1$s… (%2$s visits)', $r['prefix'], $r['hits'] ), $routes ) ) ) : I18n::t( 'No kind of address returns "not found" often.' ) ), ( $routes ? ( is_array( $previous ) && array_filter( array_column( $routes, 'previous' ) ) ? I18n::format( 'Your previous theme (%s) served addresses like these. Redirect them with a 301 to the closest category or page, so their visitors and search rankings are kept.', (string) ($previous['theme'] ?? '') ) : I18n::t( 'Redirect them with a 301 to the closest category or page, so their visitors and search rankings are kept.' ) ) : I18n::t( 'StoreRuler keeps a list of addresses that were asked for and not found.' ) ), ( $routes ? admin_url( 'admin.php?page=storesuit-redirects' ) : '' ), ( $routes ? I18n::t( 'Open redirects' ) : '' ) ) + array( 'routes' => $routes, ); // Blog covers (SeoAudit backlog, 28 Oct 2026: one image on 35 of a live store's posts, product photos as covers): a list to // look at, nothing is changed. A cover many posts share makes them look alike in search results and when shared. The // card is there only while there is something to look at. $covers = self::blog_covers(); if ( $covers['reused'] || $covers['product'] ) { $checks[] = self::check( 'blog_covers', I18n::t( 'Blog post covers' ), 'info', trim( (( $covers['reused'] ? I18n::format( '%1$s posts share one cover image (%2$s, for example).', $covers['reused'], $covers['example'] ) . ' ' : '' )) . (( $covers['product'] ? I18n::format( '%s posts use a product photo as their cover.', $covers['product'] ) : '' )) ), I18n::t( 'Search results and shared links show a post\'s cover: a picture of its own, made for the post, tells posts apart. A product photo is fine inside the post.' ), admin_url( 'edit.php?post_type=post' ), I18n::t( 'Open posts' ) ); } // Pages of results and feeds (28 Oct 2026): checked in the background, the card is there only while one does not open. $paging = get_transient( self::PAGING ); if ( !is_array( $paging ) && !wp_next_scheduled( self::PAGING_EVENT ) ) { wp_schedule_single_event( time(), self::PAGING_EVENT ); } $broken = ( is_array( $paging ) ? array_values( array_filter( (array) ($paging['broken'] ?? array()), 'is_array' ) ) : array() ); if ( $broken ) { $said = array_map( static fn( array $b ): string => ( $b['to'] !== '' ? ( $b['by'] !== '' ? I18n::format( '%1$s goes to %2$s (%3$s, by %4$s)', $b['url'], $b['to'], $b['code'], $b['by'] ) : I18n::format( '%1$s goes to %2$s (%3$s)', $b['url'], $b['to'], $b['code'] ) ) : I18n::format( '%1$s answers %2$s', $b['url'], $b['code'] ) ), $broken ); $checks[] = self::check( 'paging', I18n::t( 'Pages of results and feeds' ), 'attention', I18n::format( 'Pages of results or feeds that do not open: %s.', implode( '; ', $said ) ), I18n::t( 'Search engines and shoppers reach older products from page 2 on, and readers get new posts from the feed. A redirect here usually comes from an SEO or redirect plugin\'s rule: the plugin named after "by" made it. The check runs again every day.' ), (string) $broken[0]['url'], I18n::t( 'Open the page' ) ); } // Pages left from the previous theme (28 Oct 2026): the card is there only while one opens empty. $leftover = array_values( self::leftover_pages() ); if ( $leftover ) { $checks[] = self::check( 'leftover_pages', I18n::t( 'Pages left from the previous theme' ), 'attention', I18n::format( 'Published pages that open empty: %s.', implode( ', ', array_map( static fn( array $p ): string => $p['title'] . ' (' . $p['path'] . ')', $leftover ) ) ), I18n::t( 'The previous theme filled these pages with a template of its own, which the active theme does not have, and they have no content. Set each one to draft, or to draft with its address sent to another page, so visitors and search engines no longer land on an empty page.' ) ) + array( 'pages' => $leftover, 'redirects' => Redirects::ready() && get_option( 'permalink_structure' ), ); } // Address rows of orders that are gone (denetim R4-2): the card is there only while some are left; deleting asks the owner. $addresses = self::address_leftovers(); if ( $addresses && $addresses['rows'] > 0 ) { $checks[] = self::check( 'address_leftovers', I18n::t( 'Customer addresses left by deleted orders' ), 'attention', I18n::format( '%1$s address rows of %2$s orders that no longer exist are still in the database.', $addresses['rows'], $addresses['ids'] ), I18n::t( 'Names, e-mail addresses and phone numbers of orders that were deleted stayed behind in the post meta table. They come from WooCommerce\'s older address writing under High-Performance Order Storage, also through other plugins or an earlier sync. No order uses them. A site administrator can delete them after confirming; live orders are not touched.' ) ) + array( 'address_leftovers' => $addresses + array( 'can_clean' => current_user_can( 'manage_options' ), ), ); } // Denetim R3-5: two customer e-mails with one subject, the shopper cannot tell them apart; a subject of its own on confirmation. $subjects = self::same_mail_subjects(); if ( $subjects ) { $checks[] = self::check( 'mail_subjects', I18n::t( 'Two order e-mails with the same subject' ), 'attention', I18n::format( 'The customer\'s “on hold” and “processing” e-mails both have the subject “%s”.', $subjects['subject'] ), I18n::t( 'A customer who gets both (an order paid by bank transfer, for example) sees the same e-mail twice and cannot tell that the second one confirms the payment. The “on hold” e-mail can get a subject and heading of its own; the “processing” one stays as it is.' ) ) + array( 'mail_subjects' => $subjects + array( 'subject_new' => self::mail_on_hold_text()['subject'], 'heading_new' => self::mail_on_hold_text()['heading'], 'can_fix' => current_user_can( 'manage_woocommerce' ), ), ); } $missing = array_values( self::missing_templates() ); if ( $missing ) { $checks[] = self::check( 'missing_templates', I18n::t( 'Pages naming a template the theme does not have' ), 'attention', I18n::format( 'Pages whose template is missing: %s.', implode( ', ', array_map( static fn( array $p ): string => $p['title'] . ' (' . $p['template'] . ')', $missing ) ) ), I18n::t( 'These pages still name a template of an earlier theme. Visitors see them with the default template, but WordPress refuses to save them ("invalid page template"). Use the default template: the page content stays as it is, and the old template name is kept in the activity log.' ) ) + array( 'templates' => $missing, ); } // External connections and the bundled engines (their standalone copies, their module list): paid build. $conflicts = array(); $modules = 0; $installed_version = (string) get_option( 'storesuit_version', '' ); $version_ready = $installed_version === STORESUIT_VERSION; $checks[] = self::check( 'version', I18n::t( 'StoreRuler update' ), ( $version_ready ? 'ready' : 'blocked' ), ( $version_ready ? I18n::t( 'The code and saved data version match.' ) : I18n::t( 'The saved data version and installed code do not match.' ) ), ( $version_ready ? I18n::t( 'StoreRuler finished its last update.' ) : I18n::t( 'Deactivate and activate StoreRuler once, then press Check again. If it stays, send the support file.' ) ), admin_url( 'plugins.php' ), ( $version_ready ? '' : I18n::t( 'Review installed plugins' ) ) ); $database = self::database_room(); if ( $database ) { $checks[] = self::check( 'database_room', I18n::t( 'Database' ), $database['status'], $database['summary'], $database['detail'] ); } $counts = array( 'ready' => 0, 'attention' => 0, 'blocked' => 0, 'info' => 0, 'later' => 0, ); // An optional item (card payments where bank transfer or cash on delivery works) is nothing waiting: it counts as ready. // A step for when the owner is ready (a test order, a first connection) is not waiting work either: 'later', not 'info'. foreach ( $checks as $check ) { ++$counts[( !empty( $check['optional'] ) ? 'ready' : (( !empty( $check['later'] ) ? 'later' : $check['status'] )) )]; } $wp_version = get_bloginfo( 'version' ); $wc_version = ( defined( 'WC_VERSION' ) ? WC_VERSION : '' ); $versions_ready = version_compare( $wp_version, '6.5', '>=' ) && version_compare( $wc_version, '8.0', '>=' ) && version_compare( PHP_VERSION, '8.0', '>=' ); $order_storage = ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ? 'HPOS' : 'classic' ); return array( 'checks' => $checks, 'counts' => $counts, 'status' => ( $counts['blocked'] ? 'blocked' : (( $counts['attention'] ? 'attention' : 'ready' )) ), 'generated_at' => current_time( 'Y-m-d H:i' ), 'compatibility' => array( 'status' => ( $conflicts || !$versions_ready || !$version_ready ? 'blocked' : 'ready' ), 'wordpress' => $wp_version, 'woocommerce' => $wc_version, 'php' => PHP_VERSION, 'versions_ready' => $versions_ready, 'order_storage' => $order_storage, 'included_modules' => $modules, 'duplicate_modules' => $conflicts, 'installed_version' => $installed_version, 'previous_version' => (string) get_option( 'storesuit_previous_version', '' ), 'version_ready' => $version_ready, ), 'diagnostics' => array( 'generated_at' => gmdate( 'c' ), 'storesuit' => STORESUIT_VERSION, 'wordpress' => get_bloginfo( 'version' ), 'woocommerce' => ( defined( 'WC_VERSION' ) ? WC_VERSION : '' ), 'php' => PHP_VERSION, 'order_storage' => $order_storage, 'environment' => ( function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production' ), 'cron_disabled' => $cron_disabled, 'cron_gap' => self::cron_gap(), 'checks' => array_map( static fn( $check ) => array( 'id' => $check['id'], 'status' => $check['status'], 'summary' => $check['summary'], ), $checks ), 'products' => array_map( 'intval', $product_health ), 'work' => array_map( 'intval', $alerts ), 'support' => self::support_details(), ), ); } }