Commit 1a1326dce7b for woocommerce

commit 1a1326dce7b4f21fce9bdec30911d4d8966436d0
Author: Jaclyn Chen <watertranquil@gmail.com>
Date:   Tue Jun 30 12:19:30 2026 -0400

    Add POS staff foundation: feature flag, orchestrator, caps model (#65657)

    * Add POS staff foundation: feature flag, orchestrator, caps model

    Introduce the dev-only point_of_sale_staff feature flag (off by default, child of point_of_sale), the flag-gated POSController orchestrator (no runtime surface yet — on_init no-ops until later PRs register endpoints/hooks/admin UI), and the pos_* capability access model (Capabilities) that keys POS access on holding any pos_* WP capability.

    No WP role is registered in this iteration: a global role is the hardest artifact to revert, so new POS-only accounts default to the stock subscriber role (Capabilities::DEFAULT_STAFF_ROLE). The dedicated role can land later as a forward migration.

    * Rename POS capabilities to the woocommerce_pos_ prefix

    The bare pos_ prefix is too generic and risks colliding with capabilities
    from WordPress core or other plugins. Namespace every POS capability under
    woocommerce_pos_ so the cap catalog is unambiguously ours.

    This is a wire contract shared with the POS mobile clients, so the prefix
    must stay in sync with them.

    * Resolve POS access from $allcaps instead of looping user_can()

    has_pos_access() looped user_can() once per POS cap, re-running map_meta_cap()
    and firing the user_has_cap filter on each iteration. Read the resolved
    WP_User::$allcaps map directly instead — a plain array check per cap.

    Reading $allcaps also fixes a multisite invariant: user_can() grants a super
    admin every capability, so the old loop made every super admin implicitly
    count as POS staff. $allcaps stores only caps the user actually holds (the
    super-admin grant is a runtime gate in has_cap(), not stored), so a super
    admin now needs an explicit woocommerce_pos_* cap like anyone else. Covered
    by a new multisite-only test.

    * Document what each POS capability grants

    Add a one-line comment above every woocommerce_pos_* capability describing the
    action it authorizes (e.g. exit = leave POS mode for the full admin), and note
    the shared prefix in the group docblock. Makes the cap catalog self-explaining
    without cross-referencing the preset layer.

    * Drop the deprecated point_of_sale parent-flag gate

    Trunk made the point_of_sale feature always-enabled and deprecated it (since
    11.0.0), so gating POS staff on it is now a no-op that would break once the flag
    is removed. Gate POSController solely on the point_of_sale_staff flag, and drop
    the now-pointless parent-flag dependency from the point_of_sale_staff feature
    definition (the disabled/desc_tip setting closures and the "Requires Point of
    Sale to also be enabled" wording).

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Hide the POS staff feature flag from the Features UI

    The feature won't be complete by this release's code freeze, so its
    experimental toggle must not ship merchant-toggleable. disable_ui => true
    removes it from the Features screen; devs and QA can still enable it via
    the woocommerce_admin_features filter or by setting the option. Flip back
    to false once the feature is ready for an experimental preview.

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-pos-staff-foundation b/plugins/woocommerce/changelog/add-pos-staff-foundation
new file mode 100644
index 00000000000..f1600b67b40
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-pos-staff-foundation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the foundation for POS staff management: an experimental `point_of_sale_staff` feature flag (off by default), a flag-gated orchestrator, and the `pos_*` capability access model that keys POS access on holding any `pos_*` WP capability. No runtime surface yet; the staff endpoint, attribution, and admin UI register behind the flag in follow-up changes.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index fbfb1c68341..01b966ecab3 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -413,6 +413,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\POS\POSController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();

 		// Classes inheriting from RestApiControllerBase.
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 0d9d2e92db5..f52725987d8 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -557,6 +557,20 @@ class FeaturesController {
 				'deprecated_since'             => '11.0.0',
 				'deprecated_value'             => true,
 			),
+			'point_of_sale_staff'                => array(
+				'name'                         => __( 'POS staff', 'woocommerce' ),
+				'description'                  => __(
+					'Experimental: POS staff management, roles, and order attribution.',
+					'woocommerce'
+				),
+				'enabled_by_default'           => false,
+				// Hidden while incomplete so it can't ship merchant-toggleable; flip to
+				// false when it's ready for an experimental preview.
+				'disable_ui'                   => true,
+				'is_experimental'              => true,
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 			'fulfillments'                       => array(
 				'name'                         => __( 'Order Fulfillments', 'woocommerce' ),
 				'description'                  => __(
diff --git a/plugins/woocommerce/src/Internal/POS/Capabilities.php b/plugins/woocommerce/src/Internal/POS/Capabilities.php
new file mode 100644
index 00000000000..20fa66bbb1c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/POS/Capabilities.php
@@ -0,0 +1,131 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\POS;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * POS capability model.
+ *
+ * POS access is defined entirely by `woocommerce_pos_*` capabilities granted per-user — the
+ * same primitive WordPress uses for every other authorization decision. A user
+ * has POS access if and only if they hold at least one of the known `woocommerce_pos_*`
+ * capabilities (those in all_pos_capabilities(); see has_pos_access()).
+ *
+ * Capabilities are granted per-user via add_cap(), never bundled onto a WP role.
+ * POS access can therefore be added to any existing user (shop_manager,
+ * 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.
+ *
+ * @since 11.0.0
+ * @internal
+ */
+class Capabilities {
+
+	/**
+	 * Default WP role for brand-new POS-only accounts.
+	 *
+	 * POS access is keyed on `woocommerce_pos_*` capabilities, not this role (see
+	 * has_pos_access()), so new POS-only accounts use the stock `subscriber` role.
+	 * A dedicated `pos_staff` role is planned for a later iteration.
+	 */
+	public const DEFAULT_STAFF_ROLE = 'subscriber';
+
+	/**
+	 * POS capability identifiers.
+	 *
+	 * Real WP capabilities, granted per-user via add_cap() when POS access is
+	 * assigned. They surface in current_user_can() and the standard /wp/v2/users
+	 * response — no shadow permission store. All share the `woocommerce_pos_`
+	 * prefix to stay isolated from core and third-party caps; what each one grants
+	 * is described inline below.
+	 */
+	// Ring up and complete a sale at checkout.
+	public const CAP_PROCESS_SALES = 'woocommerce_pos_process_sales';
+	// Look up and view existing orders.
+	public const CAP_VIEW_ORDERS = 'woocommerce_pos_view_orders';
+	// Apply an existing coupon to a cart.
+	public const CAP_APPLY_COUPONS = 'woocommerce_pos_apply_coupons';
+	// Create a new coupon during a sale.
+	public const CAP_CREATE_COUPONS = 'woocommerce_pos_create_coupons';
+	// Refund a paid order.
+	public const CAP_ISSUE_REFUNDS = 'woocommerce_pos_issue_refunds';
+	// View POS settings (read-only).
+	public const CAP_VIEW_SETTINGS = 'woocommerce_pos_view_settings';
+	// Change POS settings.
+	public const CAP_EDIT_SETTINGS = 'woocommerce_pos_edit_settings';
+	// Manage POS staff and their access.
+	public const CAP_MANAGE_STAFF = 'woocommerce_pos_manage_staff';
+	// Leave POS mode for the full admin.
+	public const CAP_EXIT_POS = 'woocommerce_pos_exit';
+
+	/**
+	 * All known POS capability identifiers.
+	 *
+	 * The canonical list of `woocommerce_pos_*` caps — used to test for POS access and, by the
+	 * preset layer, to apply or clear a user's caps as a set.
+	 *
+	 * @return string[]
+	 */
+	public static function all_pos_capabilities(): array {
+		return array(
+			self::CAP_PROCESS_SALES,
+			self::CAP_VIEW_ORDERS,
+			self::CAP_APPLY_COUPONS,
+			self::CAP_CREATE_COUPONS,
+			self::CAP_ISSUE_REFUNDS,
+			self::CAP_VIEW_SETTINGS,
+			self::CAP_EDIT_SETTINGS,
+			self::CAP_MANAGE_STAFF,
+			self::CAP_EXIT_POS,
+		);
+	}
+
+	/**
+	 * Whether a user has any POS access at all.
+	 *
+	 * True if the user holds at least one of the known `woocommerce_pos_*` capabilities (those
+	 * in all_pos_capabilities()). This is the single authorization signal for POS
+	 * access: neither a WP role nor any meta value grants it on its own. The
+	 * any-cap definition fits both fixed presets
+	 * (each preset's caps granted as a bundle) and a future granular model
+	 * (individual `woocommerce_pos_*` caps assigned without a baseline cap).
+	 *
+	 * Reads the resolved capability map (WP_User::$allcaps) directly rather than
+	 * looping over user_can(). user_can() re-runs map_meta_cap() and fires the
+	 * user_has_cap filter on every call, so the loop would dispatch that machinery
+	 * once per POS cap; an $allcaps lookup is a plain array check per cap.
+	 *
+	 * Reading $allcaps also scopes access to caps the user actually holds: unlike
+	 * user_can(), it does not honor the multisite super-admin grant, which
+	 * has_cap() applies as a runtime gate rather than storing in $allcaps. A super
+	 * admin therefore does not implicitly count as POS staff — they need an
+	 * explicit `woocommerce_pos_*` cap like anyone else.
+	 *
+	 * @param int $user_id Target user.
+	 * @return bool
+	 *
+	 * @since 11.0.0
+	 */
+	public static function has_pos_access( int $user_id ): bool {
+		if ( $user_id <= 0 ) {
+			return false;
+		}
+
+		$user = get_userdata( $user_id );
+		if ( ! $user ) {
+			return false;
+		}
+
+		foreach ( self::all_pos_capabilities() as $cap ) {
+			if ( ! empty( $user->allcaps[ $cap ] ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/POS/POSController.php b/plugins/woocommerce/src/Internal/POS/POSController.php
new file mode 100644
index 00000000000..ed656333f45
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/POS/POSController.php
@@ -0,0 +1,77 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\POS;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+/**
+ * Feature orchestrator for the POS staff + attribution iteration.
+ *
+ * Gates the feature on the dev-only `point_of_sale_staff` flag. The runtime surfaces —
+ * staff REST endpoint, order/coupon attribution hooks, and the wp-admin Staff UI —
+ * register themselves here as they are added in follow-up changes; until then on_init()
+ * is an intentional no-op even when the flag is on.
+ *
+ * @since 11.0.0
+ * @internal
+ */
+class POSController implements RegisterHooksInterface {
+
+	private const FEATURE_FLAG = 'point_of_sale_staff';
+
+	/**
+	 * Features controller used to gate hook registration on the POS feature flags.
+	 *
+	 * @var FeaturesController
+	 */
+	private FeaturesController $features_controller;
+
+	/**
+	 * Initialize dependencies via the DI container.
+	 *
+	 * @internal
+	 *
+	 * @param FeaturesController $features_controller The features controller.
+	 */
+	final public function init( FeaturesController $features_controller ): void {
+		$this->features_controller = $features_controller;
+	}
+
+	/**
+	 * Register the feature surface.
+	 *
+	 * The feature-flag check is deferred to `on_init` because `feature_is_enabled()`
+	 * walks `FeaturesController::init_feature_definitions()`, which contains
+	 * `__( ..., 'woocommerce' )` calls. Evaluating those before `init` triggers
+	 * WP 6.7's "translation loading … too early" notice (and the headers-already-sent
+	 * cascade that follows).
+	 *
+	 * @since 11.0.0
+	 */
+	public function register(): void {
+		add_action( 'init', array( $this, 'on_init' ) );
+	}
+
+	/**
+	 * Wire up the feature surface once translations are safe to load.
+	 *
+	 * No-op when the gating flag is off. Runtime surfaces are registered here
+	 * as they are added in follow-up changes.
+	 *
+	 * @internal
+	 *
+	 * @since 11.0.0
+	 */
+	public function on_init(): void {
+		if ( ! $this->features_controller->feature_is_enabled( self::FEATURE_FLAG ) ) {
+			return;
+		}
+
+		// Runtime surfaces (staff REST endpoint, attribution hooks, admin Staff UI)
+		// register here as they are added in follow-up changes.
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php
new file mode 100644
index 00000000000..d328f116666
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/POS/CapabilitiesTest.php
@@ -0,0 +1,178 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\POS;
+
+use Automattic\WooCommerce\Internal\POS\Capabilities;
+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.
+ */
+class CapabilitiesTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Every POS capability is woocommerce_pos_-prefixed, keeping it isolated from core caps.
+	 */
+	public function test_all_caps_are_woocommerce_pos_prefixed(): void {
+		foreach ( Capabilities::all_pos_capabilities() as $cap ) {
+			$this->assertStringStartsWith( 'woocommerce_pos_', $cap, "POS cap '{$cap}' must be woocommerce_pos_-prefixed." );
+		}
+	}
+
+	/**
+	 * @testdox all_pos_capabilities lists exactly the nine known woocommerce_pos_* caps.
+	 *
+	 * Asserts the full set (order-insensitive) so the test fails if any cap is
+	 * added, removed, or swapped — not just when the count changes.
+	 */
+	public function test_all_pos_capabilities_lists_every_cap(): void {
+		$this->assertEqualsCanonicalizing(
+			array(
+				Capabilities::CAP_PROCESS_SALES,
+				Capabilities::CAP_VIEW_ORDERS,
+				Capabilities::CAP_APPLY_COUPONS,
+				Capabilities::CAP_CREATE_COUPONS,
+				Capabilities::CAP_ISSUE_REFUNDS,
+				Capabilities::CAP_VIEW_SETTINGS,
+				Capabilities::CAP_EDIT_SETTINGS,
+				Capabilities::CAP_MANAGE_STAFF,
+				Capabilities::CAP_EXIT_POS,
+			),
+			Capabilities::all_pos_capabilities()
+		);
+	}
+
+	/**
+	 * @testdox Default staff role is the stock subscriber role (no dedicated POS role yet).
+	 */
+	public function test_default_staff_role_is_subscriber(): void {
+		$this->assertSame( 'subscriber', Capabilities::DEFAULT_STAFF_ROLE );
+	}
+
+	/**
+	 * @return array<string, array<string>>
+	 */
+	public function provider_privileged_roles(): array {
+		return array(
+			'administrator' => array( 'administrator' ),
+			'shop manager'  => array( 'shop_manager' ),
+		);
+	}
+
+	/**
+	 * @testdox A fresh privileged WP role has no implicit POS access.
+	 *
+	 * POS access requires an explicitly granted woocommerce_pos_* cap; holding a privileged WP
+	 * role (administrator, shop_manager) grants none on its own.
+	 *
+	 * @dataProvider provider_privileged_roles
+	 *
+	 * @param string $role WP role to create the user with.
+	 */
+	public function test_role_has_no_implicit_access( string $role ): void {
+		$user_id = self::factory()->user->create( array( 'role' => $role ) );
+
+		$this->assertFalse( Capabilities::has_pos_access( $user_id ) );
+
+		wp_delete_user( $user_id );
+	}
+
+	/**
+	 * @testdox A multisite super admin has no implicit POS access until granted a cap.
+	 *
+	 * user_can() grants a super admin every capability on multisite, but POS access
+	 * is keyed on stored woocommerce_pos_* caps (WP_User::$allcaps), which omits the
+	 * runtime super-admin grant. A super admin therefore needs an explicit cap like
+	 * anyone else. Skips off multisite, where there is no super-admin concept.
+	 */
+	public function test_super_admin_has_no_implicit_access_on_multisite(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Super-admin access only applies on multisite.' );
+		}
+
+		$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		grant_super_admin( $user_id );
+
+		$this->assertTrue(
+			user_can( $user_id, Capabilities::CAP_ISSUE_REFUNDS ),
+			'Sanity: a super admin passes user_can() for any cap.'
+		);
+		$this->assertFalse(
+			Capabilities::has_pos_access( $user_id ),
+			'A super admin must not implicitly count as POS staff.'
+		);
+
+		$user = get_userdata( $user_id );
+		$user->add_cap( Capabilities::CAP_ISSUE_REFUNDS );
+		$this->assertTrue(
+			Capabilities::has_pos_access( $user_id ),
+			'A super admin gains POS access once granted an explicit woocommerce_pos_* cap.'
+		);
+
+		revoke_super_admin( $user_id );
+		wp_delete_user( $user_id );
+	}
+
+	/**
+	 * @testdox has_pos_access is true once the user holds any single woocommerce_pos_* cap.
+	 *
+	 * Locks in the granular-caps semantics: a back-office refunds user holding
+	 * only `woocommerce_pos_issue_refunds` (no baseline `woocommerce_pos_process_sales`) still counts as
+	 * POS staff.
+	 */
+	public function test_has_pos_access_true_with_a_single_cap(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		$user    = get_userdata( $user_id );
+		$user->add_cap( Capabilities::CAP_ISSUE_REFUNDS );
+
+		$this->assertTrue( Capabilities::has_pos_access( $user_id ) );
+
+		wp_delete_user( $user_id );
+	}
+
+	/**
+	 * @testdox has_pos_access is false when the user holds no woocommerce_pos_* caps.
+	 */
+	public function test_has_pos_access_false_without_caps(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+		$this->assertFalse( Capabilities::has_pos_access( $user_id ) );
+
+		wp_delete_user( $user_id );
+	}
+
+	/**
+	 * @testdox has_pos_access returns false for users that do not exist.
+	 */
+	public function test_has_pos_access_rejects_unknown_user(): void {
+		$this->assertFalse( Capabilities::has_pos_access( 0 ) );
+		$this->assertFalse( Capabilities::has_pos_access( 9999999 ) );
+	}
+
+	/**
+	 * @testdox has_pos_access survives a role overwrite because access is cap-keyed.
+	 *
+	 * The wp-admin users.php "Change role to…" dropdown calls set_role(), which
+	 * replaces all roles. POS access must survive — individual woocommerce_pos_* caps added
+	 * via add_cap() are not cleared by set_role().
+	 */
+	public function test_has_pos_access_survives_set_role_overwrite(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'shop_manager' ) );
+		$user    = get_userdata( $user_id );
+		$user->add_cap( Capabilities::CAP_ISSUE_REFUNDS );
+		$this->assertTrue( Capabilities::has_pos_access( $user_id ) );
+
+		$user->set_role( 'subscriber' );
+
+		$this->assertTrue(
+			Capabilities::has_pos_access( $user_id ),
+			'POS access must survive a role overwrite — caps remain intact.'
+		);
+
+		wp_delete_user( $user_id );
+	}
+}