Back to Spree

Tax Provider Interface

docs/plans/6.0-tax-provider.md

5.4.228.9 KB
Original Source

Tax Provider Interface

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

Summary

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.

Problem

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Current State

What existsWhereWhat it does
TaxRate modelcore/app/models/spree/tax_rate.rbStores rate %, zone, tax category. adjust() is the entry point.
TaxRate.match(zone)TaxRate class methodFinds rates whose zone includes the order's tax zone
Calculator::DefaultTaxcore/app/models/spree/calculator/Computes tax amount for line items, shipments, shipping rates
VatPriceCalculationcore/app/models/concerns/Handles included-in-price tax math
TaxRate.store_pre_tax_amountTaxRate class methodComputes and stores pre-tax amounts for VAT calculations
Adjuster::Taxcore/app/models/spree/adjustable/adjuster/Orchestrates tax adjustment updates during recalculation
TaxCategorycore/app/models/spree/tax_category.rbGroups products/variants by tax type
Zone.match(address)Zone class methodFinds geographic zone for an address
Order#tax_zoneOrder modelResolves order's zone from ship/bill address + default zone fallback
Spree::Config[:tax_using_ship_address]ConfigControls whether ship or bill address determines tax zone
Spree.adjustersEngine configRegistry of adjuster classes (includes Tax adjuster)

Key flow today

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)

Why calculators alone can't solve this

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:

  1. Order-level transactions — Avalara/TaxJar price the entire order in one API call, returning tax per line item. Calculators work item-by-item with no cross-item coordination.
  2. Lifecycle events — No hooks for commit (order completed), void (order cancelled), refund (items returned). External services distinguish estimate vs committed transactions for filing/reporting.
  3. State storage — Calculators are stateless. External providers need to store transaction IDs (e.g., avalara_transaction_id) on the order for later commit/void.
  4. Exemption logic — Need to check company exemption certificates before calling the API. Calculators only compute amounts.
  5. Entry point control — Calculators are called inside the adjustment flow via 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

Key Decisions (do not deviate without discussion)

Provider replaces TaxRate.adjust entirely

  • 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).
  • All call sites that called TaxRate.adjust (Order, LineItem, checkout services) now call the provider via Spree.tax_provider.

Provider contract: estimate and commit

ruby
# 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
  • Estimate is called during checkout recalculation. It's idempotent — repeated calls update amounts.
  • Commit is called on order completion. For external providers, this creates a transaction in the tax service (for filing/reporting). For the internal provider, it's a no-op.
  • Void is called on order cancellation. Reverses the committed tax document.

Provider writes TaxLine records directly

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).

Credentials live in Spree::Integration, not the provider

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:

  • Per-store persistence — credentials stored in the preferences column
  • Admin UI — dynamic forms for non-developer configuration
  • Connection validationcan_connect? hook tests credentials before saving
  • Activation trackingactive boolean flag

A provider gem (e.g., spree_tax_avalara) ships both an Integration subclass and a Provider subclass:

ruby
# 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:

  1. Admin navigates to Integrations → connects Avalara (enters API key, tests connection)
  2. Admin navigates to Market "US" → selects "Avalara" as tax provider
  3. At runtime, the provider reads credentials from the Integration record

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:

ruby
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

Provider is per-Market

Markets represent regional commerce operations. Each market can have its own tax provider, matching how Shopify and Medusa handle it:

ruby
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:

ruby
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).

ruby
# 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):

ProviderScopeCredentialsWhy
SearchGlobal (Spree.search_provider)Spree::IntegrationOne search engine per instance
TaxPer-Market (Market#tax_provider)Spree::IntegrationDifferent tax rules/services per region
Delivery ratesPer-DeliveryMethod (DeliveryMethod#rate_provider)Spree::IntegrationDifferent carriers per shipping option

Tax exemption is part of the provider contract

ruby
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.

Internal provider computes directly from TaxRate data

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.

Design Details

Provider Base Class

ruby
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

Internal Provider (default)

Computes tax directly from TaxRate records — no Calculator::DefaultTax:

ruby
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

External Provider Example (Avalara)

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):

ruby
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

Integration Point: Order Tax Calculation

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:

ruby
# 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

Integration Point: Order Completion

ruby
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

Integration Point: Order Cancellation

ruby
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"
}

Tax Exemption Certificates (B2B extension)

Building on the Company model from 6.0-channels-catalogs-b2b.md:

ruby
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:

ruby
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

Migration Path

Phase 1: Provider interface + Internal provider

  • Create Spree::TaxProvider::Base and Spree::TaxProvider::Internal
  • Internal provider delegates to existing TaxRate.adjust — zero behavior change
  • Add tax_provider preference to Store
  • No adjuster changes yet — just the interface + default implementation

Phase 2: Wire into adjuster

  • Modify Adjuster::Tax to delegate to provider instead of calling TaxRate directly
  • Add commit call to Carts::Complete
  • Add void call to Orders::Cancel
  • Add refund hook to returns flow
  • All existing behavior preserved via Internal provider

Phase 3: Tax exemption certificates

ruby
class 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
  • Create TaxExemptionCertificate model
  • Add to Company: has_many :tax_exemption_certificates
  • Provider exempt? checks certificates
  • Admin API for certificate CRUD

Phase 4: Adapt to Split Adjustments

When 6.0-split-adjustments.md ships:

  • Internal provider writes TaxLine records instead of Adjustment records
  • External providers also write TaxLine records
  • Adjuster reads from tax_lines instead of adjustments.tax
  • TaxRate.adjust internally updated to create TaxLines

Phase 5: Drop Zone model + data migration

Coordinate with 6.0-delivery-rate-provider.md — both plans share this migration:

ruby
# 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
  • Add country_id and state_id columns to spree_tax_rates
  • Run data migration rake task
  • Remove zone_id FK from TaxRate
  • Drop spree_zones and spree_zone_members tables (shared with Delivery Rate Provider plan)

Phase 6: Reference external provider (separate gem)

  • Create spree_tax_avalara gem as reference implementation
  • Implements estimate/commit/void/refund against Avalara AvaTax API
  • Handles: nexus detection, exemption certificate validation, address validation
  • Documents how to build custom providers

Constraints on Current Work

  • Don't add new tax logic to TaxRate.adjust. New tax behavior should be designed for the provider interface. TaxRate.adjust stays as the Internal provider's implementation.
  • Don't add tax-related configuration beyond Spree::Config[:tax_using_ship_address]. Provider-specific config belongs on the provider (via Store preferences or metadata), not in global Spree config.
  • Don't monkey-patch TaxRate for external tax services. The provider interface is the intended seam.
  • Tax exemption checks should use provider.exempt?(order). Don't add ad-hoc exemption checks in controllers or services.
  • TaxCategory stays as the product-level tax classification. External providers use tax category as the "tax code" mapping (e.g., TaxCategory "Clothing" → Avalara tax code "PC040100").

Open Questions

  1. 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.

  2. 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.

  3. 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.

  4. 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.

References

  • Current tax rate: spree/core/app/models/spree/tax_rate.rb
  • Current calculator: spree/core/app/models/spree/calculator/default_tax.rb
  • Current VAT concern: spree/core/app/models/concerns/spree/vat_price_calculation.rb
  • Current tax adjuster: spree/core/app/models/spree/adjustable/adjuster/tax.rb
  • Current order updater: spree/core/app/models/spree/order_updater.rb
  • Current zone matching: spree/core/app/models/spree/zone.rb
  • AdjustmentSource concern: spree/core/app/models/concerns/spree/adjustment_source.rb
  • Related plan: 6.0-split-adjustments.md (TaxLine model replaces polymorphic Adjustment for tax)
  • Related plan: 6.0-channels-catalogs-b2b.md (Company.tax_exempt, CompanyLocation.tax_exempt)
  • Related plan: 6.0-returns-exchanges-claims.md (Return flow triggers provider.refund)