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
← All changes | includes/API/V2/Write_Controller.php +48 -168 1.10.01.10.19 View file →
@@ -7,11 +7,13 @@
7 7
8 8 namespace WCPOS\WooCommercePOS\API\V2;
9 9
10 10 use WCPOS\WooCommercePOS\API\V2\Writers\Collection_Writer_Resolver;
11 +use WCPOS\WooCommercePOS\Services\Permission_Rules;
11 12 use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
12 13 use WCPOS\WooCommercePOS\Sync\Api;
13 14 use WCPOS\WooCommercePOS\Sync\Collections;
15 +use WCPOS\WooCommercePOS\Sync\Create_Identity;
14 16 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
15 17 use WCPOS\WooCommercePOS\Sync\Header_Mirror;
16 18 use WCPOS\WooCommercePOS\Sync\Meta_Normalizer;
17 19 use WCPOS\WooCommercePOS\Sync\Mutation_Store;
@@ -43,8 +45,9 @@
43 45 * live in an injected mutation store, so the apply logic is unit-testable with a
44 46 * fake store + a stubbed `rest_do_request`.
45 47 */
46 48 class Write_Controller extends WP_REST_Controller {
49 +
47 50 // Our gate (capability + F13 health); forwarded writes scope the client-tier grant below.
48 51 use Endpoint_Permissions;
49 52
50 53
@@ -50,9 +53,12 @@
50 53
51 54 /** @var mixed Duck-typed mutation store; tests inject an in-memory implementation. */
52 55 private $store;
53 56
57 + /** @var Create_Identity Shared proof for fresh and poisoned creates. */
58 + private Create_Identity $identity;
54 59
60 +
55 61 /**
56 62 * collection => wc/v3 route + how its uuid→id is resolved. ONE table, not
57 63 * per-collection controllers. Only collections whose resolver is correct AND
58 64 * exercised are exposed; the rest stay out until their phase:
@@ -97,8 +103,9 @@
97 103 }
98 104
99 105 public function __construct( $store = null ) {
100 106 $this->store = $store ? $store : new Mutation_Store();
107 + $this->identity = new Create_Identity( $this->store );
101 108 }
102 109
103 110 /** Resolve the collection-specific writer for registry metadata. */
104 111 private function writer( array $meta ) {
@@ -218,9 +225,14 @@
218 225 return $mismatch;
219 226 }
220 227 }
221 228 if ( is_array( $hit ) && 'poison' === ( $hit['status'] ?? '' ) ) {
222 - return $this->retry_identity_stamp( $meta, $m, $hit );
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 );
223 235 }
224 236 if ( is_array( $hit ) && in_array( ( $hit['status'] ?? '' ), array( 'done', 'applied' ), true ) ) {
225 237 if ( 'applied' === $hit['status'] && ! $this->store->finalize( $m['mutationId'], (int) $hit['remote_id'] ) ) {
226 238 return $this->finalize_error();
@@ -404,82 +416,16 @@
404 416 $this->store->mark_indeterminate( $m['mutationId'], 0, $response->get_status() );
405 417 return new WP_Error( 'woo_rxdb_sync_create_no_id', 'Create returned no server id.', array( 'status' => 502 ) );
406 418 }
407 419
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 - }
420 + $stamped = $this->identity->stamp( $meta, $m, $new_id, $response->get_status(), $writer );
421 + if ( is_wp_error( $stamped ) ) {
422 + return $stamped;
421 423 }
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 424 return $this->envelope_document( $this->document_for( $meta, $new_id ), $m['recordId'], $meta, $new_id, $response->get_status(), $writer );
434 425 }
435 426
436 427 /**
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 428 * The pre-CAS post-type capability gate shared by the update and delete paths.
483 429 *
484 430 * Only the WP-post-backed collections carry a Woo capability check of their own;
485 431 * every other collection is gated by the endpoint permission callback alone, so
@@ -537,9 +483,9 @@
537 483 return $current;
538 484 }
539 485 $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array();
540 486 $current_revision = $this->revision_for( $meta, $id, $current_bare );
541 - if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare ) ) {
487 + if ( $m['baseRevision'] !== $current_revision ) {
542 488 return new WP_REST_Response(
543 489 array(
544 490 'code' => 'woo_rxdb_sync_conflict',
545 491 'message' => 'baseRevision is stale.',
@@ -562,9 +508,9 @@
562 508 if ( $response->get_status() >= 400 ) {
563 509 return new WP_REST_Response( $response->get_data(), $response->get_status() );
564 510 }
565 511 $data = $response->get_data();
566 - $writer->persist( 'update', $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] );
512 + $writer->after_update( $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] );
567 513
568 514 $this->store->persist_uuid( $meta['id_type'], $id, $m['recordId'] );
569 515 $finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() );
570 516 if ( is_wp_error( $finalized ) ) {
@@ -602,9 +548,9 @@
602 548 return $current;
603 549 }
604 550 $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array();
605 551 $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 ) ) {
552 + if ( $m['baseRevision'] !== $current_revision ) {
607 553 return new WP_REST_Response(
608 554 array(
609 555 'code' => 'woo_rxdb_sync_conflict',
610 556 'message' => 'baseRevision is stale.',
@@ -636,13 +582,13 @@
636 582 *
637 583 * @param int $id The order id.
638 584 */
639 585 private function can_forward_delete( int $id ): bool {
640 - add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 );
586 + Permission_Rules::install_wc_filter();
641 587 try {
642 588 return (bool) wc_rest_check_post_permissions( 'shop_order', 'delete', $id );
643 589 } finally {
644 - remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 );
590 + Permission_Rules::uninstall_wc_filter();
645 591 }
646 592 }
647 593
648 594 private function checkpoint_and_finalize( string $mutation_id, int $remote_id, int $response_status ) {
@@ -684,43 +630,8 @@
684 630 $writer = $this->writer( $meta );
685 631 return $this->envelope_document( $this->document_for( $meta, $remote_id ), $expected, $meta, $remote_id, $status, $writer );
686 632 }
687 633
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 634 /**
724 635 * Validate a client-submitted `tax_ids` payload against the v1 schema.
725 636 *
726 637 * tax_ids is unknown to the stock wc/v3 controllers and is stripped before the forward,
@@ -788,9 +699,9 @@
788 699 */
789 700 private function dispatch_write( WP_REST_Request $request ) {
790 701 // Stamp here so direct callers (notably deletes) carry the scope too.
791 702 Store_Scope::stamp( $request );
792 - add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 );
703 + Permission_Rules::install_wc_filter();
793 704 try {
794 705 // Marked as OUR traffic for the duration of the forward, so a consumer
795 706 // keyed on store scope can act on a till write without also claiming
796 707 // every stock wc/v3 product write on the site (pro#425 review).
@@ -799,65 +710,15 @@
799 710 return rest_do_request( $request );
800 711 }
801 712 );
802 713 } finally {
803 - remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 );
714 + Permission_Rules::uninstall_wc_filter();
804 715 }
805 716 }
806 717
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 - */
718 + /** @deprecated Use Permission_Rules::wc_filter(). */
820 719 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;
720 + return Permission_Rules::wc_filter( $permission, $context, $object_id, $post_type, 'writes' );
860 721 }
861 722
862 723 /**
863 724 * Read this collection's document for one record, through its writer.
@@ -951,13 +812,32 @@
951 812 }
952 813
953 814 private function revision_for( array $meta, int $id, array $bare ): string {
954 815 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.
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 + */
958 836 $payload = isset( $bare['payload'] ) && is_array( $bare['payload'] ) ? $bare['payload'] : array();
959 - return (string) ( $payload['date_modified_gmt'] ?? $bare['id'] ?? $id );
837 + $date = $payload['date_modified_gmt'] ?? $bare['date_modified_gmt'] ?? null;
838 +
839 + return (string) ( $date ?? $bare['id'] ?? $id );
960 840 }
961 841 if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) {
962 842 return Order_Serializer::canonical_revision( $bare );
963 843 }