PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Services/Tax/TaxManager.php +302 -25 1.3.28 → 1.6.5 View file →
@@ -4,10 +4,13 @@
4 4
5 5
6 6 use FluentCart\App\App;
7 7 use FluentCart\Api\StoreSettings;
8 +use FluentCart\App\Models\Meta;
8 9 use FluentCart\App\Models\TaxRate;
9 10 use FluentCart\App\Models\TaxClass;
11 +use FluentCart\App\Models\BatchQuery\Batch;
12 +use FluentCart\Framework\Database\Query\Expression;
10 13 use FluentCart\Framework\Support\Arr;
11 14 use FluentCart\Framework\Support\Str;
12 15 use FluentCart\App\Helpers\AddressHelper;
13 16 use FluentCart\App\Modules\Tax\TaxModule;
@@ -37,13 +40,18 @@
37 40 * @var array
38 41 */
39 42 private array $descriptionMap = [];
40 43
44 + /**
45 + * @var array
46 + */
47 + private array $countryEnabledCache = [];
48 +
41 49 /** ISO country codes whose rates are stored under a parent country with state=<code>. */
42 50 private array $parentCountryMap = [
43 - 'GP' => 'FR', // Guadeloupe
44 - 'MQ' => 'FR', // Martinique
45 - 'RE' => 'FR', // Réunion
51 + //'GP' => 'FR', // Guadeloupe
52 + //'MQ' => 'FR', // Martinique
53 + //'RE' => 'FR', // Réunion
46 54 ];
47 55
48 56 /**
49 57 * Private constructor to prevent direct instantiation
@@ -221,14 +229,17 @@
221 229
222 230
223 231 $ratesMap[] = [
224 232 'country' => $country,
225 - 'name' => $rate['name'] ?? $country . ' ' . $taxClassLabels[$typeKey] . ' Tax',
233 + 'name' => $rate['name'] ?? (
234 + ($data['group'] ?? '') === 'EU' && $typeKey === 'standard'
235 + ? $this->buildDefaultRateLabel($country)
236 + : $country . ' ' . ($taxClassLabels[$typeKey] ?? ucfirst($typeKey)) . ' Tax'
237 + ),
226 238 'class_id' => $taxClassIds[$typeKey],
227 239 'rate' => $rate['rate'],
228 240 'is_compound' => $compound ? 1 : 0,
229 241 'group' => $data['group'] ?? '',
230 - 'for_shipping' => null,
231 242 'state' => $rate['state'] ?? '',
232 243 'city' => $rate['city'] ?? '',
233 244 ];
234 245 //$this->rates[$country]['tax'][$key]['tax_class_id'] = $idMap[$key] ?? null;
@@ -328,8 +339,128 @@
328 339 }
329 340 return ['country' => $country, 'state' => $state];
330 341 }
331 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 +
332 463 public function groupTaxRatesByGroup($taxRates): array
333 464 {
334 465 $grouped = [];
335 466
@@ -400,31 +531,177 @@
400 531 $group = Arr::get($this->rates, $countryCode . '.group');
401 532 return Arr::get($this->config, 'continents.' . $group);
402 533 }
403 534
404 - public function calculateTotalCartTax()
535 + private function buildDefaultRateLabel(string $countryCode): string
405 536 {
406 - $getCart = \FluentCart\App\Helpers\CartHelper::getCart();
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 + }
407 541
408 - $taxSettings = (new TaxModule())->getSettings();
542 + public function resetEuRates($classSlug = 'standard'): void
543 + {
544 + $taxClasses = TaxClass::query()->get()->keyBy('slug');
409 545
410 - if (Arr::get($taxSettings, 'tax_calculation_basis') === 'store') {
411 - $country = (new StoreSettings())->get('store_country') ?? '';
412 - $state = (new StoreSettings())->get('store_state') ?? null;
413 - $city = (new StoreSettings())->get('store_city') ?? null;
414 - $postCode = (new StoreSettings())->get('store_postcode') ?? null;
415 - } else if (Arr::get($taxSettings, 'tax_calculation_basis') === 'billing') {
416 - $country = Arr::get($getCart, 'checkout_data.form_data.billing_country') ?? '';
417 - $state = Arr::get($getCart, 'checkout_data.form_data.billing_state') ?? null;
418 - $city = Arr::get($getCart, 'checkout_data.form_data.billing_city') ?? null;
419 - $postCode = Arr::get($getCart, 'checkout_data.form_data.billing_postcode') ?? null;
420 - } else {
421 - $country = Arr::get($getCart, 'checkout_data.form_data.shipping_country') ?? '';
422 - $state = Arr::get($getCart, 'checkout_data.form_data.shipping_state') ?? null;
423 - $city = Arr::get($getCart, 'checkout_data.form_data.shipping_city') ?? null;
424 - $postCode = Arr::get($getCart, 'checkout_data.form_data.shipping_postcode') ?? null;
546 + $targetClass = $taxClasses->get($classSlug);
547 + if (!$targetClass) {
548 + return;
425 549 }
426 550
427 - $calculator = TaxCalculator::calculateTaxForCart($getCart, $country, $state, $city, $postCode);
428 - return $calculator->getTotalTax();
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();
429 706 }
430 707 }