| @@ -13,8 +13,27 @@ | ||
| 13 | 13 | |
| 14 | 14 | class TaxCalculator |
| 15 | 15 | { |
| 16 | 16 | |
| 17 | + /** | |
| 18 | + * Per-request memos. In production every HTTP request runs in a fresh PHP | |
| 19 | + * process, so these live exactly one request. Long-running processes that | |
| 20 | + * simulate multiple requests (test suites, CLI) must clear them between | |
| 21 | + * simulated requests via resetCache() — as function-statics they | |
| 22 | + * were unreachable and leaked the first request's terms/overrides/tax-class | |
| 23 | + * lookups into every subsequent one. | |
| 24 | + */ | |
| 25 | + private static $termsCache = null; | |
| 26 | + private static $overridesCache = null; | |
| 27 | + private static $taxClassCache = []; | |
| 28 | + | |
| 29 | + public static function resetCache(): void | |
| 30 | + { | |
| 31 | + self::$termsCache = null; | |
| 32 | + self::$overridesCache = null; | |
| 33 | + self::$taxClassCache = []; | |
| 34 | + } | |
| 35 | + | |
| 17 | 36 | protected $productIds = []; |
| 18 | 37 | |
| 19 | 38 | protected $taxMaps = []; |
| 20 | 39 | |
| @@ -694,13 +713,11 @@ | ||
| 694 | 713 | // in one query (mirrors the bulk-load pattern in getTermsByProductId). |
| 695 | 714 | // Note: static scope is per-request, not per-instance — safe because each |
| 696 | 715 | // HTTP request processes a single cart/address combination. |
| 697 | 716 | // Each term_id stores an array of overrides (multiple location variants allowed). |
| 698 | - static $overridesByTermId = null; | |
| 717 | + if (self::$overridesCache === null) { | |
| 718 | + self::$overridesCache = []; | |
| 699 | 719 | |
| 700 | - if ($overridesByTermId === null) { | |
| 701 | - $overridesByTermId = []; | |
| 702 | - | |
| 703 | 720 | $allTermIds = []; |
| 704 | 721 | foreach ($this->productIds as $pid) { |
| 705 | 722 | $allTermIds = array_merge($allTermIds, $this->getTermsByProductId($pid)); |
| 706 | 723 | } |
| @@ -713,9 +730,9 @@ | ||
| 713 | 730 | ->forTaxOverrideCountry($this->country) |
| 714 | 731 | ->get(); |
| 715 | 732 | |
| 716 | 733 | foreach ($overrides as $override) { |
| 717 | - $overridesByTermId[(int) $override->object_id][] = is_array($override->meta_value) | |
| 734 | + self::$overridesCache[(int) $override->object_id][] = is_array($override->meta_value) | |
| 718 | 735 | ? $override->meta_value |
| 719 | 736 | : []; |
| 720 | 737 | } |
| 721 | 738 | } |
| @@ -728,13 +745,13 @@ | ||
| 728 | 745 | $best = null; |
| 729 | 746 | $bestScore = -1; |
| 730 | 747 | |
| 731 | 748 | foreach ($termIds as $termId) { |
| 732 | - if (!array_key_exists((int) $termId, $overridesByTermId)) { | |
| 749 | + if (!array_key_exists((int) $termId, self::$overridesCache)) { | |
| 733 | 750 | continue; |
| 734 | 751 | } |
| 735 | 752 | |
| 736 | - foreach ($overridesByTermId[(int) $termId] as $metaValue) { | |
| 753 | + foreach (self::$overridesCache[(int) $termId] as $metaValue) { | |
| 737 | 754 | $score = $this->scoreOverrideMatch($metaValue, $lineItemClassId); |
| 738 | 755 | if ($score === null) { |
| 739 | 756 | continue; |
| 740 | 757 | } |
| @@ -945,9 +962,9 @@ | ||
| 945 | 962 | } |
| 946 | 963 | |
| 947 | 964 | $variantTaxClassSlug = Arr::get($variantOtherInfo, 'tax_class'); |
| 948 | 965 | if ($variantTaxClassSlug) { |
| 949 | - $class = TaxClass::query()->where('slug', sanitize_text_field($variantTaxClassSlug))->first(); | |
| 966 | + $class = $this->getTaxClassBySlug(sanitize_text_field($variantTaxClassSlug)); | |
| 950 | 967 | if ($class) { |
| 951 | 968 | return [$class]; |
| 952 | 969 | } |
| 953 | 970 | } |
| @@ -962,37 +979,46 @@ | ||
| 962 | 979 | } |
| 963 | 980 | |
| 964 | 981 | protected function getTermsByProductId($productId) |
| 965 | 982 | { |
| 966 | - static $formattedTerms = null; | |
| 967 | - | |
| 968 | - if ($formattedTerms === null) { | |
| 983 | + if (self::$termsCache === null) { | |
| 969 | 984 | $terms = App::make('db')->table('term_relationships') |
| 970 | 985 | ->whereIn('object_id', $this->productIds) |
| 971 | 986 | ->get(); |
| 972 | 987 | |
| 973 | - $formattedTerms = []; | |
| 988 | + self::$termsCache = []; | |
| 974 | 989 | |
| 975 | 990 | foreach ($terms as $term) { |
| 976 | - if (!isset($formattedTerms[$term->object_id])) { | |
| 977 | - $formattedTerms[$term->object_id] = []; | |
| 991 | + if (!isset(self::$termsCache[$term->object_id])) { | |
| 992 | + self::$termsCache[$term->object_id] = []; | |
| 978 | 993 | } |
| 979 | - $formattedTerms[$term->object_id][] = $term->term_taxonomy_id; | |
| 994 | + self::$termsCache[$term->object_id][] = $term->term_taxonomy_id; | |
| 980 | 995 | } |
| 981 | 996 | } |
| 982 | 997 | |
| 983 | - return Arr::get($formattedTerms, $productId, []); | |
| 998 | + return Arr::get(self::$termsCache, $productId, []); | |
| 984 | 999 | } |
| 985 | 1000 | |
| 986 | 1001 | protected function getStandardTaxClass() |
| 987 | 1002 | { |
| 988 | - static $standardTaxClass = false; | |
| 1003 | + return $this->getTaxClassBySlug('standard'); | |
| 1004 | + } | |
| 989 | 1005 | |
| 990 | - if ($standardTaxClass === false) { | |
| 991 | - $standardTaxClass = TaxClass::query()->where('slug', 'standard')->first() ?: null; | |
| 1006 | + /** | |
| 1007 | + * Lazily filled per-slug tax-class map — each slug is queried at most once | |
| 1008 | + * per request, only when the app actually needs it (variant tax_class | |
| 1009 | + * lookups and the standard-class fallback share the same cache). | |
| 1010 | + * | |
| 1011 | + * @return TaxClass|null | |
| 1012 | + */ | |
| 1013 | + protected function getTaxClassBySlug($slug) | |
| 1014 | + { | |
| 1015 | + $slug = (string) $slug; | |
| 1016 | + if (!array_key_exists($slug, self::$taxClassCache)) { | |
| 1017 | + self::$taxClassCache[$slug] = TaxClass::query()->where('slug', $slug)->first() ?: null; | |
| 992 | 1018 | } |
| 993 | 1019 | |
| 994 | - return $standardTaxClass; | |
| 1020 | + return self::$taxClassCache[$slug]; | |
| 995 | 1021 | } |
| 996 | 1022 | |
| 997 | 1023 | private function roundTax($amount) |
| 998 | 1024 | { |