| 1 |
<?php |
| 2 |
/** |
| 3 |
* Activation checks and set up. |
| 4 |
* |
| 5 |
* @author Paul Kilmurray <paul@kilbot.com> |
| 6 |
* |
| 7 |
* @see http://wcpos.com |
| 8 |
* @package WCPOS\WooCommercePOS |
| 9 |
*/ |
| 10 |
|
| 11 |
namespace WCPOS\WooCommercePOS; |
| 12 |
|
| 13 |
use WCPOS\WooCommercePOS\Admin\Consent; |
| 14 |
use WCPOS\WooCommercePOS\Services\Lifecycle_Events; |
| 15 |
use WCPOS\WooCommercePOS\Sync\Api as Sync_Api; |
| 16 |
use WCPOS\WooCommercePOS\Sync\Health as Sync_Health; |
| 17 |
use WCPOS\WooCommercePOS\Sync\Integrity_Digest; |
| 18 |
use WCPOS\WooCommercePOS\Sync\Mutation_Store; |
| 19 |
use WCPOS\WooCommercePOS\Sync\Sync_Journal; |
| 20 |
use const DOING_AJAX; |
| 21 |
|
| 22 |
/** |
| 23 |
* Activator class. |
| 24 |
*/ |
| 25 |
class Activator { |
| 26 |
/** |
| 27 |
* Lock name used by WP_Upgrader::create_lock(). |
| 28 |
*/ |
| 29 |
private const DB_UPGRADE_LOCK_NAME = 'woocommerce_pos_db_upgrade_lock'; |
| 30 |
|
| 31 |
/** |
| 32 |
* Lock TTL in seconds. |
| 33 |
*/ |
| 34 |
private const DB_UPGRADE_LOCK_TTL = 600; |
| 35 |
|
| 36 |
/** |
| 37 |
* Constructor. |
| 38 |
*/ |
| 39 |
public function __construct() { |
| 40 |
register_activation_hook( PLUGIN_FILE, array( $this, 'activate' ) ); |
| 41 |
add_action( 'wpmu_new_blog', array( $this, 'activate_new_site' ) ); |
| 42 |
add_action( 'plugins_loaded', array( $this, 'init' ) ); |
| 43 |
} |
| 44 |
|
| 45 |
/** |
| 46 |
* Checks for valid install and begins execution of the plugin. |
| 47 |
*/ |
| 48 |
public function init(): void { |
| 49 |
// Check for min requirements to run. |
| 50 |
if ( $this->php_check() && $this->woocommerce_check() ) { |
| 51 |
// Defer permalink check to admin_init so __() calls happen after |
| 52 |
// after_setup_theme (WordPress 6.7+ triggers a notice otherwise). |
| 53 |
if ( is_admin() && ( ! \defined( '\DOING_AJAX' ) || ! DOING_AJAX ) ) { // @phpstan-ignore-line |
| 54 |
add_action( |
| 55 |
'admin_init', |
| 56 |
function () { |
| 57 |
$this->permalink_check(); |
| 58 |
} |
| 59 |
); |
| 60 |
} |
| 61 |
|
| 62 |
// Init update script if required. |
| 63 |
$this->version_check(); |
| 64 |
$this->pro_version_check(); |
| 65 |
|
| 66 |
// resolve plugin plugins. |
| 67 |
$this->plugin_check(); |
| 68 |
|
| 69 |
new Init(); |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* Fired when the plugin is activated. |
| 75 |
* |
| 76 |
* @param bool $network_wide Whether to activate network-wide. |
| 77 |
*/ |
| 78 |
public function activate( $network_wide ): void { |
| 79 |
if ( \function_exists( 'is_multisite' ) && is_multisite() ) { |
| 80 |
if ( $network_wide ) { |
| 81 |
// Get all blog ids. |
| 82 |
$blog_ids = $this->get_blog_ids(); |
| 83 |
|
| 84 |
foreach ( $blog_ids as $blog_id ) { |
| 85 |
switch_to_blog( $blog_id ); |
| 86 |
$this->single_activate(); |
| 87 |
|
| 88 |
restore_current_blog(); |
| 89 |
} |
| 90 |
} else { |
| 91 |
self::single_activate(); |
| 92 |
} |
| 93 |
} else { |
| 94 |
self::single_activate(); |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* Fired when the plugin is activated. |
| 100 |
* |
| 101 |
* @param bool $install_sync_schema Whether to install the sync schema. |
| 102 |
*/ |
| 103 |
public function single_activate( bool $install_sync_schema = true ): void { |
| 104 |
$role_capabilities = self::role_capability_definition(); |
| 105 |
|
| 106 |
// Reseed the default template terms on the next request: (re)activation |
| 107 |
// is the repair a merchant reaches for after deleting a term by hand. |
| 108 |
// This also runs once per upgrade (version_check re-activates to sync |
| 109 |
// role caps), so one post-upgrade request pays the ~18 seeding queries. |
| 110 |
delete_option( Templates::DEFAULT_TERMS_OPTION ); |
| 111 |
|
| 112 |
// Second, merchant-reachable trigger for the autoload repair: db_upgrade() |
| 113 |
// only runs when version_check() trips on an admin load that reaches |
| 114 |
// woocommerce_init, and a miss there is permanent once bump_versions() ran. |
| 115 |
self::autoload_request_latches(); |
| 116 |
Admin\Permalink::ensure_default(); |
| 117 |
|
| 118 |
// create POS specific roles. |
| 119 |
$this->create_pos_roles(); |
| 120 |
|
| 121 |
// add pos capabilities to non POS roles. |
| 122 |
$this->add_pos_capability( |
| 123 |
array( |
| 124 |
'administrator' => $role_capabilities['administrator'], |
| 125 |
'shop_manager' => $role_capabilities['shop_manager'], |
| 126 |
) |
| 127 |
); |
| 128 |
|
| 129 |
$stored_roles = get_option( wp_roles()->role_key, array() ); |
| 130 |
$roles_are_persisted = is_array( $stored_roles ); |
| 131 |
if ( $roles_are_persisted ) { |
| 132 |
foreach ( $role_capabilities as $slug => $capabilities ) { |
| 133 |
$required_capabilities = 'cashier' === $slug |
| 134 |
? array_merge( array( 'access_woocommerce_pos' ), array_keys( $capabilities ) ) |
| 135 |
: $capabilities; |
| 136 |
foreach ( $required_capabilities as $capability ) { |
| 137 |
if ( empty( $stored_roles[ $slug ]['capabilities'][ $capability ] ) ) { |
| 138 |
$roles_are_persisted = false; |
| 139 |
break 2; |
| 140 |
} |
| 141 |
} |
| 142 |
} |
| 143 |
} |
| 144 |
|
| 145 |
$obsolete_customer_create_cap = isset( $role_capabilities['cashier']['create_customers'] ) ? 'promote_users' : 'create_customers'; |
| 146 |
if ( $roles_are_persisted && empty( $stored_roles['cashier']['capabilities'][ $obsolete_customer_create_cap ] ) ) { |
| 147 |
update_option( 'woocommerce_pos_role_caps_fingerprint', $this->role_caps_fingerprint(), true ); |
| 148 |
} |
| 149 |
|
| 150 |
// Flag the consent pop-up for the next admin page load. Done here |
| 151 |
// because the `activated_plugin` action in Admin\Consent fires |
| 152 |
// inside the activation request, at which point our plugin's |
| 153 |
// `plugins_loaded` callback hasn't yet instantiated Init on a |
| 154 |
// fresh install. |
| 155 |
// |
| 156 |
// Read the option directly — woocommerce_pos_get_settings() lives in |
| 157 |
// wcpos-functions.php which Init loads on `plugins_loaded`, but |
| 158 |
// plugins_loaded has already fired by the time activation runs. |
| 159 |
$general_settings = get_option( 'woocommerce_pos_settings_general', array() ); |
| 160 |
$tracking_consent = is_array( $general_settings ) && isset( $general_settings['tracking_consent'] ) |
| 161 |
? $general_settings['tracking_consent'] |
| 162 |
: 'undecided'; |
| 163 |
if ( 'undecided' === $tracking_consent ) { |
| 164 |
set_transient( Consent::MODAL_TRANSIENT, 1, Consent::MODAL_TRANSIENT_TTL ); |
| 165 |
} |
| 166 |
|
| 167 |
if ( $install_sync_schema ) { |
| 168 |
$this->install_sync_schema(); |
| 169 |
} |
| 170 |
|
| 171 |
// Record the install for analytics. Consent is still `undecided` at this |
| 172 |
// point, so the event is held until the user answers the pop-up flagged |
| 173 |
// above; Lifecycle_Events owns that deferral and reports at most once. |
| 174 |
( new Lifecycle_Events() )->record_install(); |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Install the sync store and latch its aggregate schema version after verification. |
| 179 |
*/ |
| 180 |
public function install_sync_schema(): void { |
| 181 |
$previous_schema = get_option( Sync_Api::SCHEMA_OPTION, null ); |
| 182 |
|
| 183 |
$journal = new Sync_Journal(); |
| 184 |
$journal->install(); |
| 185 |
( new Integrity_Digest() )->install(); |
| 186 |
( new Mutation_Store() )->install(); |
| 187 |
|
| 188 |
if ( ! Sync_Health::is_healthy() ) { |
| 189 |
if ( Sync_Api::SCHEMA_VERSION === $previous_schema ) { |
| 190 |
delete_option( Sync_Api::SCHEMA_OPTION ); |
| 191 |
} |
| 192 |
return; |
| 193 |
} |
| 194 |
|
| 195 |
// Schema 3 (#1379): the customer space widened from role=customer to ALL users. |
| 196 |
// Upgrading installs carry role-departure tombstones in the persisted stream that |
| 197 |
// would replay against now-live users; compensating updates supersede them (see |
| 198 |
// Sync_Journal::append_customer_updates_for_all_users). The old latch stays until |
| 199 |
// migration succeeds, so retries may append duplicate but harmless superseding |
| 200 |
// updates. Fresh installs (no previous latch) have no stream to repair. |
| 201 |
if ( |
| 202 |
null !== $previous_schema |
| 203 |
&& version_compare( (string) $previous_schema, '3', '<' ) |
| 204 |
&& ! $journal->append_customer_updates_for_all_users() |
| 205 |
) { |
| 206 |
return; |
| 207 |
} |
| 208 |
|
| 209 |
// Autoloaded: the Init constructor reads this latch on every request. |
| 210 |
// This flips an existing row only on WP 6.4+; older rows are flipped by |
| 211 |
// autoload_request_latches() on upgrade. |
| 212 |
update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, true ); |
| 213 |
|
| 214 |
if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) { |
| 215 |
global $wpdb; |
| 216 |
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. |
| 217 |
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_order_index" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. |
| 218 |
// The legacy Change_Log_Purge class is gone; its recurring cron event |
| 219 |
// would otherwise survive the upgrade and fire a hook with no handler |
| 220 |
// forever. Literal hook name — the constant was removed with the class. |
| 221 |
wp_clear_scheduled_hook( 'wcpos_change_log_purge' ); |
| 222 |
} |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* Fired when a new site is activated with a WPMU environment. |
| 227 |
* |
| 228 |
* @param int $blog_id Blog ID. |
| 229 |
*/ |
| 230 |
public function activate_new_site( $blog_id ): void { |
| 231 |
if ( 1 !== did_action( 'wpmu_new_blog' ) ) { |
| 232 |
return; |
| 233 |
} |
| 234 |
|
| 235 |
switch_to_blog( $blog_id ); |
| 236 |
$this->single_activate(); |
| 237 |
restore_current_blog(); |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* Check min version of PHP. |
| 242 |
*/ |
| 243 |
private function php_check() { |
| 244 |
$php_version = PHP_VERSION; |
| 245 |
if ( version_compare( $php_version, PHP_MIN_VERSION, '>' ) ) { |
| 246 |
return true; |
| 247 |
} |
| 248 |
|
| 249 |
// Defer __() call to avoid "too early" warning in WordPress 6.7+. |
| 250 |
add_action( |
| 251 |
'admin_init', |
| 252 |
function () { |
| 253 |
$message = \sprintf( |
| 254 |
// translators: 1: Minimum PHP version, 2: Update URL. |
| 255 |
__( '<strong>WCPOS</strong> requires PHP %1$s or higher. Read more information about <a href="%2$s">how you can update</a>', 'woocommerce-pos' ), |
| 256 |
PHP_MIN_VERSION, |
| 257 |
'http://www.wpupdatephp.com/update/' |
| 258 |
) . ' »'; |
| 259 |
|
| 260 |
Admin\Notices::add( $message ); |
| 261 |
} |
| 262 |
); |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Check min version of WooCommerce installed. |
| 267 |
*/ |
| 268 |
private function woocommerce_check() { |
| 269 |
if ( class_exists( '\WooCommerce' ) && version_compare( WC()->version, WC_MIN_VERSION, '>=' ) ) { |
| 270 |
return true; |
| 271 |
} |
| 272 |
|
| 273 |
// Defer __() call to avoid "too early" warning in WordPress 6.7+. |
| 274 |
add_action( |
| 275 |
'admin_init', |
| 276 |
function () { |
| 277 |
$message = \sprintf( |
| 278 |
// translators: 1: WooCommerce URL, 2: Minimum WC version, 3: Plugins URL. |
| 279 |
__( '<strong>WCPOS</strong> requires <a href="%1$s">WooCommerce %2$s or higher</a>. Please <a href="%3$s">install and activate WooCommerce</a>', 'woocommerce-pos' ), |
| 280 |
'http://wordpress.org/plugins/woocommerce/', |
| 281 |
WC_MIN_VERSION, |
| 282 |
admin_url( 'plugins.php' ) |
| 283 |
) . ' »'; |
| 284 |
|
| 285 |
Admin\Notices::add( $message ); |
| 286 |
} |
| 287 |
); |
| 288 |
} |
| 289 |
|
| 290 |
/** |
| 291 |
* POS Frontend will give 404 if pretty permalinks not active. |
| 292 |
*/ |
| 293 |
private function permalink_check(): void { |
| 294 |
$permalinks = get_option( 'permalink_structure' ); |
| 295 |
|
| 296 |
// early return. |
| 297 |
if ( $permalinks ) { |
| 298 |
return; |
| 299 |
} |
| 300 |
|
| 301 |
$message = /* translators: Plugin activation notice label. */ __( '<strong>WooCommerce REST API</strong> requires <em>pretty</em> permalinks to work correctly', 'woocommerce-pos' ) . '. '; |
| 302 |
$message .= \sprintf( '<a href="%s">%s</a>', admin_url( 'options-permalink.php' ), /* translators: Plugin activation notice label. */ __( 'Enable permalinks', 'woocommerce-pos' ) ) . ' »'; |
| 303 |
|
| 304 |
Admin\Notices::add( $message ); |
| 305 |
} |
| 306 |
|
| 307 |
/** |
| 308 |
* Check version number, runs every admin page load. |
| 309 |
*/ |
| 310 |
private function version_check(): void { |
| 311 |
$old = (string) Services\Settings::get_db_version(); |
| 312 |
$plugin_needs_upgrade = version_compare( $old, VERSION, '<' ); |
| 313 |
$sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); |
| 314 |
|
| 315 |
$role_caps_fingerprint = $this->role_caps_fingerprint(); |
| 316 |
$role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $role_caps_fingerprint; |
| 317 |
if ( ! $plugin_needs_upgrade && ! $sync_needs_upgrade && ! $role_caps_need_sync ) { |
| 318 |
return; |
| 319 |
} |
| 320 |
|
| 321 |
if ( ! $this->acquire_db_upgrade_lock() ) { |
| 322 |
return; |
| 323 |
} |
| 324 |
|
| 325 |
$locked_old = (string) Services\Settings::get_db_version(); |
| 326 |
$locked_plugin_needs_upgrade = version_compare( $locked_old, VERSION, '<' ); |
| 327 |
$locked_sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); |
| 328 |
|
| 329 |
$locked_role_caps_fingerprint = $this->role_caps_fingerprint(); |
| 330 |
$locked_role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $locked_role_caps_fingerprint; |
| 331 |
if ( ! $locked_plugin_needs_upgrade && ! $locked_sync_needs_upgrade && ! $locked_role_caps_need_sync ) { |
| 332 |
$this->release_db_upgrade_lock(); |
| 333 |
return; |
| 334 |
} |
| 335 |
|
| 336 |
if ( $locked_plugin_needs_upgrade ) { |
| 337 |
Services\Settings::bump_versions(); |
| 338 |
} |
| 339 |
|
| 340 |
if ( $locked_plugin_needs_upgrade || $locked_role_caps_need_sync ) { |
| 341 |
// Re-run activation to sync role capabilities. add_role() and add_cap() |
| 342 |
// are both idempotent, so this is safe. Without this, capabilities added |
| 343 |
// in newer versions would never reach existing installs because add_role() |
| 344 |
// is a no-op when the role already exists. |
| 345 |
// Deferred to 'init' because create_pos_roles() calls __() which |
| 346 |
// requires translations to be loaded (WordPress 6.7+). |
| 347 |
add_action( |
| 348 |
'init', |
| 349 |
function () { |
| 350 |
$this->single_activate( false ); |
| 351 |
} |
| 352 |
); |
| 353 |
} |
| 354 |
|
| 355 |
$lock_released = false; |
| 356 |
$release_lock = function () use ( &$lock_released ): void { |
| 357 |
if ( $lock_released ) { |
| 358 |
return; |
| 359 |
} |
| 360 |
|
| 361 |
$lock_released = true; |
| 362 |
$this->release_db_upgrade_lock(); |
| 363 |
}; |
| 364 |
|
| 365 |
// Safety net in case woocommerce_init does not fire for this request. |
| 366 |
add_action( 'shutdown', $release_lock ); |
| 367 |
|
| 368 |
// Defer db_upgrade to woocommerce_init when WC is fully loaded. |
| 369 |
// This prevents conflicts with plugins like WC Subscriptions that hook |
| 370 |
// into before_delete_post and assume WC()->order_factory is available. |
| 371 |
add_action( |
| 372 |
'woocommerce_init', |
| 373 |
function () use ( $locked_old, $locked_plugin_needs_upgrade, $locked_sync_needs_upgrade, $release_lock ) { |
| 374 |
try { |
| 375 |
$this->db_upgrade( $locked_old, VERSION ); |
| 376 |
|
| 377 |
// Report the upgrade only once the migration has actually |
| 378 |
// completed — queueing it beside bump_versions() would claim a |
| 379 |
// finished upgrade even when db_upgrade() threw or never ran. |
| 380 |
// Still exactly-once: the version was bumped above, so the |
| 381 |
// upgrade is not re-detected on the next request. |
| 382 |
if ( $locked_plugin_needs_upgrade ) { |
| 383 |
( new Lifecycle_Events() )->record_upgrade( $locked_old, VERSION ); |
| 384 |
} |
| 385 |
if ( $locked_sync_needs_upgrade && Sync_Api::SCHEMA_VERSION === get_option( Sync_Api::SCHEMA_OPTION, null ) ) { |
| 386 |
( new Sync_Journal() )->register_hooks(); |
| 387 |
( new Integrity_Digest() )->register_hooks(); |
| 388 |
} |
| 389 |
} finally { |
| 390 |
$release_lock(); |
| 391 |
remove_action( 'shutdown', $release_lock ); |
| 392 |
} |
| 393 |
} |
| 394 |
); |
| 395 |
} |
| 396 |
|
| 397 |
/** |
| 398 |
* Acquire the DB upgrade lock. |
| 399 |
* |
| 400 |
* @return bool True when this request owns the lock. |
| 401 |
*/ |
| 402 |
private function acquire_db_upgrade_lock(): bool { |
| 403 |
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; |
| 404 |
|
| 405 |
return \WP_Upgrader::create_lock( self::DB_UPGRADE_LOCK_NAME, self::DB_UPGRADE_LOCK_TTL ); |
| 406 |
} |
| 407 |
|
| 408 |
/** |
| 409 |
* Release the DB upgrade lock. |
| 410 |
*/ |
| 411 |
private function release_db_upgrade_lock(): void { |
| 412 |
if ( ! class_exists( '\WP_Upgrader', false ) ) { |
| 413 |
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; |
| 414 |
} |
| 415 |
|
| 416 |
\WP_Upgrader::release_lock( self::DB_UPGRADE_LOCK_NAME ); |
| 417 |
} |
| 418 |
|
| 419 |
/** |
| 420 |
* Plugin conflicts. |
| 421 |
* |
| 422 |
* - NextGEN Gallery is a terrible plugin. It buffers all content on 'init' action, priority -1 and inserts junk code. |
| 423 |
*/ |
| 424 |
private function plugin_check(): void { |
| 425 |
// disable NextGEN Gallery resource manager |
| 426 |
// if ( ! \defined( 'NGG_DISABLE_RESOURCE_MANAGER' ) ) { |
| 427 |
// \define( 'NGG_DISABLE_RESOURCE_MANAGER', true ); |
| 428 |
// }. |
| 429 |
} |
| 430 |
|
| 431 |
/** |
| 432 |
* Get all blog ids of blogs in the current network that are: |
| 433 |
* - not archived |
| 434 |
* - not spam |
| 435 |
* - not deleted. |
| 436 |
*/ |
| 437 |
private function get_blog_ids() { |
| 438 |
global $wpdb; |
| 439 |
|
| 440 |
// get an array of blog ids. |
| 441 |
$sql = "SELECT blog_id FROM $wpdb->blogs |
| 442 |
WHERE archived = '0' AND spam = '0' |
| 443 |
AND deleted = '0'"; |
| 444 |
|
| 445 |
return $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query, no user input |
| 446 |
} |
| 447 |
|
| 448 |
/** |
| 449 |
* Get the role capability definition. |
| 450 |
* |
| 451 |
* @return array<string, array<string, bool>|array<int, string>> Role capabilities keyed by role. |
| 452 |
*/ |
| 453 |
private static function role_capability_definition(): array { |
| 454 |
// WC 9.9 replaced promote_users with create_customers for customer creation. |
| 455 |
$customer_create_cap = \defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '9.9', '>=' ) // @phpstan-ignore-line |
| 456 |
? 'create_customers' |
| 457 |
: 'promote_users'; |
| 458 |
|
| 459 |
// Cashier role. |
| 460 |
$cashier_capabilities = array( |
| 461 |
'read' => true, |
| 462 |
'read_private_products' => true, |
| 463 |
'publish_products' => true, |
| 464 |
'edit_product' => true, |
| 465 |
'edit_products' => true, |
| 466 |
'edit_published_products' => true, |
| 467 |
'edit_private_products' => true, |
| 468 |
'edit_others_products' => true, |
| 469 |
'read_private_shop_orders' => true, |
| 470 |
'publish_shop_orders' => true, |
| 471 |
'edit_shop_orders' => true, |
| 472 |
'edit_others_shop_orders' => true, |
| 473 |
'list_users' => true, |
| 474 |
$customer_create_cap => true, |
| 475 |
'edit_users' => true, |
| 476 |
'read_private_shop_coupons' => true, |
| 477 |
'publish_shop_coupons' => true, |
| 478 |
'edit_shop_coupons' => true, |
| 479 |
'edit_published_shop_coupons' => true, |
| 480 |
'edit_private_shop_coupons' => true, |
| 481 |
'edit_others_shop_coupons' => true, |
| 482 |
'manage_product_terms' => true, |
| 483 |
); |
| 484 |
|
| 485 |
return array( |
| 486 |
'cashier' => $cashier_capabilities, |
| 487 |
'administrator' => array( |
| 488 |
'manage_woocommerce_pos', |
| 489 |
'access_woocommerce_pos', |
| 490 |
'edit_wcpos_store', |
| 491 |
'read_wcpos_store', |
| 492 |
'delete_wcpos_store', |
| 493 |
'edit_wcpos_stores', |
| 494 |
'edit_others_wcpos_stores', |
| 495 |
'publish_wcpos_stores', |
| 496 |
'read_private_wcpos_stores', |
| 497 |
'delete_wcpos_stores', |
| 498 |
'delete_private_wcpos_stores', |
| 499 |
'delete_published_wcpos_stores', |
| 500 |
'delete_others_wcpos_stores', |
| 501 |
'edit_private_wcpos_stores', |
| 502 |
'edit_published_wcpos_stores', |
| 503 |
), |
| 504 |
'shop_manager' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos' ), |
| 505 |
); |
| 506 |
} |
| 507 |
|
| 508 |
/** |
| 509 |
* Get the role-capabilities definition fingerprint. |
| 510 |
*/ |
| 511 |
private function role_caps_fingerprint(): string { |
| 512 |
return md5( wp_json_encode( self::role_capability_definition() ) ); |
| 513 |
} |
| 514 |
|
| 515 |
/** |
| 516 |
* Add POS specific roles. |
| 517 |
*/ |
| 518 |
private function create_pos_roles(): void { |
| 519 |
$role_capabilities = self::role_capability_definition(); |
| 520 |
$cashier_capabilities = $role_capabilities['cashier']; |
| 521 |
|
| 522 |
add_role( |
| 523 |
'cashier', |
| 524 |
/* translators: Plugin activation notice label. */ |
| 525 |
__( 'Cashier', 'woocommerce-pos' ), |
| 526 |
$cashier_capabilities |
| 527 |
); |
| 528 |
|
| 529 |
$obsolete_customer_create_cap = isset( $cashier_capabilities['create_customers'] ) ? 'promote_users' : 'create_customers'; |
| 530 |
$cashier = get_role( 'cashier' ); |
| 531 |
if ( $cashier ) { |
| 532 |
$cashier->remove_cap( $obsolete_customer_create_cap ); |
| 533 |
} |
| 534 |
|
| 535 |
// Sync all capabilities to the existing role. add_role() is a no-op when |
| 536 |
// the role already exists, so capabilities added in newer versions would |
| 537 |
// never reach existing installs without this. |
| 538 |
$this->add_pos_capability( |
| 539 |
array( |
| 540 |
'cashier' => array_merge( |
| 541 |
array( 'access_woocommerce_pos' ), |
| 542 |
array_keys( $cashier_capabilities ) |
| 543 |
), |
| 544 |
) |
| 545 |
); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* Add default pos capabilities to administrator and shop_manager roles. |
| 550 |
* |
| 551 |
* @param array $roles An array of arrays representing the roles and their POS capabilities. |
| 552 |
*/ |
| 553 |
private function add_pos_capability( $roles ): void { |
| 554 |
foreach ( $roles as $slug => $caps ) { |
| 555 |
$role = get_role( $slug ); |
| 556 |
if ( $role ) { |
| 557 |
foreach ( $caps as $cap ) { |
| 558 |
$role->add_cap( $cap ); |
| 559 |
} |
| 560 |
} |
| 561 |
} |
| 562 |
} |
| 563 |
|
| 564 |
/** |
| 565 |
* Upgrade database. |
| 566 |
* |
| 567 |
* @param string $old Old version. |
| 568 |
* @param string $current Current version. |
| 569 |
*/ |
| 570 |
private function db_upgrade( $old, $current ): void { |
| 571 |
$db_updates = array( |
| 572 |
'0.4' => 'updates/update-0.4.php', |
| 573 |
'0.4.6' => 'updates/update-0.4.6.php', |
| 574 |
'1.0.0-beta.1' => 'updates/update-1.0.0-beta.1.php', |
| 575 |
'1.6.1' => 'updates/update-1.6.1.php', |
| 576 |
'1.8.0' => 'updates/update-1.8.0.php', |
| 577 |
'1.8.7' => 'updates/update-1.8.7.php', |
| 578 |
'1.8.12' => 'updates/update-1.8.12.php', |
| 579 |
'1.8.13' => 'updates/update-1.8.13.php', |
| 580 |
'1.9.0' => 'updates/update-1.9.0.php', |
| 581 |
'1.10.0' => 'updates/update-1.10.0.php', |
| 582 |
); |
| 583 |
foreach ( $db_updates as $version => $updater ) { |
| 584 |
if ( version_compare( $version, $old, '>' ) && |
| 585 |
version_compare( $version, $current, '<=' ) ) { |
| 586 |
include $updater; |
| 587 |
} |
| 588 |
} |
| 589 |
|
| 590 |
if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) { |
| 591 |
$this->install_sync_schema(); |
| 592 |
} |
| 593 |
|
| 594 |
// Installs that predate 2026-09 wrote the per-request latches with |
| 595 |
// autoload off; every upgrade re-asserts autoload so the flip is |
| 596 |
// idempotent and needs no versioned update file. |
| 597 |
self::autoload_request_latches(); |
| 598 |
Admin\Permalink::ensure_default(); |
| 599 |
} |
| 600 |
|
| 601 |
/** |
| 602 |
* Every option row that is read on EVERY request and must therefore ride in |
| 603 |
* alloptions: the three sync latches the Init constructor reads, the permalink |
| 604 |
* slug Template_Router reads, and each registered settings section that |
| 605 |
* declares {@see Services\Settings\Abstract_Section::autoload()} — the |
| 606 |
* sections are the extension point, so Pro's and extensions' sections join |
| 607 |
* the repair by declaring it, without touching this file. |
| 608 |
* |
| 609 |
* Needed because core's update_option() returns early on an unchanged value |
| 610 |
* WITHOUT touching the autoload column, so a writer alone never repairs a row |
| 611 |
* an older release wrote with autoload off. Without a persistent object cache |
| 612 |
* each such row cost one `SELECT option_value` per page load (measured |
| 613 |
* 2026-09-03 on dev-next and dev-free). |
| 614 |
* |
| 615 |
* @return string[] |
| 616 |
*/ |
| 617 |
private static function request_option_names(): array { |
| 618 |
$names = array( |
| 619 |
Sync_Api::SCHEMA_OPTION, |
| 620 |
\WCPOS\WooCommercePOS\Sync\Visibility_Observer::SEED_VERSION_OPTION, |
| 621 |
\WCPOS\WooCommercePOS\Sync\Config_Fingerprint::CLEANUP_VERSION_OPTION, |
| 622 |
Admin\Permalink::DB_KEY, |
| 623 |
); |
| 624 |
foreach ( Services\Settings::instance()->sections()->all() as $section ) { |
| 625 |
if ( $section instanceof Services\Settings\Abstract_Section && $section->autoload() ) { |
| 626 |
$names[] = $section->autoload_option_name(); |
| 627 |
} |
| 628 |
} |
| 629 |
return $names; |
| 630 |
} |
| 631 |
|
| 632 |
/** |
| 633 |
* Flip the per-request rows to autoload in place, and seed the settings |
| 634 |
* sections that are absent. |
| 635 |
* |
| 636 |
* One UPDATE on the flag column: never delete-and-recreate, because |
| 637 |
* `Sync_Api::SCHEMA_OPTION` gates the sync observers on every request and a |
| 638 |
* request landing in that gap would run with journaling off. 'yes' is |
| 639 |
* accepted by every core version (6.6+ maps it alongside 'on'). Idempotent: |
| 640 |
* already-autoloaded rows match nothing. Latches that were never written |
| 641 |
* stay absent (their absence is the signal). An autoloaded settings section |
| 642 |
* that was never saved is seeded as an autoloaded row — an absent option is |
| 643 |
* queried on every request too. General also persists its migrated consent |
| 644 |
* so the legacy row does not remain on the read path; other defaults stay |
| 645 |
* dynamic. |
| 646 |
*/ |
| 647 |
public static function autoload_request_latches(): void { |
| 648 |
global $wpdb; |
| 649 |
// The key comes from the section itself (autoload_option_name()): Pro's |
| 650 |
// License section stores under a Pro-prefixed key, so deriving it from |
| 651 |
// id() flipped nothing for that row and seeded a stray free-prefixed one. |
| 652 |
foreach ( Services\Settings::instance()->sections()->all() as $section ) { |
| 653 |
if ( ! $section instanceof Services\Settings\Abstract_Section || ! $section->autoload() ) { |
| 654 |
continue; |
| 655 |
} |
| 656 |
$option_name = $section->autoload_option_name(); |
| 657 |
if ( false === get_option( $option_name ) ) { |
| 658 |
$value = $section instanceof Services\Settings\General_Section |
| 659 |
? array( 'tracking_consent' => $section->raw_tracking_consent() ) |
| 660 |
: array(); |
| 661 |
add_option( $option_name, $value, '', true ); |
| 662 |
} |
| 663 |
} |
| 664 |
$options = self::request_option_names(); |
| 665 |
$placeholders = implode( ', ', array_fill( 0, \count( $options ), '%s' ) ); |
| 666 |
$flipped = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the flag column is the target; caches are cleared below. |
| 667 |
$wpdb->prepare( |
| 668 |
"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. |
| 669 |
$options |
| 670 |
) |
| 671 |
); |
| 672 |
if ( ! $flipped ) { |
| 673 |
return; |
| 674 |
} |
| 675 |
foreach ( $options as $option ) { |
| 676 |
wp_cache_delete( $option, 'options' ); |
| 677 |
} |
| 678 |
wp_cache_delete( 'alloptions', 'options' ); |
| 679 |
} |
| 680 |
|
| 681 |
/** |
| 682 |
* If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION. |
| 683 |
*/ |
| 684 |
private function pro_version_check(): void { |
| 685 |
if ( class_exists( '\WCPOS\WooCommercePOSPro\Activator' ) ) { |
| 686 |
if ( version_compare( \WCPOS\WooCommercePOSPro\VERSION, MIN_PRO_VERSION, '<' ) ) { // @phpstan-ignore-line |
| 687 |
|
| 688 |
/* |
| 689 |
* NOTE: the deactivate_plugins function is not available in the frontend or ajax |
| 690 |
* This is an extreme situation where the Pro plugin could crash the site, so we need to deactivate it |
| 691 |
*/ |
| 692 |
if ( ! \function_exists( 'deactivate_plugins' ) ) { |
| 693 |
require_once ABSPATH . '/wp-admin/includes/plugin.php'; |
| 694 |
} |
| 695 |
|
| 696 |
// WCPOS Pro is activated, but the version is too low - use the constant for dynamic folder name. |
| 697 |
deactivate_plugins( \WCPOS\WooCommercePOSPro\PLUGIN_FILE ); // @phpstan-ignore-line |
| 698 |
|
| 699 |
// Defer __() call to avoid "too early" warning in WordPress 6.7+. |
| 700 |
add_action( |
| 701 |
'admin_init', |
| 702 |
function () { |
| 703 |
$message = \sprintf( |
| 704 |
// translators: 1: WCPOS Pro URL, 2: Minimum Pro version, 3: Plugins URL. |
| 705 |
__( '<strong>WCPOS</strong> requires <a href="%1$s">WCPOS Pro %2$s or higher</a>. Please <a href="%3$s">install and activate WCPOS Pro</a>', 'woocommerce-pos' ), |
| 706 |
'https://wcpos.com/my-account', |
| 707 |
MIN_PRO_VERSION, |
| 708 |
admin_url( 'plugins.php' ) |
| 709 |
) . ' »'; |
| 710 |
|
| 711 |
Admin\Notices::add( $message ); |
| 712 |
} |
| 713 |
); |
| 714 |
} |
| 715 |
} |
| 716 |
} |
| 717 |
} |
| 718 |
|