PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.1
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.1
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 1.9.14 All 163 releases
woocommerce-pos / includes / Sync / Pos_Uuid.php

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

657 lines 24.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync read surface.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use Exception;
11 use Ramsey\Uuid\Uuid;
12 use WC_Customer;
13 use WC_Order_Item;
14 use WCPOS\WooCommercePOS\Logger;
15
16 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
17 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries are prepared before execution.
18
19 /**
20 * Uniform record identity on the server side — the sole authority that stamps,
21 * validates, deduplicates, and checks ownership of `_woocommerce_pos_uuid`.
22 * Legacy API callers delegate here (ADR 0021, decision c).
23 *
24 * The client uses this uuid as the stable RxDB primary key (ADR 0008, guardrail
25 * G1): a record carries the SAME identity on the server and the client, may be
26 * born on either side, and is NEVER re-keyed. This is the server half of that
27 * contract — a record pulled from the lab namespace arrives WITH its uuid, so the
28 * client never has to mint a divergent one for a server-born record. Multisite
29 * customer first-stamps are serialized while adopting legacy per-blog values.
30 *
31 * Reads an existing valid uuid from the record's meta; if absent/invalid it
32 * generates one and PERSISTS it (so it is stable across pulls), then mirrors it
33 * into the serialized payload's `meta_data`. Duck-typed on the WC_Data methods so
34 * it stays unit-testable without WooCommerce loaded. UUID convergence does not
35 * use an object-cache lock; sync writes are serialized by their record lock,
36 * while stamping deterministically converges duplicate meta rows.
37 */
38 class Pos_Uuid {
39 public const META_KEY = Api::UUID_META_KEY;
40 /** Meta key carrying the freshly-recomputed variable-product price range (P2-2). */
41
42 /**
43 * A standard 8-4-4-4-12 uuid shape (any version), case-insensitive.
44 */
45 public static function is_uuid( $value ): bool {
46 return \is_string( $value )
47 && (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value );
48 }
49
50 /**
51 * The first VALID uuid among a record's meta entries (WC_Meta_Data objects
52 * with ->key/->value, or arrays), or '' if none. Skips blank / invalid / a
53 * blank duplicate in favour of a later valid one.
54 */
55 public static function read_valid_uuid_from_meta( array $meta_data ): string {
56 foreach ( $meta_data as $meta ) {
57 $key = Meta_Entry::key( $meta );
58 if ( self::META_KEY !== $key ) {
59 continue;
60 }
61 $value = Meta_Entry::value( $meta );
62 if ( self::is_uuid( $value ) ) {
63 return $value;
64 }
65 }
66
67 return '';
68 }
69
70 /**
71 * Ensure the record carries a stable, UNIQUELY-OWNED uuid: reuse a valid
72 * existing one, else generate + persist a new one. Returns the uuid (or '' if
73 * the object can't carry meta). Duck-typed on get_meta_data / update_meta_data
74 * / save_meta_data.
75 *
76 * $opts['collides'] is an optional callable (uuid, object) => bool: when it
77 * reports the existing uuid is already owned by ANOTHER record (a clone/import
78 * that copied the meta), we treat it as needing a fresh one rather than serving
79 * a duplicate RxDB key. Injected so the branching stays unit-testable; the live
80 * wiring uses the $wpdb-backed self::uuid_owned_by_other.
81 *
82 * @param mixed $object
83 */
84 public static function ensure_uuid( $object, array $opts = array() ): string {
85 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_meta_data' ) ) {
86 return '';
87 }
88 if (
89 $object instanceof WC_Customer
90 && \function_exists( 'is_multisite' )
91 && is_multisite()
92 && '' === self::read_valid_uuid_from_meta( (array) $object->get_meta_data() )
93 ) {
94 return self::ensure_multisite_customer_uuid( $object, $opts );
95 }
96
97 return self::ensure_uuid_without_user_lock( $object, $opts );
98 }
99
100 /**
101 * Ensure a UUID after any customer-specific coordination has completed.
102 *
103 * @param mixed $object
104 */
105 private static function ensure_uuid_without_user_lock( $object, array $opts ): string {
106 $collides = $opts['collides'] ?? null;
107 $persist = $opts['persist'] ?? true;
108 $existing = self::read_valid_uuid_from_meta( (array) $object->get_meta_data() );
109 if ( '' !== $existing && ! ( \is_callable( $collides ) && $collides( $existing, $object ) ) ) {
110 // Converge any duplicate uuid metas (e.g. a concurrent first-stamp) to
111 // the single canonical value — deterministic regardless of object-cache
112 // backend, so no cross-request lock is required for correctness.
113 self::prune_duplicate_uuid_meta( $object, $persist );
114
115 return $existing;
116 }
117 if ( ! method_exists( $object, 'update_meta_data' ) ) {
118 return '';
119 }
120 // Minting persists by default: a freshly-generated uuid that isn't written
121 // back would differ on the next pull, making identity unstable — worse than
122 // none. persist:false is for a BEFORE-save hook, where the in-progress save
123 // writes the meta, so we add it but skip a redundant second save.
124 if ( $persist && ! method_exists( $object, 'save_meta_data' ) ) {
125 return '';
126 }
127 $uuid = self::generate_uuid();
128 $object->update_meta_data( self::META_KEY, $uuid );
129 if ( $persist ) {
130 call_user_func( array( $object, 'save_meta_data' ) );
131 // A concurrent first-stamp may have persisted its own uuid between our
132 // read and save. Re-read and converge on the first-valid row so every
133 // racer returns the SAME winner instead of each serving its own mint.
134 if ( $object instanceof \WC_Data ) {
135 $object->read_meta_data( true );
136 self::prune_duplicate_uuid_meta( $object, true );
137 $stored = self::read_valid_uuid_from_meta( (array) $object->get_meta_data() );
138 if ( self::is_uuid( $stored ) ) {
139 return $stored;
140 }
141 }
142 }
143
144 return $uuid;
145 }
146
147 /**
148 * Legacy WP_User adapter for the shared WC_Data identity path (ADR 0021).
149 *
150 * @param mixed $user WP_User-like object or numeric user id.
151 */
152 public static function ensure_user_uuid( $user ): string {
153 $user_id = \is_object( $user ) && isset( $user->ID ) ? (int) $user->ID : (int) $user;
154 if ( $user_id <= 0 || ! class_exists( WC_Customer::class ) ) {
155 return '';
156 }
157
158 try {
159 $customer = new WC_Customer( $user_id );
160 } catch ( Exception $e ) {
161 Logger::log( 'Unable to load customer for UUID stamping: ' . $e->getMessage() );
162 return '';
163 }
164
165 if ( ! method_exists( $customer, 'get_id' ) || $user_id !== (int) $customer->get_id() ) {
166 return '';
167 }
168
169 return self::ensure_uuid(
170 $customer,
171 array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_user' ) )
172 );
173 }
174
175 /**
176 * Ensure the WC_Order_Item has a valid UUID.
177 *
178 * @param WC_Order_Item $item The order item object.
179 * @return void
180 */
181 public static function ensure_order_item_uuid( WC_Order_Item $item ): void {
182 global $wpdb;
183
184 if ( self::is_uuid( $item->get_meta( self::META_KEY ) ) ) {
185 return;
186 }
187
188 $lock_key = 'wc_pos_uuid_order_item_' . $item->get_id();
189 $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_key, 10 ) );
190 if ( '1' !== (string) $acquired ) {
191 Logger::log( 'Unable to acquire lock for order item UUID update for order item id ' . $item->get_id() );
192 return;
193 }
194 try {
195 // Persist any pending meta, then check the STORED uuid directly —
196 // a full read_meta_data(true) reload would clobber sibling in-memory
197 // meta on lanes where the datastore cache lags (HPOS misc `_sku`).
198 $item->save_meta_data();
199 $uuid = wc_get_order_item_meta( $item->get_id(), self::META_KEY, true );
200 if ( ! self::is_uuid( $uuid ) ) {
201 $uuid = Uuid::uuid4()->toString();
202 $item->update_meta_data( self::META_KEY, $uuid );
203 $item->save_meta_data();
204 } elseif ( $uuid !== $item->get_meta( self::META_KEY ) ) {
205 // A concurrent request minted first; converge the stale in-memory
206 // item on the stored winner so the served payload carries it.
207 $item->update_meta_data( self::META_KEY, $uuid );
208 }
209 } finally {
210 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_key ) );
211 }
212 }
213
214 /**
215 * Promote a legacy per-blog cashier uuid to the network-wide key.
216 *
217 * Before identity consolidated here, the cashier endpoint minted
218 * `_woocommerce_pos_uuid_{blog_id}` on multisite while every other reader used
219 * the plain network-wide key — forking one user into two RxDB identities. When
220 * the plain key holds no valid uuid yet, adopt the current blog's legacy value
221 * so existing multisite cashiers keep their identity regardless of which
222 * endpoint reads them first. An existing valid plain uuid wins, because it is
223 * what /customers has already served to clients. Legacy rows are left in place
224 * (harmless, rollback-safe). A legacy value owned by ANOTHER user — under the
225 * network key or the current blog's legacy key — is never adopted: the first
226 * reader must not claim a shared legacy uuid as their network identity
227 * (#1465). When two users share the same legacy value, neither adopts it and
228 * both are minted fresh; the 1.10.0 migration resolves the ambiguity for
229 * users it reaches first.
230 *
231 * @param WC_Customer $customer Customer being stamped.
232 */
233 private static function adopt_legacy_multisite_user_uuid( WC_Customer $customer ): void {
234 $existing = self::read_valid_uuid_from_meta( (array) $customer->get_meta_data() );
235 if ( self::is_uuid( $existing ) ) {
236 return;
237 }
238
239 $user_id = (int) $customer->get_id();
240 $legacy = get_user_meta( $user_id, self::META_KEY . '_' . get_current_blog_id(), true );
241 if ( self::is_uuid( $legacy ) && ! self::legacy_uuid_owned_by_other_user( $legacy, $customer ) ) {
242 // Never clobber a uuid another request persisted concurrently — its
243 // client is already keyed on it. A unique add when no row exists; a
244 // compare-and-swap against the OBSERVED invalid value otherwise, so a
245 // lock-timeout fallback that replaced the corrupt row between our
246 // read and this write survives (the CAS no-ops and ensure_uuid then
247 // serves the concurrent winner).
248 $stored_rows = get_user_meta( $user_id, self::META_KEY, false );
249 if ( array() === $stored_rows ) {
250 add_user_meta( $user_id, self::META_KEY, $legacy, true );
251 } elseif ( ! self::is_uuid( $stored_rows[0] ) ) {
252 update_user_meta( $user_id, self::META_KEY, $legacy, $stored_rows[0] );
253 }
254 }
255 }
256
257 /**
258 * Serialize first-stamp and legacy adoption for one multisite customer.
259 */
260 private static function ensure_multisite_customer_uuid( WC_Customer $customer, array $opts ): string {
261 global $wpdb;
262
263 $user_id = (int) $customer->get_id();
264 $lock_name = 'wcpos_user_uuid_' . $user_id;
265 $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, 5 ) );
266 if ( '1' !== (string) $acquired ) {
267 // The lock holder is (or was) stamping this user. This uuid is the
268 // client's RxDB primary key, so never serve '' — and when a legacy
269 // identity may be mid-adoption, serve it read-only rather than writing
270 // anything that would pre-empt the adoption and fork the user.
271 $customer->read_meta_data( true );
272 $persisted = self::read_valid_uuid_from_meta( (array) $customer->get_meta_data() );
273 if ( self::is_uuid( $persisted ) ) {
274 return $persisted;
275 }
276
277 // An adoptable legacy uuid is what the holder will promote — serve the
278 // same value read-only so this response and the adoption agree.
279 $legacy = get_user_meta( $user_id, self::META_KEY . '_' . get_current_blog_id(), true );
280 if ( self::is_uuid( $legacy ) && ! self::legacy_uuid_owned_by_other_user( $legacy, $customer ) ) {
281 return $legacy;
282 }
283
284 // First stamp under contention: persist a fallback without clobbering a
285 // concurrent winner — a unique add when no row exists, a compare-and-swap
286 // against the (invalid) first row otherwise — then serve whichever row
287 // stuck so every racer converges on one persisted identity.
288 $fallback = self::generate_uuid();
289 $stored_rows = get_user_meta( $user_id, self::META_KEY, false );
290 if ( array() === $stored_rows ) {
291 add_user_meta( $user_id, self::META_KEY, $fallback, true );
292 } elseif ( ! self::is_uuid( $stored_rows[0] ) ) {
293 update_user_meta( $user_id, self::META_KEY, $fallback, $stored_rows[0] );
294 }
295 $stored = get_user_meta( $user_id, self::META_KEY, true );
296
297 return self::is_uuid( $stored ) ? $stored : $fallback;
298 }
299
300 try {
301 $customer->read_meta_data( true );
302 self::adopt_legacy_multisite_user_uuid( $customer );
303 $customer->read_meta_data( true );
304
305 return self::ensure_uuid_without_user_lock( $customer, $opts );
306 } finally {
307 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
308 }
309 }
310
311 /**
312 * Legacy WP_Term adapter for the shared identity path (ADR 0021).
313 *
314 * @param mixed $term WP_Term-like object or numeric term id.
315 */
316 public static function ensure_term_uuid( $term ): string {
317 $term_id = \is_object( $term ) && isset( $term->term_id ) ? (int) $term->term_id : (int) $term;
318 if ( $term_id <= 0 ) {
319 return '';
320 }
321
322 $adapter = new Term_Meta_Adapter( $term_id );
323
324 return self::ensure_uuid(
325 $adapter,
326 array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_term' ) )
327 );
328 }
329
330 /**
331 * Return a copy of the SERIALIZED payload whose `meta_data` mirrors `$uuid`
332 * exactly once (entries here are arrays: ['id'=>,'key'=>,'value'=>]). Drops
333 * blank / duplicate / mismatched `_woocommerce_pos_uuid` entries so the served
334 * record always carries one canonical identity.
335 */
336 public static function ensure_in_payload( array $payload, string $uuid ): array {
337 $meta = ( isset( $payload['meta_data'] ) && \is_array( $payload['meta_data'] ) ) ? $payload['meta_data'] : array();
338 $others = array();
339 foreach ( $meta as $entry ) {
340 $key = Meta_Entry::key( $entry );
341 if ( self::META_KEY !== $key ) {
342 $others[] = $entry;
343 }
344 }
345 $others[] = array(
346 'key' => self::META_KEY,
347 'value' => $uuid,
348 );
349 $payload['meta_data'] = array_values( $others );
350
351 return $payload;
352 }
353
354 /**
355 * Hook for the per-collection `woocommerce_pos_sync_serialized_*` filters (product,
356 * order, …): stamp the served record's stable uuid (persisting a new one if
357 * needed) and mirror it into the payload, so EVERY read carries the identity the
358 * client keys on — regardless of how the record was born. A non-array payload or
359 * an object that can't carry meta passes through unchanged.
360 *
361 * Collection-agnostic: `ensure_uuid` only needs the WC_Data meta API
362 * (get/update/save_meta_data), which orders (HPOS-safe), customers, and terms all
363 * provide. Collision detection IS storage-specific: orders live in HPOS tables
364 * (not `wp_postmeta`), so they get the order-aware detector; products/variations
365 * keep the post-scoped one. Customers and terms use their storage-specific
366 * adapters and detectors.
367 *
368 * @param mixed $payload
369 * @param mixed $object
370 * @param null|mixed $request
371 */
372 public static function stamp_serialized_record( $payload, $object, $request = null ) {
373 if ( ! \is_array( $payload ) ) {
374 return $payload;
375 }
376 $collides = is_a( $object, 'WC_Abstract_Order' )
377 ? array( __CLASS__, 'uuid_owned_by_other_order' )
378 : array( __CLASS__, 'uuid_owned_by_other' );
379 $uuid = self::ensure_uuid( $object, array( 'collides' => $collides ) );
380
381 return '' === $uuid ? $payload : self::ensure_in_payload( $payload, $uuid );
382 }
383
384 /**
385 * Order-aware variant of {@see uuid_owned_by_other}. HPOS order meta does NOT live
386 * in `wp_postmeta`, so the post-scoped detector can't see an order that already owns
387 * `$uuid` — a duplicated/imported order with a copied uuid would slip through and two
388 * orders would share one RxDB key. Query the orders store (HPOS-safe via
389 * `wc_get_orders`) for the uuid; a match on a DIFFERENT order id is a real collision.
390 *
391 * @param mixed $uuid
392 * @param mixed $object
393 */
394 public static function uuid_owned_by_other_order( $uuid, $object ): bool {
395 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) || ! \function_exists( 'wc_get_orders' ) ) {
396 return false;
397 }
398 $order_id = (int) $object->get_id();
399 foreach ( self::get_order_ids_by_uuid( (string) $uuid ) as $other_id ) {
400 if ( (int) $other_id !== $order_id ) {
401 return true;
402 }
403 }
404
405 return false;
406 }
407
408 /**
409 * Return at most two order ids carrying a UUID. The legacy create controller
410 * treats two results as an ambiguous identity and fails closed.
411 *
412 * Datastore-aware direct meta lookup: under HPOS the uuid lives in
413 * `wc_orders_meta`, otherwise in `wp_postmeta`. `wc_get_orders()` with a
414 * `meta_query` is NOT supported on the CPT order datastore (it fires a
415 * `doing_it_wrong` and returns unfiltered results), so we query the meta table
416 * directly — the same shape the plugin's other order-uuid lookups use.
417 */
418 public static function get_order_ids_by_uuid( string $uuid ): array {
419 global $wpdb;
420 if ( ! isset( $wpdb ) ) {
421 return array();
422 }
423
424 $order_util = '\\Automattic\\WooCommerce\\Utilities\\OrderUtil';
425 $hpos = class_exists( $order_util )
426 && method_exists( $order_util, 'custom_orders_table_usage_is_enabled' )
427 && call_user_func( array( $order_util, 'custom_orders_table_usage_is_enabled' ) );
428
429 if ( $hpos ) {
430 $ids = $wpdb->get_col(
431 $wpdb->prepare(
432 "SELECT DISTINCT m.order_id FROM {$wpdb->prefix}wc_orders_meta m"
433 . " JOIN {$wpdb->prefix}wc_orders o ON o.id = m.order_id AND o.type = 'shop_order'"
434 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
435 . " AND o.status NOT IN ('trash','auto-draft')"
436 . ' ORDER BY m.order_id ASC LIMIT 2',
437 self::META_KEY,
438 $uuid
439 )
440 );
441 } else {
442 $ids = $wpdb->get_col(
443 $wpdb->prepare(
444 "SELECT DISTINCT m.post_id FROM {$wpdb->postmeta} m"
445 . " JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_type = 'shop_order'"
446 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
447 . " AND p.post_status NOT IN ('trash','auto-draft')"
448 . ' ORDER BY m.post_id ASC LIMIT 2',
449 self::META_KEY,
450 $uuid
451 )
452 );
453 }
454
455 return \is_array( $ids ) ? array_values( $ids ) : array();
456 }
457
458 /**
459 * Register WRITE-time stamping: a record gets its uuid the moment it is saved,
460 * so every read path (catalog proxy, change-signal hydration) then serves it
461 * straight from postmeta with no per-path stamping copy. Hooked BEFORE the data
462 * store writes, so the uuid lands in the SAME save — no second write, no
463 * change-log cascade, and no concurrent-first-READ race (stamping is per-save,
464 * not per-reader). The read-time filter remains as a fallback for records that
465 * predate these hooks until the backfill runs.
466 */
467 public static function register_hooks(): void {
468 if ( ! \function_exists( 'add_action' ) ) {
469 return;
470 }
471 add_action( 'woocommerce_before_product_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 );
472 add_action( 'woocommerce_before_product_variation_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 );
473 }
474
475 /**
476 * Before-save hook: ensure the WC object carries a unique uuid as part of the
477 * in-progress save (persist:false — the save itself writes it).
478 *
479 * @param mixed $object
480 */
481 public static function stamp_on_save( $object ): void {
482 self::ensure_uuid(
483 $object,
484 array(
485 'collides' => array( __CLASS__, 'uuid_owned_by_other' ),
486 'persist' => false,
487 )
488 );
489 }
490
491 /**
492 * True when $uuid is already stored as `_woocommerce_pos_uuid` on a DIFFERENT
493 * post (a cloned/imported record that copied the meta). Post-scoped (products
494 * + variations); terms/customers live in their own meta tables and get their
495 * own detector when those seams land. Returns false when $wpdb or the object's
496 * id is unavailable (e.g. unit tests inject a fake detector instead).
497 *
498 * @param mixed $uuid
499 * @param mixed $object
500 */
501 public static function uuid_owned_by_other( $uuid, $object ): bool {
502 global $wpdb;
503 if ( ! isset( $wpdb ) || ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) ) {
504 return false;
505 }
506 // Only an ACTIVE post counts as a live owner — a trashed/auto-draft record
507 // sharing the uuid is not a real collision (it will never be served), so it
508 // must not force a needless regeneration on the active record.
509 $sql = $wpdb->prepare(
510 "SELECT COUNT(*) FROM {$wpdb->postmeta} m
511 JOIN {$wpdb->posts} p ON p.ID = m.post_id
512 WHERE m.meta_key = %s AND m.meta_value = %s AND m.post_id <> %d
513 AND p.post_status NOT IN ('trash','auto-draft')",
514 self::META_KEY,
515 $uuid,
516 (int) $object->get_id()
517 );
518
519 return (int) $wpdb->get_var( $sql ) > 0;
520 }
521
522 /**
523 * User-table twin of uuid_owned_by_other for CUSTOMERS (WC_Customer over
524 * wp_usermeta). DELIBERATE asymmetry: WP users have no post_status / trash, so
525 * every user row carrying the uuid is a live owner — there is NO status
526 * exclusion (a status filter here would reference a non-existent column).
527 *
528 * @param mixed $uuid
529 * @param mixed $object
530 */
531 public static function uuid_owned_by_other_user( $uuid, $object ): bool {
532 global $wpdb;
533 if ( ! isset( $wpdb ) || ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) ) {
534 return false;
535 }
536 $sql = $wpdb->prepare(
537 "SELECT COUNT(*) FROM {$wpdb->usermeta} m
538 JOIN {$wpdb->users} u ON u.ID = m.user_id
539 WHERE m.meta_key = %s AND m.meta_value = %s AND m.user_id <> %d",
540 self::META_KEY,
541 $uuid,
542 (int) $object->get_id()
543 );
544
545 return (int) $wpdb->get_var( $sql ) > 0;
546 }
547
548 /**
549 * Adoption-gate twin of uuid_owned_by_other_user(): also checks the CURRENT
550 * blog's legacy per-blog key (`_woocommerce_pos_uuid_{blog}`), because a
551 * value another user still holds under that legacy key is the same RxDB
552 * primary key, and adopting it would fork that user's identity (#1465).
553 *
554 * Deliberately NOT used for the live-identity collision check: a stale
555 * duplicated legacy row must never discard an already-served network uuid,
556 * so `collides` stays scoped to the network key.
557 *
558 * @param mixed $uuid Candidate legacy uuid.
559 * @param WC_Customer $customer Customer being stamped.
560 */
561 private static function legacy_uuid_owned_by_other_user( $uuid, WC_Customer $customer ): bool {
562 global $wpdb;
563 if ( self::uuid_owned_by_other_user( $uuid, $customer ) ) {
564 return true;
565 }
566 if ( ! isset( $wpdb ) ) {
567 return false;
568 }
569 $sql = $wpdb->prepare(
570 "SELECT COUNT(*) FROM {$wpdb->usermeta} m
571 JOIN {$wpdb->users} u ON u.ID = m.user_id
572 WHERE m.meta_key = %s AND m.meta_value = %s AND m.user_id <> %d",
573 self::META_KEY . '_' . get_current_blog_id(),
574 $uuid,
575 (int) $customer->get_id()
576 );
577
578 return (int) $wpdb->get_var( $sql ) > 0;
579 }
580
581 /**
582 * Term-table twin for CATEGORIES + BRANDS. CROSS-TAXONOMY by design: product_cat
583 * and product_brand SHARE wp_termmeta, so a uuid on a different term in EITHER
584 * taxonomy is a real RxDB-primary-key clash — match on term_id across ALL
585 * taxonomies (NO taxonomy scoping). Terms have no trash/status, so no status
586 * exclusion (like users, unlike posts).
587 *
588 * @param mixed $uuid
589 * @param mixed $object
590 */
591 public static function uuid_owned_by_other_term( $uuid, $object ): bool {
592 global $wpdb;
593 if ( ! isset( $wpdb ) || ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) ) {
594 return false;
595 }
596 $sql = $wpdb->prepare(
597 "SELECT COUNT(*) FROM {$wpdb->termmeta} WHERE meta_key = %s AND meta_value = %s AND term_id <> %d",
598 self::META_KEY,
599 $uuid,
600 (int) $object->get_id()
601 );
602
603 return (int) $wpdb->get_var( $sql ) > 0;
604 }
605
606 /**
607 * A v4 uuid — `wp_generate_uuid4()` under WordPress, else a local fallback.
608 */
609 public static function generate_uuid(): string {
610 if ( \function_exists( 'wp_generate_uuid4' ) ) {
611 return wp_generate_uuid4();
612 }
613 $data = random_bytes( 16 );
614 $data[6] = \chr( ( \ord( $data[6] ) & 0x0f ) | 0x40 ); // version 4
615 $data[8] = \chr( ( \ord( $data[8] ) & 0x3f ) | 0x80 ); // variant 10xx
616
617 return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
618 }
619
620 /**
621 * Collapse duplicate `_woocommerce_pos_uuid` metas to ONE canonical entry —
622 * keep the first valid uuid, delete every other uuid meta (a concurrent-stamp
623 * duplicate, or a blank/invalid straggler). Only persisted metas (with a meta
624 * id) are deleted; a freshly-added unsaved one is left for the in-progress save.
625 * When $persist, the cleanup is saved immediately; otherwise the ongoing save
626 * (before-save hook) applies the deletions. Mirrors production's dedup, and is
627 * what makes the concurrent-first-stamp outcome correct without a lock.
628 *
629 * @param mixed $object
630 */
631 private static function prune_duplicate_uuid_meta( $object, bool $persist ): void {
632 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_meta_data' ) || ! method_exists( $object, 'delete_meta_data_by_mid' ) ) {
633 return;
634 }
635 $kept_valid = false;
636 $deleted = false;
637 foreach ( (array) $object->get_meta_data() as $meta ) {
638 if ( ! \is_object( $meta ) || self::META_KEY !== Meta_Entry::key( $meta ) ) {
639 continue;
640 }
641 if ( ! $kept_valid && self::is_uuid( Meta_Entry::value( $meta ) ) ) {
642 $kept_valid = true; // keep the first valid uuid meta
643
644 continue;
645 }
646 $mid = $meta->id ?? null;
647 if ( null !== $mid ) {
648 $object->delete_meta_data_by_mid( $mid );
649 $deleted = true;
650 }
651 }
652 if ( $deleted && $persist && method_exists( $object, 'save_meta_data' ) ) {
653 $object->save_meta_data();
654 }
655 }
656 }
657