PluginProbe
PostNL for WooCommerce / trunk
PostNL for WooCommerce vtrunk
5.9.11 5.9.10 5.9.9 5.9.8 5.9.7 5.9.6 trunk 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 3.1.4 3.1.5 3.1.6 3.1.7 4.0.0 4.0.1 4.0.2 4.3.2 4.3.3 4.4.0 4.4.1 4.4.2 All 71 releases
woo-postnl / src / Utils.php

Utils.php in PostNL for WooCommerce trunk, at src/Utils.php

1,297 lines 35.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Utils file.
4 *
5 * @package PostNLWooCommerce
6 */
7
8 namespace PostNLWooCommerce;
9
10 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
11 use PostNLWooCommerce\Helper\Mapping;
12 use PostNLWooCommerce\Product\Single;
13 use WC_Product;
14 use PostNLWooCommerce\Shipping_Method\Settings;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * Class Utils
22 *
23 * @package PostNLWooCommerce
24 */
25 class Utils {
26 /**
27 * List of days of week.
28 *
29 * @return array.
30 */
31 public static function days_of_week() {
32 return array(
33 'mon' => esc_html__( 'Monday', 'postnl-for-woocommerce' ),
34 'tue' => esc_html__( 'Tuesday', 'postnl-for-woocommerce' ),
35 'wed' => esc_html__( 'Wednesday', 'postnl-for-woocommerce' ),
36 'thu' => esc_html__( 'Thursday', 'postnl-for-woocommerce' ),
37 'fri' => esc_html__( 'Friday', 'postnl-for-woocommerce' ),
38 'sat' => esc_html__( 'Saturday', 'postnl-for-woocommerce' ),
39 'sun' => esc_html__( 'Sunday', 'postnl-for-woocommerce' ),
40 );
41 }
42
43 /**
44 * Get available country.
45 *
46 * @return array.
47 */
48 public static function get_available_country() {
49 return array( 'NL', 'BE' );
50 }
51
52 /**
53 * Get available country for the letterbox.
54 *
55 * @return array.
56 */
57 public static function get_available_country_for_letterbox() {
58 return array( 'NL' );
59 }
60
61 /**
62 * Get the list of countries where adults-only products can be shipped.
63 *
64 * @return array.
65 */
66 public static function get_adults_only_shipping_countries(): array {
67 return array( 'NL' );
68 }
69
70 /**
71 * Get store base country.
72 *
73 * @return String.
74 */
75 public static function get_base_country() {
76 $base_location = wc_get_base_location();
77
78 return $base_location['country'];
79 }
80
81 /**
82 * Get Unit of Measurement value that is used in PostNL REST API.
83 *
84 * @return String.
85 */
86 public static function used_api_uom() {
87 // API use Grams.
88 return 'g';
89 }
90
91 /**
92 * Get Unit of Measurement value from WooCommerce settings.
93 *
94 * @return String.
95 */
96 public static function get_uom() {
97 return get_option( 'woocommerce_weight_unit' );
98 }
99
100 /**
101 * Get store base state.
102 *
103 * @return String.
104 */
105 public static function get_base_state() {
106 $base_location = wc_get_base_location();
107
108 return $base_location['state'];
109 }
110
111 /**
112 * Check if the current settings use available country.
113 */
114 public static function use_available_country() {
115 return ( in_array( self::get_base_country(), self::get_available_country(), true ) );
116 }
117
118 /**
119 * Convert the key if it's different.
120 *
121 * @param String $key Key of the data.
122 *
123 * @return String.
124 */
125 public static function convert_data_key( $key ) {
126 $keys = array(
127 'dropoff_points' => 'pickup_points',
128 );
129
130 return ! empty( $keys[ $key ] ) ? $keys[ $key ] : $key;
131 }
132
133 /**
134 * Get field name without prefix.
135 *
136 * @param String $prefix Prefix of the field.
137 * @param String $field_name Name of the field.
138 *
139 * @return String
140 */
141 public static function remove_prefix_field( $prefix, $field_name ) {
142 return str_replace( $prefix, '', $field_name );
143 }
144
145 /**
146 * Change time string to only display hour and minutes.
147 *
148 * @param String $time_string Time string example ( 23:33:00 ).
149 *
150 * @return String
151 */
152 public static function get_hour_min( $time_string ) {
153 $exp_time = explode( ':', $time_string );
154
155 if ( empty( $exp_time ) ) {
156 return $time_string;
157 }
158
159 if ( 2 > count( $exp_time ) ) {
160 return $time_string;
161 }
162
163 return $exp_time[0] . ':' . $exp_time[1];
164 }
165
166 /**
167 * Convert the distance to KM if needs be.
168 *
169 * @param Float $distance distance in meter.
170 *
171 * @return String.
172 */
173 public static function maybe_convert_km( $distance ) {
174 $distance = intval( $distance );
175
176 return ( 999 < $distance ) ? round( ( $distance / 1000 ), 2 ) . ' km' : $distance . ' m';
177 }
178
179 /**
180 * Convert the weight based on the weight unit.
181 *
182 * @param Float $weight Weight of the thing.
183 *
184 * @return Float in gram.
185 */
186 public static function maybe_convert_weight( $weight ) {
187 return wc_get_weight( $weight, 'g' );
188 }
189
190 /**
191 * Generate PostNL tracking URL.
192 *
193 * @param String $barcode Generated barcode when creating the label.
194 * @param String $destination Two digits ISO country code.
195 * @param String $postcode Destination postcode (optional).
196 *
197 * @return String
198 */
199 public static function generate_tracking_url( $barcode, $destination, $postcode = '' ) {
200 $url = 'https://postnl.nl/tracktrace/';
201 $url_args = array_filter(
202 array(
203 'B' => $barcode,
204 'P' => $postcode,
205 'D' => $destination,
206 'T' => 'C',
207 ),
208 function ( $arg ) {
209 return ! empty( $arg );
210 }
211 );
212
213 return add_query_arg( $url_args, $url );
214 }
215
216 /**
217 * Generate the label file name.
218 *
219 * @param Int $order_id ID of the order object.
220 * @param String $label_type Type of label.
221 * @param String $barcode Barcode string.
222 * @param String $label_format Label Format whether A4 or A6.
223 * @param String $extension Label extension format given from API response. The extension could be change by the settings page.
224 *
225 * @return String.
226 */
227 public static function generate_label_name( $order_id, $label_type, $barcode, $label_format, $extension ) {
228 return 'postnl-' . $order_id . '-' . $label_type . '-' . $barcode . '-' . $label_format . '.' . $extension;
229 }
230
231 /**
232 * Get the type of label response.
233 *
234 * @return Array.
235 */
236 public static function get_label_response_type() {
237 return array(
238 'MergedLabels' => array(
239 'content_type_key' => 'Labeltype',
240 'content_type_value' => 'Label',
241 'barcode_key' => 'Barcodes',
242 ),
243 'ResponseShipments' => array(
244 'content_type_key' => 'OutputType',
245 'content_type_value' => 'PDF',
246 'barcode_key' => 'Barcode',
247 ),
248 );
249 }
250
251 /**
252 * Parsers a given array of arguments using a specific scheme.
253 *
254 * The scheme is a `key => array` associative array, where the `key` represents the argument key and the `array`
255 * represents the scheme for that single argument. Each scheme may have the following:
256 * * `default` - the default value to use if the arg is not given
257 * * `error` - the message of the exception if the arg is not given and no `default` is in the scheme
258 * * `validate` - a validation callback that receives the arg, the args array and the scheme as arguments.
259 * * `sanitize` - a sanitization callback similar to `validate` but should return the sanitized value.
260 * * `rename` - an optional new name for the argument key.
261 *
262 * @param array $args The arguments to parse.
263 * @param array $scheme The scheme to parse with, or a fixed scalar value.
264 *
265 * @return array The parsed arguments.
266 *
267 * @throws \Exception If an argument does not exist in $args and has no `default` in the $scheme.
268 * @since [*next-version*]
269 */
270 public static function parse_args( $args, $scheme ) {
271 $final_args = array();
272
273 foreach ( $scheme as $key => $s_scheme ) {
274 // If not an array, just use it as a value.
275 if ( ! is_array( $s_scheme ) ) {
276 $final_args[ $key ] = $s_scheme;
277 continue;
278 }
279
280 // Rename the key if "rename" was specified.
281 $new_key = empty( $s_scheme['rename'] ) ? $key : $s_scheme['rename'];
282
283 // Recurse for array values and nested schemes.
284 if ( ! empty( $args[ $key ] ) && isset( $s_scheme[0] ) && is_array( $s_scheme[0] ) ) {
285 $final_args[ $new_key ] = static::parse_args( $args[ $key ], $s_scheme );
286 continue;
287 }
288
289 // If the key is not set in the args.
290 if ( ! isset( $args[ $key ] ) ) {
291 // If no default value is given, throw.
292 if ( ! isset( $s_scheme['default'] ) ) {
293 // If no default value is specified, throw an exception.
294 $message = ! isset( $s_scheme['error'] )
295 // translators: %s is a field argument.
296 ? sprintf( __( 'Please specify a "%s" argument', 'postnl-for-woocommerce' ), $key )
297 : $s_scheme['error'];
298
299 throw new \Exception( $message );
300 }
301 // If a default value is specified, use that as the value.
302 $value = $s_scheme['default'];
303 } else {
304 $value = $args[ $key ];
305 }
306
307 // Call the validation function.
308 if ( ! empty( $s_scheme['validate'] ) && is_callable( $s_scheme['validate'] ) ) {
309 call_user_func_array( $s_scheme['validate'], array( $value, $args, $scheme ) );
310 }
311
312 // Call the sanitization function and get the sanitized value.
313 if ( ! empty( $s_scheme['sanitize'] ) && is_callable( $s_scheme['sanitize'] ) ) {
314 $value = call_user_func_array( $s_scheme['sanitize'], array( $value, $args, $scheme ) );
315 }
316
317 $final_args[ $new_key ] = $value;
318 }
319
320 return $final_args;
321 }
322
323 /**
324 * Unset/remove any items that are empty strings or 0
325 *
326 * @param array $array Array value.
327 *
328 * @return array
329 */
330 public static function unset_empty_values( array $array ) {
331 foreach ( $array as $k => $v ) {
332 if ( is_array( $v ) ) {
333 $array[ $k ] = self::unset_empty_values( $v );
334 }
335
336 if ( empty( $v ) ) {
337 unset( $array[ $k ] );
338 }
339 }
340
341 return $array;
342 }
343
344 /**
345 * Get shipping zone base on the shipping country and state.
346 *
347 * @param String $to_country 2 digit country code.
348 * @param String $to_state 2 digit state code.
349 *
350 * @return String
351 */
352 public static function get_shipping_zone( string $to_country, string $to_state ): string {
353 if ( in_array( $to_country, array( 'NL', 'BE' ) ) ) {
354 return $to_country;
355 }
356
357 if ( self::is_canary_island( $to_state, $to_country ) ) {
358 return 'ROW';
359 }
360
361 if ( in_array( $to_country, WC()->countries->get_european_union_countries(), true ) ) {
362 return 'EU';
363 }
364
365 return 'ROW';
366 }
367
368 /**
369 * Check if the string is JSON or not.
370 *
371 * @param Mixed $value String or value that will be validated.
372 *
373 * @return Boolean
374 */
375 public static function is_json( $value ) {
376 if ( is_array( $value ) || is_object( $value ) ) {
377 return false;
378 }
379
380 json_decode( $value );
381
382 return json_last_error() === JSON_ERROR_NONE;
383 }
384
385 /**
386 * Generating meta box fields.
387 *
388 * @param array $fields list of fields.
389 */
390 public static function fields_generator( $fields ) {
391 foreach ( $fields as $field ) {
392 if ( empty( $field['id'] ) ) {
393 continue;
394 }
395
396 if ( ! empty( $field['container'] ) && true === $field['container'] ) {
397 ?>
398 <div class="shipment-postnl-row-container shipment-<?php echo esc_attr( $field['id'] ); ?>">
399 <?php
400 }
401
402 switch ( $field['type'] ) {
403 case 'select':
404 woocommerce_wp_select( $field );
405 break;
406
407 case 'checkbox':
408 woocommerce_wp_checkbox( $field );
409 break;
410
411 case 'hidden':
412 woocommerce_wp_hidden_input( $field );
413 break;
414
415 case 'radio':
416 woocommerce_wp_radio( $field );
417 break;
418
419 case 'textarea':
420 woocommerce_wp_textarea_input( $field );
421 break;
422
423 case 'break':
424 echo '<div class="postnl-break-line ' . esc_attr( $field['id'] ) . '"><hr id="' . esc_attr( $field['id'] ) . '" /></div>';
425 break;
426
427 case 'text':
428 case 'number':
429 default:
430 woocommerce_wp_text_input( $field );
431 break;
432 }
433
434 if ( ! empty( $field['container'] ) && true === $field['container'] ) {
435 ?>
436 </div>
437 <?php
438 }
439 }
440 }
441
442 /**
443 * Get log URL in the admin.
444 *
445 * @return String.
446 */
447 public static function get_log_url() {
448 return Logger::get_log_url();
449 }
450
451 /**
452 * Get paper size information.
453 *
454 * @param String $paper Paper name.
455 *
456 * @return Array.
457 */
458 public static function get_paper_size( $paper = 'A4' ) {
459 $papers = array(
460 'A4' => array(
461 'width' => '297.03888888889',
462 'height' => '209.90277777778',
463 ),
464 'A6' => array(
465 'width' => '148.00086111111',
466 'height' => '105.00077777778',
467 ),
468 );
469
470 return isset( $papers[ $paper ] ) ? $papers[ $paper ] : array();
471 }
472
473 /**
474 * @param string $shipping_method Cart shipping method.
475 *
476 * @return string.
477 */
478 public static function get_cart_shipping_method_id( $shipping_method ) {
479 if ( empty( $shipping_method ) ) {
480 return $shipping_method;
481 }
482
483 // Assumes format 'name:id'
484 $shipping_method = explode( ':', $shipping_method );
485
486 return $shipping_method[0] ?? $shipping_method;
487 }
488
489 /**
490 * Get barcode range.
491 *
492 * @param $barcode_type .
493 * @param $globalpack_customer_code .
494 *
495 * @return string.
496 */
497 public static function get_barcode_range( $barcode_type, $globalpack_customer_code ) {
498 $globalpack_barcodes = Mapping::products_custom_barcode_types();
499
500 if ( isset( $globalpack_barcodes[ $barcode_type ] ) ) {
501 return 'NL';
502 }
503
504 if ( 0 === strpos( $barcode_type, 'C' ) ) {
505 return $globalpack_customer_code;
506 }
507
508 return '';
509 }
510
511 /**
512 * Get selected features in the order admin.
513 *
514 * @param array $backend_data list of backend data.
515 *
516 * @return array.
517 */
518 public static function get_selected_label_features( $backend_data ) {
519 $selected_features = array_filter(
520 $backend_data,
521 function ( $value ) {
522 return ( 'yes' === $value );
523 }
524 );
525
526 if ( isset( $selected_features['create_return_label'] ) ) {
527 unset( $selected_features['create_return_label'] );
528 }
529
530 return $selected_features;
531 }
532
533 /**
534 * Generate Delivery Date.
535 *
536 * @param $delivery_info
537 *
538 * @return string.
539 */
540 public static function generate_delivery_date_html( $delivery_info ) {
541 if ( ! isset( $delivery_info['delivery_day_date'] ) ) {
542 return __( 'As soon as possible', 'postnl-for-woocommerce' );
543 }
544
545 // Get the day abbreviation (Mon, Tue, Wed, etc.) and convert to lowercase
546 $day_key = strtolower( date( 'D', strtotime( $delivery_info['delivery_day_date'] ) ) );
547
548 // Get translated day names from existing method
549 $days_of_week = self::days_of_week();
550 $day = $days_of_week[ $day_key ];
551
552 // Convert to the Dutch date format
553 $date_obj = date_create_from_format( 'Y-m-d', $delivery_info['delivery_day_date'] );
554 $dutch_date = date_format( $date_obj, 'd/m/Y' );
555
556 return $day . ' ' . $dutch_date;
557 }
558
559
560 /**
561 * Generate selected shipping options html.
562 *
563 * @param $backend_data.
564 *
565 * @return string.
566 */
567 public static function generate_shipping_options_html( $backend_data, $order_id ) {
568 $options_to_display = self::get_shipping_options( $order_id );
569 $selected_options = array();
570
571 foreach ( $backend_data as $option_key => $value ) {
572 if ( 'yes' !== $value ) {
573 continue;
574 }
575
576 // Resolve either letterbox key to its 24h/48h label; 'letterbox_48' is
577 // not in the display map, so the recorded variant wins, else the key.
578 if ( 'letterbox' === $option_key || 'letterbox_48' === $option_key ) {
579 $order = wc_get_order( $order_id );
580 $letterbox_type = ( $order instanceof \WC_Order ) ? $order->get_meta( '_postnl_letterbox_type' ) : '';
581
582 if ( ! in_array( $letterbox_type, array( 'letterbox', 'letterbox_48' ), true ) ) {
583 $letterbox_type = ( 'letterbox_48' === $option_key ) ? 'letterbox_48' : 'letterbox';
584 }
585
586 $selected_options[] = self::get_letterbox_admin_label( $letterbox_type );
587 continue;
588 }
589
590 if ( ! isset( $options_to_display[ $option_key ] ) ) {
591 continue;
592 }
593
594 $selected_options[] = $options_to_display[ $option_key ];
595 }
596
597 if ( empty( $selected_options ) ) {
598 return '-';
599 }
600
601 return implode( ', ', $selected_options );
602 }
603
604 /**
605 * Get available shipping options.
606 *
607 * @return array.
608 */
609 public static function get_shipping_options( $order_id ) {
610 $order = wc_get_order( $order_id );
611 $shipping_destination = self::get_shipping_zone( $order->get_shipping_country(), $order->get_shipping_state() );
612
613 // Base shipping options (common to all destinations).
614 $base_options = array(
615 'standard_shipment' => esc_html__( 'Standard shipment', 'postnl-for-woocommerce' ),
616 'id_check' => esc_html__( 'ID Check (18+)', 'postnl-for-woocommerce' ),
617 'return_no_answer' => esc_html__( 'Return if no answer', 'postnl-for-woocommerce' ),
618 'signature_on_delivery' => esc_html__( 'Signature on Delivery', 'postnl-for-woocommerce' ),
619 'only_home_address' => esc_html__( 'Only Home Address', 'postnl-for-woocommerce' ),
620 'letterbox' => esc_html__( 'Letterbox', 'postnl-for-woocommerce' ),
621 'packets' => esc_html__( 'Packet', 'postnl-for-woocommerce' ),
622 'standard_belgium' => esc_html__( 'Standard Shipment Belgium', 'postnl-for-woocommerce' ),
623 'mailboxpacket' => esc_html__( 'Boxable Packet', 'postnl-for-woocommerce' ),
624 'track_and_trace' => esc_html__( 'Track & Trace', 'postnl-for-woocommerce' ),
625 'insured_shipping' => esc_html__( 'Insured Shipping', 'postnl-for-woocommerce' ),
626 'delivery_code_at_door' => esc_html__( 'Delivery Code at Door', 'postnl-for-woocommerce' ),
627 );
628
629 // Modify options based on shipping destination.
630 switch ( $shipping_destination ) {
631 case 'BE':
632 case 'NL':
633 $destination_options = array(
634 'eu_parcel' => esc_html__( 'Parcels Non-EU Insured', 'postnl-for-woocommerce' ),
635 'parcel_non_eu' => esc_html__( 'Parcels non-EU Insured Plus', 'postnl-for-woocommerce' ),
636 );
637 break;
638
639 case 'EU':
640 $destination_options = array(
641 'eu_parcel' => esc_html__( 'Parcels EU', 'postnl-for-woocommerce' ),
642 'insured_plus' => esc_html__( 'Insured Plus', 'postnl-for-woocommerce' ),
643 );
644 break;
645
646 default:
647 $destination_options = array(
648 'parcel_non_eu' => esc_html__( 'Parcels Non-EU', 'postnl-for-woocommerce' ),
649 'insured_plus' => esc_html__( 'Insured Plus', 'postnl-for-woocommerce' ),
650 );
651 break;
652 }
653
654 return array_merge( $base_options, $destination_options );
655 }
656
657 /**
658 * Check if current cart is eligible for automatically use letterbox.
659 *
660 * @param \WC_Cart|null $cart Cart object.
661 *
662 * @return boolean
663 */
664 public static function is_cart_eligible_auto_letterbox( ?\WC_Cart $cart ): bool {
665 if ( is_null( $cart ) ) {
666 return false;
667 }
668
669 if ( ! in_array( WC()->customer->get_shipping_country(), self::get_available_country_for_letterbox(), true ) ) {
670 return false;
671 }
672
673
674 if ( self::contains_adults_only_products( $cart->get_cart() ) ) {
675 return false;
676 }
677
678 return self::check_products_for_letterbox( $cart->get_cart() );
679 }
680
681 /**
682 * Check if current order/cart is eligible for automatically use letterbox.
683 *
684 * @param \WC_Order|int $order \WC_order or Order ID.
685 *
686 * @return boolean
687 */
688 public static function is_order_eligible_auto_letterbox( $order ) {
689 if ( wc_get_base_location()['country'] == 'BE' ) {
690 return false;
691 }
692
693 // Check if order id provided.
694 if ( is_int( $order ) ) {
695 $order = wc_get_order( $order );
696 }
697
698 if ( ! is_a( $order, 'WC_Order' ) ) {
699 return false;
700 }
701
702 if ( $order->meta_exists( '_postnl_letterbox' ) ) {
703 return (bool) $order->get_meta( '_postnl_letterbox', true );
704 }
705
706 if ( ! in_array( $order->get_shipping_country(), self::get_available_country_for_letterbox(), true ) ) {
707 $order->update_meta_data( '_postnl_letterbox', false );
708 $order->save_meta_data();
709
710 return false;
711 }
712
713 $products = $order->get_items();
714 $is_eligible = self::check_products_for_letterbox( $products );
715
716 $order->update_meta_data( '_postnl_letterbox', $is_eligible );
717 $order->save_meta_data();
718
719 return $is_eligible;
720 }
721
722 /**
723 * Check if given products are suitable for the letterbox.
724 *
725 * @param array $products WC_Products[] or order_item[].
726 *
727 * @return bool
728 */
729 public static function check_products_for_letterbox( array $products ): bool {
730 $total_fill_ratio = 0;
731 $is_eligible = false;
732
733 foreach ( $products as $item ) {
734 $variation_id = $item['variation_id'] ?? $item->get_variation_id();
735 $product_id = $item['product_id'] ?? $item->get_product_id();
736 $target_id = $variation_id > 0 ? $variation_id : $product_id;
737 $product = wc_get_product( $target_id );
738
739 // If the product is not found, consider the order not eligible.
740 if ( ! $product instanceof WC_Product ) {
741 return false;
742 }
743
744 if ( ! $product->needs_shipping() ) {
745 continue;
746 }
747
748 $is_eligible = self::is_letterbox_parcel_product( $product );
749
750 if ( ! $is_eligible ) {
751 return false;
752 }
753
754 $quantity = is_array( $item ) ? ( $item['quantity'] ?? 1 ) : $item->get_quantity();
755 $max_qty = (int) $product->get_meta( Product\Single::MAX_QTY_PER_LETTERBOX );
756 $parent = ( $variation_id > 0 ) ? wc_get_product( $product->get_parent_id() ) : null;
757
758 if ( $max_qty <= 0 && $parent ) {
759 $max_qty = (int) $parent->get_meta( Product\Single::MAX_QTY_PER_LETTERBOX );
760 }
761
762 if ( $max_qty > 0 ) {
763 $total_fill_ratio += ( $quantity / $max_qty );
764 }
765 }
766
767 return $is_eligible && $total_fill_ratio <= 1;
768 }
769
770 /**
771 * Determine if the given order contains any adults-only products.
772 *
773 * @param \WC_Order|int $order \WC_order or Order ID.
774 *
775 * @return boolean
776 */
777 public static function is_adults_only_order( $order ): bool {
778 if ( 'BE' === wc_get_base_location()['country'] ) {
779 return false;
780 }
781
782 // Check if order id provided.
783 if ( is_int( $order ) ) {
784 $order = wc_get_order( $order );
785 }
786
787 if ( ! is_a( $order, 'WC_Order' ) ) {
788 return false;
789 }
790
791 if ( ! in_array( $order->get_shipping_country(), self::get_adults_only_shipping_countries(), true ) ) {
792 return false;
793 }
794
795 return self::contains_adults_only_products( $order->get_items() );
796 }
797
798 /**
799 * Determine if any products are marked as adults-only.
800 *
801 * @param array $products WC_Products[] or order_item[].
802 *
803 * @return bool
804 */
805 public static function contains_adults_only_products( $products ): bool {
806
807 foreach ( $products as $item_id => $item ) {
808 $product = wc_get_product( $item['product_id'] ?? $item->get_product_id() );
809 if ( ! is_a( $product, 'WC_Product' ) ) {
810 continue;
811 }
812
813 if ( ! $product->needs_shipping() ) {
814 continue;
815 }
816
817 if ( self::is_adults_only_product( $product ) ) {
818 return true;
819 }
820 }
821
822 return false;
823 }
824
825 /**
826 * Check if the given product is marked as adults-only.
827 *
828 * @param WC_Product $product Product object.
829 * @return bool
830 */
831 public static function is_adults_only_product( WC_Product $product ): bool {
832 return 'yes' === $product->get_meta( Single::ADULTS_ONLY_FIELD );
833 }
834
835 /**
836 * Check if the given product is marked as Letterbox Parcel.
837 *
838 * @param WC_Product $product Product object.
839 *
840 * @return bool
841 */
842 public static function is_letterbox_parcel_product( WC_Product $product ): bool {
843 if ( 'yes' === $product->get_meta( Single::LETTERBOX_PARCEL ) ) {
844 return true;
845 }
846
847 if ( $product instanceof \WC_Product_Variation ) {
848 $parent = wc_get_product( $product->get_parent_id() );
849
850 return $parent && 'yes' === $parent->get_meta( Single::LETTERBOX_PARCEL );
851 }
852
853 return false;
854 }
855
856 /**
857 * Prepare array of selected by the user shipping option.
858 *
859 * @param string $selected_value Selected default shipping option value.
860 *
861 * @return array
862 */
863 public static function prepare_shipping_options( $selected_value ) {
864 $shipping_options = explode( '|', $selected_value );
865 $shipping_options = array_fill_keys( $shipping_options, 'yes' );
866
867 return $shipping_options;
868 }
869
870 /**
871 * Normalize an admin letterbox selection into the generic feature + variant.
872 *
873 * Collapses the explicit 'letterbox_48' token onto the generic 'letterbox'
874 * feature (which the label engine routes on) and reports the chosen 24h/48h
875 * variant so the caller can persist it to '_postnl_letterbox_type'. When both
876 * are selected, 48h wins.
877 *
878 * @since 5.9.8
879 *
880 * @param array $backend_options Backend option map ( feature => 'yes' ).
881 *
882 * @return array {
883 * @type array $options Normalized options with only the generic 'letterbox' feature.
884 * @type string $type Variant token: 'letterbox', 'letterbox_48', or '' when no letterbox is selected.
885 * }
886 */
887 public static function normalize_letterbox_options( $backend_options ) {
888 if ( ! is_array( $backend_options ) ) {
889 return array(
890 'options' => array(),
891 'type' => '',
892 );
893 }
894
895 $type = '';
896
897 if ( 'yes' === ( $backend_options['letterbox_48'] ?? '' ) ) {
898 $type = 'letterbox_48';
899 } elseif ( 'yes' === ( $backend_options['letterbox'] ?? '' ) ) {
900 $type = 'letterbox';
901 }
902
903 // Carry the variant separately; downstream letterbox logic only knows the generic feature.
904 unset( $backend_options['letterbox_48'] );
905
906 if ( '' !== $type ) {
907 $backend_options['letterbox'] = 'yes';
908 }
909
910 return array(
911 'options' => $backend_options,
912 'type' => $type,
913 );
914 }
915
916 /**
917 * Get filtered pickup points specific infos.
918 *
919 * @param array $infos Dropoff points informations.
920 *
921 * @return array
922 */
923 public static function get_filtered_pickup_points_infos( $infos ) {
924 $filtered_infos = array_filter(
925 $infos,
926 function ( $info ) {
927 $displayed_info = array(
928 'dropoff_points_date',
929 'dropoff_points_time',
930 );
931
932 return in_array( $info, $displayed_info, true );
933 },
934 ARRAY_FILTER_USE_KEY
935 );
936
937 $address_info = array_filter(
938 $infos,
939 function ( $info ) {
940 return false !== strpos( $info, '_address_' );
941 },
942 ARRAY_FILTER_USE_KEY
943 );
944
945 if ( ! empty( $address_info ) ) {
946 $filtered_infos['address'] = implode( ', ', $address_info );
947 ksort( $filtered_infos );
948 }
949
950 return $filtered_infos;
951 }
952
953 /**
954 * The Canary Islands, due to the distance from mainland Spain, count as a non-EU destination from a transport point of view.
955 * This means the regular EU shipments cannot be used for these destinations,
956 * and instead the non-EU product code must be used, along with country code IC.
957 *
958 * Return true if for Spanish states "Santa Cruz de Tenerife" or "Las Palmas".
959 *
960 * @param $state String Shipping state.
961 * @param $country String Shipping country.
962 *
963 * @return bool
964 */
965 public static function is_canary_island( string $state, string $country ): bool {
966 if ( 'ES' !== strtoupper( $country ) ) {
967 return false;
968 }
969
970 if ( in_array( $state, array( 'TF', 'GC' ) ) ) {
971 return true;
972 }
973
974 return false;
975 }
976
977 /**
978 * Get the frontend locations.
979 *
980 * @return array $locations
981 */
982 public static function get_frontend_locations(): array {
983 // Allow filtering of the locations.
984 return apply_filters(
985 'postnl_frontend_locations',
986 array(
987 'cart_before_checkout' => array(
988 'woocommerce_proceed_to_checkout',
989 'postnl_before_woocommerce/proceed-to-checkout-block',
990 ),
991 'cart_after_checkout' => array(
992 'woocommerce_after_cart_totals',
993 'postnl_after_woocommerce/proceed-to-checkout-block',
994 ),
995 'checkout_before_customer_details' => array(
996 'woocommerce_checkout_before_customer_details',
997 ),
998 'checkout_after_customer_details' => array(
999 'woocommerce_after_order_notes',
1000 ),
1001 'minicart_before_buttons' => array(
1002 'woocommerce_widget_shopping_cart_before_buttons',
1003 'postnl_before_woocommerce/mini-cart-footer-block',
1004 ),
1005 'minicart_after_buttons' => array(
1006 'woocommerce_widget_shopping_cart_after_buttons',
1007 'postnl_after_woocommerce/mini-cart-footer-block',
1008 ),
1009 )
1010 );
1011 }
1012
1013 /**
1014 * Get the frontend location mapping.
1015 *
1016 * @return array $mapping
1017 */
1018 public static function get_frontend_location_mapping(): array {
1019 // Allow filtering of the mapping.
1020 return apply_filters(
1021 'postnl_frontend_location_mapping',
1022 array(
1023 'cart_before_checkout' => array( 'postnl_cart_auto_render_button', 'postnl_cart_button_placement', 'before_checkout' ),
1024 'cart_after_checkout' => array( 'postnl_cart_auto_render_button', 'postnl_cart_button_placement', 'after_checkout' ),
1025 'checkout_before_customer_details' => array( 'postnl_checkout_auto_render_button', 'postnl_checkout_button_placement', 'before_customer_details' ),
1026 'checkout_after_customer_details' => array( 'postnl_checkout_auto_render_button', 'postnl_checkout_button_placement', 'after_customer_details' ),
1027 'minicart_before_buttons' => array( 'postnl_minicart_auto_render_button', 'postnl_minicart_button_placement', 'before_buttons' ),
1028 'minicart_after_buttons' => array( 'postnl_minicart_auto_render_button', 'postnl_minicart_button_placement', 'after_buttons' ),
1029 )
1030 );
1031 }
1032
1033 /**
1034 * Check if customer default country is allowed.
1035 *
1036 * @param \WC_Customer $customer Customer object.
1037 * @param array $allowed_countries list of allowed countries.
1038 *
1039 * @return bool
1040 */
1041 public static function is_customer_country_allowed( $customer, $allowed_countries ): bool {
1042 $billing_country = $customer->get_billing_country();
1043 $shipping_country = $customer->get_shipping_country();
1044
1045 if ( ! in_array( $billing_country, $allowed_countries, true ) &&
1046 ! in_array( $shipping_country, $allowed_countries, true ) ) {
1047 return false;
1048 }
1049 return true;
1050 }
1051
1052 /**
1053 * Get WooCommerce shop order screen ID.
1054 *
1055 * @return string
1056 */
1057 public static function get_order_screen_id(): string {
1058 try {
1059 return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
1060 ? wc_get_page_screen_id( 'shop-order' )
1061 : 'shop_order';
1062 } catch ( \Exception $e ) {
1063 return 'shop_order';
1064 }
1065 }
1066
1067 /**
1068 * Get non-EU countries
1069 *
1070 * @return array
1071 */
1072 public static function get_non_eu_countries() {
1073 $all_countries = WC()->countries->get_countries();
1074 $eu_countries = WC()->countries->get_european_union_countries();
1075 $european_non_eu = array( 'MC', 'SM', 'VA', 'AD', 'ME', 'RS', 'MK', 'AL', 'BA', 'XK', 'MD', 'UA', 'BY', 'RU', 'GE', 'AM', 'AZ', 'TR' );
1076
1077 // Remove EU countries from the list.
1078 $non_eu_countries = array_diff_key( $all_countries, array_flip( $eu_countries ) );
1079
1080 // Remove European non-EU countries from the list.
1081 $non_eu_countries = array_diff_key( $non_eu_countries, array_flip( $european_non_eu ) );
1082
1083 // Also remove Netherlands specifically.
1084 unset( $non_eu_countries['NL'] );
1085
1086 return $non_eu_countries;
1087 }
1088
1089 /**
1090 * Check if a country is non-EU (and not European)
1091 *
1092 * @param string $country_code Country code to check
1093 *
1094 * @return bool
1095 */
1096 public static function is_non_eu_country( $country_code ) {
1097 $non_eu_countries = self::get_non_eu_countries();
1098 return array_key_exists( $country_code, $non_eu_countries );
1099 }
1100
1101 /**
1102 * Get merchant code for a specific country
1103 *
1104 * @param string $country_code Country code
1105 *
1106 * @return string|null Merchant code or null if not found
1107 */
1108 public static function get_merchant_code_for_country( $country_code ) {
1109 $merchant_codes = get_option( Settings::MERCHANT_CODES_OPTION, array() );
1110 return isset( $merchant_codes[ $country_code ] ) ? $merchant_codes[ $country_code ] : null;
1111 }
1112
1113 /**
1114 * Get fee total price for display, respecting WooCommerce tax settings.
1115 *
1116 * This method calculates whether to display fees including or excluding tax
1117 * based on WooCommerce tax settings and customer tax status.
1118 *
1119 * Note: Shipping and fee prices are always entered as base prices (excluding tax)
1120 * in WooCommerce, regardless of the woocommerce_prices_include_tax setting.
1121 *
1122 * @param float $fee_amount The base fee amount (always excluding tax).
1123 *
1124 * @return float Fee amount adjusted for display per tax settings.
1125 */
1126 public static function get_fee_total_price( float $fee_amount ): float {
1127 if ( empty( $fee_amount ) || $fee_amount <= 0 ) {
1128 return 0.0;
1129 }
1130
1131 // if taxes disabled, return as-is.
1132 if ( is_null( WC()->cart ) || ! wc_tax_enabled() ) {
1133 return $fee_amount;
1134 }
1135
1136 // Check if customer is tax-exempt.
1137 if ( WC()->customer && WC()->customer->is_vat_exempt() ) {
1138 return $fee_amount;
1139 }
1140
1141 // Check how to display prices in cart (including or excluding tax).
1142 $display_mode = get_option( 'woocommerce_tax_display_cart', 'excl' );
1143
1144 // If displaying prices excluding tax, return base amount.
1145 if ( 'incl' !== $display_mode ) {
1146 return $fee_amount;
1147 }
1148
1149 // Display prices including tax - calculate tax and add to base amount.
1150 $tax_rates = \WC_Tax::get_shipping_tax_rates();
1151 if ( empty( $tax_rates ) ) {
1152 return $fee_amount;
1153 }
1154
1155 $taxes = \WC_Tax::calc_shipping_tax( $fee_amount, $tax_rates );
1156
1157 return $fee_amount + array_sum( $taxes );
1158 }
1159
1160 /**
1161 * Get formatted fee total price for display.
1162 *
1163 * This is a wrapper function that returns the fee amount formatted with currency.
1164 * Returns plain text without HTML markup for use in JavaScript/React components.
1165 *
1166 * @param float $fee_amount The base fee amount.
1167 *
1168 * @return string Formatted price string with currency (plain text, no HTML).
1169 */
1170 public static function get_formatted_fee_total_price( float $fee_amount ): string {
1171 $formatted_html = wc_price( self::get_fee_total_price( $fee_amount ) );
1172 return html_entity_decode( wp_strip_all_tags( $formatted_html ), ENT_QUOTES, 'UTF-8' );
1173 }
1174
1175 /**
1176 * Is using blocks checkout.
1177 *
1178 * @return boolean
1179 */
1180 public static function is_blocks_checkout(): bool {
1181 $checkout_page_id = wc_get_page_id( 'checkout' );
1182
1183 return has_block( 'woocommerce/checkout', $checkout_page_id );
1184 }
1185
1186 /**
1187 * Check whether free shipping is currently active for the cart.
1188 *
1189 * Returns true when any of the following apply:
1190 * - An applied coupon grants free shipping.
1191 * - The WooCommerce native "Free Shipping" method (method_id: free_shipping)
1192 * is the currently selected shipping method.
1193 *
1194 * This is used to suppress PostNL base-fee injection and morning/evening cart
1195 * fees so that no extra shipping charges appear when the cart qualifies for
1196 * free shipping. Note: PostNL's own minimum_for_free_shipping threshold is
1197 * handled separately per rate in the fee injection filters.
1198 *
1199 * @return bool
1200 */
1201 public static function is_free_shipping_applied(): bool {
1202 if ( ! WC()->cart ) {
1203 return false;
1204 }
1205
1206 foreach ( WC()->cart->get_coupons() as $coupon ) {
1207 if ( $coupon->get_free_shipping() ) {
1208 return true;
1209 }
1210 }
1211
1212 // WooCommerce native "Free Shipping" method selected.
1213 if ( WC()->session ) {
1214 $chosen = WC()->session->get( 'chosen_shipping_methods', array() );
1215 foreach ( $chosen as $method_key ) {
1216 if ( 0 === strpos( (string) $method_key, 'free_shipping' ) ) {
1217 return true;
1218 }
1219 }
1220 }
1221
1222 return false;
1223 }
1224
1225 /**
1226 * Clear all PostNL checkout session data.
1227 *
1228 * This is the centralized method for clearing PostNL session data.
1229 * Used by both classic and blocks checkout when:
1230 * - Country changes to unsupported.
1231 * - Container is hidden.
1232 * - No delivery options available.
1233 * - Checkout is complete.
1234 *
1235 * @return void
1236 */
1237 public static function clear_postnl_checkout_session(): void {
1238 if ( ! WC()->session ) {
1239 return;
1240 }
1241
1242 // Clear delivery fee data (used by blocks checkout).
1243 WC()->session->__unset( 'postnl_delivery_fee' );
1244 WC()->session->__unset( 'postnl_delivery_type' );
1245
1246 // Clear checkout post data.
1247 WC()->session->__unset( 'postnl_checkout_post_data' );
1248
1249 // Clear selected option (used by classic checkout for fee injection).
1250 WC()->session->__unset( 'postnl_option' );
1251
1252 // Clear address validation data.
1253 WC()->session->__unset( POSTNL_SETTINGS_ID . '_invalid_address_marker' );
1254 WC()->session->__unset( POSTNL_SETTINGS_ID . '_validated_address' );
1255 }
1256
1257 /**
1258 * Get the letterbox label for 24h.
1259 *
1260 * @since 5.9.6
1261 *
1262 * @return string
1263 */
1264 public static function get_letterbox_label_24h() {
1265 return esc_html__( 'Letterboxparcel (24h)', 'postnl-for-woocommerce' );
1266 }
1267
1268 /**
1269 * Get the letterbox label for 48h.
1270 *
1271 * @since 5.9.6
1272 *
1273 * @return string
1274 */
1275 public static function get_letterbox_label_48h() {
1276 return esc_html__( 'Letterboxparcel (48h)', 'postnl-for-woocommerce' );
1277 }
1278
1279 /**
1280 * Get the short admin label for a letterbox variant, used in the order
1281 * overview Shipping Options column.
1282 *
1283 * @since 5.9.6
1284 *
1285 * @param string $letterbox_type Variant token: 'letterbox' (24h) or 'letterbox_48' (48h).
1286 * @return string Human-readable label (e.g. "Letterbox 24" / "Letterbox 48").
1287 */
1288 public static function get_letterbox_admin_label( $letterbox_type ) {
1289 if ( 'letterbox_48' === $letterbox_type ) {
1290 return esc_html__( 'Letterbox 48', 'postnl-for-woocommerce' );
1291 }
1292
1293 return esc_html__( 'Letterbox 24', 'postnl-for-woocommerce' );
1294 }
1295
1296 }
1297