docs/plans/6.0-tax-provider.md
Status: Draft Target: Spree 6.0 Depends on: Split Adjustments (6.0-split-adjustments.md) for TaxLine model, Channels/Catalogs/B2B (6.0-channels-catalogs-b2b.md) for Company.tax_exempt Author: Damian + Claude Last updated: 2026-04-05
Add a pluggable tax provider interface (Spree::TaxProvider::Base) that abstracts tax calculation behind a clean contract. The default implementation (Spree::TaxProvider::Internal) preserves today's TaxRate + Zone + Calculator behavior. External providers (Avalara, TaxJar, Vertex) implement the same interface, enabling drop-in tax services without changing the checkout flow, adjustment system, or order updater.
The interface sits between the adjuster layer ("calculate tax for these items") and the calculation engine ("here's how much tax is owed"). Everything upstream (checkout flow, OrderUpdater, TaxLine creation) and downstream (totals, serializers, reporting) is unchanged.
No tax provider seam. TaxRate.adjust(order, items) is hardcoded to match rates from the database, compute amounts via Calculator::DefaultTax, and create adjustments. There's no point where an external service can intercept. Integrations like Avalara have to monkey-patch TaxRate or wrap the entire adjuster.
Internal rates don't scale across jurisdictions. US merchants with nexus in 30+ states need thousands of rate entries with zip-code-level granularity. The admin UI isn't designed for this, and rates change constantly. External services handle this via API calls with real-time rate lookups.
No tax exemption flow. Company.tax_exempt exists (from the B2B plan) but there's no exemption certificate management — no storage, validation, or automatic zero-rating based on certificate status. Enterprise B2B requires this.
No tax estimation vs commitment distinction. External providers distinguish between "estimate" (during checkout, reversible) and "commit" (on order completion, creates a tax filing record). Spree treats all tax calculations identically.
VAT reverse charge is manual. EU B2B transactions where the buyer is VAT-registered in another member state should reverse-charge (zero VAT from seller, buyer self-assesses). This requires VAT registration validation and is too complex for static rate tables.
| What exists | Where | What it does |
|---|---|---|
TaxRate model | core/app/models/spree/tax_rate.rb | Stores rate %, zone, tax category. adjust() is the entry point. |
TaxRate.match(zone) | TaxRate class method | Finds rates whose zone includes the order's tax zone |
Calculator::DefaultTax | core/app/models/spree/calculator/ | Computes tax amount for line items, shipments, shipping rates |
VatPriceCalculation | core/app/models/concerns/ | Handles included-in-price tax math |
TaxRate.store_pre_tax_amount | TaxRate class method | Computes and stores pre-tax amounts for VAT calculations |
Adjuster::Tax | core/app/models/spree/adjustable/adjuster/ | Orchestrates tax adjustment updates during recalculation |
TaxCategory | core/app/models/spree/tax_category.rb | Groups products/variants by tax type |
Zone.match(address) | Zone class method | Finds geographic zone for an address |
Order#tax_zone | Order model | Resolves order's zone from ship/bill address + default zone fallback |
Spree::Config[:tax_using_ship_address] | Config | Controls whether ship or bill address determines tax zone |
Spree.adjusters | Engine config | Registry of adjuster classes (includes Tax adjuster) |
OrderUpdater#update
→ recalculate_adjustments (each line item + shipment)
→ Adjuster::Tax#update
→ adjustment.update! (for each existing tax adjustment)
→ TaxRate#compute_amount(item)
→ Calculator::DefaultTax#compute_shipment_or_line_item(item)
Order#create_tax_charge!
→ TaxRate.adjust(order, line_items)
→ TaxRate.match(order.tax_zone) → find matching rates
→ TaxRate.store_pre_tax_amount(item, rates) → pre-tax for VAT
→ rate.adjust(order, item) → create_adjustment (via AdjustmentSource)
→ TaxRate.adjust(order, shipments)
The current Calculator::DefaultTax is a stateless per-item computation: calculator.compute(item) → BigDecimal. Calculators handle "how much tax is owed on this item?" but cannot handle:
avalara_transaction_id) on the order for later commit/void.AdjustmentSource.create_adjustment → compute_amount → calculator.compute. External providers need to intercept at the TaxRate.adjust entry point to decide whether to use internal rates or call their API.The provider replaces calculators entirely in 6.0. The Internal provider implements the tax math directly (rate × amount, VAT inclusion) — no Calculator::DefaultTax indirection. External providers call their API directly. The Calculator base class and CalculatedAdjustments concern are removed from TaxRate.
Provider (order lifecycle)
└─ Internal provider → reads TaxRate (rate %, zone, included?) → computes directly
└─ Avalara provider → Avalara API → writes TaxLine records directly
TaxRate.adjust is removed. The provider is the only entry point for tax calculation. No more AdjustmentSource, no more CalculatedAdjustments concern on TaxRate, no more Calculator::DefaultTax.TaxRate stays as a data model — stores rate %, zone, tax category, included_in_price flag. The Internal provider reads it to compute tax. But TaxRate no longer has behavior — it's pure configuration.Calculator::DefaultTax is removed. The Internal provider implements the tax math directly (rate × discounted_amount for additional tax, deduced_total for included tax).TaxRate.adjust (Order, LineItem, checkout services) now call the provider via Spree.tax_provider.# Two-phase: estimate during checkout, commit on completion
provider.estimate(order, items) # → writes TaxLine records (or Adjustments in 5.4)
provider.commit(order) # → finalizes with external service, no-op for internal
provider.void(order) # → cancels committed tax document, no-op for internal
With the split-adjustments plan (6.0), tax results are written as Spree::TaxLine records with concrete FKs. The provider creates/updates these records — the same data model regardless of whether tax was computed internally or by Avalara.
Before split-adjustments ships, the provider writes Spree::Adjustment records (backward compatible).
Providers are stateless strategy objects — they define behavior (estimate, commit, void). They do not store credentials. API keys, secrets, and connection config for external services live in Spree::Integration subclasses, which provide:
preferences columncan_connect? hook tests credentials before savingactive boolean flagA provider gem (e.g., spree_tax_avalara) ships both an Integration subclass and a Provider subclass:
# Integration — stores credentials, provides admin UI
class Spree::Integrations::Avalara < Spree::Integration
preference :api_key, :string
preference :environment, :string, default: 'sandbox'
preference :company_code, :string
def self.integration_group = 'tax'
def can_connect?
client = AvaTax::Client.new(api_key: preferred_api_key, environment: preferred_environment)
client.ping.success?
rescue => e
self.connection_error_message = e.message
false
end
end
# Provider — behavioral contract, reads credentials from Integration at runtime
class SpreeTaxAvalara::TaxProvider < Spree::TaxProvider::Base
def estimate(order, items)
# ...
response = client.create_transaction(request)
# ...
end
private
def client
@client ||= begin
integration = market.store.integrations.active.find_by!(type: 'Spree::Integrations::Avalara')
AvaTax::Client.new(
api_key: integration.preferred_api_key,
environment: integration.preferred_environment
)
end
end
end
Admin flow:
Internal providers don't need an Integration. Spree::TaxProvider::Internal reads TaxRate records directly — no external credentials.
Provider availability depends on Integration status. The Market admin UI should only offer providers whose Integration is active. Provider classes declare their dependency:
class SpreeTaxAvalara::TaxProvider < Spree::TaxProvider::Base
def self.integration_class
'Spree::Integrations::Avalara'
end
# Internal providers override to always return true
def self.available_for_store?(store)
store.integrations.active.exists?(type: integration_class)
end
end
Markets represent regional commerce operations. Each market can have its own tax provider, matching how Shopify and Medusa handle it:
class Spree::Market < Spree.base_class
attribute :tax_provider, :string, default: 'Spree::TaxProvider::Internal'
def tax_provider_instance
tax_provider.constantize.new(self)
end
end
TaxRate is an internal provider concept only. External providers ignore it — they have their own jurisdiction databases.
Market "US" → tax_provider: 'SpreeAvalara::TaxProvider'
└─ Avalara API handles all US jurisdictions
└─ TaxRates exist in DB but not used for this market
Market "EU" → tax_provider: 'Spree::TaxProvider::Internal'
└─ TaxRate: country=Germany, category=Standard, rate=19%, included=true
└─ TaxRate: country=Germany, category=Food, rate=7%, included=true
└─ TaxRate: country=France, category=Standard, rate=20%, included=true
The current Zone model is an unnecessary indirection for tax. Merchants don't think in "zones" — they think "Germany has 19% VAT". The Internal provider matches TaxRates directly by country/state on the order address:
class Spree::TaxRate < Spree.base_class
belongs_to :tax_category
belongs_to :country, class_name: 'Spree::Country', optional: true # nil = all countries
belongs_to :state, class_name: 'Spree::State', optional: true # nil = entire country
# rate: 0.19, included_in_price: true, name: "DE VAT 19%"
end
No zones, no zone members, no indirect lookups. This matches Shopify (rates per country/region) and Medusa (TaxRegion per country/province).
# TaxRate.for_address(address) — finds applicable rates
scope :for_address, ->(address) {
where(country: [address.country, nil]) # country-specific or global
.where(state: [address.state, nil]) # state-specific or country-wide
}
State-level rates override country-level (more specific wins). A rate with country=US, state=CA takes precedence over country=US, state=nil for California addresses.
The generic Spree::Zone model is dropped entirely in 6.0. A data migration (rake task) converts existing Zone records into TaxRate country/state FKs (for tax) and DeliveryZone records (for shipping, see 6.0-delivery-rate-provider.md). After migration, spree_zones and spree_zone_members tables are removed.
This matches the natural scope hierarchy (all external providers store credentials in Spree::Integration — see above):
| Provider | Scope | Credentials | Why |
|---|---|---|---|
| Search | Global (Spree.search_provider) | Spree::Integration | One search engine per instance |
| Tax | Per-Market (Market#tax_provider) | Spree::Integration | Different tax rules/services per region |
| Delivery rates | Per-DeliveryMethod (DeliveryMethod#rate_provider) | Spree::Integration | Different carriers per shipping option |
provider.exempt?(order) # → true if order should be tax-exempt
The provider checks Company.tax_exempt, exemption certificates, and any external validation. If exempt, estimate returns zero amounts. This keeps exemption logic in one place instead of scattered across models.
TaxProvider::Internal reads TaxRate records (rate %, zone, included_in_price) and computes tax directly — no Calculator::DefaultTax indirection. The math is the same (rate × discounted_amount for additional, deduced_total for VAT), just implemented in the provider instead of a calculator.
module Spree
module TaxProvider
class Base
attr_reader :market
def initialize(market)
@market = market
end
# Estimate tax for items in an order. Called during checkout recalculation.
# Must be idempotent — repeated calls update existing tax records.
#
# Creates/updates TaxLine records (6.0) or Adjustment records (5.4).
#
# @param order [Spree::Order]
# @param items [Array<Spree::LineItem, Spree::Shipment>] items to tax
# @return [void]
def estimate(order, items)
raise NotImplementedError
end
# Commit tax for a completed order. Creates a tax document in external
# services for filing/reporting. No-op for internal provider.
#
# @param order [Spree::Order]
# @return [void]
def commit(order)
# no-op by default
end
# Void a committed tax document. Called on order cancellation.
#
# @param order [Spree::Order]
# @return [void]
def void(order)
# no-op by default
end
# Refund tax for returned items. Creates an adjustment document in
# external services. Internal provider recalculates from remaining items.
#
# @param order [Spree::Order]
# @param return_items [Array<Spree::ReturnItem>]
# @return [void]
def refund(order, return_items)
# no-op by default — internal provider handles via recalculation
end
# Check if an order is tax-exempt.
#
# @param order [Spree::Order]
# @return [Boolean]
def exempt?(order)
return true if order.company&.tax_exempt?
return true if order.company_location&.tax_exempt?
false
end
end
end
end
Computes tax directly from TaxRate records — no Calculator::DefaultTax:
module Spree
module TaxProvider
class Internal < Base
def estimate(order, items)
return clear_tax(order, items) if exempt?(order)
address = order.tax_address
rates = Spree::TaxRate.for_address(address)
items.each do |item|
applicable_rates = rates.select { |r| r.tax_category_id == item.tax_category_id }
applicable_rates.each do |rate|
amount = compute_tax(rate, item)
upsert_tax_line(order, item, rate, amount)
end
end
end
private
def compute_tax(rate, item)
if rate.included_in_price?
# VAT: deduced from price
pre_tax = item.discounted_amount / (1 + rate.amount)
(pre_tax * rate.amount).round(2)
else
# Sales tax: added on top
(item.discounted_amount * rate.amount).round(2)
end
end
def upsert_tax_line(order, item, rate, amount)
# Creates or updates TaxLine (6.0) for this item + rate
tax_line = item.tax_lines.find_or_initialize_by(tax_rate: rate, order: order)
tax_line.amount = amount
tax_line.included = rate.included_in_price?
tax_line.label = rate.name
tax_line.save!
end
def clear_tax(order, items)
items.each do |item|
item.tax_lines.destroy_all
item.update_columns(
included_tax_total: 0,
additional_tax_total: 0
)
end
end
end
end
end
Shows the contract — actual implementation in spree_tax_avalara gem. Note how the provider reads credentials from its Spree::Integration record (see "Credentials live in Spree::Integration" above):
module SpreeTaxAvalara
class TaxProvider < Spree::TaxProvider::Base
def self.integration_class = 'Spree::Integrations::Avalara'
def self.available_for_store?(store)
store.integrations.active.exists?(type: integration_class)
end
def estimate(order, items)
return clear_tax(order, items) if exempt?(order)
# Build Avalara transaction request
request = build_transaction(order, items, type: :estimate)
# Call Avalara API
response = client.create_transaction(request)
# Write TaxLine records from response
response.lines.each do |avalara_line|
item = find_item(items, avalara_line.line_number)
next unless item
upsert_tax_line(
order: order,
item: item,
amount: avalara_line.tax,
included: avalara_line.tax_included,
label: avalara_line.tax_name,
tax_rate: find_or_create_rate(avalara_line)
)
end
# Store Avalara transaction ID for commit
order.update_column(:metadata,
order.metadata.merge('avalara_transaction_id' => response.id))
end
def commit(order)
transaction_id = order.metadata&.dig('avalara_transaction_id')
return unless transaction_id
client.commit_transaction(transaction_id)
end
def void(order)
transaction_id = order.metadata&.dig('avalara_transaction_id')
return unless transaction_id
client.void_transaction(transaction_id)
end
def refund(order, return_items)
request = build_return_transaction(order, return_items)
client.create_transaction(request)
end
def exempt?(order)
return true if super # company/location-level exemption
# Avalara-specific: check exemption certificate
if order.company&.metadata&.dig('avalara_exemption_id')
validate_exemption(order.company.metadata['avalara_exemption_id'])
else
false
end
end
private
def client
@client ||= begin
integration = market.store.integrations.active.find_by!(type: self.class.integration_class)
AvaTax::Client.new(
api_key: integration.preferred_api_key,
environment: integration.preferred_environment,
company_code: integration.preferred_company_code
)
end
end
end
end
The order resolves its market, then calls the market's tax provider. No more Adjuster::Tax — the provider is called directly from the order update flow:
# In OrderUpdater or checkout service
class Spree::OrderUpdater
def update_tax
market = order.market || order.store.default_market
provider = market.tax_provider_instance
items = order.line_items + order.shipments
provider.estimate(order, items)
# Read totals from TaxLines
order.included_tax_total = order.tax_lines.included_in_price.sum(:amount)
order.additional_tax_total = order.tax_lines.additional.sum(:amount)
end
end
end
def order
adjustable.respond_to?(:order) ? adjustable.order : adjustable
end
end
end
end
end
class Spree::Checkout::Complete
def call(order:)
# ... existing completion logic ...
# Commit tax document with provider
market = order.market || order.store.default_market
market.tax_provider_instance.commit(order)
# ... rest of completion ...
end
end
class Spree::Orders::Cancel
def call(order:)
# ... existing cancellation logic ...
market = order.market || order.store.default_market
market.tax_provider_instance.void(order)
end
end
Admin API allows setting per store:
```json
PATCH /api/v3/admin/stores/:id
{
"tax_provider": "SpreeTaxAvalara::TaxProvider"
}
Building on the Company model from 6.0-channels-catalogs-b2b.md:
class Spree::TaxExemptionCertificate < Spree.base_class
has_prefix_id :cert
belongs_to :company, class_name: 'Spree::Company'
belongs_to :country, class_name: 'Spree::Country', optional: true # jurisdiction (nil = all countries)
belongs_to :state, class_name: 'Spree::State', optional: true # state-level (nil = entire country)
validates :certificate_number, presence: true
validates :status, presence: true # pending, verified, expired, revoked
attribute :certificate_number, :string
attribute :status, :string, default: 'pending'
attribute :issued_at, :datetime
attribute :expires_at, :datetime
attribute :issuing_authority, :string # state/province name
has_one_attached :document # uploaded certificate PDF/image
scope :active, -> { where(status: 'verified').where('expires_at IS NULL OR expires_at > ?', Time.current) }
scope :for_address, ->(address) {
where(country: [address.country, nil])
.where(state: [address.state, nil])
}
def active?
status == 'verified' && (expires_at.nil? || expires_at > Time.current)
end
end
The provider's exempt? method checks certificates:
def exempt?(order)
return true if order.company&.tax_exempt?
return true if order.company_location&.tax_exempt?
# Check for active certificate covering the order's tax address
if order.company
order.company.tax_exemption_certificates
.active
.for_address(order.tax_address)
.exists?
else
false
end
end
Spree::TaxProvider::Base and Spree::TaxProvider::InternalTaxRate.adjust — zero behavior changetax_provider preference to StoreAdjuster::Tax to delegate to provider instead of calling TaxRate directlycommit call to Carts::Completevoid call to Orders::Cancelrefund hook to returns flowclass CreateSpreeTaxExemptionCertificates < ActiveRecord::Migration[7.2]
def change
create_table :spree_tax_exemption_certificates do |t|
t.references :company, null: false
t.references :country
t.references :state
t.string :certificate_number, null: false
t.string :status, null: false, default: 'pending'
t.datetime :issued_at
t.datetime :expires_at
t.string :issuing_authority
t.timestamps
end
add_index :spree_tax_exemption_certificates, [:company_id, :status]
end
end
TaxExemptionCertificate modelhas_many :tax_exemption_certificatesexempt? checks certificatesWhen 6.0-split-adjustments.md ships:
TaxLine records instead of Adjustment recordsTaxLine recordstax_lines instead of adjustments.taxTaxRate.adjust internally updated to create TaxLinesCoordinate with 6.0-delivery-rate-provider.md — both plans share this migration:
# rake spree:migrate_zones
#
# For each Zone used by TaxRate:
# Extract country/state from ZoneMembers → set TaxRate.country_id / TaxRate.state_id directly
# (Zone with multiple countries = one TaxRate per country)
#
# For each Zone used by ShippingMethod (DeliveryMethod):
# Create DeliveryZone + DeliveryZoneMembers from ZoneMembers
#
# After migration:
# Drop spree_zones and spree_zone_members tables
# Remove Zone, ZoneMember models
country_id and state_id columns to spree_tax_rateszone_id FK from TaxRatespree_zones and spree_zone_members tables (shared with Delivery Rate Provider plan)spree_tax_avalara gem as reference implementationSpree::Config[:tax_using_ship_address]. Provider-specific config belongs on the provider (via Store preferences or metadata), not in global Spree config.provider.exempt?(order). Don't add ad-hoc exemption checks in controllers or services.Shipping rate tax estimation. ShippingRate currently computes tax via shipping_rate.tax_rate.calculator.compute_shipping_rate(self) for display during checkout. Should the provider also handle shipping rate tax estimation? Or keep it as-is since it's display-only and the real tax is computed when the shipment is created? Leaning toward provider handles all tax, including shipping rate display.
Caching for external providers. External API calls during every recalculation could be expensive. Should the provider interface include a caching contract (e.g., "cache estimate for 5 minutes unless address changes")? Or leave caching to the provider implementation? Leaning toward leaving it to the implementation — different providers have different caching needs.
Tax document ID storage. External providers need to store transaction IDs (for commit/void). Currently the Avalara example uses order.metadata. Should there be a dedicated tax_document_id column on Order, or is metadata sufficient? Leaning toward metadata — keeps the schema clean and provider-agnostic.
Multi-provider per store. Some merchants use Avalara for US tax and internal rates for EU VAT. Should we support multiple providers per store (e.g., per-zone routing)? This adds significant complexity. Leaning toward single provider per store for 6.0 — the Avalara provider can internally handle multi-jurisdiction via Avalara's own zone logic.
spree/core/app/models/spree/tax_rate.rbspree/core/app/models/spree/calculator/default_tax.rbspree/core/app/models/concerns/spree/vat_price_calculation.rbspree/core/app/models/spree/adjustable/adjuster/tax.rbspree/core/app/models/spree/order_updater.rbspree/core/app/models/spree/zone.rbspree/core/app/models/concerns/spree/adjustment_source.rb6.0-split-adjustments.md (TaxLine model replaces polymorphic Adjustment for tax)6.0-channels-catalogs-b2b.md (Company.tax_exempt, CompanyLocation.tax_exempt)6.0-returns-exchanges-claims.md (Return flow triggers provider.refund)