| @@ -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 | } |