About spree-taxes
spree-taxes is a Spree 6 skill for configuring tax categories, rates, tax addresses, and provider-based calculation. It serves teams implementing tax handling in Spree, and details how internal rates and external providers produce tax lines across carts and orders.
Key features
- 01Store-scoped tax categories and rates
- 02Jurisdiction matching by country and state
- 03Market-specific tax provider selection
- 04Included and additional tax calculation
- 05Tax identifiers and exemption certificates
Use cases
- 01Setting up country- and state-level tax rates
- 02Choosing tax providers for different markets
- 03Configuring tax-inclusive product pricing
- 04Applying business tax identifiers and exemptions
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-taxesAfter installing, type/spree-taxesin Claude Code to confirm it loaded.
Details
From the project READMESpree Taxes
Tax in Spree 6 is three separate decisions:
- What is sold →
Spree::TaxCategoryon the product/variant (copied to line items). - Where it goes → the purchase's tax address (ship address by default) and its market.
- Who computes it → the market's tax provider, falling back to
Spree.default_tax_provider(defaultSpree::TaxProvider::Internal, which readsSpree::TaxRaterows).
Whatever computes it, the output is always the same: Spree::TaxLine rows on the cart/order, folded into included_tax_total / additional_tax_total by Spree::Carts::RecalculateTotals (see spree-order-totals).
Coming from 5.x zones and
Spree::Calculator::DefaultTax? Tax zones and tax adjustments are gone — seespree-upgrade-5-to-6.
Tax categories
Spree::TaxCategory — store-scoped; name, is_default (one default per store; items with no category use it), tax_code (the code an external engine knows it by, e.g. an Avalara tax code). Products/variants reference one; line items snapshot tax_category_id. Delivery methods can carry a category for shipping tax; fees without one use the store default.
Tax rates (Internal provider)
Spree::TaxRate (tax_…, store-scoped, soft-deleted) has no zone — it names its jurisdiction by ISO code:
| Field | Meaning |
|---|---|
amount | Decimal fraction — 0.2 = 20% (amount_percentage reads/writes 20) |
country_code / state_code | Where it applies. nil country = everywhere; nil state = whole country |
tax_category | What it applies to |
included_in_price | true = VAT-style, already inside the price; false = added on top |
name / show_rate_in_label | Label snapshotted onto tax lines ("VAT 20%") |
store = Spree::Current.store
standard = Spree::TaxCategory.default(store)
Spree::TaxRate.create!(name: 'VAT', amount: 0.20, country_code: 'DE', tax_category: standard, included_in_price: true)
Spree::TaxRate.create!(name: 'CA sales tax', amount: 0.0725, country_code: 'US', state_code: 'CA', tax_category: standard)
Admin API: /api/v3/admin/tax_categories, /api/v3/admin/tax_rates (adminClient.taxRates.create({ …, country_code: 'GB', tax_category_id: 'taxcat_…' })).
Matching: TaxRate.for_store(store).for_jurisdiction(country, state) — every rate for the item's category whose country/state match (or are nil) applies, so a country-wide US rate and a US/CA rate both tax a California address. The Internal provider declares unsupported_capabilities: us_local_tax, reverse_charge, oss_thresholds, proportional_delivery_tax — beyond simple country/state rates, use an external engine.
Tax address
purchase.tax_address is the ship address when the store preference tax_using_ship_address is true (default), else the bill address. purchase.tax_country falls back to the market's default country, then the store's — which is what lets an inclusive market show real prices before any address exists.
Included vs added-on
included_in_price(per rate) drives the math: included tax is backed out of the gross (pre_tax_amountis stored on the item) and lands inincluded_tax_total; additional tax lands inadditional_tax_totaland increasestotal.Spree::Market#tax_inclusiveis the market's declaration that its prices are quoted tax-inclusive — exposed on the Store API market so storefronts label prices ("incl. VAT"). Keep it consistent with the rates you configure for that market's countries.- Cross-border VAT restatement: when a VAT-inclusive price is shown to a customer in another country,
Spree::VatPriceCalculationremoves home VAT and adds the destination's — only with the Internal provider (external engines keep no rate rows; use a geo-scoped price list instead, seespree-pricing). - In summaries, never add
included_tax_totalto anything — it's already inside the prices.tax_total= included + additional is the safe single line.
Tax providers
cart.tax_provider # => market&.tax_provider_instance || Spree.default_tax_provider.new
Spree::Market#tax_provideris a class-name string, validated againstSpree.tax_providers(Admin API:PATCH /api/v3/admin/markets/:id { tax_provider: 'MyTax::Provider' };GET /api/v3/admin/tax_providerslists installed ones with availability and unsupported capabilities).Spree.default_tax_providerreturns a class (accepts a class or string):Spree.default_tax_provider = 'MyTax::Provider'.- Providers are stateless, instantiated fresh per call, no constructor args. Credentials live in a
Spree::Integrationthe provider reads at runtime; overrideself.available_for_store?(store)to require it.
The provider contract (Spree::TaxProvider::Base)
| Method | When | Must |
|---|---|---|
estimate(owner, items = nil, tax_date:, tax_identifier:, exemptions:, context:) | Every unfrozen recalculation (cart, draft order); never on placed orders | Replace-all per item: delete the items' existing tax lines, write fresh ones — one row per treatment, zero-amount included. items: nil = owner.taxable_items (line items, fulfillments, non-duty fees). Raise if it can't compute. |
commit(order) | After placement, outside any transaction (Carts::Complete finalize) | Be idempotent — completion replays; key the filing on the order |
void(order) | Order cancellation | Reverse the committed document |
refund(order, return_items, amount:, tax_date:) | Return refund | Credit no more tax than amount actually refunded |
service_tax_rate(address:, store:) | Marketplace commission invoicing | Return a fraction or nil (no opinion) |
self.unsupported_capabilities | Admin market pairing | Declare what you can't do (symbols) |
self.display_name, self.available_for_store?(store) | Admin | Presentation / gating |
Writing one
# server/app/models/my_tax/provider.rb
module MyTax
class Provider < Spree::TaxProvider::Base
def self.display_name = 'MyTax'
def self.unsupported_capabilities = %i[reverse_charge]
def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], context: {})
items ||= owner.taxable_items
return if items.empty?
quote = MyTax::Client.quote(owner, items, tax_date:, vat_id: tax_identifier&.value, **context)
items.group_by(&:class).each do |klass, group|
fk = { Spree::LineItem => :line_item_id, Spree::Fulfillment => :fulfillment_id, Spree::Fee => :fee_id }.fetch(klass)
Spree::TaxLine.where(fk => group.map(&:id)).delete_all # replace-all
end
owner_key = owner.is_a?(Spree::Order) ? :order : :cart
quote.lines.each do |line|
Spree::TaxLine.create!(
owner_key => owner, line.item_fk => line.item_id,
amount: line.tax, rate: line.rate, label: line.label,
included: false, provider_id: 'mytax',
taxability_reason: line.reason, # e.g. 'standard_rated', 'reverse_charge'
country_code: line.country, state_code: line.state,
data: { jurisdictions: line.breakdown } # provider payload, free-form
)
end
end
def commit(order)
MyTax::Client.commit(document_code: order.number) # idempotent by order number
end
def void(order) = MyTax::Client.void(document_code: order.number)
end
end
# server/config/initializers/spree.rb
Spree.default_tax_provider = 'MyTax::Provider' # optional fallback for markets naming none (string: no autoload at boot)
Rails.application.config.after_initialize do
Spree.tax_providers << MyTax::Provider # selectable per market; app/ classes can't be referenced during boot
end
TaxLine.taxability_reasons is a class attribute (standard_rated, reduced_rated, zero_rated, reverse_charge, intra_community_supply, export, customer_exempt, product_exempt, not_collecting, not_subject_to_tax); add your own with Spree::TaxLine.taxability_reasons += ['my_reason'].
Keep network I/O fast and failure-aware: estimate runs inside cart writes and inside the completion lock. A raise aborts the recalculation.
Feeding extra data: set_tax_line_context
Anything a provider needs that the typed inputs don't carry (customer usage codes, channel, a quote ID) comes from context handlers. Every handler's returned hash is merged and passed as context:.
module MyApp
class AvalaraEntityUseCode
def call(workflow)
code = workflow.cart.customer&.metadata&.dig('entity_use_code')
code ? { entity_use_code: code } : {}
end
end
end
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyApp::AvalaraEntityUseCode')
# Admin draft-order edits use the twin key: 'orders.recalculate_totals.set_tax_line_context'
Don't use context for exemptions or the buyer's VAT number — those are typed inputs (below).
TaxLine rows
Spree::TaxLine (tl_): owner (cart or order), exactly one of line_item / fulfillment / fee, amount, rate, label, included, tax_rate (nil for external), provider_id, taxability_reason, country_code / state_code, data (JSON). Snapshots — rates edited or deleted later don't change placed orders.
- Written only by the provider. Admin API
GET /api/v3/admin/orders/:id/tax_linesis read-only. - Copied verbatim from cart to order at completion (no re-estimate); placed orders are money-frozen.
- Tax on delivery → rows on fulfillments; tax on fees → rows on the fee (
fee_id); duties aren't in the default taxable set.
B2B: tax identifiers and exemptions
Tax identifiers (Spree::TaxIdentifier, txi_, kind + value, e.g. eu_vat / NL123456789B01) can belong to a customer, a company (legal entities only), or a cart (checkout-time override). Resolution: cart.resolved_tax_identifier → cart override → company (nearest legal-entity ancestor — never borrowed from a parent) → customer; within each, verified beats most recent. At completion the resolved one is copied onto the order (source: override/company/customer) and becomes read-only. No identifier = consumer sale (safe default).
- Store API:
GET/PUT/DELETE /api/v3/store/carts/:cart_id/tax_identifier(PUT needs bothkindandvalue; re-costs the cart). No SDK helper yet — useclient.request('PUT', \/carts/${id}/tax_identifier`, { body: { kind, value }, spreeToken })`. - Admin:
/api/v3/admin/companies/:id/tax_identifiers(+validate),/api/v3/admin/customers/:id/tax_identifiers. - Validators per kind:
Spree.tax_identifier_validators['au_abn'] = 'MyApp::AbnValidator'(subclassSpree::TaxIdentifiers::Validator::Base:self.valid_format?on save,call(tax_identifier:)for registry checks inSpree::TaxIdentifiers::ValidateJob). The built-ineu_vatvalidator is format-only — plug in a VIES/registry validator if you need verification. Status:pending/verified/unverified/unavailable/unsupported. - Reverse charge from a VAT ID is a provider capability — the Internal provider ignores
tax_identifier.
Exemptions: Spree::TaxExemptionCertificate on a company legal entity, scoped by country_code/state_code, counted only when active (verified and unexpired). Spree::Tax::ResolveExemptions (Spree.tax_resolve_exemptions_service) turns them into Spree::TaxExemption value objects (reason_code, certificate_number, jurisdiction, item_overrides) passed as exemptions:. Exempt items get zero-amount lines with taxability_reason: 'customer_exempt'. Swap the service to read certificates from elsewhere (e.g. Avalara's certificate store). See spree-b2b.
Avalara and other engines
Tax engines plug in as a provider class registered in Spree.tax_providers and selected per market. For Avalara, see node_modules/@spree/docs/dist/integrations/tax/avalara.md — check it describes the provider-based setup for your Spree version before following admin-UI steps. Map TaxCategory#tax_code to the engine's product codes and set a freight code on delivery methods' categories.
Gotchas
- Tax is zero → no
TaxRatematches: wrong store, category mismatch (item uses a category with no rate; items without one use the store'sis_defaultcategory), or no tax address yet on an added-on market. CheckSpree::TaxRate.for_store(cart.store).for_jurisdiction(cart.tax_country&.iso, cart.tax_address&.state_code). - Tax not updating after an admin edit of a placed order — correct: placed orders never re-estimate.
- Creating/editing
TaxLines by hand — overwritten on the next estimate. Change rates, categories, or the provider. - Market
tax_providermust be a registered class name; a gem's provider must be appended toSpree.tax_providersor market validation fails. commitnot idempotent → duplicate documents when completion replays.- Set
Spree::Current.storein jobs/console — rates and default categories are per store.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/taxes.md,companies.md,markets.md,order-totals.mdnode_modules/@spree/docs/dist/developer/customization/workflows.md— context hooks- Related skills:
spree-order-totals,spree-b2b,spree-providers,spree-pricing,spree-checkout - Source:
spree/core/app/models/spree/tax_provider/{base,internal}.rb,spree/core/app/models/concerns/spree/purchase/taxation.rb