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

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

564 lines 19.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\TaxRate;
7 use FluentCart\App\Models\TaxClass;
8 use FluentCart\Framework\Support\Arr;
9 use FluentCart\App\Services\Tax\TaxManager;
10 use FluentCart\Framework\Support\Collection;
11 use FluentCart\App\Services\Localization\LocalizationManager;
12
13 class TaxCalculator
14 {
15
16 protected $productIds = [];
17
18 protected $taxMaps = [];
19
20 protected $country = '';
21 protected $state = '';
22 protected $city = '';
23 protected $postCode = '';
24
25 protected $lineItems = [];
26
27 protected $formattedLineItems = [];
28
29 protected $products = [];
30
31 protected $inclusive = true;
32
33 protected $cart;
34
35 protected $manualDiscounts = 0;
36
37 protected $taxSettings = [];
38
39 public function __construct($lineItems, $config = [])
40 {
41 $this->inclusive = Arr::get($config, 'inclusive', true);
42 $this->manualDiscounts = Arr::get($config, 'manual_discounts', 0);
43 $this->country = Arr::get($config, 'country');
44 $this->state = Arr::get($config, 'state');
45 $this->city = Arr::get($config, 'city');
46 $this->postCode = Arr::get($config, 'postcode');
47
48 // Map territory country codes (GP→FR+state=GP) before rate lookup.
49 $resolved = TaxManager::getInstance()->resolveTaxCountryAndState(
50 (string) $this->country,
51 $this->state
52 );
53 $this->country = $resolved['country'];
54 $this->state = $resolved['state'];
55
56 $taxSettings = (new TaxModule())->getSettings();
57
58 $this->taxSettings = $taxSettings;
59
60 if (Arr::get($taxSettings, 'enable_tax') !== 'yes') {
61 return;
62 }
63
64 $this->inclusive = Arr::get($taxSettings, 'tax_inclusion') === 'included';
65
66 if ($lineItems) {
67 $this->lineItems = $lineItems;
68 $this->productIds = array_values(array_unique(array_filter(array_column($lineItems, 'post_id'))));
69
70 if ($this->productIds) {
71 $this->products = \FluentCart\App\Models\Product::query()->whereIn('id', $this->productIds)
72 ->with(['detail'])
73 ->get()
74 ->keyBy('ID');
75 }
76 $this->setupMaps();
77 }
78
79 }
80
81 public function getTaxBahaviorValue()
82 {
83 if ($this->inclusive) {
84 return 2;
85 }
86
87 return 1;
88 }
89
90 public function setupMaps()
91 {
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);
95 }
96
97 $formattedLineItems = [];
98 foreach ($this->lineItems as $lineItem) {
99 $isFee = !empty($lineItem['is_fee']);
100 $productId = Arr::get($lineItem, 'post_id');
101
102 // Fee items (post_id = 0) use the first product's tax rates
103 if ($isFee) {
104 $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, [])
108 : [];
109 } else {
110 $rates = Arr::get($this->taxMaps, $productId, []);
111 }
112 $taxLines = [];
113 $signupFeeTaxLines = [];
114 $lineTaxTotal = 0;
115 $signupFeeTax = 0;
116 $recurringTax = 0;
117 $signupFee = 0;
118 $recurringAmount = 0;
119 $isSubscription = Arr::get($lineItem, 'other_info.payment_type') === 'subscription';
120
121 $taxableAmount = Arr::get($lineItem, 'subtotal', 0) - Arr::get($lineItem, 'discount_total', 0);
122
123
124 if ($isSubscription) {
125 $signupFee = Arr::get($lineItem, 'other_info.signup_fee', 0);
126
127 $recurringAmount = Arr::get($lineItem, 'subtotal', 0);
128 if (Arr::get($lineItem, 'recurring_discounts.amount', 0) > 0) {
129 $recurringAmount -= Arr::get($lineItem, 'recurring_discounts.amount', 0); // remove recurring_discount from recurring amount
130 }
131
132 $havePredefinedTrialDays = Arr::get($lineItem, 'other_info.trial_days', 0) > 0;
133 if ($havePredefinedTrialDays) {
134 $taxableAmount = 0;
135 }
136 }
137
138
139 if ($rates) {
140 foreach ($rates as $rate) {
141 $rateSignupFeeTax = 0;
142 $rateRecurringTax = 0;
143 // Access is_compound as object property
144 $isCompound = $rate->is_compound;
145
146 // For compound rates, calculate on subtotal + accumulated taxes
147 $currentTaxableAmount = $taxableAmount;
148 $currentRecurringAmount = $recurringAmount;
149 $currentSignupFee = $signupFee;
150
151 if ($isCompound) {
152
153 $currentTaxableAmount = $taxableAmount + $lineTaxTotal;
154 if ($recurringAmount) {
155 $currentRecurringAmount = $recurringAmount + $recurringTax;
156 }
157 if ($signupFee) {
158 $currentSignupFee = $signupFee + $signupFeeTax;
159 }
160 }
161
162 if ($this->inclusive) {
163 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / (100 + $rate->rate);
164 if ($recurringAmount) {
165 $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / (100 + $rate->rate);
166 $recurringTax += $rateRecurringTax;
167 }
168 if ($signupFee) {
169 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / (100 + $rate->rate);
170 }
171 } else {
172
173
174 $taxAmount = ($currentTaxableAmount * (float) $rate->rate) / 100;
175 if ($recurringAmount) {
176 $rateRecurringTax = ($currentRecurringAmount * (float) $rate->rate) / 100;
177 $recurringTax += $rateRecurringTax;
178 }
179 if ($signupFee) {
180 $rateSignupFeeTax += ($currentSignupFee * (float) $rate->rate) / 100;
181 }
182 }
183
184 $taxLines[] = [
185 'rate_id' => $rate->id,
186 'label' => $rate->name,
187 'tax_amount' => ceil($taxAmount),
188 'recurring_tax' => ceil($rateRecurringTax),
189 'rate' => $rate->rate,
190 'rate_percent' => $rate->rate,
191 'for_shipping' => $rate->for_shipping,
192 'country' => $rate->country,
193 'is_compound' => $isCompound,
194 'taxable_amount' => ceil($currentTaxableAmount),
195 ];
196
197 if ($rateSignupFeeTax) {
198 $signupFeeTaxLines[] = [
199 'rate_id' => $rate->id,
200 'label' => $rate->name,
201 'tax_amount' => ceil($rateSignupFeeTax),
202 'rate' => $rate->rate,
203 'rate_percent' => $rate->rate,
204 'for_shipping' => $rate->for_shipping,
205 'country' => $rate->country,
206 'is_compound' => $isCompound,
207 'taxable_amount' => ceil($currentSignupFee),
208 ];
209
210 $signupFeeTax += $rateSignupFeeTax;
211 }
212
213
214 $lineTaxTotal += $taxAmount;
215 }
216 }
217
218 if (empty($lineItem['line_meta'])) {
219 $lineItem['line_meta'] = [];
220 }
221
222 $lineItem['line_meta']['tax_config'] = [
223 'inclusive' => $this->inclusive,
224 'rates' => $taxLines,
225 ];
226
227 if ($isSubscription) {
228 Arr::set($lineItem, 'other_info.recurring_tax', ceil($recurringTax));
229 if ($signupFeeTax) {
230 Arr::set($lineItem, 'other_info.signup_fee_tax', ceil($signupFeeTax));
231 $lineItem['signup_fee_tax_config'] = [
232 'inclusive' => $this->inclusive,
233 'rates' => $signupFeeTaxLines,
234 ];
235 }
236
237 } else {
238 unset($lineItem['other_info']['signup_fee_tax']);
239 unset($lineItem['signup_fee_tax_lines']);
240 }
241
242 $lineItem['tax_amount'] = ceil($lineTaxTotal);
243
244 $formattedLineItems[] = $lineItem;
245 }
246
247 $this->formattedLineItems = $formattedLineItems;
248 }
249
250 public function getTaxedLines()
251 {
252 return $this->formattedLineItems;
253 }
254
255 public function getTaxLinesByRates($lineItems = [])
256 {
257 if (!$lineItems) {
258 $lineItems = $this->formattedLineItems;
259 }
260
261 $taxLines = [];
262 foreach ($lineItems as $lineItem) {
263 $lineMeta = Arr::get($lineItem, 'line_meta', []);
264 $taxConfig = Arr::get($lineMeta, 'tax_config', []);
265 $rates = Arr::get($taxConfig, 'rates', []);
266 if ($rates) {
267 foreach ($rates as $rate) {
268 $rateId = Arr::get($rate, 'rate_id');
269 if (!isset($taxLines[$rateId])) {
270 $taxLines[$rateId] = [
271 'rate_id' => $rateId,
272 'label' => Arr::get($rate, 'label'),
273 'tax_amount' => 0,
274 ];
275 }
276 $taxLines[$rateId]['tax_amount'] += Arr::get($rate, 'tax_amount', 0);
277 }
278 }
279 }
280
281 return array_values($taxLines);
282 }
283
284 public function getTotalTax()
285 {
286 $taxTotal = 0;
287 foreach ($this->formattedLineItems as $lineItem) {
288 $mainTaxAmount = Arr::get($lineItem, 'tax_amount', 0);
289 $taxTotal += $mainTaxAmount;
290 $isSubscription = Arr::get($lineItem, 'other_info.payment_type') === 'subscription';
291 if ($isSubscription) {
292 $signupFeeTax = Arr::get($lineItem, 'other_info.signup_fee_tax', 0);
293 if ($signupFeeTax) {
294 $taxTotal += $signupFeeTax;
295 }
296 }
297 }
298
299 return ceil($taxTotal);
300 }
301
302 public function getRecurringTax()
303 {
304 $recurringTaxTotal = 0;
305 foreach ($this->formattedLineItems as $lineItem) {
306 $recurringTax = Arr::get($lineItem, 'other_info.recurring_tax', 0);
307 $recurringTaxTotal += $recurringTax;
308 }
309
310 return ceil($recurringTaxTotal);
311 }
312
313 public function getTaxCountry()
314 {
315 return $this->country;
316 }
317
318 public function getShippingTax()
319 {
320 $shippingTaxTotal = 0;
321 foreach($this->formattedLineItems as $item) {
322 $taxRates = Arr::get($item, 'line_meta.tax_config.rates', []);
323 $totalShippingCharge = Arr::get($item, 'shipping_charge', 0) + Arr::get($item, 'itemwise_shipping_charge', 0);
324
325 if (!$taxRates || !$totalShippingCharge) {
326 continue;
327 }
328
329 // Track accumulated shipping tax for compound calculation
330 $accumulatedShippingTax = 0;
331
332 // Calculate shipping tax for each rate
333 foreach ($taxRates as $taxMeta) {
334 $rate = Arr::get($taxMeta, 'rate', 0);
335 $forShipping = Arr::get($taxMeta, 'for_shipping', null);
336 $isCompound = Arr::get($taxMeta, 'is_compound', false);
337
338 $effectiveRate = $forShipping !== null ? (float) $forShipping : (float) $rate;
339
340 // For compound rates, add accumulated tax to the base
341 $shippingBase = $totalShippingCharge;
342 if ($isCompound) {
343 $shippingBase = $totalShippingCharge + $accumulatedShippingTax;
344 }
345
346 if ($this->inclusive) {
347 $shippingTax = ($shippingBase * $effectiveRate) / (100 + $effectiveRate);
348 } else {
349 $shippingTax = ($shippingBase * $effectiveRate) / 100;
350 }
351
352 $accumulatedShippingTax += ceil($shippingTax);
353 $shippingTaxTotal += ceil($shippingTax);
354 }
355 }
356
357 return ceil($shippingTaxTotal);
358 }
359
360 protected function getRatesByProductId($productId)
361 {
362 $taxClasses = $this->getTaxClassByProductId($productId);
363
364 if (!$taxClasses) {
365 return [];
366 }
367
368 // check EU country
369 $euCountryCodes = LocalizationManager::getInstance()->taxContinents('EU');
370 $euCountryCodes = Arr::get($euCountryCodes, 'countries');
371 $isEuCountry = in_array($this->country, $euCountryCodes);
372
373 $allValidRates = [];
374
375 // Loop through all tax classes and get rates for each
376 foreach ($taxClasses as $taxClass) {
377 $taxClassSlug = $taxClass->slug;
378
379 if ($isEuCountry) {
380 $rates = $this->getEuTaxRates($taxClass->id, $taxClassSlug);
381 } else {
382 $rates = TaxRate::query()->where('class_id', $taxClass->id)
383 ->orderBy('priority', 'asc')
384 ->where('country', $this->country)
385 ->get();
386 }
387
388 if ($rates->isEmpty()) {
389 continue;
390 }
391
392 // Validate rates for this tax class
393 $matchedRates = [];
394 foreach ($rates as $rate) {
395
396 if ($rate->state && $rate->state !== $this->state) {
397 continue;
398 }
399
400
401 if ($rate->city && $rate->city !== $this->city) {
402 continue;
403 }
404
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 }
428 }
429
430 $matchedRates[] = $rate;
431 }
432
433 if (!$matchedRates) {
434 continue;
435 }
436
437 // State-specific rates override country-level ones (e.g. GP 8.5% over FR 20%).
438 if ($this->state) {
439 $stateSpecific = array_values(array_filter($matchedRates, function ($r) {
440 return !empty($r->state);
441 }));
442 if ($stateSpecific) {
443 $matchedRates = $stateSpecific;
444 }
445 }
446
447 foreach ($matchedRates as $matchedRate) {
448 $allValidRates[] = $matchedRate;
449 }
450 }
451
452 return $allValidRates;
453 }
454
455 protected function getEuTaxRates($taxClassId, $taxClassSlug)
456 {
457 $euVatSettings = Arr::get($this->taxSettings, 'eu_vat_settings', []);
458 $vatCollectionMethod = Arr::get($euVatSettings, 'method', '');
459 $taxManager = TaxManager::getInstance();
460
461 if ($vatCollectionMethod === 'oss' || $vatCollectionMethod === 'home') {
462 if ($vatCollectionMethod === 'home') {
463 $this->country = Arr::get($euVatSettings, 'home_country', '');
464 }
465
466 $rates = TaxRate::query()->where('class_id', $taxClassId)
467 ->orderBy('priority', 'asc')
468 ->where('country', $this->country)
469 ->get();
470
471 if ($rates->isEmpty()) {
472 $rates = $taxManager->getEuTaxRatesFromPhp($this->country, $taxClassSlug);
473 return Collection::make($rates)->map(function ($rate) {
474 $rate['country'] = $this->country;
475 return new TaxRate($rate);
476 });
477 }
478 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 }
487 }
488
489 protected function getTaxClassByProductId($productId)
490 {
491 $product = Arr::get($this->products, $productId);
492 if (!$product) {
493 return [];
494 }
495
496 $taxClasId = Arr::get($product->detail->other_info, 'tax_class', '');
497
498 if ($taxClasId) {
499 $class = TaxClass::query()->find($taxClasId);
500 if ($class) {
501 return [$class];
502 }
503 }
504
505 // let's get the tax class from the product category
506 return $this->getTaxClassByTermIds($this->getTermsByProductId($productId));
507 }
508
509 protected function getTaxClassByTermIds($termIds)
510 {
511 if (!$termIds) {
512 return [];
513 }
514
515 $taxClasses = null;
516
517 $formattedTaxClasses = [];
518
519 if ($taxClasses === null) {
520 $taxClasses = TaxClass::query()->whereNotNull('meta')->get();
521
522 foreach ($taxClasses as $taxClass) {
523 $categories = Arr::get($taxClass->meta, 'categories', []);
524 if (!$categories || !array_intersect($termIds, $categories)) {
525 continue;
526 }
527 $priority = Arr::get($taxClass->meta, 'priority', 0);
528 $formattedTaxClasses[$priority] = $taxClass;
529 }
530 }
531
532 if (!$formattedTaxClasses) {
533 return [];
534 }
535
536 // return all tax classes sorted by priority (highest first)
537 krsort($formattedTaxClasses);
538 return array_values($formattedTaxClasses);
539 }
540
541 protected function getTermsByProductId($productId)
542 {
543 static $formattedTerms = null;
544
545 if ($formattedTerms === null) {
546 $terms = App::make('db')->table('term_relationships')
547 ->whereIn('object_id', $this->productIds)
548 ->get();
549
550 $formattedTerms = [];
551
552 foreach ($terms as $term) {
553 if (!isset($formattedTerms[$term->object_id])) {
554 $formattedTerms[$term->object_id] = [];
555 }
556 $formattedTerms[$term->object_id][] = $term->term_taxonomy_id;
557 }
558 }
559
560 return Arr::get($formattedTerms, $productId, []);
561 }
562
563 }
564