PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
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
fluent-cart / app / Modules / Tax / TaxCalculator.php

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

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