Apply a VAT rate on a product with Drupal Commerce 2

A calculator

Drupal Commerce 2 now allows you to manage the various taxes and VAT to apply to an online store, regardless of its country and their respective rules in this area. Most of the contributed modules managing these elements on Commerce 1.x are therefore no longer necessary. Let's find out how to use the Drupal Commerce 2.x Resolver concept to set the VAT rate to apply to different products.

Creating a Tax Type

Before determining which VAT rate we wish to apply to this product or that product type, we must first configure the tax type that will be available for our online store.

Drupal commerce now includes in its core all the management and tax detection stuff. And it also includes taxes types already set, such as the (complex) European Union VAT, including the different rates applicable per country.

Drupal commerce TVA en France

 

Thus the addition of a tax is done in a few clicks. Just create a tax type, and select the Plugin corresponding to the European Union VAT (in our case), and voila.

Création d'un type de taxe

 

And to determine the VAT rates applicable to the online store, simply set your online store by specifying for which country it will apply the tax rules.

Drupal commerce store tax settings

Here by specifying France, the VAT rates that will be automatically associated with the online store will be those of the same country. After this very brief introduction to the initial setting up of a Drupal Commerce 2.x online store, let us come to the heart of the subject namely determining the VAT rate to apply depending on the type of product.

The concept of Drupal Commerce 2 Resolver

Drupal Commerce 2.x uses the concept of Resolver, which is neither more nor less than service collectors. To dynamically determine (and also very easily alterable) the tax rate applicable to a product, the order type corresponding to a product, the checkout flow type corresponding to an order, the price corresponding to a product, etc.

In short for each of these actions / reactions we have a service collector that will collect all the services of a certain tag, ordered by priority and then test them one by one, until a service returns a value. And if simply no service is present (or does not return value), then the Resolver implemented by default by Drupal commerce core will play its role.

Thus each declared Resolver service must implement a mandatory resolve() method, which the service collector (or ChainTaxResolver) will evaluate.

/**
 * {@inheritdoc}
 */
public function resolve(TaxZone $zone, OrderItemInterface $order_item, ProfileInterface $customer_profile) {
  $result = NULL;
  foreach ($this->resolvers as $resolver) {
    $result = $resolver->resolve($zone, $order_item, $customer_profile);
    if ($result) {
      break;
    }
  }

  return $result;
}

And as far as the VAT rate is concerned, Drupal Commerce implements a default Tax Resolver by means of this below service whose priority is set to -100.

services:
  commerce_tax.default_tax_rate_resolver:
    class: Drupal\commerce_tax\Resolver\DefaultTaxRateResolver
    tags:
      - { name: commerce_tax.tax_rate_resolver, priority: -100 }

This service determines the tax rate fairly simply.

/**
 * Returns the tax zone's default tax rate.
 */
class DefaultTaxRateResolver implements TaxRateResolverInterface {

  /**
   * {@inheritdoc}
   */
  public function resolve(TaxZone $zone, OrderItemInterface $order_item, ProfileInterface $customer_profile) {
    $rates = $zone->getRates();
    // Take the default rate, or fallback to the first rate.
    $resolved_rate = reset($rates);
    foreach ($rates as $rate) {
      if ($rate->isDefault()) {
        $resolved_rate = $rate;
        break;
      }
    }
    return $resolved_rate;
  }

}

It simply returns a tax rate that is set by default by the corresponding Plugin, or if no rate is set by default, simply the first rate of those corresponding to the tax zone.

This is a basic rule, which can very easily be sufficient for an e-commerce solution that only sells products whose VAT is the default of the country concerned.

But if several VAT rates are eligible according to the product (reduced VAT rate for services, VAT rate corresponding to cultural products, etc.), then we can very easily determine which VAT rate to apply according to rules that can rely on any product's attributes, or even the customer profile.

Determine a VAT rate with Drupal Commerce 2

To vary an applicable VAT rate according to a product, or a product type, we just need to implement a service that will declare the trade_tax.tax_rate_resolver tag with a priority at least higher than the default TaxResolver provided by Drupal Commerce.

Declare this service.

services:
  my_module.product_tax_resolver:
    class: Drupal\my_module\Resolver\ProductTaxResolver
    tags:
      - { name: commerce_tax.tax_rate_resolver, priority: 100 }

We give it a priority of 100 so that it is evaluated before the default Resolver.

For then, we can evaluate the tax rate to be applied using the resolve() method.

<?php

namespace Drupal\my_module\Resolver;

use Drupal\commerce_product\Entity\ProductInterface;
use Drupal\commerce_product\Entity\ProductVariationInterface;
use Drupal\commerce_order\Entity\OrderItemInterface;
use Drupal\commerce_tax\Resolver\TaxRateResolverInterface;
use Drupal\commerce_tax\TaxZone;
use Drupal\profile\Entity\ProfileInterface;

/**
 * Returns the tax zone's default tax rate.
 */
class ProductTaxResolver implements TaxRateResolverInterface {

  /**
   * {@inheritdoc}
   */
  public function resolve(TaxZone $zone, OrderItemInterface $order_item, ProfileInterface $customer_profile) {
    $rates = $zone->getRates();

    // Get the purchased entity.
    $item = $order_item->getPurchasedEntity();
    
    // Get the corresponding product.
    $product = $item->getProduct();

    $product_type = $product->bundle();

    // Take a rate depending on the product type.
    switch ($product_type) {
      case 'book':
        $rate_id = 'reduced';
        break;
      default:
        // The rate for other product type can be resolved using the default tax
        // rate resolver.
        return NULL;
    }

    foreach ($rates as $rate) {
      if ($rate->getId() == $rate_id) {
        return $rate;
      }
    }

    // If no rate has been found, let's others resolvers try to get it.
    return NULL;
  }

}

In this example, we simply check the associated product type, and if it is a book product (a book eligible for the 5.5% VAT rate), then we simply return the corresponding VAT rate. And for all other products, eligible for the default VAT rate, we let the default Resolver do its work.

We can see here that we could have just as well evaluated a VAT rate based for example on an attribute of the product, and not globally for a product type. This service collector based approach allows us to implement business rules that can be complex in a few lines in an extremely simple and modular way.

To your Resolver !

As mentioned above, Resolver are used extensively on Drupal Commerce 2.x. You want to vary the checkout flow according to the products of an order. Take a Resolver. You want to calculate the price of a product dynamically, according to specific business rules, draw another Resolver. You want to dynamically vary the order type by product, yet another Resolver.

The Resolver will open perspectives to see contributed modules blooming whose primary purpose will be to offer a configuration interface on predefined business rules. But we can now implement a Resolver very simply for business rules that can be very complex, and without having to leave the heavy artillery.

Fire! 

 

Commentaires

Soumis par Joan (non vérifié) le 05/10/2018 à 18:21 - Permalien

Hi, I'm trying to set up taxes for a site, and I wonder what is the $rate_id of my spanish VAT super reduced type...

I think reduced vat type has $rate_id=reduced
from this example:
switch ($product_type) {
case 'book':
$rate_id = 'reduced';
break;
In fact, if I inspect the code of European VAT types admin page, I get:
<tr data-drupal-selector="edit-configuration-european-union-vat-rates-table-20" class="odd">
<td>Super Reduced</td>
<td>4% (Since gen. 1st 1995)</td>
</tr>
18:18
Then, just, where is located the "european union vat rates table"?

Soumis par Jan Henke (non vérifié) le 24/04/2019 à 17:54 - Permalien

HI there.
I´m just trying to find a solution for altering the custom Tax types for products. In your example we have the rate ID´s which are part of the EU-Vat plugin. But what do I have to adjust when trying to use a custom tax type for a product type? when I have a Tax type with machine name reduced_tax maybe? If you have any idea on how to handle this it would be great. best regards, Jan

Soumis par Kazu Hodota (non vérifié) le 19/10/2019 à 12:24 - Permalien

Hi,
I would like to add Asia / Japan Tax type at Drupal 8 Commerce 8.x-2.13 now, in Japan, Tax is "Standard 10%" and "Reduced 8%" from this month. I think this Tax style is same as Germany, so I made AsiaVat.php file from EuropeanUnionVat.php file. I attached AsiaVat.php,

<?php

namespace Drupal\commerce_tax\Plugin\Commerce\TaxType;

use Drupal\commerce_order\Entity\OrderItemInterface;
use Drupal\commerce_tax\TaxableType;
use Drupal\commerce_tax\TaxZone;
use Drupal\Core\Form\FormStateInterface;
use Drupal\profile\Entity\ProfileInterface;

/**
* Provides the Aisa VAT tax type.
*
* @CommerceTaxType(
* id = "asia_vat",
* label = "Asia VAT",
* )
*/
class AsiaVat extends LocalTaxTypeBase {

/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form = parent::buildConfigurationForm($form, $form_state);
$form['rates'] = $this->buildRateSummary();
// The Intra-Community rate is special and should not be in the summary.
unset($form['rates']['table']['ic']);
unset($form['rates']['table']['ic|zero']);
// Replace the phrase "tax rates" with "VAT rates" to be more precise.
$form['rates']['#markup'] = $this->t('The following VAT rates are provided:');

return $form;
}

/**
* {@inheritdoc}
*/
protected function resolveZones(OrderItemInterface $order_item, ProfileInterface $customer_profile) {
$zones = $this->getZones();
/** @var \Drupal\address\AddressInterface $customer_address */
$customer_address = $customer_profile->get('address')->first();
$customer_country = $customer_address->getCountryCode();
$customer_zones = array_filter($zones, function ($zone) use ($customer_address) {
/** @var \Drupal\commerce_tax\TaxZone $zone */
return $zone->match($customer_address);
});
if (empty($customer_zones)) {
// The customer is not in the EU.
return [];
}
$order = $order_item->getOrder();
$store = $order->getStore();
$store_address = $store->getAddress();
$store_country = $store_address->getCountryCode();
$store_zones = array_filter($zones, function ($zone) use ($store_address) {
/** @var \Drupal\commerce_tax\TaxZone $zone */
return $zone->match($store_address);
});
$store_registration_zones = array_filter($zones, function ($zone) use ($store) {
/** @var \Drupal\commerce_tax\TaxZone $zone */
return $this->checkRegistrations($store, $zone);
});

$customer_tax_number = '';
if (!$customer_profile->get('tax_number')->isEmpty()) {
/** @var \Drupal\commerce_tax\Plugin\Field\FieldType\TaxNumberItemInterface $tax_number_item */
$tax_number_item = $customer_profile->get('tax_number')->first();
if ($tax_number_item->checkValue('asia_vat')) {
$customer_tax_number = $tax_number_item->value;
}
}
// Since january 1st 2015 all digital goods sold to EU customers
// must use the customer zone. For example, an ebook sold
// to Germany needs to have German VAT applied.
$taxable_type = $this->getTaxableType($order_item);
$year = $this->getCalculationDate($order)->format('Y');
$is_digital = $taxable_type == TaxableType::DIGITAL_GOODS && $year >= 2015;
if (empty($store_zones) && !empty($store_registration_zones)) {
// The store is not in the EU but is registered to collect VAT for
// digital goods.
$resolved_zones = [];
if ($is_digital) {
$resolved_zones = $customer_tax_number ? [$zones['ic']] : $customer_zones;
}
}
elseif ($customer_tax_number && $customer_country != $store_country) {
// Intra-community supply (B2B).
$resolved_zones = [$zones['ic']];
}
elseif ($is_digital) {
$resolved_zones = $customer_zones;
}
else {
// Physical products use the origin zone, unless the store is
// registered to pay taxes in the destination zone. This is required
// when the total yearly transactions breach the defined threshold.
// See http://www.vatlive.com/eu-vat-rules/vat-registration-threshold/
$resolved_zones = $store_zones;
$customer_zone = reset($customer_zones);
if ($this->checkRegistrations($store, $customer_zone)) {
$resolved_zones = $customer_zones;
}
}

return $resolved_zones;
}

/**
* {@inheritdoc}
*/
public function buildZones() {
// Avoid instantiating the same labels dozens of times.
$labels = [
'standard' => $this->t('Standard'),
'intermediate' => $this->t('Intermediate'),
'reduced' => $this->t('Reduced'),
'second_reduced' => $this->t('Second Reduced'),
'super_reduced' => $this->t('Super Reduced'),
'special' => $this->t('Special'),
'zero' => $this->t('Zero'),
'vat' => $this->t('VAT'),
];

$zones = [];

$zones['jp'] = new TaxZone([
'id' => 'jp',
'label' => $this->t('Japan'),
'display_label' => $labels['vat'],
/* 'territories' => [
// Germany without Heligoland and Büsingen.
['country_code' => 'DE', 'excluded_postal_codes' => '27498, 78266'],
// Austria (Jungholz and Mittelberg).
['country_code' => 'AT', 'included_postal_codes' => '6691, 6991:6993'],
],
*/
'rates' => [
[
'id' => 'standard',
'label' => $labels['standard'],
'percentages' => [
['number' => '0.1', 'start_date' => '2019-10-01'],
],
'default' => TRUE,
],
[
'id' => 'reduced',
'label' => $labels['reduced'],
'percentages' => [
['number' => '0.08', 'start_date' => '2019-10-01'],
],
],
],
]);
return $zones;
}
}

-------

I think it needs more customize source files or something, If possible, please let me know. Thank you for your support.

Soumis par Jorge (non vérifié) le 14/04/2021 à 18:53 - Permalien

Spanish taxes depends on the product but also on the customer. A product could have one of this taxes: normal (21%), reduced (10%) and super-reduced (4%). This is managed by a field on the variation type and it works good. But there is another variable that has to be considered. The customer could has a type of bussines that implies to add another additional tax to the same product. They are named "surcharge" and they are normal (5.2%), reduced (1.4%), super-reduced (0.5%). It is calculated in the next form:
Product price without taxes: 100 €
Normal taxes (21%): 100 * (21 / 100) = 21 €
Surcharge (5.2%): 100 * (5.2 / 100) = 5.2 €
Order total: 100 + 21 + 5.2 = 126.2€

Implementing a class ProductTaxResolver implements TaxRateResolverInterface let me add one tax but I need to add more than one, depends on a customer field (custom user field). How could I do it? Could I add another tax with a custom tax condition?

Ajouter un commentaire