PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Sync / Store_Scope.php

Store_Scope.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Sync/Store_Scope.php

251 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync store scope.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WP_REST_Request;
11
12 /**
13 * The till's STORE SCOPE, carried across the v2 sync lane.
14 *
15 * ## Why this exists
16 *
17 * On the v1 lane the client's legacy REST http client attached `store_id` as a
18 * query param to every request, and the extending controller read it back with
19 * `$request->get_param( 'store_id' )`. The v2 sync lanes do not use that http
20 * client — they talk to `wcpos/v2` through the engine's own fetcher — so the
21 * whole successor surface carried NO store context at all. Anything keyed on
22 * the scoped store (Pro's per-store product pricing and taxes) therefore went
23 * dark on v2: a till price edit was forwarded to stock `wc/v3` as a plain
24 * global price write (pro#425).
25 *
26 * ## The contract
27 *
28 * The client sends the scoped store as the `X-WCPOS-Store` request header —
29 * a header rather than a query param so nothing has to rewrite sync URLs, and
30 * so the scope rides pulls, pushes and acks alike.
31 *
32 * This class translates that header back into V1'S CONTRACT SHAPE. Every inner
33 * request the v2 lane builds — the `wc/v3` write forward, the catalog proxy's
34 * read forward, each product/variation serialization — is stamped with a
35 * `store_id` param, so a consumer written against v1 (`$request->get_param(
36 * 'store_id' )`) keeps working unchanged on v2. {@see self::stamp()}.
37 *
38 * The ambient {@see self::current()} covers the consumers that have no request
39 * to read: WooCommerce's variation price filters
40 * (`woocommerce_get_variation_regular_price` and friends) are handed a product
41 * and a min/max flag and nothing else.
42 *
43 * ## What this class deliberately does NOT do
44 *
45 * It does not authorize the store. Stores are a Pro concept; the free plugin
46 * has no store registry and no notion of which stores a cashier may act as.
47 * This is the same split as `woocommerce_pos_order_store_reassignment_allowed`
48 * (free#1550 / pro#426): free carries and shapes the scalar, Pro rules on it.
49 *
50 * It also never invents a scope. A missing, blank, zero or non-numeric header
51 * resolves to `null`, and a null scope stamps NOTHING — the inner request
52 * carries no `store_id` at all, exactly as a v1 request from an unscoped
53 * client would. Downstream that reads as "the store is UNKNOWN", which is a
54 * materially different state from "the store is global" and must stay
55 * distinguishable: a consumer that owns store-scoped data is expected to
56 * refuse an ambiguous write rather than fall back to the global fields.
57 * Store `0` is the client's single-store sentinel (the same one the order lane
58 * tests before stamping `_pos_store`) and is normalized away here.
59 */
60 final class Store_Scope {
61 /** The request header carrying the till's store scope. */
62 public const HEADER = 'X-WCPOS-Store';
63
64 /** The request param the scope is republished as — v1's contract shape. */
65 public const PARAM = 'store_id';
66
67 /**
68 * The scope of the request currently being served, or null when unscoped.
69 *
70 * @var null|int
71 */
72 private static $current = null;
73
74 /**
75 * Nesting depth of in-flight v2 lane operations.
76 *
77 * @var int
78 */
79 private static $lane_depth = 0;
80
81 /**
82 * Resolve the store scope of an incoming WCPOS request.
83 *
84 * A usable header always wins. When NO header arrived at all, the
85 * `store_id` param is honoured on both lanes — v1's native wire form, and
86 * the v2 fallback for hosts that strip the header (hostile-headers B6,
87 * wcpos-infra#72). A header that was sent but is unusable still resolves
88 * to UNKNOWN on v2 — see the ruling in the resolve() body.
89 *
90 * @param WP_REST_Request $request The incoming request.
91 *
92 * @return null|int A positive store id, or null when the scope is unknown.
93 */
94 public static function resolve( WP_REST_Request $request ): ?int {
95 $header = $request->get_header( self::HEADER );
96 $scope = self::normalize( $header );
97
98 if ( null !== $scope ) {
99 return $scope;
100 }
101
102 // Absent and malformed are different signals (#1558 review ruling):
103 // only a header that never arrived invites another wire form. A header
104 // that WAS sent but is unusable stamps NOTHING — the caller believes it
105 // named a store, and substituting a scope from elsewhere would land the
106 // write somewhere it did not name. Hostile hosts STRIP the header
107 // (absence), so the hostile case is exactly the one the param covers
108 // (hostile-headers B6, wcpos-infra#72). The v1 lane keeps its legacy
109 // behavior: the param IS its native wire form.
110 $is_v1 = 0 === strpos( $request->get_route(), '/wcpos/v1/' );
111 if ( ! $is_v1 && null !== $header ) {
112 return null;
113 }
114
115 return self::normalize( $request->get_param( self::PARAM ) );
116 }
117
118 /**
119 * Record the scope of the request being served.
120 *
121 * @param null|int $store_id A positive store id, or null when unscoped.
122 */
123 public static function set_current( ?int $store_id ): void {
124 self::$current = ( null !== $store_id && $store_id > 0 ) ? $store_id : null;
125 }
126
127 /**
128 * The scope of the request being served, or null when unknown.
129 *
130 * Null means UNKNOWN, never "global" — see the class docblock.
131 *
132 * @return null|int
133 */
134 public static function current(): ?int {
135 return self::$current;
136 }
137
138 /**
139 * Run a v2 lane operation with the lane marker raised.
140 *
141 * Wrap every inner request the v2 lane dispatches, and every product it
142 * serializes, so a consumer can tell OUR traffic from everyone else's.
143 *
144 * The scope alone cannot answer that: {@see self::current()} is null both
145 * for an unscoped v2 push and for a stock `wc/v3` request from wp-admin or
146 * a third-party integration. A consumer keying only on the scope cannot
147 * distinguish a till that failed to name its store from a caller that was
148 * never a till — and treating the second like the first breaks
149 * WooCommerce's own API for any store-priced product (pro#425 review).
150 *
151 * Scoped around the operation rather than latched for the whole request on
152 * purpose. A latch needs a teardown hook, and there isn't a safe one:
153 * `rest_post_dispatch` fires for embedded-link sub-requests too, so it
154 * would clear the marker in the middle of the very request that owns it.
155 * A depth counter unwound in `finally` cannot leak, nests correctly, and
156 * is true exactly while a v2 operation is on the stack.
157 *
158 * @template T
159 *
160 * @param callable():T $operation The lane operation.
161 *
162 * @return T
163 */
164 public static function in_v2_lane( callable $operation ) {
165 ++self::$lane_depth;
166
167 try {
168 return $operation();
169 } finally {
170 if ( self::$lane_depth > 0 ) {
171 --self::$lane_depth;
172 }
173 }
174 }
175
176 /**
177 * Whether a v2 lane operation is currently in flight.
178 */
179 public static function is_v2_lane(): bool {
180 return self::$lane_depth > 0;
181 }
182
183 /**
184 * Forget the current scope (request teardown, and test isolation).
185 */
186 public static function reset(): void {
187 self::$current = null;
188 self::$lane_depth = 0;
189 }
190
191 /**
192 * Republish the ambient scope onto an inner request as `store_id`.
193 *
194 * Called wherever the v2 lane constructs a request it is about to dispatch
195 * or serialize through, so consumers keep reading the scope the v1 way.
196 * A caller that already set an explicit `store_id` wins — stamping is a
197 * default, not an override — and an unknown scope stamps nothing at all.
198 *
199 * STAMP LAST. `set_body_params()` / `set_query_params()` / `set_url_params()`
200 * each REPLACE their whole bag, so a stamp applied before one of them is
201 * silently discarded — and the failure mode is not a crash, it is a price
202 * edit quietly landing on the global fields.
203 *
204 * @param WP_REST_Request $request The inner request to stamp.
205 *
206 * @return WP_REST_Request The same request, for chaining.
207 */
208 public static function stamp( WP_REST_Request $request ): WP_REST_Request {
209 if ( null === self::$current ) {
210 return $request;
211 }
212
213 if ( null !== self::normalize( $request->get_param( self::PARAM ) ) ) {
214 return $request;
215 }
216
217 $request->set_param( self::PARAM, self::$current );
218
219 return $request;
220 }
221
222 /**
223 * Narrow a raw wire value to a usable store id.
224 *
225 * Only a positive integer is a store. Blank, `0` (the client's single-store
226 * sentinel) and anything non-numeric are all UNKNOWN — never coerced to a
227 * store, and never coerced to global.
228 *
229 * @param mixed $value The raw header or param value.
230 *
231 * @return null|int
232 */
233 private static function normalize( $value ): ?int {
234 if ( null === $value || \is_array( $value ) || \is_object( $value ) || \is_bool( $value ) ) {
235 return null;
236 }
237
238 $value = trim( (string) $value );
239
240 // Reject decimals, signs and other numeric-ish forms outright: a store id
241 // is a post id. `ctype_digit` on the trimmed string is the whole rule.
242 if ( '' === $value || ! ctype_digit( $value ) ) {
243 return null;
244 }
245
246 $store_id = (int) $value;
247
248 return $store_id > 0 ? $store_id : null;
249 }
250 }
251