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

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

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