PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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 / API / V2 / Write_Controller.php

Write_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/API/V2/Write_Controller.php

967 lines 41.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync write surface.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use WCPOS\WooCommercePOS\API\V2\Writers\Collection_Writer_Resolver;
11 use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
12 use WCPOS\WooCommercePOS\Sync\Api;
13 use WCPOS\WooCommercePOS\Sync\Collections;
14 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
15 use WCPOS\WooCommercePOS\Sync\Header_Mirror;
16 use WCPOS\WooCommercePOS\Sync\Meta_Normalizer;
17 use WCPOS\WooCommercePOS\Sync\Mutation_Store;
18 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
19 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
20 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
21 use WCPOS\WooCommercePOS\Sync\Revision;
22 use WCPOS\WooCommercePOS\Sync\Store_Scope;
23 use WP_Error;
24 use WP_REST_Controller;
25 use WP_REST_Request;
26 use WP_REST_Response;
27 use WP_REST_Server;
28
29 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
30
31 /**
32 * The generic server write surface (P1-0) — ONE controller for EVERY collection's
33 * writes (guardrail G1), the server half of the client push path. Registered at
34 * `POST /{API_NAMESPACE}/push/{collection}`; it dispatches on the envelope's
35 * `operation` (not the HTTP verb — the client always POSTs the envelope) and
36 * applies each create/update/delete through the collection's Woo write seam:
37 * every collection forwards to its `wc/v3` controller, including the nested,
38 * parent-aware variation routes. The generic identity, lock, CAS and ack pipeline
39 * remains shared across every collection.
40 *
41 * Identity is the client uuid (DECIDED): the server resolves the record by its
42 * `_woocommerce_pos_uuid`, reuses it, and NEVER re-keys. Idempotency + resolution
43 * live in an injected mutation store, so the apply logic is unit-testable with a
44 * fake store + a stubbed `rest_do_request`.
45 */
46 class Write_Controller extends WP_REST_Controller {
47 // Our gate (capability + F13 health); forwarded writes scope the client-tier grant below.
48 use Endpoint_Permissions;
49
50
51 /** @var mixed Duck-typed mutation store; tests inject an in-memory implementation. */
52 private $store;
53
54
55 /**
56 * collection => wc/v3 route + how its uuid→id is resolved. ONE table, not
57 * per-collection controllers. Only collections whose resolver is correct AND
58 * exercised are exposed; the rest stay out until their phase:
59 * - orders: RESOLVED — HPOS keeps orders in WooCommerce's own table, so id_type=>'order'
60 * resolves via wc_get_orders by the uuid meta (not post meta). This superseded (and
61 * replaced) the deleted orders-specific legacy /orders/push.
62 * - tax_rates: RESOLVED — intentionally NOT in the uuid write-path. Tax rates are
63 * pure-server-pull and have no native meta store, so they are the single principled
64 * G1 exception (ADR 0009): they key by their Woo id, not a uuid. Variations carry
65 * their parent on create and derive it from the stored object thereafter.
66 */
67 /**
68 * The write map — a PROJECTION of the registry's write capability (#421
69 * increments 6+7): route from the write group; id_type and the
70 * collection's OWN scalar resolver scope (post_type / taxonomy — what
71 * resolve_id_by_uuid gets; never the backfill scan scope) from the
72 * identity group. Only rows with BOTH write and identity are pushable;
73 * adding one is a registry-row edit. The mutation store's per-kind meta
74 * operations (persist_uuid / resolve_id_by_uuid, incl. the two-step term
75 * taxonomy re-check and the trash-exclusion live-owner rule) are
76 * unchanged — this projection is what FEEDS them.
77 */
78 private static function collections(): array {
79 $map = array();
80 foreach ( Collections::with( 'write' ) as $collection => $row ) {
81 if ( ! isset( $row['identity'] ) ) {
82 continue; // tax_rates-shaped: writeable would need an id-space first
83 }
84 $entry = array(
85 'route' => $row['write']['route'],
86 'id_type' => $row['identity']['id_type'],
87 );
88 if ( isset( $row['identity']['post_type'] ) ) {
89 $entry['post_type'] = $row['identity']['post_type'];
90 }
91 if ( isset( $row['identity']['taxonomy'] ) ) {
92 $entry['taxonomy'] = $row['identity']['taxonomy'];
93 }
94 $map[ $collection ] = $entry;
95 }
96 return $map;
97 }
98
99 public function __construct( $store = null ) {
100 $this->store = $store ? $store : new Mutation_Store();
101 }
102
103 /** Resolve the collection-specific writer for registry metadata. */
104 private function writer( array $meta ) {
105 return ( new Collection_Writer_Resolver( $this->store ) )->resolve( $meta );
106 }
107
108 public function register_routes(): void {
109 register_rest_route(
110 Api::ROUTE_NAMESPACE,
111 '/push/(?P<collection>[a-z0-9_]+)',
112 array(
113 'methods' => WP_REST_Server::CREATABLE,
114 'callback' => array( $this, 'push' ),
115 'permission_callback' => array( $this, 'permissions_check' ),
116 'args' => array( 'collection' => array( 'sanitize_callback' => 'sanitize_key' ) ),
117 )
118 );
119 }
120
121 /** POST /push/{collection} — apply one mutation envelope idempotently. */
122 public function push( WP_REST_Request $request ) {
123 $content_type = strtolower( trim( (string) $request->get_header( 'Content-Type' ) ) );
124 if ( 'application/json' !== trim( explode( ';', $content_type, 2 )[0] ) ) {
125 return new WP_Error( 'woo_rxdb_sync_json_required', 'Content-Type must be application/json.', array( 'status' => 415 ) );
126 }
127 $collection = (string) ( $request->get_url_params()['collection'] ?? $request->get_param( 'collection' ) );
128 $meta = self::collections()[ $collection ] ?? null;
129 if ( null === $meta ) {
130 return new WP_Error( 'woo_rxdb_sync_unknown_collection', 'Unknown collection.', array( 'status' => 400 ) );
131 }
132
133 $m = $this->envelope( $request );
134 $err = $this->validate_envelope( $m, $collection );
135 if ( $err instanceof WP_Error ) {
136 return $err;
137 }
138
139 // Standard-header MIRROR (ADR 0011) — Idempotency-Key (= mutationId) + If-Match (= baseRevision) are an
140 // optional cross-check over the canonical body; 422 on divergence. Same helper as the orders push path.
141 $mirror = Header_Mirror::assert( $request, $m['mutationId'], $m['baseRevision'] );
142 if ( is_wp_error( $mirror ) ) {
143 return $mirror;
144 }
145 $fingerprint = $this->envelope_fingerprint( $m );
146
147 // Idempotent replay: a mutationId already APPLIED returns its canonical result.
148 $settled = $this->replay_or_conflict( $collection, $meta, $m, $fingerprint );
149 if ( null !== $settled ) {
150 return $settled;
151 }
152
153 // Atomically CLAIM the mutationId before the non-idempotent forward, so two
154 // concurrent retries (e.g. a timeout-retry overlapping its own in-flight push)
155 // can't both create. The loser replays if it's done, else reports in-progress;
156 // a crashed winner's stale reservation is reclaimed after the TTL.
157 if ( ! $this->store->reserve( $collection, $m['mutationId'], $m['recordId'], $m['operation'], $fingerprint ) ) {
158 $settled = $this->replay_or_conflict( $collection, $meta, $m, $fingerprint );
159 if ( null !== $settled ) {
160 return $settled;
161 }
162 if ( ! $this->reclaim_and_reserve( $collection, $m, $fingerprint ) ) {
163 return new WP_REST_Response(
164 array(
165 'code' => 'woo_rxdb_sync_in_progress',
166 'message' => 'Mutation is being applied; retry shortly.',
167 ),
168 409
169 );
170 }
171 }
172
173 // We hold the mutationId reservation (same-mutation idempotency). Now serialise on
174 // the RECORD so two DISTINCT mutations on the same collection+uuid can't both
175 // read-current → pass the baseRevision compare → forward (a silent lost update):
176 // the loser waits, then re-reads the now-updated revision and gets a real 409.
177 if ( ! $this->store->acquire_record_lock( $collection, $m['recordId'] ) ) {
178 $this->store->release( $m['mutationId'] ); // couldn't serialise in time — let a retry re-claim
179 return new WP_REST_Response(
180 array(
181 'code' => 'woo_rxdb_sync_record_locked',
182 'message' => 'Record is being written; retry shortly.',
183 ),
184 409
185 );
186 }
187 try {
188 // Apply, and RELEASE the reservation on any failure so a retry can re-claim
189 // immediately (a crash leaves it pending for the TTL reclaim instead).
190 $result = $this->apply( $m['operation'], $collection, $meta, $m );
191 } finally {
192 $this->store->release_record_lock( $collection, $m['recordId'] );
193 }
194 $checkpoint = $this->store->lookup( $collection, $m['mutationId'] );
195 if ( $this->is_failure( $result ) && ! $this->retains_mutation( $result ) && ! in_array( ( $checkpoint['status'] ?? '' ), array( 'poison', 'blocked' ), true ) ) {
196 $this->store->release( $m['mutationId'] );
197 }
198 return $result;
199 }
200
201 /**
202 * Settle a mutationId that the store already knows about.
203 *
204 * Rejects an envelope that reuses the id for a different write, re-stamps a poisoned
205 * retry, and replays the canonical result of an applied/done mutation.
206 *
207 * @param string $collection The collection being written to.
208 * @param array $meta The collection metadata.
209 * @param array $m The mutation envelope.
210 * @param string $fingerprint The canonical fingerprint of the envelope.
211 * @return WP_Error|WP_REST_Response|null The settled result, or null to keep applying.
212 */
213 private function replay_or_conflict( string $collection, array $meta, array $m, string $fingerprint ) {
214 $hit = $this->store->lookup( $collection, $m['mutationId'] );
215 if ( is_array( $hit ) ) {
216 $mismatch = $this->replay_target_mismatch( $hit, $collection, $fingerprint );
217 if ( $mismatch ) {
218 return $mismatch;
219 }
220 }
221 if ( is_array( $hit ) && 'poison' === ( $hit['status'] ?? '' ) ) {
222 return $this->retry_identity_stamp( $meta, $m, $hit );
223 }
224 if ( is_array( $hit ) && in_array( ( $hit['status'] ?? '' ), array( 'done', 'applied' ), true ) ) {
225 if ( 'applied' === $hit['status'] && ! $this->store->finalize( $m['mutationId'], (int) $hit['remote_id'] ) ) {
226 return $this->finalize_error();
227 }
228 return $this->replay( $meta, $hit );
229 }
230 return null;
231 }
232
233 /**
234 * Reclaim a crashed pending reservation, then atomically claim it again.
235 */
236 private function reclaim_and_reserve( string $collection, array $mutation, string $fingerprint ): bool {
237 if ( ! $this->store->reclaim_stale( $mutation['mutationId'], $this->store->reservation_ttl() ) ) {
238 return false;
239 }
240
241 return $this->store->reserve( $collection, $mutation['mutationId'], $mutation['recordId'], $mutation['operation'], $fingerprint );
242 }
243
244 /**
245 * A stored mutationId must be replayed only for its original envelope.
246 *
247 * @param array $hit The stored mutation row.
248 * @return WP_Error|null An envelope rejection on mismatch, null when aligned.
249 */
250 private function replay_target_mismatch( array $hit, string $collection, string $fingerprint ) {
251 $stored_fingerprint = (string) ( $hit['fingerprint'] ?? '' );
252 $stored_collection = (string) ( $hit['collection'] ?? '' );
253 if ( '' !== $stored_fingerprint && hash_equals( $stored_fingerprint, $fingerprint ) && ( '' === $stored_collection || $collection === $stored_collection ) ) {
254 return null;
255 }
256 return new WP_Error( 'woo_rxdb_sync_bad_mutation_id', 'mutationId was already used for a different envelope.', array( 'status' => 422 ) );
257 }
258
259 private function envelope_fingerprint( array $envelope ): string {
260 return hash( 'sha256', (string) wp_json_encode( $this->sort_envelope_keys( $envelope ) ) );
261 }
262
263 private function sort_envelope_keys( array $value ): array {
264 if ( array_values( $value ) !== $value ) {
265 ksort( $value );
266 }
267 foreach ( $value as $key => $item ) {
268 if ( is_array( $item ) ) {
269 $value[ $key ] = $this->sort_envelope_keys( $item );
270 }
271 }
272 return $value;
273 }
274
275 private function apply( string $operation, string $collection, array $meta, array $m ) {
276 switch ( $operation ) {
277 case 'create':
278 return $this->apply_create( $collection, $meta, $m );
279 case 'update':
280 return $this->apply_update( $collection, $meta, $m );
281 case 'delete':
282 return $this->apply_delete( $collection, $meta, $m );
283 }
284 return new WP_Error( 'woo_rxdb_sync_invalid_operation', 'Invalid operation.', array( 'status' => 400 ) );
285 }
286
287 private function is_failure( $result ): bool {
288 if ( $result instanceof WP_Error ) {
289 return true;
290 }
291 if ( $result instanceof WP_REST_Response ) {
292 return $result->get_status() >= 400;
293 }
294 return false;
295 }
296
297 private function retains_mutation( $result ): bool {
298 return $result instanceof WP_Error
299 && in_array( $result->get_error_code(), array( 'woo_rxdb_sync_finalize_failed', 'woo_rxdb_sync_create_no_id' ), true );
300 }
301
302 private function envelope( WP_REST_Request $request ): array {
303 // Prefer the parsed JSON body (the pattern push/fixtures controllers use) so a
304 // nested `payload` object is read reliably; fall back to get_param otherwise.
305 $json = method_exists( $request, 'get_json_params' ) ? $request->get_json_params() : null;
306 $src = ( is_array( $json ) && ! empty( $json ) ) ? $json : null;
307 $get = static function ( string $key ) use ( $request, $src ) {
308 return null !== $src ? ( $src[ $key ] ?? null ) : $request->get_param( $key );
309 };
310 if ( null !== $src ) {
311 return $src;
312 }
313 return array(
314 'mutationId' => $get( 'mutationId' ),
315 'operation' => $get( 'operation' ),
316 'collection' => $get( 'collection' ),
317 'recordId' => $get( 'recordId' ),
318 'baseRevision' => $get( 'baseRevision' ),
319 'payload' => $get( 'payload' ),
320 );
321 }
322
323 private function validate_envelope( array $m, string $path_collection ) {
324 $allowed = array( 'mutationId', 'operation', 'collection', 'recordId', 'baseRevision', 'payload', 'force' );
325 if ( array_diff( array_keys( $m ), $allowed ) ) {
326 return new WP_Error( 'woo_rxdb_sync_bad_envelope', 'Envelope contains unknown properties.', array( 'status' => 400 ) );
327 }
328 if ( ! isset( $m['mutationId'] ) || ! is_string( $m['mutationId'] ) || ! Pos_Uuid::is_uuid( $m['mutationId'] ) ) {
329 return new WP_Error( 'woo_rxdb_sync_bad_mutation_id', 'mutationId must be a uuid.', array( 'status' => 400 ) );
330 }
331 if ( ! isset( $m['operation'] ) || ! is_string( $m['operation'] ) || ! in_array( $m['operation'], array( 'create', 'update', 'delete' ), true ) ) {
332 return new WP_Error( 'woo_rxdb_sync_bad_operation', 'operation must be create|update|delete.', array( 'status' => 400 ) );
333 }
334 // Tighten the server to the published envelope contract. The production adapter already
335 // sends this field equal to the route, so no legitimate client traffic changes.
336 if ( ! isset( $m['collection'] ) || ! is_string( $m['collection'] ) || '' === $m['collection'] || $m['collection'] !== $path_collection ) {
337 return new WP_Error( 'woo_rxdb_sync_bad_collection', 'collection must match the path collection.', array( 'status' => 400 ) );
338 }
339 if ( ! isset( $m['recordId'] ) || ! is_string( $m['recordId'] ) || ! Pos_Uuid::is_uuid( $m['recordId'] ) ) {
340 return new WP_Error( 'woo_rxdb_sync_bad_record_id', 'recordId must be a uuid.', array( 'status' => 400 ) );
341 }
342 if ( ! array_key_exists( 'baseRevision', $m ) || ( ! is_string( $m['baseRevision'] ) && null !== $m['baseRevision'] ) ) {
343 return new WP_Error( 'woo_rxdb_sync_bad_base_revision', 'baseRevision must be a string or null.', array( 'status' => 400 ) );
344 }
345 if ( 'delete' === $m['operation'] ) {
346 if ( array_key_exists( 'force', $m ) && ! is_bool( $m['force'] ) ) {
347 return new WP_Error( 'woo_rxdb_sync_bad_payload', 'force must be a boolean.', array( 'status' => 400 ) );
348 }
349 if ( array_key_exists( 'payload', $m ) ) {
350 return new WP_Error( 'woo_rxdb_sync_bad_payload', 'payload is forbidden for delete.', array( 'status' => 400 ) );
351 }
352 } else {
353 if ( array_key_exists( 'force', $m ) ) {
354 return new WP_Error( 'woo_rxdb_sync_bad_payload', 'force is only allowed for delete.', array( 'status' => 400 ) );
355 }
356 if ( ! isset( $m['payload'] ) || ! is_array( $m['payload'] ) || ( ! empty( $m['payload'] ) && array_values( $m['payload'] ) === $m['payload'] ) ) {
357 return new WP_Error( 'woo_rxdb_sync_bad_payload', 'payload must be an object.', array( 'status' => 400 ) );
358 }
359 // A payload that carries its own uuid must agree with recordId — never re-key.
360 $payload_uuid = Pos_Uuid::read_valid_uuid_from_meta(
361 isset( $m['payload']['meta_data'] ) && is_array( $m['payload']['meta_data'] ) ? $m['payload']['meta_data'] : array()
362 );
363 if ( '' !== $payload_uuid && $payload_uuid !== $m['recordId'] ) {
364 return new WP_Error( 'woo_rxdb_sync_identity_conflict', 'payload uuid disagrees with recordId.', array( 'status' => 422 ) );
365 }
366 }
367 return null;
368 }
369
370 private function apply_create( string $collection, array $meta, array $m ) {
371 $writer = $this->writer( $meta );
372 $prepared = $writer->prepare_create( $meta, $m['payload'], \Closure::fromCallable( array( $this, 'validate_tax_ids_payload' ) ) );
373 if ( ! is_array( $prepared ) ) {
374 return $prepared;
375 }
376
377 // Born-twice guard: reuse the record that already owns this uuid.
378 $existing = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta );
379 if ( is_wp_error( $existing ) ) {
380 return $existing;
381 }
382 if ( $existing > 0 ) {
383 $valid = $writer->validate_existing_create( $existing, $m['payload'], $prepared );
384 if ( null !== $valid ) {
385 return $valid;
386 }
387 $finalized = $this->checkpoint_and_finalize( $m['mutationId'], $existing, 200 );
388 if ( is_wp_error( $finalized ) ) {
389 return $finalized;
390 }
391 return $this->envelope_document( $this->document_for( $meta, $existing ), $m['recordId'], $meta, $existing, null, $writer );
392 }
393
394 $response = $writer->forward( $prepared, \Closure::fromCallable( array( $this, 'forward' ) ) );
395 if ( is_wp_error( $response ) ) {
396 return $response;
397 }
398 $data = $response->get_data();
399 if ( $response->get_status() >= 400 ) {
400 return new WP_REST_Response( $data, $response->get_status() );
401 }
402 $new_id = (int) ( is_array( $data ) ? ( $data['id'] ?? 0 ) : 0 );
403 if ( $new_id <= 0 ) {
404 $this->store->mark_indeterminate( $m['mutationId'], 0, $response->get_status() );
405 return new WP_Error( 'woo_rxdb_sync_create_no_id', 'Create returned no server id.', array( 'status' => 502 ) );
406 }
407
408 // Poison checkpoint, UUID persistence, and finalization remain shared here.
409 $checkpointed = $this->store->mark_poison( $m['mutationId'], $new_id, $response->get_status() );
410 $writer->persist( 'create_before_identity', $new_id, $m['payload'] );
411 $identity_error = null;
412 if ( ! $this->store->persist_uuid( $meta['id_type'], $new_id, $m['recordId'] ) ) {
413 $identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
414 } else {
415 $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta );
416 if ( is_wp_error( $resolved ) ) {
417 $identity_error = $resolved;
418 } elseif ( $resolved !== $new_id ) {
419 $identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
420 }
421 }
422 if ( ! $checkpointed ) {
423 $this->store->mark_indeterminate( $m['mutationId'], $new_id, $response->get_status() );
424 return $this->finalize_error();
425 }
426 if ( $identity_error ) {
427 return $identity_error;
428 }
429 $writer->persist( 'create_after_identity', $new_id, $m['payload'] );
430 if ( ! $this->store->finalize_poison( $m['mutationId'], $new_id ) ) {
431 return $this->finalize_error();
432 }
433 return $this->envelope_document( $this->document_for( $meta, $new_id ), $m['recordId'], $meta, $new_id, $response->get_status(), $writer );
434 }
435
436 /**
437 * The grace comparer (#423 step 2, option `woo_rxdb_sync_legacy_revision_grace`,
438 * default on until retirement): on a canonical mismatch, accept a baseRevision
439 * that matches the CURRENT document under a PRE-CUTOVER form —
440 * - the legacy order sha256 (no ksort, volatiles included): same content,
441 * old algorithm ⇒ the precondition is genuinely current;
442 * - a pre-taxonomy-sort sha256 for non-orders: same content, previous
443 * canonicalizer ⇒ queued writes survive the revision transition;
444 * - a pre-1b lane synthesis (non-sha256 values the client fetchers stored
445 * as sync.revision before the proxy revision stamp): date_modified_gmt
446 * for order/post/user collections, String(id) for term collections
447 * (deliberately vacuous — that lane never had optimistic concurrency;
448 * grace honours the contract the record was written under).
449 * A real conflict mismatches every form → 409 exactly as before. The ack
450 * always returns the CANONICAL currentRevision, re-anchoring the client.
451 */
452 private function revision_matches_with_grace( $base, string $current_revision, array $meta, int $id, array $bare, bool $allow_term_grace = true ): bool {
453 if ( $base === $current_revision ) {
454 return true;
455 }
456 if ( ! is_string( $base ) || 'yes' !== get_option( 'woocommerce_pos_sync_legacy_revision_grace', 'yes' ) ) {
457 return false;
458 }
459 if ( 0 === strpos( $base, 'sha256:' ) ) {
460 if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) {
461 if ( Order_Serializer::pre_augmentation_canonical_revision( $bare ) === $base
462 || Order_Serializer::pre_item_uuid_canonical_revision( $bare ) === $base ) {
463 return true;
464 }
465 $payload = ( new Order_Serializer() )->serialize_order( $id, new WP_REST_Request() );
466 return Order_Serializer::legacy_revision( $payload ) === $base;
467 }
468 return Revision::pre_taxonomy_sort_revision( $bare ) === $base;
469 }
470 $id_type = $meta['id_type'] ?? '';
471 if ( in_array( $id_type, array( 'order', 'post', 'user' ), true ) ) {
472 $date = (string) ( $bare['date_modified_gmt'] ?? '' );
473 return '' !== $date && $base === $date;
474 }
475 if ( 'term' === $id_type && $allow_term_grace ) {
476 return (string) ( $bare['id'] ?? '' ) === $base;
477 }
478 return false;
479 }
480
481 /**
482 * The pre-CAS post-type capability gate shared by the update and delete paths.
483 *
484 * Only the WP-post-backed collections carry a Woo capability check of their own;
485 * every other collection is gated by the endpoint permission callback alone, so
486 * this is a no-op for them.
487 *
488 * @param array $meta Resolved collection metadata (carries the post_type, if any).
489 * @param int $id Resolved record id.
490 * @param string $verb Woo permission context: 'edit' or 'delete'.
491 *
492 * @return WP_Error|null The refusal to return, or null when the write may proceed.
493 */
494 private function post_permission_error( array $meta, int $id, string $verb ): ?WP_Error {
495 $post_type = (string) ( $meta['post_type'] ?? '' );
496 if ( ! \in_array( $post_type, array( 'product', 'product_variation', 'shop_coupon' ), true )
497 || wc_rest_check_post_permissions( $post_type, $verb, $id ) ) {
498 return null;
499 }
500 $status = array( 'status' => rest_authorization_required_code() );
501 if ( 'delete' === $verb ) {
502 return new WP_Error( 'woocommerce_rest_cannot_delete', __( 'Sorry, you are not allowed to delete this resource.', 'woocommerce' ), $status );
503 }
504 return new WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you are not allowed to edit this resource.', 'woocommerce' ), $status );
505 }
506
507 private function apply_update( string $collection, array $meta, array $m ) {
508 $id = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta );
509 if ( is_wp_error( $id ) ) {
510 return $id;
511 }
512 if ( 0 === $id ) {
513 return new WP_Error( 'woo_rxdb_sync_record_not_found', 'No record for recordId.', array( 'status' => 404 ) );
514 }
515 $permission_error = $this->post_permission_error( $meta, $id, 'edit' );
516 if ( $permission_error ) {
517 return $permission_error;
518 }
519
520 $writer = $this->writer( $meta );
521 $prepared = $writer->prepare_update( $meta, $id, $m['payload'], \Closure::fromCallable( array( $this, 'validate_tax_ids_payload' ) ) );
522 if ( ! is_array( $prepared ) ) {
523 return $prepared;
524 }
525 if ( null === $m['baseRevision'] ) {
526 return new WP_REST_Response(
527 array(
528 'code' => 'woo_rxdb_sync_revision_required',
529 'message' => 'Updating an existing record requires an If-Match / baseRevision precondition.',
530 ),
531 428
532 );
533 }
534
535 $current = $this->document_for( $meta, $id );
536 if ( ! ( $current instanceof WP_REST_Response ) || $current->get_status() >= 400 ) {
537 return $current;
538 }
539 $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array();
540 $current_revision = $this->revision_for( $meta, $id, $current_bare );
541 if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare ) ) {
542 return new WP_REST_Response(
543 array(
544 'code' => 'woo_rxdb_sync_conflict',
545 'message' => 'baseRevision is stale.',
546 'current' => $current->get_data(),
547 'currentRevision' => $current_revision,
548 ),
549 409
550 );
551 }
552 if ( isset( $prepared['context_factory'] ) && is_callable( $prepared['context_factory'] ) ) {
553 $late = $prepared['context_factory']();
554 $prepared['payload'] = $late['payload'];
555 $prepared['context'] = $late['context'];
556 }
557
558 $response = $writer->forward( $prepared, \Closure::fromCallable( array( $this, 'forward' ) ) );
559 if ( is_wp_error( $response ) ) {
560 return $response;
561 }
562 if ( $response->get_status() >= 400 ) {
563 return new WP_REST_Response( $response->get_data(), $response->get_status() );
564 }
565 $data = $response->get_data();
566 $writer->persist( 'update', $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] );
567
568 $this->store->persist_uuid( $meta['id_type'], $id, $m['recordId'] );
569 $finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() );
570 if ( is_wp_error( $finalized ) ) {
571 return $finalized;
572 }
573 return $this->envelope_document( $this->document_for( $meta, $id ), $m['recordId'], $meta, $id, null, $writer );
574 }
575
576 private function apply_delete( string $collection, array $meta, array $m ) {
577 $id = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta );
578 if ( is_wp_error( $id ) ) {
579 return $id;
580 }
581 if ( 0 === $id ) {
582 $finalized = $this->checkpoint_and_finalize( $m['mutationId'], 0, 200 );
583 return is_wp_error( $finalized ) ? $finalized : new WP_REST_Response( (object) array(), 200 );
584 }
585 $permission_error = $this->post_permission_error( $meta, $id, 'delete' );
586 if ( $permission_error ) {
587 return $permission_error;
588 }
589 if ( null === $m['baseRevision'] ) {
590 return new WP_REST_Response(
591 array(
592 'code' => 'woo_rxdb_sync_precondition_required',
593 'message' => 'Deleting an existing record requires an If-Match / baseRevision precondition.',
594 ),
595 428
596 );
597 }
598
599 $writer = $this->writer( $meta );
600 $current = $this->document_for( $meta, $id );
601 if ( ! ( $current instanceof WP_REST_Response ) || $current->get_status() >= 400 ) {
602 return $current;
603 }
604 $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array();
605 $current_revision = $this->revision_for( $meta, $id, $current_bare );
606 if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare, false ) ) {
607 return new WP_REST_Response(
608 array(
609 'code' => 'woo_rxdb_sync_conflict',
610 'message' => 'baseRevision is stale.',
611 'current' => $current->get_data(),
612 'currentRevision' => $current_revision,
613 ),
614 409
615 );
616 }
617
618 $response = $writer->delete( $meta, $id, $m, \Closure::fromCallable( array( $this, 'dispatch_write' ) ), \Closure::fromCallable( array( $this, 'can_forward_delete' ) ) );
619 if ( is_wp_error( $response ) ) {
620 return $response;
621 }
622 if ( $response->get_status() >= 400 ) {
623 return new WP_REST_Response( $response->get_data(), $response->get_status() );
624 }
625 $finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() );
626 return is_wp_error( $finalized ) ? $finalized : new WP_REST_Response( (object) array(), 200 );
627 }
628
629 /**
630 * Whether the forwarded wc/v3 order delete would pass its capability gate.
631 *
632 * Asks the SAME question the forward will, under the same
633 * `woocommerce_rest_check_permissions` filter `dispatch_write()` installs, so the
634 * pre-flight and the forward can never disagree. Used only to keep the stock
635 * pre-restore off a delete that is going to be refused.
636 *
637 * @param int $id The order id.
638 */
639 private function can_forward_delete( int $id ): bool {
640 add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 );
641 try {
642 return (bool) wc_rest_check_post_permissions( 'shop_order', 'delete', $id );
643 } finally {
644 remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 );
645 }
646 }
647
648 private function checkpoint_and_finalize( string $mutation_id, int $remote_id, int $response_status ) {
649 if ( ! $this->store->mark_applied( $mutation_id, $remote_id, $response_status ) ) {
650 return $this->finalize_error();
651 }
652 if ( ! $this->store->finalize( $mutation_id, $remote_id ) ) {
653 return $this->finalize_error();
654 }
655 return null;
656 }
657
658 private function finalize_error(): WP_Error {
659 return new WP_Error( 'woo_rxdb_sync_finalize_failed', 'Woo write succeeded but mutation finalization failed; retry the same mutationId.', array( 'status' => 500 ) );
660 }
661
662 private function replay( array $meta, array $hit ) {
663 if ( 'delete' === ( $hit['operation'] ?? '' ) || 0 === (int) ( $hit['remote_id'] ?? 0 ) ) {
664 return new WP_REST_Response( (object) array(), 200 );
665 }
666 $remote_id = (int) $hit['remote_id'];
667 $expected = (string) ( $hit['record_uuid'] ?? '' );
668 // Verify the recorded record still EXISTS and still owns this uuid. We check
669 // via the uuid→id resolver (not the wc/v3 response, which omits the protected
670 // _woocommerce_pos_uuid meta): if the uuid no longer maps to the recorded id,
671 // the record was deleted out-of-band / its id was reused — return 410.
672 if ( '' !== $expected ) {
673 $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $expected, $meta );
674 if ( is_wp_error( $resolved ) ) {
675 return $resolved; // ambiguous identity (uuid now on >1 record) — surface 409, not a false 410-orphan
676 }
677 if ( $resolved !== $remote_id ) {
678 return new WP_Error( 'woo_rxdb_sync_orphaned_mutation', 'Recorded mutation no longer matches its record.', array( 'status' => 410 ) );
679 }
680 }
681 $status = isset( $hit['response_status'] )
682 ? (int) $hit['response_status']
683 : ( 'create' === ( $hit['operation'] ?? '' ) ? 201 : null );
684 $writer = $this->writer( $meta );
685 return $this->envelope_document( $this->document_for( $meta, $remote_id ), $expected, $meta, $remote_id, $status, $writer );
686 }
687
688 private function retry_identity_stamp( array $meta, array $m, array $hit ) {
689 $remote_id = (int) ( $hit['remote_id'] ?? 0 );
690 $record_uuid = (string) ( $hit['record_uuid'] ?? '' );
691 if ( $record_uuid !== $m['recordId'] ) {
692 return new WP_Error( 'woo_rxdb_sync_identity_conflict', 'recordId disagrees with the stored mutation identity.', array( 'status' => 422 ) );
693 }
694 if ( 'create' !== ( $hit['operation'] ?? '' ) || $remote_id <= 0 ) {
695 return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Created record identity cannot be recovered safely.', array( 'status' => 500 ) );
696 }
697 $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta );
698 if ( is_wp_error( $resolved ) ) {
699 return $resolved;
700 }
701 if ( $resolved > 0 && $resolved !== $remote_id ) {
702 return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Stored create identity points at a different record.', array( 'status' => 500 ) );
703 }
704 if ( ! $this->store->persist_uuid( $meta['id_type'], $remote_id, $record_uuid ) ) {
705 return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
706 }
707 $verified = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta );
708 if ( is_wp_error( $verified ) ) {
709 return $verified;
710 }
711 if ( $verified !== $remote_id ) {
712 return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
713 }
714 $writer = $this->writer( $meta );
715 $writer->persist( 'create_recovery', $remote_id, $m['payload'] );
716 if ( ! $this->store->finalize_poison( $m['mutationId'], $remote_id ) ) {
717 return $this->finalize_error();
718 }
719 $status = isset( $hit['response_status'] ) ? (int) $hit['response_status'] : 201;
720 return $this->envelope_document( $this->document_for( $meta, $remote_id ), $record_uuid, $meta, $remote_id, $status, $writer );
721 }
722
723 /**
724 * Validate a client-submitted `tax_ids` payload against the v1 schema.
725 *
726 * tax_ids is unknown to the stock wc/v3 controllers and is stripped before the forward,
727 * so wc/v3 never validates it. The v1 controllers (Orders_Controller::wcpos_get_item_schema,
728 * Customers_Controller) exposed a TaxId[] schema (typed enum, string value, nullable
729 * country/label) that WordPress enforced on every create/update; reproduce that check here
730 * for both orders and customers so malformed or unsupported entries are rejected with a
731 * 400 instead of being silently dropped by Tax_Id_Writer.
732 *
733 * @param array $payload Mutation payload.
734 *
735 * @return null|WP_Error null when tax_ids is absent or valid; WP_Error (400) otherwise.
736 */
737 private function validate_tax_ids_payload( array $payload ) {
738 if ( ! array_key_exists( 'tax_ids', $payload ) ) {
739 return null;
740 }
741 $schema = array(
742 'type' => 'array',
743 'items' => array(
744 'type' => 'object',
745 // value/type are required: Tax_Id_Writer silently drops an entry with no value and
746 // rewrites a missing type to `other`, so an accepted-but-mutated ack would diverge
747 // from the submitted IDs. Require them so the API returns a 400 instead.
748 'required' => array( 'value', 'type' ),
749 'properties' => array(
750 'type' => array(
751 'type' => 'string',
752 'enum' => Tax_Id_Types::all_types(),
753 ),
754 'value' => array(
755 'type' => 'string',
756 ),
757 'country' => array(
758 'type' => array( 'string', 'null' ),
759 ),
760 'label' => array(
761 'type' => array( 'string', 'null' ),
762 ),
763 ),
764 ),
765 );
766 $valid = rest_validate_value_from_schema( $payload['tax_ids'], $schema, 'tax_ids' );
767 if ( is_wp_error( $valid ) ) {
768 return new WP_Error( 'woocommerce_pos_rest_invalid_tax_ids', $valid->get_error_message(), array( 'status' => 400 ) );
769 }
770 return null;
771 }
772 private function forward( string $method, string $route, $payload ) {
773 $request = new WP_REST_Request( $method, $route );
774 if ( is_array( $payload ) ) {
775 // The route id (resolved server-side from the uuid) is authoritative — never
776 // let a client-supplied body `id` override it or pin a create's id. The
777 // v2 header is likewise the only authority for the legacy store param.
778 unset( $payload['id'], $payload[ Store_Scope::PARAM ] );
779 $request->set_body_params( $payload );
780 }
781 return $this->dispatch_write( $request );
782 }
783
784 /**
785 * Dispatch one raw WooCommerce mutation with the client-tier grant scoped to it.
786 *
787 * @return WP_REST_Response
788 */
789 private function dispatch_write( WP_REST_Request $request ) {
790 // Stamp here so direct callers (notably deletes) carry the scope too.
791 Store_Scope::stamp( $request );
792 add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 );
793 try {
794 // Marked as OUR traffic for the duration of the forward, so a consumer
795 // keyed on store scope can act on a till write without also claiming
796 // every stock wc/v3 product write on the site (pro#425 review).
797 return Store_Scope::in_v2_lane(
798 static function () use ( $request ) {
799 return rest_do_request( $request );
800 }
801 );
802 } finally {
803 remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 );
804 }
805 }
806
807 /**
808 * Authorize proxied catalog mutations for POS users.
809 *
810 * This filter is attached only while a sync push is forwarded to wc/v3, so
811 * direct WooCommerce requests keep their normal permission checks.
812 *
813 * @param bool $permission The current permission.
814 * @param string $context The request context.
815 * @param int $object_id The object ID.
816 * @param string $post_type The object type passed by WooCommerce.
817 *
818 * @return bool
819 */
820 public function wcpos_check_permissions( $permission, $context, $object_id, $post_type ) {
821 // Catalog and coupon WRITES require the user's real WooCommerce
822 // capabilities — no POS-tier widening. The cashier role is deliberately
823 // read-only on catalog (Activator), and a blanket grant here handed
824 // every POS user product deletion and coupon minting. Product decision
825 // 2026-08-06: strict wc/v3 parity for catalog mutations; only the
826 // HPOS placeholder remap below (orders) adjusts anything, and it never
827 // grants beyond the user's own role caps.
828
829 // Orders: with HPOS enabled (sync off), get_post() yields shop_order_placehold
830 // (map_meta_cap = false, no capability_type), so WooCommerce's REST check maps
831 // to the generic edit_post/delete_post caps that cashier-tier roles lack —
832 // even though they hold the real shop_orders caps. Re-check the capability the
833 // mapping SHOULD have produced, mirroring V1\Orders_Controller's
834 // update_item_permissions_check fix. No grant beyond the user's own role caps.
835 if ( ! $permission && 'shop_order' === $post_type ) {
836 $order_caps = array(
837 'read' => 'read_private_shop_orders',
838 'create' => 'publish_shop_orders',
839 'delete' => 'delete_shop_orders',
840 );
841 $order_cap = $order_caps[ $context ] ?? null;
842 // edit and delete are ownership-sensitive: the base *_shop_orders cap only
843 // authorizes acting on the user's OWN orders. Touching another user's order
844 // additionally requires the *_others_shop_orders cap, mirroring WooCommerce's
845 // own meta-cap map. Without this, a cashier with delete_shop_orders (but not
846 // delete_others_shop_orders) could delete/void orders they do not own.
847 if ( \in_array( $context, array( 'edit', 'delete' ), true ) ) {
848 $order_post = get_post( $object_id );
849 if ( $order_post ) {
850 $owns_order = get_current_user_id() === (int) $order_post->post_author;
851 $order_cap = $owns_order ? "{$context}_shop_orders" : "{$context}_others_shop_orders";
852 }
853 }
854 if ( $order_cap && current_user_can( $order_cap ) ) {
855 $permission = true;
856 }
857 }
858
859 return $permission;
860 }
861
862 /**
863 * Read this collection's document for one record, through its writer.
864 *
865 * The single place the writer's document step is invoked. Kept as a named
866 * method rather than inlined at each call site because it is also the seam
867 * Test_Rest_Dispatch_Write_Contract and Test_Sync_Hook_Isolation reach for
868 * to pin the variation parent-route and re-read-price behaviours.
869 *
870 * @param array $meta Collection meta for the record.
871 * @param int $id Record id.
872 *
873 * @return mixed
874 */
875 private function document_for( array $meta, int $id ) {
876 return $this->writer( $meta )->document( $meta, $id, \Closure::fromCallable( array( $this, 'default_document_for' ) ) );
877 }
878
879 /** Read and normalize a generic wc/v3 response document. */
880 private function default_document_for( array $meta, int $id, array $params = array() ) {
881 $request = new WP_REST_Request( 'GET', $meta['route'] . '/' . $id );
882 Store_Scope::stamp( $request );
883 foreach ( $params as $key => $value ) {
884 $request->set_param( $key, $value );
885 }
886 $response = Store_Scope::in_v2_lane(
887 static function () use ( $request ) {
888 return rest_do_request( $request );
889 }
890 );
891 $data = $response->get_data();
892 if ( is_array( $data ) ) {
893 $response->set_data( Meta_Normalizer::normalize( $data ) );
894 }
895 return $response;
896 }
897
898 /** Apply generic product augmentation and inject the client UUID. */
899 private function default_response_document( array $bare, string $record_id, array $meta, int $id ): array {
900 if ( 'product' === ( $meta['post_type'] ?? '' ) ) {
901 $product = wc_get_product( $id );
902 if ( $product ) {
903 $bare = Product_Serializer::augment( $bare, $product, new WP_REST_Request( 'GET', $meta['route'] . '/' . $id ) );
904 }
905 }
906 return Pos_Uuid::ensure_in_payload( $bare, $record_id );
907 }
908
909 /** Wrap a collection document in the unchanged mutation response envelope. */
910 private function respond( array $bare, string $record_id, int $status, array $meta, int $id, $writer ) {
911 $current_revision = $this->revision_for( $meta, $id, $bare );
912 $document = $writer->build_response_document(
913 $bare,
914 $record_id,
915 $meta,
916 $id,
917 \Closure::fromCallable( array( $this, 'default_response_document' ) )
918 );
919 return new WP_REST_Response(
920 array(
921 'document' => $document,
922 'currentRevision' => $current_revision,
923 ),
924 $status
925 );
926 }
927
928 /**
929 * Wrap a collection document in the write-ack envelope.
930 *
931 * $status and $writer default so the four-argument form still resolves —
932 * Test_Sync_Hook_Isolation reaches this method by reflection to pin the
933 * variation re-read price behaviour.
934 *
935 * @param mixed $document Document to envelope.
936 * @param string $record_id Client record id.
937 * @param array $meta Collection meta for the record.
938 * @param int $id Record id.
939 * @param int|null $status Status to report, or null to use the document's.
940 * @param object|null $writer Writer for the collection, resolved from $meta when null.
941 *
942 * @return mixed
943 */
944 private function envelope_document( $document, string $record_id, array $meta, int $id, ?int $status = null, $writer = null ) {
945 if ( ! ( $document instanceof WP_REST_Response ) || $document->get_status() >= 400 ) {
946 return $document;
947 }
948 $writer = $writer ?? $this->writer( $meta );
949 $bare = $document->get_data();
950 return $this->respond( is_array( $bare ) ? $bare : array(), $record_id, $status ?? $document->get_status(), $meta, $id, $writer );
951 }
952
953 private function revision_for( array $meta, int $id, array $bare ): string {
954 if ( 'product_variation' === ( $meta['post_type'] ?? '' ) ) {
955 // The targeted variation pull stores exactly this source as
956 // sync.revision (apps/web/src/db/variationIncludePull.ts): modified
957 // timestamp, falling back to the wrapper id.
958 $payload = isset( $bare['payload'] ) && is_array( $bare['payload'] ) ? $bare['payload'] : array();
959 return (string) ( $payload['date_modified_gmt'] ?? $bare['id'] ?? $id );
960 }
961 if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) {
962 return Order_Serializer::canonical_revision( $bare );
963 }
964 return Revision::compute( $bare );
965 }
966 }
967