PluginProbe
wePOS – Point Of Sale (POS) for WooCommerce & Dokan / trunk
wePOS – Point Of Sale (POS) for WooCommerce & Dokan vtrunk
2.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.10 1.1.11 1.1.12 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 All 36 releases
wepos / includes / Common.php

Common.php in wePOS – Point Of Sale (POS) for WooCommerce & Dokan trunk, at includes/Common.php

610 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WeDevs\WePOS;
4
5 defined( 'ABSPATH' ) || exit;
6
7 /**
8 * Common Class.
9 *
10 * Class for doing generic common operations from both frontend and backend.
11 *
12 * @since WEPOS_LITE_SINCE
13 */
14 class Common {
15
16 /**
17 * Canonical meta key for a product's POS visibility setting.
18 *
19 * Mirrors `Admin\Products::POS_VISIBILITY_META`. Kept as a local
20 * constant so the frontend/REST handlers below don't need to pull in
21 * an admin-only class.
22 */
23 const POS_VISIBILITY_META = '_wepos_pos_visibility';
24
25 /**
26 * Canonical list of POS visibility options.
27 *
28 * @return array<string,string>
29 */
30 public static function get_pos_visibility_options() {
31 return [
32 'pos_and_online' => __( 'POS & Online', 'wepos' ),
33 'pos_only' => __( 'POS Only', 'wepos' ),
34 'online_only' => __( 'Online Only', 'wepos' ),
35 ];
36 }
37
38 /**
39 * Constructor method.
40 */
41 public function __construct() {
42 $this->init_hooks();
43 }
44
45 /**
46 * Init hooks method.
47 *
48 * @since WEPOS_LITE_SINCE
49 *
50 * @return void
51 */
52 public function init_hooks() {
53 // Register custom POS order statuses.
54 $this->register_order_status();
55 add_filter( 'wc_order_statuses', [ $this, 'wc_order_statuses' ] );
56 add_filter( 'woocommerce_valid_order_statuses_for_payment', [ $this, 'valid_order_statuses_for_payment' ], 10, 2 );
57 add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', [ $this, 'valid_order_statuses_for_payment_complete' ], 10, 2 );
58
59 // Manipulate WooCommerce Order Data.
60 add_action( 'woocommerce_new_order', [ $this, 'set_order_created_via_wepos' ], 10, 2 );
61
62 // Tax overrides for POS orders.
63 add_filter( 'woocommerce_order_get_tax_location', [ $this, 'get_tax_location' ], 10, 2 );
64 add_action( 'woocommerce_order_item_after_calculate_taxes', [ $this, 'order_item_after_calculate_taxes' ] );
65 add_action( 'woocommerce_order_item_shipping_after_calculate_taxes', [ $this, 'order_item_shipping_after_calculate_taxes' ], 10, 2 );
66 add_action( 'woocommerce_order_item_fee_after_calculate_taxes', [ $this, 'order_item_fee_after_calculate_taxes' ], 10, 2 );
67
68 // Hide POS-internal meta keys from the WooCommerce order edit screen.
69 add_filter( 'woocommerce_hidden_order_itemmeta', [ $this, 'hidden_order_itemmeta' ] );
70
71 // POS Visibility (public-facing hooks).
72 // Admin-only hooks live in `Admin\Products`; these must fire on the
73 // storefront and on REST, which is why they live here.
74 //
75 // Hide `pos_only` products from every frontend surface: shop loop,
76 // archives, search, widgets, related products, upsells/cross-sells,
77 // and `[products]`/block-editor shortcodes.
78 add_action( 'pre_get_posts', [ $this, 'hide_pos_only_from_frontend' ] );
79
80 // 404 on direct product URL access (/product/slug/, /?p=123) for
81 // `pos_only` products. `pre_get_posts` can't reliably block singular
82 // requests with a meta_query; this is the authoritative gate.
83 add_action( 'template_redirect', [ $this, 'block_pos_only_singular' ] );
84
85 // Hide `pos_only` from the public WooCommerce REST API for external
86 // consumers. The wepos POS endpoint inherits this filter through
87 // class extension, so we detect `/wepos/` routes and bail so the
88 // POS can still see POS-only products.
89 add_filter( 'woocommerce_rest_product_object_query', [ $this, 'hide_pos_only_from_wc_rest' ], 10, 2 );
90
91 // REST read/write support for `pos_visibility` — covers both the
92 // public WC REST endpoints and the POS endpoints.
93 add_filter( 'woocommerce_rest_prepare_product_object', [ $this, 'rest_expose_pos_visibility' ], 10, 2 );
94 add_action( 'woocommerce_rest_insert_product_object', [ $this, 'rest_save_pos_visibility' ], 10, 2 );
95
96 // Decimal quantities — always preserve fractional values at the data
97 // layer. WooCommerce casts every quantity passed through
98 // `wc_stock_amount()` to int via the default
99 // `woocommerce_stock_amount => intval` filter. That truncates
100 // decimals on both write AND read — so once a user toggles decimal
101 // quantities OFF, every previously saved decimal (order line qty,
102 // product stock) would display as an integer even though the DB still
103 // holds the float. Swapping in `floatval` is always safe: for an
104 // integer value like `5`, `floatval()` returns `5.0` which PHP
105 // coerces back to `"5"` on output (no visible change).
106 //
107 // The `enable_decimal_quantities` toggle only gates the UI surfaces
108 // (cart input, admin stock step, QuickEdit step, order REST schema).
109 remove_filter( 'woocommerce_stock_amount', 'intval' );
110 add_filter( 'woocommerce_stock_amount', 'floatval' );
111 add_action( 'woocommerce_before_product_object_save', [ $this, 'recalc_decimal_stock_status' ] );
112 }
113
114 /**
115 * Register custom POS order statuses.
116 *
117 * @since WEPOS_LITE_SINCE
118 *
119 * @return void
120 */
121 private function register_order_status() {
122 register_post_status(
123 'wc-pos-open',
124 [
125 'label' => _x( 'POS - Open', 'Order status', 'wepos' ),
126 'public' => true,
127 'exclude_from_search' => false,
128 'show_in_admin_all_list' => true,
129 'show_in_admin_status_list' => true,
130 'label_count' => _n_noop(
131 'POS - Open <span class="count">(%s)</span>',
132 'POS - Open <span class="count">(%s)</span>',
133 'wepos'
134 ),
135 ]
136 );
137 }
138
139 /**
140 * Add custom POS order statuses to WooCommerce status list.
141 *
142 * @param array $order_statuses Existing order statuses.
143 *
144 * @return array
145 */
146 public function wc_order_statuses( array $order_statuses ): array {
147 $order_statuses['wc-pos-open'] = _x( 'POS - Open', 'Order status', 'wepos' );
148
149 return $order_statuses;
150 }
151
152 /**
153 * Allow payment on pos-open orders.
154 *
155 * @param array $order_statuses Valid order statuses.
156 * @param \WC_Abstract_Order $order The order object.
157 *
158 * @return array
159 */
160 public function valid_order_statuses_for_payment( array $order_statuses, $order ): array {
161 $order_statuses[] = 'pos-open';
162
163 return $order_statuses;
164 }
165
166 /**
167 * Allow payment complete on pos-open orders.
168 *
169 * @param array $order_statuses Valid order statuses.
170 * @param \WC_Abstract_Order $order The order object.
171 *
172 * @return array
173 */
174 public function valid_order_statuses_for_payment_complete( array $order_statuses, $order ): array {
175 $order_statuses[] = 'pos-open';
176
177 return $order_statuses;
178 }
179
180 /**
181 * Set order created via wepos.
182 *
183 * @since WEPOS_LITE_SINCE
184 *
185 * @param int $order_id The order ID.
186 * @param \WC_Order $order The order object.
187 *
188 * @return void|\WP_Error
189 */
190 public function set_order_created_via_wepos( $order_id, $order ) {
191 if ( ! $order instanceof \WC_Order ) {
192 return;
193 }
194
195 if ( empty( $order->get_meta( '_wepos_is_pos_order' ) ) ) {
196 return;
197 }
198
199 $order->set_created_via( 'wepos' );
200 }
201
202 /**
203 * Override the tax location for POS orders based on order metadata.
204 *
205 * @since WEPOS_LITE_SINCE
206 *
207 * @param array $args Tax location arguments (country, state, postcode, city).
208 * @param \WC_Abstract_Order $order The order object.
209 *
210 * @return array
211 */
212 public function get_tax_location( $args, $order ) {
213 if ( ! $order instanceof \WC_Order ) {
214 return $args;
215 }
216
217 if ( empty( $order->get_meta( '_wepos_is_pos_order' ) ) ) {
218 return $args;
219 }
220
221 $tax_based_on = $order->get_meta( '_wepos_tax_based_on' );
222
223 if ( 'billing' === $tax_based_on ) {
224 $args['country'] = $order->get_billing_country();
225 $args['state'] = $order->get_billing_state();
226 $args['postcode'] = $order->get_billing_postcode();
227 $args['city'] = $order->get_billing_city();
228 } elseif ( 'shipping' === $tax_based_on ) {
229 $args['country'] = $order->get_shipping_country();
230 $args['state'] = $order->get_shipping_state();
231 $args['postcode'] = $order->get_shipping_postcode();
232 $args['city'] = $order->get_shipping_city();
233 } else {
234 // Default to store base address for POS orders.
235 $args['country'] = \WC()->countries->get_base_country();
236 $args['state'] = \WC()->countries->get_base_state();
237 $args['postcode'] = \WC()->countries->get_base_postcode();
238 $args['city'] = \WC()->countries->get_base_city();
239 }
240
241 return $args;
242 }
243
244 /**
245 * Override item-level tax after WooCommerce calculates taxes.
246 *
247 * If the item carries _wepos_pos_data metadata with tax_status = 'none',
248 * clear all taxes on that item.
249 *
250 * @since WEPOS_LITE_SINCE
251 *
252 * @param \WC_Order_Item $item The order item.
253 *
254 * @return void
255 */
256 public function order_item_after_calculate_taxes( $item ): void {
257 $meta_data = $item->get_meta_data();
258
259 foreach ( $meta_data as $meta ) {
260 if ( '_wepos_pos_data' === $meta->key ) {
261 $pos_data = json_decode( $meta->value, true );
262
263 if ( JSON_ERROR_NONE === json_last_error() && isset( $pos_data['tax_status'] ) && 'none' === $pos_data['tax_status'] ) {
264 $item->set_taxes( false );
265 }
266
267 break;
268 }
269 }
270 }
271
272 /**
273 * Override shipping item tax after WooCommerce calculates taxes.
274 *
275 * WC_Order_Item_Shipping ignores per-item tax_class and tax_status
276 * (get_tax_class() returns the global option, set_tax_status() is a no-op).
277 * The POS frontend stores the desired values in _wepos_pos_data meta.
278 *
279 * This handler:
280 * - Clears taxes when tax_status = 'none'
281 * - Recalculates with the correct tax_class when it differs from global
282 * - Back-calculates net amount when amount_includes_tax is true
283 *
284 * @since WEPOS_LITE_SINCE
285 *
286 * @param \WC_Order_Item_Shipping $item The shipping item.
287 * @param array $calculate_tax_for The tax calculation location data.
288 *
289 * @return void
290 */
291 public function order_item_shipping_after_calculate_taxes( $item, $calculate_tax_for ): void {
292 $pos_data = null;
293
294 foreach ( $item->get_meta_data() as $meta ) {
295 if ( '_wepos_pos_data' === $meta->key ) {
296 $pos_data = json_decode( $meta->value, true );
297 break;
298 }
299 }
300
301 if ( ! $pos_data || JSON_ERROR_NONE !== json_last_error() ) {
302 return;
303 }
304
305 $tax_status = $pos_data['tax_status'] ?? 'taxable';
306 $tax_class = $pos_data['tax_class'] ?? '';
307 $includes = ! empty( $pos_data['amount_includes_tax'] );
308
309 // Tax status = none → clear all taxes.
310 if ( 'none' === $tax_status ) {
311 $item->set_taxes( false );
312 return;
313 }
314
315 // Determine if we need to recalculate (custom tax class or amount includes tax).
316 $global_class = get_option( 'woocommerce_shipping_tax_class', 'inherit' );
317
318 // Resolve 'inherit' to empty string (standard) for comparison.
319 $effective_global = 'inherit' === $global_class ? '' : $global_class;
320 $needs_recalc = ( $tax_class !== $effective_global ) || $includes;
321
322 if ( ! $needs_recalc ) {
323 return;
324 }
325
326 // Use the POS-specified tax class for rate lookup.
327 $calculate_tax_for['tax_class'] = $tax_class;
328 $tax_rates = \WC_Tax::find_shipping_rates( $calculate_tax_for );
329
330 if ( $includes ) {
331 // Amount entered includes tax — back-calculate net and tax.
332 $inclusive_taxes = \WC_Tax::calc_inclusive_tax( (float) $item->get_total(), $tax_rates );
333 $net = (float) $item->get_total() - array_sum( $inclusive_taxes );
334 $item->set_total( wc_format_decimal( $net ) );
335 $item->set_taxes( [ 'total' => $inclusive_taxes ] );
336 } else {
337 // Recalculate taxes with the correct class.
338 $taxes = \WC_Tax::calc_tax( (float) $item->get_total(), $tax_rates, false );
339 $item->set_taxes( [ 'total' => $taxes ] );
340 }
341 }
342
343 /**
344 * Fix tax calculation for negative fees (discounts).
345 *
346 * WooCommerce bypasses normal tax calculation for negative fees, disregarding
347 * the tax_status and tax_class. This corrects that behavior for POS orders.
348 *
349 * @since WEPOS_LITE_SINCE
350 *
351 * @param \WC_Order_Item_Fee $fee_item The fee item.
352 * @param array $calculate_tax_for The tax calculation data.
353 *
354 * @return void
355 */
356 public function order_item_fee_after_calculate_taxes( $fee_item, $calculate_tax_for ): void {
357 if ( $fee_item->get_total() >= 0 ) {
358 return;
359 }
360
361 $tax_status = $fee_item->get_tax_status();
362
363 if ( 'taxable' === $tax_status ) {
364 $tax_class = $fee_item->get_tax_class();
365 $calculate_tax_for['tax_class'] = $tax_class ? $tax_class : '';
366
367 $tax_rates = \WC_Tax::find_rates( $calculate_tax_for );
368 $discount_taxes = \WC_Tax::calc_tax( (float) $fee_item->get_total(), $tax_rates );
369
370 $fee_item->set_taxes( [ 'total' => $discount_taxes ] );
371 } else {
372 $fee_item->set_taxes( [] );
373 }
374
375 $fee_item->save();
376 }
377
378 /**
379 * Hide POS-internal meta keys from the WooCommerce order edit screen.
380 *
381 * @since WEPOS_LITE_SINCE
382 *
383 * @param array $meta_keys Existing hidden meta keys.
384 *
385 * @return array
386 */
387 public function hidden_order_itemmeta( array $meta_keys ): array {
388 return array_merge( $meta_keys, [ '_wepos_pos_data', '_wepos_tax_status' ] );
389 }
390
391 /**
392 * Hide products flagged as POS-only from every frontend surface:
393 * shop loop, category/tag archives, search, widgets, related products,
394 * upsells/cross-sells, and the `[products]` / block-editor shortcodes.
395 *
396 * Skipped in the admin and during REST requests (REST has its own filter).
397 *
398 * @since 1.5.0
399 *
400 * @param \WP_Query $query
401 *
402 * @return void
403 */
404 public function hide_pos_only_from_frontend( $query ) {
405 if ( is_admin() ) {
406 return;
407 }
408
409 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
410 return;
411 }
412
413 if ( 'yes' !== wepos_get_option( 'enable_pos_only_products', 'wepos_general', 'no' ) ) {
414 return;
415 }
416
417 $post_type = $query->get( 'post_type' );
418 $is_product_query =
419 'product' === $post_type
420 || ( is_array( $post_type ) && in_array( 'product', $post_type, true ) )
421 || ( $query->is_main_query() && ( is_shop() || is_product_taxonomy() || is_product_category() || is_product_tag() ) );
422
423 if ( ! $is_product_query ) {
424 return;
425 }
426
427 $meta_query = $query->get( 'meta_query' );
428 if ( ! is_array( $meta_query ) ) {
429 $meta_query = [];
430 }
431
432 $meta_query[] = [
433 'relation' => 'OR',
434 [
435 'key' => self::POS_VISIBILITY_META,
436 'compare' => 'NOT EXISTS',
437 ],
438 [
439 'key' => self::POS_VISIBILITY_META,
440 'value' => 'pos_only',
441 'compare' => '!=',
442 ],
443 ];
444
445 $query->set( 'meta_query', $meta_query );
446 }
447
448 /**
449 * Block direct access to a `pos_only` product's single page — turn it
450 * into a 404 so the storefront never reveals the product, even when the
451 * URL is known (shared links, bookmarks, `/?p=123`).
452 *
453 * @since 1.5.0
454 *
455 * @return void
456 */
457 public function block_pos_only_singular() {
458 if ( is_admin() ) {
459 return;
460 }
461
462 if ( 'yes' !== wepos_get_option( 'enable_pos_only_products', 'wepos_general', 'no' ) ) {
463 return;
464 }
465
466 if ( ! is_singular( 'product' ) ) {
467 return;
468 }
469
470 $product_id = get_queried_object_id();
471 if ( ! $product_id ) {
472 return;
473 }
474
475 $visibility = get_post_meta( $product_id, self::POS_VISIBILITY_META, true );
476 if ( 'pos_only' !== $visibility ) {
477 return;
478 }
479
480 global $wp_query;
481 $wp_query->set_404();
482 status_header( 404 );
483 nocache_headers();
484
485 $template = get_404_template();
486 if ( $template ) {
487 include $template;
488 }
489 exit;
490 }
491
492 /**
493 * Hide `pos_only` products from the public WooCommerce REST API.
494 *
495 * The wepos POS endpoint extends `WC_REST_Products_Controller` and
496 * therefore inherits this filter — so we detect the namespace and bail,
497 * leaving POS listings intact.
498 *
499 * @since 1.5.0
500 *
501 * @param array $args
502 * @param \WP_REST_Request $request
503 *
504 * @return array
505 */
506 public function hide_pos_only_from_wc_rest( $args, $request ) {
507 if ( 'yes' !== wepos_get_option( 'enable_pos_only_products', 'wepos_general', 'no' ) ) {
508 return $args;
509 }
510
511 // Skip for wepos's own endpoints — the POS must see pos_only products.
512 if ( $request && 0 === strpos( (string) $request->get_route(), '/wepos/' ) ) {
513 return $args;
514 }
515
516 if ( ! isset( $args['meta_query'] ) || ! is_array( $args['meta_query'] ) ) {
517 $args['meta_query'] = [];
518 }
519
520 $args['meta_query'][] = [
521 'relation' => 'OR',
522 [
523 'key' => self::POS_VISIBILITY_META,
524 'compare' => 'NOT EXISTS',
525 ],
526 [
527 'key' => self::POS_VISIBILITY_META,
528 'value' => 'pos_only',
529 'compare' => '!=',
530 ],
531 ];
532
533 return $args;
534 }
535
536 /**
537 * Expose `pos_visibility` in WC REST product responses.
538 *
539 * @since 1.5.0
540 *
541 * @param \WP_REST_Response $response
542 * @param \WC_Product $product
543 *
544 * @return \WP_REST_Response
545 */
546 public function rest_expose_pos_visibility( $response, $product ) {
547 $value = get_post_meta( $product->get_id(), self::POS_VISIBILITY_META, true );
548 $response->data['pos_visibility'] = $value ? $value : 'pos_and_online';
549 return $response;
550 }
551
552 /**
553 * Persist `pos_visibility` sent on REST insert/update requests.
554 *
555 * @since 1.5.0
556 *
557 * @param \WC_Product $product
558 * @param \WP_REST_Request $request
559 *
560 * @return void
561 */
562 public function rest_save_pos_visibility( $product, $request ) {
563 if ( ! $request->has_param( 'pos_visibility' ) ) {
564 return;
565 }
566
567 $value = sanitize_text_field( $request['pos_visibility'] );
568 if ( ! array_key_exists( $value, self::get_pos_visibility_options() ) ) {
569 $value = 'pos_and_online';
570 }
571
572 update_post_meta( $product->get_id(), self::POS_VISIBILITY_META, $value );
573 }
574
575 /**
576 * Recompute stock_status for products with decimal stock quantities.
577 *
578 * WooCommerce's built-in out-of-stock check uses integer comparisons;
579 * a stock of `0.5` would be flagged as "in stock" but subsequent decimal
580 * reductions may cross zero at awkward times. This mirrors WC's own
581 * logic while treating a stock quantity > 0 as "in stock" regardless of
582 * fractional precision.
583 *
584 * @since 1.5.0
585 *
586 * @param \WC_Product $product
587 *
588 * @return void
589 */
590 public function recalc_decimal_stock_status( $product ): void {
591 if ( ! $product->get_manage_stock() ) {
592 $product->set_stock_status( 'instock' );
593 return;
594 }
595
596 $stock_quantity = (float) $product->get_stock_quantity();
597 $notify_threshold = (float) get_option( 'woocommerce_notify_no_stock_amount', 0 );
598 $above_threshold = $stock_quantity > 0 && $stock_quantity > $notify_threshold;
599 $allow_backorders = 'no' !== $product->get_backorders();
600
601 if ( $above_threshold ) {
602 $product->set_stock_status( 'instock' );
603 } elseif ( $allow_backorders ) {
604 $product->set_stock_status( 'onbackorder' );
605 } else {
606 $product->set_stock_status( 'outofstock' );
607 }
608 }
609 }
610