Creating a Drupal 8 Plugin to Handle State, City, and County Sales Tax

Steps taken to create a custom plugin for Drupal 8 (D8)

These are notes taken to document work (analysis, design, and implementation) to create a plugin to create a custom tax type for drupal commerce 2.0 that has the ability to use the SmartyStreetsAPI service plugin in order to lookup the county and apply sales tax appropriately for Colorado.

The work was completed using a Development, Test, and Production environment.  The development environment is installed on the development workstation using an IDE called Eclipse and XDebug to debug the web application running on apache/MySQL.  The test environment is installed on a shared hosting environment.  The production environment was also installed on a shared hosting environment.  All environments used their own dedicated backend MySQL databases/servers and separate file systems.

General D8 Plugin Information:

Web sites providing details on how the plugin component of D8 works can be found at the following web locations:

  1. https://www.drupal.org/docs/8/api/plugin-api/plugin-api-overview 
  2. https://drupalize.me/blog/201409/unravelling-drupal-8-plugin-system

There are multiple methods that D8 plugins are discovered by the system in order for them to be made available: StaticDiscovery, HookDiscovery, AnnotatedClassDiscovery, and YamlDiscovery.  More information on these methods can be found in the links above.

D8 Commerce 2.0 TaxType Plugin reference Information:

  1. ZoneTerritory object reference info can be found here: https://github.com/bojanz/address/blob/8.x-1.x/src/Element/ZoneTerritory.php
  2. addressItem object information can be found here: https://github.com/commerceguys/addressing/blob/master/src/AddressInterface.php

D8 module building reference Information:

Drupal.org has a good set of documents online that can help with this located at https://www.drupal.org/docs/8/creating-custom-modules.

Analysis of the Custom TaxType plugin included in the drupal commerce 2.0 Tax Module

The Custom TaxType module has just about everything needed, but does not have the ability to produce the proper sales tax for US states that have the rules requiring that a buyer be charged district/county tax if the seller business presence (aka nexus) is in the same county.  It is for these reasons that the Custom TaxType plugin will be used as a starting point and its code will be reused as much as possible in order to build a new "County" custom TaxType plugin from it.

The commerce tax module plugin manager leverages the DefaultPluginManager from Drupal Core.  This is seen in Line 13 in commerce\modules\tax\src\TaxTypeManager.php:

class TaxTypeManager extends DefaultPluginManager {

Since the commerce tax module leverages the DefaultPluginManager provided by D8 core and because there is no reference to another discovery method in the plugin manager (TaxTypeManager.php) it uses AnnotatedClassDiscovery methods and the PSR-4 standard to find the files that contains the definition of the plugin.  This is further supported by the fact that the Custom TaxType plugin (commerce\modules\tax\src\Plugin\Commerce\TaxType\Custom.php) has annotations in it which define the plugin for drupal commerce tax to find/use:

/**
 * Provides the Custom tax type.
 *
 * @CommerceTaxType(
 *   id = "custom",
 *   label = "Custom",
 * )
 */

 

Building the "County" Custom TaxType plugin for drupal commerce 2.0

This plugin is going to work very similarly (almost exactly) to the existing custom tax type plugin that is delivered with drupal commerce 2.0.  In order to make development easier, the custom tax type plugin will be copied and then modified to add in a feature to the territories area that allows for the sales tax to be applied properly based on the county of the county that the store (business nexus) is physically in as it relates to the county that the customer's shipping address is in so that if the customer happens to be in the same county as the store, the sales tax will be applied to account for this.

The county information will be looked up automatically (so that the customer does not have to put in this info) by using another service module that is already created for this purpose called SmartyStreetsAPI.

1. Create new module for the new "County" TaxType plugin:

There needs to be a new module that the plugin can be delivered with.  The new module created is called "commerce_tax_plus". 

  • Folder: commerce_tax_plus
    • File: commerce_tax_plus.info.yml
    • File: commerce_tax_plus.module
    • Folder: src
      • Folder: Plugin
        • Folder: Commerce
          • Folder: TaxType

1-A. Need to add in module dependency info

1-B. Need to add install info

1-C. Need to add readme info

2. Copy the plugin we are going to modify to become the new "County" custom TaxType plugin:

Copy the original custom.php file from the commerce tax module (commerce\modules\tax\src\Plugin\Commerce\TaxType\Custom.php)  and place it into the new module folder (commerce_tax_plus\src\Plugin\Commerce\TaxType).  Rename the file to "county.php" (commerce_tax_plus\src\Plugin\Commerce\TaxType\county.php).

3. Modifications to the newly copied plugin in order to build a "County" TaxType plugin from it (aka county.php):

This section deals with the modifications that were needed to make the base tax type plugin (aka custom.php) to make it into the new "County Tax Type Plugin"


3-A. Change the annotations to identify the new plugin properly:

/**
 * Provides the County tax type.
 *
 * @CommerceTaxType(
 *   id = "County",
 *   label = "County",
 * )
 */
 


3-B. Change the class name from "Custom" to "County":

from -

class Custom extends LocalTaxTypeBase {

to -

class County extends LocalTaxTypeBase {


3-C. The class extends the "LocalTaxTypeBase" object, and so we need to add a "use" statement above in the use area of the county.php file:

use Drupal\commerce_tax\Plugin\Commerce\TaxType\LocalTaxTypeBase;


3-D. Modify the submitConfigurationForm function

At this point, the new tax type plugin needed to be tested and while trying to test it out (adding an item to the shopping cart), it errors that needed to be resolved.  Due to the error, the submitConfigurationForm function needs to be changed so that percentage value saves as a string in the configuration (config table in DB) - commerce_tax.commerce_tax_type) when a new tax type is created using the new County tax type plugin.

3-D-i. Here are the details of the issues with the new "county" plugin that occurred during testing and the remediation (modifications) steps taken:

After modifying just the annotations, the class name, and the file name to create the base of the new tax type plugin, there is a problem that caused an error as soon as an item was added to the shopping cart. If the item was using the new tax type record that used the new tax type plugin (aka county.php)

The website encountered an unexpected error. Please try again later.
Drupal\Core\Entity\EntityStorageException: The provided number "0.043" must be a string, not a float. in Drupal\Core\Entity\Sql\SqlContentEntityStorage->save() (line 805 of core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php).

 

Looking at the backtrace on the site page did not provide much help other than to point to the stack that caused the error but it did not help much in tracking down the problem.  Debugging the site code in eclipse using xdebug in apache helped to see that the "percentage" value of the rate in the configuration array was a "float" value (shown with no quotes around the percentage value in the debugger - 0.043) in comparison to the built in/default custom tax type plugin that comes with drupal commerce 2.0 where this value was a "string" (shown with quotes surrounding the percentage - "0.043").  I traced this in the debugger all the way back to when it actually grabbed the value out of the config table in the database (commerce_tax.commerce_tax_type) and it appeared that it was actually getting this as a number from the database.

When comparing the values in the blob data within the config table in the database I saw that it was indeed stored in the database as a number for the new county tax plugin whereas it was a string in the case of the builtin custom tax type plugin:

3-D-ii. Builtin custom.php tax type plugin commerce_tax.commerce_tax_type config table blob db data:

a:8:{s:4:"uuid";s:36:"68256254-541c-44c7-b252-071a052abeb5";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:0:{}s:2:"id";s:27:"test_custom_plugin_tax_type";s:5:"label";s:27:"Test custom plugin tax type";s:6:"plugin";s:6:"custom";s:13:"configuration";a:5:{s:13:"display_label";s:3:"tax";s:5:"round";b:1;s:5:"rates";a:1:{i:0;a:3:{s:2:"id";s:36:"036bda99-d75c-46ed-a58f-0e212fb14792";s:5:"label";s:27:"test custom plugin tax rate";s:10:"percentage";s:5:"0.043";}}s:11:"territories";a:1:{i:0;a:2:{s:12:"country_code";s:2:"US";s:19:"administrative_area";s:2:"CO";}}s:17:"display_inclusive";b:1;}}

 

3-D-iii. New county.php tax type plugin commerce_tax.commerce_tax_type config table blob db data:

a:8:{s:4:"uuid";s:36:"8dfd1137-3b72-4dfa-b65a-55bff03a307e";s:8:"langcode";s:2:"en";s:6:"status";b:0;s:12:"dependencies";a:1:{s:6:"module";a:1:{i:0;s:17:"commerce_tax_plus";}}s:2:"id";s:13:"test_tax_type";s:5:"label";s:13:"Test County Tax Type";s:6:"plugin";s:6:"county";s:13:"configuration";a:5:{s:13:"display_label";s:3:"tax";s:5:"round";s:1:"1";s:5:"rates";a:1:{i:0;a:3:{s:2:"id";s:36:"2423f9a1-8453-4065-9336-2fc73e15e69a";s:5:"label";s:13:"Test County Tax Rate";s:10:"percentage";d:0.043;}}s:11:"territories";a:1:{i:0;a:2:{s:12:"country_code";s:2:"US";s:19:"administrative_area";s:2:"CO";}}s:17:"display_inclusive";b:1;}}

3-D-iv. SQL code to look at this part of the database is:

SELECT * FROM `dev`.`drupal_config`
WHERE (CONVERT(`collection` USING utf8) LIKE '%commerce_tax.commerce_tax_type%%'
OR CONVERT(`name` USING utf8) LIKE '%commerce_tax.commerce_tax_type%%'
OR CONVERT(`data` USING utf8) LIKE '%commerce_tax.commerce_tax_type%%')

(Note: the above SQL script code is is assuming that the DB name is "Dev" and the table has a prefix and therefore is called "drupal_config" -- these would need to be altered to be the actual DB name and table name)

With this new diagnostic information, the solution was to make sure that when the new tax type plugin was used to create a new tax type/rate to apply to a cart item, it needed to have the percentage value casted as a string type before it was saved.  

3-D-v. Alter the "submitConfigurationForm" function in the county.php tax type plugin file to make sure the percentage entered into the tax rate in the county tax type is saved as a string in the config (the altered function is listed below - bold characters are the alterations):

  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
    parent::submitConfigurationForm($form, $form_state);

    if (!$form_state->getErrors()) {
      $values = $form_state->getValue($form['#parents']);
      $this->configuration['display_label'] = $values['display_label'];
      $this->configuration['round'] = $values['round'];
      $this->configuration['rates'] = [];
      foreach (array_filter($values['rates']) as $rate) {
          $ratestr = $rate['percentage'];
          $ratestr=$ratestr/100;
          settype($ratestr,"string");

        $this->configuration['rates'][] = [
          'id' => $rate['rate']['id'],
          'label' => $rate['rate']['label'],
            'percentage' => $ratestr,
        ];
      }
      $this->configuration['territories'] = [];
      foreach (array_filter($values['territories']) as $territory) {
        $this->configuration['territories'][] = $territory['territory'];
      }
    }
  }

This code modification to the function above resolved the error message saying that the percentage had to be a string value.


3-E. Next steps still needed to be worked on/designed are the modification to add in a "Limit by County" checkbox in the Territory area of the plugin.

Add needed "use" statements under the namespace area at the top of the county.php file (next to all the other use statements):
use Drupal\commerce_order\Entity\OrderItemInterface;
use Drupal\profile\Entity\ProfileInterface;
use Drupal\SmartyStreetsAPI\Controller\SmartyStreetsAPIService;
use CommerceGuys\Addressing\AddressInterface;

Add to $APIService variable declaration at the top of the County class definition:
     /**
     * @var \Drupal\SmartyStreetsAPI\Controller\SmartyStreetsAPIService
     */
     protected $APIService;

Add to annotation on _construct function (Note: not sure if this is actually needed):
* {@inheritdoc}

Add argument/parameter to _construct function (within the parentheses "(....)"): 
     SmartyStreetsAPIService $APIService

Add code to bottom of the body of the _construct function (before the closing brace "}":
     $this->APIService = $APIService;

Add argument/parameter to create function (within the parentheses "(....)"):
     ContainerInterface $container)

Add to the body of the "return new static(" area within the create function body before the ending parentheses ");":
     $container->get('smartystreetsapi.service')
 

Add 2 new functions to county.php to handle the address lookup to SmartyStreetsAPI:

public function LookupValidAddress($street_address,$city,$state) {
    
$arrLookup = $this->APIService->LookupAddress($street_address,$city,$state);
    
if ($arrLookup['valid'] == 1) {
         
return $arrLookup['county'];
          }
    
else {
         
return 'error';
    
}
}

public function MatchCounty(AddressInterface $customer_address){
    
/** @var \Drupal\commerce_store\Resolver\StoreResolverInterface $resolver */
    
$resolver = \Drupal::service('commerce_store.default_store_resolver');
    
$store_street_address = $resolver->resolve()->getAddress()->getAddressLine1();
    
$store_city = $resolver->resolve()->getAddress()->getLocality();
    
$store_state = $resolver->resolve()->getAddress()->getAdministrativeArea();
    
$store_county = $this->LookupValidAddress($store_street_address,$store_city,$store_state);
    
$cust_street_address = $customer_address->getAddressLine1();
    
$cust_state = $customer_address->getAdministrativeArea();
    
$cust_city = $customer_address->getLocality();
    
$cust_county = $this->LookupValidAddress($cust_street_address, $cust_city, $cust_state);
    
if($store_county == $cust_county) {
         
return true;
    
}
    
else {
         
return false;
    
}
}

Override resolveZones function from LocalTaxTypeBase.php by putting it into county.php and alter it to  use SmartyStreetsAPI service lookup:

     /**
     * Resolves the tax zones for the given order item and customer profile.
     *
     * @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
     * The order item.
     * @param \Drupal\profile\Entity\ProfileInterface $customer_profile
     * The customer profile. Contains the address and tax number.
     *
     * @return \Drupal\commerce_tax\TaxZone[]
     * The tax zones.
     */
     protected function resolveZones(OrderItemInterface $order_item, ProfileInterface $customer_profile) {
          $customer_address = $customer_profile->get('address')->first();
          $resolved_zones = [];
          foreach ($this->getZones() as $zone) {
               if ($zone->match($customer_address)) {
                    if($this->MatchCounty($customer_address)){
                         $resolved_zones[] = $zone;
                    }
               }
          }
          return $resolved_zones;
     }

3-F. Detect invalid addresses and redirect back to the review step to provide an opportunity for the customer to edit the address submitted.

When an address is looked up with SmartyStreetsAPI, its checked to make sure its valid, and if its not valid, we need to tell the customer that its not a valid address and prompt the customer to re-enter the address. 

The array that is returned by the SmartyStreetsAPI to the LookupValidAddress function provides a value of a 1 or a 0 for the item called "valid" (1 = valid address, 0 = invalid address).

Redirecting the customer to the URL path of /checkout/[order number]/order_information will bring the customer back to the order info part of the checkout process.

Two options for this address validation are:
3-F-i. Use the TaxType plugin and add a validation step in the resolveZones function to throw a NeedsRedirectException when the address is found to be invalid similar to the following:
              Alter the MatchCounty function to detect an error condition when one of the addresses (Customer or Store) are invalid:
                   if($store_county == 'error')
                     {
                          return "store_error";
                     }

                     elseif($cust_county == 'error')
                     {

                          return "cust_error";
                     }

                     elseif($store_county == $cust_county)
                     {

                          return "yes";
                     }

                     else
                     {
                          return "no";
                     }


              Obtain the order ID of the current order in the resolveZones function:
                        $order = $order_item->getOrder()->id();

              Throw NeedsRedirectException in the resolveZones function:
                   throw new NeedsRedirectException(Url::fromRoute('commerce_checkout.form', [
         
              'commerce_order' => $order,
         
              'step' => 'order_information',
    
              ])->toString());

3-F-i. Build a CheckOutPane plugin

Create another folder in the commerce_tax_plus module for the new CheckOutPane plugin and a new file called "PayerAddressValidation.php".  This plugin will extend the default "PaymentInformation" that already exists. Here is the folder structure and where the file goes:

  • Folder: commerce_tax_plus
    • Folder: src
      • Folder: Plugin
        • Folder: Commerce
          • Folder: CheckOutPane
            • File: PayerAddressValidation.php

Contents of the new PayerAddressValidation.php CheckOutPane plugin:

<?php

namespace Drupal\commerce_tax_plus\Plugin\Commerce\CheckoutPane;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;

//This is the use statement for the PaymentInformation BASE plugin aliased as BasePaymentInformation:
use Drupal\commerce_payment\Plugin\Commerce\CheckoutPane\PaymentInformation as BasePaymentInformation;

//This is the use statement for the SmartyStreetsAPI service module:
use Drupal\SmartyStreetsAPI\Controller\SmartyStreetsAPIService;


/**
 * Provides the payment information plus pane.
 *
 * @CommerceCheckoutPane(
 *   id = "payment_info_plus",
 *   label = @Translation("Payment Info Plus"),
 *   default_step = "order_information",
 *  
 * )
 */

//PayerAddressValidation class extending the functionality in the PaymentInformation
//(aka BasePaymentInformation - see use statement above)
class PayerAddressValidation extends BasePaymentInformation {
    //Declaration of the $APIService variable to be used to connect to the SmartyStreetsAPI service module:
    /**
     * @var \Drupal\SmartyStreetsAPI\Controller\SmartyStreetsAPIService
     */
    protected $APIService;
   
    //Constructor function to setup the APIService and call the parent constructure function from the
    //CheckOutPaneBase plugin.
    /**
     * Constructs a new CheckoutPaneBase object.
     *
     * @param array $configuration
     *   A configuration array containing information about the plugin instance.
     * @param string $plugin_id
     *   The plugin_id for the plugin instance.
     * @param mixed $plugin_definition
     *   The plugin implementation definition.
     * @param \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface $checkout_flow
     *   The parent checkout flow.
     * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
     *   The entity type manager.
     */
    public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, EntityTypeManagerInterface $entity_type_manager, SmartyStreetsAPIService $APIService) {
        parent::__construct($configuration, $plugin_id, $plugin_definition, $checkout_flow, $entity_type_manager);
        $this->APIService = $APIService;
    }
   
    //create function that overrides the CheckOutBasePane plugin create function.
    /**
     * {@inheritdoc}
     */
    public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow = NULL) {
        return new static(
            $configuration,
            $plugin_id,
            $plugin_definition,
            $checkout_flow,
            $container->get('entity_type.manager'),
            $container->get('smartystreetsapi.service')      
            );
    }
   
    //This is the validatePaneForm function that overrides the one in the PaymentInformation plugin (aka BasePaymentInformation)
    //In this function is where the address that the customer entered is validated by calling the LookupValidAddress function
    //to check it via the SmartyStreetsAPI service plugin.  It will set an error state and send the user back to the
    //billing information screen to prompt the user to re-enter the customers address.
    /**
     * {@inheritdoc}
     */
    public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
        parent::validatePaneForm($pane_form, $form_state, $complete_form);
        $values = $form_state->getValue($pane_form['#parents']);
        $cust_address = $values['add_payment_method']['billing_information']['address'][0]['address']['address_line1'];
        $cust_city = $values['add_payment_method']['billing_information']['address'][0]['address']['locality'];
        $cust_state = $values['add_payment_method']['billing_information']['address'][0]['address']['administrative_area'];
        $cust_zip = $values['add_payment_method']['billing_information']['address'][0]['address']['postal_code'];
        $valid_address = $this->LookupValidAddress($cust_address, $cust_city, $cust_state);
        if($valid_address==0){
            $form_state->setErrorByName('billing_information', t('<strong><font color="red">Error: The address entered is not valid. Please input a valid address.</font></strong>'));
        }
    }

    //This is the LookupValidAddress function.  This function uses the SmartyStreetsAPI service to
    //validate the address entered by calling the services at smartystreets.com.
    //todo: add zip code into lookup (also requires smartystreetsAPI module update too)
    public function LookupValidAddress($street_address,$city,$state) {
        $arrLookup = $this->APIService->LookupAddress($street_address,$city,$state);
        if ($arrLookup['valid'] == 1) {
            return 1;
        }
        else {
            return 0;
        }
    }
   
}

3-G. Add any error handling needed

Category