PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / Tax / TaxCalculator.php

TaxCalculator.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/Tax/TaxCalculator.php

1,156 lines 41.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\Tax;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Models\Meta;
7 use FluentCart\App\Models\TaxRate;
8 use FluentCart\App\Models\TaxClass;
9 use FluentCart\Framework\Support\Arr;
10 use FluentCart\App\Services\Tax\TaxManager;
11 use FluentCart\Framework\Support\Collection;
12 use FluentCart\App\Services\Localization\LocalizationManager;
13
14 class TaxCalculator
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
36 protected $productIds = [];
37
38 protected $taxMaps = [];
39
40 protected $country = '';
41 protected $state = '';
42 protected $city = '';
43 protected $postCode = '';
44
45 protected $lineItems = [];
46
47 protected $formattedLineItems = [];
48
49 protected $products = [];
50
51 protected $inclusive = true;
52
53 protected $cart;
54
55 protected $manualDiscounts = 0;
56
57 protected $taxSettings = [];
58
59 private $roundingMode = 'item';
60
61 public function __construct($lineItems, $config = [])
62 {
63 $this->inclusive = Arr::get($config, 'inclusive', true);
64 $this->manualDiscounts = Arr::get($config, 'manual_discounts', 0);
65 $this->country = Arr::get($config, 'country');
66 $this->state = Arr::get($config, 'state');
67 $this->city = Arr::get($config, 'city');
68 $this->postCode = Arr::get($config, 'postcode');
69 $this->roundingMode = Arr::get($config, 'tax_rounding', 'item');
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
79 $taxSettings = (new TaxModule())->getSettings();
80
81 $this->taxSettings = $taxSettings;
82
83 if (Arr::get($taxSettings, 'enable_tax') !== 'yes') {
84 return;
85 }
86
87 $this->inclusive = Arr::get($taxSettings, 'tax_inclusion') === 'included';
88
89 if ($lineItems) {
90 $this->lineItems = $lineItems;
91 $this->productIds = array_values(array_unique(array_filter(array_column($lineItems, 'post_id'))));
92
93 if ($this->productIds) {
94 $this->products = \FluentCart\App\Models\Product::query()->whereIn('id', $this->productIds)
95 ->with(['detail', 'variants'])
96 ->get()
97 ->keyBy('ID');
98 }
99 $this->setupMaps();
100 }
101
102 }
103
104 public function getTaxBehaviorValue()
105 {
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');
116 }
117
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;
129 }
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
144 public function setupMaps()
145 {
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 }
157 }
158
159 $formattedLineItems = [];
160 foreach ($this->lineItems as $lineItem) {
161 $isFee = !empty($lineItem['is_fee']);
162
163 // Fee items (post_id = 0) use the first product's tax rates
164 if ($isFee) {
165 $isTaxable = Arr::get($lineItem, 'other_info.taxable', false);
166 $firstTaxMapKey = Arr::first(array_keys($this->taxMaps));
167 $rates = ($isTaxable && $firstTaxMapKey !== null)
168 ? Arr::get($this->taxMaps, $firstTaxMapKey, [])
169 : [];
170 } else {
171 $rates = Arr::get($this->taxMaps, $this->getTaxMapKey($lineItem), []);
172 }
173 $taxLines = [];
174 $signupFeeTaxLines = [];
175 $lineTaxTotal = 0;
176 $signupFeeTax = 0;
177 $recurringTax = 0;
178 $signupFee = 0;
179 $recurringAmount = 0;
180 $isSubscription = Arr::get($lineItem, 'other_info.payment_type') === 'subscription';
181
182 $taxableAmount = max(0, Arr::get($lineItem, 'subtotal', 0) - Arr::get($lineItem, 'discount_total', 0));
183
184
185 if ($isSubscription) {
186 $signupFee = Arr::get($lineItem, 'other_info.signup_fee', 0);
187
188 $recurringAmount = Arr::get($lineItem, 'subtotal', 0);
189 if (Arr::get($lineItem, 'recurring_discounts.amount', 0) > 0) {
190 $recurringAmount -= Arr::get($lineItem, 'recurring_discounts.amount', 0); // remove recurring_discount from recurring amount
191 }
192
193 $havePredefinedTrialDays = Arr::get($lineItem, 'other_info.trial_days', 0) > 0;
194 if ($havePredefinedTrialDays) {
195 $taxableAmount = 0;
196 }
197 }
198
199
200 $lineInclusive = $this->getVariantInclusiveForLineItem($lineItem);
201 if ($lineInclusive === null) {
202 $lineInclusive = $this->inclusive;
203 }
204
205 if ($rates) {
206 foreach ($rates as $rate) {
207 $rateSignupFeeTax = 0;
208 $rateRecurringTax = 0;
209 // Access is_compound as object property
210 $isCompound = $rate->is_compound;
211
212 // For compound rates, calculate on subtotal + accumulated taxes
213 $currentTaxableAmount = $taxableAmount;
214 $currentRecurringAmount = $recurringAmount;
215 $currentSignupFee = $signupFee;
216
217 if ($isCompound) {
218
219 $currentTaxableAmount = $taxableAmount + $lineTaxTotal;
220 if ($recurringAmount) {
221 $currentRecurringAmount = $recurringAmount + $recurringTax;
222 }
223 if ($signupFee) {
224 $currentSignupFee = $signupFee + $signupFeeTax;
225 }
226 }
227
228 if ($lineInclusive) {
229 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / (100 + $rate->rate);
230 if ($recurringAmount) {
231 $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / (100 + $rate->rate);
232 $recurringTax += $rateRecurringTax;
233 }
234 if ($signupFee) {
235 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / (100 + $rate->rate);
236 }
237 } else {
238
239
240 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / 100;
241 if ($recurringAmount) {
242 $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / 100;
243 $recurringTax += $rateRecurringTax;
244 }
245 if ($signupFee) {
246 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / 100;
247 }
248 }
249
250 $taxLines[] = [
251 'rate_id' => $rate->id,
252 'label' => $rate->name,
253 'tax_amount' => $this->roundTax($taxAmount),
254 'recurring_tax' => $this->roundTax($rateRecurringTax),
255 'rate' => $rate->rate,
256 'rate_percent' => $rate->rate,
257 'for_shipping' => $rate->for_shipping,
258 'country' => $rate->country,
259 'is_compound' => $isCompound,
260 'taxable_amount' => $this->roundTax($currentTaxableAmount),
261 ];
262
263 if ($rateSignupFeeTax) {
264 $signupFeeTaxLines[] = [
265 'rate_id' => $rate->id,
266 'label' => $rate->name,
267 'tax_amount' => $this->roundTax($rateSignupFeeTax),
268 'rate' => $rate->rate,
269 'rate_percent' => $rate->rate,
270 'for_shipping' => $rate->for_shipping,
271 'country' => $rate->country,
272 'is_compound' => $isCompound,
273 'taxable_amount' => $this->roundTax($currentSignupFee),
274 ];
275
276 $signupFeeTax += $rateSignupFeeTax;
277 }
278
279
280 $lineTaxTotal += $taxAmount;
281 }
282 }
283
284 if (empty($lineItem['line_meta'])) {
285 $lineItem['line_meta'] = [];
286 }
287
288 $lineItem['line_meta']['tax_config'] = [
289 'inclusive' => $lineInclusive,
290 'rates' => $taxLines,
291 ];
292
293 if ($isSubscription) {
294 Arr::set($lineItem, 'other_info.recurring_tax', $this->roundTax($recurringTax));
295 if ($signupFeeTax) {
296 Arr::set($lineItem, 'other_info.signup_fee_tax', $this->roundTax($signupFeeTax));
297 $lineItem['signup_fee_tax_config'] = [
298 'inclusive' => $lineInclusive,
299 'rates' => $signupFeeTaxLines,
300 ];
301 } else {
302 unset($lineItem['other_info']['signup_fee_tax']);
303 unset($lineItem['signup_fee_tax_config']);
304 }
305
306 } else {
307 unset($lineItem['other_info']['signup_fee_tax']);
308 unset($lineItem['signup_fee_tax_lines']);
309 }
310
311 $lineItem['tax_amount'] = $this->roundTax($lineTaxTotal);
312
313 $formattedLineItems[] = $lineItem;
314 }
315
316 $this->formattedLineItems = $formattedLineItems;
317 }
318
319 public function getTaxedLines()
320 {
321 return $this->formattedLineItems;
322 }
323
324 public function getTaxLinesByRates($lineItems = [])
325 {
326 if (!$lineItems) {
327 $lineItems = $this->formattedLineItems;
328 }
329
330 $taxLines = [];
331 foreach ($lineItems as $lineItem) {
332 $lineMeta = Arr::get($lineItem, 'line_meta', []);
333 $taxConfig = Arr::get($lineMeta, 'tax_config', []);
334 $rates = Arr::get($taxConfig, 'rates', []);
335 $lineInclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
336 if ($rates) {
337 foreach ($rates as $rate) {
338 $rateId = Arr::get($rate, 'rate_id');
339 if (!isset($taxLines[$rateId])) {
340 $taxLines[$rateId] = [
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,
349 ];
350 } elseif ($taxLines[$rateId]['inclusive'] !== $lineInclusive) {
351 $taxLines[$rateId]['inclusive'] = null;
352 $taxLines[$rateId]['is_mixed_inclusive'] = true;
353 }
354 $taxLines[$rateId]['tax_amount'] += Arr::get($rate, 'tax_amount', 0);
355 $taxLines[$rateId]['taxable_amount'] += Arr::get($rate, 'taxable_amount', 0);
356 }
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 }
369 }
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
378 return array_values($taxLines);
379 }
380
381 public function getTotalTax()
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
392 $taxTotal = 0;
393 foreach ($this->formattedLineItems as $lineItem) {
394 $mainTaxAmount = Arr::get($lineItem, 'tax_amount', 0);
395 $taxTotal += $mainTaxAmount;
396 $isSubscription = Arr::get($lineItem, 'other_info.payment_type') === 'subscription';
397 if ($isSubscription) {
398 $signupFeeTax = Arr::get($lineItem, 'other_info.signup_fee_tax', 0);
399 if ($signupFeeTax) {
400 $taxTotal += $signupFeeTax;
401 }
402 }
403 }
404
405 return $this->finalRound($taxTotal);
406 }
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
440 public function getRecurringTax()
441 {
442 $recurringTaxTotal = 0;
443 foreach ($this->formattedLineItems as $lineItem) {
444 $recurringTax = Arr::get($lineItem, 'other_info.recurring_tax', 0);
445 $recurringTaxTotal += $recurringTax;
446 }
447
448 return $this->finalRound($recurringTaxTotal);
449 }
450
451 public function getTaxCountry()
452 {
453 return $this->country;
454 }
455
456 public function getShippingTax()
457 {
458 $shippingTaxTotal = 0;
459 foreach($this->formattedLineItems as $item) {
460 $taxRates = Arr::get($item, 'line_meta.tax_config.rates', []);
461 $totalShippingCharge = Arr::get($item, 'shipping_charge', 0) + Arr::get($item, 'itemwise_shipping_charge', 0);
462
463 if (!$taxRates || !$totalShippingCharge) {
464 continue;
465 }
466
467 // Track accumulated shipping tax for compound calculation
468 $accumulatedShippingTax = 0;
469
470 // Calculate shipping tax for each rate
471 foreach ($taxRates as $taxMeta) {
472 $rate = Arr::get($taxMeta, 'rate', 0);
473 $forShipping = Arr::get($taxMeta, 'for_shipping', null);
474 $isCompound = Arr::get($taxMeta, 'is_compound', false);
475
476 $effectiveRate = $forShipping !== null ? (float) $forShipping : (float) $rate;
477
478 // For compound rates, add accumulated tax to the base
479 $shippingBase = $totalShippingCharge;
480 if ($isCompound) {
481 $shippingBase = $totalShippingCharge + $accumulatedShippingTax;
482 }
483
484 if ($this->inclusive) {
485 $shippingTax = $effectiveRate > 0 ? ($shippingBase * $effectiveRate) / (100 + $effectiveRate) : 0;
486 } else {
487 $shippingTax = ($shippingBase * $effectiveRate) / 100;
488 }
489
490 $accumulatedShippingTax += $this->roundTax($shippingTax);
491 $shippingTaxTotal += $this->roundTax($shippingTax);
492 }
493 }
494
495 return $this->finalRound($shippingTaxTotal);
496 }
497
498 public function getShippingTaxByRates(): array
499 {
500 $byRate = [];
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 {
620 if (!$taxClasses) {
621 return [];
622 }
623
624 if (!TaxManager::getInstance()->isTaxEnabledForCountry($this->country)) {
625 return [];
626 }
627
628 // check EU country
629 $euCountryCodes = LocalizationManager::getInstance()->taxContinents('EU');
630 $euCountryCodes = Arr::get($euCountryCodes, 'countries');
631 $isEuCountry = in_array($this->country, $euCountryCodes);
632
633 $allValidRates = [];
634
635 // Loop through all tax classes and get rates for each
636 foreach ($taxClasses as $taxClass) {
637 $taxClassSlug = $taxClass->slug;
638
639 if ($isEuCountry) {
640 $rates = $this->getEuTaxRates($taxClass->id, $taxClassSlug);
641 } else {
642 $rates = TaxRate::query()->where('class_id', $taxClass->id)
643 ->orderBy('priority', 'asc')
644 ->orderBy('id', 'asc')
645 ->where('country', $this->country)
646 ->get();
647 }
648
649 if ($rates->isEmpty()) {
650 continue;
651 }
652
653 $matchedRates = [];
654
655 // Validate rates for this tax class
656 foreach ($rates as $rate) {
657
658 if ($rate->state && $rate->state !== $this->state) {
659 continue;
660 }
661
662
663 if ($rate->city && $rate->city !== $this->city) {
664 continue;
665 }
666
667 if ($rate->postcode && !$this->matchesPostcode($rate->postcode, $this->postCode)) {
668 continue;
669 }
670
671 $matchedRates[] = $rate;
672 }
673
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;
698 }
699 }
700 }
701
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;
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
862 protected function getEuTaxRates($taxClassId, $taxClassSlug)
863 {
864 $euVatSettings = Arr::get($this->taxSettings, 'eu_vat_settings', []);
865 $vatCollectionMethod = Arr::get($euVatSettings, 'method', '');
866
867 if ($vatCollectionMethod === 'oss') {
868 $taxManager = TaxManager::getInstance();
869 $rates = TaxRate::query()->where('class_id', $taxClassId)
870 ->orderBy('priority', 'asc')
871 ->orderBy('id', 'asc')
872 ->where('country', $this->country)
873 ->get();
874
875 if ($rates->isEmpty()) {
876 $rates = $taxManager->getEuTaxRatesFromPhp($this->country, $taxClassSlug);
877 return Collection::make($rates)->map(function ($rate) {
878 $rate['country'] = $this->country;
879 return new TaxRate($rate);
880 });
881 }
882 return $rates;
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([]);
895 }
896
897 protected function getRatesFromRegistrations($euVatSettings, $taxClassId, $taxClassSlug, $country)
898 {
899 $regForCountry = TaxManager::getInstance()->getEuVatRegistration($country);
900
901 if (!$regForCountry) {
902 return Collection::make([]);
903 }
904
905 $rates = (array) Arr::get($regForCountry, 'rates', []);
906 $rateData = Arr::get($rates, $taxClassSlug);
907
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', '')];
913 }
914 }
915
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]);
939 }
940
941 protected function getTaxClassByLineItem($lineItem)
942 {
943 $productId = Arr::get($lineItem, 'post_id');
944 $product = Arr::get($this->products, $productId);
945 if (!$product) {
946 return [];
947 }
948
949 $variationId = Arr::get($lineItem, 'object_id') ?: Arr::get($lineItem, 'variation_id');
950 $variants = $product->variants ?? [];
951
952 if ($variationId && $variants) {
953 foreach ($variants as $productVariant) {
954 if ((string) $productVariant->id !== (string) $variationId) {
955 continue;
956 }
957
958 $variantOtherInfo = $productVariant->other_info ?: [];
959
960 if (Arr::get($variantOtherInfo, 'tax_exempt') === 'yes') {
961 return [];
962 }
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;
973 }
974 }
975
976 $standardClass = $this->getStandardTaxClass();
977
978 return $standardClass ? [$standardClass] : [];
979 }
980
981 protected function getTermsByProductId($productId)
982 {
983 if (self::$termsCache === null) {
984 $terms = App::make('db')->table('term_relationships')
985 ->whereIn('object_id', $this->productIds)
986 ->get();
987
988 self::$termsCache = [];
989
990 foreach ($terms as $term) {
991 if (!isset(self::$termsCache[$term->object_id])) {
992 self::$termsCache[$term->object_id] = [];
993 }
994 self::$termsCache[$term->object_id][] = $term->term_taxonomy_id;
995 }
996 }
997
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;
1153 }
1154
1155 }
1156