Building a United States Sales Tax Calculator: A TDD Approach

How test-driven development ensured our tax calculator always adheres to all of the Amazon Laws for online orders

by Jim Rottinger, Commerce Engineer @ Weebly

When it comes to collecting tax on online orders, the rules that merchants have to abide by these days are complicated. Due to a set of laws being called the Amazon Laws, whether or not an online order is taxable or not depends on a number of factors, including destination, shipping origin, and tax nexus. And because these laws are imposed at the state level, each state has a different set of rules to follow. It gets even more complex when an order has to ship over state lines.

In the early days of our eCommerce platform at Weebly, we simply had a system to manually enter in the tax rates you want to apply and where to apply them. This worked fine, but it left it up to our users to research for themselves how they should be charging tax, and help with setting up taxes became one of our largest drivers of support requests. So we wanted to do better, and that meant building a system that can automatically calculate taxes on orders.

This created a very interesting software design problem and, in this post, I will be doing a deep dive into the technical approach and implementation that went into creating the automatic tax calculator. In addition to covering the topic of online sales tax, it will serve as a case study for approaching a problem with a test driven development (TDD) mindset.

United States Sales Tax Laws

Before getting into the technical approach, it is worthwhile to quickly go over the sales tax laws that our service will have to abide by.

Nexus — In the United States, a business is only required to collect taxes in states that they have a physical presence in. A physical presence can be constituted by an office, store location, warehouse, or anything else of that like.

— In the United States, a business is only required to collect taxes in states that they have a physical presence in. A physical presence can be constituted by an office, store location, warehouse, or anything else of that like. Destination vs. Origin — If an order is crossing state lines, or even regional lines, should the tax rate at the destination location or the origin location be applied to the order? It turns out that states have different rules for determining this. Some states are origin-based, meaning the tax rate at the origin location should be applied, while others are destination-based.

— If an order is crossing state lines, or even regional lines, should the tax rate at the destination location or the origin location be applied to the order? It turns out that states have different rules for determining this. Some states are origin-based, meaning the tax rate at the origin location should be applied, while others are destination-based. Interstate vs. Intrastate — Believe it or not, a state can be both destination-based and origin-based conditional upon whether the order is shipping interstate or intrastate. Interstate means it is crossing state lines and intrastate means it is shipping to a location within the same state. A state can be intrastate origin-based but interstate destination-based.

— Believe it or not, a state can be both destination-based and origin-based conditional upon whether the order is shipping interstate or intrastate. Interstate means it is crossing state lines and intrastate means it is shipping to a location within the same state. A state can be intrastate origin-based but interstate destination-based. Taxes on Shipping & Handling — Should the cost of shipping and handling be taxed? Once again, that is up to the state. Some say no taxes on shipping, some say charge tax, and some others treat shipping and handling as individual entities such that the handling should be taxed but not the shipping (but if they are listed as the same line item, charge tax). For the purposes of this discussion, we will assume shipping and handling are always listed together.

— Should the cost of shipping and handling be taxed? Once again, that is up to the state. Some say no taxes on shipping, some say charge tax, and some others treat shipping and handling as individual entities such that the handling should be taxed but not the shipping (but if they are listed as the same line item, charge tax). For the purposes of this discussion, we will assume shipping and handling are always listed together. Special Cases — Despite all of the possible combinations of the above rules, some states still do not fit the mold and are thus considered special cases. One example is California which has a special rule for charging district tax. California is intrastate destination-based, but the additional district tax should only be applied if there is nexus at the destination.

To help visualize all of the above rules, a flowchart has been created and can be seen in Figure 1.

Figure 1 — Flowchart to correctly determine the tax on an order within the United States

Technical Approach

Whenever writing software to be used as a service such as this tax calculator, considering what the interface to your service will look like can be a huge influence in the design of your software. It will also help us create our tests. In this case, we are interested in taking an order, an origin, and a destination, and then returning a tax rate to apply to the order. In other words, something like this:

$taxRate = TaxCalculator->getTaxRate($origin, $destination);

$tax = $currentOrder->getSubtotal() * $taxRate;

There is our basic use case. However, it does not yet include all of the information we need to properly calculate the tax. As was explained above, we also need the tax nexus. To handle this, we can construct an instance of the tax calculator that takes in the addresses to use as the nexus.

$taxNexus = [

[

'city' => 'San Francisco',

'state' => 'CA',

'postal_code' => '94107',

'country' => 'US'

],

[

'city' => 'New York',

'state' => 'NY',

'postal_code' => '10001',

'country' => 'US'

]

]; $taxCalculator = new TaxCalculator($origin, $destination, $taxNexus);

$taxRate = $taxCalculator->getTaxRate();

$tax = $currentOrder->getSubtotal() * $taxRate;

This initial pass is not meant to perfect. For instance, we are eventually going to want to have a standard address format, as well as a way to lookup tax rates. But for now, this gives us an idea of how our service is going to be used.

Defining the Test Cases

At this point, we have a flowchart to implement and a rough idea of the interface to our service. This is enough information to define some test cases so that we can develop in a test-driven manner.

In order to have 100% test coverage, we should test each possible path in the flowchart. To keep this simple, lets exclude the California special case. That gives us the following 9 paths to test:

There is no nexus at the order destination. Do not charge tax Intrastate origin-based with no taxes on shipping Intrastate origin-based with taxes on shipping Intrastate destination-based with no taxes on shipping Intrastate destination-based with taxes on shipping Interstate origin-based with no taxes on shipping Interstate origin-based with taxes on shipping Interstate destination-based with no taxes on shipping Interstate destination-based with taxes on shipping

We know this will be 100% test coverage because there are four binary decisions in our flowchart. This gives us 2⁴, or 16, test cases. However, since the first decision has a termination case, we only need to test it one time, resulting in 7 of the possible permutations being short-circuited. Thus leaving us with 16–7 = 9 cases.

Now, using the same interface as we saw above, we can write our tests.



*

*/

public function testTaxCalculatorRates($destination, $origin, $nexus, $expectedRate)

{

$taxCalculator = new TaxCalculator($destination, $origin, $nexus); /** @dataProvider taxCalculatorData*/public function testTaxCalculatorRates($destination, $origin, $nexus, $expectedRate)$taxCalculator = new TaxCalculator($destination, $origin, $nexus); $taxRate = $taxCalculator->getRate(); $this->assertEquals($expectedRate, $taxRate->tax_rate, '', 0.00001);

}

* Data provider. Data should be an iterable with data in the

* format of:

* [0] — $destination

* [1] — $origin

* [2] — $nexus

* [3] — $expectedRate

*

*

*/

public function taxCalculatorData()

{

return [

//out of nexus

[

'destination' => [

'country_code' => 'US',

'region' => 'NJ',

'postal_code' => '08075'

],

'origin' => [

'country_code' => 'US',

'region' => 'CA',

'postal_code' => '94107'

],

'nexus' => [

[

'country_code' => 'US',

'region' => 'CA',

'postal_code' => '94109'

]

],

'expectedRate' => 0.00

], /** Data provider. Data should be an iterable with data in the* format of:* [0] — $destination* [1] — $origin* [2] — $nexus* [3] — $expectedRate @return array*/public function taxCalculatorData()return [//out of nexus'destination' => ['country_code' => 'US','region' => 'NJ','postal_code' => '08075'],'origin' => ['country_code' => 'US','region' => 'CA','postal_code' => '94107'],'nexus' => ['country_code' => 'US','region' => 'CA','postal_code' => '94109'],'expectedRate' => 0.00], //in nexus, intrastate, state is origin based, charge shipping tax

[

'destination' => [

'country_code' => 'US',

'region' => 'AZ',

'postal_code' => '85372'

],

'origin' => [

'country_code' => 'US',

'region' => 'AZ',

'postal_code' => '85040'

],

'nexus' => [

[

'country_code' => 'US',

'region' => 'AZ',

'postal_code' => '85040'

],

],

'expectedRate' => 0.0860

], //in nexus, intrastate, state is destination based, charge shipping tax

[

'destination' => [

'country' => 'US',

'region' => 'NY',

'postal_code' => '00501' //+0.04625 tax rate

],

'origin' => [

'country' => 'US',

'region' => 'NY',

'postal_code' => '12065' //+0.03000 tax rate

],

'nexus' => [

[

'country_code' => 'US',

'region' => 'NY',

'postal_code' => '00501'

]

],

'expectedRate' => 0.08625

], //… full test cases omitted for brevity…

];

}

If you are not familiar with the way PHP unit works, this will simply take the test cases provided by the dataProvider and run them through the testTaxCalculatorRates function to see if the returned rate is equal to the expected rate.

With all of our test cases written out, we can run the tests and, as you might expect, they will all fail. This is a good thing! It means we are ready to begin development of a system that will satisfy all of the cases we just defined.

Conclusion and Learnings

There is an ongoing holy war about the effectiveness of TDD. Some think that it is a waste of time while others believe it is a sure-fire way to avoid bugs. I am not here to convince you one way or the other, but lets just take a look at where we are at in our development process.

We wrote out the full requirements of our service and confirmed them with legal before development began.

We have a basic idea of what the interface into our service will look like. This will help us implement the tax calculator class.

We have defined test cases that guarantee us 100% test coverage of our service

Doing these things ahead of time gives us the confidence that the code we are writing will satisfy all requirements of the system. Whether or not you are a TDD believer, we can all agree tests are useful — especially when it comes to charging money which comes along with legal liabilities. It is not something we want to break even for a few minutes. Therefore, having these tests from the start is immeasurably helpful.

Where TDD falls short, however, is that it is sometimes (if not often) impossible to foresee how the software design will change over the course of the implementation. This results in having to tweak our tests as we implement the system.

Originally published on http://engineering.weebly.com/