PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / utils / formatting.php

formatting.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/utils/formatting.php

1,766 lines 59.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Formatting utilities
4 */
5
6 namespace StoreEngine\Utils;
7
8 use DateTime;
9 use DateTimeZone;
10 use Exception;
11 use StoreEngine;
12 use StoreEngine\Classes\Countries;
13 use StoreEngine\Classes\Coupon;
14 use StoreEngine\Classes\Customer;
15 use StoreEngine\Classes\Exceptions\StoreEngineException;
16 use StoreEngine\Classes\StoreengineDatetime;
17 use StoreEngine\Classes\Tax;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 class Formatting {
24
25 public static function init_hooks() {
26 add_filter( 'storeengine/coupon_code', [ self::class, 'entity_decode_utf8' ] );
27 add_filter( 'storeengine/coupon_code', [ self::class, 'sanitize_coupon_code' ] );
28 add_filter( 'storeengine/coupon_code', [ self::class, 'strtolower' ] );
29 }
30
31 /**
32 * Wrapper for mb_strtoupper which see's if supported first.
33 *
34 * @param ?string $string String to format.
35 *
36 * @return string
37 */
38 public static function strtoupper( ?string $string ): string {
39 $string ??= '';
40
41 return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $string ) : strtoupper( $string );
42 }
43
44 /**
45 * Make a string lowercase.
46 * Try to use mb_strtolower() when available.
47 *
48 * @param ?string $string String to format.
49 *
50 * @return string
51 */
52 public static function strtolower( ?string $string ): string {
53 $string ??= '';
54
55 return function_exists( 'mb_strtolower' ) ? mb_strtolower( $string ) : strtolower( $string );
56 }
57
58 public static function entity_decode_utf8( string $string ): string {
59 return html_entity_decode( $string, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
60 }
61
62 public static function entity_encode_utf8( string $string ): string {
63 return htmlentities( $string, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
64 }
65
66 /**
67 * Make a slug (string joined with dash/underscore) into words and returned with applied callback.
68 *
69 * @param string $slug Slug/string to split into multiple word.
70 * @param callable $pretty_cb Prettify callback. Default is `ucwords`
71 *
72 * @return string
73 */
74 public static function slug_to_words( string $slug, $pretty_cb = 'ucwords' ): string {
75 $slug = str_replace( [ '_', '-' ], ' ', $slug );
76 $slug = preg_replace( '/\s+/', ' ', $slug );
77
78 return call_user_func( $pretty_cb, trim( $slug ) );
79 }
80
81 /**
82 * Sanitize a coupon code.
83 *
84 * Uses sanitize_post_field since coupon codes are stored as post_titles - the sanitization and escaping must match.
85 *
86 * Due to the unfiltered_html capability that some (admin) users have, we need to account for slashes.
87 *
88 * @param string|int|float $value Coupon code to format.
89 *
90 * @return string
91 */
92 public static function sanitize_coupon_code( $value ): string {
93 $value = wp_kses( sanitize_post_field( 'post_title', $value ?? '', 0, 'db' ), 'entities' );
94
95 return current_user_can( 'unfiltered_html' ) ? $value : stripslashes( $value );
96 }
97
98 public static function sanitize_permalink( $value ): string {
99 global $wpdb;
100 $value = $wpdb->strip_invalid_text_for_column( $wpdb->options, 'option_value', $value );
101
102 if ( is_wp_error( $value ) ) {
103 $value = '';
104 }
105
106 $value = esc_url_raw( trim( $value ) );
107 /** @noinspection HttpUrlsUsage */
108 $value = str_replace( 'http://', '', $value );
109
110 return untrailingslashit( $value );
111 }
112
113 /**
114 * Clean variables using sanitize_text_field. Arrays are cleaned recursively.
115 * Non-scalar values are ignored.
116 *
117 * @param string|array $var Data to sanitize.
118 *
119 * @return string|array
120 */
121 public static function clean( $var ) {
122 if ( is_array( $var ) ) {
123 return array_map( [ self::class, 'clean' ], $var );
124 } else {
125 return is_scalar( $var ) ? sanitize_text_field( $var ) : $var;
126 }
127 }
128
129 /**
130 * Cleanup extra whitespaces (tab, newline) from html/text content.
131 *
132 * @param string $html
133 *
134 * @return string
135 */
136 public static function clean_html_whitespaces( string $html ): string {
137 // Remove tabs
138 $html = str_replace( ["\t"], ' ', $html );
139
140 // Remove newlines
141 $html = str_replace( [ "\r", "\n", "\r\n" ], ' ', $html );
142
143 // Replace multiple spaces with one
144 $html = preg_replace( '/ {2,}/', ' ', $html );
145 $html = preg_replace( '/\s{2,}/', ' ', $html );
146 //$html = preg_replace( '/\s+/', ' ', $html );
147
148 // WP generating empty p tag for comment blocks too...
149 // Remove HTML comments (but keep IE conditionals and WP block comments)
150 $html = preg_replace_callback(
151 '/<!--(.*?)-->/s',
152 function ( $matches ) {
153 $comment = trim( $matches[1] );
154
155 // Keep if it's a conditional comment or WP block comment
156 if (
157 str_starts_with( $comment, '[' ) || // IE conditional
158 str_starts_with( $comment, 'wp:' ) || // WP block start
159 str_starts_with( $comment, '/wp:' ) // WP block end
160 ) {
161 return $matches[0]; // return as-is
162 }
163
164 return ''; // remove standard comment
165 },
166 $html
167 );
168
169 // Handle empty space between tags (excluding span)
170 // This also generates extra/empty p tags specially before ending of div.
171 // Caching should be done by the server or third-party content caching plugin.
172 $html = str_replace( ' </div', '</div', $html );
173 $html = str_replace( ' <div', '<div', $html );
174 $html = str_replace( 'div> ', 'div>', $html );
175
176 $html = str_replace( '</a> <div', '</a><div', $html );
177 $html = str_replace( '</div> <a', '</div><a', $html );
178
179 // Collapsing spaces between inline elements brakes some styles and forcing to use extra css.
180 /*$html = str_replace( '/span> <span', '/span>|<span', $html );
181 $html = str_replace( '/span> <small', '/span>|<small', $html );
182 $html = str_replace( '/small> <small', '/small>|<small', $html );
183 $html = str_replace( '> <', '><', $html );
184 $html = str_replace( '>|<', '> <', $html );*/
185
186 // Trim any leading/trailing space
187 return trim( $html );
188 }
189
190 protected static array $safe_text_kses_rules = [
191 'br' => true,
192 'img' => [
193 'alt' => true,
194 'class' => true,
195 'src' => true,
196 'title' => true,
197 ],
198 'p' => [
199 'class' => true,
200 ],
201 'span' => [
202 'class' => true,
203 'title' => true,
204 ],
205 ];
206
207 /**
208 * Get part of a string before :.
209 *
210 * Used for example in shipping methods ids where they take the format
211 * method_id:instance_id
212 *
213 * @param ?string $string String to extract.
214 *
215 * @return string
216 * @since 1.6.4
217 */
218 public static function get_string_before_colon( ?string $string ): string {
219 return trim( current( explode( ':', $value ?? '' ) ) );
220 }
221
222 public static function sanitize_safe_text_field( ?string $value ): string {
223 return wp_kses( force_balance_tags( stripslashes( wp_unslash( $value ?? '' ) ) ), self::$safe_text_kses_rules );
224 }
225
226 /**
227 * Formats a string in the format COUNTRY:STATE into an array.
228 *
229 * @param string $country_string Country string.
230 *
231 * @return array
232 */
233 public static function format_country_state_string( string $country_string ): array {
234 if ( strstr( $country_string, ':' ) ) {
235 list( $country, $state ) = explode( ':', $country_string );
236 } else {
237 $country = $country_string;
238 $state = '';
239 }
240
241 return [
242 'country' => $country,
243 'state' => $state,
244 ];
245 }
246
247 /**
248 * Format the postcode according to the country and length of the postcode.
249 *
250 * @param ?string $postcode Unformatted postcode.
251 * @param string $country Base country.
252 *
253 * @return string
254 */
255 public static function format_postcode( ?string $postcode, string $country ): string {
256 $postcode = self::normalize_postcode( $postcode ?? '' );
257
258 switch ( $country ) {
259 case 'SE':
260 $postcode = substr_replace( $postcode, ' ', - 2, 0 );
261 break;
262 case 'CA':
263 case 'GB':
264 $postcode = substr_replace( $postcode, ' ', - 3, 0 );
265 break;
266 case 'IE':
267 $postcode = substr_replace( $postcode, ' ', 3, 0 );
268 break;
269 case 'BR':
270 case 'PL':
271 $postcode = substr_replace( $postcode, '-', - 3, 0 );
272 break;
273 case 'JP':
274 $postcode = substr_replace( $postcode, '-', 3, 0 );
275 break;
276 case 'PT':
277 $postcode = substr_replace( $postcode, '-', 4, 0 );
278 break;
279 case 'PR':
280 case 'US':
281 case 'MN':
282 $postcode = rtrim( substr_replace( $postcode, '-', 5, 0 ), '-' );
283 break;
284 case 'NL':
285 $postcode = substr_replace( $postcode, ' ', 4, 0 );
286 break;
287 case 'LV':
288 $postcode = preg_replace( '/^(LV)?-?(\d+)$/', 'LV-${2}', $postcode );
289 break;
290 case 'CZ':
291 case 'SK':
292 $postcode = preg_replace( "/^({$country})-?(\d+)$/", '${1}-${2}', $postcode );
293 $postcode = substr_replace( $postcode, ' ', - 2, 0 );
294 break;
295 case 'DK':
296 $postcode = preg_replace( '/^(DK)(.+)$/', '${1}-${2}', $postcode );
297 break;
298 }
299
300 return apply_filters( 'storeengine/format_postcode', trim( $postcode ), $country );
301 }
302
303 /**
304 * Normalize postcodes.
305 *
306 * Remove spaces and convert characters to uppercase.
307 *
308 * @param ?string $postcode Postcode.
309 *
310 * @return string
311 */
312 public static function normalize_postcode( ?string $postcode ): string {
313 return preg_replace( '/[\s\-]/', '', trim( self::strtoupper( $postcode ?? '' ) ) );
314 }
315
316 /**
317 * Make numeric postcode.
318 *
319 * Converts letters to numbers so we can do a simple range check on postcodes.
320 * E.g. PE30 becomes 16050300 (P = 16, E = 05, 3 = 03, 0 = 00)
321 *
322 * @param string|int $postcode Regular postcode.
323 *
324 * @return string
325 */
326 public static function make_numeric_postcode( $postcode ): string {
327 $postcode = str_replace( [ ' ', '-' ], '', $postcode ?? '' );
328 $postcode_length = strlen( $postcode );
329 $letters_to_numbers = array_merge( [ 0 ], range( 'A', 'Z' ) );
330 $letters_to_numbers = array_flip( $letters_to_numbers );
331 $numeric_postcode = '';
332
333 for ( $i = 0; $i < $postcode_length; $i ++ ) {
334 if ( is_numeric( $postcode[ $i ] ) ) {
335 $numeric_postcode .= str_pad( $postcode[ $i ], 2, '0', STR_PAD_LEFT );
336 } elseif ( isset( $letters_to_numbers[ $postcode[ $i ] ] ) ) {
337 $numeric_postcode .= str_pad( $letters_to_numbers[ $postcode[ $i ] ], 2, '0', STR_PAD_LEFT );
338 } else {
339 $numeric_postcode .= '00';
340 }
341 }
342
343 return $numeric_postcode;
344 }
345
346 /**
347 * Format phone numbers.
348 *
349 * @param string|null $phone Phone number.
350 *
351 * @return string
352 */
353 public static function format_phone_number( ?string $phone ): string {
354 if ( ! Validation::is_phone( $phone ) ) {
355 return '';
356 }
357
358 /** @noinspection RegExpRedundantEscape */
359 return preg_replace( '/[^0-9\+\-\(\)\s]/', '-', preg_replace( '/[\x00-\x1F\x7F-\xFF]/', '', $phone ?? '' ) );
360 }
361
362 /**
363 * Get the price format depending on the currency position.
364 *
365 * @return string
366 */
367 public static function get_price_format(): string {
368 $currency_pos = self::get_currency_position();
369 $format = '%1$s%2$s';
370
371 switch ( $currency_pos ) {
372 case 'left':
373 $format = '%1$s%2$s';
374 break;
375 case 'right':
376 $format = '%2$s%1$s';
377 break;
378 case 'left_space':
379 $format = '%1$s&nbsp;%2$s';
380 break;
381 case 'right_space':
382 $format = '%2$s&nbsp;%1$s';
383 break;
384 }
385
386 return apply_filters( 'storeengine/price_format', $format, $currency_pos );
387 }
388
389 public static function get_currency(): string {
390 return apply_filters( 'storeengine/currency', Helper::get_settings( 'store_currency', 'USD' ) );
391 }
392
393 public static function get_currency_position(): string {
394 return apply_filters( 'storeengine/currency_position', Helper::get_settings( 'store_currency_position' ) );
395 }
396
397 /**
398 * Return the thousand separator for prices.
399 *
400 * @return string
401 */
402 public static function get_price_thousand_separator(): string {
403 return stripslashes( apply_filters( 'storeengine/price_thousand_separator', Helper::get_settings( 'store_currency_thousand_separator' ) ) );
404 }
405
406 /**
407 * Return the decimal separator for prices.
408 *
409 * @return string
410 */
411 public static function get_price_decimal_separator(): string {
412 $separator = apply_filters( 'storeengine/price_decimal_separator', Helper::get_settings( 'store_currency_decimal_separator' ) );
413
414 return $separator ? stripslashes( $separator ) : '.';
415 }
416
417 /**
418 * Return the number of decimals after the decimal point.
419 *
420 * @return int
421 */
422 public static function get_price_decimals(): int {
423 return absint( apply_filters( 'storeengine/price_decimals', Helper::get_settings( 'store_currency_decimal_limit', 2 ) ) );
424 }
425
426 /**
427 * Format the price with a currency symbol.
428 *
429 * @param float|string|int $price Raw price.
430 * @param ?array{
431 * ex_tax_label?:bool,
432 * currency?:string,
433 * decimal_separator?:string,
434 * thousand_separator?:string,
435 * decimals?:string,
436 * price_format?:string
437 * }|?string $args Arguments to format a price {
438 * Array of arguments.
439 * Defaults to empty array.
440 *
441 * @type bool $ex_tax_label Adds exclude tax label. Defaults to false.
442 * @type string $currency Currency code. Defaults to empty string (Use the result from get_storeengine/currency()).
443 * @type string $decimal_separator A Decimal separator. Defaults the result of self::get_price_decimal_separator().
444 * @type string $thousand_separator A Thousand separator. Defaults the result of self::get_price_thousand_separator().
445 * @type string $decimals Number of decimals. Defaults the result of self::get_price_decimals().
446 * @type string $price_format Price format depending on the currency position. Defaults the result of self::get_price_format().
447 * }
448 * @return string
449 */
450 public static function price( $price, $args = [] ): string {
451 $args = apply_filters( 'storeengine/price_args', wp_parse_args( $args, [
452 'ex_tax_label' => false,
453 'currency' => '',
454 'decimal_separator' => self::get_price_decimal_separator(),
455 'thousand_separator' => self::get_price_thousand_separator(),
456 'decimals' => self::get_price_decimals(),
457 'price_format' => self::get_price_format(),
458 'in_span' => true,
459 'aria-hidden' => false,
460 ] ) );
461
462 $original_price = $price;
463
464 // Convert to float to avoid issues on PHP 8.
465 $price = (float) $price;
466
467 $unformatted_price = $price;
468 $negative = $price < 0;
469
470 /**
471 * Filter raw price.
472 *
473 * @param float $raw_price Raw price.
474 * @param float|string $original_price Original price as float, or empty string.
475 */
476 $price = apply_filters( 'storeengine/raw/price', $negative ? $price * - 1 : $price, $original_price );
477
478 /**
479 * Filter formatted price.
480 *
481 * @param float $formatted_price Formatted price.
482 * @param float $price Unformatted price.
483 * @param int $decimals Number of decimals.
484 * @param string $decimal_separator A Decimal separator.
485 * @param string $thousand_separator A Thousand separator.
486 * @param float|string $original_price Original price as float, or empty string.
487 */
488 $price = apply_filters( 'storeengine/formatted/price', number_format( $price, $args['decimals'], $args['decimal_separator'], $args['thousand_separator'] ), $price, $args['decimals'], $args['decimal_separator'], $args['thousand_separator'], $original_price );
489
490 if ( apply_filters( 'storeengine/price_trim_zeros', false ) && $args['decimals'] > 0 ) {
491 $price = self::trim_zeros( $price );
492 }
493
494 // @TODO add <bdi> tag support for bi-directional issue in rtl as currency position can be set from admin will get reverse in rtl.
495 if ( $args['in_span'] ) {
496 $formatted_price = ( $negative ? '-' : '' ) . sprintf( $args['price_format'], '<span class="storeengine-price--currency-symbol">' . Helper::get_currency_symbol( $args['currency'] ) . '</span>', $price );
497 $aria_hidden = $args['aria-hidden'] ? ' aria-hidden="true"' : '';
498 $return = '<span class="storeengine-price amount"' . $aria_hidden . '><bdi>' . $formatted_price . '</bdi></span>';
499 } else {
500 $formatted_price = ( $negative ? '-' : '' ) . sprintf( $args['price_format'], Helper::get_currency_symbol( $args['currency'] ), $price );
501 $return = $formatted_price;
502 }
503
504 if ( $args['ex_tax_label'] && TaxUtil::is_tax_enabled() ) {
505 $return .= ' <small class="storeengine-price-tax">' . Countries::init()->ex_tax_or_vat() . '</small>';
506 }
507
508 /**
509 * Filters the string of price markup.
510 *
511 * @param string $return Price HTML markup.
512 * @param string $price Formatted price.
513 * @param array $args Pass on the args.
514 * @param float $unformatted_price Price as float to allow plugins custom formatting.
515 * @param float|string $original_price Original price as float, or empty string.
516 */
517 return apply_filters( 'storeengine/price', $return, $price, $args, $unformatted_price, $original_price );
518 }
519
520 /**
521 * Normalise dimensions, unify to cm then convert to wanted unit value.
522 *
523 * Usage:
524 * Formatting::get_dimension( 55, 'in' );
525 * Formatting::get_dimension( 55, 'in', 'm' );
526 *
527 * @param int|float $dimension Dimension.
528 * @param string $to_unit Unit to convert to.
529 * Options: 'in', 'mm', 'cm', 'm'.
530 * @param string $from_unit Unit to convert from.
531 * Defaults to ''.
532 * Options: 'in', 'mm', 'cm', 'm'.
533 *
534 * @return float
535 */
536 public static function get_dimension( $dimension, string $to_unit, string $from_unit = '' ): float {
537 $to_unit = strtolower( $to_unit );
538
539 if ( empty( $from_unit ) ) {
540 $from_unit = strtolower( Helper::get_settings( 'store_dimension_unit' ) );
541 }
542
543 // Unify all units to cm first.
544 if ( $from_unit !== $to_unit ) {
545 switch ( $from_unit ) {
546 case 'in':
547 $dimension *= 2.54;
548 break;
549 case 'm':
550 $dimension *= 100;
551 break;
552 case 'mm':
553 $dimension *= 0.1;
554 break;
555 case 'yd':
556 $dimension *= 91.44;
557 break;
558 }
559
560 // Output desired unit.
561 switch ( $to_unit ) {
562 case 'in':
563 $dimension *= 0.3937;
564 break;
565 case 'm':
566 $dimension *= 0.01;
567 break;
568 case 'mm':
569 $dimension *= 10;
570 break;
571 case 'yd':
572 $dimension *= 0.010936133;
573 break;
574 }
575 }
576
577 return (float) ( ( $dimension < 0 ) ? 0 : $dimension );
578 }
579
580 /**
581 * Format dimensions for display.
582 *
583 * @param int[]|float[]|string[] $dimensions Array of dimensions.
584 *
585 * @return string
586 */
587 public static function format_dimensions( array $dimensions ): string {
588 $dimension_string = implode( ' &times; ', array_filter( array_map( [ self::class, 'format_localized_decimal' ], $dimensions ) ) );
589
590 if ( ! empty( $dimension_string ) ) {
591 $dimension_label = I18n::get_dimensions_unit_label( Helper::get_settings( 'store_dimension_unit' ) );
592
593 $dimension_string = sprintf(
594 // translators: 1. A formatted number; 2. A label for a dimensions unit of measure. E.g. 3.14 cm.
595 _x( '%1$s %2$s', 'formatted dimensions', 'storeengine' ),
596 $dimension_string,
597 $dimension_label
598 );
599 } else {
600 $dimension_string = __( 'N/A', 'storeengine' );
601 }
602
603 return apply_filters( 'storeengine/format_dimensions', $dimension_string, $dimensions );
604 }
605
606 /**
607 * Normalise weights, unify to kg then convert to wanted unit value.
608 *
609 * Usage:
610 * Formatting::get_weight(55, 'kg');
611 * Formatting::get_weight(55, 'kg', 'lbs');
612 *
613 * @param int|float $weight Weight.
614 * @param string $to_unit Unit to convert to.
615 * Options: 'g', 'kg', 'lbs', 'oz'.
616 * @param string $from_unit Unit to convert from.
617 * Defaults to ''.
618 * Options: 'g', 'kg', 'lbs', 'oz'.
619 *
620 * @return float
621 */
622 public static function get_weight( $weight, string $to_unit, string $from_unit = '' ): float {
623 $weight = (float) $weight;
624 $to_unit = strtolower( $to_unit );
625
626 if ( empty( $from_unit ) ) {
627 $from_unit = strtolower( Helper::get_settings( 'store_weight_unit' ) );
628 }
629
630 // Unify all units to kg first.
631 if ( $from_unit !== $to_unit ) {
632 switch ( $from_unit ) {
633 case 'g':
634 $weight *= 0.001;
635 break;
636 case 'lbs':
637 $weight *= 0.453592;
638 break;
639 case 'oz':
640 $weight *= 0.0283495;
641 break;
642 }
643
644 // Output desired unit.
645 switch ( $to_unit ) {
646 case 'g':
647 $weight *= 1000;
648 break;
649 case 'lbs':
650 $weight *= 2.20462;
651 break;
652 case 'oz':
653 $weight *= 35.274;
654 break;
655 }
656 }
657
658 return (float) ( $weight < 0 ) ? 0 : $weight;
659 }
660
661 /**
662 * Format a weight for display.
663 *
664 * @param float|int|string $weight Weight.
665 *
666 * @return string
667 */
668 public static function format_weight( $weight ): string {
669 $weight_string = self::format_localized_decimal( $weight );
670
671 if ( ! empty( $weight_string ) ) {
672 $weight_label = I18n::get_weight_unit_label( Helper::get_settings( 'store_weight_unit' ) );
673
674 $weight_string = sprintf(
675 // translators: 1. A formatted number; 2. A label for a weight unit of measure. E.g. 2.72 kg.
676 _x( '%1$s %2$s', 'formatted weight', 'storeengine' ),
677 $weight_string,
678 $weight_label
679 );
680 } else {
681 $weight_string = __( 'N/A', 'storeengine' );
682 }
683
684 return apply_filters( 'storeengine/format_weight', $weight_string, $weight );
685 }
686
687 /**
688 * Trim trailing zeros off prices.
689 *
690 * @param string|float|int $price Price.
691 *
692 * @return string
693 */
694 public static function trim_zeros( $price ): string {
695 return preg_replace( '/' . preg_quote( self::get_price_decimal_separator(), '/' ) . '0++$/', '', $price ?? '' );
696 }
697
698 /**
699 * Round a tax amount.
700 *
701 * @param float|string $value Amount to round.
702 * @param int|null $precision DP to round. Defaults to self::get_price_decimals.
703 *
704 * @return float
705 */
706 public static function round_tax_total( $value, ?int $precision = null ): float {
707 $precision = is_null( $precision ) ? self::get_price_decimals() : intval( $precision );
708 $rounded_tax = NumberUtil::round( $value, $precision, TaxUtil::get_tax_rounding_mode() ); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctionParameters.round_modeFound
709
710 return apply_filters( 'storeengine/round_tax_total', $rounded_tax, $value, $precision, TaxUtil::get_tax_rounding_mode() );
711 }
712
713
714 /**
715 * Round discount.
716 *
717 * @param float $value Amount to round.
718 * @param int $precision DP to round.
719 *
720 * @return float
721 */
722 public static function round_discount( float $value, int $precision ): float {
723 $mode = apply_filters( 'storeengine/discount_coupon_rounding_mode', PHP_ROUND_HALF_DOWN );
724
725 return NumberUtil::round( $value, $precision, $mode ); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctionParameters.round_modeFound
726 }
727
728 /**
729 * Format decimal numbers ready for DB storage.
730 *
731 * Sanitize, optionally remove decimals, and optionally round + trim off zeros.
732 *
733 * This function does not remove thousands - this should be done before passing a value to the function.
734 *
735 * @param float|string $number Expects either a float or a string with a decimal separator only (no thousands).
736 * @param mixed $dp number. Number of decimal points to use, blank to use storeengine/price_num_decimals, or false to avoid all rounding.
737 * @param bool $trim_zeros From end of string.
738 *
739 * @return string|float
740 */
741 public static function format_decimal( $number, $dp = false, bool $trim_zeros = false ) {
742 $number ??= '';
743
744 $locale = localeconv();
745 $decimals = [
746 self::get_price_decimal_separator(),
747 $locale['decimal_point'],
748 $locale['mon_decimal_point'],
749 ];
750
751 // Remove locale from string.
752 if ( ! is_float( $number ) ) {
753 $number = str_replace( $decimals, '.', $number );
754
755 // Convert multiple dots to just one.
756 $number = preg_replace( '/\.(?![^.]+$)|[^0-9.-]/', '', sanitize_text_field( $number ) );
757 }
758
759 if ( false !== $dp ) {
760 $dp = intval( '' === $dp ? self::get_price_decimals() : $dp );
761 $number = number_format( floatval( $number ), $dp, '.', '' );
762 } elseif ( is_float( $number ) ) {
763 // DP is false - don't use number format, just return a string using whatever is given. Remove scientific notation using sprintf.
764 $number = str_replace( $decimals, '.', sprintf( '%.' . self::get_rounding_precision() . 'f', $number ) );
765 // We already had a float, so trailing zeros are not needed.
766 $trim_zeros = true;
767 }
768
769 if ( $trim_zeros && strstr( $number, '.' ) ) {
770 $number = rtrim( rtrim( $number, '0' ), '.' );
771 }
772
773 return $number;
774 }
775
776 public static function format_decimal_array( $numbers, $dp = false, bool $trim_zeros = false ): array {
777 return array_map( fn( $number ) => self::format_decimal( $number, $dp, $trim_zeros ), $numbers );
778 }
779
780 /**
781 * Convert a float to a string without locale formatting which PHP adds when changing floats to strings.
782 *
783 * @param float|string $float Float value to format.
784 *
785 * @return string
786 */
787 public static function float_to_string( $float ): string {
788 if ( ! is_float( $float ) ) {
789 return $float;
790 }
791
792 $locale = localeconv();
793 $string = strval( $float );
794
795 return str_replace( $locale['decimal_point'], '.', $string );
796 }
797
798 /**
799 * Format a price with Currency Locale settings.
800 *
801 * @param string|float $value Price to localize.
802 *
803 * @return string
804 */
805 public static function format_localized_price( $value ): string {
806 return apply_filters( 'storeengine/format_localized_price', str_replace( '.', self::get_price_decimal_separator(), strval( $value ) ), $value );
807 }
808
809 /**
810 * Format a decimal with the decimal separator for prices or PHP Locale settings.
811 *
812 * @param string|float $value Decimal to localize.
813 *
814 * @return string
815 */
816 public static function format_localized_decimal( $value ): string {
817 $locale = localeconv();
818 $decimal_point = $locale['decimal_point'] ?? '.';
819 $decimal = ( ! empty( self::get_price_decimal_separator() ) ) ? self::get_price_decimal_separator() : $decimal_point;
820
821 return apply_filters( 'storeengine/format_localized_decimal', str_replace( '.', $decimal, strval( $value ) ), $value );
822 }
823
824 /**
825 * Format a coupon code.
826 *
827 * @param string|int|float $value Coupon code to format.
828 *
829 * @return string
830 */
831 public static function format_coupon_code( $value ): string {
832 return apply_filters( 'storeengine/coupon_code', $value );
833 }
834
835 public static function get_base_rounding_precision(): int {
836 return absint( apply_filters( 'storeengine/base_rounding_precision', 6 ) );
837 }
838
839 /**
840 * Get rounding precision for internal calculations.
841 * Will return the value of self::get_price_decimals increased by 2 decimals, with Formatting::ROUNDING_PRECISION being the minimum.
842 *
843 * @return int
844 */
845 public static function get_rounding_precision(): int {
846 $precision = self::get_price_decimals() + 2;
847 $base_precision = self::get_base_rounding_precision();
848
849 if ( $precision < $base_precision ) {
850 $precision = $base_precision;
851 }
852
853 /**
854 * Filter the rounding precision for internal calculations. This is different from the number of decimals used for display.
855 * Generally, this filter can be used to decrease the precision, but if you choose to decrease, there maybe side effects such as off by one rounding errors for certain tax rate combinations.
856 *
857 * @param int $precision The number of decimals to round to.
858 */
859 return apply_filters( 'storeengine/internal_rounding_precision', $precision );
860 }
861
862 /**
863 * Add precision to a number by moving the decimal point to the right as many places as indicated by self::get_price_decimals().
864 * Optionally the result is rounded so that the total number of digits equals self::get_rounding_precision() plus one.
865 *
866 * @param float|int|null $value Number to add precision to.
867 * @param bool $round If the result should be rounded.
868 *
869 * @return int|float
870 */
871 public static function add_number_precision( $value, bool $round = true ) {
872 if ( ! $value ) {
873 return 0.0;
874 }
875
876 $cent_precision = pow( 10, self::get_price_decimals() );
877 $value = $value * $cent_precision;
878
879 return $round ? NumberUtil::round( $value, self::get_rounding_precision() - self::get_price_decimals() ) : $value;
880 }
881
882 /**
883 * Remove precision from a number and return a float.
884 *
885 * @param float|int|null $value Number to add precision to.
886 *
887 * @return float
888 */
889 public static function remove_number_precision( float $value ): float {
890 if ( ! $value ) {
891 return 0.0;
892 }
893
894 $cent_precision = pow( 10, self::get_price_decimals() );
895
896 return $value / $cent_precision;
897 }
898
899 /**
900 * Add precision to an array of number and return an array of int.
901 *
902 * @param int|int[]|float|float[]|array $value Number to add precision to.
903 * @param bool $round Should we round after adding precision?.
904 *
905 * @return int|array
906 */
907 public static function add_number_precision_deep( $value, bool $round = true ) {
908 if ( ! is_array( $value ) ) {
909 return self::add_number_precision( $value, $round );
910 }
911
912 foreach ( $value as $key => $sub_value ) {
913 $value[ $key ] = self::add_number_precision_deep( $sub_value, $round );
914 }
915
916 return $value;
917 }
918
919 /**
920 * Remove precision from an array of number and return an array of int.
921 *
922 * @param array|int|int[]|float|float[] $value Number to add precision to.
923 *
924 * @return float[]|float
925 */
926 public static function remove_number_precision_deep( $value ) {
927 if ( ! is_array( $value ) ) {
928 return self::remove_number_precision( $value );
929 }
930
931 foreach ( $value as $key => $sub_value ) {
932 $value[ $key ] = self::remove_number_precision_deep( $sub_value );
933 }
934
935 return $value;
936 }
937
938 /**
939 * Converts a string (e.g. 'yes' or 'no') to a bool.
940 *
941 * @param string|bool $string String to convert. If a bool is passed it will be returned as-is.
942 *
943 * @return bool
944 */
945 public static function string_to_bool( $string ): bool {
946 $string = $string ?? '';
947
948 return is_bool( $string ) ? $string : ( 'yes' === strtolower( $string ) || 1 === $string || 'true' === strtolower( $string ) || '1' === $string );
949 }
950
951 /**
952 * Converts a bool to a 'yes' or 'no'.
953 *
954 * @param bool|string $bool Bool to convert. If a string is passed it will first be converted to a bool.
955 *
956 * @return string
957 */
958 public static function bool_to_string( $bool ): string {
959 if ( ! is_bool( $bool ) ) {
960 $bool = self::string_to_bool( $bool );
961 }
962
963 return true === $bool ? 'yes' : 'no';
964 }
965
966 /**
967 * StoreEngine Date Format - Allows to change date format for everything into site's date format.
968 *
969 * @return string
970 */
971 public static function date_format(): string {
972 $date_format = get_option( 'date_format' );
973 if ( empty( $date_format ) ) {
974 // Return default date format if the option is empty.
975 $date_format = 'F j, Y';
976 }
977
978 return apply_filters( 'storeengine/date_format', $date_format );
979 }
980
981 /**
982 * StoreEngine Time Format - Allows to change time format for everything into site's time format.
983 *
984 * @return string
985 */
986 public static function time_format(): string {
987 $time_format = get_option( 'time_format' );
988 if ( empty( $time_format ) ) {
989 // Return default time format if the option is empty.
990 $time_format = 'g:i a';
991 }
992
993 return apply_filters( 'storeengine/time_format', $time_format );
994 }
995
996 /**
997 * Convert mysql datetime to PHP timestamp, forcing UTC. Wrapper for strtotime.
998 * Based on wcs_strtotime_dark_knight() from WC Subscriptions by Prospress.
999 *
1000 * @param string|null $time_string Time string.
1001 * @param int|null $from_timestamp Timestamp to convert from.
1002 *
1003 * @return int
1004 * @noinspection SpellCheckingInspection
1005 */
1006 public static function string_to_timestamp( ?string $time_string = null, ?int $from_timestamp = null ): int {
1007 $time_string = $time_string ?? '';
1008
1009 $original_timezone = date_default_timezone_get();
1010
1011 // @codingStandardsIgnoreStart
1012 date_default_timezone_set( 'UTC' );
1013
1014 if ( null === $from_timestamp ) {
1015 $next_timestamp = strtotime( $time_string );
1016 } else {
1017 $next_timestamp = strtotime( $time_string, $from_timestamp );
1018 }
1019
1020 date_default_timezone_set( $original_timezone );
1021
1022 // @codingStandardsIgnoreEnd
1023
1024 return $next_timestamp;
1025 }
1026
1027 /**
1028 * Convert a date string to a StoreengineDatetime.
1029 *
1030 * @param string|int|null $time_string Time string.
1031 *
1032 * @return StoreengineDatetime
1033 * @throws StoreEngineException
1034 */
1035 public static function string_to_datetime( $time_string = null ): StoreengineDatetime {
1036 try {
1037 $time_string = $time_string ?? '';
1038
1039 if ( is_a( $time_string, StoreengineDatetime::class ) ) {
1040 $datetime = clone $time_string;
1041 } elseif ( is_numeric( $time_string ) ) {
1042 // Timestamps are handled as UTC timestamps in all cases.
1043 $datetime = new StoreengineDatetime( "@{$time_string}", new DateTimeZone( 'UTC' ) );
1044 } else {
1045 // Strings are defined in local WP timezone. Convert to UTC.
1046 /** @noinspection RegExpSingleCharAlternation */
1047 if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|((-|\+)\d{2}:\d{2}))$/', $time_string, $date_bits ) ) {
1048 $offset = ! empty( $date_bits[7] ) ? iso8601_timezone_to_offset( $date_bits[7] ) : self::timezone_offset();
1049 $timestamp = gmmktime( $date_bits[4], $date_bits[5], $date_bits[6], $date_bits[2], $date_bits[3], $date_bits[1] ) - $offset;
1050 } else {
1051 $timestamp = self::string_to_timestamp( get_gmt_from_date( gmdate( 'Y-m-d H:i:s', self::string_to_timestamp( $time_string ) ) ) );
1052 }
1053
1054 $datetime = new StoreengineDatetime( "@{$timestamp}", new DateTimeZone( 'UTC' ) );
1055 }
1056
1057 // Set local timezone
1058 $datetime->setTimezone( wp_timezone() );
1059
1060 return $datetime;
1061 } catch ( Exception $e ) {
1062 throw new StoreEngineException( esc_html( $e->getMessage() ), 'failed-to-convert-string-into-datetime', [ 'datetime_string' => $time_string ], $e->getCode(), $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1063 }
1064 }
1065
1066 /**
1067 * Convert a date string into a timestamp without ever adding or deducting time.
1068 *
1069 * The strtotime() would be handy for this purpose, but alas, if other code running on the server
1070 * is calling date_default_timezone_set() to change the timezone, strtotime() will assume the
1071 * date is in that timezone unless the timezone is specific on the string (which it isn't for
1072 * any MySQL formatted date) and attempt to convert it to UTC time by adding or deducting the
1073 * GMT/UTC offset for that timezone, so for example, when 3rd party code has set the servers
1074 * timezone using date_default_timezone_set( 'America/Los_Angeles' ) doing something like
1075 * gmdate( "Y-m-d H:i:s", strtotime( gmdate( "Y-m-d H:i:s" ) ) ) will actually add 7 hours to
1076 * the date even though it is a date in UTC timezone because the timezone wasn't specificed.
1077 *
1078 * This makes sure the date is never converted.
1079 *
1080 * @param string $date_string A date string formatted in MySQl or similar format that will map correctly when instantiating an instance of DateTime()
1081 *
1082 * @return int Unix timestamp representation of the timestamp passed in without any changes for timezones
1083 */
1084 public static function string_to_datetime_utc( $date_string ): int {
1085 if ( ! $date_string ) {
1086 return 0;
1087 }
1088
1089 $date_time = new StoreengineDatetime( $date_string, new DateTimeZone( 'UTC' ) );
1090
1091 return intval( $date_time->getTimestamp() );
1092 }
1093
1094 /**
1095 * Take a date in the form of a timestamp, MySQL date/time string or DateTime object (or perhaps
1096 * a date-time object) and create a StoreengineDatetime object.
1097 *
1098 * @param string|integer|StoreengineDatetime|null $variable_date_type UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. Null if their is no date.
1099 *
1100 * @return null|StoreengineDatetime in site's timezone
1101 */
1102 public static function get_datetime_from( $variable_date_type ): ?StoreengineDatetime {
1103 try {
1104 if ( empty( $variable_date_type ) ) {
1105 $datetime = null;
1106 } elseif ( is_a( $variable_date_type, StoreengineDatetime::class ) ) {
1107 $datetime = $variable_date_type;
1108 } elseif ( is_numeric( $variable_date_type ) ) {
1109 $datetime = new StoreengineDatetime( "@{$variable_date_type}", new DateTimeZone( 'UTC' ) );
1110 $datetime->setTimezone( new DateTimeZone( self::timezone_string() ) );
1111 } else {
1112 $datetime = new StoreengineDatetime( $variable_date_type, new DateTimeZone( self::timezone_string() ) );
1113 }
1114 } catch ( Exception $e ) {
1115 $datetime = null;
1116 }
1117
1118 return $datetime;
1119 }
1120
1121 public static function is_datetime( ?string $maybe_datetime ): bool {
1122 /** @noinspection RegExpSingleCharAlternation */
1123 return $maybe_datetime && preg_match( '/^(\d{4})-(\d{2})-(\d{2}).*(\d{2}):(\d{2}):(\d{2})(Z|((-|\+)\d{2}:\d{2}))?$/', $maybe_datetime );
1124 }
1125
1126 /**
1127 * StoreEngine Timezone - helper to retrieve the timezone string for a site until
1128 * a WP core method exists (see https://core.trac.wordpress.org/ticket/24730).
1129 *
1130 * Adapted from https://secure.php.net/manual/en/function.timezone-name-from-abbr.php#89155.
1131 *
1132 * @return string PHP timezone string for the site
1133 */
1134 public static function timezone_string(): string {
1135 // Added in WordPress 5.3 Ref https://developer.wordpress.org/reference/functions/wp_timezone_string/.
1136 if ( function_exists( 'wp_timezone_string' ) ) {
1137 return wp_timezone_string();
1138 }
1139
1140 // If site timezone string exists, return it.
1141 $timezone = get_option( 'timezone_string' );
1142 if ( $timezone ) {
1143 return $timezone;
1144 }
1145
1146 // Get UTC offset, if it isn't set then return UTC.
1147 $utc_offset = floatval( get_option( 'gmt_offset', 0 ) );
1148 if ( ! is_numeric( $utc_offset ) || 0.0 === $utc_offset ) {
1149 return 'UTC';
1150 }
1151
1152 // Adjust UTC offset from hours to seconds.
1153 $utc_offset = (int) ( $utc_offset * 3600 );
1154
1155 // Attempt to guess the timezone string from the UTC offset.
1156 $timezone = timezone_name_from_abbr( '', $utc_offset );
1157 if ( $timezone ) {
1158 return $timezone;
1159 }
1160
1161 // Last try, guess timezone string manually.
1162 foreach ( timezone_abbreviations_list() as $abbr ) {
1163 foreach ( $abbr as $city ) {
1164 // WordPress restrict the use of date(), since it's affected by timezone settings, but in this case is just what we need to guess the correct timezone.
1165 if ( (bool) date( 'I' ) === (bool) $city['dst'] && $city['timezone_id'] && intval( $city['offset'] ) === $utc_offset ) { // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1166 return $city['timezone_id'];
1167 }
1168 }
1169 }
1170
1171 // Fallback to UTC.
1172 return 'UTC';
1173 }
1174
1175 /**
1176 * Get timezone offset in seconds.
1177 *
1178 * @return float
1179 * @throws Exception
1180 */
1181 public static function timezone_offset() {
1182 $timezone = get_option( 'timezone_string' );
1183
1184 if ( $timezone ) {
1185 $timezone_object = new DateTimeZone( $timezone );
1186
1187 return $timezone_object->getOffset( new DateTime( 'now' ) );
1188 } else {
1189 return floatval( get_option( 'gmt_offset', 0 ) ) * HOUR_IN_SECONDS;
1190 }
1191 }
1192
1193 /**
1194 * Format a date for output.
1195 *
1196 * @param StoreengineDatetime|DateTime $date Instance of StoreengineDatetime.
1197 * @param string $format Data format. Defaults to the Formatting::date_format function if not set.
1198 *
1199 * @return string
1200 */
1201 public static function format_datetime( $date, string $format = '' ): string {
1202 if ( ! $format ) {
1203 $format = self::date_format();
1204 }
1205
1206 if ( is_a( $date, StoreengineDatetime::class ) ) {
1207 return $date->date_i18n( $format );
1208 }
1209
1210 if ( is_a( $date, DateTime::class ) ) {
1211 return date_i18n( $format, $date->getTimestamp() + $date->getOffset() );
1212 }
1213
1214 return '';
1215 }
1216
1217 /**
1218 * Get a coupon label.
1219 *
1220 * @param string|Coupon $coupon Coupon data or code.
1221 * @param bool $echo Echo or return.
1222 *
1223 * @return string|void
1224 */
1225 public static function cart_totals_coupon_label( $coupon, bool $echo = true ) {
1226 if ( is_string( $coupon ) ) {
1227 $coupon = new Coupon( $coupon );
1228 }
1229
1230 /* translators: %s: coupon code */
1231 $label = apply_filters( 'storeengine/cart/totals_coupon_label', sprintf( esc_html__( 'Coupon: %s', 'storeengine' ), $coupon->get_code() ), $coupon );
1232
1233 if ( $echo ) {
1234 echo $label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1235 } else {
1236 return $label;
1237 }
1238 }
1239
1240 /**
1241 * Get coupon display HTML.
1242 *
1243 * @param string|Coupon $coupon Coupon data or code.
1244 */
1245 public static function cart_totals_coupon_html( $coupon ) {
1246 if ( is_string( $coupon ) ) {
1247 $coupon = new Coupon( $coupon );
1248 }
1249
1250 $amount = storeengine_cart()->get_coupon_discount_amount( $coupon->get_code(), storeengine_cart()->display_prices_excluding_tax() );
1251 $discount_amount_html = '-' . self::price( $amount );
1252
1253 if ( $coupon->get_free_shipping() && empty( $amount ) ) {
1254 $discount_amount_html = __( 'Free shipping coupon', 'storeengine' );
1255 }
1256
1257 $discount_amount_html = apply_filters( 'storeengine/coupon_discount_amount_html', $discount_amount_html, $coupon );
1258 $remove_url = add_query_arg( 'remove_coupon', rawurlencode( $coupon->get_code() ), Helper::is_checkout() ? Helper::get_checkout_url() : Helper::get_cart_url() );
1259 $remove_url = wp_nonce_url( $remove_url, 'storeengine/cart/remove_coupon' );
1260 $coupon_html = $discount_amount_html . ' <a href="' . esc_url( $remove_url ) . '" class="storeengine-remove-coupon" data-coupon="' . esc_attr( $coupon->get_code() ) . '" aria-label="' . esc_html__( 'Remove coupon', 'storeengine' ) . '"><i class="storeengine-icon storeengine-icon--trash" aria-hidden="true"></i></a>';
1261
1262 echo wp_kses( apply_filters( 'storeengine/cart/totals_coupon_html', $coupon_html, $coupon, $discount_amount_html ), array_replace_recursive( wp_kses_allowed_html( 'post' ), [ 'a' => [ 'data-coupon' => true ] ] ) ); // phpcs:ignore PHPCompatibility.PHP.NewFunctions.array_replace_recursiveFound
1263 }
1264
1265 /**
1266 * Get a coupon label.
1267 *
1268 * @param string|Coupon $coupon Coupon data or code.
1269 * @param bool $echo Echo or return.
1270 *
1271 * @return string|void
1272 */
1273 public static function cart_totals_fee_label( $fee, bool $echo = true ) {
1274 if ( is_string( $fee ) ) {
1275 $fee = (object) [ 'name' => $fee ];
1276 }
1277
1278 /* translators: %s: Fee name */
1279 $label = apply_filters( 'storeengine/cart/totals_fee_label', sprintf( esc_html__( '%s Fee', 'storeengine' ), $fee->name ), $fee );
1280
1281 if ( $echo ) {
1282 echo $label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1283 } else {
1284 return $label;
1285 }
1286 }
1287
1288 /**
1289 * Get order total html including inc tax if needed.
1290 */
1291 public static function cart_totals_order_total_html() {
1292 $value = '<strong>' . storeengine_cart()->get_total() . '</strong> ';
1293
1294 // If prices are tax inclusive, show taxes here.
1295 if ( TaxUtil::is_tax_enabled() && storeengine_cart()->display_prices_including_tax() ) {
1296 $tax_string_array = array();
1297 $cart_tax_totals = storeengine_cart()->get_tax_totals();
1298
1299 if ( 'itemized' === Helper::get_settings( 'tax_total_display' ) ) {
1300 foreach ( $cart_tax_totals as $code => $tax ) {
1301 $tax_string_array[] = sprintf( '%s %s', $tax->formatted_amount, $tax->label );
1302 }
1303 } elseif ( ! empty( $cart_tax_totals ) ) {
1304 $tax_string_array[] = sprintf( '%s %s', self::price( storeengine_cart()->get_taxes_total() ), Countries::init()->tax_or_vat() );
1305 }
1306
1307 if ( ! empty( $tax_string_array ) ) {
1308 $taxable_address = StoreEngine::init()->customer->get_taxable_address();
1309 if ( StoreEngine::init()->customer->is_customer_outside_base() && ! StoreEngine::init()->customer->has_calculated_shipping() ) {
1310 $country = Countries::init()->estimated_for_prefix( $taxable_address[0] ) . Countries::init()->get_countries()[ $taxable_address[0] ];
1311 /* translators: 1: tax amount 2: country name */
1312 $tax_text = wp_kses_post( sprintf( __( '(includes %1$s estimated for %2$s)', 'storeengine' ), implode( ', ', $tax_string_array ), $country ) );
1313 } else {
1314 /* translators: %s: tax amounts */
1315 $tax_text = wp_kses_post( sprintf( __( '(includes %s)', 'storeengine' ), implode( ', ', $tax_string_array ) ) );
1316 }
1317
1318 $value .= '<small class="includes_tax">' . $tax_text . '</small>';
1319 }
1320 }
1321
1322 echo apply_filters( 'storeengine/cart/totals_order_total_html', $value ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1323 }
1324
1325 public static function cart_totals_shipping_html() {
1326 $cart = Helper::cart();
1327 $chosen_methods = $cart->get_meta( 'chosen_shipping_methods' );
1328 $packages = StoreEngine\Shipping\Shipping::init()->get_packages();
1329 $first = true;
1330
1331 foreach ( $packages as $i => $package ) {
1332 $chosen_method = $chosen_methods[ $i ] ?? '';
1333 $product_names = [];
1334
1335 if ( count( $packages ) > 1 ) {
1336 foreach ( $package['contents'] as $item_id => $values ) {
1337 $product_names[ $item_id ] = $values['data']->get_name() . ' &times;' . $values['quantity'];
1338 }
1339 $product_names = apply_filters( 'storeengine/shipping/package_details_array', $product_names, $package );
1340 }
1341
1342 Template::get_template(
1343 'cart/cart-shipping.php',
1344 [
1345 'package' => $package,
1346 'available_methods' => $package['rates'],
1347 'show_package_details' => count( $packages ) > 1,
1348 'show_shipping_calculator' => Helper::is_cart() && apply_filters( 'storeengine/shipping/show_shipping_calculator', $first, $i, $package ),
1349 'package_details' => implode( ', ', $product_names ),
1350 /* translators: %d: shipping package number */
1351 'package_name' => apply_filters( 'storeengine/shipping/package_name', ( ( $i + 1 ) > 1 ) ? sprintf( _x( 'Shipping %d', 'shipping packages', 'storeengine' ), ( $i + 1 ) ) : _x( 'Shipping', 'shipping packages', 'storeengine' ), $i, $package ),
1352 'index' => $i,
1353 'chosen_method' => $chosen_method,
1354 'formatted_destination' => Countries::init()->get_formatted_address( $package['destination'], ', ' ),
1355 'has_calculated_shipping' => $cart->has_calculated_shipping(),
1356 ]
1357 );
1358
1359 $first = false;
1360 }
1361 }
1362
1363 /**
1364 * Array merge and sum function.
1365 *
1366 * Source: https://gist.github.com/Nickology/f700e319cbafab5eaedc
1367 *
1368 * @return array
1369 */
1370 public static function array_merge_recursive_numeric(): array {
1371 $arrays = func_get_args();
1372
1373 // If there's only one array, it's already merged.
1374 if ( 1 === count( $arrays ) ) {
1375 return $arrays[0];
1376 }
1377
1378 // Remove any items in $arrays that are NOT arrays.
1379 foreach ( $arrays as $key => $array ) {
1380 if ( ! is_array( $array ) ) {
1381 unset( $arrays[ $key ] );
1382 }
1383 }
1384
1385 // We start by setting the first array as our final array.
1386 // We will merge all other arrays with this one.
1387 $final = array_shift( $arrays );
1388
1389 foreach ( $arrays as $b ) {
1390 foreach ( $final as $key => $value ) {
1391 // If $key does not exist in $b, then it is unique and can be safely merged.
1392 if ( ! isset( $b[ $key ] ) ) {
1393 $final[ $key ] = $value;
1394 } else {
1395 // If $key is present in $b, then we need to merge and sum numeric values in both.
1396 if ( is_numeric( $value ) && is_numeric( $b[ $key ] ) ) {
1397 // If both values for these keys are numeric, we sum them.
1398 $final[ $key ] = $value + $b[ $key ];
1399 } elseif ( is_array( $value ) && is_array( $b[ $key ] ) ) {
1400 // If both values are arrays, we recursively call ourselves.
1401 $final[ $key ] = self::array_merge_recursive_numeric( $value, $b[ $key ] );
1402 } else {
1403 // If both keys exist but differ in type, then we cannot merge them.
1404 // In this scenario, we will $b's value for $key is used.
1405 $final[ $key ] = $b[ $key ];
1406 }
1407 }
1408 }
1409
1410 // Finally, we need to merge any keys that exist only in $b.
1411 foreach ( $b as $key => $value ) {
1412 if ( ! isset( $final[ $key ] ) ) {
1413 $final[ $key ] = $value;
1414 }
1415 }
1416 }
1417
1418 return $final;
1419 }
1420
1421 /**
1422 * For a given product, and optionally price/qty, work out the price with tax included, based on store settings.
1423 *
1424 * @param float|int|string $price Product object.
1425 * @param ?int $priceId Product id.
1426 * @param ?int $product Product object.
1427 * @param array $args Optional arguments to pass product quantity and price.
1428 *
1429 * @return float|string Price with tax included, or an empty string if price calculation failed.
1430 */
1431 public static function get_price_including_tax( $price, ?int $priceId = null, ?int $product = null, array $args = [] ) {
1432 $product = $product ? Helper::get_product( $product ) : null;
1433 $args = wp_parse_args( $args, [
1434 'qty' => '',
1435 'price' => '',
1436 'taxable' => ! $product || $product->is_taxable(),
1437 ] );
1438
1439 $price = '' !== $args['price'] ? max( 0.0, (float) $args['price'] ) : (float) $price;
1440 $qty = '' !== $args['qty'] ? max( 0, absint( $args['qty'] ) ) : 1;
1441
1442 if ( empty( $qty ) ) {
1443 return 0.0;
1444 }
1445
1446 $line_price = $price * $qty;
1447 $return_price = $line_price;
1448
1449 if ( $args['taxable'] ) {
1450 if ( ! TaxUtil::prices_include_tax() ) {
1451 // If the customer is exempt from VAT, set tax total to 0.
1452 if ( ! empty( StoreEngine::init()->customer ) && StoreEngine::init()->customer->get_is_vat_exempt() ) {
1453 $taxes_total = 0.00;
1454 } else {
1455 $tax_rates = Tax::get_rates( '' );
1456 $taxes = Tax::calc_tax( $line_price, $tax_rates, false );
1457
1458 if ( Tax::$round_at_subtotal ) {
1459 $taxes_total = array_sum( $taxes );
1460 } else {
1461 $taxes_total = array_sum( array_map( [ self::class, 'round_tax_total' ], $taxes ) );
1462 }
1463 }
1464
1465 $return_price = NumberUtil::round( $line_price + $taxes_total, self::get_price_decimals() );
1466 } else {
1467 $tax_rates = Tax::get_rates( $product ? $product->get_tax_class() : '' );
1468 $base_tax_rates = Tax::get_base_tax_rates( $product ? $product->get_tax_class( 'unfiltered' ) : '' );
1469
1470 /**
1471 * If the customer is exempt from VAT, remove the taxes here.
1472 * Either remove the base or the user taxes depending on storeengine/adjust_non_base_location_prices setting.
1473 */
1474 if ( ! empty( StoreEngine::init()->customer ) && StoreEngine::init()->customer->get_is_vat_exempt() ) {
1475 if ( apply_filters( 'storeengine/adjust_non_base_location_prices', true ) ) {
1476 $remove_taxes = Tax::calc_tax( $line_price, $base_tax_rates, true );
1477 } else {
1478 $remove_taxes = Tax::calc_tax( $line_price, $tax_rates, true );
1479 }
1480
1481 if ( Tax::$round_at_subtotal ) {
1482 $remove_taxes_total = array_sum( $remove_taxes );
1483 } else {
1484 $remove_taxes_total = array_sum( array_map( [ self::class, 'round_tax_total' ], $remove_taxes ) );
1485 }
1486
1487 $return_price = NumberUtil::round( $line_price - $remove_taxes_total, self::get_price_decimals() );
1488
1489 /**
1490 * The storeengine/adjust_non_base_location_prices filter can stop base taxes being taken off when
1491 * dealing without of base locations. e.g. If a product costs 10 including tax, all users will pay
1492 * 10 regardless of location and taxes.
1493 *
1494 * This feature is experimental and may change in the future. Use at your risk.
1495 */
1496 } elseif ( $tax_rates !== $base_tax_rates && apply_filters( 'storeengine/adjust_non_base_location_prices', true ) ) {
1497 $base_taxes = Tax::calc_tax( $line_price, $base_tax_rates, true );
1498 $modded_taxes = Tax::calc_tax( $line_price - array_sum( $base_taxes ), $tax_rates, false );
1499
1500 if ( Tax::$round_at_subtotal ) {
1501 $base_taxes_total = array_sum( $base_taxes );
1502 $modded_taxes_total = array_sum( $modded_taxes );
1503 } else {
1504 $base_taxes_total = array_sum( array_map( [ self::class, 'round_tax_total' ], $base_taxes ) );
1505 $modded_taxes_total = array_sum( array_map( [ self::class, 'round_tax_total' ], $modded_taxes ) );
1506 }
1507
1508 $return_price = NumberUtil::round( $line_price - $base_taxes_total + $modded_taxes_total, self::get_price_decimals() );
1509 }
1510 }
1511 }
1512
1513 return apply_filters( 'storeengine/get_price_including_tax', $return_price, $qty, $priceId, $product );
1514 }
1515
1516 /**
1517 * For a given product, and optionally price/qty, work out the price with tax excluded, based on store settings.
1518 *
1519 * @param float|int|string $price Product object.
1520 * @param ?int $priceId Product id.
1521 * @param ?int $product Product id.
1522 * @param array $args Optional arguments to pass product quantity and price.
1523 *
1524 * @return float|string Price with tax excluded, or an empty string if price calculation failed.
1525 */
1526 public static function get_price_excluding_tax( $price, ?int $priceId = null, ?int $product = null, array $args = [] ) {
1527 $product = $product ? Helper::get_product( $product ) : null;
1528 $args = wp_parse_args( $args, [
1529 'qty' => '',
1530 'price' => '',
1531 'taxable' => ! $product || $product->is_taxable(),
1532 ] );
1533
1534 $price = '' !== $args['price'] ? max( 0.0, (float) $args['price'] ) : (float) $price;
1535 $qty = '' !== $args['qty'] ? max( 0, absint( $args['qty'] ) ) : 1;
1536
1537 if ( empty( $qty ) ) {
1538 return 0.0;
1539 }
1540
1541 $line_price = $price * $qty;
1542
1543 if ( $args['taxable'] && TaxUtil::prices_include_tax() ) {
1544 $order = $args['order'] ?? null;
1545 $customer_id = $order ? $order->get_customer_id() : 0;
1546 if ( apply_filters( 'storeengine/adjust_non_base_location_prices', true ) ) {
1547 $tax_rates = Tax::get_base_tax_rates( $product ? $product->get_tax_class( 'unfiltered' ) : '' );
1548 } else {
1549 $customer = $customer_id ? new Customer( $customer_id ) : null;
1550 $tax_rates = Tax::get_rates( '', $customer );
1551 }
1552
1553 $remove_taxes = Tax::calc_tax( $line_price, $tax_rates, true );
1554 $return_price = $line_price - array_sum( $remove_taxes ); // Un-rounded since we're dealing with tax inclusive prices. Matches logic in cart-totals class. @see adjust_non_base_location_price.
1555 } else {
1556 $return_price = $line_price;
1557 }
1558
1559 return apply_filters( 'storeengine/get_price_excluding_tax', $return_price, $qty, $priceId, $product );
1560 }
1561
1562 /**
1563 * Returns the price including or excluding tax.
1564 *
1565 * By default, it's based on the 'tax_display_shop' setting.
1566 * Set `$arg['display_context']` to 'cart' to base on the 'tax_display_cart' setting instead.
1567 *
1568 * @param float|int|string $price Product object.
1569 * @param ?int $priceId Product id.
1570 * @param ?int $product Product id.
1571 * @param array $args Optional arguments to pass product quantity and price.
1572 *
1573 * @return float|string Price with tax excluded, or an empty string if price calculation failed.
1574 */
1575 public static function get_price_to_display( $price, ?int $priceId = null, ?int $product = null, array $args = [] ) {
1576 $args = wp_parse_args(
1577 $args,
1578 [
1579 'qty' => 1,
1580 'price' => '',
1581 'display_context' => 'shop',
1582 ]
1583 );
1584
1585 $price = '' !== $args['price'] ? max( 0.0, (float) $args['price'] ) : (float) $price;
1586 $qty = '' !== $args['qty'] ? max( 0, absint( $args['qty'] ) ) : 1;
1587 $tax_display = Helper::get_settings( 'cart' === $args['display_context'] ? 'tax_display_cart' : 'tax_display_shop' );
1588
1589 if ( 'incl' === $tax_display ) {
1590 return self::get_price_including_tax( $price, $priceId, $product, [
1591 'qty' => $qty,
1592 'price' => $price,
1593 ] );
1594 } else {
1595 return self::get_price_excluding_tax( $price, $priceId, $product, [
1596 'qty' => $qty,
1597 'price' => $price,
1598 ] );
1599 }
1600 }
1601
1602 /**
1603 * Format a sale price for display.
1604 *
1605 * @param string|int|float $regular_price Regular price.
1606 * @param string|int|float $sale_price Sale price.
1607 *
1608 * @return string
1609 */
1610 public static function format_sale_price( $regular_price, $sale_price ): string {
1611 // Format the prices.
1612 $formatted_regular_price = is_numeric( $regular_price ) ? self::price( $regular_price ) : $regular_price;
1613 $formatted_sale_price = is_numeric( $sale_price ) ? self::price( $sale_price ) : $sale_price;
1614
1615 // Strikethrough pricing.
1616 $price = '<del aria-hidden="true">' . $formatted_regular_price . '</del> ';
1617
1618 // For accessibility (a11y) we'll also display that information to screen readers.
1619 $price .= '<span class="screen-reader-text"> ';
1620 // translators: %s is a product's regular price.
1621 $price .= esc_html( sprintf( __( 'Original price was: %s.', 'storeengine' ), wp_strip_all_tags( $formatted_regular_price ) ) );
1622 $price .= '</span>';
1623
1624 // Add the sale price.
1625 $price .= ' <ins aria-hidden="true">' . $formatted_sale_price . '</ins> ';
1626
1627 // For accessibility (a11y) we'll also display that information to screen readers.
1628 $price .= '<span class="screen-reader-text"> ';
1629 // translators: %s is a product's current (sale) price.
1630 $price .= esc_html( sprintf( __( 'Current price is: %s.', 'storeengine' ), wp_strip_all_tags( $formatted_sale_price ) ) );
1631 $price .= '</span>';
1632
1633 return apply_filters( 'storeengine/format_sale_price', trim( $price ), $regular_price, $sale_price );
1634 }
1635
1636 /**
1637 * Format a price range for display.
1638 *
1639 * @param string|int|float $from Price from.
1640 * @param string|int|float $to Price to.
1641 *
1642 * @return string
1643 */
1644 public static function format_price_range( $from, $to ): string {
1645 /* translators: 1: price from 2: price to */
1646 $price = sprintf( _x( '%1$s &ndash; %2$s', 'Price range: from-to', 'storeengine' ), is_numeric( $from ) ? self::price( $from, [ 'aria-hidden' => true ] ) : $from, is_numeric( $to ) ? self::price( $to, [ 'aria-hidden' => true ] ) : $to );
1647
1648 $price .= '<span class="screen-reader-text">';
1649 $price .= sprintf(
1650 /* translators: 1: price from 2: price to */
1651 __( 'Price range: %1$s through %2$s', 'storeengine' ),
1652 is_numeric( $from ) ? wp_strip_all_tags( self::price( $from ) ) : wp_strip_all_tags( $from ),
1653 is_numeric( $to ) ? wp_strip_all_tags( self::price( $to ) ) : wp_strip_all_tags( $to )
1654 );
1655 $price .= '</span>';
1656
1657 return apply_filters( 'storeengine/format_price_range', $price, $from, $to );
1658 }
1659
1660 /**
1661 * Make a refund total negative.
1662 *
1663 * @param float|int|string $amount Refunded amount.
1664 *
1665 * @return float
1666 */
1667 public static function format_refund_total( $amount ): float {
1668 return $amount * - 1;
1669 }
1670
1671 /**
1672 * Return an i18n'ified associative array of all possible subscription trial periods.
1673 *
1674 * @param int $number (optional) An interval in the range 1-6
1675 * @param string $period (optional) One of day, week, month or year. If empty, all subscription ranges are returned.
1676 *
1677 * @return string|array
1678 *
1679 * @since 1.6.9
1680 */
1681 public static function time_period_strings( int $number = 1, string $period = '' ) {
1682 $translated_periods = apply_filters( 'storeengine/time_periods',
1683 [
1684 // translators: placeholder is a number of days.
1685 'day' => sprintf( _n( '%s day', '%s days', $number, 'storeengine' ), number_format_i18n( $number ) ),
1686 // translators: placeholder is a number of weeks.
1687 'week' => sprintf( _n( '%s week', '%s weeks', $number, 'storeengine' ), number_format_i18n( $number ) ),
1688 // translators: placeholder is a number of months.
1689 'month' => sprintf( _n( '%s month', '%s months', $number, 'storeengine' ), number_format_i18n( $number ) ),
1690 // translators: placeholder is a number of years.
1691 'year' => sprintf( _n( '%s year', '%s years', $number, 'storeengine' ), number_format_i18n( $number ) ),
1692 ],
1693 $number
1694 );
1695
1696 return ( ! empty( $period ) ) ? $translated_periods[ $period ] : $translated_periods;
1697 }
1698
1699 /**
1700 * Appends the ordinal suffix to a given number.
1701 *
1702 * E.G. Given 2, the function returns 2nd.
1703 *
1704 * @param string $number The number to append the ordinal suffix to.
1705 *
1706 * @return string
1707 *
1708 * @since 1.6.9
1709 */
1710 public static function append_numeral_suffix( string $number ): string {
1711
1712 // Handle teens: if the tens digit of a number is 1, then write "th" after the number. For example: 11th, 13th, 19th, 112th, 9311th. http://en.wikipedia.org/wiki/English_numerals
1713 if ( strlen( $number ) > 1 && 1 == substr( $number, - 2, 1 ) ) {
1714 // translators: placeholder is a number, this is for the teens
1715 $number_string = sprintf( __( '%sth', 'storeengine' ), number_format_i18n( $number ) );
1716 } else { // Append relevant suffix
1717 switch ( substr( $number, - 1 ) ) {
1718 case 1:
1719 // translators: placeholder is a number, numbers ending in 1
1720 $number_string = sprintf( __( '%sst', 'storeengine' ), number_format_i18n( $number ) );
1721 break;
1722 case 2:
1723 // translators: placeholder is a number, numbers ending in 2
1724 $number_string = sprintf( __( '%snd', 'storeengine' ), number_format_i18n( $number ) );
1725 break;
1726 case 3:
1727 // translators: placeholder is a number, numbers ending in 3
1728 $number_string = sprintf( __( '%srd', 'storeengine' ), number_format_i18n( $number ) );
1729 break;
1730 default:
1731 // translators: placeholder is a number, numbers ending in 4-9, 0
1732 $number_string = sprintf( __( '%sth', 'storeengine' ), number_format_i18n( $number ) );
1733 break;
1734 }
1735 }
1736
1737 return apply_filters( 'storeengine/numeral_suffix', $number_string, $number );
1738 }
1739
1740 /**
1741 * Parse ID array/string into int array.
1742 *
1743 * @param string|string[]|int[]|float[]|object $ids
1744 *
1745 * @return int[]
1746 */
1747 public static function parse_ids( $ids ): array {
1748 if ( empty( $ids ) || ! is_array( $ids ) ) {
1749 if ( is_string( $ids ) ) {
1750 $ids = explode( ',', $ids );
1751 } elseif ( is_object( $ids ) ) {
1752 $ids = array_values( get_object_vars( $ids ) );
1753 } else {
1754 $ids = [];
1755 }
1756 }
1757
1758 ArrayUtil::flatten( $ids );
1759 sort( $ids );
1760
1761 return array_unique( array_filter( array_map( 'absint', $ids ) ) );
1762 }
1763 }
1764
1765 // End of file formatting.php
1766