docs/plans/6.0-delivery-rate-provider.md
Status: Draft
Target: Spree 6.0
Depends on: Fulfillment & Delivery (6.0-fulfillment-and-delivery.md) for the DeliveryMethod/DeliveryRate rename. Coordinates with: 6.0-multi-vendor-marketplace.md (vendor-scoped method filtering — this plan owns the filter), 6.0-cart-order-split.md (where estimation is invoked pre-completion), 5.6-6.0-single-store-promotions-payment-methods.md (single-store pattern reused for store_id)
Author: Damian + Claude
Last updated: 2026-07-27
Add a pluggable delivery rate provider interface (Spree::DeliveryRateProvider::Base) that abstracts shipping rate estimation behind a clean contract. The default implementation (Spree::DeliveryRateProvider::Internal) delegates to the existing shipping calculators, so behavior is unchanged for every store that doesn't opt in. External providers (EasyPost, ShipStation, Shippo) implement the same interface, enabling real-time carrier rate lookups without monkey-patching Stock::Estimator.
This follows the same open-closed provider pattern as SearchProvider (shipped in 5.4) and TaxProvider (planned for 6.0).
Calculators are kept. An earlier draft of this plan proposed deleting ShippingCalculator and all Calculator::Shipping::* classes in favor of a pricing_type enum plus preferred_amount columns on DeliveryMethod. That is reversed — see "Why calculators stay" below.
Calculators can't call external APIs. ShippingCalculator#compute_package(package) → BigDecimal is a synchronous, stateless computation with no credentials, no error handling, and no caching.
Calculators return a single number. External carriers return structured results — carrier name, service level, delivery date, tracking capability. A BigDecimal can't model "UPS Ground: $8.99, arrives Thu."
No lifecycle hooks. External services need to book a rate on selection and generate labels on fulfillment. Calculators have no post-selection hooks. (FulfillmentProvider in 6.0-fulfillment-and-delivery.md covers post-purchase but not rate estimation.)
No access to order context. Calculators receive a Stock::Package. External APIs need origin address, destination address, parcel dimensions, declared value, residential/commercial flags.
No seam for a whole-basket API call. EasyPost returns rates for every carrier and service in one request. Today the only extension point is per-method, so N methods means N calls.
Note that (1)-(5) are all about the layer above the calculator — where rates come from and what shape they have. None of them are complaints about arithmetic, which is all a calculator does.
Verified against HEAD (4e5b6fd, 2026-07-27).
| What exists | Where | What it does |
|---|---|---|
ShippingCalculator | core/app/models/spree/shipping_calculator.rb:2 | Abstract base; declares compute_shipment, compute_package, available?(package) |
Calculator::Shipping::FlatRate | .../shipping/flat_rate.rb:5 | Fixed amount, plus four suppression thresholds (minimum/maximum_item_total, minimum/maximum_weight) that return nil to hide the method |
Calculator::Shipping::PerItem | .../per_item.rb:5 | preferred_amount × package quantity |
Calculator::Shipping::FlexiRate | .../flexi_rate.rb:5 | first_item + additional_item + max_items; delegates to Spree::Calculator::FlexiRate |
Calculator::Shipping::FlatPercentItemTotal | .../flat_percent_item_total.rb:5 | Percentage of item total, with explicit 2dp rounding |
Calculator::Shipping::PriceSack | .../price_sack.rb:5 | Three-tier: minimal_amount threshold selects normal_amount or discount_amount |
Calculator::Shipping::DigitalDelivery | .../digital_delivery.rb:5 | Flat amount; its real content is available? — only offered when every content item is a digital variant |
Stock::Estimator | core/app/models/spree/stock/estimator.rb | Filters methods, calls calculators, wraps in ShippingRates, applies VAT gross-up, picks + sorts default |
Stock::Package | core/app/models/spree/stock/package.rb | Items + stock location; exposes item_total, weight, quantity, volume, dimension, order, currency |
ShippingMethod | core/app/models/spree/shipping_method.rb | Zones + calculator + display settings. has_prefix_id :dm already |
ShippingRate | core/app/models/spree/shipping_rate.rb | cost + tax_rate + selected. has_prefix_id :dr already |
The registered calculator set is exactly those six (core/lib/spree/core/engine.rb:137-144).
The previous draft of this plan had this diagram wrong. The corrected flow:
Spree::Shipment#refresh_rates(display_filter) # shipment.rb:344 — PRIMARY path
→ guards: return if shipped?; return [] unless can_get_rates?
→ remembers original_shipping_method_id # :348
→ Stock::Estimator.new(order).shipping_rates(to_package, display_filter)
→ re-selects the previously chosen method if still offered # :355-361
Spree::Stock::Coordinator#packages # coordinator.rb:18
→ build_packages → Packer + Spree.stock_splitters # :24-31, :57-64
→ prioritize_packages → Prioritizer/Adjuster # :44-47
→ estimate_packages → Estimator per package # :49-55
Spree::Stock::Estimator#shipping_rates(package, filter) # estimator.rb:13
→ shipping_methods(package, filter) # :61-71
filters on available_to_display?, include?(ship_address),
calculator.available?(package), and calculator currency match
→ shipping_method.calculator.compute(package) # :33 — nil suppresses the method
→ gross_amount(cost, taxation_options_for(method)) # :38 — VAT gross-up
→ ShippingRate.new(cost:, tax_rate: first_tax_rate_for(...))
→ choose_default_shipping_rate (min by cost) + sort # :21-29
Three production entry points reach the Estimator:
Spree::Shipment#refresh_rates (shipment.rb:351) — the load-bearing one.Spree::OrderRouting::Strategy::Rules#estimate_rates (order_routing/strategy/rules.rb:76) — shipped 5.5; runs its own packages → prioritize → estimate pipeline parallel to Coordinator.Spree::Cart::EstimateShippingRates (cart/estimate_shipping_rates.rb:11) — builds a dummy order, estimates only packages.first (:13).Spree::Checkout::GetShippingRates does not call the Estimator directly. It calls order.create_proposed_shipments inside a transaction with create_shipment_tax_charge!, set_shipments_cost, and apply_free_shipping_promotions (checkout/get_shipping_rates.rb:37-45), guarded by if order.shipments.empty? (:33).
The problem statement is about where rates come from and what shape they arrive in. A calculator is a small arithmetic object with a preference form. Those are orthogonal concerns, and the provider seam sits cleanly above the calculator seam.
Replacing calculators with a pricing_type enum + preferred_amount was evaluated and rejected:
flat_rate/per_item/weight/percent/free) has no representation for FlexiRate (three preferences plus delegation to Spree::Calculator::FlexiRate) or PriceSack (three-tier). Reaching parity needs roughly twelve loosely-related nullable columns, most meaningless for any given row — a worse model than a typed subclass with its own preferences.FlatRate's four thresholds and the Estimator's currency gate (estimator.rb:67-69) both suppress a method by yielding nil. An enum switch that returns preferred_amount loses this, changing which methods appear at checkout with no test failing.ShippingCalculator are a long-standing, documented extension point. An enum is closed by construction — the exact opposite of the open-closed principle this plan invokes.DigitalDelivery isn't a pricing type at all. Its substance is available?(package) (digital_delivery.rb:17-19), an availability predicate. It doesn't fit a pricing enum even in principle.Calculators remain the mechanism for internal arithmetic + admin-configurable preferences. Providers own where rates come from, what metadata they carry, and what happens after selection. The Internal provider delegates to the calculator; external providers ignore it.
Provider (source + shape of rates)
└─ Internal → delegates to DeliveryMethod#calculator → BigDecimal → wrapped in an estimate
└─ EasyPost → EasyPost API → estimates with carrier, service level, delivery date
Stock::Estimator stays. It owns responsibilities no provider should re-implement per-vendor:
include?, calculator.available?, currency match) — estimator.rb:61-71Spree::VatPriceCalculation#gross_amount — estimator.rb:4, 38estimator.rb:54-59estimator.rb:21-29The provider is invoked at exactly one point: where the Estimator today calls shipping_method.calculator.compute(package) (estimator.rb:33). That call becomes a provider dispatch. Everything around it is untouched, which is what makes "no behavior change by default" real rather than aspirational.
Corollary: all three Estimator entry points — Shipment#refresh_rates, OrderRouting::Strategy::Rules, and Cart::EstimateShippingRates — get provider support for free, with no per-call-site changes. A design that bypassed the Estimator would have to update all three, and OrderRouting::Strategy::Rules in particular is easy to miss.
Shipment#refresh_rates remembers the previously selected method and re-selects it if still offered (shipment.rb:348, 355-361). This is checkout UX, not rate estimation. It stays exactly where it is. No provider or checkout service may unconditionally min_by(&:cost).update(selected: true) — that would silently reset a customer's or admin's chosen rate on every refresh.
One store can mix providers freely:
Internal (FlatRate calculator, amount 0)SpreeEasyPost::DeliveryRateProvider (UPS Ground)SpreeEasyPost::DeliveryRateProvider (UPS Express)Internalclass Spree::ShippingMethod < Spree.base_class
# nil / blank → Internal. Calculator association is unchanged.
def rate_provider_class
(self[:rate_provider].presence || 'Spree::DeliveryRateProvider::Internal').constantize
end
def rate_provider
rate_provider_class.new(self)
end
end
Stored as a string column and constantized at call time, mirroring Spree.search_provider (core/lib/spree/core.rb:134-139).
store_id (this plan owns the migration)The provider needs a store to resolve credentials, because Spree::Integration is strictly per-store. Spree::ShippingMethod has no store association today — no has_many :stores, no store_id, no SingleStoreResource (shipping_method.rb:25-34; no store_id in spree_shipping_methods). It reaches a store only transitively and in one direction, via Spree::Zone.joins(:shipping_methods) in Store#countries_with_shipping_coverage (store.rb:342).
5.6-6.0-single-store-promotions-payment-methods.md does not cover ShippingMethod — it scopes itself to Promotion and PaymentMethod as "the last two multi-store resources" (:11), which is accurate precisely because ShippingMethod was never store-scoped at all. So no other plan will supply this.
This plan adds store_id, following the established single-owner pattern and advancing the broader goal that every major model carries a store_id:
class Spree::ShippingMethod < Spree.base_class
include Spree::SingleStoreResource
belongs_to :store, class_name: 'Spree::Store'
validates :store, presence: true, unless: -> { Spree::Config[:disable_store_presence_validation] }
end
Spree::SingleStoreResource (core/app/models/concerns/spree/single_store_resource.rb) supplies scope :for_store, before_validation :ensure_store (assigns Spree::Current.store when blank), and a "store can't be changed once set" validation. Same shape as Promotion (promotion.rb:5, 42, 64) and PaymentMethod (payment_method.rb:8, 25, 28).
No join table and no LegacyMultiStoreSupport bridge. Promotion and PaymentMethod needed those because they were migrating away from has_many :stores. ShippingMethod has no sharing semantics to preserve, so there is nothing to deprecate — this is a pure addition. That also makes it the cheapest of the single-store migrations.
Interaction with VendorConcern. ShippingMethod conditionally includes Spree::VendorConcern (shipping_method.rb:10-12) for the marketplace. Store ownership and vendor ownership are different axes: the store is the tenant boundary (and the credential scope); the vendor is the seller within it. A vendor's shipping methods belong to the marketplace's store. Coordinate with 6.0-multi-vendor-marketplace.md before enforcing null: false.
6.0-multi-vendor-marketplace.md requires per-vendor delivery selection at checkout: line items partition into per-vendor packages before the order split, and "a DeliveryMethod with vendor_id is offered only on that vendor's packages; vendor_id: nil methods are marketplace-wide" — and it delegates that filtering to this plan.
Because the provider wraps the Estimator, this lands in exactly one place: Estimator#shipping_methods(package, filter) gains a vendor gate — skip methods whose vendor_id is present and differs from the package's vendor; nil means marketplace-wide. The vendor_id column itself is added by the marketplace plan's schema wave (it replaces today's Enterprise VendorConcern decorator seam at shipping_method.rb:10-12); this plan owns the filter. Per-package granularity is native here — the Estimator already runs per package, which is exactly the granularity per-vendor selection needs. Single-vendor stores are unaffected (vendor_id is nil everywhere, the gate is a no-op).
Verified shipped and matching this design: core/app/models/spree/integration.rb, table spree_integrations.
| Capability | Evidence |
|---|---|
preferences column | schema t.text "preferences" |
can_connect? hook | integration.rb:61-63 |
connection_error_message | integration.rb:28 (attr_accessor, not persisted) |
self.integration_group | integration.rb:33-35 (used as a Spree.t key) |
| Per-store scoping | include Spree::SingleStoreResource (:5), belongs_to :store, touch: true (:10), store_id null: false |
active flag + .active scope | schema (default: false, null: false), integration.rb:21 |
Providers are stateless strategy objects and never store credentials. A provider gem ships both an Integration subclass and a Provider subclass:
class Spree::Integrations::EasyPost < Spree::Integration
preference :api_key, :string
preference :mode, :string, default: 'test'
def self.integration_group = 'shipping'
def can_connect?
EasyPost::Client.new(api_key: preferred_api_key).address.create(
street1: '417 Montgomery St', city: 'San Francisco', state: 'CA', zip: '94104', country: 'US'
)
true
rescue => e
self.connection_error_message = e.message
false
end
end
Two constraints to design around:
validates :store, presence: true, uniqueness: { scope: :type } (integration.rb:16). A store cannot hold two EasyPost credential sets. Multi-account support is a separate change; see the grooming note in 5.7-payment-method-rules.md.Spree::Integrations:: namespace — there is no core/app/models/spree/integrations/ directory, so integration_group, icon_path, integration_name, and integration_key (:33-47) are currently unexercised. The EasyPost provider gem would be the first real consumer.Internal providers need no Integration.
Multiple DeliveryMethods can share one external provider, and a single API call should serve them all. Use Spree::Current (core/app/models/spree/current.rb) — an ActiveSupport::CurrentAttributes subclass already used for per-request memoization with proper resets semantics (:9) and lazy-load guards (:59-66).
RequestStore is used in core in exactly one file (core/lib/spree/events.rb) and arrives transitively through Mobility (mobility-1.3.2 depends on request_store ~> 1.0) — it is not declared in spree_core.gemspec. Two independent actions:
request_store to spree_core.gemspec regardless of this plan. events.rb already depends on it at runtime; relying on a transitive dependency for shipped code is a latent break if Mobility ever drops it.Spree::Current for new provider code — it is the in-repo convention and resets correctly between requests and jobs.module Spree
module DeliveryRateProvider
# Structured result. `cost` is pre-tax and pre-VAT-gross-up; the Estimator
# applies gross_amount and tax rate resolution, same as it does for
# calculator output today.
DeliveryRateEstimate = Struct.new(
:cost,
:carrier,
:service_level,
:estimated_delivery_date,
:estimated_transit_days,
:metadata,
keyword_init: true
)
class Base
attr_reader :delivery_method
def initialize(delivery_method)
@delivery_method = delivery_method
end
# Estimate the rate for a package.
#
# @param package [Spree::Stock::Package]
# @return [DeliveryRateEstimate, nil] nil suppresses this method,
# matching the calculator contract where nil hides the method
def estimate(package)
raise NotImplementedError
end
# Whether this provider can serve the store at all. Used by the admin UI
# to hide providers whose Integration isn't connected.
#
# @param store [Spree::Store]
# @return [Boolean]
def self.available_for_store?(_store)
true
end
# Lifecycle hooks — no-ops by default, mirroring SearchProvider::Base
# (search_provider/base.rb:41-80). Only #estimate is required.
def book(_delivery_rate); end
def release(_delivery_rate); end
private
def store
delivery_method.store
end
def integration
return if self.class.integration_class.blank?
@integration ||= store.integrations.active.find_by(type: self.class.integration_class)
end
def self.integration_class = nil
end
end
end
nil from #estimate suppresses the method — deliberately identical to the calculator contract (estimator.rb:35), so FlatRate's thresholds keep working unchanged through the Internal provider.
The entire body is a delegation. This is the point:
module Spree
module DeliveryRateProvider
class Internal < Base
def estimate(package)
cost = delivery_method.calculator.compute(package)
return nil if cost.nil?
DeliveryRateEstimate.new(
cost: cost,
carrier: nil,
service_level: nil,
estimated_delivery_date: estimated_delivery_date,
estimated_transit_days: delivery_method.estimated_transit_business_days_min,
metadata: {}
)
end
private
def estimated_delivery_date
days = delivery_method.estimated_transit_business_days_min
return nil if days.blank?
days.business_days.from_now.to_date
end
end
end
end
Every calculator — including merchant subclasses — keeps working, thresholds and all. estimated_transit_business_days_min/_max are existing columns (schema spree_shipping_methods), validated at shipping_method.rb:37-38 and already surfaced through #delivery_range (:92-100) and ShippingRate#delivery_range (shipping_rate.rb:78-91).
The single dispatch point. Note the filtering, VAT, tax, and sort logic around it is unchanged:
def calculate_shipping_rates(package, ui_filter)
shipping_methods(package, ui_filter).map do |shipping_method|
estimate = shipping_method.rate_provider.estimate(package)
next if estimate.nil?
shipping_method.shipping_rates.new(
cost: gross_amount(estimate.cost, taxation_options_for(shipping_method)),
tax_rate: first_tax_rate_for(shipping_method.tax_category),
carrier: estimate.carrier,
service_level: estimate.service_level,
estimated_delivery_date: estimate.estimated_delivery_date,
metadata: estimate.metadata
)
end.compact
end
#shipping_methods (estimator.rb:61-71) is untouched, so calculator.available?(package) still gates DigitalDelivery and the currency check still applies. ShippingMethod#calculator therefore stays required even for external providers — it supplies available?. External providers can use a FlatRate calculator with amount 0 as a no-op; the cost it computes is ignored.
module SpreeEasyPost
class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
def self.integration_class = 'Spree::Integrations::EasyPost'
def self.available_for_store?(store)
store.integrations.active.exists?(type: integration_class)
end
def estimate(package)
rate = easypost_shipment(package).rates.detect do |r|
r.carrier == delivery_method.metadata['carrier'] &&
r.service == delivery_method.metadata['service']
end
return nil if rate.nil?
Spree::DeliveryRateProvider::DeliveryRateEstimate.new(
cost: rate.rate.to_d,
carrier: rate.carrier,
service_level: rate.service,
estimated_delivery_date: rate.delivery_date,
estimated_transit_days: rate.delivery_days,
metadata: { easypost_rate_id: rate.id, easypost_shipment_id: rate.shipment_id }
)
end
private
def client
@client ||= EasyPost::Client.new(api_key: integration.preferred_api_key)
end
# One API call serves every DeliveryMethod sharing this provider within a request.
def easypost_shipment(package)
key = [:easypost_shipment, store.id, package.stock_location.id, package.order.id]
Spree::Current.provider_cache[key] ||= client.shipment.create(
from_address: address_from_stock_location(package.stock_location),
to_address: address_from_order(package.order),
parcel: parcel_from_package(package)
)
end
end
end
Spree::Current.provider_cache is a new CurrentAttributes entry (a plain Hash, reset per request) added by this plan.
New nullable columns on spree_shipping_rates: carrier (string), service_level (string), estimated_delivery_date (date), plus the Spree::Metadata concern (single metadata JSON column per the 2026-03-16 metadata-consolidation decision in decisions.md). ShippingRate does not currently include Metadata (unlike ShippingMethod at :7-8), so the concern must be added alongside the columns.
Constraint to respect: spree_shipping_rates has a unique index on ["shipment_id", "shipping_method_id"] (spree_shipping_rates_join_index). One row per (shipment, method) is why each DeliveryMethod yields at most one rate. A provider wanting to expose Ground and Express must be configured as two DeliveryMethods — which is also what makes the shared-API-call caching above worthwhile. Do not attempt multi-rate-per-method without first deciding whether to drop that index.
The Store API already speaks this vocabulary: Spree::Api::V3::DeliveryRateSerializer exists and FulfillmentSerializer exposes many :shipping_rates, key: :delivery_rates (fulfillment_serializer.rb:55). New fields are added there and to the admin serializer.
add_reference :spree_shipping_methods, :store, null: true, if_not_exists: true, following 20260628000001_add_store_id_to_spree_promotions.rb.ShippingMethod includes SingleStoreResource, declares belongs_to :store + presence validation gated on Spree::Config[:disable_store_presence_validation].Spree::Store.default when only one store exists (the overwhelmingly common case); where multiple stores exist, use the zone-overlap signal from Store#countries_with_shipping_coverage (store.rb:342) and log every ambiguous method loudly rather than guessing silently. Idempotent — skip rows that already have store_id.shipping_method_factory.rb and the admin controller (shipping_methods_controller.rb) to set/scope by store.null: false is deferred to the 6.0 cleanup, after the vendor interaction is settled.Base, Internal, DeliveryRateEstimate, and Spree::Current.provider_cache.rate_provider string column to spree_shipping_methods (nullable; blank means Internal).Estimator#calculate_shipping_rates.calculator.compute and all filtering/VAT/tax/sort logic is unchanged, any diff in rates is a bug.carrier, service_level, estimated_delivery_date columns + the Metadata concern.DeliveryRateEstimate in the Estimator.rate_provider on the delivery-method serializer/permitted params, plus a provider-discovery endpoint (available providers filtered by available_for_store?(store)); admin-sdk resource + generated types.PreferencesForm, per the 2026-07-21 routing-rules pattern), <Can>-gated, i18n in all locale files._calculator_fields.html.erb and shipping-method form are deleted with spree/admin at 6.0 — no work there. (Any 5.x interim wiring may touch them; note the partial is shared with tax rates and promotion actions.)Stock::Estimator. The provider dispatch is the intended seam.ShippingCalculator or Calculator::Shipping::*. They are load-bearing and staying. CalculatedAdjustments is shared with TaxRate and promotion actions; its fate belongs to 6.0-tax-provider.md and 6.0-split-adjustments.md, not here.pricing_type / preferred_amount columns to ShippingMethod. Superseded; see "Why calculators stay."selected on refresh. Preserve the existing behavior at shipment.rb:355-361.FulfillmentProvider is separate. Don't mix rate estimation into it.Rate caching TTL. Per-request caching is specified above. Cross-request caching (5-minute TTL for expensive carrier calls) is left to the provider — different APIs have different staleness rules. Revisit if every provider ends up reimplementing it.
Multi-package rates. Carriers sometimes discount multi-package shipments. The Estimator processes packages independently (coordinator.rb:50-53), and Cart::EstimateShippingRates is stricter still — it estimates only packages.first (estimate_shipping_rates.rb:13), discarding the rest. Keeping one-package-at-a-time for now; a multi-package hook is a later addition.
Rate validation at completion. Rates can expire between checkout and payment. Add validate_rate(delivery_rate) to the base class, or re-estimate and compare at completion? Leaning toward a lifecycle hook alongside book/release.
Backfill heuristic for multi-store installs. The zone-overlap signal in Phase 1 is imperfect where several stores share a zone. Is loud logging plus manual reassignment sufficient, or should the task refuse to guess and require an explicit mapping?
6.0-fulfillment-and-delivery.md)Renames the domain (ShippingMethod → DeliveryMethod, ShippingRate → DeliveryRate, Shipment → Fulfillment) and adds FulfillmentProvider for post-purchase lifecycle.
Pre-purchase: DeliveryRateProvider → "What are the options and what do they cost?"
Post-purchase: FulfillmentProvider → "Create the shipment, generate a label, track it"
A DeliveryMethod carries both rate_provider and fulfillment_provider. Note the API layer already uses the new vocabulary — DeliveryRateSerializer, DeliveryMethodSerializer, and the Spree.api.delivery_* registrations (api/lib/spree/api/dependencies.rb:127-128, 209, 213) all ship today, and the models already carry has_prefix_id :dm / :dr. Only the model/table names are outstanding.
Sequencing: that plan drops ShippingCategory, which is both a stock splitter (engine.rb:156) and the discriminator behind ShippingMethod#digital? / #requires_zone_check? / .digital (shipping_method.rb:42, 57, 128). Those read calculator.is_a?(DigitalDelivery). Since this plan keeps calculators, that coupling is preserved rather than broken — but if the fulfillment plan introduces a kind/fulfillment_type discriminator, these three call sites plus MEMOIZED_METHODS (:17) should move to it.
6.0-cart-order-split.md)Checkout — and therefore rate estimation — happens on the Cart under copy-on-completion semantics. This plan is deliberately agnostic: it changes only the inside of the Estimator, which runs per package wherever checkout invokes it. Where packages, delivery rates, and the selected rate live pre-completion (and how they copy to the order at cart.complete!) is owned by the cart-order-split and fulfillment plans; nothing in the provider contract assumes an Order.
6.0-tax-provider.md)Same open-closed pattern. Note the coupling: Stock::Estimator depends on Order#tax_zone for both VAT gross-up (estimator.rb:38) and tax rate resolution (:54-59), so the shipping path is affected by tax-side Zone changes. This also answers the tax plan's open question about shipping-rate tax display: it stays Estimator-side (gross_amount + tax_rate on the rate), not in the delivery-rate provider.
The reference implementation. Spree.search_provider / search_provider= at core/lib/spree/core.rb:134-139, string-valued and constantized by the caller; SearchProvider::Base at core/app/models/spree/search_provider/base.rb.
| Search | Tax | Delivery Rates | |
|---|---|---|---|
| Scope | Global (Spree.search_provider) | Per-Market | Per-method (DeliveryMethod#rate_provider) |
| Initialized with | store (base.rb:12) | market | delivery_method (store via delivery_method.store) |
| Wraps | Ransack/ILIKE | TaxRate.adjust + Calculator | Stock::Estimator + Calculator |
| Internal | Database (SQL) | Reads TaxRate data | Delegates to the existing calculator |
| External | Meilisearch | Avalara / TaxJar | EasyPost / Shippo |
| Calculators | N/A | Decided in tax plan | Kept |
| Credentials | Spree::Integration | Spree::Integration | Spree::Integration |
SearchProvider::Base raises NotImplementedError only for the two required methods and no-ops the rest (:41-80) — the delivery Base above mirrors that.
6.0-delivery-zones.md)Zone replacement is not this plan's scope, but it ships in 6.0. The earlier draft asserted Spree::Zone is "dropped entirely" while owning none of the ~183 non-spec references (Spree::Current.zone, Market#tax_zone, Order#tax_zone, VatPriceCalculation, Pricing::Context, permission sets, admin CRUD, the Zone.global factory monkey-patch every :shipping_method factory depends on). That inventory now has a dedicated owner: 6.0-delivery-zones.md (2026-07-27 — 6.0 is the breaking-change window; this does not wait for 6.1).
Impact here is minimal by design: the zone check inside Estimator#shipping_methods swaps from ShippingMethod#include? over Spree::Zone to the same-shaped check over DeliveryZone; the provider interface never sees zones. Sequencing: this plan's phases don't wait for delivery-zones — the Estimator filter swap happens in that plan's Phase C.
spree/core/app/models/spree/stock/estimator.rbspree/core/app/models/spree/calculator/shipping/spree/core/app/models/spree/shipping_method.rbspree/core/app/models/spree/shipping_rate.rbspree/core/app/models/spree/stock/coordinator.rbspree/core/app/models/spree/shipment.rb:344-361spree/core/app/models/spree/order_routing/strategy/rules.rb:76spree/core/app/models/spree/integration.rbspree/core/app/models/concerns/spree/single_store_resource.rbspree/core/lib/tasks/single_store_associations.rakespree/core/app/models/spree/search_provider/6.0-fulfillment-and-delivery.md, 6.0-tax-provider.md, 5.6-6.0-single-store-promotions-payment-methods.md, 6.0-multi-vendor-marketplace.md