Back to Spree

Delivery Rate Provider Interface

docs/plans/6.0-delivery-rate-provider.md

5.6.134.4 KB
Original Source

Delivery Rate Provider Interface

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

Summary

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.

Problem

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

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

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

  4. No access to order context. Calculators receive a Stock::Package. External APIs need origin address, destination address, parcel dimensions, declared value, residential/commercial flags.

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

Current State

Verified against HEAD (4e5b6fd, 2026-07-27).

What existsWhereWhat it does
ShippingCalculatorcore/app/models/spree/shipping_calculator.rb:2Abstract base; declares compute_shipment, compute_package, available?(package)
Calculator::Shipping::FlatRate.../shipping/flat_rate.rb:5Fixed 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:5preferred_amount × package quantity
Calculator::Shipping::FlexiRate.../flexi_rate.rb:5first_item + additional_item + max_items; delegates to Spree::Calculator::FlexiRate
Calculator::Shipping::FlatPercentItemTotal.../flat_percent_item_total.rb:5Percentage of item total, with explicit 2dp rounding
Calculator::Shipping::PriceSack.../price_sack.rb:5Three-tier: minimal_amount threshold selects normal_amount or discount_amount
Calculator::Shipping::DigitalDelivery.../digital_delivery.rb:5Flat amount; its real content is available? — only offered when every content item is a digital variant
Stock::Estimatorcore/app/models/spree/stock/estimator.rbFilters methods, calls calculators, wraps in ShippingRates, applies VAT gross-up, picks + sorts default
Stock::Packagecore/app/models/spree/stock/package.rbItems + stock location; exposes item_total, weight, quantity, volume, dimension, order, currency
ShippingMethodcore/app/models/spree/shipping_method.rbZones + calculator + display settings. has_prefix_id :dm already
ShippingRatecore/app/models/spree/shipping_rate.rbcost + tax_rate + selected. has_prefix_id :dr already

The registered calculator set is exactly those six (core/lib/spree/core/engine.rb:137-144).

Key flow today

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:

  1. Spree::Shipment#refresh_rates (shipment.rb:351) — the load-bearing one.
  2. 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.
  3. 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).

Why calculators stay

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:

  • It can't express what ships today. A five-case enum (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.
  • It silently drops behavior. 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.
  • It breaks every custom calculator with no migration path. Merchant subclasses of 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.
  • It buys nothing. Every motivating problem is solved by the provider layer. Deleting calculators is orthogonal scope that only adds risk.

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

Key Decisions (do not deviate without discussion)

Provider wraps the Estimator; it does not replace it

Stock::Estimator stays. It owns responsibilities no provider should re-implement per-vendor:

  • method filtering (display target, zone include?, calculator.available?, currency match) — estimator.rb:61-71
  • VAT gross-up via Spree::VatPriceCalculation#gross_amountestimator.rb:4, 38
  • tax rate resolution — estimator.rb:54-59
  • default selection + sorting — estimator.rb:21-29

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

Selected-rate preservation is not the provider's business

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.

Provider is per delivery method, resolved through the method's store

One store can mix providers freely:

  • "Free Shipping" → Internal (FlatRate calculator, amount 0)
  • "Standard" → SpreeEasyPost::DeliveryRateProvider (UPS Ground)
  • "Express" → SpreeEasyPost::DeliveryRateProvider (UPS Express)
  • "Local Pickup" → Internal
ruby
class 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).

DeliveryMethod gets 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:

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

Vendor-scoped methods filter in the Estimator (marketplace)

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

Credentials live in Spree::Integration

Verified shipped and matching this design: core/app/models/spree/integration.rb, table spree_integrations.

CapabilityEvidence
preferences columnschema t.text "preferences"
can_connect? hookintegration.rb:61-63
connection_error_messageintegration.rb:28 (attr_accessor, not persisted)
self.integration_groupintegration.rb:33-35 (used as a Spree.t key)
Per-store scopinginclude Spree::SingleStoreResource (:5), belongs_to :store, touch: true (:10), store_id null: false
active flag + .active scopeschema (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:

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

  • One integration per (store, type)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.
  • Core ships no 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.

Per-request caching uses Spree::Current, not RequestStore

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:

  1. Add 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.
  2. Still prefer Spree::Current for new provider code — it is the in-repo convention and resets correctly between requests and jobs.

Design Details

Provider base class

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

Internal provider (default)

The entire body is a delegation. This is the point:

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

Estimator integration

The single dispatch point. Note the filtering, VAT, tax, and sort logic around it is unchanged:

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

External provider example (EasyPost)

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

ShippingRate enrichment

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.

Migration Path

Phase 1 — store ownership (independent, ship first)

  • Migration: 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].
  • Backfill rake task. Unlike Promotion/PaymentMethod there is no join table to read from, so ownership is derived: assign 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.
  • Update 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.

Phase 2 — provider interface + Internal provider

  • Add Base, Internal, DeliveryRateEstimate, and Spree::Current.provider_cache.
  • Add the rate_provider string column to spree_shipping_methods (nullable; blank means Internal).
  • Wire the dispatch in Estimator#calculate_shipping_rates.
  • Zero behavior change is the acceptance criterion: the full existing shipping suite passes untouched. Because Internal delegates to calculator.compute and all filtering/VAT/tax/sort logic is unchanged, any diff in rates is a bug.

Phase 3 — enrich ShippingRate

  • Add carrier, service_level, estimated_delivery_date columns + the Metadata concern.
  • Populate from DeliveryRateEstimate in the Estimator.
  • Expose in the Store and Admin serializers; regenerate types via the pipeline in CLAUDE.md.

Phase 4 — admin surface (React dashboard only — no Rails admin in 6.0)

  • Admin API v3: 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.
  • Dashboard delivery-method page: provider select + schema-driven calculator preference forms (PreferencesForm, per the 2026-07-21 routing-rules pattern), <Can>-gated, i18n in all locale files.
  • The legacy admin's _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.)

Phase 5 — reference external provider

  • Ship an EasyPost provider gem and a how-to guide, mirroring the custom-search-provider doc.

Constraints on Current Work

  • Don't add new shipping calculators for external carrier APIs. Calculators are for internal arithmetic. Use the provider interface.
  • Don't monkey-patch Stock::Estimator. The provider dispatch is the intended seam.
  • Don't delete or deprecate 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.
  • Don't add pricing_type / preferred_amount columns to ShippingMethod. Superseded; see "Why calculators stay."
  • New ShippingRate fields should fit the enriched model (carrier, service_level, estimated_delivery_date, metadata).
  • Never unconditionally set selected on refresh. Preserve the existing behavior at shipment.rb:355-361.
  • FulfillmentProvider is separate. Don't mix rate estimation into it.

Open Questions

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

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

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

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

Relationship to Other 6.0 Plans

Fulfillment & Delivery (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.

Cart/Order Split (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.

Tax Provider (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.

Search Provider (shipped 5.4)

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.

SearchTaxDelivery Rates
ScopeGlobal (Spree.search_provider)Per-MarketPer-method (DeliveryMethod#rate_provider)
Initialized withstore (base.rb:12)marketdelivery_method (store via delivery_method.store)
WrapsRansack/ILIKETaxRate.adjust + CalculatorStock::Estimator + Calculator
InternalDatabase (SQL)Reads TaxRate dataDelegates to the existing calculator
ExternalMeilisearchAvalara / TaxJarEasyPost / Shippo
CalculatorsN/ADecided in tax planKept
CredentialsSpree::IntegrationSpree::IntegrationSpree::Integration

SearchProvider::Base raises NotImplementedError only for the two required methods and no-ops the rest (:41-80) — the delivery Base above mirrors that.

Zones (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.

References

  • Estimator: spree/core/app/models/spree/stock/estimator.rb
  • Calculators: spree/core/app/models/spree/calculator/shipping/
  • Shipping method: spree/core/app/models/spree/shipping_method.rb
  • Shipping rate: spree/core/app/models/spree/shipping_rate.rb
  • Coordinator: spree/core/app/models/spree/stock/coordinator.rb
  • Shipment refresh path: spree/core/app/models/spree/shipment.rb:344-361
  • Order routing estimator: spree/core/app/models/spree/order_routing/strategy/rules.rb:76
  • Integration: spree/core/app/models/spree/integration.rb
  • SingleStoreResource: spree/core/app/models/concerns/spree/single_store_resource.rb
  • Single-store backfill precedent: spree/core/lib/tasks/single_store_associations.rake
  • Search provider (reference): spree/core/app/models/spree/search_provider/
  • Related plans: 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