Commit 40b162f72ba for woocommerce
commit 40b162f72ba939fe4bdb5a8ff66bd8c724c351ae
Author: Jaclyn Chen <watertranquil@gmail.com>
Date: Thu Jul 2 10:10:17 2026 -0400
Add POS capability presets and per-user assignment (#65661)
* Add POS capability presets and per-user assignment
Build the preset layer on the POS capability model: the Cashier / Manager / Admin cap bundles (capabilities_for_preset), per-user assign/clear via set_pos_preset (caps + _woocommerce_pos_preset meta, never WP roles), get_pos_preset, assignable_pos_presets, preset_label, and pos_staff_user_query_args. Clean up the preset meta on uninstall.
Caps remain the authorization signal; the preset meta is UI bookkeeping only. Behind the off-by-default point_of_sale_staff flag.
* Centralize POS preset definitions (caps + labels) in one map
* Align preset docs and tests with the woocommerce_pos_ prefix
Follow-up to the foundation cap rename: update the preset layer's docblocks,
the preset capability table, inline comments, and test descriptions from the
old pos_* shorthand to woocommerce_pos_*. No behavior change — preset slugs
(pos_cashier / pos_manager / pos_admin) are meta values, not capabilities, so
they keep their names.
* Drop the leading underscore from the POS preset meta key
Rename the preset user meta key from _woocommerce_pos_preset to
woocommerce_pos_preset. The leading underscore is the WordPress protected-meta
marker, which only affects post meta (the Custom Fields UI and REST meta
writes) and does nothing for user meta. Without it the key falls under core's
existing uninstall sweep (DELETE ... meta_key LIKE 'woocommerce\_%'), so the
explicit per-key DELETE in uninstall.php is no longer needed — and future POS
user meta (e.g. the staff PIN) named woocommerce_pos_* is cleaned up for free.
The key is unreleased and lives behind the point_of_sale_staff flag, so there
is no stored data to migrate.
* Simplify the POS preset cap-strip loop to call remove_cap() directly
The isset() guard before remove_cap() was redundant: remove_cap() already
no-ops for a cap the user does not hold, and it strips a cap whether it was
granted (true) or explicitly denied (false) — isset() returns true for a
false-valued entry, so denied caps were never skipped. Drop the guard and
document the true/false behavior inline.
* Use the woocommerce_pos_ prefix in the presets changelog entry
* Add @since tag to POSPreset::get_all()
* Clarify pos_staff_user_query_args() is a candidate query
* Clean up direct POS capabilities on uninstall
* Store POS preset meta per-site for multisite alignment
diff --git a/plugins/woocommerce/changelog/add-pos-staff-presets b/plugins/woocommerce/changelog/add-pos-staff-presets
new file mode 100644
index 00000000000..35cbc138345
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-pos-staff-presets
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the POS preset layer to the capability model: capability bundles for the Cashier, Manager, and Admin presets, assigned or cleared per user via `woocommerce_pos_*` capabilities (never WP roles), with the assigned preset recorded in user meta and cleaned up on uninstall. Behind the off-by-default `point_of_sale_staff` feature flag.
diff --git a/plugins/woocommerce/src/Internal/POS/Capabilities.php b/plugins/woocommerce/src/Internal/POS/Capabilities.php
index 20fa66bbb1c..ca6e91a3540 100644
--- a/plugins/woocommerce/src/Internal/POS/Capabilities.php
+++ b/plugins/woocommerce/src/Internal/POS/Capabilities.php
@@ -5,6 +5,9 @@ namespace Automattic\WooCommerce\Internal\POS;
defined( 'ABSPATH' ) || exit;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WP_User;
+
/**
* POS capability model.
*
@@ -18,8 +21,11 @@ defined( 'ABSPATH' ) || exit;
* administrator, …) without altering their role, and revoked without leaving
* them roleless.
*
- * The preset layer — which `woocommerce_pos_*` caps a Cashier / Manager / Admin receives, and
- * the code that assigns them per user — is added separately.
+ * The preset layer maps each preset (see POSPreset) to a bundle of
+ * `woocommerce_pos_*` caps and assigns or clears them per user via
+ * set_pos_preset(). The assigned preset is recorded in `woocommerce_pos_preset`
+ * user meta for the UI; the caps remain the authorization signal, so a stray
+ * meta value alone grants nothing.
*
* @since 11.0.0
* @internal
@@ -63,6 +69,16 @@ class Capabilities {
// Leave POS mode for the full admin.
public const CAP_EXIT_POS = 'woocommerce_pos_exit';
+ /**
+ * User meta key recording which preset was assigned to a user.
+ *
+ * The value is one of the POSPreset constants. It drives the admin UI, but it
+ * is not the authorization signal: has_pos_access() reads the `woocommerce_pos_*` caps, not
+ * this meta. Stored per-site via Users::*_site_user_meta() (which suffixes the blog prefix)
+ * so it stays aligned with the blog-scoped capabilities on multisite.
+ */
+ public const POS_PRESET_META_KEY = 'woocommerce_pos_preset';
+
/**
* All known POS capability identifiers.
*
@@ -128,4 +144,191 @@ class Capabilities {
}
return false;
}
+
+ /**
+ * Resolve the assigned POS preset for a user, or null if none is set.
+ *
+ * Returns the `woocommerce_pos_preset` meta value only if it matches an
+ * assignable preset, so a stale or hand-edited value reads as "no preset".
+ *
+ * The meta is stored per-site (see set_pos_preset()) so it stays aligned with the
+ * blog-scoped POS capabilities on multisite.
+ *
+ * @param int $user_id Target user.
+ * @return string|null One of the POSPreset constants, or null.
+ *
+ * @since 11.0.0
+ */
+ public static function get_pos_preset( int $user_id ): ?string {
+ $meta = Users::get_site_user_meta( $user_id, self::POS_PRESET_META_KEY, true );
+ if ( in_array( $meta, POSPreset::get_all(), true ) ) {
+ return (string) $meta;
+ }
+ return null;
+ }
+
+ /**
+ * WP_User_Query args selecting candidate POS staff — every user holding any
+ * `woocommerce_pos_*` capability, via WP_User_Query's capability__in.
+ *
+ * Use it to enumerate POS staff — e.g. the GET /wc/pos/v1/staff endpoint and the
+ * wp-admin Staff list — which refine the candidates for their own needs (the staff
+ * endpoint also requires a PIN). Keying on caps rather than the preset meta keeps
+ * this aligned with the authorization signal: a user whose caps were stripped is
+ * excluded, and a cap granted outside a preset is still included.
+ *
+ * This is a candidate query, not exact parity with has_pos_access(): capability__in
+ * matches the capability *name* wherever it appears in the serialized capabilities
+ * row, so a user with an explicit denial (add_cap( $cap, false )) is still selected
+ * even though has_pos_access() — which reads the resolved capabilities — treats them
+ * as no access. Capabilities only ever grants or strips caps, never denies, so this
+ * diverges only when external code sets an explicit denial; callers needing exact
+ * parity should refine results with has_pos_access().
+ *
+ * @return array<string, mixed>
+ *
+ * @since 11.0.0
+ */
+ public static function pos_staff_user_query_args(): array {
+ return array(
+ 'capability__in' => self::all_pos_capabilities(),
+ );
+ }
+
+ /**
+ * Preset metadata: the `woocommerce_pos_*` cap bundle and display label for each preset.
+ *
+ * Single source of truth for capabilities_for_preset() and preset_label(), so
+ * adding or renaming a preset is one edit here (plus the POSPreset constant)
+ * rather than several parallel switches.
+ *
+ * Capability Cashier Manager Admin
+ * woocommerce_pos_process_sales yes yes yes
+ * woocommerce_pos_view_orders yes yes yes
+ * woocommerce_pos_apply_coupons yes yes yes
+ * woocommerce_pos_create_coupons no yes yes
+ * woocommerce_pos_issue_refunds no yes yes
+ * woocommerce_pos_view_settings no yes yes
+ * woocommerce_pos_edit_settings no no yes
+ * woocommerce_pos_manage_staff no no yes
+ * woocommerce_pos_exit no no yes
+ *
+ * @return array<string, array{caps: array<string, true>, label: string}>
+ */
+ private static function preset_definitions(): array {
+ $cashier_caps = array(
+ self::CAP_PROCESS_SALES => true,
+ self::CAP_VIEW_ORDERS => true,
+ self::CAP_APPLY_COUPONS => true,
+ );
+
+ $manager_caps = $cashier_caps + array(
+ self::CAP_CREATE_COUPONS => true,
+ self::CAP_ISSUE_REFUNDS => true,
+ self::CAP_VIEW_SETTINGS => true,
+ );
+
+ $admin_caps = $manager_caps + array(
+ self::CAP_EDIT_SETTINGS => true,
+ self::CAP_MANAGE_STAFF => true,
+ self::CAP_EXIT_POS => true,
+ );
+
+ return array(
+ POSPreset::CASHIER => array(
+ 'caps' => $cashier_caps,
+ 'label' => __( 'POS cashier', 'woocommerce' ),
+ ),
+ POSPreset::MANAGER => array(
+ 'caps' => $manager_caps,
+ 'label' => __( 'POS manager', 'woocommerce' ),
+ ),
+ POSPreset::ADMIN => array(
+ 'caps' => $admin_caps,
+ 'label' => __( 'POS admin', 'woocommerce' ),
+ ),
+ );
+ }
+
+ /**
+ * The `woocommerce_pos_*` capability bundle for a given preset (see preset_definitions()).
+ *
+ * @param string $preset One of the POSPreset constants.
+ * @return array<string, true> Map of granted cap => true. Empty for unknown presets.
+ *
+ * @since 11.0.0
+ */
+ public static function capabilities_for_preset( string $preset ): array {
+ $definitions = self::preset_definitions();
+
+ return isset( $definitions[ $preset ] ) ? $definitions[ $preset ]['caps'] : array();
+ }
+
+ /**
+ * Assign or clear the POS preset for a user.
+ *
+ * Touches only caps + meta, never WP roles: granting access to an existing user
+ * leaves their role intact, and clearing it never leaves them roleless. Every
+ * `woocommerce_pos_*` cap the user holds directly (via add_cap — the only way this class grants
+ * them) is stripped first, so a preset change (Manager to Cashier) drops the caps
+ * the new preset omits, and a clear (null) removes them along with the preset meta.
+ * Role-granted `woocommerce_pos_*` caps, if any, are out of scope: this class never adds caps
+ * to roles.
+ *
+ * @param int $user_id Target user.
+ * @param string|null $preset One of POSPreset::get_all(), or null to clear.
+ * @return bool True on success (including clears); false if the user does not
+ * exist or the preset value is not assignable.
+ *
+ * @since 11.0.0
+ */
+ public static function set_pos_preset( int $user_id, ?string $preset ): bool {
+ $user = get_user_by( 'id', $user_id );
+ if ( ! $user instanceof WP_User ) {
+ return false;
+ }
+
+ // Validate before mutating any state.
+ if ( null !== $preset && ! in_array( $preset, POSPreset::get_all(), true ) ) {
+ return false;
+ }
+
+ // Strip the user's directly-held woocommerce_pos_* caps so a preset change (or clear)
+ // starts clean. remove_cap() is a no-op for caps the user does not hold and strips a cap
+ // whether it was granted (true) or explicitly denied (false). This class only grants caps
+ // per-user via add_cap(), so role-granted caps are out of scope.
+ foreach ( self::all_pos_capabilities() as $cap ) {
+ $user->remove_cap( $cap );
+ }
+
+ if ( null === $preset ) {
+ Users::delete_site_user_meta( $user_id, self::POS_PRESET_META_KEY );
+ return true;
+ }
+
+ // Store the preset per-site so the bookkeeping stays aligned with the blog-scoped
+ // POS capabilities on multisite (Users::update_site_user_meta suffixes the blog prefix,
+ // so the key still matches the woocommerce_% uninstall sweep).
+ Users::update_site_user_meta( $user_id, self::POS_PRESET_META_KEY, $preset );
+
+ foreach ( array_keys( self::capabilities_for_preset( $preset ) ) as $cap ) {
+ $user->add_cap( $cap );
+ }
+
+ return true;
+ }
+
+ /**
+ * Translated label for a POS preset.
+ *
+ * @param string $preset One of the POSPreset constants.
+ * @return string Empty string for an unknown preset.
+ *
+ * @since 11.0.0
+ */
+ public static function preset_label( string $preset ): string {
+ $definitions = self::preset_definitions();
+
+ return isset( $definitions[ $preset ] ) ? $definitions[ $preset ]['label'] : '';
+ }
}
diff --git a/plugins/woocommerce/src/Internal/POS/POSPreset.php b/plugins/woocommerce/src/Internal/POS/POSPreset.php
new file mode 100644
index 00000000000..ce6a67909b0
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/POS/POSPreset.php
@@ -0,0 +1,59 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\POS;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Enum-style class for the assignable POS staff presets.
+ *
+ * Presets are UI-curated bundles of `woocommerce_pos_*` capabilities (Cashier / Manager /
+ * Admin); see Capabilities::capabilities_for_preset(). Kept under the POS
+ * namespace rather than the shared `Enums` namespace because presets are a
+ * UI-level grouping that may change as the granular-caps model matures, not a
+ * stable store-wide vocabulary.
+ *
+ * Constant class rather than a native PHP enum so it stays within WooCommerce's
+ * PHP 7.4 minimum.
+ *
+ * @since 11.0.0
+ * @internal
+ */
+final class POSPreset {
+ /**
+ * Cashier preset.
+ *
+ * @var string
+ */
+ public const CASHIER = 'pos_cashier';
+
+ /**
+ * Manager preset.
+ *
+ * @var string
+ */
+ public const MANAGER = 'pos_manager';
+
+ /**
+ * Admin preset.
+ *
+ * @var string
+ */
+ public const ADMIN = 'pos_admin';
+
+ /**
+ * All assignable presets, in ascending capability order.
+ *
+ * @return string[]
+ *
+ * @since 11.0.0
+ */
+ public static function get_all(): array {
+ return array(
+ self::CASHIER,
+ self::MANAGER,
+ self::ADMIN,
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php
index d328f116666..ac03d901d2e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php
@@ -4,13 +4,16 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\POS;
use Automattic\WooCommerce\Internal\POS\Capabilities;
+use Automattic\WooCommerce\Internal\POS\POSPreset;
+use Automattic\WooCommerce\Internal\Utilities\Users;
use WC_Unit_Test_Case;
/**
* Unit tests for the POS access model (capability primitives).
*
- * Covers the cap catalog and has_pos_access() — the single authorization signal.
- * The preset layer that assigns these caps is tested separately.
+ * Covers the cap catalog, has_pos_access() (the single authorization signal), and
+ * the preset layer (POSPreset, capabilities_for_preset / get_pos_preset /
+ * set_pos_preset / preset_label).
*/
class CapabilitiesTest extends WC_Unit_Test_Case {
@@ -175,4 +178,252 @@ class CapabilitiesTest extends WC_Unit_Test_Case {
wp_delete_user( $user_id );
}
+
+ /**
+ * @testdox POSPreset::get_all returns the three presets in ascending order.
+ */
+ public function test_pos_preset_get_all(): void {
+ $this->assertSame(
+ array(
+ POSPreset::CASHIER,
+ POSPreset::MANAGER,
+ POSPreset::ADMIN,
+ ),
+ POSPreset::get_all()
+ );
+ }
+
+ /**
+ * @return array<string, array<mixed>>
+ */
+ public function provider_preset_caps(): array {
+ $cashier = array( Capabilities::CAP_PROCESS_SALES, Capabilities::CAP_VIEW_ORDERS, Capabilities::CAP_APPLY_COUPONS );
+ $manager = array_merge( $cashier, array( Capabilities::CAP_CREATE_COUPONS, Capabilities::CAP_ISSUE_REFUNDS, Capabilities::CAP_VIEW_SETTINGS ) );
+ $admin = array_merge( $manager, array( Capabilities::CAP_EDIT_SETTINGS, Capabilities::CAP_MANAGE_STAFF, Capabilities::CAP_EXIT_POS ) );
+
+ return array(
+ 'cashier' => array( POSPreset::CASHIER, $cashier ),
+ 'manager' => array( POSPreset::MANAGER, $manager ),
+ 'admin' => array( POSPreset::ADMIN, $admin ),
+ );
+ }
+
+ /**
+ * @testdox capabilities_for_preset returns exactly the documented cap bundle per preset.
+ *
+ * Asserts the full expected set (including caps inherited from lower tiers), so a
+ * regression that drops a base cap or adds an unexpected one fails the test.
+ *
+ * @dataProvider provider_preset_caps
+ *
+ * @param string $preset Preset slug.
+ * @param string[] $expected The exact caps the preset grants.
+ */
+ public function test_capabilities_for_preset( string $preset, array $expected ): void {
+ $this->assertEqualsCanonicalizing(
+ $expected,
+ array_keys( Capabilities::capabilities_for_preset( $preset ) )
+ );
+ }
+
+ /**
+ * @testdox capabilities_for_preset returns an empty bundle for an unknown preset.
+ */
+ public function test_capabilities_for_unknown_preset_is_empty(): void {
+ $this->assertSame( array(), Capabilities::capabilities_for_preset( 'bogus' ) );
+ }
+
+ /**
+ * @testdox preset_label returns a non-empty label per preset and empty for unknown.
+ */
+ public function test_preset_label(): void {
+ $this->assertNotSame( '', Capabilities::preset_label( POSPreset::CASHIER ) );
+ $this->assertNotSame( '', Capabilities::preset_label( POSPreset::MANAGER ) );
+ $this->assertNotSame( '', Capabilities::preset_label( POSPreset::ADMIN ) );
+ $this->assertSame( '', Capabilities::preset_label( 'bogus' ) );
+ }
+
+ /**
+ * @testdox pos_staff_user_query_args selects POS cap-holders and excludes others.
+ *
+ * Verifies the query matches the access definition (any woocommerce_pos_* cap), not the
+ * preset meta: a cap-holder is returned and a fresh administrator is not.
+ */
+ public function test_pos_staff_user_query_args_selects_cap_holders(): void {
+ $staff = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ $outsider = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ get_userdata( $staff )->add_cap( Capabilities::CAP_ISSUE_REFUNDS );
+
+ $results = ( new \WP_User_Query( Capabilities::pos_staff_user_query_args() ) )->get_results();
+ $ids = wp_list_pluck( $results, 'ID' );
+
+ $this->assertContains( $staff, $ids, 'A user holding a woocommerce_pos_* cap should be selected.' );
+ $this->assertNotContains( $outsider, $ids, 'An administrator without any woocommerce_pos_* cap should not be selected.' );
+
+ wp_delete_user( $staff );
+ wp_delete_user( $outsider );
+ }
+
+ /**
+ * @testdox get_pos_preset returns the assigned preset.
+ */
+ public function test_get_pos_preset_returns_assigned_preset(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ Capabilities::set_pos_preset( $user_id, POSPreset::MANAGER );
+
+ $this->assertSame( POSPreset::MANAGER, Capabilities::get_pos_preset( $user_id ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox get_pos_preset returns null when unset or when the meta is not an assignable preset.
+ */
+ public function test_get_pos_preset_null_when_unset_or_invalid(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+ $this->assertNull( Capabilities::get_pos_preset( $user_id ) );
+
+ Users::update_site_user_meta( $user_id, Capabilities::POS_PRESET_META_KEY, 'bogus' );
+ $this->assertNull( Capabilities::get_pos_preset( $user_id ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox set_pos_preset grants the preset's woocommerce_pos_* caps as real WP capabilities.
+ */
+ public function test_set_pos_preset_grants_preset_caps(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+ $this->assertTrue( Capabilities::set_pos_preset( $user_id, POSPreset::MANAGER ) );
+
+ $this->assertTrue( user_can( $user_id, Capabilities::CAP_PROCESS_SALES ) );
+ $this->assertTrue( user_can( $user_id, Capabilities::CAP_ISSUE_REFUNDS ) );
+ $this->assertFalse( user_can( $user_id, Capabilities::CAP_MANAGE_STAFF ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox Switching a preset from Manager to Cashier strips the manager-only caps.
+ */
+ public function test_set_pos_preset_downgrade_strips_higher_caps(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ Capabilities::set_pos_preset( $user_id, POSPreset::MANAGER );
+
+ Capabilities::set_pos_preset( $user_id, POSPreset::CASHIER );
+
+ $this->assertTrue( user_can( $user_id, Capabilities::CAP_PROCESS_SALES ) );
+ $this->assertFalse( user_can( $user_id, Capabilities::CAP_ISSUE_REFUNDS ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox Clearing a preset strips every woocommerce_pos_* cap and deletes the preset meta.
+ */
+ public function test_set_pos_preset_clear_strips_caps_and_meta(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ Capabilities::set_pos_preset( $user_id, POSPreset::ADMIN );
+
+ $this->assertTrue( Capabilities::set_pos_preset( $user_id, null ) );
+
+ foreach ( Capabilities::all_pos_capabilities() as $cap ) {
+ $this->assertFalse( user_can( $user_id, $cap ), "Cap {$cap} should be cleared." );
+ }
+ $this->assertNull( Capabilities::get_pos_preset( $user_id ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox Clearing a preset leaves the user's non-POS capabilities untouched.
+ *
+ * The strip loop iterates only all_pos_capabilities(), so a directly-granted cap
+ * outside the woocommerce_pos_* set must survive a clear — guarding against a regression to a
+ * blanket reset.
+ */
+ public function test_set_pos_preset_clear_leaves_non_pos_caps_untouched(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ $user = get_userdata( $user_id );
+ $user->add_cap( 'edit_posts' );
+ Capabilities::set_pos_preset( $user_id, POSPreset::MANAGER );
+
+ Capabilities::set_pos_preset( $user_id, null );
+
+ $this->assertFalse( Capabilities::has_pos_access( $user_id ), 'POS caps should be cleared.' );
+ $this->assertTrue( user_can( $user_id, 'edit_posts' ), 'A non-POS cap held directly must survive a preset clear.' );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox set_pos_preset rejects an invalid preset without mutating existing caps.
+ */
+ public function test_set_pos_preset_rejects_invalid_preset(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ Capabilities::set_pos_preset( $user_id, POSPreset::MANAGER );
+
+ $this->assertFalse( Capabilities::set_pos_preset( $user_id, 'bogus' ) );
+
+ $this->assertTrue( user_can( $user_id, Capabilities::CAP_ISSUE_REFUNDS ) );
+ $this->assertSame( POSPreset::MANAGER, Capabilities::get_pos_preset( $user_id ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox set_pos_preset returns false for a non-existent user.
+ */
+ public function test_set_pos_preset_rejects_unknown_user(): void {
+ $this->assertFalse( Capabilities::set_pos_preset( 0, POSPreset::CASHIER ) );
+ $this->assertFalse( Capabilities::set_pos_preset( 9999999, POSPreset::CASHIER ) );
+ }
+
+ /**
+ * @testdox Granting or clearing a preset leaves the user's WP role untouched.
+ */
+ public function test_set_pos_preset_leaves_role_untouched(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'shop_manager' ) );
+
+ Capabilities::set_pos_preset( $user_id, POSPreset::MANAGER );
+ $this->assertSame( array( 'shop_manager' ), get_userdata( $user_id )->roles );
+
+ Capabilities::set_pos_preset( $user_id, null );
+ $this->assertSame( array( 'shop_manager' ), get_userdata( $user_id )->roles );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox has_pos_access tracks set_pos_preset: true after assign, false after clear.
+ */
+ public function test_has_pos_access_tracks_set_pos_preset(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+ Capabilities::set_pos_preset( $user_id, POSPreset::CASHIER );
+ $this->assertTrue( Capabilities::has_pos_access( $user_id ) );
+
+ Capabilities::set_pos_preset( $user_id, null );
+ $this->assertFalse( Capabilities::has_pos_access( $user_id ) );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * @testdox Preset meta without any woocommerce_pos_* cap does not grant POS access.
+ *
+ * The caps are the authorization signal, not the meta: a planted or partially
+ * migrated preset meta value must not by itself confer access.
+ */
+ public function test_has_pos_access_false_with_stale_preset_meta_only(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+ Users::update_site_user_meta( $user_id, Capabilities::POS_PRESET_META_KEY, POSPreset::CASHIER );
+
+ $this->assertFalse( Capabilities::has_pos_access( $user_id ) );
+
+ wp_delete_user( $user_id );
+ }
}
diff --git a/plugins/woocommerce/uninstall.php b/plugins/woocommerce/uninstall.php
index efc13c5a532..d586b16e7c3 100644
--- a/plugins/woocommerce/uninstall.php
+++ b/plugins/woocommerce/uninstall.php
@@ -133,6 +133,41 @@ if ( defined( 'WC_REMOVE_ALL_DATA' ) && true === WC_REMOVE_ALL_DATA ) {
OR meta_key IN ( 'wc_last_active', 'wc_marketplace_suggestions_dismissed_suggestions' );"
);
+ /*
+ * Remove direct POS capabilities (woocommerce_pos_*) granted per user via WP_User::add_cap().
+ *
+ * Unlike the woocommerce_pos_preset meta removed above, these caps live inside the serialized
+ * {prefix}capabilities meta row rather than a woocommerce_ meta key, so the sweep above can't reach
+ * them. Left behind, a reinstall would silently restore POS access because has_pos_access() keys off
+ * these caps. The row also stores the user's role, so strip only the woocommerce_pos_ caps per user
+ * via remove_cap() rather than deleting the row.
+ *
+ * Users are matched by the woocommerce_pos_ cap prefix — the same {prefix}capabilities LIKE that
+ * WP_User_Query's capability__in (used by Capabilities::pos_staff_user_query_args()) is built on —
+ * so no fixed cap list is duplicated here; the PSR-4 Capabilities class is not autoloadable during
+ * uninstall. The per-user strip is prefix-based for the same reason.
+ */
+ $pos_staff_ids = get_users(
+ array(
+ 'fields' => 'ID',
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off uninstall cleanup, not a runtime query.
+ array(
+ 'key' => $wpdb->prefix . 'capabilities',
+ 'value' => 'woocommerce_pos_',
+ 'compare' => 'LIKE',
+ ),
+ ),
+ )
+ );
+ foreach ( $pos_staff_ids as $pos_staff_id ) {
+ $pos_staff_user = new WP_User( (int) $pos_staff_id );
+ foreach ( array_keys( $pos_staff_user->caps ) as $pos_capability ) {
+ if ( 0 === strpos( (string) $pos_capability, 'woocommerce_pos_' ) ) {
+ $pos_staff_user->remove_cap( (string) $pos_capability );
+ }
+ }
+ }
+
// Delete our data from the post and post meta tables, and remove any additional tables we created.
$wpdb->query( "DELETE FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation', 'shop_coupon', 'shop_order', 'shop_order_refund' );" );
$wpdb->query( "DELETE meta FROM {$wpdb->postmeta} meta LEFT JOIN {$wpdb->posts} posts ON posts.ID = meta.post_id WHERE posts.ID IS NULL;" );