PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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 +329 -43 1.9.141.10.19 View file →
@@ -10,8 +10,14 @@
10 10
11 11 namespace WCPOS\WooCommercePOS;
12 12
13 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;
14 20 use const DOING_AJAX;
15 21
16 22 /**
17 23 * Activator class.
@@ -90,37 +96,79 @@
90 96 }
91 97
92 98 /**
93 99 * Fired when the plugin is activated.
100 + *
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.
94 103 */
95 - public function single_activate(): void {
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 + }
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 +
96 135 // create POS specific roles.
97 - $this->create_pos_roles();
136 + $this->create_pos_roles( $granted['cashier'] );
98 137
99 138 // add pos capabilities to non POS roles.
100 139 $this->add_pos_capability(
101 140 array(
102 - 'administrator' => array(
103 - 'manage_woocommerce_pos',
104 - 'access_woocommerce_pos',
105 - 'edit_wcpos_store',
106 - 'read_wcpos_store',
107 - 'delete_wcpos_store',
108 - 'edit_wcpos_stores',
109 - 'edit_others_wcpos_stores',
110 - 'publish_wcpos_stores',
111 - 'read_private_wcpos_stores',
112 - 'delete_wcpos_stores',
113 - 'delete_private_wcpos_stores',
114 - 'delete_published_wcpos_stores',
115 - 'delete_others_wcpos_stores',
116 - 'edit_private_wcpos_stores',
117 - 'edit_published_wcpos_stores',
118 - ),
119 - 'shop_manager' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos' ),
141 + 'administrator' => $granted['administrator'],
142 + 'shop_manager' => $granted['shop_manager'],
120 143 )
121 144 );
122 145
146 + $stored_roles = get_option( wp_roles()->role_key, array() );
147 + $roles_are_persisted = is_array( $stored_roles );
148 + if ( $roles_are_persisted ) {
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 ) {
155 + if ( empty( $stored_roles[ $slug ]['capabilities'][ $capability ] ) ) {
156 + $roles_are_persisted = false;
157 + break 2;
158 + }
159 + }
160 + }
161 + }
162 +
163 + $obsolete_customer_create_cap = isset( $role_capabilities['cashier']['create_customers'] ) ? 'promote_users' : 'create_customers';
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 );
168 + update_option( 'woocommerce_pos_role_caps_fingerprint', $this->role_caps_fingerprint(), true );
169 + }
170 +
123 171 // Flag the consent pop-up for the next admin page load. Done here
124 172 // because the `activated_plugin` action in Admin\Consent fires
125 173 // inside the activation request, at which point our plugin's
126 174 // `plugins_loaded` callback hasn't yet instantiated Init on a
@@ -135,11 +183,68 @@
135 183 : 'undecided';
136 184 if ( 'undecided' === $tracking_consent ) {
137 185 set_transient( Consent::MODAL_TRANSIENT, 1, Consent::MODAL_TRANSIENT_TTL );
138 186 }
187 +
188 + if ( $install_sync_schema ) {
189 + $this->install_sync_schema();
190 + }
191 +
192 + // Record the install for analytics. Consent is still `undecided` at this
193 + // point, so the event is held until the user answers the pop-up flagged
194 + // above; Lifecycle_Events owns that deferral and reports at most once.
195 + ( new Lifecycle_Events() )->record_install();
139 196 }
140 197
141 198 /**
199 + * Install the sync store and latch its aggregate schema version after verification.
200 + */
201 + public function install_sync_schema(): void {
202 + $previous_schema = get_option( Sync_Api::SCHEMA_OPTION, null );
203 +
204 + $journal = new Sync_Journal();
205 + $journal->install();
206 + ( new Integrity_Digest() )->install();
207 + ( new Mutation_Store() )->install();
208 +
209 + if ( ! Sync_Health::is_healthy() ) {
210 + if ( Sync_Api::SCHEMA_VERSION === $previous_schema ) {
211 + delete_option( Sync_Api::SCHEMA_OPTION );
212 + }
213 + return;
214 + }
215 +
216 + // Schema 3 (#1379): the customer space widened from role=customer to ALL users.
217 + // Upgrading installs carry role-departure tombstones in the persisted stream that
218 + // would replay against now-live users; compensating updates supersede them (see
219 + // Sync_Journal::append_customer_updates_for_all_users). The old latch stays until
220 + // migration succeeds, so retries may append duplicate but harmless superseding
221 + // updates. Fresh installs (no previous latch) have no stream to repair.
222 + if (
223 + null !== $previous_schema
224 + && version_compare( (string) $previous_schema, '3', '<' )
225 + && ! $journal->append_customer_updates_for_all_users()
226 + ) {
227 + return;
228 + }
229 +
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 );
234 +
235 + if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) {
236 + global $wpdb;
237 + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name.
238 + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_order_index" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name.
239 + // The legacy Change_Log_Purge class is gone; its recurring cron event
240 + // would otherwise survive the upgrade and fire a hook with no handler
241 + // forever. Literal hook name — the constant was removed with the class.
242 + wp_clear_scheduled_hook( 'wcpos_change_log_purge' );
243 + }
244 + }
245 +
246 + /**
142 247 * Fired when a new site is activated with a WPMU environment.
143 248 *
144 249 * @param int $blog_id Blog ID.
145 250 */
@@ -223,10 +328,16 @@
223 328 /**
224 329 * Check version number, runs every admin page load.
225 330 */
226 331 private function version_check(): void {
227 - $old = (string) Services\Settings::get_db_version();
228 - if ( ! version_compare( $old, VERSION, '<' ) ) {
332 + $old = (string) Services\Settings::get_db_version();
333 + $plugin_needs_upgrade = version_compare( $old, VERSION, '<' );
334 + $sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null );
335 +
336 + $role_caps_fingerprint = $this->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' );
339 + if ( ! $plugin_needs_upgrade && ! $sync_needs_upgrade && ! $role_caps_need_sync ) {
229 340 return;
230 341 }
231 342
232 343 if ( ! $this->acquire_db_upgrade_lock() ) {
@@ -232,28 +343,38 @@
232 343 if ( ! $this->acquire_db_upgrade_lock() ) {
233 344 return;
234 345 }
235 346
236 - $locked_old = (string) Services\Settings::get_db_version();
237 - if ( ! version_compare( $locked_old, VERSION, '<' ) ) {
347 + $locked_old = (string) Services\Settings::get_db_version();
348 + $locked_plugin_needs_upgrade = version_compare( $locked_old, VERSION, '<' );
349 + $locked_sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null );
350 +
351 + $locked_role_caps_fingerprint = $this->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' );
354 + if ( ! $locked_plugin_needs_upgrade && ! $locked_sync_needs_upgrade && ! $locked_role_caps_need_sync ) {
238 355 $this->release_db_upgrade_lock();
239 356 return;
240 357 }
241 358
242 - Services\Settings::bump_versions();
359 + if ( $locked_plugin_needs_upgrade ) {
360 + Services\Settings::bump_versions();
361 + }
243 362
244 - // Re-run activation to sync role capabilities. add_role() and add_cap()
245 - // are both idempotent, so this is safe. Without this, capabilities added
246 - // in newer versions would never reach existing installs because add_role()
247 - // is a no-op when the role already exists.
248 - // Deferred to 'init' because create_pos_roles() calls __() which
249 - // requires translations to be loaded (WordPress 6.7+).
250 - add_action(
251 - 'init',
252 - function () {
253 - $this->single_activate();
254 - }
255 - );
363 + if ( $locked_plugin_needs_upgrade || $locked_role_caps_need_sync ) {
364 + // Re-run activation to sync role capabilities. add_role() and add_cap()
365 + // are both idempotent, so this is safe. Without this, capabilities added
366 + // in newer versions would never reach existing installs because add_role()
367 + // is a no-op when the role already exists.
368 + // Deferred to 'init' because create_pos_roles() calls __() which
369 + // requires translations to be loaded (WordPress 6.7+).
370 + add_action(
371 + 'init',
372 + function () {
373 + $this->single_activate( false, false );
374 + }
375 + );
376 + }
256 377
257 378 $lock_released = false;
258 379 $release_lock = function () use ( &$lock_released ): void {
259 380 if ( $lock_released ) {
@@ -271,11 +392,24 @@
271 392 // This prevents conflicts with plugins like WC Subscriptions that hook
272 393 // into before_delete_post and assume WC()->order_factory is available.
273 394 add_action(
274 395 'woocommerce_init',
275 - function () use ( $locked_old, $release_lock ) {
396 + function () use ( $locked_old, $locked_plugin_needs_upgrade, $locked_sync_needs_upgrade, $release_lock ) {
276 397 try {
277 398 $this->db_upgrade( $locked_old, VERSION );
399 +
400 + // Report the upgrade only once the migration has actually
401 + // completed — queueing it beside bump_versions() would claim a
402 + // finished upgrade even when db_upgrade() threw or never ran.
403 + // Still exactly-once: the version was bumped above, so the
404 + // upgrade is not re-detected on the next request.
405 + if ( $locked_plugin_needs_upgrade ) {
406 + ( new Lifecycle_Events() )->record_upgrade( $locked_old, VERSION );
407 + }
408 + if ( $locked_sync_needs_upgrade && Sync_Api::SCHEMA_VERSION === get_option( Sync_Api::SCHEMA_OPTION, null ) ) {
409 + ( new Sync_Journal() )->register_hooks();
410 + ( new Integrity_Digest() )->register_hooks();
411 + }
278 412 } finally {
279 413 $release_lock();
280 414 remove_action( 'shutdown', $release_lock );
281 415 }
@@ -334,11 +468,13 @@
334 468 return $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query, no user input
335 469 }
336 470
337 471 /**
338 - * Add POS specific roles.
472 + * Get the role capability definition.
473 + *
474 + * @return array<string, array<string, bool>|array<int, string>> Role capabilities keyed by role.
339 475 */
340 - private function create_pos_roles(): void {
476 + private static function role_capability_definition(): array {
341 477 // WC 9.9 replaced promote_users with create_customers for customer creation.
342 478 $customer_create_cap = \defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '9.9', '>=' ) // @phpstan-ignore-line
343 479 ? 'create_customers'
344 480 : 'promote_users';
@@ -346,8 +482,14 @@
346 482 // Cashier role.
347 483 $cashier_capabilities = array(
348 484 'read' => true,
349 485 'read_private_products' => true,
486 + 'publish_products' => true,
487 + 'edit_product' => true,
488 + 'edit_products' => true,
489 + 'edit_published_products' => true,
490 + 'edit_private_products' => true,
491 + 'edit_others_products' => true,
350 492 'read_private_shop_orders' => true,
351 493 'publish_shop_orders' => true,
352 494 'edit_shop_orders' => true,
353 495 'edit_others_shop_orders' => true,
@@ -354,24 +496,77 @@
354 496 'list_users' => true,
355 497 $customer_create_cap => true,
356 498 'edit_users' => true,
357 499 'read_private_shop_coupons' => true,
500 + 'publish_shop_coupons' => true,
501 + 'edit_shop_coupons' => true,
502 + 'edit_published_shop_coupons' => true,
503 + 'edit_private_shop_coupons' => true,
504 + 'edit_others_shop_coupons' => true,
358 505 'manage_product_terms' => true,
359 506 );
360 507
508 + return array(
509 + 'cashier' => $cashier_capabilities,
510 + 'administrator' => array(
511 + 'manage_woocommerce_pos',
512 + 'access_woocommerce_pos',
513 + 'edit_wcpos_store',
514 + 'read_wcpos_store',
515 + 'delete_wcpos_store',
516 + 'edit_wcpos_stores',
517 + 'edit_others_wcpos_stores',
518 + 'publish_wcpos_stores',
519 + 'read_private_wcpos_stores',
520 + 'delete_wcpos_stores',
521 + 'delete_private_wcpos_stores',
522 + 'delete_published_wcpos_stores',
523 + 'delete_others_wcpos_stores',
524 + 'edit_private_wcpos_stores',
525 + 'edit_published_wcpos_stores',
526 + ),
527 + 'shop_manager' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos' ),
528 + );
529 + }
530 +
531 + /**
532 + * Get the role-capabilities definition fingerprint.
533 + */
534 + private function role_caps_fingerprint(): string {
535 + return md5( wp_json_encode( self::role_capability_definition() ) );
536 + }
537 +
538 + /**
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.
543 + */
544 + private function create_pos_roles( ?array $capabilities = null ): void {
545 + $role_capabilities = self::role_capability_definition();
546 + $cashier_capabilities = $role_capabilities['cashier'];
547 +
361 548 add_role(
362 549 'cashier',
363 550 /* translators: Plugin activation notice label. */
364 551 __( 'Cashier', 'woocommerce-pos' ),
365 - $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 )
366 555 );
367 556
368 - // Sync all capabilities to the existing role. add_role() is a no-op when
557 + $obsolete_customer_create_cap = isset( $cashier_capabilities['create_customers'] ) ? 'promote_users' : 'create_customers';
558 + $cashier = get_role( 'cashier' );
559 + if ( $cashier ) {
560 + $cashier->remove_cap( $obsolete_customer_create_cap );
561 + }
562 +
563 + // Sync the requested capabilities to the role. add_role() is a no-op when
369 564 // the role already exists, so capabilities added in newer versions would
370 565 // never reach existing installs without this.
371 566 $this->add_pos_capability(
372 567 array(
373 - 'cashier' => array_merge(
568 + 'cashier' => $capabilities ?? array_merge(
374 569 array( 'access_woocommerce_pos' ),
375 570 array_keys( $cashier_capabilities )
376 571 ),
377 572 )
@@ -410,8 +605,9 @@
410 605 '1.8.7' => 'updates/update-1.8.7.php',
411 606 '1.8.12' => 'updates/update-1.8.12.php',
412 607 '1.8.13' => 'updates/update-1.8.13.php',
413 608 '1.9.0' => 'updates/update-1.9.0.php',
609 + '1.10.0' => 'updates/update-1.10.0.php',
414 610 );
415 611 foreach ( $db_updates as $version => $updater ) {
416 612 if ( version_compare( $version, $old, '>' ) &&
417 613 version_compare( $version, $current, '<=' ) ) {
@@ -417,8 +613,98 @@
417 613 version_compare( $version, $current, '<=' ) ) {
418 614 include $updater;
419 615 }
420 616 }
617 +
618 + if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) {
619 + $this->install_sync_schema();
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' );
421 707 }
422 708
423 709 /**
424 710 * If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION.