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.6 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 All 49 releases
← All changes | app/Modules/Tax/TaxCalculator.php +716 -124 1.3.28 → 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,8 +65,9 @@
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
48 71 // Map territory country codes (GP→FR+state=GP) before rate lookup.
49 72 $resolved = TaxManager::getInstance()->resolveTaxCountryAndState(
50 73 (string) $this->country,
@@ -68,9 +91,9 @@
68 91 $this->productIds = array_values(array_unique(array_filter(array_column($lineItems, 'post_id'))));
69 92
70 93 if ($this->productIds) {
71 94 $this->products = \FluentCart\App\Models\Product::query()->whereIn('id', $this->productIds)
72 - ->with(['detail'])
95 + ->with(['detail', 'variants'])
73 96 ->get()
74 97 ->keyBy('ID');
75 98 }
76 99 $this->setupMaps();
@@ -77,38 +100,76 @@
77 100 }
78 101
79 102 }
80 103
81 - public function getTaxBahaviorValue()
104 + public function getTaxBehaviorValue()
82 105 {
83 - if ($this->inclusive) {
84 - 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');
85 116 }
86 117
87 - 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;
88 129 }
89 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 +
90 144 public function setupMaps()
91 145 {
92 - foreach ($this->productIds as $productId) {
93 - // we have to check if the product has specific tax rate assigned!
94 - $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 + }
95 157 }
96 158
97 159 $formattedLineItems = [];
98 160 foreach ($this->lineItems as $lineItem) {
99 161 $isFee = !empty($lineItem['is_fee']);
100 - $productId = Arr::get($lineItem, 'post_id');
101 162
102 163 // Fee items (post_id = 0) use the first product's tax rates
103 164 if ($isFee) {
104 165 $isTaxable = Arr::get($lineItem, 'other_info.taxable', false);
105 - $firstProductId = Arr::first(array_keys($this->taxMaps));
106 - $rates = ($isTaxable && $firstProductId !== null)
107 - ? Arr::get($this->taxMaps, $firstProductId, [])
166 + $firstTaxMapKey = Arr::first(array_keys($this->taxMaps));
167 + $rates = ($isTaxable && $firstTaxMapKey !== null)
168 + ? Arr::get($this->taxMaps, $firstTaxMapKey, [])
108 169 : [];
109 170 } else {
110 - $rates = Arr::get($this->taxMaps, $productId, []);
171 + $rates = Arr::get($this->taxMaps, $this->getTaxMapKey($lineItem), []);
111 172 }
112 173 $taxLines = [];
113 174 $signupFeeTaxLines = [];
114 175 $lineTaxTotal = 0;
@@ -117,9 +178,9 @@
117 178 $signupFee = 0;
118 179 $recurringAmount = 0;
119 180 $isSubscription = Arr::get($lineItem, 'other_info.payment_type') === 'subscription';
120 181
121 - $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));
122 183
123 184
124 185 if ($isSubscription) {
125 186 $signupFee = Arr::get($lineItem, 'other_info.signup_fee', 0);
@@ -135,8 +196,13 @@
135 196 }
136 197 }
137 198
138 199
200 + $lineInclusive = $this->getVariantInclusiveForLineItem($lineItem);
201 + if ($lineInclusive === null) {
202 + $lineInclusive = $this->inclusive;
203 + }
204 +
139 205 if ($rates) {
140 206 foreach ($rates as $rate) {
141 207 $rateSignupFeeTax = 0;
142 208 $rateRecurringTax = 0;
@@ -141,16 +207,16 @@
141 207 $rateSignupFeeTax = 0;
142 208 $rateRecurringTax = 0;
143 209 // Access is_compound as object property
144 210 $isCompound = $rate->is_compound;
145 -
211 +
146 212 // For compound rates, calculate on subtotal + accumulated taxes
147 213 $currentTaxableAmount = $taxableAmount;
148 214 $currentRecurringAmount = $recurringAmount;
149 215 $currentSignupFee = $signupFee;
150 -
216 +
151 217 if ($isCompound) {
152 -
218 +
153 219 $currentTaxableAmount = $taxableAmount + $lineTaxTotal;
154 220 if ($recurringAmount) {
155 221 $currentRecurringAmount = $recurringAmount + $recurringTax;
156 222 }
@@ -157,10 +223,10 @@
157 223 if ($signupFee) {
158 224 $currentSignupFee = $signupFee + $signupFeeTax;
159 225 }
160 226 }
161 -
162 - if ($this->inclusive) {
227 +
228 + if ($lineInclusive) {
163 229 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / (100 + $rate->rate);
164 230 if ($recurringAmount) {
165 231 $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / (100 + $rate->rate);
166 232 $recurringTax += $rateRecurringTax;
@@ -168,14 +234,14 @@
168 234 if ($signupFee) {
169 235 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / (100 + $rate->rate);
170 236 }
171 237 } else {
172 -
173 238
239 +
174 240 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / 100;
175 241 if ($recurringAmount) {
176 - $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / 100;
177 - $recurringTax += $rateRecurringTax;
242 + $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / 100;
243 + $recurringTax += $rateRecurringTax;
178 244 }
179 245 if ($signupFee) {
180 246 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / 100;
181 247 }
@@ -183,16 +249,16 @@
183 249
184 250 $taxLines[] = [
185 251 'rate_id' => $rate->id,
186 252 'label' => $rate->name,
187 - 'tax_amount' => ceil($taxAmount),
188 - 'recurring_tax' => ceil($rateRecurringTax),
253 + 'tax_amount' => $this->roundTax($taxAmount),
254 + 'recurring_tax' => $this->roundTax($rateRecurringTax),
189 255 'rate' => $rate->rate,
190 256 'rate_percent' => $rate->rate,
191 257 'for_shipping' => $rate->for_shipping,
192 258 'country' => $rate->country,
193 259 'is_compound' => $isCompound,
194 - 'taxable_amount' => ceil($currentTaxableAmount),
260 + 'taxable_amount' => $this->roundTax($currentTaxableAmount),
195 261 ];
196 262
197 263 if ($rateSignupFeeTax) {
198 264 $signupFeeTaxLines[] = [
@@ -197,15 +263,15 @@
197 263 if ($rateSignupFeeTax) {
198 264 $signupFeeTaxLines[] = [
199 265 'rate_id' => $rate->id,
200 266 'label' => $rate->name,
201 - 'tax_amount' => ceil($rateSignupFeeTax),
267 + 'tax_amount' => $this->roundTax($rateSignupFeeTax),
202 268 'rate' => $rate->rate,
203 269 'rate_percent' => $rate->rate,
204 270 'for_shipping' => $rate->for_shipping,
205 271 'country' => $rate->country,
206 272 'is_compound' => $isCompound,
207 - 'taxable_amount' => ceil($currentSignupFee),
273 + 'taxable_amount' => $this->roundTax($currentSignupFee),
208 274 ];
209 275
210 276 $signupFeeTax += $rateSignupFeeTax;
211 277 }
@@ -219,20 +285,23 @@
219 285 $lineItem['line_meta'] = [];
220 286 }
221 287
222 288 $lineItem['line_meta']['tax_config'] = [
223 - 'inclusive' => $this->inclusive,
289 + 'inclusive' => $lineInclusive,
224 290 'rates' => $taxLines,
225 291 ];
226 292
227 293 if ($isSubscription) {
228 - Arr::set($lineItem, 'other_info.recurring_tax', ceil($recurringTax));
294 + Arr::set($lineItem, 'other_info.recurring_tax', $this->roundTax($recurringTax));
229 295 if ($signupFeeTax) {
230 - Arr::set($lineItem, 'other_info.signup_fee_tax', ceil($signupFeeTax));
296 + Arr::set($lineItem, 'other_info.signup_fee_tax', $this->roundTax($signupFeeTax));
231 297 $lineItem['signup_fee_tax_config'] = [
232 - 'inclusive' => $this->inclusive,
298 + 'inclusive' => $lineInclusive,
233 299 'rates' => $signupFeeTaxLines,
234 300 ];
301 + } else {
302 + unset($lineItem['other_info']['signup_fee_tax']);
303 + unset($lineItem['signup_fee_tax_config']);
235 304 }
236 305
237 306 } else {
238 307 unset($lineItem['other_info']['signup_fee_tax']);
@@ -238,9 +307,9 @@
238 307 unset($lineItem['other_info']['signup_fee_tax']);
239 308 unset($lineItem['signup_fee_tax_lines']);
240 309 }
241 310
242 - $lineItem['tax_amount'] = ceil($lineTaxTotal);
311 + $lineItem['tax_amount'] = $this->roundTax($lineTaxTotal);
243 312
244 313 $formattedLineItems[] = $lineItem;
245 314 }
246 315
@@ -262,28 +331,65 @@
262 331 foreach ($lineItems as $lineItem) {
263 332 $lineMeta = Arr::get($lineItem, 'line_meta', []);
264 333 $taxConfig = Arr::get($lineMeta, 'tax_config', []);
265 334 $rates = Arr::get($taxConfig, 'rates', []);
335 + $lineInclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
266 336 if ($rates) {
267 337 foreach ($rates as $rate) {
268 338 $rateId = Arr::get($rate, 'rate_id');
269 339 if (!isset($taxLines[$rateId])) {
270 340 $taxLines[$rateId] = [
271 - 'rate_id' => $rateId,
272 - 'label' => Arr::get($rate, 'label'),
273 - '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,
274 349 ];
350 + } elseif ($taxLines[$rateId]['inclusive'] !== $lineInclusive) {
351 + $taxLines[$rateId]['inclusive'] = null;
352 + $taxLines[$rateId]['is_mixed_inclusive'] = true;
275 353 }
276 354 $taxLines[$rateId]['tax_amount'] += Arr::get($rate, 'tax_amount', 0);
355 + $taxLines[$rateId]['taxable_amount'] += Arr::get($rate, 'taxable_amount', 0);
277 356 }
278 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 + }
279 369 }
280 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 +
281 378 return array_values($taxLines);
282 379 }
283 380
284 381 public function getTotalTax()
285 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 +
286 392 $taxTotal = 0;
287 393 foreach ($this->formattedLineItems as $lineItem) {
288 394 $mainTaxAmount = Arr::get($lineItem, 'tax_amount', 0);
289 395 $taxTotal += $mainTaxAmount;
@@ -295,11 +401,43 @@
295 401 }
296 402 }
297 403 }
298 404
299 - return ceil($taxTotal);
405 + return $this->finalRound($taxTotal);
300 406 }
301 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 +
302 440 public function getRecurringTax()
303 441 {
304 442 $recurringTaxTotal = 0;
305 443 foreach ($this->formattedLineItems as $lineItem) {
@@ -306,9 +444,9 @@
306 444 $recurringTax = Arr::get($lineItem, 'other_info.recurring_tax', 0);
307 445 $recurringTaxTotal += $recurringTax;
308 446 }
309 447
310 - return ceil($recurringTaxTotal);
448 + return $this->finalRound($recurringTaxTotal);
311 449 }
312 450
313 451 public function getTaxCountry()
314 452 {
@@ -343,29 +481,151 @@
343 481 $shippingBase = $totalShippingCharge + $accumulatedShippingTax;
344 482 }
345 483
346 484 if ($this->inclusive) {
347 - $shippingTax = ($shippingBase * $effectiveRate) / (100 + $effectiveRate);
485 + $shippingTax = $effectiveRate > 0 ? ($shippingBase * $effectiveRate) / (100 + $effectiveRate) : 0;
348 486 } else {
349 487 $shippingTax = ($shippingBase * $effectiveRate) / 100;
350 488 }
351 489
352 - $accumulatedShippingTax += ceil($shippingTax);
353 - $shippingTaxTotal += ceil($shippingTax);
490 + $accumulatedShippingTax += $this->roundTax($shippingTax);
491 + $shippingTaxTotal += $this->roundTax($shippingTax);
354 492 }
355 493 }
356 494
357 - return ceil($shippingTaxTotal);
495 + return $this->finalRound($shippingTaxTotal);
358 496 }
359 497
360 - protected function getRatesByProductId($productId)
498 + public function getShippingTaxByRates(): array
361 499 {
362 - $taxClasses = $this->getTaxClassByProductId($productId);
500 + $byRate = [];
363 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 + {
364 620 if (!$taxClasses) {
365 621 return [];
366 622 }
367 623
624 + if (!TaxManager::getInstance()->isTaxEnabledForCountry($this->country)) {
625 + return [];
626 + }
627 +
368 628 // check EU country
369 629 $euCountryCodes = LocalizationManager::getInstance()->taxContinents('EU');
370 630 $euCountryCodes = Arr::get($euCountryCodes, 'countries');
371 631 $isEuCountry = in_array($this->country, $euCountryCodes);
@@ -379,11 +639,12 @@
379 639 if ($isEuCountry) {
380 640 $rates = $this->getEuTaxRates($taxClass->id, $taxClassSlug);
381 641 } else {
382 642 $rates = TaxRate::query()->where('class_id', $taxClass->id)
383 - ->orderBy('priority', 'asc')
384 - ->where('country', $this->country)
385 - ->get();
643 + ->orderBy('priority', 'asc')
644 + ->orderBy('id', 'asc')
645 + ->where('country', $this->country)
646 + ->get();
386 647 }
387 648
388 649 if ($rates->isEmpty()) {
389 650 continue;
@@ -388,10 +649,11 @@
388 649 if ($rates->isEmpty()) {
389 650 continue;
390 651 }
391 652
653 + $matchedRates = [];
654 +
392 655 // Validate rates for this tax class
393 - $matchedRates = [];
394 656 foreach ($rates as $rate) {
395 657
396 658 if ($rate->state && $rate->state !== $this->state) {
397 659 continue;
@@ -401,31 +663,10 @@
401 663 if ($rate->city && $rate->city !== $this->city) {
402 664 continue;
403 665 }
404 666
405 - if ($rate->postcode) {
406 - $hasRange = strpos($rate->postcode, '...') !== false;
407 - $postcodes = array_map('trim', explode(',', $rate->postcode));
408 -
409 - if ($hasRange) {
410 - $rangedPostcodes = [];
411 - foreach ($postcodes as $postcode) {
412 - if (strpos($postcode, '...') !== false) {
413 - list($start, $end) = explode('...', $postcode);
414 - if($end > $start) {
415 - $rangedPostcodes = array_merge($rangedPostcodes, range($start, $end));
416 - }
417 - } else {
418 - $rangedPostcodes[] = $postcode;
419 - }
420 - }
421 -
422 - $postcodes = $rangedPostcodes;
423 - }
424 -
425 - if (!in_array($this->postCode, $postcodes)) {
426 - continue;
427 - }
667 + if ($rate->postcode && !$this->matchesPostcode($rate->postcode, $this->postCode)) {
668 + continue;
428 669 }
429 670
430 671 $matchedRates[] = $rate;
431 672 }
@@ -433,19 +674,33 @@
433 674 if (!$matchedRates) {
434 675 continue;
435 676 }
436 677
437 - // State-specific rates override country-level ones (e.g. GP 8.5% over FR 20%).
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).
438 683 if ($this->state) {
439 684 $stateSpecific = array_values(array_filter($matchedRates, function ($r) {
440 685 return !empty($r->state);
441 686 }));
442 687 if ($stateSpecific) {
443 - $matchedRates = $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;
698 + }
444 699 }
445 700 }
446 701
447 - foreach ($matchedRates as $matchedRate) {
702 + foreach ($this->resolveMatchedRates($matchedRates) as $matchedRate) {
448 703 $allValidRates[] = $matchedRate;
449 704 }
450 705 }
451 706
@@ -451,24 +706,173 @@
451 706
452 707 return $allValidRates;
453 708 }
454 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;
807 + }
808 + continue;
809 + }
810 + }
811 + if ($customer === $pc) {
812 + return true;
813 + }
814 + }
815 + return false;
816 + }
817 +
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;
831 + }
832 +
833 + $countryRates[] = $rate;
834 + }
835 +
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;
860 + }
861 +
455 862 protected function getEuTaxRates($taxClassId, $taxClassSlug)
456 863 {
457 864 $euVatSettings = Arr::get($this->taxSettings, 'eu_vat_settings', []);
458 865 $vatCollectionMethod = Arr::get($euVatSettings, 'method', '');
459 - $taxManager = TaxManager::getInstance();
460 866
461 - if ($vatCollectionMethod === 'oss' || $vatCollectionMethod === 'home') {
462 - if ($vatCollectionMethod === 'home') {
463 - $this->country = Arr::get($euVatSettings, 'home_country', '');
464 - }
465 -
867 + if ($vatCollectionMethod === 'oss') {
868 + $taxManager = TaxManager::getInstance();
466 869 $rates = TaxRate::query()->where('class_id', $taxClassId)
467 870 ->orderBy('priority', 'asc')
871 + ->orderBy('id', 'asc')
468 872 ->where('country', $this->country)
469 873 ->get();
470 -
874 +
471 875 if ($rates->isEmpty()) {
472 876 $rates = $taxManager->getEuTaxRatesFromPhp($this->country, $taxClassSlug);
473 877 return Collection::make($rates)->map(function ($rate) {
474 878 $rate['country'] = $this->country;
@@ -475,89 +879,277 @@
475 879 return new TaxRate($rate);
476 880 });
477 881 }
478 882 return $rates;
479 - } else if ($vatCollectionMethod === 'specific') {
480 - return TaxRate::query()->where('class_id', $taxClassId)
481 - ->orderBy('priority', 'asc')
482 - ->where('country', $this->country)
483 - ->get();
484 - } else {
485 - return Collection::make([]);
486 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([]);
487 895 }
488 896
489 - protected function getTaxClassByProductId($productId)
897 + protected function getRatesFromRegistrations($euVatSettings, $taxClassId, $taxClassSlug, $country)
490 898 {
491 - $product = Arr::get($this->products, $productId);
492 - if (!$product) {
493 - return [];
899 + $regForCountry = TaxManager::getInstance()->getEuVatRegistration($country);
900 +
901 + if (!$regForCountry) {
902 + return Collection::make([]);
494 903 }
495 904
496 - $taxClasId = Arr::get($product->detail->other_info, 'tax_class', '');
905 + $rates = (array) Arr::get($regForCountry, 'rates', []);
906 + $rateData = Arr::get($rates, $taxClassSlug);
497 907
498 - if ($taxClasId) {
499 - $class = TaxClass::query()->find($taxClasId);
500 - if ($class) {
501 - 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', '')];
502 913 }
503 914 }
504 915
505 - // let's get the tax class from the product category
506 - 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]);
507 939 }
508 940
509 - protected function getTaxClassByTermIds($termIds)
941 + protected function getTaxClassByLineItem($lineItem)
510 942 {
511 - if (!$termIds) {
943 + $productId = Arr::get($lineItem, 'post_id');
944 + $product = Arr::get($this->products, $productId);
945 + if (!$product) {
512 946 return [];
513 947 }
514 948
515 - $taxClasses = null;
949 + $variationId = Arr::get($lineItem, 'object_id') ?: Arr::get($lineItem, 'variation_id');
950 + $variants = $product->variants ?? [];
516 951
517 - $formattedTaxClasses = [];
952 + if ($variationId && $variants) {
953 + foreach ($variants as $productVariant) {
954 + if ((string) $productVariant->id !== (string) $variationId) {
955 + continue;
956 + }
518 957
519 - if ($taxClasses === null) {
520 - $taxClasses = TaxClass::query()->whereNotNull('meta')->get();
958 + $variantOtherInfo = $productVariant->other_info ?: [];
521 959
522 - foreach ($taxClasses as $taxClass) {
523 - $categories = Arr::get($taxClass->meta, 'categories', []);
524 - if (!$categories || !array_intersect($termIds, $categories)) {
525 - continue;
960 + if (Arr::get($variantOtherInfo, 'tax_exempt') === 'yes') {
961 + return [];
526 962 }
527 - $priority = Arr::get($taxClass->meta, 'priority', 0);
528 - $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;
529 973 }
530 974 }
531 975
532 - if (!$formattedTaxClasses) {
533 - return [];
534 - }
976 + $standardClass = $this->getStandardTaxClass();
535 977
536 - // return all tax classes sorted by priority (highest first)
537 - krsort($formattedTaxClasses);
538 - return array_values($formattedTaxClasses);
978 + return $standardClass ? [$standardClass] : [];
539 979 }
540 980
541 981 protected function getTermsByProductId($productId)
542 982 {
543 - static $formattedTerms = null;
544 -
545 - if ($formattedTerms === null) {
983 + if (self::$termsCache === null) {
546 984 $terms = App::make('db')->table('term_relationships')
547 985 ->whereIn('object_id', $this->productIds)
548 986 ->get();
549 987
550 - $formattedTerms = [];
988 + self::$termsCache = [];
551 989
552 990 foreach ($terms as $term) {
553 - if (!isset($formattedTerms[$term->object_id])) {
554 - $formattedTerms[$term->object_id] = [];
991 + if (!isset(self::$termsCache[$term->object_id])) {
992 + self::$termsCache[$term->object_id] = [];
555 993 }
556 - $formattedTerms[$term->object_id][] = $term->term_taxonomy_id;
994 + self::$termsCache[$term->object_id][] = $term->term_taxonomy_id;
557 995 }
558 996 }
559 997
560 - 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;
561 1153 }
562 1154
563 1155 }