PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / V1 / Orders_Controller.php

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

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