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 / classes / tax.php

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

1,268 lines 41.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tax calculation and rate finding class.
4 */
5
6 namespace StoreEngine\Classes;
7
8 use StoreEngine\Utils\Caching;
9 use StoreEngine\Utils\Formatting;
10 use StoreEngine\Utils\Helper;
11 use StoreEngine\Utils\NumberUtil;
12 use StoreEngine\Utils\TaxUtil;
13 use WP_Error;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Mirrors the standard tax-engine init.
21 */
22 class Tax {
23
24 /**
25 * Precision.
26 *
27 * @var int
28 */
29 public static int $precision = 6;
30
31 /**
32 * Round at subtotal.
33 *
34 * @var bool
35 */
36 public static bool $round_at_subtotal = false;
37
38 /**
39 * Load options.
40 */
41 public static function init() {
42 self::$precision = Formatting::get_rounding_precision();
43 self::$round_at_subtotal = TaxUtil::tax_round_at_subtotal();
44 }
45
46 /**
47 * Calculate tax for a line.
48 *
49 * @param float|string $price Price to calc tax on.
50 * @param array $rates Rates to apply.
51 * @param boolean $price_includes_tax Whether the passed price has taxes included.
52 *
53 * @return array Array of rates + prices after tax.
54 */
55 public static function calc_tax( $price, array $rates, bool $price_includes_tax = false ): array {
56 $price = (float) $price;
57
58 // Allow an alternative tax calculator (e.g. Stripe Automatic Tax) to short-circuit
59 // the local rate-based math. A non-null return value is used as-is. See
60 // StoreEngine\Addons\Stripe\Tax\StripeTaxCalculator for the bundled implementation.
61 $override = apply_filters( 'storeengine/tax/calculator', null, $price, $rates, $price_includes_tax );
62 if ( null !== $override && is_array( $override ) ) {
63 return apply_filters( 'storeengine/calc_tax', $override, $price, $rates, $price_includes_tax );
64 }
65
66 if ( $price_includes_tax ) {
67 $taxes = self::calc_inclusive_tax( $price, $rates );
68 } else {
69 $taxes = self::calc_exclusive_tax( $price, $rates );
70 }
71
72 return apply_filters( 'storeengine/calc_tax', $taxes, $price, $rates, $price_includes_tax );
73 }
74
75 /**
76 * Calculate the shipping tax using a passed array of rates.
77 *
78 * @param float|string $price Shipping cost.
79 * @param array $rates Taxation Rate.
80 *
81 * @return array
82 */
83 public static function calc_shipping_tax( $price, array $rates ): array {
84 $taxes = self::calc_exclusive_tax( $price, $rates );
85
86 return apply_filters( 'storeengine/calc_shipping_tax', $taxes, $price, $rates );
87 }
88
89 /**
90 * Round to precision.
91 *
92 * Filter example: to return rounding to .5 cents you'd use:
93 *
94 * function euro_5cent_rounding( $in ) {
95 * return round( $in / 5, 2 ) * 5;
96 * }
97 * add_filter( 'storeengine_tax_round', 'euro_5cent_rounding' );
98 *
99 * @param float|int|string $in Value to round.
100 *
101 * @return float
102 */
103 public static function round( $in ): float {
104 return apply_filters( 'storeengine/tax_round', NumberUtil::round( $in, self::$precision ), $in );
105 }
106
107 /**
108 * Calc tax from inclusive price.
109 *
110 * @param float|string $price Price to calculate tax for.
111 * @param array $rates Array of tax rates.
112 *
113 * @return array
114 */
115 public static function calc_inclusive_tax( $price, array $rates ): array {
116 $taxes = [];
117 $compound_rates = [];
118 $regular_rates = [];
119
120 // Index array so taxes are output in correct order and see what compound/regular rates we have to calculate.
121 foreach ( $rates as $key => $rate ) {
122 $taxes[ $key ] = 0;
123
124 if ( 'yes' === $rate['compound'] ) {
125 $compound_rates[ $key ] = $rate['rate'];
126 } else {
127 $regular_rates[ $key ] = $rate['rate'];
128 }
129 }
130
131 $compound_rates = array_reverse( $compound_rates, true ); // Working backwards.
132 $non_compound_price = $price;
133
134 foreach ( $compound_rates as $key => $compound_rate ) {
135 $tax_amount = apply_filters( 'storeengine/price_inc_tax_amount', $non_compound_price - ( $non_compound_price / ( 1 + ( $compound_rate / 100 ) ) ), $key, $rates[ $key ], $price );
136 $non_compound_price = $non_compound_price - $tax_amount;
137 // Add to tax total data.
138 $taxes[ $key ] += $tax_amount;
139 }
140
141 // Regular taxes.
142 $regular_tax_rate = 1 + ( array_sum( $regular_rates ) / 100 );
143
144 foreach ( $regular_rates as $key => $regular_rate ) {
145 $the_rate = ( $regular_rate / 100 ) / $regular_tax_rate;
146 $net_price = $price - ( $the_rate * $non_compound_price );
147 $tax_amount = apply_filters( 'storeengine/price_inc_tax_amount', $price - $net_price, $key, $rates[ $key ], $price );
148 // Add to tax total data.
149 $taxes[ $key ] += $tax_amount;
150 }
151
152 /**
153 * Round all taxes to precision (4DP) before passing them back. Note, this is not the same rounding
154 * as in the cart calculation class which, depending on settings, will round to 2DP when calculating
155 * final totals. Also unlike that class, this rounds .5 up for all cases.
156 */
157 return array_map( [ __CLASS__, 'round' ], $taxes );
158 }
159
160 /**
161 * Calc tax from exclusive price.
162 *
163 * @param float|string $price Price to calculate tax for.
164 * @param array $rates Array of tax rates.
165 *
166 * @return array
167 */
168 public static function calc_exclusive_tax( $price, array $rates ): array {
169 $taxes = [];
170 $price = (float) $price;
171
172 if ( ! empty( $rates ) ) {
173 foreach ( $rates as $key => $rate ) {
174 if ( 'yes' === $rate['compound'] ) {
175 continue;
176 }
177
178 $tax_amount = $price * ( floatval( $rate['rate'] ) / 100 );
179 $tax_amount = apply_filters( 'storeengine/price_ex_tax_amount', $tax_amount, $key, $rate, $price ); // ADVANCED: Allow third parties to modify this rate.
180
181 if ( ! isset( $taxes[ $key ] ) ) {
182 $taxes[ $key ] = (float) $tax_amount;
183 } else {
184 $taxes[ $key ] += (float) $tax_amount;
185 }
186 }
187
188 $pre_compound_total = array_sum( $taxes );
189
190 // Compound taxes.
191 foreach ( $rates as $key => $rate ) {
192 if ( 'no' === $rate['compound'] ) {
193 continue;
194 }
195 $the_price_inc_tax = $price + $pre_compound_total;
196 $tax_amount = $the_price_inc_tax * ( floatval( $rate['rate'] ) / 100 );
197 $tax_amount = apply_filters( 'storeengine/price_ex_tax_amount', $tax_amount, $key, $rate, $price, $the_price_inc_tax, $pre_compound_total ); // ADVANCED: Allow third parties to modify this rate.
198
199 if ( ! isset( $taxes[ $key ] ) ) {
200 $taxes[ $key ] = (float) $tax_amount;
201 } else {
202 $taxes[ $key ] += (float) $tax_amount;
203 }
204
205 $pre_compound_total = array_sum( $taxes );
206 }
207 }
208
209 /**
210 * Round all taxes to precision (4DP) before passing them back. Note, this is not the same rounding
211 * as in the cart calculation class which, depending on settings, will round to 2DP when calculating
212 * final totals. Also unlike that class, this rounds .5 up for all cases.
213 */
214 return array_map( [ __CLASS__, 'round' ], $taxes );
215 }
216
217 /**
218 * Searches for all matching country/state/postcode tax rates.
219 *
220 * @param array|string $args Args that determine the rate to find.
221 *
222 * @return array
223 */
224 public static function find_rates( $args = [] ): array {
225 $args = wp_parse_args( $args, [
226 'country' => '',
227 'state' => '',
228 'city' => '',
229 'postcode' => '',
230 'tax_class' => '',
231 ] );
232
233 $country = $args['country'];
234 $state = $args['state'];
235 $city = $args['city'];
236 $postcode = Formatting::normalize_postcode( sanitize_text_field( $args['postcode'] ) );
237 $tax_class = $args['tax_class'];
238
239 if ( ! $country ) {
240 return [];
241 }
242
243 $cache_key = Caching::get_cache_prefix( 'taxes' ) . 'storeengine_tax_rates_' . md5( sprintf( '%s+%s+%s+%s+%s', $country, $state, $city, $postcode, $tax_class ) );
244 $matched_tax_rates = wp_cache_get( $cache_key, 'taxes' );
245
246 if ( false === $matched_tax_rates ) {
247 $matched_tax_rates = self::get_matched_tax_rates( $country, $state, $postcode, $city, $tax_class );
248 wp_cache_set( $cache_key, $matched_tax_rates, 'taxes' );
249 }
250
251 return apply_filters( 'storeengine/find_rates', $matched_tax_rates, $args );
252 }
253
254 /**
255 * Searches for all matching country/state/postcode tax rates.
256 *
257 * @param array|string $args Args that determine the rate to find.
258 *
259 * @return array
260 */
261 public static function find_shipping_rates( $args = [] ): array {
262 $rates = self::find_rates( $args );
263 $shipping_rates = [];
264
265 if ( $rates ) {
266 foreach ( $rates as $key => $rate ) {
267 if ( 'yes' === $rate['shipping'] ) {
268 $shipping_rates[ $key ] = $rate;
269 }
270 }
271 }
272
273 return $shipping_rates;
274 }
275
276 /**
277 * Does the sort comparison. Compares (in this order):
278 * - Priority
279 * - Country
280 * - State
281 * - Number of postcodes
282 * - Number of cities
283 * - ID
284 *
285 * @param object $rate1 First rate to compare.
286 * @param object $rate2 Second rate to compare.
287 *
288 * @return int
289 */
290 private static function sort_rates_callback( object $rate1, object $rate2 ): int {
291 if ( $rate1->tax_rate_priority !== $rate2->tax_rate_priority ) {
292 return $rate1->tax_rate_priority < $rate2->tax_rate_priority ? - 1 : 1; // ASC.
293 }
294
295 if ( $rate1->tax_rate_country !== $rate2->tax_rate_country ) {
296 if ( '' === $rate1->tax_rate_country ) {
297 return 1;
298 }
299 if ( '' === $rate2->tax_rate_country ) {
300 return - 1;
301 }
302
303 return strcmp( $rate1->tax_rate_country, $rate2->tax_rate_country ) > 0 ? 1 : - 1;
304 }
305
306 if ( $rate1->tax_rate_state !== $rate2->tax_rate_state ) {
307 if ( '' === $rate1->tax_rate_state ) {
308 return 1;
309 }
310 if ( '' === $rate2->tax_rate_state ) {
311 return - 1;
312 }
313
314 return strcmp( $rate1->tax_rate_state, $rate2->tax_rate_state ) > 0 ? 1 : - 1;
315 }
316
317 if ( isset( $rate1->postcode_count, $rate2->postcode_count ) && $rate1->postcode_count !== $rate2->postcode_count ) {
318 return $rate1->postcode_count < $rate2->postcode_count ? 1 : - 1;
319 }
320
321 if ( isset( $rate1->city_count, $rate2->city_count ) && $rate1->city_count !== $rate2->city_count ) {
322 return $rate1->city_count < $rate2->city_count ? 1 : - 1;
323 }
324
325 return $rate1->tax_rate_id < $rate2->tax_rate_id ? - 1 : 1;
326 }
327
328 /**
329 * Logical sort order for tax rates based on the following in order of priority.
330 *
331 * @param array $rates Rates to be sorted.
332 *
333 * @return array
334 */
335 private static function sort_rates( array $rates ): array {
336 uasort( $rates, __CLASS__ . '::sort_rates_callback' );
337
338 $i = 0;
339 foreach ( $rates as $rate ) {
340 $rate->tax_rate_order = $i ++;
341 }
342
343 return $rates;
344 }
345
346 /**
347 * Loop through a set of tax rates and get the matching rates (1 per priority).
348 *
349 * @param string $country Country code to match against.
350 * @param string $state State code to match against.
351 * @param string $postcode Postcode to match against.
352 * @param string $city City to match against.
353 * @param string $tax_class Tax class to match against.
354 *
355 * @return array
356 */
357 private static function get_matched_tax_rates( string $country, string $state, string $postcode, string $city, string $tax_class ): array {
358 global $wpdb;
359
360 // Query criteria - these will be ANDed.
361 $criteria = [];
362 $criteria[] = $wpdb->prepare( "tax_rate_country IN ( %s, '' )", strtoupper( $country ) );
363 $criteria[] = $wpdb->prepare( "tax_rate_state IN ( %s, '' )", strtoupper( $state ) );
364 $criteria[] = $wpdb->prepare( 'tax_rate_class = %s', sanitize_title( $tax_class ) );
365
366 // Pre-query postcode ranges for PHP based matching.
367 $postcode_search = Helper::get_wildcard_postcodes( $postcode, $country );
368 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
369 $postcode_ranges = $wpdb->get_results( "SELECT tax_rate_id, location_code FROM {$wpdb->prefix}storeengine_tax_rate_locations WHERE location_type = 'postcode' AND location_code LIKE '%...%';" );
370 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
371
372 if ( $postcode_ranges ) {
373 $matches = Helper::postcode_location_matcher( $postcode, $postcode_ranges, 'tax_rate_id', 'location_code', $country );
374 if ( ! empty( $matches ) ) {
375 foreach ( $matches as $matched_postcodes ) {
376 $postcode_search = array_merge( $postcode_search, $matched_postcodes );
377 }
378 }
379 }
380
381 $postcode_search = array_unique( $postcode_search );
382
383 /**
384 * Location matching criteria - ORed
385 * Needs to match:
386 * - rates with no postcodes and cities
387 * - rates with a matching postcode and city
388 * - rates with matching postcode, no city
389 * - rates with matching city, no postcode
390 */
391 $locations_criteria = [];
392 $locations_criteria[] = 'locations.location_type IS NULL';
393 $locations_criteria[] = "
394 locations.location_type = 'postcode' AND locations.location_code IN ('" . implode( "','", array_map( 'esc_sql', $postcode_search ) ) . "')
395 AND (
396 ( locations2.location_type = 'city' AND locations2.location_code = '" . esc_sql( strtoupper( $city ) ) . "' )
397 OR NOT EXISTS (
398 SELECT sub.tax_rate_id FROM {$wpdb->prefix}storeengine_tax_rate_locations as sub
399 WHERE sub.location_type = 'city'
400 AND sub.tax_rate_id = tax_rates.tax_rate_id
401 )
402 )
403 ";
404 $locations_criteria[] = "
405 locations.location_type = 'city' AND locations.location_code = '" . esc_sql( strtoupper( $city ) ) . "'
406 AND NOT EXISTS (
407 SELECT sub.tax_rate_id FROM {$wpdb->prefix}storeengine_tax_rate_locations as sub
408 WHERE sub.location_type = 'postcode'
409 AND sub.tax_rate_id = tax_rates.tax_rate_id
410 )
411 ";
412
413 $criteria[] = '( ( ' . implode( ' ) OR ( ', $locations_criteria ) . ' ) )';
414
415 $criteria_string = implode( ' AND ', $criteria );
416
417 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
418 /** @noinspection SqlAggregates */
419 /** @noinspection SqlConstantExpression */
420 $found_rates = $wpdb->get_results(
421 "
422 SELECT tax_rates.*, COUNT( locations.location_id ) as postcode_count, COUNT( locations2.location_id ) as city_count
423 FROM {$wpdb->prefix}storeengine_tax_rates as tax_rates
424 LEFT OUTER JOIN {$wpdb->prefix}storeengine_tax_rate_locations as locations ON tax_rates.tax_rate_id = locations.tax_rate_id
425 LEFT OUTER JOIN {$wpdb->prefix}storeengine_tax_rate_locations as locations2 ON tax_rates.tax_rate_id = locations2.tax_rate_id
426 WHERE 1=1 AND {$criteria_string}
427 GROUP BY tax_rates.tax_rate_id
428 ORDER BY tax_rates.tax_rate_priority
429 "
430 );
431 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
432
433 $found_rates = self::sort_rates( $found_rates );
434 $matched_tax_rates = [];
435 $found_priority = [];
436
437 foreach ( $found_rates as $found_rate ) {
438 if ( in_array( $found_rate->tax_rate_priority, $found_priority, true ) ) {
439 continue;
440 }
441
442 $matched_tax_rates[ $found_rate->tax_rate_id ] = [
443 'rate' => (float) $found_rate->tax_rate,
444 'label' => $found_rate->tax_rate_name,
445 'shipping' => $found_rate->tax_rate_shipping ? 'yes' : 'no',
446 'compound' => $found_rate->tax_rate_compound ? 'yes' : 'no',
447 ];
448
449 $found_priority[] = $found_rate->tax_rate_priority;
450 }
451
452 return apply_filters( 'storeengine/matched_tax_rates', $matched_tax_rates, $country, $state, $postcode, $city, $tax_class );
453 }
454
455 /**
456 * Get the customer tax location based on their status and the current page.
457 *
458 * Used by get_rates(), get_shipping_rates().
459 *
460 * @param string $tax_class string Optional, passed to the filter for advanced tax setups.
461 * @param ?Customer $customer Override the customer object to get their location.
462 *
463 * @return array
464 */
465 public static function get_tax_location( string $tax_class = '', ?Customer $customer = null ): array {
466 $location = [];
467
468 if ( is_null( $customer ) && storeengine()->customer ) {
469 $customer = storeengine()->customer;
470 }
471
472 if ( ! empty( $customer ) ) {
473 $location = $customer->get_taxable_address();
474 } elseif ( TaxUtil::prices_include_tax() || 'base' === TaxUtil::default_customer_address() || 'base' === TaxUtil::tax_based_on() ) {
475 $location = [
476 Countries::init()->get_base_country(),
477 Countries::init()->get_base_state(),
478 Countries::init()->get_base_postcode(),
479 Countries::init()->get_base_city(),
480 ];
481 }
482
483 return apply_filters( 'storeengine/get_tax_location', $location, $tax_class, $customer );
484 }
485
486 /**
487 * Get's an array of matching rates for a tax class.
488 *
489 * @param string $tax_class Tax class to get rates for.
490 * @param ?Customer $customer Override the customer object to get their location.
491 *
492 * @return array
493 */
494 public static function get_rates( string $tax_class = '', ?Customer $customer = null ): ?array {
495 $tax_class = sanitize_title( $tax_class );
496 $location = self::get_tax_location( $tax_class, $customer );
497
498 return self::get_rates_from_location( $tax_class, $location, $customer );
499 }
500
501 /**
502 * Get's an array of matching rates from location and tax class. $customer parameter is used to preserve backward compatibility for filter.
503 *
504 * @param string $tax_class Tax class to get rates for.
505 * @param array $location Location to compute rates for. Should be in form: array( country, state, postcode, city).
506 * @param ?Customer $customer Only used to maintain backward compatibility for filter `storeengine/matched_rates`.
507 *
508 * @return mixed|void Tax rates.
509 */
510 public static function get_rates_from_location( string $tax_class, array $location, ?Customer $customer = null ) {
511 $tax_class = sanitize_title( $tax_class );
512 $matched_tax_rates = [];
513
514 if ( count( $location ) === 4 ) {
515 list( $country, $state, $postcode, $city ) = $location;
516
517 $matched_tax_rates = self::find_rates( [
518 'country' => $country,
519 'state' => $state,
520 'postcode' => $postcode,
521 'city' => $city,
522 'tax_class' => $tax_class,
523 ] );
524 }
525
526 return apply_filters( 'storeengine/matched_rates', $matched_tax_rates, $tax_class, $customer );
527 }
528
529 /**
530 * Get's an array of matching rates for the shop's base country.
531 *
532 * @param string $tax_class Tax Class.
533 *
534 * @return array
535 */
536 public static function get_base_tax_rates( string $tax_class = '' ): array {
537 return apply_filters(
538 'storeengine/base_tax_rates',
539 self::find_rates(
540 [
541 'country' => Countries::init()->get_base_country(),
542 'state' => Countries::init()->get_base_state(),
543 'postcode' => Countries::init()->get_base_postcode(),
544 'city' => Countries::init()->get_base_city(),
545 'tax_class' => $tax_class,
546 ]
547 ),
548 $tax_class
549 );
550 }
551
552 /**
553 * Gets an array of matching shipping tax rates for a given class.
554 *
555 * @param ?string $tax_class Tax class to get rates for.
556 * @param ?Customer $customer Override the customer object to get their location.
557 *
558 * @return array
559 */
560 public static function get_shipping_tax_rates( ?string $tax_class = null, ?Customer $customer = null ): array {
561 // See if we have an explicitly set shipping tax class.
562 $shipping_tax_class = Helper::get_settings( 'shipping_tax_class' );
563
564 if ( 'inherit' !== $shipping_tax_class ) {
565 $tax_class = $shipping_tax_class;
566 }
567
568 $location = self::get_tax_location( $tax_class, $customer );
569 $matched_tax_rates = [];
570
571 if ( 4 === count( $location ) ) {
572 list( $country, $state, $postcode, $city ) = $location;
573
574 if ( ! is_null( $tax_class ) ) {
575 // This will be per item shipping.
576 $matched_tax_rates = self::find_shipping_rates(
577 [
578 'country' => $country,
579 'state' => $state,
580 'postcode' => $postcode,
581 'city' => $city,
582 'tax_class' => $tax_class,
583 ]
584 );
585 } elseif ( Helper::cart()->has_items() ) {
586
587 // This will be per order shipping - loop through the order and find the highest tax class rate.
588
589 $cart_tax_classes = Helper::cart()->get_cart_item_tax_classes_for_shipping();
590
591 // No tax classes = no taxable items.
592 if ( empty( $cart_tax_classes ) ) {
593 return [];
594 }
595
596 // If multiple classes are found, use the first one found unless a standard rate item is found. This will be the first listed in the 'additional tax class' section.
597 if ( count( $cart_tax_classes ) > 1 && ! in_array( '', $cart_tax_classes, true ) ) {
598 $tax_classes = self::get_tax_class_slugs();
599
600 foreach ( $tax_classes as $tax_class ) {
601 if ( in_array( $tax_class, $cart_tax_classes, true ) ) {
602 $matched_tax_rates = self::find_shipping_rates( [
603 'country' => $country,
604 'state' => $state,
605 'postcode' => $postcode,
606 'city' => $city,
607 'tax_class' => $tax_class,
608 ] );
609 break;
610 }
611 }
612 } elseif ( 1 === count( $cart_tax_classes ) ) {
613 // If a single tax class is found, use it.
614 $matched_tax_rates = self::find_shipping_rates( [
615 'country' => $country,
616 'state' => $state,
617 'postcode' => $postcode,
618 'city' => $city,
619 'tax_class' => $cart_tax_classes[0],
620 ] );
621 }
622 }
623
624 // Get standard rate if no taxes were found.
625 if ( ! count( $matched_tax_rates ) ) {
626 $matched_tax_rates = self::find_shipping_rates( [
627 'country' => $country,
628 'state' => $state,
629 'postcode' => $postcode,
630 'city' => $city,
631 ] );
632 }
633 }
634
635 return $matched_tax_rates;
636 }
637
638 /**
639 * Return true/false depending on if a rate is a compound rate.
640 *
641 * @param mixed $key_or_rate Tax rate ID, or the db row itself in object format.
642 *
643 * @return bool
644 */
645 public static function is_compound( $key_or_rate ): bool {
646 global $wpdb;
647
648 if ( is_object( $key_or_rate ) ) {
649 $key = $key_or_rate->tax_rate_id;
650 $compound = $key_or_rate->tax_rate_compound;
651 } else {
652 $key = $key_or_rate;
653 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
654 $compound = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT tax_rate_compound FROM {$wpdb->prefix}storeengine_tax_rates WHERE tax_rate_id = %s", $key ) );
655 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
656 }
657
658 return (bool) apply_filters( 'storeengine/rate_compound', $compound, $key );
659 }
660
661 /**
662 * Return a given rates label.
663 *
664 * @param mixed $key_or_rate Tax rate ID, or the db row itself in object format.
665 *
666 * @return string
667 */
668 public static function get_rate_label( $key_or_rate ): string {
669 global $wpdb;
670
671 if ( is_object( $key_or_rate ) ) {
672 $key = $key_or_rate->tax_rate_id;
673 $rate_name = $key_or_rate->tax_rate_name;
674 } else {
675 $key = $key_or_rate;
676 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
677 $rate_name = $wpdb->get_var( $wpdb->prepare( "SELECT tax_rate_name FROM {$wpdb->prefix}storeengine_tax_rates WHERE tax_rate_id = %s", $key ) );
678 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
679 }
680
681 if ( ! $rate_name ) {
682 $rate_name = Countries::init()->tax_or_vat();
683 }
684
685 return apply_filters( 'storeengine/rate_label', $rate_name, $key );
686 }
687
688 /**
689 * Return a given rates percent.
690 *
691 * @param mixed $key_or_rate Tax rate ID, or the db row itself in object format.
692 *
693 * @return string
694 */
695 public static function get_rate_percent( $key_or_rate ): string {
696 $rate_percent_value = self::get_rate_percent_value( $key_or_rate );
697 $tax_rate_id = is_object( $key_or_rate ) ? $key_or_rate->tax_rate_id : $key_or_rate;
698
699 return apply_filters( 'storeengine/rate_percent', $rate_percent_value . '%', $tax_rate_id );
700 }
701
702 /**
703 * Return a given rates percent.
704 *
705 * @param mixed $key_or_rate Tax rate ID, or the db row itself in object format.
706 *
707 * @return float
708 */
709 public static function get_rate_percent_value( $key_or_rate ): float {
710 global $wpdb;
711
712 if ( is_object( $key_or_rate ) ) {
713 $tax_rate = $key_or_rate->tax_rate;
714 } else {
715 $key = $key_or_rate;
716 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
717 $tax_rate = $wpdb->get_var( $wpdb->prepare( "SELECT tax_rate FROM {$wpdb->prefix}storeengine_tax_rates WHERE tax_rate_id = %s", $key ) );
718 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
719 }
720
721 return floatval( $tax_rate );
722 }
723
724
725 /**
726 * Get a rates code. Code is made up of COUNTRY-STATE-NAME-Priority. E.g GB-VAT-1, US-AL-TAX-1.
727 *
728 * @param mixed $key_or_rate Tax rate ID, or the db row itself in object format.
729 *
730 * @return string
731 */
732 public static function get_rate_code( $key_or_rate ): string {
733 global $wpdb;
734
735 if ( is_object( $key_or_rate ) ) {
736 $key = $key_or_rate->tax_rate_id;
737 $rate = $key_or_rate;
738 } else {
739 $key = $key_or_rate;
740 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
741 $rate = $wpdb->get_row( $wpdb->prepare( "SELECT tax_rate_country, tax_rate_state, tax_rate_name, tax_rate_priority FROM {$wpdb->prefix}storeengine_tax_rates WHERE tax_rate_id = %s", $key ) );
742 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
743 }
744
745 $code_string = '';
746
747 if ( null !== $rate ) {
748 $code = [];
749 $code[] = $rate->tax_rate_country;
750 $code[] = $rate->tax_rate_state;
751 $code[] = $rate->tax_rate_name ? $rate->tax_rate_name : 'TAX';
752 $code[] = absint( $rate->tax_rate_priority );
753 $code_string = strtoupper( implode( '-', array_filter( $code ) ) );
754 }
755
756 return apply_filters( 'storeengine/rate_code', $code_string, $key );
757 }
758
759 /**
760 * Sums a set of taxes to form a single total. Values are pre-rounded to precision from 3.6.0.
761 *
762 * @param float[]|int[] $taxes Array of taxes.
763 *
764 * @return float
765 */
766 public static function get_tax_total( array $taxes ): float {
767 return array_sum( $taxes );
768 }
769
770 /**
771 * Gets all tax rate classes from the database.
772 *
773 * @return array Array of tax class objects consisting of tax_rate_class_id, name, and slug.
774 */
775 public static function get_tax_rate_classes(): array {
776 return [];
777
778 // @TODO implement tax rate class table.
779 // phpcs:disable Squiz.PHP.CommentedOutCode.Found
780 // Not implemented.
781 /*global $wpdb;
782
783 $cache_key = 'tax-rate-classes';
784 $tax_rate_classes = wp_cache_get( $cache_key, 'taxes' );
785
786 if ( ! is_array( $tax_rate_classes ) ) {
787 $tax_rate_classes = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}storeengine_tax_rate_classes ORDER BY name;" );
788 wp_cache_set( $cache_key, $tax_rate_classes, 'taxes' );
789 }
790
791 return $tax_rate_classes;*/
792 // phpcs:enable Squiz.PHP.CommentedOutCode.Found
793 }
794
795 /**
796 * Get store tax class names.
797 *
798 * @return array Array of class names ("Reduced rate", "Zero rate", etc).
799 */
800 public static function get_tax_classes(): array {
801 return wp_list_pluck( self::get_tax_rate_classes(), 'name' );
802 }
803
804 /**
805 * Get store tax classes as slugs.
806 *
807 * @return array Array of class slugs ("reduced-rate", "zero-rate", etc).
808 */
809 public static function get_tax_class_slugs(): array {
810 return wp_list_pluck( self::get_tax_rate_classes(), 'slug' );
811 }
812
813 /**
814 * Create a new tax class.
815 *
816 * @param string $name Name of the tax class to add.
817 * @param string $slug (optional) Slug of the tax class to add. Defaults to sanitized name.
818 *
819 * @return WP_Error|array Returns name and slug (array) if the tax class is created, or WP_Error if something went wrong.
820 */
821 public static function create_tax_class( string $name, string $slug = '' ) {
822 global $wpdb;
823
824 if ( empty( $name ) ) {
825 return new WP_Error( 'tax_class_invalid_name', __( 'Tax class requires a valid name', 'storeengine' ) );
826 }
827
828 $existing = self::get_tax_classes();
829 $existing_slugs = self::get_tax_class_slugs();
830 $name = sanitize_text_field( $name );
831
832 if ( in_array( $name, $existing, true ) ) {
833 return new WP_Error( 'tax_class_exists', __( 'Tax class already exists', 'storeengine' ) );
834 }
835
836 if ( ! $slug ) {
837 $slug = sanitize_title( $name );
838 }
839
840 // Stop if there's no slug.
841 if ( ! $slug ) {
842 return new WP_Error( 'tax_class_slug_invalid', __( 'Tax class slug is invalid', 'storeengine' ) );
843 }
844
845 if ( in_array( $slug, $existing_slugs, true ) ) {
846 return new WP_Error( 'tax_class_slug_exists', __( 'Tax class slug already exists', 'storeengine' ) );
847 }
848
849 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
850 $insert = $wpdb->insert(
851 $wpdb->prefix . 'storeengine_tax_rate_classes',
852 [
853 'name' => $name,
854 'slug' => $slug,
855 ],
856 [ '%s', '%s' ]
857 );
858 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
859
860 if ( ! $insert && $wpdb->last_error ) {
861 return new WP_Error( 'tax_class_insert_error', $wpdb->last_error );
862 }
863
864 wp_cache_delete( 'tax-rate-classes', 'taxes' );
865
866 return [
867 'name' => $name,
868 'slug' => $slug,
869 ];
870 }
871
872 /**
873 * Get an existing tax class.
874 *
875 * @param string $field Field to get by. Valid values are id, name, or slug.
876 * @param string|int $item Item to get.
877 *
878 * @return array|bool|WP_Error Returns the tax class as an array. False if not found.
879 */
880 public static function get_tax_class_by( string $field, $item ) {
881 if ( ! in_array( $field, [ 'id', 'name', 'slug' ], true ) ) {
882 return new WP_Error( 'invalid_field', __( 'Invalid field', 'storeengine' ) );
883 }
884
885 if ( 'id' === $field ) {
886 $field = 'tax_rate_class_id';
887 }
888
889 $matches = wp_list_filter( self::get_tax_rate_classes(), [ $field => $item ] );
890
891 if ( ! $matches ) {
892 return false;
893 }
894
895 $tax_class = current( $matches );
896
897 return [
898 'name' => $tax_class->name,
899 'slug' => $tax_class->slug,
900 ];
901 }
902
903 /**
904 * Delete an existing tax class.
905 *
906 * @param string $field Field to delete by. Valid values are id, name, or slug.
907 * @param string|int $item Item to delete.
908 *
909 * @return WP_Error|bool Returns true if deleted successfully, false if nothing was deleted, or WP_Error if there is an invalid request.
910 */
911 public static function delete_tax_class_by( string $field, $item ) {
912 global $wpdb;
913
914 if ( ! in_array( $field, [ 'id', 'name', 'slug' ], true ) ) {
915 return new WP_Error( 'invalid_field', __( 'Invalid field', 'storeengine' ) );
916 }
917
918 $tax_class = self::get_tax_class_by( $field, $item );
919
920 if ( ! $tax_class ) {
921 return new WP_Error( 'invalid_tax_class', __( 'Invalid tax class', 'storeengine' ) );
922 }
923
924 $format = '%s';
925 if ( 'id' === $field ) {
926 $field = 'tax_rate_class_id';
927 $format = '%d';
928 }
929
930 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
931 $delete = $wpdb->delete( $wpdb->prefix . 'storeengine_tax_rate_classes', [ $field => $item ], [ $format ] );
932 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
933
934 if ( $delete ) {
935 // Delete associated tax rates.
936 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
937 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_tax_rates WHERE tax_rate_class = %s;", $tax_class['slug'] ) );
938 $wpdb->query( "DELETE locations FROM {$wpdb->prefix}storeengine_tax_rate_locations locations LEFT JOIN {$wpdb->prefix}storeengine_tax_rates rates ON rates.tax_rate_id = locations.tax_rate_id WHERE rates.tax_rate_id IS NULL;" );
939 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
940 }
941
942 wp_cache_delete( 'tax-rate-classes', 'taxes' );
943 Caching::invalidate_cache_group( 'taxes' );
944
945 return (bool) $delete;
946 }
947
948 /**
949 * Format the city.
950 *
951 * @param string $city Value to format.
952 *
953 * @return string
954 */
955 private static function format_tax_rate_city( string $city ): string {
956 return strtoupper( trim( $city ) );
957 }
958
959 /**
960 * Format the state.
961 *
962 * @param string $state Value to format.
963 *
964 * @return string
965 */
966 private static function format_tax_rate_state( string $state ): string {
967 $state = strtoupper( $state );
968
969 return ( '*' === $state ) ? '' : $state;
970 }
971
972 /**
973 * Format the country.
974 *
975 * @param string $country Value to format.
976 *
977 * @return string
978 */
979 private static function format_tax_rate_country( string $country ): string {
980 $country = strtoupper( $country );
981
982 return ( '*' === $country ) ? '' : $country;
983 }
984
985 /**
986 * Format the tax rate name.
987 *
988 * @param string $name Value to format.
989 *
990 * @return string
991 */
992 private static function format_tax_rate_name( string $name ): string {
993 return $name ? $name : __( 'Tax', 'storeengine' );
994 }
995
996 /**
997 * Format the rate.
998 *
999 * @param float|string|int $rate Value to format.
1000 *
1001 * @return string
1002 */
1003 private static function format_tax_rate( $rate ): string {
1004 return number_format( (float) $rate, 4, '.', '' );
1005 }
1006
1007 /**
1008 * Format the priority.
1009 *
1010 * @param string|int $priority Value to format.
1011 *
1012 * @return int
1013 */
1014 private static function format_tax_rate_priority( string $priority ): int {
1015 return absint( $priority );
1016 }
1017
1018 /**
1019 * Format the class.
1020 *
1021 * @param string $class Value to format.
1022 *
1023 * @return string
1024 */
1025 public static function format_tax_rate_class( string $class ): string {
1026 $class = sanitize_title( $class );
1027 $classes = self::get_tax_class_slugs();
1028 if ( ! in_array( $class, $classes, true ) ) {
1029 $class = '';
1030 }
1031
1032 return ( 'standard' === $class ) ? '' : $class;
1033 }
1034
1035 /**
1036 * Prepare and format tax rate for DB insertion.
1037 *
1038 * @param array $tax_rate Tax rate to format.
1039 *
1040 * @return array
1041 */
1042 private static function prepare_tax_rate( array $tax_rate ): array {
1043 foreach ( $tax_rate as $key => $value ) {
1044 if ( method_exists( __CLASS__, 'format_' . $key ) ) {
1045 if ( 'tax_rate_state' === $key ) {
1046 $tax_rate[ $key ] = call_user_func( [ __CLASS__, 'format_' . $key ], sanitize_key( $value ) );
1047 } else {
1048 $tax_rate[ $key ] = call_user_func( [ __CLASS__, 'format_' . $key ], $value );
1049 }
1050 }
1051 }
1052
1053 return $tax_rate;
1054 }
1055
1056 /**
1057 * Insert a new tax rate.
1058 *
1059 * Internal use only.
1060 *
1061 * @param array $tax_rate Tax rate to insert.
1062 *
1063 * @return int tax rate id
1064 */
1065 public static function _insert_tax_rate( array $tax_rate ): int {
1066 global $wpdb;
1067
1068 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
1069 $wpdb->insert( $wpdb->prefix . 'storeengine_tax_rates', self::prepare_tax_rate( $tax_rate ) );
1070 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
1071
1072 $tax_rate_id = (int) $wpdb->insert_id;
1073
1074 Caching::invalidate_cache_group( 'taxes' );
1075
1076 do_action( 'storeengine/tax_rate_added', $tax_rate_id, $tax_rate );
1077
1078 return $tax_rate_id;
1079 }
1080
1081 /**
1082 * Get tax rate.
1083 *
1084 * Internal use only.
1085 *
1086 * @param int|string $tax_rate_id Tax rate ID.
1087 * @param string $output_type Type of output.
1088 *
1089 * @return array|object
1090 */
1091 public static function _get_tax_rate( $tax_rate_id, string $output_type = ARRAY_A ) {
1092 global $wpdb;
1093
1094 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
1095 return $wpdb->get_row(
1096 $wpdb->prepare(
1097 "
1098 SELECT *
1099 FROM {$wpdb->prefix}storeengine_tax_rates
1100 WHERE tax_rate_id = %d
1101 ",
1102 absint( $tax_rate_id )
1103 ),
1104 $output_type
1105 );
1106 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
1107 }
1108
1109 /**
1110 * Update a tax rate.
1111 *
1112 * Internal use only.
1113 *
1114 * @param int|string $tax_rate_id Tax rate to update.
1115 * @param array $tax_rate Tax rate values.
1116 */
1117 public static function _update_tax_rate( $tax_rate_id, array $tax_rate ) {
1118 global $wpdb;
1119
1120 $tax_rate_id = absint( $tax_rate_id );
1121
1122 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1123 $wpdb->update( $wpdb->prefix . 'storeengine_tax_rates', self::prepare_tax_rate( $tax_rate ), [ 'tax_rate_id' => $tax_rate_id ] );
1124 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1125
1126 Caching::invalidate_cache_group( 'taxes' );
1127
1128 do_action( 'storeengine/tax_rate_updated', $tax_rate_id, $tax_rate );
1129 }
1130
1131 /**
1132 * Delete a tax rate from the database.
1133 *
1134 * Internal use only.
1135 *
1136 * @param int|string $tax_rate_id Tax rate to delete.
1137 */
1138 public static function _delete_tax_rate( $tax_rate_id ) {
1139 global $wpdb;
1140
1141 $tax_rate_id = absint( $tax_rate_id );
1142
1143 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1144 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_tax_rate_locations WHERE tax_rate_id = %d;", $tax_rate_id ) );
1145 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_tax_rates WHERE tax_rate_id = %d;", $tax_rate_id ) );
1146 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1147
1148 Caching::invalidate_cache_group( 'taxes' );
1149
1150 do_action( 'storeengine/tax_rate_deleted', $tax_rate_id );
1151 }
1152
1153 /**
1154 * Update postcodes for a tax rate in the DB.
1155 *
1156 * Internal use only.
1157 *
1158 * @param int|string $tax_rate_id Tax rate to update.
1159 * @param string|string[] $postcodes String of postcodes separated by ; characters.
1160 */
1161 public static function _update_tax_rate_postcodes( $tax_rate_id, $postcodes ) {
1162 if ( ! is_array( $postcodes ) ) {
1163 $postcodes = explode( ';', $postcodes );
1164 }
1165 // No normalization - postcodes are matched against both normal and formatted versions to support wildcards.
1166 foreach ( $postcodes as $key => $postcode ) {
1167 $postcodes[ $key ] = strtoupper( trim( str_replace( chr( 226 ) . chr( 128 ) . chr( 166 ), '...', $postcode ) ) );
1168 }
1169 self::update_tax_rate_locations( $tax_rate_id, array_diff( array_filter( $postcodes ), [ '*' ] ), 'postcode' );
1170 }
1171
1172 /**
1173 * Update cities for a tax rate in the DB.
1174 *
1175 * Internal use only.
1176 *
1177 * @param int|string $tax_rate_id Tax rate to update.
1178 * @param string|string[] $cities Cities to set.
1179 */
1180 public static function _update_tax_rate_cities( $tax_rate_id, $cities ) {
1181 if ( ! is_array( $cities ) ) {
1182 $cities = explode( ';', $cities );
1183 }
1184 $cities = array_filter( array_diff( array_map( [ __CLASS__, 'format_tax_rate_city' ], $cities ), [ '*' ] ) );
1185
1186 self::update_tax_rate_locations( $tax_rate_id, $cities, 'city' );
1187 }
1188
1189 /**
1190 * Updates locations (postcode and city).
1191 *
1192 * Internal use only.
1193 *
1194 * @param int|string $tax_rate_id Tax rate ID to update.
1195 * @param array $values Values to set.
1196 * @param string $type Location type.
1197 */
1198 private static function update_tax_rate_locations( $tax_rate_id, array $values, string $type ) {
1199 global $wpdb;
1200
1201 $tax_rate_id = absint( $tax_rate_id );
1202
1203 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- query prepared.
1204 $wpdb->query(
1205 $wpdb->prepare(
1206 "DELETE FROM {$wpdb->prefix}storeengine_tax_rate_locations WHERE tax_rate_id = %d AND location_type = %s;",
1207 $tax_rate_id,
1208 $type
1209 )
1210 );
1211
1212 if ( count( $values ) > 0 ) {
1213 $sql = "( '" . implode( "', $tax_rate_id, '" . esc_sql( $type ) . "' ),( '", array_map( 'esc_sql', $values ) ) . "', $tax_rate_id, '" . esc_sql( $type ) . "' )";
1214
1215 $wpdb->query( "INSERT INTO {$wpdb->prefix}storeengine_tax_rate_locations ( location_code, tax_rate_id, location_type ) VALUES $sql;" );
1216 }
1217 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- query prepared.
1218
1219 Caching::invalidate_cache_group( 'taxes' );
1220 }
1221
1222 /**
1223 * Used by admin settings page.
1224 *
1225 * @param string $tax_class Tax class slug.
1226 *
1227 * @return array|null|object
1228 */
1229 public static function get_rates_for_tax_class( $tax_class ) {
1230 global $wpdb;
1231
1232 $tax_class = self::format_tax_rate_class( $tax_class );
1233
1234 // Get all the rates and locations. Snagging all at once should significantly cut down on the number of queries.
1235 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1236 $rates = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `{$wpdb->prefix}storeengine_tax_rates` WHERE `tax_rate_class` = %s;", $tax_class ) );
1237 $locations = $wpdb->get_results( "SELECT * FROM `{$wpdb->prefix}storeengine_tax_rate_locations`" );
1238 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1239
1240 if ( ! empty( $rates ) ) {
1241 // Set the rates keys equal to their ids.
1242 $rates = array_combine( wp_list_pluck( $rates, 'tax_rate_id' ), $rates );
1243 }
1244
1245 // Drop the locations into the rates array.
1246 foreach ( $locations as $location ) {
1247 // Don't set them for nonexistent rates.
1248 if ( ! isset( $rates[ $location->tax_rate_id ] ) ) {
1249 continue;
1250 }
1251 // If the rate exists, initialize the array before appending to it.
1252 if ( ! isset( $rates[ $location->tax_rate_id ]->{$location->location_type} ) ) {
1253 $rates[ $location->tax_rate_id ]->{$location->location_type} = [];
1254 }
1255 $rates[ $location->tax_rate_id ]->{$location->location_type}[] = $location->location_code;
1256 }
1257
1258 foreach ( $rates as $rate_id => $rate ) {
1259 $rates[ $rate_id ]->postcode_count = isset( $rates[ $rate_id ]->postcode ) ? count( $rates[ $rate_id ]->postcode ) : 0;
1260 $rates[ $rate_id ]->city_count = isset( $rates[ $rate_id ]->city ) ? count( $rates[ $rate_id ]->city ) : 0;
1261 }
1262
1263 return self::sort_rates( $rates );
1264 }
1265 }
1266
1267 // End of file tax.php
1268