PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 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 All 169 releases
← All changes | includes/API/V2/Write_Controller.php +163 -22 1.10.21 → 1.10.17 View file →
@@ -7,13 +7,12 @@
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 +use WCPOS\WooCommercePOS\Services\Customer_Account_Guard;
12 12 use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
13 13 use WCPOS\WooCommercePOS\Sync\Api;
14 14 use WCPOS\WooCommercePOS\Sync\Collections;
15 -use WCPOS\WooCommercePOS\Sync\Create_Identity;
16 15 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
17 16 use WCPOS\WooCommercePOS\Sync\Header_Mirror;
18 17 use WCPOS\WooCommercePOS\Sync\Meta_Normalizer;
19 18 use WCPOS\WooCommercePOS\Sync\Mutation_Store;
@@ -45,8 +44,14 @@
45 44 * live in an injected mutation store, so the apply logic is unit-testable with a
46 45 * fake store + a stubbed `rest_do_request`.
47 46 */
48 47 class Write_Controller extends WP_REST_Controller {
48 + /**
49 + * True while wc_rest_check_user_permissions() is re-run for a cleared target.
50 + *
51 + * @var bool
52 + */
53 + private $rejudging_user_target = false;
49 54
50 55 // Our gate (capability + F13 health); forwarded writes scope the client-tier grant below.
51 56 use Endpoint_Permissions;
52 57
@@ -53,12 +58,9 @@
53 58
54 59 /** @var mixed Duck-typed mutation store; tests inject an in-memory implementation. */
55 60 private $store;
56 61
57 - /** @var Create_Identity Shared proof for fresh and poisoned creates. */
58 - private Create_Identity $identity;
59 62
60 -
61 63 /**
62 64 * collection => wc/v3 route + how its uuid→id is resolved. ONE table, not
63 65 * per-collection controllers. Only collections whose resolver is correct AND
64 66 * exercised are exposed; the rest stay out until their phase:
@@ -103,9 +105,8 @@
103 105 }
104 106
105 107 public function __construct( $store = null ) {
106 108 $this->store = $store ? $store : new Mutation_Store();
107 - $this->identity = new Create_Identity( $this->store );
108 109 }
109 110
110 111 /** Resolve the collection-specific writer for registry metadata. */
111 112 private function writer( array $meta ) {
@@ -225,14 +226,9 @@
225 226 return $mismatch;
226 227 }
227 228 }
228 229 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 );
230 + return $this->retry_identity_stamp( $meta, $m, $hit );
235 231 }
236 232 if ( is_array( $hit ) && in_array( ( $hit['status'] ?? '' ), array( 'done', 'applied' ), true ) ) {
237 233 if ( 'applied' === $hit['status'] && ! $this->store->finalize( $m['mutationId'], (int) $hit['remote_id'] ) ) {
238 234 return $this->finalize_error();
@@ -416,12 +412,33 @@
416 412 $this->store->mark_indeterminate( $m['mutationId'], 0, $response->get_status() );
417 413 return new WP_Error( 'woo_rxdb_sync_create_no_id', 'Create returned no server id.', array( 'status' => 502 ) );
418 414 }
419 415
420 - $stamped = $this->identity->stamp( $meta, $m, $new_id, $response->get_status(), $writer );
421 - if ( is_wp_error( $stamped ) ) {
422 - return $stamped;
416 + // Poison checkpoint, UUID persistence, and finalization remain shared here.
417 + $checkpointed = $this->store->mark_poison( $m['mutationId'], $new_id, $response->get_status() );
418 + $writer->persist( 'create_before_identity', $new_id, $m['payload'] );
419 + $identity_error = null;
420 + if ( ! $this->store->persist_uuid( $meta['id_type'], $new_id, $m['recordId'] ) ) {
421 + $identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
422 + } else {
423 + $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta );
424 + if ( is_wp_error( $resolved ) ) {
425 + $identity_error = $resolved;
426 + } elseif ( $resolved !== $new_id ) {
427 + $identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
428 + }
423 429 }
430 + if ( ! $checkpointed ) {
431 + $this->store->mark_indeterminate( $m['mutationId'], $new_id, $response->get_status() );
432 + return $this->finalize_error();
433 + }
434 + if ( $identity_error ) {
435 + return $identity_error;
436 + }
437 + $writer->persist( 'create_after_identity', $new_id, $m['payload'] );
438 + if ( ! $this->store->finalize_poison( $m['mutationId'], $new_id ) ) {
439 + return $this->finalize_error();
440 + }
424 441 return $this->envelope_document( $this->document_for( $meta, $new_id ), $m['recordId'], $meta, $new_id, $response->get_status(), $writer );
425 442 }
426 443
427 444 /**
@@ -508,9 +525,9 @@
508 525 if ( $response->get_status() >= 400 ) {
509 526 return new WP_REST_Response( $response->get_data(), $response->get_status() );
510 527 }
511 528 $data = $response->get_data();
512 - $writer->after_update( $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] );
529 + $writer->persist( 'update', $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] );
513 530
514 531 $this->store->persist_uuid( $meta['id_type'], $id, $m['recordId'] );
515 532 $finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() );
516 533 if ( is_wp_error( $finalized ) ) {
@@ -582,13 +599,13 @@
582 599 *
583 600 * @param int $id The order id.
584 601 */
585 602 private function can_forward_delete( int $id ): bool {
586 - Permission_Rules::install_wc_filter();
603 + add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 );
587 604 try {
588 605 return (bool) wc_rest_check_post_permissions( 'shop_order', 'delete', $id );
589 606 } finally {
590 - Permission_Rules::uninstall_wc_filter();
607 + remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 );
591 608 }
592 609 }
593 610
594 611 private function checkpoint_and_finalize( string $mutation_id, int $remote_id, int $response_status ) {
@@ -630,8 +647,43 @@
630 647 $writer = $this->writer( $meta );
631 648 return $this->envelope_document( $this->document_for( $meta, $remote_id ), $expected, $meta, $remote_id, $status, $writer );
632 649 }
633 650
651 + private function retry_identity_stamp( array $meta, array $m, array $hit ) {
652 + $remote_id = (int) ( $hit['remote_id'] ?? 0 );
653 + $record_uuid = (string) ( $hit['record_uuid'] ?? '' );
654 + if ( $record_uuid !== $m['recordId'] ) {
655 + return new WP_Error( 'woo_rxdb_sync_identity_conflict', 'recordId disagrees with the stored mutation identity.', array( 'status' => 422 ) );
656 + }
657 + if ( 'create' !== ( $hit['operation'] ?? '' ) || $remote_id <= 0 ) {
658 + return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Created record identity cannot be recovered safely.', array( 'status' => 500 ) );
659 + }
660 + $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta );
661 + if ( is_wp_error( $resolved ) ) {
662 + return $resolved;
663 + }
664 + if ( $resolved > 0 && $resolved !== $remote_id ) {
665 + return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Stored create identity points at a different record.', array( 'status' => 500 ) );
666 + }
667 + if ( ! $this->store->persist_uuid( $meta['id_type'], $remote_id, $record_uuid ) ) {
668 + return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
669 + }
670 + $verified = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta );
671 + if ( is_wp_error( $verified ) ) {
672 + return $verified;
673 + }
674 + if ( $verified !== $remote_id ) {
675 + return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) );
676 + }
677 + $writer = $this->writer( $meta );
678 + $writer->persist( 'create_recovery', $remote_id, $m['payload'] );
679 + if ( ! $this->store->finalize_poison( $m['mutationId'], $remote_id ) ) {
680 + return $this->finalize_error();
681 + }
682 + $status = isset( $hit['response_status'] ) ? (int) $hit['response_status'] : 201;
683 + return $this->envelope_document( $this->document_for( $meta, $remote_id ), $record_uuid, $meta, $remote_id, $status, $writer );
684 + }
685 +
634 686 /**
635 687 * Validate a client-submitted `tax_ids` payload against the v1 schema.
636 688 *
637 689 * tax_ids is unknown to the stock wc/v3 controllers and is stripped before the forward,
@@ -699,9 +751,9 @@
699 751 */
700 752 private function dispatch_write( WP_REST_Request $request ) {
701 753 // Stamp here so direct callers (notably deletes) carry the scope too.
702 754 Store_Scope::stamp( $request );
703 - Permission_Rules::install_wc_filter();
755 + add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 );
704 756 try {
705 757 // Marked as OUR traffic for the duration of the forward, so a consumer
706 758 // keyed on store scope can act on a till write without also claiming
707 759 // every stock wc/v3 product write on the site (pro#425 review).
@@ -710,15 +762,104 @@
710 762 return rest_do_request( $request );
711 763 }
712 764 );
713 765 } finally {
714 - Permission_Rules::uninstall_wc_filter();
766 + remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 );
715 767 }
716 768 }
717 769
718 - /** @deprecated Use Permission_Rules::wc_filter(). */
770 + /**
771 + * Judge a customer edit or delete the way V1\Customers_Controller does.
772 + *
773 + * The staff guard runs first and is final. A target it has cleared is then
774 + * re-judged by WooCommerce with the target's own roles allowed through the
775 + * shop_manager role-name restriction, so a shop manager can edit a subscriber
776 + * from a current app exactly as from the legacy route. WooCommerce's
777 + * credential fence is untouched: it runs in the controller, not here.
778 + *
779 + * @param bool $permission WooCommerce's verdict so far.
780 + * @param string $context 'edit' or 'delete'.
781 + * @param int $target_id Target user ID.
782 + */
783 + private function check_user_permission( bool $permission, string $context, int $target_id ): bool {
784 + if ( $this->rejudging_user_target ) {
785 + return $permission;
786 + }
787 + if ( ! Customer_Account_Guard::can_modify( get_current_user_id(), $target_id ) ) {
788 + return false;
789 + }
790 + if ( $permission ) {
791 + return true;
792 + }
793 + $this->rejudging_user_target = true;
794 + $restore = Customer_Account_Guard::allow_target_roles( $target_id );
795 + try {
796 + return (bool) wc_rest_check_user_permissions( $context, $target_id );
797 + } finally {
798 + $restore();
799 + $this->rejudging_user_target = false;
800 + }
801 + }
802 +
803 + /**
804 + * Authorize proxied mutations for POS users while protecting staff accounts.
805 + *
806 + * This filter is attached only while a sync push is forwarded to wc/v3, so
807 + * direct WooCommerce requests keep their normal permission checks.
808 + *
809 + * @param bool $permission The current permission.
810 + * @param string $context The request context.
811 + * @param int $object_id The object ID.
812 + * @param string $post_type The object type passed by WooCommerce.
813 + *
814 + * @return bool
815 + */
719 816 public function wcpos_check_permissions( $permission, $context, $object_id, $post_type ) {
720 - return Permission_Rules::wc_filter( $permission, $context, $object_id, $post_type, 'writes' );
817 + // Customer edits/deletes: the staff guard is final, then a cleared target
818 + // is judged by WooCommerce the same way the v1 controller judges it.
819 + if ( 'user' === $post_type && (int) $object_id > 0 && \in_array( $context, array( 'edit', 'delete' ), true ) ) {
820 + return $this->check_user_permission( (bool) $permission, $context, (int) $object_id );
821 + }
822 +
823 + // Catalog and coupon WRITES require the user's real WooCommerce
824 + // capabilities — no POS-tier widening. The cashier role is deliberately
825 + // read-only on catalog (Activator), and a blanket grant here handed
826 + // every POS user product deletion and coupon minting. Product decision
827 + // 2026-08-06: strict wc/v3 parity for catalog mutations; only the
828 + // HPOS placeholder remap below (orders) adjusts anything, and it never
829 + // grants beyond the user's own role caps.
830 +
831 + // Orders: with HPOS enabled (sync off), get_post() yields shop_order_placehold
832 + // (map_meta_cap = false, no capability_type), so WooCommerce's REST check maps
833 + // to the generic edit_post/delete_post caps that cashier-tier roles lack —
834 + // even though they hold the real shop_orders caps. Re-check the capability the
835 + // mapping SHOULD have produced, mirroring V1\Orders_Controller's
836 + // update_item_permissions_check fix. No grant beyond the user's own role caps.
837 + if ( ! $permission && 'shop_order' === $post_type ) {
838 + $order_caps = array(
839 + 'read' => 'read_private_shop_orders',
840 + 'create' => 'publish_shop_orders',
841 + 'delete' => 'delete_shop_orders',
842 + );
843 + $order_cap = $order_caps[ $context ] ?? null;
844 + // edit and delete are ownership-sensitive: the base *_shop_orders cap only
845 + // authorizes acting on the user's OWN orders. Touching another user's order
846 + // additionally requires the *_others_shop_orders cap, mirroring WooCommerce's
847 + // own meta-cap map. Without this, a cashier with delete_shop_orders (but not
848 + // delete_others_shop_orders) could delete/void orders they do not own.
849 + if ( \in_array( $context, array( 'edit', 'delete' ), true ) ) {
850 + $order_post = get_post( $object_id );
851 + if ( $order_post ) {
852 + $owns_order = get_current_user_id() === (int) $order_post->post_author;
853 + $order_cap = $owns_order ? "{$context}_shop_orders" : "{$context}_others_shop_orders";
854 + }
855 + }
856 + if ( $order_cap && current_user_can( $order_cap ) ) {
857 + $permission = true;
858 + }
859 + }
860 +
861 + return $permission;
721 862 }
722 863
723 864 /**
724 865 * Read this collection's document for one record, through its writer.