spree-taxes
Use when the user is configuring or extending tax in Spree 6 — tax categories (`tax_code`), tax rates by `country_code`/`state_code`, VAT-inclusive vs added-on pricing (`included_in_price`, market `tax_inclusive`), per-market tax providers and `Spree.default_tax_provider`, writing a custom tax provider (`estimate`/`commit`/`void`/`refund`), `Spree::TaxLine` rows, the `carts.recalculate_totals.set_tax_line_context` hook, B2B tax identifiers (VAT numbers) and exemption certificates, or connecting Avalara/an external tax engine. Common phrasings include "tax not calculated", "tax is zero", "VAT included in price", "EU VAT", "US sales tax", "tax per state", "integrate Avalara / TaxJar / Stripe Tax", "custom tax calculation", "tax exempt customer", "reverse charge", "VAT number at checkout", "tax on shipping", "tax on fees".
How do I install this agent skill?
npx skills add https://github.com/spree/agent-skills --skill spree-taxesIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides documentation and code examples for managing tax calculation logic in Spree 6. It describes standard practices for configuring tax categories, rates, and custom providers, but involves processing external data like tax identifiers that could serve as an attack surface for indirect prompt injection.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Spree 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
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/spree/agent-skills/spree-taxes">View spree-taxes on skillZs</a>