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.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
← All changes | app/Services/Tax/TaxManager.php +318 -22 1.3.21 → 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;
@@ -38,8 +41,20 @@
38 41 */
39 42 private array $descriptionMap = [];
40 43
41 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 + /**
42 57 * Private constructor to prevent direct instantiation
43 58 */
44 59 private function __construct()
45 60 {
@@ -214,14 +229,17 @@
214 229
215 230
216 231 $ratesMap[] = [
217 232 'country' => $country,
218 - '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 + ),
219 238 'class_id' => $taxClassIds[$typeKey],
220 239 'rate' => $rate['rate'],
221 240 'is_compound' => $compound ? 1 : 0,
222 241 'group' => $data['group'] ?? '',
223 - 'for_shipping' => null,
224 242 'state' => $rate['state'] ?? '',
225 243 'city' => $rate['city'] ?? '',
226 244 ];
227 245 //$this->rates[$country]['tax'][$key]['tax_class_id'] = $idMap[$key] ?? null;
@@ -308,9 +326,141 @@
308 326
309 327 return $groupedTaxRates;
310 328 }
311 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 + }
312 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 +
313 463 public function groupTaxRatesByGroup($taxRates): array
314 464 {
315 465 $grouped = [];
316 466
@@ -381,31 +531,177 @@
381 531 $group = Arr::get($this->rates, $countryCode . '.group');
382 532 return Arr::get($this->config, 'continents.' . $group);
383 533 }
384 534
385 - public function calculateTotalCartTax()
535 + private function buildDefaultRateLabel(string $countryCode): string
386 536 {
387 - $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 + }
388 541
389 - $taxSettings = (new TaxModule())->getSettings();
542 + public function resetEuRates($classSlug = 'standard'): void
543 + {
544 + $taxClasses = TaxClass::query()->get()->keyBy('slug');
390 545
391 - if (Arr::get($taxSettings, 'tax_calculation_basis') === 'store') {
392 - $country = (new StoreSettings())->get('store_country') ?? '';
393 - $state = (new StoreSettings())->get('store_state') ?? null;
394 - $city = (new StoreSettings())->get('store_city') ?? null;
395 - $postCode = (new StoreSettings())->get('store_postcode') ?? null;
396 - } else if (Arr::get($taxSettings, 'tax_calculation_basis') === 'billing') {
397 - $country = Arr::get($getCart, 'checkout_data.form_data.billing_country') ?? '';
398 - $state = Arr::get($getCart, 'checkout_data.form_data.billing_state') ?? null;
399 - $city = Arr::get($getCart, 'checkout_data.form_data.billing_city') ?? null;
400 - $postCode = Arr::get($getCart, 'checkout_data.form_data.billing_postcode') ?? null;
401 - } else {
402 - $country = Arr::get($getCart, 'checkout_data.form_data.shipping_country') ?? '';
403 - $state = Arr::get($getCart, 'checkout_data.form_data.shipping_state') ?? null;
404 - $city = Arr::get($getCart, 'checkout_data.form_data.shipping_city') ?? null;
405 - $postCode = Arr::get($getCart, 'checkout_data.form_data.shipping_postcode') ?? null;
546 + $targetClass = $taxClasses->get($classSlug);
547 + if (!$targetClass) {
548 + return;
406 549 }
407 550
408 - $calculator = TaxCalculator::calculateTaxForCart($getCart, $country, $state, $city, $postCode);
409 - 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();
410 706 }
411 707 }