PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.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 / Services / Tax / TaxManager.php

TaxManager.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.3, at app/Services/Tax/TaxManager.php

708 lines 22.6 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\Services\Tax;
4
5
6 use FluentCart\App\App;
7 use FluentCart\Api\StoreSettings;
8 use FluentCart\App\Models\Meta;
9 use FluentCart\App\Models\TaxRate;
10 use FluentCart\App\Models\TaxClass;
11 use FluentCart\App\Models\BatchQuery\Batch;
12 use FluentCart\Framework\Database\Query\Expression;
13 use FluentCart\Framework\Support\Arr;
14 use FluentCart\Framework\Support\Str;
15 use FluentCart\App\Helpers\AddressHelper;
16 use FluentCart\App\Modules\Tax\TaxModule;
17 use FluentCart\Framework\Support\Collection;
18 use FluentCart\App\Services\DateTime\DateTime;
19
20 class TaxManager
21 {
22 /**
23 * @var TaxManager|null
24 */
25 private static $instance = null;
26
27 /**
28 * @var array
29 */
30 private $rates = [];
31
32
33 /**
34 * @var array
35 */
36 private $config = [];
37
38
39 /**
40 * @var array
41 */
42 private array $descriptionMap = [];
43
44 /**
45 * @var array
46 */
47 private array $countryEnabledCache = [];
48
49 /** ISO country codes whose rates are stored under a parent country with state=<code>. */
50 private array $parentCountryMap = [
51 //'GP' => 'FR', // Guadeloupe
52 //'MQ' => 'FR', // Martinique
53 //'RE' => 'FR', // Réunion
54 ];
55
56 /**
57 * Private constructor to prevent direct instantiation
58 */
59 private function __construct()
60 {
61 $this->descriptionMap = [
62 'standard' => __('Default tax class for most products.', 'fluent-cart'),
63 'zero' => __('For items with 0% tax.', 'fluent-cart'),
64 'reduced' => __('For items with a reduced tax rate.', 'fluent-cart'),
65 ];
66 $this->rates = require __DIR__ . '/tax.php';
67 $this->config = require __DIR__ . '/config.php';
68 }
69
70 /**
71 * Get the singleton instance
72 *
73 * @return TaxManager
74 */
75 public static function getInstance(): TaxManager
76 {
77 if (self::$instance === null) {
78 self::$instance = new self();
79 }
80 return self::$instance;
81 }
82
83 /**
84 * Get all tax rates
85 *
86 * @return array
87 */
88 public function getRates(): array
89 {
90 return $this->rates;
91 }
92
93 /**
94 * Generate human-readable label from a tax key
95 *
96 * @param string $key
97 * @return string
98 */
99 private function formatLabel(string $key): string
100 {
101 // Special mappings
102 $map = [
103 'standard' => __('Standard', 'fluent-cart'),
104 'zero' => __('Zero', 'fluent-cart'),
105 ];
106
107 if (isset($map[$key])) {
108 return $map[$key];
109 }
110
111 // If key ends with number (like reduced_1, reduced_2)
112 if (preg_match('/^(.+?)_(\d+)$/', $key, $matches)) {
113 $prefix = ucfirst(str_replace('_', ' ', $matches[1]));
114 $num = (int)$matches[2];
115
116 $numberMap = [
117 1 => __('One', 'fluent-cart'),
118 2 => __('Two', 'fluent-cart'),
119 3 => __('Three', 'fluent-cart'),
120 4 => __('Four', 'fluent-cart'),
121 5 => __('Five', 'fluent-cart'),
122 6 => __('Six', 'fluent-cart'),
123 7 => __('Seven', 'fluent-cart'),
124 8 => __('Eight', 'fluent-cart'),
125 9 => __('Nine', 'fluent-cart'),
126 10 => __('Ten', 'fluent-cart'),
127 11 => __('Eleven', 'fluent-cart'),
128 12 => __('Twelve', 'fluent-cart'),
129 13 => __('Thirteen', 'fluent-cart'),
130 14 => __('Fourteen', 'fluent-cart'),
131 15 => __('Fifteen', 'fluent-cart')
132 ];
133
134 return $prefix . ' ' . ($numberMap[$num] ?? $num);
135 }
136
137 // Default: convert snake_case → words
138 return ucwords(str_replace('_', ' ', $key));
139 }
140
141 /**
142 * Iterate all countries and collect all unique tax labels
143 *
144 * @return array
145 */
146 public function generateAllTaxLabels($only = []): array
147 {
148 $labels = [];
149
150 $rates = $this->rates;
151
152 if (!empty($only)) {
153 $rates = Arr::only($rates, $only);
154 }
155
156 foreach ($rates as $country => $data) {
157 if (!isset($data['tax'])) {
158 continue;
159 }
160
161 foreach (array_keys($data['tax']) as $key) {
162 if (!isset($labels[$key])) {
163 $labels[$key] = $this->formatLabel($key);
164 }
165 }
166 }
167
168 return $labels;
169 }
170
171 public function generateTaxClasses($only = [])
172 {
173
174 $taxClassLabels = [
175 'standard' => __('Standard', 'fluent-cart'),
176 'reduced' => __('Reduced', 'fluent-cart'),
177 'zero' => __('Zero', 'fluent-cart'),
178 ];
179
180 $taxClassIds = [];
181
182 foreach ($taxClassLabels as $key => $label) {
183 $description = $this->descriptionMap[$key];
184 $priority = $key === 'standard' ? 10 : ($key === 'reduced' ? 5 : 2);
185 $taxClass = TaxClass::query()->firstOrCreate(
186 ['title' => $label], // search by title
187 [
188 'slug' => $key,
189 'description' => $description,
190 'meta' => [
191 'categories' => [],
192 'priority' => $priority,
193 ]
194 ]
195 );
196
197 $taxClassIds[$key] = $taxClass->id;
198 }
199
200 $ratesMap = [];
201
202 $rates = $this->rates;
203
204 if (!empty($only)) {
205 $rates = Arr::only($rates, $only);
206 }
207
208 // Get existing countries already in tax_rates
209 $existingCountries = TaxRate::query()
210 ->pluck('country')
211 ->unique()
212 ->toArray();
213
214 foreach ($rates as $country => $data) {
215 if (in_array($country, $existingCountries)) {
216 // Skip if this country already exists
217 continue;
218 }
219
220 if (!isset($data['tax'] )) {
221 continue;
222 }
223
224 foreach ($data['tax'] as $key => $rate) {
225 $compound = $rate['compound'] ?? false;
226 $shipping = $rate['shipping'] ?? false;
227
228 $typeKey = $rate['type'] ?? explode('_', $key)[0];
229
230
231 $ratesMap[] = [
232 'country' => $country,
233 'name' => $rate['name'] ?? (
234 ($data['group'] ?? '') === 'EU' && $typeKey === 'standard'
235 ? $this->buildDefaultRateLabel($country)
236 : $country . ' ' . ($taxClassLabels[$typeKey] ?? ucfirst($typeKey)) . ' Tax'
237 ),
238 'class_id' => $taxClassIds[$typeKey],
239 'rate' => $rate['rate'],
240 'is_compound' => $compound ? 1 : 0,
241 'group' => $data['group'] ?? '',
242 'state' => $rate['state'] ?? '',
243 'city' => $rate['city'] ?? '',
244 ];
245 //$this->rates[$country]['tax'][$key]['tax_class_id'] = $idMap[$key] ?? null;
246 }
247 }
248
249 TaxRate::query()->insert($ratesMap);
250 }
251
252
253 public function getEuTaxRatesFromPhp(string $country = '', $taxClassSlug = ''): array
254 {
255 if (!empty($country)) {
256 $countryData = $this->rates[$country] ?? null;
257 $rates = Arr::get($countryData, 'tax', []);
258 if (empty($taxClassSlug)) {
259 return $rates;
260 }
261 $rates = array_filter($rates, function ($tax) use ($taxClassSlug) {
262 return $tax['type'] === $taxClassSlug;
263 });
264 return $rates;
265 }
266
267 $formattedData = [];
268 foreach ($this->rates as $countryCode => $rate) {
269 if (isset($rate['group']) && $rate['group'] === 'EU' && isset($rate['tax'])) {
270 $formattedData[$countryCode] = $rate['tax'];
271 }
272 }
273
274 return $formattedData;
275 }
276
277 public function getTaxRatesFromTaxPhp(): array
278 {
279 $rates = $this->rates;
280 $formattedData = [];
281
282 foreach ($rates as $countryCode => $countries) {
283 $group = $countries['group'];
284
285 $countryName = AddressHelper::getCountryNameByCode($countryCode);
286
287 if (!isset($formattedData[$group])) {
288 $localization = App::localization();
289 $continent = $localization->continents($group);
290
291 if ($group === 'EU') {
292 $groupName = __('European Union', 'fluent-cart');
293 } else {
294 $groupName = Arr::get($continent, 'name') ?? __('Rest of the World', 'fluent-cart');
295 }
296
297 $formattedData[$group] = [
298 'group_name' => $groupName,
299 'group_code' => $group,
300 'countries' => [],
301 'total_countries' => 0
302 ];
303 }
304
305 $formattedData[$group]['countries'][] = [
306 'country_code' => $countryCode,
307 'country_name' => $countryName,
308 'total_rates' => count($countries['tax']),
309 'rates' => $countries['tax']
310 ];
311
312 $formattedData[$group]['total_countries'] += 1;
313 }
314 return $formattedData;
315 }
316
317 public function getTaxRates(): array
318 {
319 $taxRates = TaxRate::query()->select('group', 'country', 'name', 'rate', 'class_id')
320 ->orderBy('group')
321 ->orderBy('country')
322 ->orderBy('class_id')
323 ->get();
324
325 $groupedTaxRates = $this->groupTaxRatesByGroup($taxRates);
326
327 return $groupedTaxRates;
328 }
329
330 /**
331 * Map territory country codes (e.g. GP) to their parent country + state
332 * so tax rate lookups hit the correct DB rows (e.g. country=FR, state=GP).
333 */
334 public function resolveTaxCountryAndState(string $country, ?string $state): array
335 {
336 $country = strtoupper($country);
337 if (isset($this->parentCountryMap[$country])) {
338 return ['country' => $this->parentCountryMap[$country], 'state' => $country];
339 }
340 return ['country' => $country, 'state' => $state];
341 }
342
343 public function normalizeTaxStatusCountryCode(string $countryCode): string
344 {
345 $countryCode = strtoupper(sanitize_text_field($countryCode));
346
347 if ($countryCode === 'EU') {
348 return 'EU';
349 }
350
351 // Resolve territory codes (GP→FR) before the EU group check.
352 if (isset($this->parentCountryMap[$countryCode])) {
353 $countryCode = $this->parentCountryMap[$countryCode];
354 }
355
356 if (Arr::get($this->rates, $countryCode . '.group') === 'EU') {
357 return 'EU';
358 }
359
360 return $countryCode;
361 }
362
363 public function getCountryTaxEnabledMetaKey(string $countryCode): string
364 {
365 return 'fluent_cart_tax_enabled_' . $this->normalizeTaxStatusCountryCode($countryCode);
366 }
367
368 public function getCountryTaxEnabledMap(array $countryCodes): array
369 {
370 $countryCodes = array_values(array_unique(array_filter(array_map(function ($countryCode) {
371 return strtoupper(sanitize_text_field($countryCode));
372 }, $countryCodes))));
373
374 if (!$countryCodes) {
375 return [];
376 }
377
378 $normalizedMap = [];
379 foreach ($countryCodes as $countryCode) {
380 $normalizedMap[$countryCode] = $this->normalizeTaxStatusCountryCode($countryCode);
381 }
382
383 $normalizedCodes = array_values(array_unique(array_values($normalizedMap)));
384 $metaKeys = array_map(function ($countryCode) {
385 return 'fluent_cart_tax_enabled_' . $countryCode;
386 }, $normalizedCodes);
387
388 $metaRows = Meta::query()
389 ->where('object_type', 'tax')
390 ->whereIn('meta_key', $metaKeys)
391 ->get();
392
393 $enabledByNormalizedCode = array_fill_keys($normalizedCodes, true);
394
395 foreach ($metaRows as $metaRow) {
396 $normalizedCountryCode = strtoupper(str_replace('fluent_cart_tax_enabled_', '', $metaRow->meta_key));
397 $enabledByNormalizedCode[$normalizedCountryCode] = $this->parseCountryTaxEnabledValue($metaRow->meta_value);
398 $this->countryEnabledCache[$normalizedCountryCode] = $enabledByNormalizedCode[$normalizedCountryCode];
399 }
400
401 $enabledMap = [];
402 foreach ($normalizedMap as $countryCode => $normalizedCountryCode) {
403 $enabledMap[$countryCode] = $enabledByNormalizedCode[$normalizedCountryCode] ?? true;
404 }
405
406 return $enabledMap;
407 }
408
409 public function isTaxEnabledForCountry(string $countryCode): bool
410 {
411 $normalizedCountryCode = $this->normalizeTaxStatusCountryCode($countryCode);
412
413 if (array_key_exists($normalizedCountryCode, $this->countryEnabledCache)) {
414 return $this->countryEnabledCache[$normalizedCountryCode];
415 }
416
417 $metaKey = 'fluent_cart_tax_enabled_' . $normalizedCountryCode;
418 $meta = Meta::query()
419 ->where('meta_key', $metaKey)
420 ->where('object_type', 'tax')
421 ->first();
422
423 if ($meta === null) {
424 $this->countryEnabledCache[$normalizedCountryCode] = true;
425 return true;
426 }
427
428 $isEnabled = $this->parseCountryTaxEnabledValue($meta->meta_value);
429 $this->countryEnabledCache[$normalizedCountryCode] = $isEnabled;
430
431 return $isEnabled;
432 }
433
434 public function setTaxEnabledForCountry(string $countryCode, bool $enabled): void
435 {
436 $normalizedCountryCode = $this->normalizeTaxStatusCountryCode($countryCode);
437 $metaKey = $this->getCountryTaxEnabledMetaKey($normalizedCountryCode);
438
439 Meta::query()
440 ->where('meta_key', $metaKey)
441 ->where('object_type', 'tax')
442 ->delete();
443
444 if (!$enabled) {
445 Meta::query()->create([
446 'meta_key' => $metaKey,
447 'meta_value' => ['enabled' => 0],
448 'object_type' => 'tax'
449 ]);
450 }
451
452 $this->countryEnabledCache[$normalizedCountryCode] = $enabled;
453 }
454
455 private function parseCountryTaxEnabledValue($metaValue): bool
456 {
457 $enabledValue = is_array($metaValue) ? Arr::get($metaValue, 'enabled', 1) : $metaValue;
458
459 return intval($enabledValue) === 1;
460 }
461
462
463 public function groupTaxRatesByGroup($taxRates): array
464 {
465 $grouped = [];
466
467 foreach ($taxRates as $rate) {
468 $localization = App::localization();
469 $continent = $localization->continents($rate->group);
470 $groupName = Arr::get($continent, 'name') ?? __('Other', 'fluent-cart');
471 $countryCode = $rate->country;
472 $countryName = AddressHelper::getCountryNameByCode($countryCode);
473
474 // Initialize group
475 if (!isset($grouped[$groupName])) {
476 $grouped[$groupName] = [
477 'group_name' => $groupName,
478 'group_code' => $rate->group,
479 'countries' => [],
480 'total_countries' => 0
481 ];
482 }
483
484 // Initialize country
485 if (!isset($grouped[$groupName]['countries'][$countryCode])) {
486 $grouped[$groupName]['countries'][$countryCode] = [
487 'country_code' => $countryCode,
488 'country_name' => $countryName,
489 'rates' => [],
490 'total_rates' => 0
491 ];
492 }
493
494 // Add rate
495 $grouped[$groupName]['countries'][$countryCode]['rates'][] = [
496 'class_id' => $rate->class_id,
497 'name' => $rate->name,
498 'rate' => $rate->rate,
499 'for_shipping' => $rate->for_shipping
500 ];
501 }
502
503 // Format result
504 $result = [];
505 foreach ($grouped as $groupName => $groupData) {
506 $countries = [];
507 foreach ($groupData['countries'] as $countryData) {
508 $countryData['total_rates'] = count($countryData['rates']);
509 $countries[] = $countryData;
510 }
511
512 $result[] = [
513 'group_name' => $groupName,
514 'group_code' => $groupData['group_code'],
515 'countries' => $countries,
516 'total_countries' => count($countries)
517 ];
518 }
519
520 return $result;
521 }
522
523 public function getCountryConfiguration(string $countryCode)
524 {
525 $config = Arr::get($this->config, 'countries.' . $countryCode);
526
527 if(!empty($config)){
528 return $config;
529 }
530
531 $group = Arr::get($this->rates, $countryCode . '.group');
532 return Arr::get($this->config, 'continents.' . $group);
533 }
534
535 private function buildDefaultRateLabel(string $countryCode): string
536 {
537 $countryName = preg_replace('/\s*\([^)]+\)$/', '', AddressHelper::getCountryNameByCode($countryCode));
538 /* translators: %1$s: country code (e.g. DE), %2$s: country name (e.g. Germany), %3$s: tax class type (e.g. Standard) */
539 return sprintf(__('%1$s VAT - %2$s - %3$s rate', 'fluent-cart'), $countryCode, $countryName, __('Standard', 'fluent-cart'));
540 }
541
542 public function resetEuRates($classSlug = 'standard'): void
543 {
544 $taxClasses = TaxClass::query()->get()->keyBy('slug');
545
546 $targetClass = $taxClasses->get($classSlug);
547 if (!$targetClass) {
548 return;
549 }
550
551 $existing = TaxRate::query()
552 ->where('group', 'EU')
553 ->where('class_id', $targetClass->id)
554 ->where(function ($q) {
555 $q->whereNull('state')->orWhere('state', '');
556 })
557 ->where(function ($q) {
558 $q->whereNull('city')->orWhere('city', '');
559 })
560 ->where(function ($q) {
561 $q->whereNull('postcode')->orWhere('postcode', '');
562 })
563 ->get(['id', 'country', 'class_id', 'name', 'rate', 'for_shipping'])
564 ->keyBy(function ($row) {
565 return $row->country . ':' . $row->class_id;
566 });
567
568 $typeLabels = [
569 'standard' => __('Standard', 'fluent-cart'),
570 'reduced' => __('Reduced', 'fluent-cart'),
571 'zero' => __('Zero', 'fluent-cart'),
572 ];
573
574 $toCreate = [];
575 $toUpdate = [];
576
577 foreach ($this->rates as $countryCode => $data) {
578 if (Arr::get($data, 'group') !== 'EU' || empty($data['tax'])) {
579 continue;
580 }
581
582 foreach ($data['tax'] as $rateData) {
583 // Only reset country-level rates; skip state-specific entries (e.g. ES-Mainland, ES-Canary)
584 if (!empty($rateData['state'])) {
585 continue;
586 }
587
588 $typeKey = $rateData['type'] ?? 'standard';
589 if ($typeKey !== $classSlug) {
590 continue;
591 }
592 $taxClass = $taxClasses->get($typeKey);
593 if (!$taxClass) {
594 continue;
595 }
596
597 $name = $rateData['name'] ?? (
598 $typeKey === 'standard'
599 ? $this->buildDefaultRateLabel($countryCode)
600 : $countryCode . ' ' . ($typeLabels[$typeKey] ?? ucfirst($typeKey)) . ' Tax'
601 );
602 $rate = $rateData['rate'];
603 $key = $countryCode . ':' . $taxClass->id;
604
605 if ($existing->has($key)) {
606 $existingRow = $existing->get($key);
607 $shippingIsWrong = $existingRow->for_shipping !== null;
608 if ($existingRow->name !== $name || (float) $existingRow->rate !== (float) $rate || $shippingIsWrong) {
609 $toUpdate[] = ['id' => $existingRow->id, 'name' => $name, 'rate' => $rate, 'for_shipping' => null];
610 }
611 } else {
612 $toCreate[] = [
613 'country' => $countryCode,
614 'group' => 'EU',
615 'class_id' => $taxClass->id,
616 'state' => '',
617 'city' => '',
618 'name' => $name,
619 'rate' => $rate,
620 'is_compound' => 0,
621 'for_order' => 0,
622 'priority' => 0,
623 ];
624 }
625 }
626 }
627
628 if (empty($toCreate) && empty($toUpdate)) {
629 return;
630 }
631
632 $db = App::db();
633 try {
634 $db->beginTransaction();
635 if (!empty($toCreate)) {
636 TaxRate::query()->insert($toCreate);
637 }
638 if (!empty($toUpdate)) {
639 (new Batch())->update(new TaxRate(), $toUpdate, 'id');
640 }
641 $db->commit();
642 } catch (\Exception $e) {
643 $db->rollBack();
644 throw $e;
645 }
646 }
647
648 public static function getProductOverrideById($overrideId)
649 {
650 return Meta::query()
651 ->where('id', $overrideId)
652 ->where('object_type', 'tax_override')
653 ->where('meta_key', 'product_category_override')
654 ->first();
655 }
656
657 public static function clearShippingOverrideById($taxRateId)
658 {
659 TaxRate::query()->findOrFail($taxRateId);
660
661 // wpdb->prepare() converts PHP null bindings to '' (empty string), which MySQL
662 // coerces to 0 on TINYINT columns rather than NULL. Expression inlines NULL
663 // directly into the SQL, bypassing the binding path.
664 TaxRate::query()->where('id', $taxRateId)->update(['for_shipping' => new Expression('NULL')]);
665 }
666
667 // EU VAT registration helpers — stored in fct_meta, keyed by country code.
668
669 public function getEuVatRegistrations()
670 {
671 $rows = Meta::query()
672 ->where('object_type', 'eu_vat_registration')
673 ->get();
674
675 $result = [];
676 foreach ($rows as $row) {
677 $result[] = (array) $row->meta_value;
678 }
679 return $result;
680 }
681
682 public function getEuVatRegistration($country)
683 {
684 $row = Meta::query()
685 ->where('object_type', 'eu_vat_registration')
686 ->where('meta_key', strtoupper($country))
687 ->first();
688
689 return $row ? (array) $row->meta_value : null;
690 }
691
692 public function saveEuVatRegistration($country, $data)
693 {
694 Meta::query()->updateOrCreate(
695 ['object_type' => 'eu_vat_registration', 'meta_key' => strtoupper($country)],
696 ['meta_value' => $data, 'object_id' => 0]
697 );
698 }
699
700 public function deleteEuVatRegistration($country)
701 {
702 Meta::query()
703 ->where('object_type', 'eu_vat_registration')
704 ->where('meta_key', strtoupper($country))
705 ->delete();
706 }
707 }
708