PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/Activator.php +147 -18 1.10.21.10.18 View file →
@@ -98,20 +98,49 @@
98 98 /**
99 99 * Fired when the plugin is activated.
100 100 *
101 101 * @param bool $install_sync_schema Whether to install the sync schema.
102 + * @param bool $full_role_sync Whether to repair all default role capabilities.
102 103 */
103 - public function single_activate( bool $install_sync_schema = true ): void {
104 - $role_capabilities = self::role_capability_definition();
104 + public function single_activate( bool $install_sync_schema = true, bool $full_role_sync = true ): void {
105 + $role_capabilities = self::role_capability_definition();
106 + $capability_names = $role_capabilities;
107 + $capability_names['cashier'] = array_merge( array( 'access_woocommerce_pos' ), array_keys( $role_capabilities['cashier'] ) );
108 + $synced = get_option( 'woocommerce_pos_role_caps_synced', false );
109 + if ( ! $full_role_sync && false === $synced && get_option( 'woocommerce_pos_role_caps_fingerprint' ) === $this->role_caps_fingerprint() ) {
110 + $synced = $capability_names;
111 + }
112 + // An upgrade grants only capabilities new to the definition since the
113 + // last sync, so a capability the merchant removed on the Access screen
114 + // stays removed. Explicit activation still repairs every default.
115 + $granted = $capability_names;
116 + if ( ! $full_role_sync && \is_array( $synced ) ) {
117 + foreach ( $granted as $slug => $capabilities ) {
118 + $already = isset( $synced[ $slug ] ) && \is_array( $synced[ $slug ] ) ? $synced[ $slug ] : array();
119 + $granted[ $slug ] = array_values( array_diff( $capabilities, $already ) );
120 + }
121 + }
105 122
123 + // Reseed the default template terms on the next request: (re)activation
124 + // is the repair a merchant reaches for after deleting a term by hand.
125 + // This also runs once per upgrade (version_check re-activates to sync
126 + // role caps), so one post-upgrade request pays the ~18 seeding queries.
127 + delete_option( Templates::DEFAULT_TERMS_OPTION );
128 +
129 + // Second, merchant-reachable trigger for the autoload repair: db_upgrade()
130 + // only runs when version_check() trips on an admin load that reaches
131 + // woocommerce_init, and a miss there is permanent once bump_versions() ran.
132 + self::autoload_request_latches();
133 + Admin\Permalink::ensure_default();
134 +
106 135 // create POS specific roles.
107 - $this->create_pos_roles();
136 + $this->create_pos_roles( $granted['cashier'] );
108 137
109 138 // add pos capabilities to non POS roles.
110 139 $this->add_pos_capability(
111 140 array(
112 - 'administrator' => $role_capabilities['administrator'],
113 - 'shop_manager' => $role_capabilities['shop_manager'],
141 + 'administrator' => $granted['administrator'],
142 + 'shop_manager' => $granted['shop_manager'],
114 143 )
115 144 );
116 145
117 146 $stored_roles = get_option( wp_roles()->role_key, array() );
@@ -116,13 +145,14 @@
116 145
117 146 $stored_roles = get_option( wp_roles()->role_key, array() );
118 147 $roles_are_persisted = is_array( $stored_roles );
119 148 if ( $roles_are_persisted ) {
120 - foreach ( $role_capabilities as $slug => $capabilities ) {
121 - $required_capabilities = 'cashier' === $slug
122 - ? array_merge( array( 'access_woocommerce_pos' ), array_keys( $capabilities ) )
123 - : $capabilities;
124 - foreach ( $required_capabilities as $capability ) {
149 + foreach ( $granted as $slug => $capabilities ) {
150 + if ( ! isset( $stored_roles[ $slug ] ) ) {
151 + $roles_are_persisted = false;
152 + break;
153 + }
154 + foreach ( $capabilities as $capability ) {
125 155 if ( empty( $stored_roles[ $slug ]['capabilities'][ $capability ] ) ) {
126 156 $roles_are_persisted = false;
127 157 break 2;
128 158 }
@@ -131,8 +161,11 @@
131 161 }
132 162
133 163 $obsolete_customer_create_cap = isset( $role_capabilities['cashier']['create_customers'] ) ? 'promote_users' : 'create_customers';
134 164 if ( $roles_are_persisted && empty( $stored_roles['cashier']['capabilities'][ $obsolete_customer_create_cap ] ) ) {
165 + // Snapshot first: a fingerprint that advanced past a failed snapshot
166 + // write would never retry it.
167 + update_option( 'woocommerce_pos_role_caps_synced', $capability_names, true );
135 168 update_option( 'woocommerce_pos_role_caps_fingerprint', $this->role_caps_fingerprint(), true );
136 169 }
137 170
138 171 // Flag the consent pop-up for the next admin page load. Done here
@@ -193,9 +226,12 @@
193 226 ) {
194 227 return;
195 228 }
196 229
197 - update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, false );
230 + // Autoloaded: the Init constructor reads this latch on every request.
231 + // This flips an existing row only on WP 6.4+; older rows are flipped by
232 + // autoload_request_latches() on upgrade.
233 + update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, true );
198 234
199 235 if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) {
200 236 global $wpdb;
201 237 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name.
@@ -297,9 +333,10 @@
297 333 $plugin_needs_upgrade = version_compare( $old, VERSION, '<' );
298 334 $sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null );
299 335
300 336 $role_caps_fingerprint = $this->role_caps_fingerprint();
301 - $role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $role_caps_fingerprint;
337 + $role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $role_caps_fingerprint
338 + || false === get_option( 'woocommerce_pos_role_caps_synced' );
302 339 if ( ! $plugin_needs_upgrade && ! $sync_needs_upgrade && ! $role_caps_need_sync ) {
303 340 return;
304 341 }
305 342
@@ -311,9 +348,10 @@
311 348 $locked_plugin_needs_upgrade = version_compare( $locked_old, VERSION, '<' );
312 349 $locked_sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null );
313 350
314 351 $locked_role_caps_fingerprint = $this->role_caps_fingerprint();
315 - $locked_role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $locked_role_caps_fingerprint;
352 + $locked_role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $locked_role_caps_fingerprint
353 + || false === get_option( 'woocommerce_pos_role_caps_synced' );
316 354 if ( ! $locked_plugin_needs_upgrade && ! $locked_sync_needs_upgrade && ! $locked_role_caps_need_sync ) {
317 355 $this->release_db_upgrade_lock();
318 356 return;
319 357 }
@@ -331,9 +369,9 @@
331 369 // requires translations to be loaded (WordPress 6.7+).
332 370 add_action(
333 371 'init',
334 372 function () {
335 - $this->single_activate( false );
373 + $this->single_activate( false, false );
336 374 }
337 375 );
338 376 }
339 377
@@ -498,10 +536,13 @@
498 536 }
499 537
500 538 /**
501 539 * Add POS specific roles.
540 + *
541 + * @param string[]|null $capabilities Capability names to sync onto an existing role, or null for
542 + * every default. A missing role is always created with the full set.
502 543 */
503 - private function create_pos_roles(): void {
544 + private function create_pos_roles( ?array $capabilities = null ): void {
504 545 $role_capabilities = self::role_capability_definition();
505 546 $cashier_capabilities = $role_capabilities['cashier'];
506 547
507 548 add_role(
@@ -507,9 +548,11 @@
507 548 add_role(
508 549 'cashier',
509 550 /* translators: Plugin activation notice label. */
510 551 __( 'Cashier', 'woocommerce-pos' ),
511 - $cashier_capabilities
552 + // A missing role is created whole, access gate included, whatever
553 + // subset an incremental upgrade asked to sync.
554 + array_merge( array( 'access_woocommerce_pos' => true ), $cashier_capabilities )
512 555 );
513 556
514 557 $obsolete_customer_create_cap = isset( $cashier_capabilities['create_customers'] ) ? 'promote_users' : 'create_customers';
515 558 $cashier = get_role( 'cashier' );
@@ -516,14 +559,14 @@
516 559 if ( $cashier ) {
517 560 $cashier->remove_cap( $obsolete_customer_create_cap );
518 561 }
519 562
520 - // Sync all capabilities to the existing role. add_role() is a no-op when
563 + // Sync the requested capabilities to the role. add_role() is a no-op when
521 564 // the role already exists, so capabilities added in newer versions would
522 565 // never reach existing installs without this.
523 566 $this->add_pos_capability(
524 567 array(
525 - 'cashier' => array_merge(
568 + 'cashier' => $capabilities ?? array_merge(
526 569 array( 'access_woocommerce_pos' ),
527 570 array_keys( $cashier_capabilities )
528 571 ),
529 572 )
@@ -574,8 +617,94 @@
574 617
575 618 if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) {
576 619 $this->install_sync_schema();
577 620 }
621 +
622 + // Installs that predate 2026-09 wrote the per-request latches with
623 + // autoload off; every upgrade re-asserts autoload so the flip is
624 + // idempotent and needs no versioned update file.
625 + self::autoload_request_latches();
626 + Admin\Permalink::ensure_default();
627 + }
628 +
629 + /**
630 + * Every option row that is read on EVERY request and must therefore ride in
631 + * alloptions: the three sync latches the Init constructor reads, the permalink
632 + * slug Template_Router reads, and each registered settings section that
633 + * declares {@see Services\Settings\Abstract_Section::autoload()} — the
634 + * sections are the extension point, so Pro's and extensions' sections join
635 + * the repair by declaring it, without touching this file.
636 + *
637 + * Needed because core's update_option() returns early on an unchanged value
638 + * WITHOUT touching the autoload column, so a writer alone never repairs a row
639 + * an older release wrote with autoload off. Without a persistent object cache
640 + * each such row cost one `SELECT option_value` per page load (measured
641 + * 2026-09-03 on dev-next and dev-free).
642 + *
643 + * @return string[]
644 + */
645 + private static function request_option_names(): array {
646 + $names = array(
647 + Sync_Api::SCHEMA_OPTION,
648 + \WCPOS\WooCommercePOS\Sync\Visibility_Observer::SEED_VERSION_OPTION,
649 + \WCPOS\WooCommercePOS\Sync\Config_Fingerprint::CLEANUP_VERSION_OPTION,
650 + Admin\Permalink::DB_KEY,
651 + );
652 + foreach ( Services\Settings::instance()->sections()->all() as $section ) {
653 + if ( $section instanceof Services\Settings\Abstract_Section && $section->autoload() ) {
654 + $names[] = $section->autoload_option_name();
655 + }
656 + }
657 + return $names;
658 + }
659 +
660 + /**
661 + * Flip the per-request rows to autoload in place, and seed the settings
662 + * sections that are absent.
663 + *
664 + * One UPDATE on the flag column: never delete-and-recreate, because
665 + * `Sync_Api::SCHEMA_OPTION` gates the sync observers on every request and a
666 + * request landing in that gap would run with journaling off. 'yes' is
667 + * accepted by every core version (6.6+ maps it alongside 'on'). Idempotent:
668 + * already-autoloaded rows match nothing. Latches that were never written
669 + * stay absent (their absence is the signal). An autoloaded settings section
670 + * that was never saved is seeded as an autoloaded row — an absent option is
671 + * queried on every request too. General also persists its migrated consent
672 + * so the legacy row does not remain on the read path; other defaults stay
673 + * dynamic.
674 + */
675 + public static function autoload_request_latches(): void {
676 + global $wpdb;
677 + // The key comes from the section itself (autoload_option_name()): Pro's
678 + // License section stores under a Pro-prefixed key, so deriving it from
679 + // id() flipped nothing for that row and seeded a stray free-prefixed one.
680 + foreach ( Services\Settings::instance()->sections()->all() as $section ) {
681 + if ( ! $section instanceof Services\Settings\Abstract_Section || ! $section->autoload() ) {
682 + continue;
683 + }
684 + $option_name = $section->autoload_option_name();
685 + if ( false === get_option( $option_name ) ) {
686 + $value = $section instanceof Services\Settings\General_Section
687 + ? array( 'tracking_consent' => $section->raw_tracking_consent() )
688 + : array();
689 + add_option( $option_name, $value, '', true );
690 + }
691 + }
692 + $options = self::request_option_names();
693 + $placeholders = implode( ', ', array_fill( 0, \count( $options ), '%s' ) );
694 + $flipped = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the flag column is the target; caches are cleared below.
695 + $wpdb->prepare(
696 + "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.
697 + $options
698 + )
699 + );
700 + if ( ! $flipped ) {
701 + return;
702 + }
703 + foreach ( $options as $option ) {
704 + wp_cache_delete( $option, 'options' );
705 + }
706 + wp_cache_delete( 'alloptions', 'options' );
578 707 }
579 708
580 709 /**
581 710 * If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION.