* * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS; use WCPOS\WooCommercePOS\Admin\Consent; use WCPOS\WooCommercePOS\Services\Lifecycle_Events; use WCPOS\WooCommercePOS\Sync\Api as Sync_Api; use WCPOS\WooCommercePOS\Sync\Health as Sync_Health; use WCPOS\WooCommercePOS\Sync\Integrity_Digest; use WCPOS\WooCommercePOS\Sync\Mutation_Store; use WCPOS\WooCommercePOS\Sync\Sync_Journal; use const DOING_AJAX; /** * Activator class. */ class Activator { /** * Lock name used by WP_Upgrader::create_lock(). */ private const DB_UPGRADE_LOCK_NAME = 'woocommerce_pos_db_upgrade_lock'; /** * Lock TTL in seconds. */ private const DB_UPGRADE_LOCK_TTL = 600; /** * Constructor. */ public function __construct() { register_activation_hook( PLUGIN_FILE, array( $this, 'activate' ) ); add_action( 'wpmu_new_blog', array( $this, 'activate_new_site' ) ); add_action( 'plugins_loaded', array( $this, 'init' ) ); } /** * Checks for valid install and begins execution of the plugin. */ public function init(): void { // Check for min requirements to run. if ( $this->php_check() && $this->woocommerce_check() ) { // Defer permalink check to admin_init so __() calls happen after // after_setup_theme (WordPress 6.7+ triggers a notice otherwise). if ( is_admin() && ( ! \defined( '\DOING_AJAX' ) || ! DOING_AJAX ) ) { // @phpstan-ignore-line add_action( 'admin_init', function () { $this->permalink_check(); } ); } // Init update script if required. $this->version_check(); $this->pro_version_check(); // resolve plugin plugins. $this->plugin_check(); new Init(); } } /** * Fired when the plugin is activated. * * @param bool $network_wide Whether to activate network-wide. */ public function activate( $network_wide ): void { if ( \function_exists( 'is_multisite' ) && is_multisite() ) { if ( $network_wide ) { // Get all blog ids. $blog_ids = $this->get_blog_ids(); foreach ( $blog_ids as $blog_id ) { switch_to_blog( $blog_id ); $this->single_activate(); restore_current_blog(); } } else { self::single_activate(); } } else { self::single_activate(); } } /** * Fired when the plugin is activated. * * @param bool $install_sync_schema Whether to install the sync schema. */ public function single_activate( bool $install_sync_schema = true ): void { $role_capabilities = self::role_capability_definition(); // Reseed the default template terms on the next request: (re)activation // is the repair a merchant reaches for after deleting a term by hand. // This also runs once per upgrade (version_check re-activates to sync // role caps), so one post-upgrade request pays the ~18 seeding queries. delete_option( Templates::DEFAULT_TERMS_OPTION ); // Second, merchant-reachable trigger for the autoload repair: db_upgrade() // only runs when version_check() trips on an admin load that reaches // woocommerce_init, and a miss there is permanent once bump_versions() ran. self::autoload_request_latches(); Admin\Permalink::ensure_default(); // create POS specific roles. $this->create_pos_roles(); // add pos capabilities to non POS roles. $this->add_pos_capability( array( 'administrator' => $role_capabilities['administrator'], 'shop_manager' => $role_capabilities['shop_manager'], ) ); $stored_roles = get_option( wp_roles()->role_key, array() ); $roles_are_persisted = is_array( $stored_roles ); if ( $roles_are_persisted ) { foreach ( $role_capabilities as $slug => $capabilities ) { $required_capabilities = 'cashier' === $slug ? array_merge( array( 'access_woocommerce_pos' ), array_keys( $capabilities ) ) : $capabilities; foreach ( $required_capabilities as $capability ) { if ( empty( $stored_roles[ $slug ]['capabilities'][ $capability ] ) ) { $roles_are_persisted = false; break 2; } } } } $obsolete_customer_create_cap = isset( $role_capabilities['cashier']['create_customers'] ) ? 'promote_users' : 'create_customers'; if ( $roles_are_persisted && empty( $stored_roles['cashier']['capabilities'][ $obsolete_customer_create_cap ] ) ) { update_option( 'woocommerce_pos_role_caps_fingerprint', $this->role_caps_fingerprint(), true ); } // Flag the consent pop-up for the next admin page load. Done here // because the `activated_plugin` action in Admin\Consent fires // inside the activation request, at which point our plugin's // `plugins_loaded` callback hasn't yet instantiated Init on a // fresh install. // // Read the option directly — woocommerce_pos_get_settings() lives in // wcpos-functions.php which Init loads on `plugins_loaded`, but // plugins_loaded has already fired by the time activation runs. $general_settings = get_option( 'woocommerce_pos_settings_general', array() ); $tracking_consent = is_array( $general_settings ) && isset( $general_settings['tracking_consent'] ) ? $general_settings['tracking_consent'] : 'undecided'; if ( 'undecided' === $tracking_consent ) { set_transient( Consent::MODAL_TRANSIENT, 1, Consent::MODAL_TRANSIENT_TTL ); } if ( $install_sync_schema ) { $this->install_sync_schema(); } // Record the install for analytics. Consent is still `undecided` at this // point, so the event is held until the user answers the pop-up flagged // above; Lifecycle_Events owns that deferral and reports at most once. ( new Lifecycle_Events() )->record_install(); } /** * Install the sync store and latch its aggregate schema version after verification. */ public function install_sync_schema(): void { $previous_schema = get_option( Sync_Api::SCHEMA_OPTION, null ); $journal = new Sync_Journal(); $journal->install(); ( new Integrity_Digest() )->install(); ( new Mutation_Store() )->install(); if ( ! Sync_Health::is_healthy() ) { if ( Sync_Api::SCHEMA_VERSION === $previous_schema ) { delete_option( Sync_Api::SCHEMA_OPTION ); } return; } // Schema 3 (#1379): the customer space widened from role=customer to ALL users. // Upgrading installs carry role-departure tombstones in the persisted stream that // would replay against now-live users; compensating updates supersede them (see // Sync_Journal::append_customer_updates_for_all_users). The old latch stays until // migration succeeds, so retries may append duplicate but harmless superseding // updates. Fresh installs (no previous latch) have no stream to repair. if ( null !== $previous_schema && version_compare( (string) $previous_schema, '3', '<' ) && ! $journal->append_customer_updates_for_all_users() ) { return; } // Autoloaded: the Init constructor reads this latch on every request. // This flips an existing row only on WP 6.4+; older rows are flipped by // autoload_request_latches() on upgrade. update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, true ); if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) { global $wpdb; $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_order_index" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. // The legacy Change_Log_Purge class is gone; its recurring cron event // would otherwise survive the upgrade and fire a hook with no handler // forever. Literal hook name — the constant was removed with the class. wp_clear_scheduled_hook( 'wcpos_change_log_purge' ); } } /** * Fired when a new site is activated with a WPMU environment. * * @param int $blog_id Blog ID. */ public function activate_new_site( $blog_id ): void { if ( 1 !== did_action( 'wpmu_new_blog' ) ) { return; } switch_to_blog( $blog_id ); $this->single_activate(); restore_current_blog(); } /** * Check min version of PHP. */ private function php_check() { $php_version = PHP_VERSION; if ( version_compare( $php_version, PHP_MIN_VERSION, '>' ) ) { return true; } // Defer __() call to avoid "too early" warning in WordPress 6.7+. add_action( 'admin_init', function () { $message = \sprintf( // translators: 1: Minimum PHP version, 2: Update URL. __( 'WCPOS requires PHP %1$s or higher. Read more information about how you can update', 'woocommerce-pos' ), PHP_MIN_VERSION, 'http://www.wpupdatephp.com/update/' ) . ' »'; Admin\Notices::add( $message ); } ); } /** * Check min version of WooCommerce installed. */ private function woocommerce_check() { if ( class_exists( '\WooCommerce' ) && version_compare( WC()->version, WC_MIN_VERSION, '>=' ) ) { return true; } // Defer __() call to avoid "too early" warning in WordPress 6.7+. add_action( 'admin_init', function () { $message = \sprintf( // translators: 1: WooCommerce URL, 2: Minimum WC version, 3: Plugins URL. __( 'WCPOS requires WooCommerce %2$s or higher. Please install and activate WooCommerce', 'woocommerce-pos' ), 'http://wordpress.org/plugins/woocommerce/', WC_MIN_VERSION, admin_url( 'plugins.php' ) ) . ' »'; Admin\Notices::add( $message ); } ); } /** * POS Frontend will give 404 if pretty permalinks not active. */ private function permalink_check(): void { $permalinks = get_option( 'permalink_structure' ); // early return. if ( $permalinks ) { return; } $message = /* translators: Plugin activation notice label. */ __( 'WooCommerce REST API requires pretty permalinks to work correctly', 'woocommerce-pos' ) . '. '; $message .= \sprintf( '%s', admin_url( 'options-permalink.php' ), /* translators: Plugin activation notice label. */ __( 'Enable permalinks', 'woocommerce-pos' ) ) . ' »'; Admin\Notices::add( $message ); } /** * Check version number, runs every admin page load. */ private function version_check(): void { $old = (string) Services\Settings::get_db_version(); $plugin_needs_upgrade = version_compare( $old, VERSION, '<' ); $sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); $role_caps_fingerprint = $this->role_caps_fingerprint(); $role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $role_caps_fingerprint; if ( ! $plugin_needs_upgrade && ! $sync_needs_upgrade && ! $role_caps_need_sync ) { return; } if ( ! $this->acquire_db_upgrade_lock() ) { return; } $locked_old = (string) Services\Settings::get_db_version(); $locked_plugin_needs_upgrade = version_compare( $locked_old, VERSION, '<' ); $locked_sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); $locked_role_caps_fingerprint = $this->role_caps_fingerprint(); $locked_role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $locked_role_caps_fingerprint; if ( ! $locked_plugin_needs_upgrade && ! $locked_sync_needs_upgrade && ! $locked_role_caps_need_sync ) { $this->release_db_upgrade_lock(); return; } if ( $locked_plugin_needs_upgrade ) { Services\Settings::bump_versions(); } if ( $locked_plugin_needs_upgrade || $locked_role_caps_need_sync ) { // Re-run activation to sync role capabilities. add_role() and add_cap() // are both idempotent, so this is safe. Without this, capabilities added // in newer versions would never reach existing installs because add_role() // is a no-op when the role already exists. // Deferred to 'init' because create_pos_roles() calls __() which // requires translations to be loaded (WordPress 6.7+). add_action( 'init', function () { $this->single_activate( false ); } ); } $lock_released = false; $release_lock = function () use ( &$lock_released ): void { if ( $lock_released ) { return; } $lock_released = true; $this->release_db_upgrade_lock(); }; // Safety net in case woocommerce_init does not fire for this request. add_action( 'shutdown', $release_lock ); // Defer db_upgrade to woocommerce_init when WC is fully loaded. // This prevents conflicts with plugins like WC Subscriptions that hook // into before_delete_post and assume WC()->order_factory is available. add_action( 'woocommerce_init', function () use ( $locked_old, $locked_plugin_needs_upgrade, $locked_sync_needs_upgrade, $release_lock ) { try { $this->db_upgrade( $locked_old, VERSION ); // Report the upgrade only once the migration has actually // completed — queueing it beside bump_versions() would claim a // finished upgrade even when db_upgrade() threw or never ran. // Still exactly-once: the version was bumped above, so the // upgrade is not re-detected on the next request. if ( $locked_plugin_needs_upgrade ) { ( new Lifecycle_Events() )->record_upgrade( $locked_old, VERSION ); } if ( $locked_sync_needs_upgrade && Sync_Api::SCHEMA_VERSION === get_option( Sync_Api::SCHEMA_OPTION, null ) ) { ( new Sync_Journal() )->register_hooks(); ( new Integrity_Digest() )->register_hooks(); } } finally { $release_lock(); remove_action( 'shutdown', $release_lock ); } } ); } /** * Acquire the DB upgrade lock. * * @return bool True when this request owns the lock. */ private function acquire_db_upgrade_lock(): bool { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; return \WP_Upgrader::create_lock( self::DB_UPGRADE_LOCK_NAME, self::DB_UPGRADE_LOCK_TTL ); } /** * Release the DB upgrade lock. */ private function release_db_upgrade_lock(): void { if ( ! class_exists( '\WP_Upgrader', false ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; } \WP_Upgrader::release_lock( self::DB_UPGRADE_LOCK_NAME ); } /** * Plugin conflicts. * * - NextGEN Gallery is a terrible plugin. It buffers all content on 'init' action, priority -1 and inserts junk code. */ private function plugin_check(): void { // disable NextGEN Gallery resource manager // if ( ! \defined( 'NGG_DISABLE_RESOURCE_MANAGER' ) ) { // \define( 'NGG_DISABLE_RESOURCE_MANAGER', true ); // }. } /** * Get all blog ids of blogs in the current network that are: * - not archived * - not spam * - not deleted. */ private function get_blog_ids() { global $wpdb; // get an array of blog ids. $sql = "SELECT blog_id FROM $wpdb->blogs WHERE archived = '0' AND spam = '0' AND deleted = '0'"; return $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query, no user input } /** * Get the role capability definition. * * @return array|array> Role capabilities keyed by role. */ private static function role_capability_definition(): array { // WC 9.9 replaced promote_users with create_customers for customer creation. $customer_create_cap = \defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '9.9', '>=' ) // @phpstan-ignore-line ? 'create_customers' : 'promote_users'; // Cashier role. $cashier_capabilities = array( 'read' => true, 'read_private_products' => true, 'publish_products' => true, 'edit_product' => true, 'edit_products' => true, 'edit_published_products' => true, 'edit_private_products' => true, 'edit_others_products' => true, 'read_private_shop_orders' => true, 'publish_shop_orders' => true, 'edit_shop_orders' => true, 'edit_others_shop_orders' => true, 'list_users' => true, $customer_create_cap => true, 'edit_users' => true, 'read_private_shop_coupons' => true, 'publish_shop_coupons' => true, 'edit_shop_coupons' => true, 'edit_published_shop_coupons' => true, 'edit_private_shop_coupons' => true, 'edit_others_shop_coupons' => true, 'manage_product_terms' => true, ); return array( 'cashier' => $cashier_capabilities, 'administrator' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos', 'edit_wcpos_store', 'read_wcpos_store', 'delete_wcpos_store', 'edit_wcpos_stores', 'edit_others_wcpos_stores', 'publish_wcpos_stores', 'read_private_wcpos_stores', 'delete_wcpos_stores', 'delete_private_wcpos_stores', 'delete_published_wcpos_stores', 'delete_others_wcpos_stores', 'edit_private_wcpos_stores', 'edit_published_wcpos_stores', ), 'shop_manager' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos' ), ); } /** * Get the role-capabilities definition fingerprint. */ private function role_caps_fingerprint(): string { return md5( wp_json_encode( self::role_capability_definition() ) ); } /** * Add POS specific roles. */ private function create_pos_roles(): void { $role_capabilities = self::role_capability_definition(); $cashier_capabilities = $role_capabilities['cashier']; add_role( 'cashier', /* translators: Plugin activation notice label. */ __( 'Cashier', 'woocommerce-pos' ), $cashier_capabilities ); $obsolete_customer_create_cap = isset( $cashier_capabilities['create_customers'] ) ? 'promote_users' : 'create_customers'; $cashier = get_role( 'cashier' ); if ( $cashier ) { $cashier->remove_cap( $obsolete_customer_create_cap ); } // Sync all capabilities to the existing role. add_role() is a no-op when // the role already exists, so capabilities added in newer versions would // never reach existing installs without this. $this->add_pos_capability( array( 'cashier' => array_merge( array( 'access_woocommerce_pos' ), array_keys( $cashier_capabilities ) ), ) ); } /** * Add default pos capabilities to administrator and shop_manager roles. * * @param array $roles An array of arrays representing the roles and their POS capabilities. */ private function add_pos_capability( $roles ): void { foreach ( $roles as $slug => $caps ) { $role = get_role( $slug ); if ( $role ) { foreach ( $caps as $cap ) { $role->add_cap( $cap ); } } } } /** * Upgrade database. * * @param string $old Old version. * @param string $current Current version. */ private function db_upgrade( $old, $current ): void { $db_updates = array( '0.4' => 'updates/update-0.4.php', '0.4.6' => 'updates/update-0.4.6.php', '1.0.0-beta.1' => 'updates/update-1.0.0-beta.1.php', '1.6.1' => 'updates/update-1.6.1.php', '1.8.0' => 'updates/update-1.8.0.php', '1.8.7' => 'updates/update-1.8.7.php', '1.8.12' => 'updates/update-1.8.12.php', '1.8.13' => 'updates/update-1.8.13.php', '1.9.0' => 'updates/update-1.9.0.php', '1.10.0' => 'updates/update-1.10.0.php', ); foreach ( $db_updates as $version => $updater ) { if ( version_compare( $version, $old, '>' ) && version_compare( $version, $current, '<=' ) ) { include $updater; } } if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) { $this->install_sync_schema(); } // Installs that predate 2026-09 wrote the per-request latches with // autoload off; every upgrade re-asserts autoload so the flip is // idempotent and needs no versioned update file. self::autoload_request_latches(); Admin\Permalink::ensure_default(); } /** * Every option row that is read on EVERY request and must therefore ride in * alloptions: the three sync latches the Init constructor reads, the permalink * slug Template_Router reads, and each registered settings section that * declares {@see Services\Settings\Abstract_Section::autoload()} — the * sections are the extension point, so Pro's and extensions' sections join * the repair by declaring it, without touching this file. * * Needed because core's update_option() returns early on an unchanged value * WITHOUT touching the autoload column, so a writer alone never repairs a row * an older release wrote with autoload off. Without a persistent object cache * each such row cost one `SELECT option_value` per page load (measured * 2026-09-03 on dev-next and dev-free). * * @return string[] */ private static function request_option_names(): array { $names = array( Sync_Api::SCHEMA_OPTION, \WCPOS\WooCommercePOS\Sync\Visibility_Observer::SEED_VERSION_OPTION, \WCPOS\WooCommercePOS\Sync\Config_Fingerprint::CLEANUP_VERSION_OPTION, Admin\Permalink::DB_KEY, ); foreach ( Services\Settings::instance()->sections()->all() as $section ) { if ( $section instanceof Services\Settings\Abstract_Section && $section->autoload() ) { $names[] = $section->autoload_option_name(); } } return $names; } /** * Flip the per-request rows to autoload in place, and seed the settings * sections that are absent. * * One UPDATE on the flag column: never delete-and-recreate, because * `Sync_Api::SCHEMA_OPTION` gates the sync observers on every request and a * request landing in that gap would run with journaling off. 'yes' is * accepted by every core version (6.6+ maps it alongside 'on'). Idempotent: * already-autoloaded rows match nothing. Latches that were never written * stay absent (their absence is the signal). An autoloaded settings section * that was never saved is seeded as an autoloaded row — an absent option is * queried on every request too. General also persists its migrated consent * so the legacy row does not remain on the read path; other defaults stay * dynamic. */ public static function autoload_request_latches(): void { global $wpdb; // The key comes from the section itself (autoload_option_name()): Pro's // License section stores under a Pro-prefixed key, so deriving it from // id() flipped nothing for that row and seeded a stray free-prefixed one. foreach ( Services\Settings::instance()->sections()->all() as $section ) { if ( ! $section instanceof Services\Settings\Abstract_Section || ! $section->autoload() ) { continue; } $option_name = $section->autoload_option_name(); if ( false === get_option( $option_name ) ) { $value = $section instanceof Services\Settings\General_Section ? array( 'tracking_consent' => $section->raw_tracking_consent() ) : array(); add_option( $option_name, $value, '', true ); } } $options = self::request_option_names(); $placeholders = implode( ', ', array_fill( 0, \count( $options ), '%s' ) ); $flipped = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the flag column is the target; caches are cleared below. $wpdb->prepare( "UPDATE {$wpdb->options} SET autoload = 'yes' WHERE option_name IN ({$placeholders}) AND autoload NOT IN ('yes', 'on', 'auto-on')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders are generated for the prepared values. $options ) ); if ( ! $flipped ) { return; } foreach ( $options as $option ) { wp_cache_delete( $option, 'options' ); } wp_cache_delete( 'alloptions', 'options' ); } /** * If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION. */ private function pro_version_check(): void { if ( class_exists( '\WCPOS\WooCommercePOSPro\Activator' ) ) { if ( version_compare( \WCPOS\WooCommercePOSPro\VERSION, MIN_PRO_VERSION, '<' ) ) { // @phpstan-ignore-line /* * NOTE: the deactivate_plugins function is not available in the frontend or ajax * This is an extreme situation where the Pro plugin could crash the site, so we need to deactivate it */ if ( ! \function_exists( 'deactivate_plugins' ) ) { require_once ABSPATH . '/wp-admin/includes/plugin.php'; } // WCPOS Pro is activated, but the version is too low - use the constant for dynamic folder name. deactivate_plugins( \WCPOS\WooCommercePOSPro\PLUGIN_FILE ); // @phpstan-ignore-line // Defer __() call to avoid "too early" warning in WordPress 6.7+. add_action( 'admin_init', function () { $message = \sprintf( // translators: 1: WCPOS Pro URL, 2: Minimum Pro version, 3: Plugins URL. __( 'WCPOS requires WCPOS Pro %2$s or higher. Please install and activate WCPOS Pro', 'woocommerce-pos' ), 'https://wcpos.com/my-account', MIN_PRO_VERSION, admin_url( 'plugins.php' ) ) . ' »'; Admin\Notices::add( $message ); } ); } } } }