PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/Tax/TaxCalculator.php +737 -118 1.3.21 → 1.6.5 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace FluentCart\App\Modules\Tax;
4 4
5 5 use FluentCart\App\App;
6 +use FluentCart\App\Models\Meta;
6 7 use FluentCart\App\Models\TaxRate;
7 8 use FluentCart\App\Models\TaxClass;
8 9 use FluentCart\Framework\Support\Arr;
9 10 use FluentCart\App\Services\Tax\TaxManager;
@@ -12,8 +13,27 @@
12 13
13 14 class TaxCalculator
14 15 {
15 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 +
16 36 protected $productIds = [];
17 37
18 38 protected $taxMaps = [];
19 39
@@ -35,8 +55,10 @@
35 55 protected $manualDiscounts = 0;
36 56
37 57 protected $taxSettings = [];
38 58
59 + private $roundingMode = 'item';
60 +
39 61 public function __construct($lineItems, $config = [])
40 62 {
41 63 $this->inclusive = Arr::get($config, 'inclusive', true);
42 64 $this->manualDiscounts = Arr::get($config, 'manual_discounts', 0);
@@ -43,9 +65,18 @@
43 65 $this->country = Arr::get($config, 'country');
44 66 $this->state = Arr::get($config, 'state');
45 67 $this->city = Arr::get($config, 'city');
46 68 $this->postCode = Arr::get($config, 'postcode');
69 + $this->roundingMode = Arr::get($config, 'tax_rounding', 'item');
47 70
71 + // Map territory country codes (GP→FR+state=GP) before rate lookup.
72 + $resolved = TaxManager::getInstance()->resolveTaxCountryAndState(
73 + (string) $this->country,
74 + $this->state
75 + );
76 + $this->country = $resolved['country'];
77 + $this->state = $resolved['state'];
78 +
48 79 $taxSettings = (new TaxModule())->getSettings();
49 80
50 81 $this->taxSettings = $taxSettings;
51 82
@@ -60,9 +91,9 @@
60 91 $this->productIds = array_values(array_unique(array_filter(array_column($lineItems, 'post_id'))));
61 92
62 93 if ($this->productIds) {
63 94 $this->products = \FluentCart\App\Models\Product::query()->whereIn('id', $this->productIds)
64 - ->with(['detail'])
95 + ->with(['detail', 'variants'])
65 96 ->get()
66 97 ->keyBy('ID');
67 98 }
68 99 $this->setupMaps();
@@ -69,38 +100,76 @@
69 100 }
70 101
71 102 }
72 103
73 - public function getTaxBahaviorValue()
104 + public function getTaxBehaviorValue()
74 105 {
75 - if ($this->inclusive) {
76 - return 2;
106 + // Collect the effective per-line inclusive flags (set in setupMaps via variation
107 + // override or store fallback). If every non-fee line agrees, use that value so
108 + // per-variation tax_inclusion overrides propagate to tax_behavior, the cart total
109 + // filter, and the checkout display. Mixed carts return 3.
110 + $lineInclusiveValues = [];
111 + foreach ($this->formattedLineItems as $lineItem) {
112 + if (!empty($lineItem['is_fee'])) {
113 + continue;
114 + }
115 + $lineInclusiveValues[] = (bool) Arr::get($lineItem, 'line_meta.tax_config.inclusive');
77 116 }
78 117
79 - return 1;
118 + if ($lineInclusiveValues && count(array_unique($lineInclusiveValues)) === 1) {
119 + return $lineInclusiveValues[0] ? 2 : 1;
120 + }
121 +
122 + // Mixed cart: at least one item differs from the rest.
123 + if (count($lineInclusiveValues) > 0) {
124 + return 3;
125 + }
126 +
127 + // Empty cart (all fees, no product lines).
128 + return $this->inclusive ? 2 : 1;
80 129 }
81 130
131 + /**
132 + * Returns the store-level inclusive mode unconditionally (1 or 2, never 3).
133 + * Used alongside tax_behavior=3 to determine how to handle shipping and fee tax.
134 + *
135 + * $this->inclusive is always the store setting — TaxCalculator.__construct() overrides
136 + * the $config['inclusive'] with Arr::get($taxSettings, 'tax_inclusion') === 'included'
137 + * at line 68, so this value is always correct regardless of what was passed in config.
138 + */
139 + public function getStoreTaxBehaviorValue()
140 + {
141 + return $this->inclusive ? 2 : 1;
142 + }
143 +
82 144 public function setupMaps()
83 145 {
84 - foreach ($this->productIds as $productId) {
85 - // we have to check if the product has specific tax rate assigned!
86 - $this->taxMaps[$productId] = $this->getRatesByProductId($productId);
146 + foreach ($this->lineItems as $lineItem) {
147 + if (!empty($lineItem['is_fee'])) {
148 + continue;
149 + }
150 +
151 + $taxMapKey = $this->getTaxMapKey($lineItem);
152 + if (!array_key_exists($taxMapKey, $this->taxMaps)) {
153 + // Rates must be resolved from the concrete variation in the cart
154 + // before using category or standard fallbacks.
155 + $this->taxMaps[$taxMapKey] = $this->getRatesByLineItem($lineItem);
156 + }
87 157 }
88 158
89 159 $formattedLineItems = [];
90 160 foreach ($this->lineItems as $lineItem) {
91 161 $isFee = !empty($lineItem['is_fee']);
92 - $productId = Arr::get($lineItem, 'post_id');
93 162
94 163 // Fee items (post_id = 0) use the first product's tax rates
95 164 if ($isFee) {
96 165 $isTaxable = Arr::get($lineItem, 'other_info.taxable', false);
97 - $firstProductId = Arr::first(array_keys($this->taxMaps));
98 - $rates = ($isTaxable && $firstProductId !== null)
99 - ? Arr::get($this->taxMaps, $firstProductId, [])
166 + $firstTaxMapKey = Arr::first(array_keys($this->taxMaps));
167 + $rates = ($isTaxable && $firstTaxMapKey !== null)
168 + ? Arr::get($this->taxMaps, $firstTaxMapKey, [])
100 169 : [];
101 170 } else {
102 - $rates = Arr::get($this->taxMaps, $productId, []);
171 + $rates = Arr::get($this->taxMaps, $this->getTaxMapKey($lineItem), []);
103 172 }
104 173 $taxLines = [];
105 174 $signupFeeTaxLines = [];
106 175 $lineTaxTotal = 0;
@@ -109,9 +178,9 @@
109 178 $signupFee = 0;
110 179 $recurringAmount = 0;
111 180 $isSubscription = Arr::get($lineItem, 'other_info.payment_type') === 'subscription';
112 181
113 - $taxableAmount = Arr::get($lineItem, 'subtotal', 0) - Arr::get($lineItem, 'discount_total', 0);
182 + $taxableAmount = max(0, Arr::get($lineItem, 'subtotal', 0) - Arr::get($lineItem, 'discount_total', 0));
114 183
115 184
116 185 if ($isSubscription) {
117 186 $signupFee = Arr::get($lineItem, 'other_info.signup_fee', 0);
@@ -127,8 +196,13 @@
127 196 }
128 197 }
129 198
130 199
200 + $lineInclusive = $this->getVariantInclusiveForLineItem($lineItem);
201 + if ($lineInclusive === null) {
202 + $lineInclusive = $this->inclusive;
203 + }
204 +
131 205 if ($rates) {
132 206 foreach ($rates as $rate) {
133 207 $rateSignupFeeTax = 0;
134 208 $rateRecurringTax = 0;
@@ -133,16 +207,16 @@
133 207 $rateSignupFeeTax = 0;
134 208 $rateRecurringTax = 0;
135 209 // Access is_compound as object property
136 210 $isCompound = $rate->is_compound;
137 -
211 +
138 212 // For compound rates, calculate on subtotal + accumulated taxes
139 213 $currentTaxableAmount = $taxableAmount;
140 214 $currentRecurringAmount = $recurringAmount;
141 215 $currentSignupFee = $signupFee;
142 -
216 +
143 217 if ($isCompound) {
144 -
218 +
145 219 $currentTaxableAmount = $taxableAmount + $lineTaxTotal;
146 220 if ($recurringAmount) {
147 221 $currentRecurringAmount = $recurringAmount + $recurringTax;
148 222 }
@@ -149,10 +223,10 @@
149 223 if ($signupFee) {
150 224 $currentSignupFee = $signupFee + $signupFeeTax;
151 225 }
152 226 }
153 -
154 - if ($this->inclusive) {
227 +
228 + if ($lineInclusive) {
155 229 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / (100 + $rate->rate);
156 230 if ($recurringAmount) {
157 231 $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / (100 + $rate->rate);
158 232 $recurringTax += $rateRecurringTax;
@@ -160,14 +234,14 @@
160 234 if ($signupFee) {
161 235 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / (100 + $rate->rate);
162 236 }
163 237 } else {
164 -
165 238
239 +
166 240 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / 100;
167 241 if ($recurringAmount) {
168 - $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / 100;
169 - $recurringTax += $rateRecurringTax;
242 + $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / 100;
243 + $recurringTax += $rateRecurringTax;
170 244 }
171 245 if ($signupFee) {
172 246 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / 100;
173 247 }
@@ -175,16 +249,16 @@
175 249
176 250 $taxLines[] = [
177 251 'rate_id' => $rate->id,
178 252 'label' => $rate->name,
179 - 'tax_amount' => ceil($taxAmount),
180 - 'recurring_tax' => ceil($rateRecurringTax),
253 + 'tax_amount' => $this->roundTax($taxAmount),
254 + 'recurring_tax' => $this->roundTax($rateRecurringTax),
181 255 'rate' => $rate->rate,
182 256 'rate_percent' => $rate->rate,
183 257 'for_shipping' => $rate->for_shipping,
184 258 'country' => $rate->country,
185 259 'is_compound' => $isCompound,
186 - 'taxable_amount' => ceil($currentTaxableAmount),
260 + 'taxable_amount' => $this->roundTax($currentTaxableAmount),
187 261 ];
188 262
189 263 if ($rateSignupFeeTax) {
190 264 $signupFeeTaxLines[] = [
@@ -189,15 +263,15 @@
189 263 if ($rateSignupFeeTax) {
190 264 $signupFeeTaxLines[] = [
191 265 'rate_id' => $rate->id,
192 266 'label' => $rate->name,
193 - 'tax_amount' => ceil($rateSignupFeeTax),
267 + 'tax_amount' => $this->roundTax($rateSignupFeeTax),
194 268 'rate' => $rate->rate,
195 269 'rate_percent' => $rate->rate,
196 270 'for_shipping' => $rate->for_shipping,
197 271 'country' => $rate->country,
198 272 'is_compound' => $isCompound,
199 - 'taxable_amount' => ceil($currentSignupFee),
273 + 'taxable_amount' => $this->roundTax($currentSignupFee),
200 274 ];
201 275
202 276 $signupFeeTax += $rateSignupFeeTax;
203 277 }
@@ -211,20 +285,23 @@
211 285 $lineItem['line_meta'] = [];
212 286 }
213 287
214 288 $lineItem['line_meta']['tax_config'] = [
215 - 'inclusive' => $this->inclusive,
289 + 'inclusive' => $lineInclusive,
216 290 'rates' => $taxLines,
217 291 ];
218 292
219 293 if ($isSubscription) {
220 - Arr::set($lineItem, 'other_info.recurring_tax', ceil($recurringTax));
294 + Arr::set($lineItem, 'other_info.recurring_tax', $this->roundTax($recurringTax));
221 295 if ($signupFeeTax) {
222 - Arr::set($lineItem, 'other_info.signup_fee_tax', ceil($signupFeeTax));
296 + Arr::set($lineItem, 'other_info.signup_fee_tax', $this->roundTax($signupFeeTax));
223 297 $lineItem['signup_fee_tax_config'] = [
224 - 'inclusive' => $this->inclusive,
298 + 'inclusive' => $lineInclusive,
225 299 'rates' => $signupFeeTaxLines,
226 300 ];
301 + } else {
302 + unset($lineItem['other_info']['signup_fee_tax']);
303 + unset($lineItem['signup_fee_tax_config']);
227 304 }
228 305
229 306 } else {
230 307 unset($lineItem['other_info']['signup_fee_tax']);
@@ -230,9 +307,9 @@
230 307 unset($lineItem['other_info']['signup_fee_tax']);
231 308 unset($lineItem['signup_fee_tax_lines']);
232 309 }
233 310
234 - $lineItem['tax_amount'] = ceil($lineTaxTotal);
311 + $lineItem['tax_amount'] = $this->roundTax($lineTaxTotal);
235 312
236 313 $formattedLineItems[] = $lineItem;
237 314 }
238 315
@@ -254,28 +331,65 @@
254 331 foreach ($lineItems as $lineItem) {
255 332 $lineMeta = Arr::get($lineItem, 'line_meta', []);
256 333 $taxConfig = Arr::get($lineMeta, 'tax_config', []);
257 334 $rates = Arr::get($taxConfig, 'rates', []);
335 + $lineInclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
258 336 if ($rates) {
259 337 foreach ($rates as $rate) {
260 338 $rateId = Arr::get($rate, 'rate_id');
261 339 if (!isset($taxLines[$rateId])) {
262 340 $taxLines[$rateId] = [
263 - 'rate_id' => $rateId,
264 - 'label' => Arr::get($rate, 'label'),
265 - 'tax_amount' => 0,
341 + 'rate_id' => $rateId,
342 + 'label' => Arr::get($rate, 'label'),
343 + 'rate_percent' => Arr::get($rate, 'rate_percent', 0),
344 + 'tax_amount' => 0,
345 + 'taxable_amount' => 0,
346 + 'is_compound' => Arr::get($rate, 'is_compound', false),
347 + 'inclusive' => $lineInclusive,
348 + 'is_mixed_inclusive' => false,
266 349 ];
350 + } elseif ($taxLines[$rateId]['inclusive'] !== $lineInclusive) {
351 + $taxLines[$rateId]['inclusive'] = null;
352 + $taxLines[$rateId]['is_mixed_inclusive'] = true;
267 353 }
268 354 $taxLines[$rateId]['tax_amount'] += Arr::get($rate, 'tax_amount', 0);
355 + $taxLines[$rateId]['taxable_amount'] += Arr::get($rate, 'taxable_amount', 0);
269 356 }
270 357 }
358 +
359 + // Also include signup fee tax so the breakdown totals reflect the combined taxable base.
360 + $signupFeeRates = Arr::get($lineItem, 'signup_fee_tax_config.rates', []);
361 + foreach ($signupFeeRates as $rate) {
362 + $rateId = Arr::get($rate, 'rate_id');
363 + if (!isset($taxLines[$rateId])) {
364 + continue;
365 + }
366 + $taxLines[$rateId]['tax_amount'] += Arr::get($rate, 'tax_amount', 0);
367 + $taxLines[$rateId]['taxable_amount'] += Arr::get($rate, 'taxable_amount', 0);
368 + }
271 369 }
272 370
371 + // For subtotal mode: round once per rate after accumulating all items
372 + if ($this->roundingMode === 'subtotal') {
373 + foreach ($taxLines as $rateId => $line) {
374 + $taxLines[$rateId]['tax_amount'] = $this->finalRound($line['tax_amount']);
375 + }
376 + }
377 +
273 378 return array_values($taxLines);
274 379 }
275 380
276 381 public function getTotalTax()
277 382 {
383 + if ($this->roundingMode === 'subtotal') {
384 + $taxLines = $this->getTaxLinesByRates();
385 + $taxTotal = 0;
386 + foreach ($taxLines as $line) {
387 + $taxTotal += $line['tax_amount'];
388 + }
389 + return (int) $taxTotal;
390 + }
391 +
278 392 $taxTotal = 0;
279 393 foreach ($this->formattedLineItems as $lineItem) {
280 394 $mainTaxAmount = Arr::get($lineItem, 'tax_amount', 0);
281 395 $taxTotal += $mainTaxAmount;
@@ -287,11 +401,43 @@
287 401 }
288 402 }
289 403 }
290 404
291 - return ceil($taxTotal);
405 + return $this->finalRound($taxTotal);
292 406 }
293 407
408 + public function getExclusiveTaxTotal()
409 + {
410 + $exclusiveLines = array_values(array_filter($this->formattedLineItems, function ($lineItem) {
411 + if (!empty($lineItem['is_fee'])) {
412 + return false;
413 + }
414 + return !Arr::get($lineItem, 'line_meta.tax_config.inclusive', false);
415 + }));
416 +
417 + if ($this->roundingMode === 'subtotal') {
418 + $taxLines = $this->getTaxLinesByRates($exclusiveLines);
419 + $total = 0;
420 + foreach ($taxLines as $line) {
421 + $total += $line['tax_amount'];
422 + }
423 + return (int) $total;
424 + }
425 +
426 + $total = 0;
427 + foreach ($exclusiveLines as $lineItem) {
428 + $total += Arr::get($lineItem, 'tax_amount', 0);
429 + if (Arr::get($lineItem, 'other_info.payment_type') === 'subscription') {
430 + $signupFeeTax = Arr::get($lineItem, 'other_info.signup_fee_tax', 0);
431 + if ($signupFeeTax) {
432 + $total += $signupFeeTax;
433 + }
434 + }
435 + }
436 +
437 + return $this->finalRound($total);
438 + }
439 +
294 440 public function getRecurringTax()
295 441 {
296 442 $recurringTaxTotal = 0;
297 443 foreach ($this->formattedLineItems as $lineItem) {
@@ -298,9 +444,9 @@
298 444 $recurringTax = Arr::get($lineItem, 'other_info.recurring_tax', 0);
299 445 $recurringTaxTotal += $recurringTax;
300 446 }
301 447
302 - return ceil($recurringTaxTotal);
448 + return $this->finalRound($recurringTaxTotal);
303 449 }
304 450
305 451 public function getTaxCountry()
306 452 {
@@ -335,29 +481,151 @@
335 481 $shippingBase = $totalShippingCharge + $accumulatedShippingTax;
336 482 }
337 483
338 484 if ($this->inclusive) {
339 - $shippingTax = ($shippingBase * $effectiveRate) / (100 + $effectiveRate);
485 + $shippingTax = $effectiveRate > 0 ? ($shippingBase * $effectiveRate) / (100 + $effectiveRate) : 0;
340 486 } else {
341 487 $shippingTax = ($shippingBase * $effectiveRate) / 100;
342 488 }
343 489
344 - $accumulatedShippingTax += ceil($shippingTax);
345 - $shippingTaxTotal += ceil($shippingTax);
490 + $accumulatedShippingTax += $this->roundTax($shippingTax);
491 + $shippingTaxTotal += $this->roundTax($shippingTax);
346 492 }
347 493 }
348 494
349 - return ceil($shippingTaxTotal);
495 + return $this->finalRound($shippingTaxTotal);
350 496 }
351 497
352 - protected function getRatesByProductId($productId)
498 + public function getShippingTaxByRates(): array
353 499 {
354 - $taxClasses = $this->getTaxClassByProductId($productId);
500 + $byRate = [];
355 501
502 + foreach ($this->formattedLineItems as $item) {
503 + $taxRates = Arr::get($item, 'line_meta.tax_config.rates', []);
504 + $totalShippingCharge = Arr::get($item, 'shipping_charge', 0) + Arr::get($item, 'itemwise_shipping_charge', 0);
505 +
506 + if (!$taxRates || !$totalShippingCharge) {
507 + continue;
508 + }
509 +
510 + $accumulatedShippingTax = 0;
511 +
512 + foreach ($taxRates as $taxMeta) {
513 + $rate = Arr::get($taxMeta, 'rate', 0);
514 + $forShipping = Arr::get($taxMeta, 'for_shipping', null);
515 + $isCompound = Arr::get($taxMeta, 'is_compound', false);
516 + $rateId = (int) Arr::get($taxMeta, 'rate_id', 0);
517 + $label = Arr::get($taxMeta, 'label', '');
518 +
519 + $effectiveRate = $forShipping !== null ? (float) $forShipping : (float) $rate;
520 +
521 + $shippingBase = $totalShippingCharge;
522 + if ($isCompound) {
523 + $shippingBase = $totalShippingCharge + $accumulatedShippingTax;
524 + }
525 +
526 + if ($this->inclusive) {
527 + $shippingTax = $effectiveRate > 0 ? ($shippingBase * $effectiveRate) / (100 + $effectiveRate) : 0;
528 + } else {
529 + $shippingTax = ($shippingBase * $effectiveRate) / 100;
530 + }
531 +
532 + $rounded = $this->roundTax($shippingTax);
533 + $accumulatedShippingTax += $rounded;
534 +
535 + if (!$rateId) {
536 + continue;
537 + }
538 +
539 + if (!isset($byRate[$rateId])) {
540 + $byRate[$rateId] = [
541 + 'rate_id' => $rateId,
542 + 'label' => $label,
543 + 'rate_percent' => $effectiveRate,
544 + 'shipping_tax' => 0,
545 + ];
546 + }
547 + $byRate[$rateId]['shipping_tax'] += $rounded;
548 + }
549 + }
550 +
551 + $result = [];
552 + foreach ($byRate as $line) {
553 + $line['shipping_tax'] = $this->finalRound($line['shipping_tax']);
554 + if ($line['shipping_tax'] > 0) {
555 + $result[] = $line;
556 + }
557 + }
558 + return $result;
559 + }
560 +
561 + protected function getTaxMapKey($lineItem)
562 + {
563 + return Arr::get($lineItem, 'post_id', 0) . ':' . (Arr::get($lineItem, 'object_id') ?: Arr::get($lineItem, 'variation_id', 0));
564 + }
565 +
566 + protected function getVariantInclusiveForLineItem($lineItem)
567 + {
568 + $explicitInclusive = Arr::get($lineItem, 'inclusive');
569 + if ($explicitInclusive !== null) {
570 + return (bool) $explicitInclusive;
571 + }
572 +
573 + $productId = Arr::get($lineItem, 'post_id');
574 + $variationId = Arr::get($lineItem, 'object_id') ?: Arr::get($lineItem, 'variation_id');
575 +
576 + if (!$variationId) {
577 + return null;
578 + }
579 +
580 + $product = Arr::get($this->products, $productId);
581 + if (!$product) {
582 + return null;
583 + }
584 +
585 + foreach ($product->variants ?? [] as $variant) {
586 + if ((string) $variant->id !== (string) $variationId) {
587 + continue;
588 + }
589 + $taxInclusion = Arr::get($variant->other_info ?: [], 'tax_inclusion');
590 + if ($taxInclusion === 'included') {
591 + return true;
592 + }
593 + if ($taxInclusion === 'excluded') {
594 + return false;
595 + }
596 + return null;
597 + }
598 +
599 + return null;
600 + }
601 +
602 + protected function getRatesByLineItem($lineItem)
603 + {
604 + $productId = Arr::get($lineItem, 'post_id');
605 + $termIds = $this->getTermsByProductId($productId);
606 + $taxClasses = $this->getTaxClassByLineItem($lineItem);
607 + $lineItemClassId = ($taxClasses && isset($taxClasses[0])) ? (int) $taxClasses[0]->id : 0;
608 + $productOverride = $this->getProductOverrideByTermIds($termIds, $lineItemClassId);
609 + $allValidRates = $this->getRatesByTaxClasses($taxClasses);
610 +
611 + if ($productOverride) {
612 + return $this->applyProductOverrideToRates($allValidRates, $productOverride);
613 + }
614 +
615 + return $allValidRates;
616 + }
617 +
618 + protected function getRatesByTaxClasses($taxClasses)
619 + {
356 620 if (!$taxClasses) {
357 621 return [];
358 622 }
359 623
624 + if (!TaxManager::getInstance()->isTaxEnabledForCountry($this->country)) {
625 + return [];
626 + }
627 +
360 628 // check EU country
361 629 $euCountryCodes = LocalizationManager::getInstance()->taxContinents('EU');
362 630 $euCountryCodes = Arr::get($euCountryCodes, 'countries');
363 631 $isEuCountry = in_array($this->country, $euCountryCodes);
@@ -371,11 +639,12 @@
371 639 if ($isEuCountry) {
372 640 $rates = $this->getEuTaxRates($taxClass->id, $taxClassSlug);
373 641 } else {
374 642 $rates = TaxRate::query()->where('class_id', $taxClass->id)
375 - ->orderBy('priority', 'asc')
376 - ->where('country', $this->country)
377 - ->get();
643 + ->orderBy('priority', 'asc')
644 + ->orderBy('id', 'asc')
645 + ->where('country', $this->country)
646 + ->get();
378 647 }
379 648
380 649 if ($rates->isEmpty()) {
381 650 continue;
@@ -380,8 +649,10 @@
380 649 if ($rates->isEmpty()) {
381 650 continue;
382 651 }
383 652
653 + $matchedRates = [];
654 +
384 655 // Validate rates for this tax class
385 656 foreach ($rates as $rate) {
386 657
387 658 if ($rate->state && $rate->state !== $this->state) {
@@ -387,43 +658,206 @@
387 658 if ($rate->state && $rate->state !== $this->state) {
388 659 continue;
389 660 }
390 661
391 -
662 +
392 663 if ($rate->city && $rate->city !== $this->city) {
393 664 continue;
394 665 }
395 666
396 - if ($rate->postcode) {
397 - $hasRange = strpos($rate->postcode, '...') !== false;
398 - $postcodes = array_map('trim', explode(',', $rate->postcode));
667 + if ($rate->postcode && !$this->matchesPostcode($rate->postcode, $this->postCode)) {
668 + continue;
669 + }
399 670
400 - if ($hasRange) {
401 - $rangedPostcodes = [];
402 - foreach ($postcodes as $postcode) {
403 - if (strpos($postcode, '...') !== false) {
404 - list($start, $end) = explode('...', $postcode);
405 - if($end > $start) {
406 - $rangedPostcodes = array_merge($rangedPostcodes, range($start, $end));
407 - }
408 - } else {
409 - $rangedPostcodes[] = $postcode;
410 - }
411 - }
671 + $matchedRates[] = $rate;
672 + }
412 673
413 - $postcodes = $rangedPostcodes;
674 + if (!$matchedRates) {
675 + continue;
676 + }
677 +
678 + // Three state-rate modes (set via admin compound dropdown):
679 + // for_order=1 → "instead of" : state replaces country rate
680 + // for_order=0, is_compound=0 → "added to" : both rates apply (additive)
681 + // for_order=0, is_compound=1 → "compounded on top of": compound stacks on country rate
682 + // Only drop country rates when every state rate is an "instead of" override (for_order=1).
683 + if ($this->state) {
684 + $stateSpecific = array_values(array_filter($matchedRates, function ($r) {
685 + return !empty($r->state);
686 + }));
687 + if ($stateSpecific) {
688 + $hasAdditiveStateRate = (bool) array_filter($stateSpecific, function ($r) {
689 + return (int) $r->for_order !== 1;
690 + });
691 + if ($hasAdditiveStateRate) {
692 + $countryRates = array_values(array_filter($matchedRates, function ($r) {
693 + return empty($r->state);
694 + }));
695 + $matchedRates = array_merge($countryRates, $stateSpecific);
696 + } else {
697 + $matchedRates = $stateSpecific;
414 698 }
699 + }
700 + }
415 701
416 - if (!in_array($this->postCode, $postcodes)) {
417 - continue;
702 + foreach ($this->resolveMatchedRates($matchedRates) as $matchedRate) {
703 + $allValidRates[] = $matchedRate;
704 + }
705 + }
706 +
707 + return $allValidRates;
708 + }
709 +
710 + protected function getProductOverrideByTermIds($termIds, $lineItemClassId = 0)
711 + {
712 + // Static cache: load all category tax overrides for every product in the cart
713 + // in one query (mirrors the bulk-load pattern in getTermsByProductId).
714 + // Note: static scope is per-request, not per-instance — safe because each
715 + // HTTP request processes a single cart/address combination.
716 + // Each term_id stores an array of overrides (multiple location variants allowed).
717 + if (self::$overridesCache === null) {
718 + self::$overridesCache = [];
719 +
720 + $allTermIds = [];
721 + foreach ($this->productIds as $pid) {
722 + $allTermIds = array_merge($allTermIds, $this->getTermsByProductId($pid));
723 + }
724 + $allTermIds = array_values(array_unique($allTermIds));
725 +
726 + if ($allTermIds && $this->country) {
727 + $overrides = Meta::query()
728 + ->productCategoryTaxOverrides()
729 + ->whereIn('object_id', $allTermIds)
730 + ->forTaxOverrideCountry($this->country)
731 + ->get();
732 +
733 + foreach ($overrides as $override) {
734 + self::$overridesCache[(int) $override->object_id][] = is_array($override->meta_value)
735 + ? $override->meta_value
736 + : [];
737 + }
738 + }
739 + }
740 +
741 + if (!$termIds || !$this->country) {
742 + return null;
743 + }
744 +
745 + $best = null;
746 + $bestScore = -1;
747 +
748 + foreach ($termIds as $termId) {
749 + if (!array_key_exists((int) $termId, self::$overridesCache)) {
750 + continue;
751 + }
752 +
753 + foreach (self::$overridesCache[(int) $termId] as $metaValue) {
754 + $score = $this->scoreOverrideMatch($metaValue, $lineItemClassId);
755 + if ($score === null) {
756 + continue;
757 + }
758 + if ($score > $bestScore) {
759 + $bestScore = $score;
760 + $best = $metaValue;
761 + }
762 + }
763 + }
764 +
765 + return $best;
766 + }
767 +
768 + private function scoreOverrideMatch($metaValue, $lineItemClassId = 0)
769 + {
770 + $overrideState = Arr::get($metaValue, 'state', '');
771 + $overrideCity = Arr::get($metaValue, 'city', '');
772 + $overridePostcode = Arr::get($metaValue, 'postcode', '');
773 + $overrideClassId = (int) Arr::get($metaValue, 'class_id', 0);
774 +
775 + if ($overrideState && $overrideState !== $this->state) { return null; }
776 + if ($overrideCity && $overrideCity !== $this->city) { return null; }
777 + if ($overridePostcode && !$this->matchesPostcode($overridePostcode, $this->postCode)) { return null; }
778 +
779 + // Discard overrides targeting a specific class that doesn't match the line item
780 + if ($overrideClassId !== 0 && $overrideClassId !== (int) $lineItemClassId) {
781 + return null;
782 + }
783 +
784 + $locationScore = (int) (bool) $overrideState
785 + + (int) (bool) $overrideCity
786 + + (int) (bool) $overridePostcode;
787 +
788 + $classScore = ($overrideClassId !== 0 && $overrideClassId === (int) $lineItemClassId) ? 1 : 0;
789 +
790 + // Location is primary sort key (0–3); class is tiebreaker (0–1).
791 + // Encoding: locationScore * 2 + classScore preserves the ordering.
792 + return $locationScore * 2 + $classScore;
793 + }
794 +
795 + private function matchesPostcode($rule, $customer)
796 + {
797 + $postcodes = array_map('trim', explode(',', $rule));
798 + foreach ($postcodes as $pc) {
799 + if (strpos($pc, '-') !== false) {
800 + list($start, $end) = explode('-', $pc, 2);
801 + $start = trim($start);
802 + $end = trim($end);
803 + if (is_numeric($start) && is_numeric($end) && is_numeric($customer)) {
804 + $customerInt = (int) $customer;
805 + if ($customerInt >= (int) $start && $customerInt <= (int) $end) {
806 + return true;
418 807 }
808 + continue;
419 809 }
810 + }
811 + if ($customer === $pc) {
812 + return true;
813 + }
814 + }
815 + return false;
816 + }
420 817
421 - $allValidRates[] = $rate;
818 + protected function applyProductOverrideToRates($resolvedRates, $productOverride)
819 + {
820 + $overrideRate = (float) Arr::get($productOverride, 'rate', 0);
821 + $overrideLabel = Arr::get($productOverride, 'tax_label', '');
822 + $overrideStateTax = Arr::get($productOverride, 'override_state_tax', 'no') === 'yes';
823 +
824 + $countryRates = [];
825 + $stateRates = [];
826 +
827 + foreach ($resolvedRates as $rate) {
828 + if (!empty($rate->state)) {
829 + $stateRates[] = $rate;
830 + continue;
422 831 }
832 +
833 + $countryRates[] = $rate;
423 834 }
424 835
425 - return $allValidRates;
836 + $baseRate = Arr::first($countryRates) ?: Arr::first($stateRates);
837 + $overrideTaxRate = new TaxRate([
838 + 'class_id' => $baseRate ? $baseRate->class_id : 0,
839 + 'country' => $this->country,
840 + 'state' => '',
841 + 'city' => '',
842 + 'postcode' => '',
843 + 'name' => $overrideLabel ?: ($baseRate ? $baseRate->name : __('Tax', 'fluent-cart')),
844 + 'rate' => $overrideRate,
845 + 'group' => $baseRate ? $baseRate->group : '',
846 + 'priority' => $baseRate ? $baseRate->priority : 0,
847 + 'is_compound' => 0,
848 + 'for_shipping' => $baseRate ? $baseRate->for_shipping : null,
849 + 'for_order' => 0,
850 + ]);
851 + $overrideTaxRate->id = $baseRate ? $baseRate->id : null;
852 +
853 + if ($overrideStateTax) {
854 + return [$overrideTaxRate];
855 + }
856 +
857 + array_unshift($stateRates, $overrideTaxRate);
858 +
859 + return $stateRates;
426 860 }
427 861
428 862 protected function getEuTaxRates($taxClassId, $taxClassSlug)
429 863 {
@@ -428,20 +862,17 @@
428 862 protected function getEuTaxRates($taxClassId, $taxClassSlug)
429 863 {
430 864 $euVatSettings = Arr::get($this->taxSettings, 'eu_vat_settings', []);
431 865 $vatCollectionMethod = Arr::get($euVatSettings, 'method', '');
432 - $taxManager = TaxManager::getInstance();
433 866
434 - if ($vatCollectionMethod === 'oss' || $vatCollectionMethod === 'home') {
435 - if ($vatCollectionMethod === 'home') {
436 - $this->country = Arr::get($euVatSettings, 'home_country', '');
437 - }
438 -
867 + if ($vatCollectionMethod === 'oss') {
868 + $taxManager = TaxManager::getInstance();
439 869 $rates = TaxRate::query()->where('class_id', $taxClassId)
440 870 ->orderBy('priority', 'asc')
871 + ->orderBy('id', 'asc')
441 872 ->where('country', $this->country)
442 873 ->get();
443 -
874 +
444 875 if ($rates->isEmpty()) {
445 876 $rates = $taxManager->getEuTaxRatesFromPhp($this->country, $taxClassSlug);
446 877 return Collection::make($rates)->map(function ($rate) {
447 878 $rate['country'] = $this->country;
@@ -448,89 +879,277 @@
448 879 return new TaxRate($rate);
449 880 });
450 881 }
451 882 return $rates;
452 - } else if ($vatCollectionMethod === 'specific') {
453 - return TaxRate::query()->where('class_id', $taxClassId)
454 - ->orderBy('priority', 'asc')
455 - ->where('country', $this->country)
456 - ->get();
457 - } else {
458 - return Collection::make([]);
459 883 }
884 +
885 + $effectiveCountry = $this->country;
886 + if ($vatCollectionMethod === 'home') {
887 + $effectiveCountry = Arr::get($euVatSettings, 'home_country', '');
888 + }
889 +
890 + if ($vatCollectionMethod === 'home' || $vatCollectionMethod === 'specific') {
891 + return $this->getRatesFromRegistrations($euVatSettings, $taxClassId, $taxClassSlug, $effectiveCountry);
892 + }
893 +
894 + return Collection::make([]);
460 895 }
461 896
462 - protected function getTaxClassByProductId($productId)
897 + protected function getRatesFromRegistrations($euVatSettings, $taxClassId, $taxClassSlug, $country)
463 898 {
464 - $product = Arr::get($this->products, $productId);
465 - if (!$product) {
466 - return [];
899 + $regForCountry = TaxManager::getInstance()->getEuVatRegistration($country);
900 +
901 + if (!$regForCountry) {
902 + return Collection::make([]);
467 903 }
468 904
469 - $taxClasId = Arr::get($product->detail->other_info, 'tax_class', '');
905 + $rates = (array) Arr::get($regForCountry, 'rates', []);
906 + $rateData = Arr::get($rates, $taxClassSlug);
470 907
471 - if ($taxClasId) {
472 - $class = TaxClass::query()->find($taxClasId);
473 - if ($class) {
474 - return [$class];
908 + // Legacy registrations stored a single rate at the top level rather than per-class.
909 + if (!$rateData && $taxClassSlug === 'standard') {
910 + $legacyRate = floatval(Arr::get($regForCountry, 'rate', 0));
911 + if ($legacyRate > 0) {
912 + $rateData = ['rate' => $legacyRate, 'label' => Arr::get($regForCountry, 'tax_label', '')];
475 913 }
476 914 }
477 915
478 - // let's get the tax class from the product category
479 - return $this->getTaxClassByTermIds($this->getTermsByProductId($productId));
916 + if (!$rateData || floatval(Arr::get($rateData, 'rate', 0)) <= 0) {
917 + return Collection::make([]);
918 + }
919 +
920 + $taxRate = new TaxRate([
921 + 'country' => $country,
922 + 'state' => '',
923 + 'city' => '',
924 + 'postcode' => '',
925 + 'rate' => floatval($rateData['rate']),
926 + 'name' => sanitize_text_field($rateData['label'] ?? '') ?: ($country . ' Tax'),
927 + 'group' => 'EU',
928 + 'class_id' => $taxClassId,
929 + 'priority' => 1,
930 + 'is_compound' => 0,
931 + 'for_order' => 0,
932 + 'for_shipping' => null,
933 + ]);
934 + // Use a negative class-scoped id so each class has a unique virtual identity
935 + // and never collides with the zero-tax sentinel (tax_rate_id=0) in order persistence.
936 + $taxRate->id = -$taxClassId;
937 +
938 + return Collection::make([$taxRate]);
480 939 }
481 940
482 - protected function getTaxClassByTermIds($termIds)
941 + protected function getTaxClassByLineItem($lineItem)
483 942 {
484 - if (!$termIds) {
943 + $productId = Arr::get($lineItem, 'post_id');
944 + $product = Arr::get($this->products, $productId);
945 + if (!$product) {
485 946 return [];
486 947 }
487 948
488 - $taxClasses = null;
949 + $variationId = Arr::get($lineItem, 'object_id') ?: Arr::get($lineItem, 'variation_id');
950 + $variants = $product->variants ?? [];
489 951
490 - $formattedTaxClasses = [];
952 + if ($variationId && $variants) {
953 + foreach ($variants as $productVariant) {
954 + if ((string) $productVariant->id !== (string) $variationId) {
955 + continue;
956 + }
491 957
492 - if ($taxClasses === null) {
493 - $taxClasses = TaxClass::query()->whereNotNull('meta')->get();
958 + $variantOtherInfo = $productVariant->other_info ?: [];
494 959
495 - foreach ($taxClasses as $taxClass) {
496 - $categories = Arr::get($taxClass->meta, 'categories', []);
497 - if (!$categories || !array_intersect($termIds, $categories)) {
498 - continue;
960 + if (Arr::get($variantOtherInfo, 'tax_exempt') === 'yes') {
961 + return [];
499 962 }
500 - $priority = Arr::get($taxClass->meta, 'priority', 0);
501 - $formattedTaxClasses[$priority] = $taxClass;
963 +
964 + $variantTaxClassSlug = Arr::get($variantOtherInfo, 'tax_class');
965 + if ($variantTaxClassSlug) {
966 + $class = $this->getTaxClassBySlug(sanitize_text_field($variantTaxClassSlug));
967 + if ($class) {
968 + return [$class];
969 + }
970 + }
971 +
972 + break;
502 973 }
503 974 }
504 975
505 - if (!$formattedTaxClasses) {
506 - return [];
507 - }
976 + $standardClass = $this->getStandardTaxClass();
508 977
509 - // return all tax classes sorted by priority (highest first)
510 - krsort($formattedTaxClasses);
511 - return array_values($formattedTaxClasses);
978 + return $standardClass ? [$standardClass] : [];
512 979 }
513 980
514 981 protected function getTermsByProductId($productId)
515 982 {
516 - static $formattedTerms = null;
517 -
518 - if ($formattedTerms === null) {
983 + if (self::$termsCache === null) {
519 984 $terms = App::make('db')->table('term_relationships')
520 985 ->whereIn('object_id', $this->productIds)
521 986 ->get();
522 987
523 - $formattedTerms = [];
988 + self::$termsCache = [];
524 989
525 990 foreach ($terms as $term) {
526 - if (!isset($formattedTerms[$term->object_id])) {
527 - $formattedTerms[$term->object_id] = [];
991 + if (!isset(self::$termsCache[$term->object_id])) {
992 + self::$termsCache[$term->object_id] = [];
528 993 }
529 - $formattedTerms[$term->object_id][] = $term->term_taxonomy_id;
994 + self::$termsCache[$term->object_id][] = $term->term_taxonomy_id;
530 995 }
531 996 }
532 997
533 - return Arr::get($formattedTerms, $productId, []);
998 + return Arr::get(self::$termsCache, $productId, []);
999 + }
1000 +
1001 + protected function getStandardTaxClass()
1002 + {
1003 + return $this->getTaxClassBySlug('standard');
1004 + }
1005 +
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;
1018 + }
1019 +
1020 + return self::$taxClassCache[$slug];
1021 + }
1022 +
1023 + private function roundTax($amount)
1024 + {
1025 + if ($this->roundingMode === 'item') {
1026 + return (int) round($amount, 0, PHP_ROUND_HALF_UP);
1027 + }
1028 + // subtotal and total modes defer rounding — caller accumulates raw float
1029 + return $amount;
1030 + }
1031 +
1032 + private function finalRound($amount)
1033 + {
1034 + return (int) round($amount, 0, PHP_ROUND_HALF_UP);
1035 + }
1036 +
1037 + protected function resolveMatchedRates($matchedRates)
1038 + {
1039 + if (count($matchedRates) < 2) {
1040 + return $matchedRates;
1041 + }
1042 +
1043 + usort($matchedRates, function ($leftRate, $rightRate) {
1044 + $priorityCompare = $this->compareRatePriority($leftRate, $rightRate);
1045 + if ($priorityCompare !== 0) {
1046 + return $priorityCompare;
1047 + }
1048 +
1049 + $compoundCompare = $this->compareRateCompoundMode($leftRate, $rightRate);
1050 + if ($compoundCompare !== 0) {
1051 + return $compoundCompare;
1052 + }
1053 +
1054 + $specificityCompare = $this->compareRateSpecificity($leftRate, $rightRate);
1055 + if ($specificityCompare !== 0) {
1056 + return $specificityCompare;
1057 + }
1058 +
1059 + return $this->compareRateId($leftRate, $rightRate);
1060 + });
1061 +
1062 + $resolvedRates = [];
1063 + foreach ($matchedRates as $matchedRate) {
1064 + if ($this->shouldReplaceBroaderRates($matchedRate)) {
1065 + $resolvedRates = array_values(array_filter($resolvedRates, function ($resolvedRate) use ($matchedRate) {
1066 + return !$this->isBroaderMatchingRate($resolvedRate, $matchedRate);
1067 + }));
1068 + }
1069 +
1070 + $resolvedRates[] = $matchedRate;
1071 + }
1072 +
1073 + return $resolvedRates;
1074 + }
1075 +
1076 + protected function shouldReplaceBroaderRates($rate)
1077 + {
1078 + return (int) $rate->for_order === 1 && $this->getRateSpecificity($rate) > 0;
1079 + }
1080 +
1081 + protected function isBroaderMatchingRate($baseRate, $replacementRate)
1082 + {
1083 + if ((int) $baseRate->class_id !== (int) $replacementRate->class_id) {
1084 + return false;
1085 + }
1086 +
1087 + if ((int) $baseRate->id === (int) $replacementRate->id) {
1088 + return false;
1089 + }
1090 +
1091 + if ($this->getRateSpecificity($baseRate) >= $this->getRateSpecificity($replacementRate)) {
1092 + return false;
1093 + }
1094 +
1095 + foreach (['state', 'city', 'postcode'] as $field) {
1096 + $baseValue = (string) $baseRate->{$field};
1097 + $replacementValue = (string) $replacementRate->{$field};
1098 +
1099 + if ($baseValue !== '' && $baseValue !== $replacementValue) {
1100 + return false;
1101 + }
1102 + }
1103 +
1104 + return true;
1105 + }
1106 +
1107 + protected function compareRatePriority($leftRate, $rightRate)
1108 + {
1109 + return $this->normalizeRatePriority($leftRate) <=> $this->normalizeRatePriority($rightRate);
1110 + }
1111 +
1112 + protected function compareRateCompoundMode($leftRate, $rightRate)
1113 + {
1114 + return $this->normalizeRateCompoundFlag($leftRate) <=> $this->normalizeRateCompoundFlag($rightRate);
1115 + }
1116 +
1117 + protected function compareRateSpecificity($leftRate, $rightRate)
1118 + {
1119 + return $this->getRateSpecificity($leftRate) <=> $this->getRateSpecificity($rightRate);
1120 + }
1121 +
1122 + protected function compareRateId($leftRate, $rightRate)
1123 + {
1124 + return $this->normalizeRateId($leftRate) <=> $this->normalizeRateId($rightRate);
1125 + }
1126 +
1127 + protected function normalizeRatePriority($rate)
1128 + {
1129 + return (int) $rate->priority;
1130 + }
1131 +
1132 + protected function normalizeRateCompoundFlag($rate)
1133 + {
1134 + return (int) $rate->is_compound;
1135 + }
1136 +
1137 + protected function normalizeRateId($rate)
1138 + {
1139 + return (int) $rate->id;
1140 + }
1141 +
1142 + protected function getRateSpecificity($rate)
1143 + {
1144 + $specificity = 0;
1145 +
1146 + foreach (['state', 'city', 'postcode'] as $field) {
1147 + if ((string) $rate->{$field} !== '') {
1148 + $specificity++;
1149 + }
1150 + }
1151 +
1152 + return $specificity;
534 1153 }
535 1154
536 1155 }