PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.14
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.14
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 / Orders_Controller.php

Orders_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.9.14, at includes/API/Orders_Controller.php

1,444 lines 47.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Orders_Controller.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\API;
9
10 \defined( 'ABSPATH' ) || die;
11
12 if ( ! class_exists( 'WC_REST_Orders_Controller' ) ) {
13 return;
14 }
15
16 use Automattic\WooCommerce\Utilities\OrderUtil;
17 use Exception;
18 use WC_Abstract_Order;
19 use WC_Data;
20 use WC_Email_Customer_Invoice;
21 use WC_Order_Item;
22 use WC_Order_Item_Fee;
23 use WC_Order_Item_Product;
24 use WC_REST_Orders_Controller;
25 use WC_Tax;
26 use WCPOS\WooCommercePOS\Logger;
27 use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
28 use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
29 use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
30 use const WCPOS\WooCommercePOS\PLUGIN_NAME;
31 use const WCPOS\WooCommercePOS\VERSION;
32 use WP_Error;
33 use WP_REST_Request;
34 use WP_REST_Response;
35
36 use WP_Query;
37 use WP_REST_Server;
38
39 /**
40 * Orders controller class.
41 *
42 * @NOTE: methods not prefixed with wcpos_ will override WC_REST_Orders_Controller methods
43 */
44 class Orders_Controller extends WC_REST_Orders_Controller {
45 use Traits\Uuid_Handler;
46 use Traits\WCPOS_REST_API;
47
48 /**
49 * Endpoint namespace.
50 *
51 * @var string
52 */
53 protected $namespace = 'wcpos/v1';
54
55 /**
56 * Store the request object for use in lifecycle methods.
57 *
58 * @var WP_REST_Request|null
59 */
60 protected $wcpos_request;
61
62 /**
63 * Whether we are creating a new order.
64 *
65 * @var bool
66 */
67 private $is_creating = false;
68
69 /**
70 * Whether High Performance Orders is enabled.
71 *
72 * @var bool
73 */
74 private $hpos_enabled = false;
75
76 /**
77 * Constructor.
78 */
79 public function __construct() {
80 $this->hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
81
82 if ( method_exists( parent::class, '__construct' ) ) {
83 parent::__construct();
84 }
85 }
86
87 /**
88 * Check if the current user can update an order.
89 *
90 * Overrides the parent to fix HPOS compatibility. When HPOS is enabled with
91 * sync disabled, get_post() returns a shop_order_placehold post type that has
92 * map_meta_cap = false and no capability_type, causing WordPress to check the
93 * generic 'edit_post' capability instead of 'edit_shop_order'. Non-admin roles
94 * like cashier have 'edit_shop_orders' but not the generic 'edit_posts', so the
95 * permission check fails.
96 *
97 * @param WP_REST_Request $request Full details about the request.
98 *
99 * @return bool|WP_Error
100 */
101 public function update_item_permissions_check( $request ) {
102 $result = parent::update_item_permissions_check( $request );
103
104 if ( ! is_wp_error( $result ) ) {
105 return $result;
106 }
107
108 // Parent check failed - try direct capability check for HPOS compatibility.
109 $id = (int) $request['id'];
110 $order = wc_get_order( $id );
111
112 if ( ! $order ) {
113 return $result;
114 }
115
116 if ( ! current_user_can( 'edit_shop_orders' ) ) {
117 return $result;
118 }
119
120 return true;
121 }
122
123 /**
124 * Check if the current user can delete an order.
125 *
126 * Same HPOS fix as update_item_permissions_check.
127 *
128 * @param WP_REST_Request $request Full details about the request.
129 *
130 * @return bool|WP_Error
131 */
132 public function delete_item_permissions_check( $request ) {
133 $result = parent::delete_item_permissions_check( $request );
134
135 if ( ! is_wp_error( $result ) ) {
136 return $result;
137 }
138
139 $id = (int) $request['id'];
140 $order = wc_get_order( $id );
141
142 if ( ! $order ) {
143 return $result;
144 }
145
146 if ( ! current_user_can( 'delete_shop_orders' ) ) {
147 return $result;
148 }
149
150 return true;
151 }
152
153 /**
154 * Delete a single order.
155 *
156 * WooCommerce core does not restore stock when orders are trashed or deleted.
157 * This override restores stock on successful deletion.
158 *
159 * @see https://github.com/woocommerce/woocommerce/issues/26716
160 *
161 * @param WP_REST_Request $request Full details about the request.
162 *
163 * @return WP_REST_Response|WP_Error
164 */
165 public function delete_item( $request ) {
166 $order_id = (int) $request['id'];
167 $order = wc_get_order( $order_id );
168
169 if ( ! $order ) {
170 return parent::delete_item( $request );
171 }
172
173 $setting = woocommerce_pos_get_settings( 'general', 'restore_stock_on_delete' );
174
175 /**
176 * Filter whether to restore stock when an order is deleted via the POS API.
177 *
178 * @since 1.9.0
179 *
180 * @param bool $restore_stock Whether to restore stock. Default from settings.
181 * @param int $order_id The order ID being deleted.
182 */
183 $restore_stock = apply_filters( 'woocommerce_pos_restore_stock_on_delete', (bool) $setting, $order_id );
184 $force = (bool) $request->get_param( 'force' );
185
186 // Force-delete permanently removes the order, so restore stock beforehand.
187 if ( $restore_stock && $force ) {
188 wc_maybe_increase_stock_levels( $order_id );
189 }
190
191 $response = parent::delete_item( $request );
192
193 if ( is_wp_error( $response ) ) {
194 // Rollback pre-restore on force-delete failure.
195 if ( $restore_stock && $force ) {
196 wc_maybe_reduce_stock_levels( $order_id );
197 }
198
199 return $response;
200 }
201
202 // Trash path: order still exists, so restore stock after confirmed success.
203 if ( $restore_stock && ! $force ) {
204 wc_maybe_increase_stock_levels( $order_id );
205 }
206
207 return $response;
208 }
209
210 /**
211 * Dispatch request to parent controller, or override if needed.
212 *
213 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
214 * @param WP_REST_Request $request Request used to generate the response.
215 * @param string $route Route matched for the request.
216 * @param array $handler Route handler used for the request.
217 */
218 public function wcpos_dispatch_request( $dispatch_result, WP_REST_Request $request, $route, $handler ) {
219 /*
220 * Force decimal rounding to 6 places for all order data. This matches the POS.
221 *
222 * @TODO - should this be flexible via a query param from the POS?
223 */
224 $request->set_param( 'dp', '6' );
225
226 $this->wcpos_request = $request;
227 // set hpos_enabled again for tests to work @TODO - fix this.
228 $this->hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
229
230 add_filter( 'woocommerce_rest_prepare_shop_order_object', array( $this, 'wcpos_order_response' ), 10, 3 );
231 add_filter( 'woocommerce_order_get_items', array( $this, 'wcpos_order_get_items' ), 10, 3 );
232 add_action( 'woocommerce_before_order_object_save', array( $this, 'wcpos_before_order_object_save' ), 10, 1 );
233 add_filter( 'woocommerce_rest_shop_order_object_query', array( $this, 'wcpos_shop_order_query' ), 10, 2 );
234 add_action( 'woocommerce_order_item_fee_after_calculate_taxes', array( $this, 'wcpos_order_item_fee_after_calculate_taxes' ), 10, 2 );
235
236 /*
237 * Check if the request is for all orders and if the 'posts_per_page' is set to -1.
238 * Optimised query for getting all order IDs.
239 */
240 if ( -1 == $request->get_param( 'posts_per_page' ) && null !== $request->get_param( 'fields' ) ) {
241 return $this->wcpos_get_all_posts( $request );
242 }
243
244 return $dispatch_result;
245 }
246
247 /**
248 * Register routes.
249 */
250 public function register_routes(): void {
251 parent::register_routes();
252
253 register_rest_route(
254 $this->namespace,
255 '/' . $this->rest_base . '/(?P<order_id>[\d]+)/email',
256 array(
257 array(
258 'methods' => WP_REST_Server::CREATABLE,
259 'callback' => array( $this, 'wcpos_send_email' ),
260 'permission_callback' => array( $this, 'wcpos_send_email_permissions_check' ),
261 'args' => array_merge(
262 $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
263 array(
264 'email' => array(
265 'type' => 'string',
266 'description' => /* translators: REST API schema field label or error message. */ __( 'Email address', 'woocommerce-pos' ),
267 'required' => true,
268 ),
269 'save_to' => array(
270 'type' => 'string',
271 'description' => __( 'Save email to order', 'woocommerce-pos' ),
272 'required' => false,
273 ),
274 )
275 ),
276 ),
277 'schema' => array(),
278 )
279 );
280
281 register_rest_route(
282 $this->namespace,
283 '/' . $this->rest_base . '/statuses',
284 array(
285 array(
286 'methods' => WP_REST_Server::READABLE,
287 'callback' => array( $this, 'wcpos_get_order_statuses' ),
288 'permission_callback' => array( $this, 'get_item_permissions_check' ),
289 ),
290 'schema' => array( $this, 'wcpos_get_public_order_statuses_schema' ),
291 )
292 );
293 }
294
295 /**
296 * Add custom fields to the order schema.
297 */
298 public function get_item_schema() {
299 $schema = parent::get_item_schema();
300
301 // Add barcode property to the schema.
302 $schema['properties']['barcode'] = array(
303 'description' => /* translators: REST API schema field label or error message. */ __( 'Barcode', 'woocommerce-pos' ),
304 'type' => 'string',
305 'context' => array( 'view', 'edit' ),
306 'readonly' => false,
307 );
308
309 // Add structured tax_ids property (TaxId[]) snapshotted from the customer
310 // at create time. Editable via update for corrections.
311 $schema['properties']['tax_ids'] = array(
312 'description' => __( 'Customer tax IDs snapshotted at sale time.', 'woocommerce-pos' ),
313 'type' => 'array',
314 'context' => array( 'view', 'edit' ),
315 'items' => array(
316 'type' => 'object',
317 'properties' => array(
318 'type' => array(
319 'type' => 'string',
320 'enum' => Tax_Id_Types::all_types(),
321 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID type.', 'woocommerce-pos' ),
322 ),
323 'value' => array(
324 'type' => 'string',
325 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID value.', 'woocommerce-pos' ),
326 ),
327 'country' => array(
328 'type' => array( 'string', 'null' ),
329 'description' => __( 'ISO 3166-1 alpha-2 country code.', 'woocommerce-pos' ),
330 ),
331 'label' => array(
332 'type' => array( 'string', 'null' ),
333 'description' => /* translators: REST API schema field label or error message. */ __( 'Optional human-readable label.', 'woocommerce-pos' ),
334 ),
335 ),
336 ),
337 );
338
339 // Check and remove email format validation from the billing property.
340 if ( isset( $schema['properties']['billing']['properties']['email']['format'] ) ) {
341 unset( $schema['properties']['billing']['properties']['email']['format'] );
342 }
343
344 // Modify line_items->parent_name to accept 'string' or 'null'.
345 if ( isset( $schema['properties']['line_items'] ) &&
346 \is_array( $schema['properties']['line_items']['items']['properties'] ) ) {
347 $schema['properties']['line_items']['items']['properties']['parent_name']['type'] = array( 'string', 'null' );
348 }
349
350 // Check for 'stock_quantity' and allow decimal.
351 if ( $this->wcpos_allow_decimal_quantities() &&
352 isset( $schema['properties']['line_items'] ) &&
353 \is_array( $schema['properties']['line_items']['items']['properties'] ) ) {
354 $schema['properties']['line_items']['items']['properties']['quantity']['type'] = array( 'number' );
355 }
356
357 return $schema;
358 }
359
360
361 /**
362 * Create a single order.
363 * - Validate billing email.
364 * - Do a sanity check on the UUID, if the internet connection is bad, several requests can be made with the same UUID.
365 *
366 * @param WP_REST_Request $request Full details about the request.
367 *
368 * @return WP_Error|WP_REST_Response
369 */
370 public function create_item( $request ) {
371 // check if the UUID is already in use.
372 if ( isset( $request['meta_data'] ) && \is_array( $request['meta_data'] ) ) {
373 foreach ( $request['meta_data'] as $meta ) {
374 if ( '_woocommerce_pos_uuid' === $meta['key'] ) {
375 $uuid = $meta['value'];
376 $ids = $this->get_order_ids_by_uuid( $uuid );
377
378 /*
379 * If the UUID is already in use, and there is only one order with that UUID, return the existing order.
380 * This can happen if the internet connection is bad and the request is made several times.
381 *
382 * @NOTE: This means that $request data is lost, but we can't update existing order because it has resource ids now.
383 * The alternative would be to update the existing order, but that would require a lot of extra work.
384 * Or return an error, which would be a bad user experience.
385 */
386 if ( 1 === \count( $ids ) ) {
387 $order_id = (int) $ids[0];
388 Logger::log( 'UUID already in use, return existing order.', $order_id );
389
390 // Pre-flight: check meta count before WC loads the full order.
391 $meta_count = $this->wcpos_preflight_meta_count( $order_id, 'order' );
392 $error_threshold = (int) apply_filters( 'woocommerce_pos_meta_data_error_threshold', 500 );
393
394 if ( $meta_count >= $error_threshold ) {
395 return $this->wcpos_build_safe_order_response( $order_id, $meta_count );
396 }
397
398 // Create a new WP_REST_Request object for the GET request.
399 $get_request = new WP_REST_Request( 'GET', $this->namespace . '/' . $this->rest_base . '/' . $order_id );
400 $get_request->set_param( 'id', $order_id );
401
402 return $this->get_item( $get_request );
403 }
404 if ( \count( $ids ) > 1 ) {
405 Logger::log( 'UUID already in use for multiple orders. This should not happen.', $ids );
406
407 return new WP_Error( 'woocommerce_rest_order_invalid_id', __( 'UUID already in use.', 'woocommerce' ), array( 'status' => 400 ) );
408 }
409 }
410 }
411 }
412
413 $valid_email = $this->wcpos_validate_billing_email( $request );
414 if ( is_wp_error( $valid_email ) ) {
415 return $valid_email;
416 }
417
418 // Set the creating flag, used in woocommerce_before_order_object_save.
419 $this->is_creating = true;
420
421 add_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_preserve_client_created_date_gmt' ), 10, 3 );
422
423 try {
424 // Proceed with the parent method to handle the creation.
425 $response = parent::create_item( $request );
426 } finally {
427 remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'wcpos_preserve_client_created_date_gmt' ), 10 );
428 $this->is_creating = false;
429 }
430
431 $this->wcpos_snapshot_tax_ids_to_order( $response, $request, true );
432
433 return $response;
434 }
435
436 /**
437 * Preserve client-provided order creation time for offline-created orders.
438 *
439 * WooCommerce marks date_created/date_created_gmt as read-only in the REST
440 * schema, so those fields are removed before the parent controller prepares
441 * the order. WCPOS clients can create orders offline and later sync the full
442 * local document; read the raw JSON payload here so the server keeps the
443 * transaction time instead of the sync time.
444 *
445 * @param WC_Data|WP_Error $order Order object prepared by WooCommerce.
446 * @param WP_REST_Request $request Request object.
447 * @param bool $creating Whether a new order is being created.
448 *
449 * @return WC_Data|WP_Error
450 */
451 public function wcpos_preserve_client_created_date_gmt( $order, WP_REST_Request $request, bool $creating ) {
452 if ( ! $creating || is_wp_error( $order ) ) {
453 return $order;
454 }
455
456 $body = $request->get_json_params();
457
458 if ( ! isset( $body['date_created_gmt'] ) ) {
459 return $order;
460 }
461
462 if ( ! is_scalar( $body['date_created_gmt'] ) ) {
463 return new WP_Error(
464 'woocommerce_pos_rest_invalid_date_created_gmt',
465 __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ),
466 array( 'status' => 400 )
467 );
468 }
469
470 $client_date_gmt = wc_clean( wp_unslash( (string) $body['date_created_gmt'] ) );
471
472 if ( '' === $client_date_gmt ) {
473 return $order;
474 }
475
476 if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $client_date_gmt ) ) {
477 return new WP_Error(
478 'woocommerce_pos_rest_invalid_date_created_gmt',
479 __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ),
480 array( 'status' => 400 )
481 );
482 }
483
484 // WooCommerce serializes *_gmt fields without a timezone suffix; treat bare values as UTC.
485 $parse_date_gmt = 'Z' === strtoupper( substr( $client_date_gmt, -1 ) )
486 ? $client_date_gmt
487 : $client_date_gmt . 'Z';
488 $timestamp = rest_parse_date(
489 $parse_date_gmt,
490 true
491 );
492
493 if ( false === $timestamp ) {
494 return new WP_Error(
495 'woocommerce_pos_rest_invalid_date_created_gmt',
496 __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ),
497 array( 'status' => 400 )
498 );
499 }
500
501 $maximum_future_timestamp = time() + DAY_IN_SECONDS;
502
503 if ( $timestamp > $maximum_future_timestamp ) {
504 return new WP_Error(
505 'woocommerce_pos_rest_future_date_created_gmt',
506 __( 'date_created_gmt cannot be more than 24 hours in the future.', 'woocommerce-pos' ),
507 array( 'status' => 400 )
508 );
509 }
510
511 $order->set_date_created( $timestamp );
512
513 return $order;
514 }
515
516 /**
517 * Update a single order.
518 *
519 * @param WP_REST_Request $request Full details about the request.
520 *
521 * @return WP_Error|WP_REST_Response
522 */
523 public function update_item( $request ) {
524 $valid_email = $this->wcpos_validate_billing_email( $request );
525 if ( is_wp_error( $valid_email ) ) {
526 return $valid_email;
527 }
528
529 // Proceed with the parent method to handle the update.
530 $response = parent::update_item( $request );
531 $this->wcpos_snapshot_tax_ids_to_order( $response, $request, false );
532
533 return $response;
534 }
535
536 /**
537 * Persist tax_ids onto the order.
538 *
539 * On create: if the request did not provide `tax_ids`, snapshot from the
540 * resolved customer record so the order is self-contained. If the request
541 * provided `tax_ids`, write those (cashier-entered tax IDs override).
542 *
543 * On update: only write what the request explicitly provided; never
544 * re-snapshot, since editing a customer must not mutate historical orders.
545 *
546 * @param mixed $response Response from parent controller.
547 * @param WP_REST_Request $request Original request.
548 * @param bool $is_create True for create, false for update.
549 */
550 protected function wcpos_snapshot_tax_ids_to_order( $response, WP_REST_Request $request, bool $is_create ): void {
551 if ( ! ( $response instanceof WP_REST_Response ) ) {
552 return;
553 }
554
555 $data = $response->get_data();
556 $order_id = isset( $data['id'] ) ? (int) $data['id'] : 0;
557 if ( $order_id <= 0 ) {
558 return;
559 }
560 $order = \wc_get_order( $order_id );
561 if ( ! $order ) {
562 return;
563 }
564
565 $tax_ids = $request->get_param( 'tax_ids' );
566 $writer = new Tax_Id_Writer();
567
568 if ( \is_array( $tax_ids ) ) {
569 $writer->write_for_order( $order, $tax_ids );
570 } elseif ( $is_create ) {
571 $customer_id = (int) $order->get_customer_id();
572 if ( $customer_id > 0 ) {
573 $writer->snapshot_from_user_to_order( $order, $customer_id );
574 }
575 }
576
577 $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_order( $order );
578 $response->set_data( $data );
579 }
580
581 /**
582 * Create or update a line item.
583 *
584 * @param array $posted Line item data.
585 * @param string $action 'create' to add line item or 'update' to update it.
586 * @param object $item Passed when updating an item. Null during creation.
587 *
588 * @throws \WC_REST_Exception Invalid data, server error.
589 *
590 * @return WC_Order_Item_Product
591 */
592 public function prepare_line_items( $posted, $action = 'create', $item = null ) {
593 $item = parent::prepare_line_items( $posted, $action, $item );
594
595 /**
596 * If you send a variation with meta_data, the meta_data will be duplicated
597 * WooCommerce attempts to delete the duped meta_data in $item->set_product( $variation )
598 * but later it gets added right back in $this->maybe_set_item_meta_data.
599 *
600 * To fix this we check for a variation_id and remove the meta_data before setting the product
601 */
602 if ( 'create' !== $action && $item->get_variation_id() ) {
603 $attributes = wc_get_product_variation_attributes( $item->get_variation_id() );
604
605 // Loop through attributes and remove any duplicates.
606 foreach ( $attributes as $key => $value ) {
607 $attribute = str_replace( 'attribute_', '', $key );
608 $meta_data = $item->get_meta( $attribute, false );
609
610 if ( \is_array( $meta_data ) && \count( $meta_data ) > 1 ) {
611 $meta_to_keep = null;
612
613 // Check each meta to find one with an ID to keep.
614 foreach ( $meta_data as $meta ) {
615 if ( isset( $meta->id ) ) {
616 $meta_to_keep = $meta;
617
618 break;
619 }
620 }
621
622 // If no meta with an ID is found, keep the first one.
623 if ( ! $meta_to_keep ) {
624 $meta_to_keep = $meta_data[0];
625 }
626
627 // Remove all other meta data for this attribute.
628 foreach ( $meta_data as $meta ) {
629 if ( $meta !== $meta_to_keep ) {
630 if ( $meta->id ) {
631 $item->delete_meta_data_by_mid( $meta->id );
632 } else {
633 $meta->value = null;
634 }
635 }
636 }
637 }
638 }
639 }
640
641 return $item;
642 }
643
644 /**
645 * Maybe set item meta if posted.
646 *
647 * @param WC_Order_Item $item Order item data.
648 * @param array $posted Request data.
649 */
650 public function maybe_set_item_meta_data( $item, $posted ): void {
651 /*
652 * Call the parent method first to handle standard meta data
653 * This will populate the attribute key, eg: 'pa_color' or 'logo'
654 * BUT: if the attribute can be 'any' then we need to handle that
655 */
656 parent::maybe_set_item_meta_data( $item, $posted );
657
658 // Ensure this is a product line item, not a fee or shipping.
659 if ( ! \is_object( $item ) || 'WC_Order_Item_Product' !== \get_class( $item ) ) {
660 return;
661 }
662
663 // SKU meta is not stored by default, we will add it for 'miscellaneous' products.
664 if ( isset( $posted['sku'] ) && 0 === $item->get_product_id() ) {
665 $item->add_meta_data( '_sku', $posted['sku'], true );
666 }
667
668 // Only proceed if there's a variation ID and we have posted meta.
669 if ( ! $item->get_variation_id() || empty( $posted['meta_data'] ) || ! \is_array( $posted['meta_data'] ) ) {
670 return;
671 }
672
673 $attributes = wc_get_product_variation_attributes( $item->get_variation_id() );
674 $product_id = $item->get_product_id();
675 $product = wc_get_product( $product_id );
676 $parent_attributes = $product->get_attributes();
677
678 foreach ( $attributes as $key => $value ) {
679 if ( '' === $value ) {
680 $slug = str_replace( 'attribute_', '', $key );
681
682 if ( ! isset( $parent_attributes[ $slug ] ) ) {
683 continue;
684 }
685
686 $name = $parent_attributes[ $slug ]['name'] ?? $slug;
687 if ( $name === $slug ) {
688 $name = wc_attribute_label( $slug );
689 }
690
691 // find the value from $posted['meta_data'].
692 foreach ( $posted['meta_data'] as $meta ) {
693 // Match posted attribute label to the $name we just determined.
694 if ( isset( $meta['display_key'], $meta['display_value'] ) && $meta['display_key'] === $name ) {
695 $posted_value = $meta['display_value'];
696 // Only update if the posted value is non-empty.
697 if ( $posted_value ) {
698 $item->update_meta_data(
699 $slug,
700 $posted_value,
701 $meta['id'] ?? ''
702 );
703
704 break; // Stop searching once found.
705 }
706 }
707 }
708 }
709 }
710 }
711
712 /**
713 * The way WooCommerce handles negative fees is ... weird.
714 * They by-pass the normal tax calculation, disregard the tax_status and tax_class, and apply the taxes to the fee line.
715 * This is a problem because if people want to apply a negative fee to an order, and set tax_status to 'none', it will give
716 * the wrong result.
717 *
718 * @param \WC_Order_Item_Fee $fee_item The fee item.
719 * @param array $calculate_tax_for The tax calculation data.
720 */
721 public function wcpos_order_item_fee_after_calculate_taxes( $fee_item, $calculate_tax_for ): void {
722 if ( $fee_item->get_total() < 0 ) {
723 // Respect the fee line's tax_class and tax_status.
724 $tax_class = $fee_item->get_tax_class();
725 $tax_status = $fee_item->get_tax_status();
726
727 if ( 'taxable' === $tax_status ) {
728 // Use the tax_class if set, otherwise default.
729 $calculate_tax_for['tax_class'] = $tax_class ? $tax_class : '';
730
731 // Find rates and calculate taxes for the fee.
732 $tax_rates = WC_Tax::find_rates( $calculate_tax_for );
733 $discount_taxes = WC_Tax::calc_tax( (float) $fee_item->get_total(), $tax_rates );
734
735 // Apply calculated taxes to the fee item.
736 $fee_item->set_taxes( array( 'total' => $discount_taxes ) );
737 } else {
738 // Set taxes to none if tax_status is 'none'.
739 $fee_item->set_taxes( array() );
740 }
741
742 // Save the updated fee item.
743 $fee_item->save();
744 }
745 }
746
747 /**
748 * Gets the product ID from posted ID.
749 *
750 * @param array $posted Request data.
751 * @param string $action 'create' to add line item or 'update' to update it.
752 *
753 * @throws WC_REST_Exception When SKU or ID is not valid.
754 *
755 * @return int
756 */
757 public function get_product_id( $posted, $action = 'create' ) {
758 // If id = 0, ie: miscellaneaous product, just return 0.
759 if ( isset( $posted['product_id'] ) && 0 == $posted['product_id'] ) {
760 return 0;
761 }
762
763 // Bypass the sku check. Some users have products with duplicated SKUs, esp. variable/variations.
764 $data = $posted;
765 unset( $data['sku'] );
766
767 return parent::get_product_id( $data, $action );
768 }
769
770 /**
771 * Validate billing email.
772 * NOTE: we have removed the format check to allow empty email addresses.
773 *
774 * @param WP_REST_Request $request Full details about the request.
775 *
776 * @return bool|WP_Error
777 */
778 public function wcpos_validate_billing_email( WP_REST_Request $request ) {
779 $billing = $request['billing'] ?? null;
780 $email = \is_array( $billing ) ? ( $billing['email'] ?? null ) : null;
781
782 if ( ! \is_null( $email ) && '' !== $email && ! is_email( $email ) ) {
783 return new WP_Error(
784 'rest_invalid_param',
785 // translators: Use default WordPress translation.
786 __( 'Invalid email address.', 'woocommerce-pos' ),
787 array( 'status' => 400 )
788 );
789 }
790
791 return true;
792 }
793
794 /**
795 * Modify the collection params.
796 */
797 public function get_collection_params() {
798 $params = parent::get_collection_params();
799
800 // Ensure 'per_page' is an array and has a 'minimum' key.
801 if ( isset( $params['per_page'] ) && \is_array( $params['per_page'] ) ) {
802 $params['per_page']['minimum'] = -1;
803 }
804
805 // Ensure 'orderby' is an array and has an 'enum' key that is also an array.
806 if ( isset( $params['orderby'] ) && \is_array( $params['orderby'] ) && isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) {
807 $params['orderby']['enum'] = array_merge(
808 $params['orderby']['enum'],
809 array( 'status', 'customer_id', 'payment_method', 'total' )
810 );
811 }
812
813 // Add 'pos_cashier' parameter.
814 $params['pos_cashier'] = array(
815 'description' => /* translators: REST API schema field label or error message. */ __( 'Filter orders by POS cashier.', 'woocommerce-pos' ),
816 'type' => 'integer',
817 'required' => false,
818 );
819
820 // Add 'pos_store' parameter
821 // @NOTE - this is different to 'store_id' which is the store the request was made from.
822 $params['pos_store'] = array(
823 'description' => /* translators: REST API schema field label or error message. */ __( 'Filter orders by POS store.', 'woocommerce-pos' ),
824 'type' => 'integer',
825 'required' => false,
826 );
827
828 return $params;
829 }
830
831 /**
832 * Send order email, optionally add email address.
833 *
834 * @param WP_REST_Request $request Full details about the request.
835 *
836 * @return WP_Error|WP_REST_Response
837 */
838 public function wcpos_send_email( WP_REST_Request $request ) {
839 $this->wcpos_request = $request;
840 $order = wc_get_order( (int) $request['order_id'] );
841 $email = $request['email'];
842
843 if ( ! $order || $this->post_type !== $order->get_type() ) {
844 return new WP_Error( 'woocommerce_rest_order_invalid_id', __( 'Invalid order ID.', 'woocommerce' ), array( 'status' => 404 ) );
845 }
846
847 if ( 'billing' == $request['save_to'] ) {
848 $order->set_billing_email( $email );
849 $order->save();
850 // translators: %s: email address.
851 $order->add_order_note( \sprintf( __( 'Email address %s added to billing details from WCPOS.', 'woocommerce-pos' ), $email ), 0, true );
852 }
853
854 do_action( 'woocommerce_before_resend_order_emails', $order, 'customer_invoice' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
855 add_filter( 'woocommerce_email_recipient_customer_invoice', array( $this, 'wcpos_recipient_email_address' ), 99 );
856
857 // Send the customer invoice email.
858 WC()->payment_gateways();
859 WC()->shipping();
860 WC()->mailer()->customer_invoice( $order );
861
862 // Note the event.
863 // translators: %s: email address.
864 $order->add_order_note( \sprintf( __( 'Order details manually sent to %s from WCPOS.', 'woocommerce-pos' ), $email ), 0, true );
865
866 do_action( 'woocommerce_after_resend_order_email', $order, 'customer_invoice' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
867
868 $request->set_param( 'context', 'edit' );
869
870 return rest_ensure_response( array( 'success' => true ) );
871
872 // $response->set_status( 201 );
873 }
874
875 /**
876 * Send email permissions check.
877 */
878 public function wcpos_send_email_permissions_check() {
879 if ( ! wc_rest_check_post_permissions( $this->post_type, 'create' ) ) {
880 return new WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
881 }
882
883 return true;
884 }
885
886 /**
887 * Get the recipient email address, for manual sending of order emails.
888 *
889 * @return string
890 */
891 public function wcpos_recipient_email_address() {
892 return $this->wcpos_request['email'];
893 }
894
895 /**
896 * Get formatted order statuses.
897 *
898 * @return WP_REST_Response
899 */
900 public function wcpos_get_order_statuses() {
901 $statuses = wc_get_order_statuses();
902 $formatted_statuses = array();
903
904 foreach ( $statuses as $status_key => $status_name ) {
905 // Remove the 'wc-' prefix from the status key.
906 $status_id = 'wc-' === substr( $status_key, 0, 3 ) ? substr( $status_key, 3 ) : $status_key;
907
908 $formatted_statuses[] = array(
909 'id' => $status_id,
910 'name' => $status_name,
911 );
912 }
913
914 return rest_ensure_response( $formatted_statuses );
915 }
916
917 /**
918 * Get the public order statuses schema.
919 *
920 * @return array
921 */
922 public function wcpos_get_public_order_statuses_schema() {
923 return array(
924 '$schema' => 'http://json-schema.org/draft-04/schema#',
925 'title' => 'order_status',
926 'type' => 'object',
927 'properties' => array(
928 'id' => array(
929 'description' => __( 'Unique identifier for the order status.', 'woocommerce-pos' ),
930 'type' => 'string',
931 'context' => array( 'view', 'edit' ),
932 'readonly' => true,
933 ),
934 'name' => array(
935 'description' => __( 'Display name of the order status.', 'woocommerce-pos' ),
936 'type' => 'string',
937 'context' => array( 'view', 'edit' ),
938 'readonly' => true,
939 ),
940 ),
941 );
942 }
943
944 /**
945 * Modify the order response.
946 *
947 * @param WP_REST_Response $response The response object.
948 * @param WC_Abstract_Order $order Object data.
949 * @param WP_REST_Request $request Request object.
950 *
951 * @return WP_REST_Response
952 */
953 public function wcpos_order_response( WP_REST_Response $response, WC_Abstract_Order $order, WP_REST_Request $request ): WP_REST_Response {
954 $data = $response->get_data();
955
956 // Add UUID to order.
957 $this->maybe_add_post_uuid( $order );
958
959 // Add payment link to the order.
960 $pos_payment_url = add_query_arg(
961 array(
962 'pay_for_order' => true,
963 'key' => method_exists( $order, 'get_order_key' ) ? $order->get_order_key() : '',
964 ),
965 wcpos_checkout_url( 'order-pay/' . $order->get_id() )
966 );
967
968 $response->add_link( 'payment', $pos_payment_url, array( 'foo' => 'bar' ) );
969
970 // Add receipt link to the order.
971 $pos_receipt_url = add_query_arg(
972 array(
973 'key' => method_exists( $order, 'get_order_key' ) ? $order->get_order_key() : '',
974 ),
975 wcpos_checkout_url( 'wcpos-receipt/' . $order->get_id() )
976 );
977 $response->add_link( 'receipt', $pos_receipt_url );
978
979 // WC core's get_image_id() returns a string; cast line item image IDs to int.
980 if ( isset( $data['line_items'] ) && \is_array( $data['line_items'] ) ) {
981 foreach ( $data['line_items'] as &$item ) {
982 if ( isset( $item['image']['id'] ) ) {
983 $item['image']['id'] = (int) $item['image']['id'];
984 }
985 }
986 unset( $item );
987 }
988
989 // Parse the meta data before returning the response.
990 $data['meta_data'] = $this->wcpos_parse_meta_data( $order );
991
992 // Add structured tax_ids list (read fallback across legacy plugin meta keys).
993 $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_order( $order );
994
995 // Estimate response size and log if excessive.
996 $this->wcpos_estimate_response_size( $data, $order->get_id(), 'Order' );
997
998 $response->set_data( $data );
999
1000 return $response;
1001 }
1002
1003 /**
1004 * Build a safe order response when meta count exceeds the error threshold.
1005 *
1006 * Loads the order but suppresses the full meta serialization by filtering
1007 * get_meta_data to return empty, then substitutes only essential POS meta keys
1008 * queried directly from the database.
1009 *
1010 * @param int $order_id The order ID.
1011 * @param int $meta_count The total meta count (for logging).
1012 *
1013 * @return WP_REST_Response|WP_Error
1014 */
1015 private function wcpos_build_safe_order_response( int $order_id, int $meta_count ) {
1016 Logger::error(
1017 "Order #{$order_id} has {$meta_count} meta_data entries. Returning response with essential meta only to prevent out-of-memory."
1018 );
1019
1020 // Suppress meta loading during WC's response preparation.
1021 add_filter( 'woocommerce_order_get_meta_data', array( $this, 'wcpos_return_empty_meta' ), 999 );
1022
1023 $get_request = new WP_REST_Request( 'GET', $this->namespace . '/' . $this->rest_base . '/' . $order_id );
1024 $get_request->set_param( 'id', $order_id );
1025 $response = $this->get_item( $get_request );
1026
1027 remove_filter( 'woocommerce_order_get_meta_data', array( $this, 'wcpos_return_empty_meta' ), 999 );
1028
1029 if ( is_wp_error( $response ) ) {
1030 return $response;
1031 }
1032
1033 // Replace the empty meta_data with our essential subset.
1034 $data = $response->get_data();
1035 $data['meta_data'] = $this->wcpos_get_essential_meta( $order_id, 'order' );
1036 $response->set_data( $data );
1037
1038 return $response;
1039 }
1040
1041 /**
1042 * Filter callback to return empty meta data array.
1043 *
1044 * Used to suppress meta loading when we know it would cause OOM.
1045 *
1046 * @return array Empty array.
1047 */
1048 public function wcpos_return_empty_meta(): array {
1049 return array();
1050 }
1051
1052 /**
1053 * Add UUID to order items.
1054 *
1055 * NOTE: OrderRefund can also be passed
1056 *
1057 * @param WC_Order_Item[] $items The order items.
1058 * @param WC_Abstract_Order $order The order object.
1059 * @param array $item_type string[] ['line_item' | 'fee' | 'shipping' | 'tax' | 'coupon'].
1060 *
1061 * @return WC_Order_Item[]
1062 */
1063 public function wcpos_order_get_items( array $items, WC_Abstract_Order $order, array $item_type ): array {
1064 foreach ( $items as $item ) {
1065 $this->maybe_add_order_item_uuid( $item );
1066 }
1067
1068 return $items;
1069 }
1070
1071 /**
1072 * Add extra data for wcpos orders.
1073 * - Add custom 'created_via' prop for POS orders, used in WC Admin display.
1074 *
1075 * @param WC_Abstract_Order $order The object being saved.
1076 *
1077 * @throws \WC_Data_Exception If order data is invalid.
1078 */
1079 public function wcpos_before_order_object_save( WC_Abstract_Order $order ): void {
1080 if ( $this->is_creating && method_exists( $order, 'set_created_via' ) ) {
1081 $order->set_created_via( PLUGIN_NAME );
1082 // This is the server plugin version that accepted the new order. The
1083 // line-item storage shape remains authoritative for offline-synced orders.
1084 $order->update_meta_data( '_woocommerce_pos_version', VERSION );
1085 }
1086
1087 /**
1088 * Add cashier user id to order meta
1089 * Note: There should only be one cashier per order, currently this will overwrite previous cashier id.
1090 */
1091 $user_id = get_current_user_id();
1092 $cashier_id = $order->get_meta( '_pos_user' );
1093
1094 if ( ! $cashier_id ) {
1095 $order->update_meta_data( '_pos_user', (string) $user_id );
1096 }
1097 }
1098
1099 /**
1100 * Filter the order query.
1101 *
1102 * @param array $args Query arguments.
1103 * @param WP_REST_Request $request Request object.
1104 */
1105 public function wcpos_shop_order_query( array $args, WP_REST_Request $request ) {
1106 // Check for wcpos_include/wcpos_exclude parameter.
1107 if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) {
1108 if ( $this->hpos_enabled ) {
1109 add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orders_table_query_clauses' ), 10, 3 );
1110 } else {
1111 add_filter( 'posts_where', array( $this, 'wcpos_posts_where_order_include_exclude' ), 10, 2 );
1112 }
1113 }
1114
1115 // Add 'pos_cashier' filter.
1116 if ( isset( $request['pos_cashier'] ) ) {
1117 $args['meta_query'][] = array(
1118 'key' => '_pos_user',
1119 'value' => $request['pos_cashier'],
1120 );
1121 }
1122
1123 // Add 'pos_store' filter.
1124 if ( isset( $request['pos_store'] ) ) {
1125 $args['meta_query'][] = array(
1126 'key' => '_pos_store',
1127 'value' => $request['pos_store'],
1128 );
1129 }
1130
1131 // @TODO - Add 'created_via' filter.
1132 // 'created_via' is stored in the operational data table for HPOS and postmeta for legacy.
1133
1134 return $args;
1135 }
1136
1137 /**
1138 * Filter the WHERE clause of the query.
1139 *
1140 * @param string $where WHERE clause of the query.
1141 * @param object $query The WP_Query instance.
1142 *
1143 * @return string
1144 */
1145 public function wcpos_posts_where_order_include_exclude( string $where, $query ) {
1146 global $wpdb;
1147
1148 // Handle 'wcpos_include'.
1149 if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) {
1150 $include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] );
1151 $ids_format = implode( ',', array_fill( 0, \count( $include_ids ), '%d' ) );
1152 $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID IN ($ids_format) ", $include_ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is generated from array_fill with %d placeholders.
1153 }
1154
1155 // Handle 'wcpos_exclude'.
1156 if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) {
1157 $exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] );
1158 $ids_format = implode( ',', array_fill( 0, \count( $exclude_ids ), '%d' ) );
1159 $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID NOT IN ($ids_format) ", $exclude_ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is generated from array_fill with %d placeholders.
1160 }
1161
1162 return $where;
1163 }
1164
1165 /**
1166 * Filters all query clauses at once.
1167 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
1168 *
1169 * @param string[] $clauses Associative array of the clauses for the query.
1170 * @param object $query The OrdersTableQuery instance (passed by reference).
1171 * @param array $args Query args.
1172 */
1173 public function wcpos_hpos_orders_table_query_clauses( array $clauses, $query, array $args ) {
1174 // Handle 'wcpos_include'.
1175 if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) {
1176 $include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] );
1177 $clauses['where'] .= ' AND ' . $query->get_table_name( 'orders' ) . '.id IN (' . implode( ',', $include_ids ) . ')';
1178 }
1179
1180 // Handle 'wcpos_exclude'.
1181 if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) {
1182 $exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] );
1183 $clauses['where'] .= ' AND ' . $query->get_table_name( 'orders' ) . '.id NOT IN (' . implode( ',', $exclude_ids ) . ')';
1184 }
1185
1186 return $clauses;
1187 }
1188
1189 /**
1190 * Returns array of all order ids.
1191 *
1192 * @param WP_REST_Request $request Full details about the request.
1193 *
1194 * @return WP_Error|WP_REST_Response
1195 */
1196 public function wcpos_get_all_posts( $request ) {
1197 global $wpdb;
1198
1199 // Start timing execution.
1200 $start_time = microtime( true );
1201
1202 $modified_after = $request->get_param( 'modified_after' );
1203 $dates_are_gmt = true; // Dates are always in GMT.
1204 $fields = $request->get_param( 'fields' );
1205 $id_with_modified_date = array( 'id', 'date_modified_gmt' ) === $fields;
1206
1207 // Check if HPOS is enabled and custom orders table is used.
1208 $hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
1209 $sql = '';
1210
1211 $statuses = array_map(
1212 function ( $status ) {
1213 return "'$status'";
1214 },
1215 array_keys( wc_get_order_statuses() )
1216 );
1217
1218 if ( $hpos_enabled ) {
1219 $select_fields = $id_with_modified_date ? 'id, date_updated_gmt as date_modified_gmt' : 'id';
1220 $sql .= "SELECT DISTINCT {$select_fields} FROM {$wpdb->prefix}wc_orders WHERE type = 'shop_order'";
1221 $sql .= ' AND status IN (' . implode( ',', $statuses ) . ')';
1222
1223 // Add modified_after condition if provided.
1224 if ( $modified_after ) {
1225 $modified_after_date = gmdate( 'Y-m-d H:i:s', strtotime( $modified_after ) );
1226 $sql .= $wpdb->prepare( ' AND date_updated_gmt > %s', $modified_after_date );
1227 }
1228
1229 // Order by date_created_gmt DESC to maintain order consistency.
1230 $sql .= " ORDER BY {$wpdb->prefix}wc_orders.date_created_gmt DESC";
1231 } else {
1232 $select_fields = $id_with_modified_date ? 'ID as id, post_modified_gmt as date_modified_gmt' : 'ID as id';
1233 $sql .= "SELECT DISTINCT {$select_fields} FROM {$wpdb->posts} WHERE post_type = 'shop_order'";
1234 $sql .= ' AND post_status IN (' . implode( ',', $statuses ) . ')';
1235
1236 // Add modified_after condition if provided.
1237 if ( $modified_after ) {
1238 $modified_after_date = gmdate( 'Y-m-d H:i:s', strtotime( $modified_after ) );
1239 $sql .= $wpdb->prepare( ' AND post_modified_gmt > %s', $modified_after_date );
1240 }
1241
1242 // Order by post_date DESC to maintain order consistency.
1243 $sql .= " ORDER BY {$wpdb->posts}.post_date DESC";
1244 }
1245
1246 try {
1247 $results = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is built with prepare() for dynamic parts, static parts are safe.
1248 $formatted_results = $this->wcpos_format_all_posts_response( $results );
1249
1250 // Get the total number of orders for the given criteria.
1251 $total = \count( $formatted_results );
1252
1253 // Collect execution time and server load.
1254 $execution_time = microtime( true ) - $start_time;
1255 $execution_time_ms = number_format( $execution_time * 1000, 2 );
1256 $server_load = $this->get_server_load();
1257
1258 $response = rest_ensure_response( $formatted_results );
1259 $response->header( 'X-WP-Total', (string) $total );
1260 $response->header( 'X-Execution-Time', $execution_time_ms . ' ms' );
1261 $response->header( 'X-Server-Load', json_encode( $server_load ) );
1262
1263 return $response;
1264 } catch ( Exception $e ) {
1265 Logger::log( 'Error fetching order data: ' . $e->getMessage() );
1266
1267 return new WP_Error( 'woocommerce_pos_rest_cannot_fetch', 'Error fetching order data.', array( 'status' => 500 ) );
1268 }
1269 }
1270
1271 /**
1272 * Filters all query clauses at once.
1273 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
1274 *
1275 * @param string[] $clauses Associative array of the clauses for the query.
1276 * @param object $query The OrdersTableQuery instance (passed by reference).
1277 * @param array $args Query args.
1278 *
1279 * @return string[] $clauses
1280 */
1281 public function wcpos_hpos_orderby_query( array $clauses, $query, $args ) {
1282 if ( isset( $clauses['orderby'] ) && '' === $clauses['orderby'] ) {
1283 $order = $args['order'] ?? 'ASC';
1284 $orderby = $this->wcpos_request->get_param( 'orderby' );
1285
1286 switch ( $orderby ) {
1287 case 'status':
1288 $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.status ' . $order;
1289
1290 break;
1291 case 'customer_id':
1292 $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.customer_id ' . $order;
1293
1294 break;
1295 case 'payment_method':
1296 $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.payment_method ' . $order;
1297
1298 break;
1299 case 'total':
1300 $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.total_amount ' . $order;
1301
1302 break;
1303 }
1304 }
1305
1306 return $clauses;
1307 }
1308
1309 /**
1310 * Modify ORDER BY clause for legacy (non-HPOS) order status sorting.
1311 *
1312 * @param string $orderby The ORDER BY clause.
1313 * @param WP_Query $query The WP_Query instance.
1314 *
1315 * @return string Modified ORDER BY clause.
1316 */
1317 public function wcpos_legacy_order_status_orderby( string $orderby, WP_Query $query ): string {
1318 global $wpdb;
1319
1320 // Only modify if this is an order query.
1321 if ( isset( $query->query_vars['post_type'] ) && 'shop_order' === $query->query_vars['post_type'] ) {
1322 $order = isset( $this->wcpos_request ) ? strtoupper( $this->wcpos_request->get_param( 'order' ) ?? 'ASC' ) : 'ASC';
1323 $orderby = "{$wpdb->posts}.post_status {$order}";
1324
1325 // Remove filter after use.
1326 remove_filter( 'posts_orderby', array( $this, 'wcpos_legacy_order_status_orderby' ), 10 );
1327 }
1328
1329 return $orderby;
1330 }
1331
1332 /**
1333 * Prepare objects query.
1334 *
1335 * @param WP_REST_Request $request Full details about the request.
1336 *
1337 * @return array|WP_Error
1338 */
1339 protected function prepare_objects_query( $request ) {
1340 $args = parent::prepare_objects_query( $request );
1341
1342 /*
1343 * Extend the orderby parameter to include custom options.
1344 * Legacy order options.
1345 */
1346 if ( isset( $request['orderby'] ) && ! $this->hpos_enabled ) {
1347 switch ( $request['orderby'] ) {
1348 case 'status':
1349 // Use posts_orderby filter since post_status isn't a valid WP_Query orderby.
1350 add_filter( 'posts_orderby', array( $this, 'wcpos_legacy_order_status_orderby' ), 10, 2 );
1351
1352 break;
1353 case 'customer_id':
1354 $args['meta_key'] = '_customer_user';
1355 $args['orderby'] = 'meta_value_num';
1356
1357 break;
1358 case 'payment_method':
1359 $args['meta_key'] = '_payment_method_title';
1360 $args['orderby'] = 'meta_value';
1361
1362 break;
1363 case 'total':
1364 $args['meta_key'] = '_order_total';
1365 $args['orderby'] = 'meta_value';
1366
1367 break;
1368 }
1369 }
1370
1371 /*
1372 * Extend the orderby parameter to include custom options.
1373 * HOPS orders options.
1374 */
1375 if ( isset( $request['orderby'] ) && $this->hpos_enabled ) {
1376 add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orderby_query' ), 10, 3 );
1377 }
1378
1379 return $args;
1380 }
1381
1382 /**
1383 * Override WooCommerce V3's calculate_coupons to handle the POS sending
1384 * back full order data (including coupon line IDs).
1385 *
1386 * WooCommerce V3 treats coupon_lines differently from other line types:
1387 * instead of using IDs to match existing items, it does a full remove-and-
1388 * reapply by code. It also rejects any coupon_line with an 'id' field.
1389 *
1390 * Since the POS always sends the complete order object on updates, coupon_lines
1391 * will contain IDs from the previous response. We compare the requested coupon
1392 * codes with the existing ones on the order: if they match, we skip the
1393 * recalculation entirely (preserving stable line item IDs). If they differ,
1394 * we strip the IDs and delegate to the parent for the remove-and-reapply.
1395 *
1396 * @throws \WC_REST_Exception When a coupon is invalid.
1397 *
1398 * @param WP_REST_Request $request Request object.
1399 * @param \WC_Order $order Order object.
1400 *
1401 * @return bool True if coupons were recalculated, false if skipped.
1402 */
1403 protected function calculate_coupons( $request, $order ) {
1404 if ( ! isset( $request['coupon_lines'] ) || ! \is_array( $request['coupon_lines'] ) ) {
1405 return false;
1406 }
1407
1408 // Extract coupon codes from the request.
1409 $requested_codes = array();
1410 foreach ( $request['coupon_lines'] as $item ) {
1411 $code = $item['code'] ?? '';
1412 if ( '' !== $code ) {
1413 $requested_codes[] = wc_strtolower( wc_format_coupon_code( wc_clean( $code ) ) );
1414 }
1415 }
1416
1417 // Get the existing coupon codes on the order.
1418 $existing_codes = array_map(
1419 function ( $coupon ) {
1420 return wc_strtolower( $coupon->get_code() );
1421 },
1422 array_values( $order->get_coupons() )
1423 );
1424
1425 sort( $requested_codes );
1426 sort( $existing_codes );
1427
1428 // If the coupon codes haven't changed, skip recalculation entirely.
1429 // This preserves stable coupon line item IDs across saves.
1430 if ( $requested_codes === $existing_codes ) {
1431 return false;
1432 }
1433
1434 // Codes have changed — strip IDs and let the parent handle remove-and-reapply.
1435 $coupon_lines = $request['coupon_lines'];
1436 foreach ( $coupon_lines as &$coupon_line ) {
1437 unset( $coupon_line['id'] );
1438 }
1439 $request->set_param( 'coupon_lines', $coupon_lines );
1440
1441 return parent::calculate_coupons( $request, $order );
1442 }
1443 }
1444