At first glance, the tax system in Solidus can seem complicated. I know I struggled for a long time to understand how all of the pieces fit together. This blog post will help shed some light on the key components of the tax system in Solidus, its shortcomings, and how best to integrate with it to ensure your store is tax compliant.
There are several “layers” to tax calculation in Solidus. Fortunately, unlike
promotions, tax adjustments are always calculated in the same way. It starts in
Spree::OrderUpdater#update
. Since taxes are stored as Spree::Adjustment
records in Solidus, their calculation happens at the same time as all of
adjustments on an order.
The Spree::OrderUpdater
delegates recalculating the taxes to our first
configurable layer: Spree::Config.tax_adjuster_class
. Swapping out the tax
adjuster is unlikely to be something most stores will need to do, but it allows
you full control over the entire process. The built-in adjuster is quite
simple, and introduces the next layer that can be configured:
taxes = Spree::Config.tax_calculator_class.new(order).calculate
Spree::OrderTaxation.new(order).apply(taxes)
This might be a good point for brief interlude into some of the historical
context for all of Solidus’ many different types of tax calculators. Several
years ago, while working on updating a store’s integration with
Avalara, we
realized that the integration point for taxes in Solidus was not sufficient for
what we needed to do. At that point in time, the only way to modify how taxes
were calculated was to provide your own Spree::Calculator::DefaultTax
class
and assign it to the Spree::TaxRate
record. Unfortunately, those calculators
only have knowledge of a single line item or shipment. They’re great if you’re
looking to calculate taxes based on the cost of something in isolation but
that’s not how most third-party tax services work. They all want to be able to
calculate taxes for an entire order. Which meant a lot of our earlier
integrations had to make duplicate API calls or use a lot of caching to avoid
that problem.
This lead to me
introducing Spree::TaxCalculator::Default
.
It provides an integration point at a higher level. Instead of only calculating
tax for a single item, this calculator is responsible for calculating the tax
on the entire order. I still regret the unfortunate naming to this day, but I
haven’t been able to come up with anything better and it’s too late now. Using
this new integration point, it’s now much easier to swap out the entirety of
Solidus’ tax calculations and use a third-party service to help manage them.
So to summarize, calculators in Spree::Calculator
generally operate on a
single item/shipment and those in Spree::TaxCalculator
work with the entire
order at once.
As mentioned in the interlude, swapping out the tax calculator class is what you’ll most likely want to do if you’re looking to integrate with a third-party service. For now, let’s dive into the provided default to see where things go from there.
Spree::TaxCalculator::Default
delegates the computation of taxes to
Spree::TaxRate
records. For every line item and shipment in the order, it
will search for all applicable rates. There are three conditions a rate needs
to meet in order to be applicable for an item:
Once we know which rates apply to an item, we compute how much tax should be
applied to it. This is where that second type of calculator mentioned earlier
comes in. Solidus has two built-in calculators: Spree::Calculator::DefaultTax
and Spree::Calculator::FlatFee
.
That first calculator will compute taxes using a percentage based rate and is likely what most people would think of when they think of calculating taxes. It has two different formulas based on the type of tax being applied: additional or included. The second calculator is meant to help capture tax-like fees for orders, that are often just a flat amount and not based on a percentage of the item’s total. We’ll talk more about it down below.
Value-added tax (VAT) is a flat tax levied on goods. It is very similar to sales tax, with the key distinction being that sales tax is only collected once: at the initial point of sale. VAT on the other hand is collected multiple times by multiple different parties, each responsible for remitting the tax to the government. When working with Solidus, we often use the term VAT instead to refer to taxes that are included in the price of goods because that’s how VAT is commonly applied in Europe.
# To calculate how much tax is included on an item based on a rate:
(cost_of_item * tax_rate) / (1 + tax_rate)
However, included taxes are not unique to Europe or VAT. For example, Canada has a 5% goods and services tax (GST) across the country which is almost always calculated at the point of purchase and considered an additional tax. However, items purchased at arenas will often have the tax included in the price to help make paying with cash easier and in those cases are calculated as an included tax. Essentially, the decision of using an included versus an additional tax comes down to what’s typically expected in the region you’re selling.
Recently, we’ve come across new types of taxes collected by some states that don’t fall under what we would usually consider a VAT or sales tax. Of note, Colorado introduced a retail delivery fee that must be collected for any delivery made to an address within Colorado that contains at least one tangible good. Because the delivery fee is a flat amount that is applied to an order, and shares many similarities to a tax, it is modeled as such in Solidus.
Flat fees in Solidus could be used to help capture other common fees on items where it’s important to you to track the amount separately from the base cost of the item. As an example, British Columbia’s Environmental Handling Fee (EHF) is a flat amount that is applied on the sale of all new electronic products in the province. Fees do often have some key distinctions from taxes. (Notably, they’re often non-refundable.) As such, it’s possible that fees will be pulled out into their own separate entity in Solidus eventually, but there’s nothing currently on the roadmap to do that work.
Now that we’ve got a better understanding for how all of the tax code in Solidus works, let’s take a look at why we would want to modify it. In all of the years I’ve worked on Solidus projects, the only time I’ve noticed the need for customizations to the tax system in Solidus is when working with stores that ship to/from the USA. Granted the large majority of the stores I’ve worked with have been primarily focused on Western markets, so maybe there are other countries out there with tax systems nearing the complexity of the USA that I’m blissfully unaware of. The majority of the complexity in the US stems from sales taxes varying from for each state, or in some cases each county. The taxes also often have differing rules that apply based on the amount being sold, where the warehouse the item was shipped from is located, where the item was manufactured, where the company is headquatered, and so on. Dealing with all of these rules could be a full-time job so most stores opt to use a third-party service to help.
The three most common services are: Avalara, TaxJar, and TaxCloud. And luckily for us there are already extensions available for all three:
SuperGoodSoft/solidus_taxjar
:
Uses the new Spree::TaxCalculator
integration point to calculate taxes for
an entire order.solidusio-contrib/solidus_tax_cloud
:
Unfortunately still uses the old integration point and provides a new
Spree::Calculator
to help make the API calls to calculate taxes for items.boomerdigital/solidus_avatax_certified
:
Similarly to the TaxCloud extension, provides a new Spree::Calculator
.