PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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/V1/Orders_Controller.php +76 -213 1.10.131.10.18 View file →
@@ -12,8 +12,9 @@
12 12 if ( ! class_exists( 'WC_REST_Orders_Controller' ) ) {
13 13 return;
14 14 }
15 15
16 +use WCPOS\WooCommercePOS\Services\Permission_Rules;
16 17 use Automattic\WooCommerce\Utilities\OrderUtil;
17 18 use Exception;
18 19 use WC_Abstract_Order;
19 20 use WC_Data;
@@ -24,17 +25,17 @@
24 25 use WC_Order_Item_Product;
25 26 use WC_REST_Orders_Controller;
26 27 use WC_Tax;
27 28 use WCPOS\WooCommercePOS\Logger;
29 +use WCPOS\WooCommercePOS\Services\Order_Write_Intent;
28 30 use WCPOS\WooCommercePOS\Services\Pos_Order_Audit;
29 31 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
30 32 use WCPOS\WooCommercePOS\Services\Stock_Validator;
31 33 use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
32 -use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
33 -use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
34 34 use WCPOS\WooCommercePOS\Sync\Collection_Rules;
35 35 use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan;
36 36 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
37 +use WCPOS\WooCommercePOS\Sync\Order_Write_Payload;
37 38 use const WCPOS\WooCommercePOS\PLUGIN_NAME;
38 39 use const WCPOS\WooCommercePOS\VERSION;
39 40 use WP_Error;
40 41 use WP_REST_Request;
@@ -84,25 +85,26 @@
84 85 */
85 86 protected $wcpos_request;
86 87
87 88 /**
88 - * The order object being created by the current request.
89 + * Whether High Performance Orders is enabled.
89 90 *
90 - * @var WC_Abstract_Order|null
91 + * @var bool
91 92 */
92 - private $creating_order;
93 + private $hpos_enabled = false;
93 94
94 95 /**
95 - * Whether High Performance Orders is enabled.
96 + * Shared date validation and tax-ID persistence for the order write lanes.
96 97 *
97 - * @var bool
98 + * @var Order_Write_Payload
98 99 */
99 - private $hpos_enabled = false;
100 + private $order_payload;
100 101
101 102 /**
102 103 * Constructor.
103 104 */
104 105 public function __construct() {
106 + $this->order_payload = new Order_Write_Payload();
105 107 $this->hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
106 108
107 109 if ( method_exists( parent::class, '__construct' ) ) {
108 110 parent::__construct();
@@ -145,72 +147,22 @@
145 147 $request->set_param( 'set_paid', $set_paid );
146 148 }
147 149 }
148 150
149 - /**
150 - * Check if the current user can update an order.
151 + /** Delegate the edit decision, preserving WooCommerce's request-dependent checks.
151 152 *
152 - * Overrides the parent to fix HPOS compatibility. When HPOS is enabled with
153 - * sync disabled, get_post() returns a shop_order_placehold post type that has
154 - * map_meta_cap = false and no capability_type, causing WordPress to check the
155 - * generic 'edit_post' capability instead of 'edit_shop_order'. Non-admin roles
156 - * like cashier have 'edit_shop_orders' but not the generic 'edit_posts', so the
157 - * permission check fails.
158 - *
159 - * @param WP_REST_Request $request Full details about the request.
160 - *
161 - * @return bool|WP_Error
153 + * @param \WP_REST_Request $request Full request details.
162 154 */
163 155 public function update_item_permissions_check( $request ) {
164 - $result = parent::update_item_permissions_check( $request );
165 -
166 - if ( ! is_wp_error( $result ) ) {
167 - return $result;
168 - }
169 -
170 - // Parent check failed - try direct capability check for HPOS compatibility.
171 - $id = (int) $request['id'];
172 - $order = wc_get_order( $id );
173 -
174 - if ( ! $order ) {
175 - return $result;
176 - }
177 -
178 - if ( ! current_user_can( 'edit_shop_orders' ) ) {
179 - return $result;
180 - }
181 -
182 - return true;
156 + return Permission_Rules::verdict( 'orders', 'edit', (int) $request['id'], 0, 'v1', $request->get_params() );
183 157 }
184 158
185 - /**
186 - * Check if the current user can delete an order.
159 + /** Delegate the delete decision, preserving WooCommerce's request-dependent checks.
187 160 *
188 - * Same HPOS fix as update_item_permissions_check.
189 - *
190 - * @param WP_REST_Request $request Full details about the request.
191 - *
192 - * @return bool|WP_Error
161 + * @param \WP_REST_Request $request Full request details.
193 162 */
194 163 public function delete_item_permissions_check( $request ) {
195 - $result = parent::delete_item_permissions_check( $request );
196 -
197 - if ( ! is_wp_error( $result ) ) {
198 - return $result;
199 - }
200 -
201 - $id = (int) $request['id'];
202 - $order = wc_get_order( $id );
203 -
204 - if ( ! $order ) {
205 - return $result;
206 - }
207 -
208 - if ( ! current_user_can( 'delete_shop_orders' ) ) {
209 - return $result;
210 - }
211 -
212 - return true;
164 + return Permission_Rules::verdict( 'orders', 'delete', (int) $request['id'], 0, 'v1', $request->get_params() );
213 165 }
214 166
215 167 /**
216 168 * Delete a single order.
@@ -336,9 +288,9 @@
336 288 ),
337 289 )
338 290 ),
339 291 ),
340 - 'schema' => array(),
292 + 'schema' => array( $this, 'wcpos_get_public_send_email_schema' ),
341 293 )
342 294 );
343 295
344 296 register_rest_route(
@@ -366,30 +318,9 @@
366 318 $schema['properties']['tax_ids'] = array(
367 319 'description' => __( 'Customer tax IDs snapshotted at sale time.', 'woocommerce-pos' ),
368 320 'type' => 'array',
369 321 'context' => array( 'view', 'edit' ),
370 - 'items' => array(
371 - 'type' => 'object',
372 - 'properties' => array(
373 - 'type' => array(
374 - 'type' => 'string',
375 - 'enum' => Tax_Id_Types::all_types(),
376 - 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID type.', 'woocommerce-pos' ),
377 - ),
378 - 'value' => array(
379 - 'type' => 'string',
380 - 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID value.', 'woocommerce-pos' ),
381 - ),
382 - 'country' => array(
383 - 'type' => array( 'string', 'null' ),
384 - 'description' => __( 'ISO 3166-1 alpha-2 country code.', 'woocommerce-pos' ),
385 - ),
386 - 'label' => array(
387 - 'type' => array( 'string', 'null' ),
388 - 'description' => /* translators: REST API schema field label or error message. */ __( 'Optional human-readable label.', 'woocommerce-pos' ),
389 - ),
390 - ),
391 - ),
322 + 'items' => array( 'type' => 'object' ),
392 323 );
393 324
394 325 // Check and remove email format validation from the billing property.
395 326 if ( isset( $schema['properties']['billing']['properties']['email']['format'] ) ) {
@@ -482,122 +413,48 @@
482 413 if ( isset( $request['meta_data'] ) && \is_array( $request['meta_data'] ) ) {
483 414 $request->set_param( 'meta_data', Pos_Order_Audit::sanitize_create_meta( $request['meta_data'] ) );
484 415 }
485 416
486 - $this->creating_order = null;
417 + $response = Order_Write_Intent::open(
418 + array(
419 + 'operation' => 'create',
420 + 'requested_status' => (string) $request->get_param( 'status' ),
421 + 'set_paid' => $request->has_param( 'set_paid' ) && rest_sanitize_boolean( $request->get_param( 'set_paid' ) ),
422 + ),
423 + function () use ( $request ) {
424 + add_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_preserve_client_created_date_gmt' ), 10, 3 );
425 + try {
426 + // Proceed with the parent method to handle the creation.
427 + return parent::create_item( $request );
428 + } finally {
429 + remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_preserve_client_created_date_gmt' ), 10 );
430 + }
431 + }
432 + );
487 433
488 - add_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_track_creating_order' ), 9, 3 );
489 - add_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_preserve_client_created_date_gmt' ), 10, 3 );
434 + $this->wcpos_refresh_tax_ids_response( $response, $request, true );
490 435
491 - try {
492 - // Proceed with the parent method to handle the creation.
493 - $response = parent::create_item( $request );
494 - } finally {
495 - remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_preserve_client_created_date_gmt' ), 10 );
496 - remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_track_creating_order' ), 9 );
497 - $this->creating_order = null;
498 - }
499 -
500 - $this->wcpos_snapshot_tax_ids_to_order( $response, $request, true );
501 -
502 436 return $response;
503 437 }
504 438
505 439 /**
506 - * Record the exact order object prepared for this create request.
440 + * Preserve the client creation time using the shared payload validator.
507 441 *
508 - * @param WC_Data|WP_Error $order Order object prepared by WooCommerce.
442 + * @param WC_Data|WP_Error $order Prepared order.
509 443 * @param WP_REST_Request $request Request object.
510 - * @param bool $creating Whether a new order is being created.
511 - *
444 + * @param bool $creating Whether this is a create.
512 445 * @return WC_Data|WP_Error
513 446 */
514 - public function wcpos_track_creating_order( $order, WP_REST_Request $request, bool $creating ) {
515 - if ( $creating && $order instanceof WC_Abstract_Order ) {
516 - $this->creating_order = $order;
517 - }
518 -
519 - return $order;
520 - }
521 -
522 - /**
523 - * Preserve client-provided order creation time for offline-created orders.
524 - *
525 - * WooCommerce marks date_created/date_created_gmt as read-only in the REST
526 - * schema, so those fields are removed before the parent controller prepares
527 - * the order. WCPOS clients can create orders offline and later sync the full
528 - * local document; read the raw JSON payload here so the server keeps the
529 - * transaction time instead of the sync time.
530 - *
531 - * @param WC_Data|WP_Error $order Order object prepared by WooCommerce.
532 - * @param WP_REST_Request $request Request object.
533 - * @param bool $creating Whether a new order is being created.
534 - *
535 - * @return WC_Data|WP_Error
536 - */
537 447 public function wcpos_preserve_client_created_date_gmt( $order, WP_REST_Request $request, bool $creating ) {
538 448 if ( ! $creating || ! ( $order instanceof WC_Abstract_Order ) ) {
539 449 return $order;
540 450 }
541 - $this->creating_order = $order;
542 -
543 451 $body = $request->get_json_params();
544 -
545 - if ( ! isset( $body['date_created_gmt'] ) ) {
546 - return $order;
452 + $timestamp = $this->order_payload->validate_client_created_gmt( is_array( $body ) ? $body : array() );
453 + if ( is_wp_error( $timestamp ) || null === $timestamp ) {
454 + return is_wp_error( $timestamp ) ? $timestamp : $order;
547 455 }
548 -
549 - if ( ! is_scalar( $body['date_created_gmt'] ) ) {
550 - return new WP_Error(
551 - 'woocommerce_pos_rest_invalid_date_created_gmt',
552 - __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ),
553 - array( 'status' => 400 )
554 - );
555 - }
556 -
557 - $client_date_gmt = wc_clean( wp_unslash( (string) $body['date_created_gmt'] ) );
558 -
559 - if ( '' === $client_date_gmt ) {
560 - return $order;
561 - }
562 -
563 - if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $client_date_gmt ) ) {
564 - return new WP_Error(
565 - 'woocommerce_pos_rest_invalid_date_created_gmt',
566 - __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ),
567 - array( 'status' => 400 )
568 - );
569 - }
570 -
571 - // WooCommerce serializes *_gmt fields without a timezone suffix; treat bare values as UTC.
572 - $parse_date_gmt = 'Z' === strtoupper( substr( $client_date_gmt, -1 ) )
573 - ? $client_date_gmt
574 - : $client_date_gmt . 'Z';
575 - $timestamp = rest_parse_date(
576 - $parse_date_gmt,
577 - true
578 - );
579 -
580 - if ( false === $timestamp ) {
581 - return new WP_Error(
582 - 'woocommerce_pos_rest_invalid_date_created_gmt',
583 - __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ),
584 - array( 'status' => 400 )
585 - );
586 - }
587 -
588 - $maximum_future_timestamp = time() + DAY_IN_SECONDS;
589 -
590 - if ( $timestamp > $maximum_future_timestamp ) {
591 - return new WP_Error(
592 - 'woocommerce_pos_rest_future_date_created_gmt',
593 - __( 'date_created_gmt cannot be more than 24 hours in the future.', 'woocommerce-pos' ),
594 - array( 'status' => 400 )
595 - );
596 - }
597 -
598 456 $order->set_date_created( $timestamp );
599 -
600 457 return $order;
601 458 }
602 459
603 460 /**
@@ -633,28 +490,21 @@
633 490 }
634 491
635 492 // Proceed with the parent method to handle the update.
636 493 $response = parent::update_item( $request );
637 - $this->wcpos_snapshot_tax_ids_to_order( $response, $request, false );
494 + $this->wcpos_refresh_tax_ids_response( $response, $request, false );
638 495
639 496 return $response;
640 497 }
641 498
642 499 /**
643 - * Persist tax_ids onto the order.
500 + * Adapt the parent response to the shared snapshot and refresh its tax_ids.
644 501 *
645 - * On create: if the request did not provide `tax_ids`, snapshot from the
646 - * resolved customer record so the order is self-contained. If the request
647 - * provided `tax_ids`, write those (cashier-entered tax IDs override).
648 - *
649 - * On update: only write what the request explicitly provided; never
650 - * re-snapshot, since editing a customer must not mutate historical orders.
651 - *
652 502 * @param mixed $response Response from parent controller.
653 503 * @param WP_REST_Request $request Original request.
654 504 * @param bool $is_create True for create, false for update.
655 505 */
656 - protected function wcpos_snapshot_tax_ids_to_order( $response, WP_REST_Request $request, bool $is_create ): void {
506 + private function wcpos_refresh_tax_ids_response( $response, WP_REST_Request $request, bool $is_create ): void {
657 507 if ( ! ( $response instanceof WP_REST_Response ) ) {
658 508 return;
659 509 }
660 510
@@ -662,27 +512,13 @@
662 512 $order_id = isset( $data['id'] ) ? (int) $data['id'] : 0;
663 513 if ( $order_id <= 0 ) {
664 514 return;
665 515 }
666 - $order = \wc_get_order( $order_id );
667 - if ( ! $order ) {
668 - return;
516 + $tax_ids = $this->order_payload->persist_tax_ids( $order_id, $request->get_params(), $is_create );
517 + if ( null !== $tax_ids ) {
518 + $data['tax_ids'] = $tax_ids;
519 + $response->set_data( $data );
669 520 }
670 -
671 - $tax_ids = $request->get_param( 'tax_ids' );
672 - $writer = new Tax_Id_Writer();
673 -
674 - if ( \is_array( $tax_ids ) ) {
675 - $writer->write_for_order( $order, $tax_ids );
676 - } elseif ( $is_create ) {
677 - $customer_id = (int) $order->get_customer_id();
678 - if ( $customer_id > 0 ) {
679 - $writer->snapshot_from_user_to_order( $order, $customer_id );
680 - }
681 - }
682 -
683 - $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_order( $order );
684 - $response->set_data( $data );
685 521 }
686 522
687 523 /**
688 524 * Create or update a line item.
@@ -1019,8 +855,34 @@
1019 855 );
1020 856 }
1021 857
1022 858 /**
859 + * Get the route schema for the send-email action.
860 + *
861 + * Registered as the route-level `schema` callback. WordPress invokes it with
862 + * `call_user_func()` whenever a namespace index is requested with
863 + * `context=help`, so it must be a real callable — an empty array there
864 + * passes `isset()` and then fatals with a TypeError.
865 + *
866 + * @return array
867 + */
868 + public function wcpos_get_public_send_email_schema() {
869 + return array(
870 + '$schema' => 'http://json-schema.org/draft-04/schema#',
871 + 'title' => 'order_email',
872 + 'type' => 'object',
873 + 'properties' => array(
874 + 'success' => array(
875 + 'description' => __( 'Whether the order email was sent.', 'woocommerce-pos' ),
876 + 'type' => 'boolean',
877 + 'context' => array( 'view', 'edit' ),
878 + 'readonly' => true,
879 + ),
880 + ),
881 + );
882 + }
883 +
884 + /**
1023 885 * Modify the order response.
1024 886 *
1025 887 * @param WP_REST_Response $response The response object.
1026 888 * @param WC_Abstract_Order $order Object data.
@@ -1150,9 +1012,10 @@
1150 1012 *
1151 1013 * @throws \WC_Data_Exception If order data is invalid.
1152 1014 */
1153 1015 public function wcpos_before_order_object_save( WC_Abstract_Order $order ): void {
1154 - $is_creating_order = $order === $this->creating_order;
1016 + $intent = Order_Write_Intent::current();
1017 + $is_creating_order = null !== $intent && $intent->is_create() && $intent->is_subject( $order );
1155 1018
1156 1019 if ( $is_creating_order && method_exists( $order, 'set_created_via' ) ) {
1157 1020 $order->set_created_via( PLUGIN_NAME );
1158 1021 // Record provenance only; receipt calculations continue to infer historical