PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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.19, at includes/API/V2/Write_Controller.php

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