PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
1.10.20 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 All 164 releases
← All changes | includes/Init.php +379 -134 1.9.15 → 1.10.20 View file →
@@ -12,15 +12,9 @@
12 12
13 13 use WCPOS\WooCommercePOS\Admin\Consent;
14 14 use WCPOS\WooCommercePOS\Admin\Menu;
15 15 use WCPOS\WooCommercePOS\Services\Auth as AuthService;
16 -use WCPOS\WooCommercePOS\Services\Extensions;
17 -use WCPOS\WooCommercePOS\Services\Receipt_Snapshot_Store;
18 -use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
19 -use WP_HTTP_Response;
20 -use WP_REST_Request;
21 -use WP_REST_Server;
22 -use const DOING_AJAX;
16 +use WCPOS\WooCommercePOS\Services\Service_Groups;
23 17
24 18 /**
25 19 * Init class.
26 20 */
@@ -25,41 +19,307 @@
25 19 * Init class.
26 20 */
27 21 class Init {
28 22 /**
29 - * Constructor.
23 + * Observer awaiting the constructor's non-hook seed step.
24 + *
25 + * @var Sync\Visibility_Observer|null
30 26 */
27 + private $visibility_observer;
28 +
29 + /**
30 + * Install the ordered wiring declared by {@see hook_rows()}.
31 + *
32 + * Non-hook setup stays here, interleaved at its original registration boundaries.
33 + */
31 34 public function __construct() {
32 - // global helper functions.
33 35 require_once PLUGIN_PATH . 'includes/wcpos-functions.php';
34 36 require_once PLUGIN_PATH . 'includes/wcpos-store-functions.php';
37 + wp_cache_add_global_groups( 'wc_pos_user_uuid_locks' );
35 38
36 - // Tracking consent pop-up + callout. Registered here (during
37 - // plugins_loaded) so its lifecycle hooks (activated_plugin,
38 - // upgrader_process_complete) are in place before those actions
39 - // fire on a plugin activation or update request.
40 - new Consent();
39 + $rows = $this->hook_rows( true );
40 + Hook_Manifest::validate( $rows );
41 + Hook_Manifest::install( wp_list_filter( $rows, array( 'phase' => 'pre-latch' ) ) );
42 + $sync_latched = Sync\Api::SCHEMA_VERSION === get_option( Sync\Api::SCHEMA_OPTION, null );
43 + foreach ( $rows as $row ) {
44 + if ( 'pre-latch' === $row['phase'] || ( 'sync-latched' === $row['phase'] && ! $sync_latched ) ) {
45 + continue;
46 + }
47 + if ( array( $this, 'init' ) === $row['callback'] ) {
48 + ( new Sync\Config_Fingerprint() )->maybe_cleanup_legacy_options();
49 + }
50 + Hook_Manifest::install( array( $row ) );
51 + if ( null !== $this->visibility_observer ) {
52 + $this->visibility_observer->maybe_seed_hidden_tombstones();
53 + $this->visibility_observer = null;
54 + }
55 + }
56 + }
41 57
42 - // Init hooks.
43 - add_action( 'init', array( $this, 'init' ) );
44 - add_action( 'rest_api_init', array( $this, 'init_rest_api' ), 20 );
45 - add_filter( 'query_vars', array( $this, 'query_vars' ) );
58 + /**
59 + * Declare bootstrap wiring in registration order, without installing it.
60 + *
61 + * Null hooks invoke registrars immediately; their internal priorities/arity stay
62 + * in register_hooks(). Phases keep the schema read after pre-latch hooks;
63 + * sync-latched rows are post-read hooks omitted when the latch is down.
64 + * The guard registrar MUST precede the JWT row: both register at priority 20,
65 + * after core cookie/application-password handlers. Reversing them attributes
66 + * JWT identity to prior authentication and fails open on /wc/v3/orders.
67 + *
68 + * @param bool $sync_latched Whether the verified sync schema latch is set.
69 + * @return array Ordered rows consumed by Hook_Manifest::install().
70 + */
71 + public function hook_rows( bool $sync_latched ): array {
72 + $rows = array(
73 + array(
74 + 'hook' => null,
75 + 'callback' => static function (): void {
76 + new Consent();
77 + },
78 + 'priority' => 10,
79 + 'args' => 0,
80 + 'reason' => 'Default 10; lifecycle hooks must exist during plugins_loaded, before activation/update actions.',
81 + 'phase' => 'pre-latch',
82 + ),
83 + array(
84 + 'hook' => 'woocommerce_pos_rest_api_controllers',
85 + 'callback' => array( Sync\Api::class, 'register_controllers' ),
86 + 'priority' => 10,
87 + 'args' => 1,
88 + 'reason' => 'Default; sole callback. Response registrars stay inside this filter to retain REST activation timing.',
89 + 'phase' => 'pre-latch',
90 + ),
91 + array(
92 + 'hook' => Sync\Integrity_Digest::REBUILD_HOOK,
93 + 'callback' => array( Sync\Integrity_Digest::class, 'run_scheduled_rebuild' ),
94 + 'priority' => 10,
95 + 'args' => 1,
96 + 'reason' => 'Default; sole callback. Unlatched so an already-scheduled rebuild still has a listener.',
97 + 'phase' => 'pre-latch',
98 + ),
99 + array(
100 + 'hook' => null,
101 + 'callback' => array( Sync\Meta_Normalizer::class, 'register_hooks' ),
102 + 'priority' => 10,
103 + 'args' => 0,
104 + 'reason' => 'Priority 5 before revision 9 and augmentation 10, so revisions match bare wc/v3 rereads; also serves orders.',
105 + 'phase' => 'sync-latched',
106 + ),
107 + array(
108 + 'hook' => 'woocommerce_pos_sync_serialized_order',
109 + 'callback' => array( Sync\Pos_Uuid::class, 'stamp_serialized_record' ),
110 + 'priority' => 10,
111 + 'args' => 3,
112 + 'reason' => 'After normalization at 5, in step with product stampers at 10.',
113 + 'phase' => 'sync-latched',
114 + ),
115 + array(
116 + 'hook' => null,
117 + 'callback' => array( Sync\Augmentation_Pipeline::class, 'install' ),
118 + 'priority' => 10,
119 + 'args' => 0,
120 + 'reason' => 'Revision 9 hashes normalized, unaugmented bytes; UUID/digest/projections at 10 preserve extension order, including order-pull digests.',
121 + 'phase' => 'sync-latched',
122 + ),
123 + array(
124 + 'hook' => null,
125 + 'callback' => array( Sync\Pos_Uuid::class, 'register_hooks' ),
126 + 'priority' => 10,
127 + 'args' => 0,
128 + 'reason' => 'Default 10; identity is unconditional: before-save UUIDs land in the same write and native restores re-prove ownership (ADR 0038).',
129 + 'phase' => 'post-latch',
130 + ),
131 + array(
132 + 'hook' => null,
133 + 'callback' => static function (): void {
134 + ( new Sync\Sync_Journal() )->register_hooks();
135 + },
136 + 'priority' => 10,
137 + 'args' => 0,
138 + 'reason' => 'Default 10; dirty order updates coalesce until shutdown at PHP_INT_MAX, after WooCommerce customer 10/session 20 saves.',
139 + 'phase' => 'sync-latched',
140 + ),
141 + array(
142 + 'hook' => null,
143 + 'callback' => function (): void {
144 + $this->visibility_observer = new Sync\Visibility_Observer();
145 + $this->visibility_observer->register_hooks();
146 + },
147 + 'priority' => 10,
148 + 'args' => 0,
149 + 'reason' => 'Default 10 after journal; records servable-set transitions, using generic pre-delete_option; Init then seeds tombstones.',
150 + 'phase' => 'sync-latched',
151 + ),
152 + array(
153 + 'hook' => null,
154 + 'callback' => static function (): void {
155 + ( new Sync\Sync_Journal_Purge() )->register_hooks();
156 + },
157 + 'priority' => 10,
158 + 'args' => 0,
159 + 'reason' => 'Default 10; sole cron listener; the registrar also schedules the daily purge.',
160 + 'phase' => 'sync-latched',
161 + ),
162 + array(
163 + 'hook' => null,
164 + 'callback' => static function (): void {
165 + ( new Sync\Integrity_Digest() )->register_hooks();
166 + },
167 + 'priority' => 10,
168 + 'args' => 0,
169 + 'reason' => 'Default 10, shutdown PHP_INT_MAX; journal registers first on shared hooks (reason unknown); dirty digests coalesce until flush.',
170 + 'phase' => 'sync-latched',
171 + ),
172 + array(
173 + 'hook' => 'init',
174 + 'callback' => array( $this, 'init' ),
175 + 'priority' => 10,
176 + 'args' => 1,
177 + 'reason' => 'Default 10; free services must exist before Pro init at 20.',
178 + 'phase' => 'post-latch',
179 + ),
180 + array(
181 + 'hook' => 'rest_api_init',
182 + 'callback' => array( $this, 'init_rest_api' ),
183 + 'priority' => 20,
184 + 'args' => 1,
185 + 'reason' => 'Original reason unknown (8f2b9eac); Pro deliberately registers before free at 9.',
186 + 'phase' => 'post-latch',
187 + ),
188 + array(
189 + 'hook' => 'query_vars',
190 + 'callback' => array( $this, 'query_vars' ),
191 + 'priority' => 10,
192 + 'args' => 1,
193 + 'reason' => 'Default; appends one variable.',
194 + 'phase' => 'post-latch',
195 + ),
196 + array(
197 + 'hook' => 'pre_update_option_woocommerce_pos_pro_settings_license',
198 + 'callback' => array( self::class, 'remove_license_transient' ),
199 + 'priority' => 10,
200 + 'args' => 2,
201 + 'reason' => 'Default; the reentrancy guard, not priority, makes legacy Pro license cache invalidation safe (f33b8d655).',
202 + 'phase' => 'post-latch',
203 + ),
204 + array(
205 + 'hook' => null,
206 + 'callback' => array( Rest_Cors::class, 'register_hooks' ),
207 + 'priority' => 10,
208 + 'args' => 0,
209 + 'reason' => 'Unconditional for unmarked preflights/relay; serve at 20 after core CORS at 10 so WCPOS is the last writer.',
210 + 'phase' => 'post-latch',
211 + ),
212 + array(
213 + 'hook' => 'send_headers',
214 + 'callback' => array( $this, 'send_headers' ),
215 + 'priority' => 99,
216 + 'args' => 1,
217 + 'reason' => 'Unknown beyond running late for WPSEO integration (62da70551).',
218 + 'phase' => 'post-latch',
219 + ),
220 + array(
221 + 'hook' => 'send_headers',
222 + 'callback' => array( $this, 'remove_x_frame_options' ),
223 + 'priority' => 9999,
224 + 'args' => 1,
225 + 'reason' => 'Must remove X-Frame-Options AFTER security plugins set it (80ee545a5).',
226 + 'phase' => 'post-latch',
227 + ),
228 + array(
229 + 'hook' => null,
230 + 'callback' => static function (): void {
231 + ( new Services\Core_Order_Audit_Guard() )->register_hooks();
232 + },
233 + 'priority' => 10,
234 + 'args' => 0,
235 + 'reason' => 'Auth provenance at 20 MUST register before JWT at 20, after core cookie/password auth; rest_pre_dispatch reads it at 10.',
236 + 'phase' => 'post-latch',
237 + ),
238 + array(
239 + 'hook' => null,
240 + 'callback' => array( Sync\Coupon_Modified_Date::class, 'register_hooks' ),
241 + 'priority' => 10,
242 + 'args' => 0,
243 + 'reason' => 'Default 10; unconditional meta-only coupon edit timestamps for date-based replication; journal uses wall clock, not post_modified.',
244 + 'phase' => 'post-latch',
245 + ),
246 + array(
247 + 'hook' => 'determine_current_user',
248 + 'callback' => array( $this, 'determine_current_user_early' ),
249 + 'priority' => 20,
250 + 'args' => 1,
251 + 'reason' => 'At 20 AFTER the audit guard and core cookie/password handlers; register before init, regardless of the request marker.',
252 + 'phase' => 'post-latch',
253 + ),
254 + array(
255 + 'hook' => null,
256 + 'callback' => static function (): void {
257 + ( new Services\Lifecycle_Events() )->register_hooks();
258 + },
259 + 'priority' => 10,
260 + 'args' => 0,
261 + 'reason' => 'Default 10; append after auth pair; admin_init flushes pending events and consent-gates refresh scheduling, not the cron listener.',
262 + 'phase' => 'post-latch',
263 + ),
264 + array(
265 + 'hook' => null,
266 + 'callback' => static function (): void {
267 + Services\Error_Reporter::instance()->register_hooks();
268 + },
269 + 'priority' => 10,
270 + 'args' => 0,
271 + 'reason' => 'Append after lifecycle; REST priority 999 reports the final response status, gated by consent (#1811).',
272 + 'phase' => 'post-latch',
273 + ),
274 + );
46 275
47 - // Headers for API discoverability.
48 - add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 5, 4 );
49 - add_action( 'send_headers', array( $this, 'send_headers' ), 99, 1 );
50 - add_action( 'send_headers', array( $this, 'remove_x_frame_options' ), 9999, 1 );
276 + return array_values(
277 + array_filter(
278 + $rows,
279 + static function ( array $row ) use ( $sync_latched ): bool {
280 + return $sync_latched || 'sync-latched' !== $row['phase'];
281 + }
282 + )
283 + );
284 + }
51 285
52 - /*
53 - * Add JWT authentication filter.
54 - *
55 - * Hook order: plugins_loaded -> init (determine_current_user) -> rest_api_init
56 - *
57 - * This filter runs at priority 20 (after WordPress core's cookie auth at priority 10).
58 - * It must be registered here (during plugins_loaded) because determine_current_user
59 - * fires during 'init', which is BEFORE rest_api_init where our API class loads.
60 - */
61 - add_filter( 'determine_current_user', array( $this, 'determine_current_user_early' ), 20 );
286 + /**
287 + * Clear cached data that depends on the Pro license.
288 + *
289 + * @param mixed $value The new option value.
290 + * @param mixed $old_value The previous option value (false when unset).
291 + *
292 + * @return mixed
293 + */
294 + public static function remove_license_transient( $value, $old_value = false ) {
295 + // Pro's updater can react to the update_plugins deletion by reading —
296 + // and, when the stored instance id is blank, re-saving — the license
297 + // option, which re-enters this filter. Without the guard that cycle is
298 + // unbounded and OOMs the first license activation on a fresh install.
299 + static $clearing = false;
300 + if ( $clearing ) {
301 + return $value;
302 + }
303 + $clearing = true;
304 + delete_transient( 'woocommerce_pos_pro_license_status' );
305 +
306 + // The update caches bind to the license key and activation state. A
307 + // write that changes neither — e.g. Pro's read-side instance mint —
308 + // must not wipe update_plugins: Pro reacts to that deletion by
309 + // clearing its own update-data cache, which empties the payload of an
310 + // update check that is in flight when the mint occurs.
311 + $old = \is_array( $old_value ) ? $old_value : array();
312 + $new = \is_array( $value ) ? $value : array();
313 + if (
314 + (string) ( $old['key'] ?? '' ) !== (string) ( $new['key'] ?? '' )
315 + || ! empty( $old['activated'] ) !== ! empty( $new['activated'] )
316 + ) {
317 + delete_site_transient( 'update_plugins' );
318 + }
319 + $clearing = false;
320 +
321 + return $value;
62 322 }
63 323
64 324 /**
65 325 * Early authentication check for JWT tokens.
@@ -82,30 +342,14 @@
82 342 if ( ! empty( $user_id ) ) {
83 343 return $user_id;
84 344 }
85 345
86 - // Check for authorization token (header or param).
87 - $auth_header = $this->get_auth_header_early();
88 - if ( ! \is_string( $auth_header ) || empty( $auth_header ) ) {
346 + $authenticated_user_id = AuthService::instance()->authenticate_request();
347 + if ( false === $authenticated_user_id || is_wp_error( $authenticated_user_id ) ) {
89 348 return $user_id;
90 349 }
91 350
92 - // Extract Bearer token.
93 - list( $token ) = sscanf( $auth_header, 'Bearer %s' );
94 - if ( ! $token ) {
95 - return $user_id;
96 - }
97 -
98 - // Validate token - this will fail for non-WCPOS tokens.
99 - $auth_service = AuthService::instance();
100 - $decoded_token = $auth_service->validate_token( $token );
101 -
102 - if ( is_wp_error( $decoded_token ) ) {
103 - return $user_id;
104 - }
105 -
106 - // Return the authenticated user ID.
107 - return absint( $decoded_token->data->user->id );
351 + return $authenticated_user_id;
108 352 }
109 353
110 354 /**
111 355 * Load the required resources.
@@ -123,8 +367,13 @@
123 367 public function init_rest_api(): void {
124 368 $is_wcpos_request = woocommerce_pos_request();
125 369
126 370 if ( $is_wcpos_request ) {
371 + if ( ! wcpos_request( 'header' ) && ! wcpos_request( 'query_var' ) ) {
372 + // Namespace-detected only: routes still register, but surface
373 + // that a proxy/WAF is stripping the X-WCPOS marker.
374 + $this->log_unmarked_wcpos_rest_request();
375 + }
127 376 new API();
128 377 } else {
129 378 // Queue the registration at a later priority of the SAME
130 379 // rest_api_init pass this method runs on (priority 20), so
@@ -131,9 +380,8 @@
131 380 // register_rest_route() executes during the action as WP requires.
132 381 // When this method is called outside the action (tests), the
133 382 // add_action is simply inert.
134 383 add_action( 'rest_api_init', array( $this, 'register_public_relay_routes' ), 30 );
135 - $this->log_unmarked_wcpos_rest_request();
136 384 new WC_API();
137 385 }
138 386 }
139 387
@@ -150,9 +398,9 @@
150 398 SHORT_NAME . '/v1',
151 399 '/print-jobs/relay-verification',
152 400 array(
153 401 'methods' => 'GET',
154 - 'callback' => array( new API\Print_Jobs_Controller(), 'relay_verification' ),
402 + 'callback' => array( new API\V1\Print_Jobs_Controller(), 'relay_verification' ),
155 403 'permission_callback' => '__return_true',
156 404 )
157 405 );
158 406 }
@@ -157,12 +405,13 @@
157 405 );
158 406 }
159 407
160 408 /**
161 - * Log requests for a WCPOS namespace that omitted the required request marker.
409 + * Log requests for a WCPOS namespace that omitted the request marker.
162 410 *
163 - * This runs before WCPOS routes are registered, so it captures the otherwise
164 - * silent rest_no_route response. Warnings are limited by API version to avoid
411 + * Namespace detection registers the routes anyway; this surfaces that a
412 + * proxy/WAF is stripping the X-WCPOS marker so misconfigured hosts stay
413 + * visible in the logs. Warnings are limited by API version to avoid
165 414 * allowing repeated unauthenticated requests to flood WooCommerce logs.
166 415 */
167 416 private function log_unmarked_wcpos_rest_request(): void {
168 417 global $wp;
@@ -186,9 +435,9 @@
186 435 return;
187 436 }
188 437
189 438 set_transient( $transient, 1, 5 * MINUTE_IN_SECONDS );
190 - Logger::warning( $route . ': missing WCPOS request marker.' );
439 + Logger::warning( $route . ': request marker missing (routes still registered via namespace detection).' );
191 440 }
192 441
193 442 /**
194 443 * Adds 'wcpos' to the query variables allowed before processing.
@@ -207,46 +456,29 @@
207 456 return $query_vars;
208 457 }
209 458
210 459 /**
211 - * Allow pre-flight requests from WCPOS Desktop and Mobile Apps
212 - * Note: pre-flight requests cannot have headers, so I can't filter by pos request
213 - * See: https://fetch.spec.whatwg.org/#cors-preflight-fetch.
460 + * Allow HEAD checks for WP API Link URL and server uptime.
214 461 *
215 - * @param bool $served Whether the request has already been served.
216 - * Default false.
217 - * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`.
218 - * @param WP_REST_Request $request Request used to generate the response.
219 - * @param WP_REST_Server $server Server instance.
462 + * This is the NON-REST lane and is not part of the REST wire contract
463 + * ({@see Rest_Cors}): `send_headers` fires from `WP::main()`, which a REST
464 + * request never reaches — core's `rest_api_loaded()` runs on
465 + * `parse_request` and dies. What it serves is the app's site-discovery
466 + * probe against an ordinary page: the app reads the `Link:
467 + * <.../wp-json/>; rel="https://api.w.org/"` header cross-origin to find
468 + * the REST root, which needs both headers below. Some servers turn HEAD
469 + * into GET, hence the `?_method=head` query param rather than the method.
220 470 *
221 - * @return bool $served
222 - */
223 - public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) {
224 - if ( 'OPTIONS' == $request->get_method() ) {
225 - $allow_headers = array(
226 - 'Authorization', // For user-agent authentication with a server.
227 - 'X-WP-Nonce', // WordPress-specific header, used for CSRF protection.
228 - 'Content-Disposition', // Informs how to process the response data.
229 - 'Content-MD5', // For verifying data integrity.
230 - 'Content-Type', // Specifies the media type of the resource.
231 - 'X-HTTP-Method-Override', // Used to override the HTTP method.
232 - 'X-WCPOS', // Used to identify WCPOS requests.
233 - );
234 -
235 - $server->send_header( 'Access-Control-Allow-Origin', '*' );
236 - $server->send_header( 'Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE' );
237 - $server->send_header( 'Access-Control-Allow-Headers', implode( ', ', $allow_headers ) );
238 - }
239 -
240 - return $served;
241 - }
242 -
243 - /**
244 - * Allow HEAD checks for WP API Link URL and server uptime
245 - * Fires once the requested HTTP headers for caching, content type, etc. have been sent.
471 + * This is live, not legacy. The client calls it on every Connect:
472 + * `packages/core/src/screens/auth/hooks/use-url-discovery.ts` issues
473 + * `http.head()` against the site root, and
474 + * `packages/hooks/src/use-http-client/use-http-client.tsx` sets
475 + * `params._method = 'HEAD'` on every HEAD request (both in the client
476 + * monorepo). That same client code deliberately omits the `X-WCPOS`
477 + * marker for HEAD, so this handler cannot be marker-gated and must stay
478 + * unconditional. 521ccb9a added it; the `?wcpos=1` gate it originally
479 + * carried is long gone.
246 480 *
247 - * FIXME: Why is Link header not exposed sometimes on my development machine?
248 - *
249 481 * @return void
250 482 */
251 483 public function send_headers(): void {
252 484 // some server convert HEAD to GET method, so use this query param instead.
@@ -274,57 +506,53 @@
274 506 }
275 507 }
276 508
277 509 /**
278 - * Get authorization header/param value.
510 + * Common initializations, by request lane.
279 511 *
280 - * Checks multiple sources for the authorization token:
281 - * 1. HTTP_AUTHORIZATION server variable (standard)
282 - * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
283 - * 3. authorization query parameter (for servers that strip auth headers)
512 + * Every request gets the always group; everything else is constructed only
513 + * on the lanes that use it, and the order group additionally on the first
514 + * order write of ANY request, so the lane classifier is an optimisation
515 + * rather than a correctness gate. {@see Service_Groups} holds the membership
516 + * of each group and the reasons behind it.
284 517 *
285 - * @return false|string The authorization value or false if not found.
518 + * Measured 2026-09-03: a storefront page loaded ~80 plugin files and 22
519 + * objects for hooks that never fire there (see
520 + * .claude/research/2026-09-03-lazy-service-construction-spec.md).
286 521 */
287 - private function get_auth_header_early() {
288 - // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
289 - if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
290 - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
291 - }
522 + private function init_common(): void {
523 + Service_Groups::ensure( Service_Groups::ALWAYS );
292 524
293 - // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
294 - if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
295 - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
525 + if ( Services\Request_Lane::is_storefront() ) {
526 + // Order-event services arrive on the first order write, if any.
527 + Service_Groups::arm_order_group();
528 + return;
296 529 }
297 530
298 - // Check authorization query param.
299 - if ( ! empty( $_GET['authorization'] ) ) {
300 - return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
301 - }
531 + Service_Groups::ensure( Service_Groups::ORDER );
532 + Service_Groups::ensure( Service_Groups::POS );
533 + }
302 534
303 - return false;
535 + /**
536 + * Construct the order-event services exactly once per request.
537 + *
538 + * Idempotent and safe to call after `init`; each service handles its own
539 + * late registration. Fires `woocommerce_pos_order_services_ready` once so
540 + * Pro and extensions can construct their own order-event services at the
541 + * same moment on every lane. Implementation now lives in Service_Groups.
542 + */
543 + public static function ensure_order_services(): void {
544 + Service_Groups::ensure( Service_Groups::ORDER );
304 545 }
305 546
306 547 /**
307 - * Common initializations.
548 + * Forget which groups were constructed. Tests only.
549 + *
550 + * @internal
308 551 */
309 - private function init_common(): void {
310 - // init the Services.
311 - SettingsService::instance();
312 - AuthService::instance();
313 - Extensions::instance();
314 - Receipt_Snapshot_Store::instance();
315 -
316 - // init other functionality needed by both frontend and admin.
317 - new i18n();
318 - new Gateways();
319 - new Products();
320 - new Orders();
321 - new Emails();
322 - new Templates();
323 - new Services\Print_Job_Service();
324 - new Services\Cloud_Print_Trigger_Service();
325 - new Services\Cloud_Print_Submit_Service();
326 - new Services\Cloud_Print_Relay_Service();
552 + public static function reset_request_state(): void {
553 + Service_Groups::reset();
554 + Services\Request_Lane::reset();
327 555 }
328 556
329 557 /**
330 558 * Frontend specific initializations.
@@ -329,12 +557,19 @@
329 557 /**
330 558 * Frontend specific initializations.
331 559 */
332 560 private function init_frontend(): void {
333 - if ( ! is_admin() ) {
561 + if ( is_admin() ) {
562 + return;
563 + }
564 + // The public receipt shortcode and the My Account receipt action are
565 + // storefront features; they construct the template services when used.
566 + new Storefront_Receipts();
567 + if ( ! Services\Request_Lane::is_storefront() ) {
568 + // The POS routes (rewrite rules, checkout context, order-pay and
569 + // coupon forms) only matter on requests the classifier saw as POS.
334 570 new Template_Router();
335 571 new Form_Handler();
336 - new Storefront_Receipts();
337 572 }
338 573 }
339 574
340 575 /**
@@ -345,9 +580,9 @@
345 580 // Register AJAX handler before the branch so it's available during AJAX requests.
346 581 add_action( 'wp_ajax_wcpos_track_upgrade_click_ajax', array( Menu::class, 'handle_upgrade_click_ajax' ) );
347 582 add_action( 'admin_post_wcpos_track_upgrade_click', array( Menu::class, 'handle_upgrade_click_redirect' ) );
348 583
349 - if ( \defined( 'DOING_AJAX' ) && DOING_AJAX ) {
584 + if ( wp_doing_ajax() ) {
350 585 new AJAX();
351 586 } else {
352 587 new Admin();
353 588 }
@@ -369,7 +604,17 @@
369 604 }
370 605
371 606 // wePOS alters the WooCommerce REST API, breaking the expected schema
372 607 // It's very bad form on their part, but we need to work around it.
373 - new Integrations\WePOS();
608 + // Its only hook is admin_init (a conflict notice), so admin lane only.
609 + if ( is_admin() ) {
610 + new Integrations\WePOS();
611 + }
612 +
613 + // WooCommerce Tax - https://wordpress.org/plugins/woocommerce-services/
614 + // Its class exists whenever the plugin is active, but its callbacks are
615 + // only hooked when automated taxes are on and the store country is
616 + // supported, so the integration looks them up on the hooks at
617 + // recalculation time instead of gating on the class here.
618 + new Integrations\WooCommerce_Tax();
374 619 }
375 620 }