PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.22
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.22
1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 All 166 releases
woocommerce-pos / includes / Activator.php

Activator.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.22, at includes/Activator.php

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