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.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 1.9.13 All 162 releases
woocommerce-pos / includes / API / V1 / Orders_Controller.php

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

1,464 lines 49.5 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\V1;
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;
22 use WC_Order_Item;
23 use WC_Order_Item_Fee;
24 use WC_Order_Item_Product;
25 use WC_REST_Orders_Controller;
26 use WC_Tax;
27 use WCPOS\WooCommercePOS\Logger;
28 use WCPOS\WooCommercePOS\Services\Pos_Order_Audit;
29 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
30 use WCPOS\WooCommercePOS\Services\Stock_Validator;
31 use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
32 use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
33 use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
34 use WCPOS\WooCommercePOS\Sync\Collection_Rules;
35 use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan;
36 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
37 use const WCPOS\WooCommercePOS\PLUGIN_NAME;
38 use const WCPOS\WooCommercePOS\VERSION;
39 use WP_Error;
40 use WP_REST_Request;
41 use WP_REST_Response;
42
43 use WP_Query;
44 use WP_REST_Server;
45
46 /**
47 * Orders controller class.
48 *
49 * @NOTE: methods not prefixed with wcpos_ will override WC_REST_Orders_Controller methods
50 */
51 class Orders_Controller extends WC_REST_Orders_Controller {
52 use Traits\Uuid_Handler;
53 use Traits\WCPOS_REST_API;
54
55 /**
56 * Endpoint namespace.
57 *
58 * @var string
59 */
60 protected $namespace = 'wcpos/v1';
61
62 /**
63 * Canonical Collection Rule name => the request key this lane exposes it under.
64 *
65 * The direct lane's narrowing map. `created_via` is DELIBERATELY absent: the rule row
66 * exists (the proxy lane claims it), but `wcpos/v1` has never supported the filter and
67 * adding it here would be a wire change, not a refactor. See `Sync\Collection_Rules`.
68 *
69 * @var array<string, string>
70 */
71 private const WCPOS_COLLECTION_PARAM_MAP = array(
72 'orderby' => 'orderby',
73 'order' => 'order',
74 'include' => 'wcpos_include',
75 'exclude' => 'wcpos_exclude',
76 'pos_cashier' => 'pos_cashier',
77 'pos_store' => 'pos_store',
78 );
79
80 /**
81 * Store the request object for use in lifecycle methods.
82 *
83 * @var WP_REST_Request|null
84 */
85 protected $wcpos_request;
86
87 /**
88 * The order object being created by the current request.
89 *
90 * @var WC_Abstract_Order|null
91 */
92 private $creating_order;
93
94 /**
95 * Whether High Performance Orders is enabled.
96 *
97 * @var bool
98 */
99 private $hpos_enabled = false;
100
101 /**
102 * Constructor.
103 */
104 public function __construct() {
105 $this->hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
106
107 if ( method_exists( parent::class, '__construct' ) ) {
108 parent::__construct();
109 }
110 }
111
112 /**
113 * Persist new checkout orders as pending until stock is reserved atomically.
114 *
115 * @param WP_REST_Request $request Full request details.
116 * @param bool $creating Whether a new order is being created.
117 * @return WC_Data|WP_Error
118 * @throws \Throwable If checkout stock validation cannot be completed.
119 */
120 protected function save_object( $request, $creating = false ) {
121 $validator = Stock_Validator::instance();
122 if ( ! $creating || ! \wcpos_request() || ! SettingsService::instance()->prevent_overselling_enabled() || ! $validator->should_validate_create_request( $request ) ) {
123 return parent::save_object( $request, $creating );
124 }
125
126 $target_status = $request->get_param( 'status' );
127 $set_paid = $request->get_param( 'set_paid' );
128
129 try {
130 return $validator->around_paid_create(
131 array(
132 'status' => $target_status,
133 'set_paid' => rest_sanitize_boolean( $set_paid ),
134 'transaction_id' => $request->get_param( 'transaction_id' ),
135 ),
136 function ( array $neutralised ) use ( $request, $creating ) {
137 $request->set_param( 'status', $neutralised['status'] );
138 $request->set_param( 'set_paid', $neutralised['set_paid'] );
139
140 return parent::save_object( $request, $creating );
141 }
142 );
143 } finally {
144 $request->set_param( 'status', $target_status );
145 $request->set_param( 'set_paid', $set_paid );
146 }
147 }
148
149 /**
150 * Check if the current user can update an order.
151 *
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
162 */
163 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;
183 }
184
185 /**
186 * Check if the current user can delete an order.
187 *
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
193 */
194 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;
213 }
214
215 /**
216 * Delete a single order.
217 *
218 * WooCommerce core does not restore stock when orders are trashed or deleted.
219 * This override restores stock on successful deletion.
220 *
221 * @see https://github.com/woocommerce/woocommerce/issues/26716
222 *
223 * @param WP_REST_Request $request Full details about the request.
224 *
225 * @return WP_REST_Response|WP_Error
226 */
227 public function delete_item( $request ) {
228 $order_id = (int) $request['id'];
229 $order = wc_get_order( $order_id );
230
231 if ( ! $order ) {
232 return parent::delete_item( $request );
233 }
234
235 $setting = SettingsService::instance()->restore_stock_on_delete_enabled();
236
237 /**
238 * Filter whether to restore stock when an order is deleted via the POS API.
239 *
240 * @since 1.9.0
241 *
242 * @param bool $restore_stock Whether to restore stock. Default from settings.
243 * @param int $order_id The order ID being deleted.
244 */
245 $restore_stock = apply_filters( 'woocommerce_pos_restore_stock_on_delete', $setting, $order_id );
246 $force = (bool) $request->get_param( 'force' );
247
248 // Force-delete permanently removes the order, so restore stock beforehand.
249 if ( $restore_stock && $force ) {
250 wc_maybe_increase_stock_levels( $order_id );
251 }
252
253 $response = parent::delete_item( $request );
254
255 if ( is_wp_error( $response ) ) {
256 // Rollback pre-restore on force-delete failure.
257 if ( $restore_stock && $force ) {
258 wc_maybe_reduce_stock_levels( $order_id );
259 }
260
261 return $response;
262 }
263
264 // Trash path: order still exists, so restore stock after confirmed success.
265 if ( $restore_stock && ! $force ) {
266 wc_maybe_increase_stock_levels( $order_id );
267 }
268
269 return $response;
270 }
271
272 /**
273 * Dispatch request to parent controller, or override if needed.
274 *
275 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
276 * @param WP_REST_Request $request Request used to generate the response.
277 * @param string $route Route matched for the request.
278 * @param array $handler Route handler used for the request.
279 */
280 public function wcpos_dispatch_request( $dispatch_result, WP_REST_Request $request, $route, $handler ) {
281 /*
282 * Force decimal rounding to 6 places for all order data. This matches the POS.
283 *
284 * @TODO - should this be flexible via a query param from the POS?
285 */
286 $request->set_param( 'dp', '6' );
287
288 $this->wcpos_request = $request;
289 // set hpos_enabled again for tests to work @TODO - fix this.
290 $this->hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
291
292 add_filter( 'woocommerce_rest_prepare_shop_order_object', array( $this, 'wcpos_order_response' ), 10, 3 );
293 add_filter( 'woocommerce_order_get_items', array( $this, 'wcpos_order_get_items' ), 10, 3 );
294 add_action( 'woocommerce_before_order_object_save', array( $this, 'wcpos_before_order_object_save' ), 10, 1 );
295 add_filter( 'woocommerce_rest_shop_order_object_query', array( $this, 'wcpos_shop_order_query' ), 10, 2 );
296 // Negative-fee tax handling is registered globally by WCPOS\WooCommercePOS\Orders
297 // (request-gated) so the v2 push forward shares it — no per-dispatch registration.
298
299 /*
300 * Check if the request is for all orders and if the 'posts_per_page' is set to -1.
301 * Optimised query for getting all order IDs.
302 */
303 if ( Bulk_ID_Fast_Path::supports_request( $request ) ) {
304 return $this->wcpos_get_all_posts( $request );
305 }
306
307 return $dispatch_result;
308 }
309
310 /**
311 * Register routes.
312 */
313 public function register_routes(): void {
314 parent::register_routes();
315
316 register_rest_route(
317 $this->namespace,
318 '/' . $this->rest_base . '/(?P<order_id>[\d]+)/email',
319 array(
320 array(
321 'methods' => WP_REST_Server::CREATABLE,
322 'callback' => array( $this, 'wcpos_send_email' ),
323 'permission_callback' => array( $this, 'wcpos_send_email_permissions_check' ),
324 'args' => array_merge(
325 $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
326 array(
327 'email' => array(
328 'type' => 'string',
329 'description' => /* translators: REST API schema field label or error message. */ __( 'Email address', 'woocommerce-pos' ),
330 'required' => true,
331 ),
332 'save_to' => array(
333 'type' => 'string',
334 'description' => __( 'Save email to order', 'woocommerce-pos' ),
335 'required' => false,
336 ),
337 )
338 ),
339 ),
340 'schema' => array( $this, 'wcpos_get_public_send_email_schema' ),
341 )
342 );
343
344 register_rest_route(
345 $this->namespace,
346 '/' . $this->rest_base . '/statuses',
347 array(
348 array(
349 'methods' => WP_REST_Server::READABLE,
350 'callback' => array( $this, 'wcpos_get_order_statuses' ),
351 'permission_callback' => array( $this, 'get_item_permissions_check' ),
352 ),
353 'schema' => array( $this, 'wcpos_get_public_order_statuses_schema' ),
354 )
355 );
356 }
357
358 /**
359 * Add custom fields to the order schema.
360 */
361 public function get_item_schema() {
362 $schema = parent::get_item_schema();
363
364 // Add structured tax_ids property (TaxId[]) snapshotted from the customer
365 // at create time. Editable via update for corrections.
366 $schema['properties']['tax_ids'] = array(
367 'description' => __( 'Customer tax IDs snapshotted at sale time.', 'woocommerce-pos' ),
368 'type' => 'array',
369 '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 ),
392 );
393
394 // Check and remove email format validation from the billing property.
395 if ( isset( $schema['properties']['billing']['properties']['email']['format'] ) ) {
396 unset( $schema['properties']['billing']['properties']['email']['format'] );
397 }
398
399 // Modify line_items->parent_name to accept 'string' or 'null'.
400 if ( isset( $schema['properties']['line_items'] ) &&
401 \is_array( $schema['properties']['line_items']['items']['properties'] ) ) {
402 $schema['properties']['line_items']['items']['properties']['parent_name']['type'] = array( 'string', 'null' );
403 }
404
405 // Check for 'stock_quantity' and allow decimal.
406 if ( $this->wcpos_allow_decimal_quantities() &&
407 isset( $schema['properties']['line_items'] ) &&
408 \is_array( $schema['properties']['line_items']['items']['properties'] ) ) {
409 $schema['properties']['line_items']['items']['properties']['quantity']['type'] = array( 'number' );
410 }
411
412 return $schema;
413 }
414
415
416 /**
417 * Create a single order.
418 * - Validate billing email.
419 * - Do a sanity check on the UUID, if the internet connection is bad, several requests can be made with the same UUID.
420 *
421 * @param WP_REST_Request $request Full details about the request.
422 *
423 * @return WP_Error|WP_REST_Response
424 */
425 public function create_item( $request ) {
426 $invalid_meta = $this->wcpos_sanitize_meta_data_param( $request );
427 if ( is_wp_error( $invalid_meta ) ) {
428 return $invalid_meta;
429 }
430
431 // check if the UUID is already in use.
432 if ( isset( $request['meta_data'] ) && \is_array( $request['meta_data'] ) ) {
433 foreach ( $request['meta_data'] as $meta ) {
434 if ( '_woocommerce_pos_uuid' === $meta['key'] ) {
435 $uuid = $meta['value'];
436 $ids = $this->get_order_ids_by_uuid( $uuid );
437
438 /*
439 * If the UUID is already in use, and there is only one order with that UUID, return the existing order.
440 * This can happen if the internet connection is bad and the request is made several times.
441 *
442 * @NOTE: This means that $request data is lost, but we can't update existing order because it has resource ids now.
443 * The alternative would be to update the existing order, but that would require a lot of extra work.
444 * Or return an error, which would be a bad user experience.
445 */
446 if ( 1 === \count( $ids ) ) {
447 $order_id = (int) $ids[0];
448 Logger::log( 'UUID already in use, return existing order.', $order_id );
449
450 // Pre-flight: check meta count before WC loads the full order.
451 $meta_count = $this->wcpos_preflight_meta_count( $order_id, 'order' );
452 $error_threshold = (int) apply_filters( 'woocommerce_pos_meta_data_error_threshold', 500 );
453
454 if ( $meta_count >= $error_threshold ) {
455 return $this->wcpos_build_safe_order_response( $order_id, $meta_count );
456 }
457
458 // Create a new WP_REST_Request object for the GET request.
459 $get_request = new WP_REST_Request( 'GET', $this->namespace . '/' . $this->rest_base . '/' . $order_id );
460 $get_request->set_param( 'id', $order_id );
461
462 return $this->get_item( $get_request );
463 }
464 if ( \count( $ids ) > 1 ) {
465 Logger::log( 'UUID already in use for multiple orders. This should not happen.', $ids );
466
467 return new WP_Error( 'woocommerce_rest_order_invalid_id', __( 'UUID already in use.', 'woocommerce' ), array( 'status' => 400 ) );
468 }
469 }
470 }
471 }
472
473 $valid_email = $this->wcpos_validate_billing_email( $request );
474 if ( is_wp_error( $valid_email ) ) {
475 return $valid_email;
476 }
477
478 // The POS audit meta is server-authoritative: WooCommerce applies `meta_data`
479 // (incl. `_`-prefixed) at create, and the before-save stamp only fills a MISSING
480 // `_pos_user` — so a client-forged value would land first and win. Strip the
481 // server-derived keys and drop invalid till values before the write.
482 if ( isset( $request['meta_data'] ) && \is_array( $request['meta_data'] ) ) {
483 $request->set_param( 'meta_data', Pos_Order_Audit::sanitize_create_meta( $request['meta_data'] ) );
484 }
485
486 $this->creating_order = null;
487
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 );
490
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 return $response;
503 }
504
505 /**
506 * Record the exact order object prepared for this create request.
507 *
508 * @param WC_Data|WP_Error $order Order object prepared by WooCommerce.
509 * @param WP_REST_Request $request Request object.
510 * @param bool $creating Whether a new order is being created.
511 *
512 * @return WC_Data|WP_Error
513 */
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 public function wcpos_preserve_client_created_date_gmt( $order, WP_REST_Request $request, bool $creating ) {
538 if ( ! $creating || ! ( $order instanceof WC_Abstract_Order ) ) {
539 return $order;
540 }
541 $this->creating_order = $order;
542
543 $body = $request->get_json_params();
544
545 if ( ! isset( $body['date_created_gmt'] ) ) {
546 return $order;
547 }
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 $order->set_date_created( $timestamp );
599
600 return $order;
601 }
602
603 /**
604 * Update a single order.
605 *
606 * @param WP_REST_Request $request Full details about the request.
607 *
608 * @return WP_Error|WP_REST_Response
609 */
610 public function update_item( $request ) {
611 $invalid_meta = $this->wcpos_sanitize_meta_data_param( $request );
612 if ( is_wp_error( $invalid_meta ) ) {
613 return $invalid_meta;
614 }
615
616 $valid_email = $this->wcpos_validate_billing_email( $request );
617 if ( is_wp_error( $valid_email ) ) {
618 return $valid_email;
619 }
620
621 // The audit trail is write-once at the sale: an update must not rewrite the
622 // cashier, store, or cash amounts (the gateway and Pro's store stamp remain
623 // the only writers after create). The existing audit rows' meta ids are
624 // protected too — an id-addressed entry would otherwise rename a row away.
625 if ( isset( $request['meta_data'] ) && \is_array( $request['meta_data'] ) ) {
626 $request->set_param(
627 'meta_data',
628 Pos_Order_Audit::strip_audit_meta(
629 $request['meta_data'],
630 Pos_Order_Audit::audit_meta_ids( wc_get_order( (int) $request['id'] ) )
631 )
632 );
633 }
634
635 // Proceed with the parent method to handle the update.
636 $response = parent::update_item( $request );
637 $this->wcpos_snapshot_tax_ids_to_order( $response, $request, false );
638
639 return $response;
640 }
641
642 /**
643 * Persist tax_ids onto the order.
644 *
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 * @param mixed $response Response from parent controller.
653 * @param WP_REST_Request $request Original request.
654 * @param bool $is_create True for create, false for update.
655 */
656 protected function wcpos_snapshot_tax_ids_to_order( $response, WP_REST_Request $request, bool $is_create ): void {
657 if ( ! ( $response instanceof WP_REST_Response ) ) {
658 return;
659 }
660
661 $data = $response->get_data();
662 $order_id = isset( $data['id'] ) ? (int) $data['id'] : 0;
663 if ( $order_id <= 0 ) {
664 return;
665 }
666 $order = \wc_get_order( $order_id );
667 if ( ! $order ) {
668 return;
669 }
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 }
686
687 /**
688 * Create or update a line item.
689 *
690 * @param array $posted Line item data.
691 * @param string $action 'create' to add line item or 'update' to update it.
692 * @param object $item Passed when updating an item. Null during creation.
693 *
694 * @throws \WC_REST_Exception Invalid data, server error.
695 *
696 * @return WC_Order_Item_Product
697 */
698 public function prepare_line_items( $posted, $action = 'create', $item = null ) {
699 $item = parent::prepare_line_items( $posted, $action, $item );
700
701 /**
702 * If you send a variation with meta_data, the meta_data will be duplicated
703 * WooCommerce attempts to delete the duped meta_data in $item->set_product( $variation )
704 * but later it gets added right back in $this->maybe_set_item_meta_data.
705 *
706 * To fix this we check for a variation_id and remove the meta_data before setting the product
707 */
708 if ( 'create' !== $action && $item->get_variation_id() ) {
709 $attributes = wc_get_product_variation_attributes( $item->get_variation_id() );
710
711 // Loop through attributes and remove any duplicates.
712 foreach ( $attributes as $key => $value ) {
713 $attribute = str_replace( 'attribute_', '', $key );
714 $meta_data = $item->get_meta( $attribute, false );
715
716 if ( \is_array( $meta_data ) && \count( $meta_data ) > 1 ) {
717 $meta_to_keep = null;
718
719 // Check each meta to find one with an ID to keep.
720 foreach ( $meta_data as $meta ) {
721 if ( isset( $meta->id ) ) {
722 $meta_to_keep = $meta;
723
724 break;
725 }
726 }
727
728 // If no meta with an ID is found, keep the first one.
729 if ( ! $meta_to_keep ) {
730 $meta_to_keep = $meta_data[0];
731 }
732
733 // Remove all other meta data for this attribute.
734 foreach ( $meta_data as $meta ) {
735 if ( $meta !== $meta_to_keep ) {
736 if ( $meta->id ) {
737 $item->delete_meta_data_by_mid( $meta->id );
738 } else {
739 $meta->value = null;
740 }
741 }
742 }
743 }
744 }
745 }
746
747 return $item;
748 }
749
750 /**
751 * Maybe set item meta if posted.
752 *
753 * @param WC_Order_Item $item Order item data.
754 * @param array $posted Request data.
755 */
756 public function maybe_set_item_meta_data( $item, $posted ): void {
757 /*
758 * Call the parent method first to handle standard meta data
759 * This will populate the attribute key, eg: 'pa_color' or 'logo'
760 * BUT: if the attribute can be 'any' then we need to handle that
761 */
762 parent::maybe_set_item_meta_data( $item, $posted );
763
764 // Ensure this is a product line item, not a fee or shipping.
765 if ( ! \is_object( $item ) || 'WC_Order_Item_Product' !== \get_class( $item ) ) {
766 return;
767 }
768
769 // SKU meta is not stored by default, we will add it for 'miscellaneous' products.
770 if ( isset( $posted['sku'] ) && 0 === $item->get_product_id() ) {
771 $item->add_meta_data( '_sku', $posted['sku'], true );
772 }
773
774 // Only proceed if there's a variation ID and we have posted meta.
775 if ( ! $item->get_variation_id() || empty( $posted['meta_data'] ) || ! \is_array( $posted['meta_data'] ) ) {
776 return;
777 }
778
779 $attributes = wc_get_product_variation_attributes( $item->get_variation_id() );
780 $product_id = $item->get_product_id();
781 $product = wc_get_product( $product_id );
782 $parent_attributes = $product->get_attributes();
783
784 foreach ( $attributes as $key => $value ) {
785 if ( '' === $value ) {
786 $slug = str_replace( 'attribute_', '', $key );
787
788 if ( ! isset( $parent_attributes[ $slug ] ) ) {
789 continue;
790 }
791
792 $name = $parent_attributes[ $slug ]['name'] ?? $slug;
793 if ( $name === $slug ) {
794 $name = wc_attribute_label( $slug );
795 }
796
797 // find the value from $posted['meta_data'].
798 foreach ( $posted['meta_data'] as $meta ) {
799 // Match posted attribute label to the $name we just determined.
800 if ( isset( $meta['display_key'], $meta['display_value'] ) && $meta['display_key'] === $name ) {
801 $posted_value = $meta['display_value'];
802 // Only update if the posted value is non-empty.
803 if ( $posted_value ) {
804 $item->update_meta_data(
805 $slug,
806 $posted_value,
807 $meta['id'] ?? ''
808 );
809
810 break; // Stop searching once found.
811 }
812 }
813 }
814 }
815 }
816 }
817
818 /**
819 * The way WooCommerce handles negative fees is ... weird.
820 * They by-pass the normal tax calculation, disregard the tax_status and tax_class, and apply the taxes to the fee line.
821 * 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
822 * the wrong result.
823 *
824 * The implementation lives in WCPOS\WooCommercePOS\Orders::fee_after_calculate_taxes,
825 * registered globally and request-gated so the v2 push forward shares it (issue #1403).
826 * This public method is preserved for backward compatibility and delegates.
827 *
828 * @param \WC_Order_Item_Fee $fee_item The fee item.
829 * @param array $calculate_tax_for The tax calculation data.
830 */
831 public function wcpos_order_item_fee_after_calculate_taxes( $fee_item, $calculate_tax_for ): void {
832 \WCPOS\WooCommercePOS\Orders::fee_after_calculate_taxes( $fee_item, $calculate_tax_for );
833 }
834
835 /**
836 * Gets the product ID from posted ID.
837 *
838 * @param array $posted Request data.
839 * @param string $action 'create' to add line item or 'update' to update it.
840 *
841 * @throws WC_REST_Exception When SKU or ID is not valid.
842 *
843 * @return int
844 */
845 public function get_product_id( $posted, $action = 'create' ) {
846 // If id = 0, ie: miscellaneaous product, just return 0.
847 if ( isset( $posted['product_id'] ) && 0 == $posted['product_id'] ) {
848 return 0;
849 }
850
851 // Bypass the sku check. Some users have products with duplicated SKUs, esp. variable/variations.
852 $data = $posted;
853 unset( $data['sku'] );
854
855 return parent::get_product_id( $data, $action );
856 }
857
858 /**
859 * Validate billing email.
860 * NOTE: we have removed the format check to allow empty email addresses.
861 *
862 * @param WP_REST_Request $request Full details about the request.
863 *
864 * @return bool|WP_Error
865 */
866 public function wcpos_validate_billing_email( WP_REST_Request $request ) {
867 $billing = $request['billing'] ?? null;
868 $email = \is_array( $billing ) ? ( $billing['email'] ?? null ) : null;
869
870 if ( ! \is_null( $email ) && '' !== $email && ! is_email( $email ) ) {
871 return new WP_Error(
872 'rest_invalid_param',
873 // translators: Use default WordPress translation.
874 __( 'Invalid email address.', 'woocommerce-pos' ),
875 array( 'status' => 400 )
876 );
877 }
878
879 return true;
880 }
881
882 /**
883 * Modify the collection params.
884 */
885 public function get_collection_params() {
886 $params = parent::get_collection_params();
887
888 // Ensure 'per_page' is an array and has a 'minimum' key.
889 if ( isset( $params['per_page'] ) && \is_array( $params['per_page'] ) ) {
890 $params['per_page']['minimum'] = -1;
891 }
892
893 // Ensure 'orderby' is an array and has an 'enum' key that is also an array.
894 // The extra values are a PROJECTION of the Collection Rule sort rows, so the
895 // schema cannot advertise a sort the clause bodies do not implement.
896 if ( isset( $params['orderby'] ) && \is_array( $params['orderby'] ) && isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) {
897 $params['orderby']['enum'] = array_merge(
898 $params['orderby']['enum'],
899 Collection_Rules::orderby_enum( 'orders' )
900 );
901 }
902
903 // Add the 'pos_cashier' and 'pos_store' parameters (projection of the filter rows).
904 $params = array_merge( $params, Collection_Rules::collection_params( 'orders' ) );
905
906 return $params;
907 }
908
909 /**
910 * Send order email, optionally add email address.
911 *
912 * @param WP_REST_Request $request Full details about the request.
913 *
914 * @return WP_Error|WP_REST_Response
915 */
916 public function wcpos_send_email( WP_REST_Request $request ) {
917 $this->wcpos_request = $request;
918 $order = wc_get_order( (int) $request['order_id'] );
919 $email = $request['email'];
920
921 if ( ! $order || $this->post_type !== $order->get_type() ) {
922 return new WP_Error( 'woocommerce_rest_order_invalid_id', __( 'Invalid order ID.', 'woocommerce' ), array( 'status' => 404 ) );
923 }
924
925 if ( 'billing' == $request['save_to'] ) {
926 $order->set_billing_email( $email );
927 $order->save();
928 // translators: %s: email address.
929 $order->add_order_note( \sprintf( __( 'Email address %s added to billing details from WCPOS.', 'woocommerce-pos' ), $email ), 0, true );
930 }
931
932 do_action( 'woocommerce_before_resend_order_emails', $order, 'customer_invoice' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
933 add_filter( 'woocommerce_email_recipient_customer_invoice', array( $this, 'wcpos_recipient_email_address' ), 99 );
934
935 // Send the customer invoice email.
936 WC()->payment_gateways();
937 WC()->shipping();
938 WC()->mailer()->customer_invoice( $order );
939
940 // Note the event.
941 // translators: %s: email address.
942 $order->add_order_note( \sprintf( __( 'Order details manually sent to %s from WCPOS.', 'woocommerce-pos' ), $email ), 0, true );
943
944 do_action( 'woocommerce_after_resend_order_email', $order, 'customer_invoice' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
945
946 $request->set_param( 'context', 'edit' );
947
948 return rest_ensure_response( array( 'success' => true ) );
949
950 // $response->set_status( 201 );
951 }
952
953 /**
954 * Send email permissions check.
955 */
956 public function wcpos_send_email_permissions_check() {
957 if ( ! wc_rest_check_post_permissions( $this->post_type, 'create' ) ) {
958 return new WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
959 }
960
961 return true;
962 }
963
964 /**
965 * Get the recipient email address, for manual sending of order emails.
966 *
967 * @return string
968 */
969 public function wcpos_recipient_email_address() {
970 return $this->wcpos_request['email'];
971 }
972
973 /**
974 * Get formatted order statuses.
975 *
976 * @return WP_REST_Response
977 */
978 public function wcpos_get_order_statuses() {
979 $statuses = wc_get_order_statuses();
980 $formatted_statuses = array();
981
982 foreach ( $statuses as $status_key => $status_name ) {
983 // Remove the 'wc-' prefix from the status key.
984 $status_id = 'wc-' === substr( $status_key, 0, 3 ) ? substr( $status_key, 3 ) : $status_key;
985
986 $formatted_statuses[] = array(
987 'id' => $status_id,
988 'name' => $status_name,
989 );
990 }
991
992 return rest_ensure_response( $formatted_statuses );
993 }
994
995 /**
996 * Get the public order statuses schema.
997 *
998 * @return array
999 */
1000 public function wcpos_get_public_order_statuses_schema() {
1001 return array(
1002 '$schema' => 'http://json-schema.org/draft-04/schema#',
1003 'title' => 'order_status',
1004 'type' => 'object',
1005 'properties' => array(
1006 'id' => array(
1007 'description' => __( 'Unique identifier for the order status.', 'woocommerce-pos' ),
1008 'type' => 'string',
1009 'context' => array( 'view', 'edit' ),
1010 'readonly' => true,
1011 ),
1012 'name' => array(
1013 'description' => __( 'Display name of the order status.', 'woocommerce-pos' ),
1014 'type' => 'string',
1015 'context' => array( 'view', 'edit' ),
1016 'readonly' => true,
1017 ),
1018 ),
1019 );
1020 }
1021
1022 /**
1023 * Get the route schema for the send-email action.
1024 *
1025 * Registered as the route-level `schema` callback. WordPress invokes it with
1026 * `call_user_func()` whenever a namespace index is requested with
1027 * `context=help`, so it must be a real callable — an empty array there
1028 * passes `isset()` and then fatals with a TypeError.
1029 *
1030 * @return array
1031 */
1032 public function wcpos_get_public_send_email_schema() {
1033 return array(
1034 '$schema' => 'http://json-schema.org/draft-04/schema#',
1035 'title' => 'order_email',
1036 'type' => 'object',
1037 'properties' => array(
1038 'success' => array(
1039 'description' => __( 'Whether the order email was sent.', 'woocommerce-pos' ),
1040 'type' => 'boolean',
1041 'context' => array( 'view', 'edit' ),
1042 'readonly' => true,
1043 ),
1044 ),
1045 );
1046 }
1047
1048 /**
1049 * Modify the order response.
1050 *
1051 * @param WP_REST_Response $response The response object.
1052 * @param WC_Abstract_Order $order Object data.
1053 * @param WP_REST_Request $request Request object.
1054 *
1055 * @return WP_REST_Response
1056 */
1057 public function wcpos_order_response( WP_REST_Response $response, WC_Abstract_Order $order, WP_REST_Request $request ): WP_REST_Response {
1058 $data = $response->get_data();
1059
1060 // Add UUID to order.
1061 $this->maybe_add_post_uuid( $order );
1062
1063 // Add payment link to the order.
1064 $pos_payment_url = add_query_arg(
1065 array(
1066 'pay_for_order' => true,
1067 'key' => method_exists( $order, 'get_order_key' ) ? $order->get_order_key() : '',
1068 ),
1069 wcpos_checkout_url( 'order-pay/' . $order->get_id() )
1070 );
1071
1072 $response->add_link( 'payment', $pos_payment_url, array( 'foo' => 'bar' ) );
1073
1074 // Add receipt link to the order.
1075 $pos_receipt_url = add_query_arg(
1076 array(
1077 'key' => method_exists( $order, 'get_order_key' ) ? $order->get_order_key() : '',
1078 ),
1079 wcpos_checkout_url( 'wcpos-receipt/' . $order->get_id() )
1080 );
1081 $response->add_link( 'receipt', $pos_receipt_url );
1082
1083 // WC core's get_image_id() returns a string; cast line item image IDs to int.
1084 // Shared with the v2 order-document assembly — same cast, one implementation.
1085 // This is the ONLY thing v1 borrows from it: the surrounding v1 response
1086 // shape (HAL links via add_link, parsed meta_data) is frozen.
1087 $data = Order_Serializer::cast_line_item_image_ids( $data );
1088
1089 // Parse the meta data before returning the response.
1090 $data['meta_data'] = $this->wcpos_parse_meta_data( $order );
1091
1092 // Add structured tax_ids list (read fallback across legacy plugin meta keys).
1093 $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_order( $order );
1094
1095 // Estimate response size and log if excessive.
1096 $this->wcpos_estimate_response_size( $data, $order->get_id(), 'Order' );
1097
1098 $response->set_data( $data );
1099
1100 return $response;
1101 }
1102
1103 /**
1104 * Build a safe order response when meta count exceeds the error threshold.
1105 *
1106 * Loads the order but suppresses the full meta serialization by filtering
1107 * get_meta_data to return empty, then substitutes only essential POS meta keys
1108 * queried directly from the database.
1109 *
1110 * @param int $order_id The order ID.
1111 * @param int $meta_count The total meta count (for logging).
1112 *
1113 * @return WP_REST_Response|WP_Error
1114 */
1115 private function wcpos_build_safe_order_response( int $order_id, int $meta_count ) {
1116 Logger::error(
1117 "Order #{$order_id} has {$meta_count} meta_data entries. Returning response with essential meta only to prevent out-of-memory."
1118 );
1119
1120 // Suppress meta loading during WC's response preparation.
1121 add_filter( 'woocommerce_order_get_meta_data', array( $this, 'wcpos_return_empty_meta' ), 999 );
1122
1123 $get_request = new WP_REST_Request( 'GET', $this->namespace . '/' . $this->rest_base . '/' . $order_id );
1124 $get_request->set_param( 'id', $order_id );
1125 $response = $this->get_item( $get_request );
1126
1127 remove_filter( 'woocommerce_order_get_meta_data', array( $this, 'wcpos_return_empty_meta' ), 999 );
1128
1129 if ( is_wp_error( $response ) ) {
1130 return $response;
1131 }
1132
1133 // Replace the empty meta_data with our essential subset.
1134 $data = $response->get_data();
1135 $data['meta_data'] = $this->wcpos_get_essential_meta( $order_id, 'order' );
1136 $response->set_data( $data );
1137
1138 return $response;
1139 }
1140
1141 /**
1142 * Filter callback to return empty meta data array.
1143 *
1144 * Used to suppress meta loading when we know it would cause OOM.
1145 *
1146 * @return array Empty array.
1147 */
1148 public function wcpos_return_empty_meta(): array {
1149 return array();
1150 }
1151
1152 /**
1153 * Add UUID to order items.
1154 *
1155 * NOTE: OrderRefund can also be passed
1156 *
1157 * @param WC_Order_Item[] $items The order items.
1158 * @param WC_Abstract_Order $order The order object.
1159 * @param array $item_type string[] ['line_item' | 'fee' | 'shipping' | 'tax' | 'coupon'].
1160 *
1161 * @return WC_Order_Item[]
1162 */
1163 public function wcpos_order_get_items( array $items, WC_Abstract_Order $order, array $item_type ): array {
1164 foreach ( $items as $item ) {
1165 $this->maybe_add_order_item_uuid( $item );
1166 }
1167
1168 return $items;
1169 }
1170
1171 /**
1172 * Add extra data for wcpos orders.
1173 * - Add custom 'created_via' prop for POS orders, used in WC Admin display.
1174 *
1175 * @param WC_Abstract_Order $order The object being saved.
1176 *
1177 * @throws \WC_Data_Exception If order data is invalid.
1178 */
1179 public function wcpos_before_order_object_save( WC_Abstract_Order $order ): void {
1180 $is_creating_order = $order === $this->creating_order;
1181
1182 if ( $is_creating_order && method_exists( $order, 'set_created_via' ) ) {
1183 $order->set_created_via( PLUGIN_NAME );
1184 // Record provenance only; receipt calculations continue to infer historical
1185 // pricing from the persisted line-item data of offline-synced orders.
1186 $order->update_meta_data( '_woocommerce_pos_version', VERSION );
1187 }
1188
1189 /**
1190 * `_pos_user` records who rang up the sale and is server-derived: the order
1191 * being created is always stamped with the authenticated user (any client-
1192 * supplied value was stripped before the write); on later saves only a missing
1193 * value is filled, so an edit under a different user never reassigns the
1194 * recorded cashier. The forced stamp is limited to the exact order prepared
1195 * for this request — an extension saving another new order mid-create must not
1196 * have that order's cashier overwritten.
1197 */
1198 if ( $is_creating_order || ! $order->get_meta( '_pos_user' ) ) {
1199 $order->update_meta_data( '_pos_user', (string) get_current_user_id() );
1200 }
1201 // Immutable attribution anchor: stamped once with the creator, never
1202 // rewritten (v2's reassignment flow only ever touches `_pos_user`).
1203 if ( ! $order->get_meta( '_pos_user_created' ) ) {
1204 $order->update_meta_data( '_pos_user_created', (string) get_current_user_id() );
1205 }
1206 }
1207
1208 /**
1209 * Filter the order query.
1210 *
1211 * @param array $args Query arguments.
1212 * @param WP_REST_Request $request Request object.
1213 */
1214 public function wcpos_shop_order_query( array $args, WP_REST_Request $request ) {
1215 // Which id-set rows this request claims is the declaration table's question,
1216 // not a literal list of param names — so a new `id_set` row installs on this
1217 // lane too, instead of only on the proxy lane.
1218 if ( $this->wcpos_collection_plan( $request )->claims_id_sets() ) {
1219 if ( $this->hpos_enabled ) {
1220 add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orders_table_query_clauses' ), 10, 3 );
1221 } else {
1222 add_filter( 'posts_where', array( $this, 'wcpos_posts_where_order_include_exclude' ), 10, 2 );
1223 }
1224 }
1225
1226 return $this->wcpos_collection_plan( $request )->filter( Collection_Rules_Plan::HOOK_QUERY_ARGS, $args );
1227 }
1228
1229 /**
1230 * The Collection Rules plan for the order query in flight.
1231 *
1232 * One declaration table feeds both Read Lanes; this lane keeps its own `add_filter`
1233 * topology (Pro subclasses these callbacks) and delegates only the clause bodies.
1234 *
1235 * @param WP_REST_Request|null $request Request to plan against, defaulting to the dispatched one.
1236 *
1237 * @return Collection_Rules_Plan
1238 */
1239 private function wcpos_collection_plan( ?WP_REST_Request $request = null ): Collection_Rules_Plan {
1240 $request = $request instanceof WP_REST_Request ? $request : $this->wcpos_request;
1241
1242 return Collection_Rules::for_request(
1243 'orders',
1244 $request instanceof WP_REST_Request ? $request : new WP_REST_Request(),
1245 self::WCPOS_COLLECTION_PARAM_MAP,
1246 $this->hpos_enabled ? Collection_Rules::STORAGE_HPOS : Collection_Rules::STORAGE_POSTS
1247 );
1248 }
1249
1250 /**
1251 * Filter the WHERE clause of the query.
1252 *
1253 * @param string $where WHERE clause of the query.
1254 * @param object $query The WP_Query instance.
1255 *
1256 * @return string
1257 */
1258 public function wcpos_posts_where_order_include_exclude( string $where, $query ) {
1259 return $this->wcpos_collection_plan()->filter( Collection_Rules_Plan::HOOK_POSTS_WHERE, $where, $query );
1260 }
1261
1262 /**
1263 * Filters all query clauses at once.
1264 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
1265 *
1266 * @param string[] $clauses Associative array of the clauses for the query.
1267 * @param object $query The OrdersTableQuery instance (passed by reference).
1268 * @param array $args Query args.
1269 */
1270 public function wcpos_hpos_orders_table_query_clauses( array $clauses, $query, array $args ) {
1271 return $this->wcpos_collection_plan()->filter( Collection_Rules_Plan::HOOK_HPOS_FILTERS, $clauses, $query );
1272 }
1273
1274 /**
1275 * Returns array of all order ids.
1276 *
1277 * @param WP_REST_Request $request Full details about the request.
1278 *
1279 * @return WP_Error|WP_REST_Response
1280 */
1281 public function wcpos_get_all_posts( $request ) {
1282 global $wpdb;
1283
1284 $start_time = microtime( true );
1285
1286 $hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
1287 $sql = '';
1288
1289 $statuses = array_map(
1290 function ( $status ) {
1291 return "'$status'";
1292 },
1293 array_keys( wc_get_order_statuses() )
1294 );
1295
1296 if ( $hpos_enabled ) {
1297 $select_fields = Bulk_ID_Fast_Path::select_fields( $request, 'id', 'date_updated_gmt' );
1298 $sql .= "SELECT DISTINCT {$select_fields} FROM {$wpdb->prefix}wc_orders WHERE type = 'shop_order'";
1299 $sql .= ' AND status IN (' . implode( ',', $statuses ) . ')';
1300
1301 $modified_after_date = Bulk_ID_Fast_Path::modified_after_gmt( $request );
1302 if ( $modified_after_date ) {
1303 $sql .= $wpdb->prepare( ' AND date_updated_gmt > %s', $modified_after_date );
1304 }
1305
1306 $sql = Bulk_ID_Fast_Path::append_id_filters_sql( $sql, $request, "{$wpdb->prefix}wc_orders.id" );
1307 $sql .= " ORDER BY {$wpdb->prefix}wc_orders.date_created_gmt DESC";
1308 } else {
1309 $select_fields = Bulk_ID_Fast_Path::select_fields( $request, 'ID', 'post_modified_gmt' );
1310 $sql .= "SELECT DISTINCT {$select_fields} FROM {$wpdb->posts} WHERE post_type = 'shop_order'";
1311 $sql .= ' AND post_status IN (' . implode( ',', $statuses ) . ')';
1312
1313 $modified_after_date = Bulk_ID_Fast_Path::modified_after_gmt( $request );
1314 if ( $modified_after_date ) {
1315 $sql .= $wpdb->prepare( ' AND post_modified_gmt > %s', $modified_after_date );
1316 }
1317
1318 $sql = Bulk_ID_Fast_Path::append_id_filters_sql( $sql, $request, "{$wpdb->posts}.ID" );
1319 $sql .= " ORDER BY {$wpdb->posts}.post_date DESC";
1320 }
1321
1322 try {
1323 $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.
1324
1325 return Bulk_ID_Fast_Path::response( $this, $results, $start_time );
1326 } catch ( Exception $e ) {
1327 return Bulk_ID_Fast_Path::fetch_error( 'Error fetching order data: ' . $e->getMessage(), 'Error fetching order data.' );
1328 }
1329 }
1330
1331 /**
1332 * Filters all query clauses at once.
1333 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
1334 *
1335 * @param string[] $clauses Associative array of the clauses for the query.
1336 * @param object $query The OrdersTableQuery instance (passed by reference).
1337 * @param array $args Query args.
1338 *
1339 * @return string[] $clauses
1340 */
1341 public function wcpos_hpos_orderby_query( array $clauses, $query, $args ) {
1342 return $this->wcpos_collection_plan()->filter( Collection_Rules_Plan::HOOK_HPOS_ORDERBY, $clauses, $query, (array) $args );
1343 }
1344
1345 /**
1346 * Modify ORDER BY clause for legacy (non-HPOS) order status sorting.
1347 *
1348 * @param string $orderby The ORDER BY clause.
1349 * @param WP_Query $query The WP_Query instance.
1350 *
1351 * @return string Modified ORDER BY clause.
1352 */
1353 public function wcpos_legacy_order_status_orderby( string $orderby, WP_Query $query ): string {
1354 $rewritten = $this->wcpos_collection_plan()->filter( Collection_Rules_Plan::HOOK_POSTS_ORDERBY, $orderby, $query );
1355
1356 // Remove filter after use. The rule rewrites the clause only for a shop_order
1357 // query, so a changed value is exactly the "this was our order query" signal the
1358 // inline post_type check used to provide.
1359 if ( $rewritten !== $orderby ) {
1360 remove_filter( 'posts_orderby', array( $this, 'wcpos_legacy_order_status_orderby' ), 10 );
1361 }
1362
1363 return $rewritten;
1364 }
1365
1366 /**
1367 * Prepare objects query.
1368 *
1369 * @param WP_REST_Request $request Full details about the request.
1370 *
1371 * @return array|WP_Error
1372 */
1373 protected function prepare_objects_query( $request ) {
1374 $args = parent::prepare_objects_query( $request );
1375
1376 /*
1377 * Extend the orderby parameter to include custom options.
1378 * Legacy order options.
1379 */
1380 if ( isset( $request['orderby'] ) && ! $this->hpos_enabled ) {
1381 // Whether the claimed sort needs the legacy rewrite is declared by the sort's
1382 // own row, so a second `posts_orderby` recipe reaches both Read Lanes.
1383 if ( $this->wcpos_collection_plan( $request )->needs_legacy_posts_orderby() ) {
1384 // Use posts_orderby filter since post_status isn't a valid WP_Query orderby.
1385 add_filter( 'posts_orderby', array( $this, 'wcpos_legacy_order_status_orderby' ), 10, 2 );
1386 }
1387
1388 $args = $this->wcpos_collection_plan( $request )->filter( Collection_Rules_Plan::HOOK_PREPARE_ARGS, $args );
1389 }
1390
1391 /*
1392 * Extend the orderby parameter to include custom options.
1393 * HOPS orders options.
1394 */
1395 if ( isset( $request['orderby'] ) && $this->hpos_enabled ) {
1396 add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orderby_query' ), 10, 3 );
1397 }
1398
1399 return $args;
1400 }
1401
1402 /**
1403 * Override WooCommerce V3's calculate_coupons to handle the POS sending
1404 * back full order data (including coupon line IDs).
1405 *
1406 * WooCommerce V3 treats coupon_lines differently from other line types:
1407 * instead of using IDs to match existing items, it does a full remove-and-
1408 * reapply by code. It also rejects any coupon_line with an 'id' field.
1409 *
1410 * Since the POS always sends the complete order object on updates, coupon_lines
1411 * will contain IDs from the previous response. We compare the requested coupon
1412 * codes with the existing ones on the order: if they match, we skip the
1413 * recalculation entirely (preserving stable line item IDs). If they differ,
1414 * we strip the IDs and delegate to the parent for the remove-and-reapply.
1415 *
1416 * @throws \WC_REST_Exception When a coupon is invalid.
1417 *
1418 * @param WP_REST_Request $request Request object.
1419 * @param \WC_Order $order Order object.
1420 *
1421 * @return bool True if coupons were recalculated, false if skipped.
1422 */
1423 protected function calculate_coupons( $request, $order ) {
1424 if ( ! isset( $request['coupon_lines'] ) || ! \is_array( $request['coupon_lines'] ) ) {
1425 return false;
1426 }
1427
1428 // Extract coupon codes from the request.
1429 $requested_codes = array();
1430 foreach ( $request['coupon_lines'] as $item ) {
1431 $code = $item['code'] ?? '';
1432 if ( '' !== $code ) {
1433 $requested_codes[] = wc_strtolower( wc_format_coupon_code( wc_clean( $code ) ) );
1434 }
1435 }
1436
1437 // Get the existing coupon codes on the order.
1438 $existing_codes = array_map(
1439 function ( $coupon ) {
1440 return wc_strtolower( $coupon->get_code() );
1441 },
1442 array_values( $order->get_coupons() )
1443 );
1444
1445 sort( $requested_codes );
1446 sort( $existing_codes );
1447
1448 // If the coupon codes haven't changed, skip recalculation entirely.
1449 // This preserves stable coupon line item IDs across saves.
1450 if ( $requested_codes === $existing_codes ) {
1451 return false;
1452 }
1453
1454 // Codes have changed — strip IDs and let the parent handle remove-and-reapply.
1455 $coupon_lines = $request['coupon_lines'];
1456 foreach ( $coupon_lines as &$coupon_line ) {
1457 unset( $coupon_line['id'] );
1458 }
1459 $request->set_param( 'coupon_lines', $coupon_lines );
1460
1461 return parent::calculate_coupons( $request, $order );
1462 }
1463 }
1464