PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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.19, at includes/API/V1/Orders_Controller.php

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