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 );
+ }
+}