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

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