Solidus
Search…
Tax calculation

Architecture overview

In the following paragraphs, we use the terms tax calculator and rate calculator. While they sound similar, they are different things: the taxation system in Solidus primarily relies on tax calculators, not rate calculators.
While the default tax calculators delegate the actual taxation math to rate calculators, it's perfectly possible to write a custom tax calculator that doesn't use rate calculators and instead relies on its own logic, e.g. by querying an external API.
Solidus's taxation system revolves around the concept of tax calculators. These are classes that implement the logic required for calculating taxes on orders and shipping rates.
The default tax calculators rely on tax categories, tax rates and rate calculators. These are concepts that help configure item taxation through the Solidus backend:
    tax categories are used to group tax rates together—all products and shipping methods are assigned a tax category;
    tax rates associate a tax category with a geographic zone and a rate calculator (and also determine whether the tax is included in the item's original amount or not);
    rate calculators implement the actual math for converting a tax rate (e.g., 3% included in price) into a final amount (e.g., $3.00, when computed on a $100.00 amount).
This system is very flexible and allows you to insert your custom logic at different abstraction levels. But before we dive into how to customize it, let's take a look at the flow Solidus follows for calculating taxes on orders and shipping rates.

Order taxation

Note that promotions are applied to orders before taxes are calculated. This is to comply with tax regulations for value-added taxation as outlined by the Government of the United Kingdom and for sales tax as outlined by the California State Board of Equalization.
The flow for order taxation is the following:
    1.
    Whenever an order is updated, Solidus calls the OrderUpdater service. This service is responsible for recalculating all the amounts on the order, including tax amounts. This is done in the update_taxes method, which in turn calls the configured order adjuster.
    2.
    The default order adjuster uses the configured order tax calculator to determine which taxes should be applied to the order, then builds an OrderTaxation object and uses it to apply them.
    3.
    The Spree::OrderTaxation class applies the taxes on the order by upserting the corresponding adjustments on the taxed items (i.e., line items and shipments).
    4.
    The order's included_tax_total or additional_tax_total are updated according to the adjustments created in the previous step.

Shipping rate taxation

For more information on when and how shipments and shipping rates are built, you can refer to the Stock management guide.
In addition to calculating taxes on orders Solidus also calculates taxes on shipping rates. The flow here is slightly different, and is kicked off by the default stock estimator:
    1.
    Right after building the shipping rate for a shipment, Solidus calls the configured shipping rate tax calculator to calculate the tax for each shipping rate.
    2.
    Shipping rates don't have adjustments, so the resulting taxes are stored in a dedicated ShippingRateTax model instead.
Note that, while these tax amounts will be included in the shipping rates that are displayed to your user, Solidus will still re-calculate taxes on your shipment cost, and the final amount the user is charged depends on the shipment's cost rather than the shipping rate's cost.
This is because you may have additional adjustments on your shipment, e.g. you're offering a "free shipping" promotion and want to completely discount shipping for the user. In this case, the shipping rate might be $10.0 + a $2.0 tax, but your shipment total will still be $0.0.
You should treat tax calculation for shipping rates as a UI-only matter. The standard order tax calculation flow determines the price your user will pay.

Customizing tax calculation

If you want to customize the tax calculation logic, you may do it at two different levels:
    Write a custom rate calculator: with this approach, admins will create a tax rate that uses your own rate calculator and tell Solidus to use that tax rate for your products and shipping methods. The default tax calculator will call the configured tax rate, which in turn will delegate the amount computation to your custom rate calculator.
    Replace the tax calculator (recommended): this way, Solidus will not use the rate calculators at all. This approach affords you maximum flexibility, since you'll be calculating taxes on the entire order at the same time rather than on a per-item basis.

With a custom tax calculator

The public interface for a tax calculator is pretty simple: it takes an order during initialization and exposes a #calculate method that returns a Spree::Tax::OrderTax instance. This is an object that contains information about all taxes to apply to the item.
For orders
For shipping rates
Here's a dead-simple custom order tax calculator that simply applies a 1% tax on all line items and a 2% tax on all shipments:
app/models/awesome_store/tax_calculator/default.rb
1
module AwesomeStore
2
module TaxCalculator
3
class Default
4
def initialize(order)
5
@order = order
6
end
7
8
def calculate
9
Spree::Tax::OrderTax.new(
10
order_id: order.id,
11
line_item_taxes: line_item_rates,
12
shipment_taxes: shipment_rates
13
)
14
end
15
16
private
17
18
def line_item_rates
19
order.line_items.flat_map do |line_item|
20
calculate_rates(line_item)
21
end
22
end
23
24
def shipment_rates
25
order.shipments.flat_map do |shipment|
26
calculate_rates(shipment)
27
end
28
end
29
30
def calculate_rates(item)
31
amount = if item.is_a?(Spree::LineItem)
32
item.amount * 0.01
33
elsif item.is_a?(Spree::Shipment)
34
item.amount * 0.02
35
end
36
37
[
38
Spree::Tax::ItemTax.new(
39
item_id: item.id,
40
label: 'Custom Tax',
41
# NOTE: You still need to tie the item tax to a tax rate, otherwise
42
# Solidus will not be able to compare tax adjustments to each other
43
tax_rate: Spree::TaxRate.find_by(name: 'Custom Tax Rate'),
44
amount: amount,
45
included_in_price: false,
46
)
47
]
48
end
49
end
50
end
51
end
Copied!
Once you have implemented your calculator, you need to tell Solidus to use it:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.tax_calculator_class = 'AwesomeStore::TaxCalculator::Default'
5
end
Copied!
Here's a sample shipping rate tax calculator that applies a 3% tax to all shipping rates:
app/models/awesome_store/tax_calculator/shipping_rate.rb
1
module AwesomeStore
2
module TaxCalculator
3
class ShippingRate
4
def initialize(order)
5
@order = order
6
end
7
8
def calculate(shipping_rate)
9
# Run your custom logic here and return an array
10
# of `Spree::Tax::ItemTax` objects. For example:
11
12
[
13
Spree::Tax::ItemTax.new(
14
item_id: shipping_rate.id,
15
label: 'Custom tax',
16
tax_rate: 0.03,
17
amount: shipping_rate.amount * 0.03,
18
included_in_price: false,
19
)
20
]
21
end
22
end
23
end
24
end
Copied!
Once you have created the tax calculator, you need to tell Solidus to use your custom implementation instead of the default:
config/initializers/spree.rb
1
Spree.config do |config|
2
config.shipping_rate_tax_calculator_class = 'AwesomeStore::TaxCalculator:ShippingRate'
3
end
Copied!
Reboot your server, and Solidus should start using your custom tax calculator!

With a custom rate calculator

With a custom rate calculator, store administrators configure tax rates as usual in the Solidus backend, but select your custom rate calculator instead of the default one. When a tax rate is applied to an item, the custom tax calculator will be called and your logic will be triggered.
A custom rate calculator is pretty simple, and it looks like the following:
app/models/awesome_store/calculator/default_tax.rb
1
module AwesomeStore
2
module Calculator
3
class DefaultTax < Spree::Calculator::DefaultTax
4
class << self
5
def description
6
'My Custom Calculator'
7
end
8
end
9
10
def compute_line_item(line_item)
11
calculate(line_item.total_before_tax)
12
end
13
14
def compute_shipping_rate(shipping_rate)
15
calculate(shipping_rate.total_before_tax)
16
end
17
18
def compute_shipment(shipment)
19
calculate(shipment.total_before_tax)
20
end
21
22
private
23
24
def calculate(amount)
25
# Skip the calculation if this tax rate is not active.
26
return 0 unless calculable.active?
27
28
# e.g. do some API call here and return the tax amount
29
# ...
30
end
31
end
32
end
33
end
Copied!
As you can see, you can specify different logic for calculating taxes on line items, shipping rates and shipments, if you need to (e.g., if you're not charging tax on shipments). If you're using the same logic for all objects, you may further simplify the implementation:
app/models/awesome_store/calculator/default_tax.rb
1
module AwesomeStore
2
module Calculator
3
class DefaultTax < Spree::Calculator::DefaultTax
4
class << self
5
def description
6
'My Custom Calculator'
7
end
8
end
9
10
def compute_item(item)
11
# Skip the calculation if the tax rate is not active.
12
return 0 unless calculable.active?
13
14
amount = item.total_before_tax
15
16
# e.g. do some API call here and return the tax amount
17
# ...
18
end
19
20
alias_method :compute_shipment, :compute_item
21
alias_method :compute_line_item, :compute_item
22
alias_method :compute_shipping_rate, :compute_item
23
end
24
end
25
end
Copied!
This is how the default tax calculator is implemented, for instance!
Once you have implemented your custom rate calculator, you need to register it by adding the following to an initializer:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
config.environment.calculators.tax_rates << 'AwesomeStore::Calculator::DefaultTax'
4
end
Copied!
At this point, you can create a new tax rate in the admin panel and select your custom rate calculator. In the admin panel, go to Settings -> Taxes -> Tax Rates and click on New Tax Rate, then configure the new tax rate like this (you may want to change the validity period, zone and tax categories):
You can now save your tax rate, and your custom rate calculator will start being called for all items in one of the tax rate's tax categories, as long as they belong to the tax rate's zone!
You'll notice that we entered a Rate of 0.0 in the configuration above, and that we disabled the Show rate in label option.
This is because, in our custom rate calculator, the user-provided tax rate is not being used at all: instead, we are calling an external API to return the correct tax rate for us.
This kind of inconsistency is one of the reasons you should almost always use a custom tax calculator instead of a custom rate calculator.
Last modified 3mo ago