Back to Spree

Delivery Rate Provider Interface

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

5.4.223.3 KB
Original Source

Delivery Rate Provider Interface

Status: Draft Target: Spree 6.0 Depends on: Fulfillment & Delivery (6.0-fulfillment-and-delivery.md) for DeliveryMethod rename Author: Damian + Claude Last updated: 2026-04-05

Summary

Add a pluggable delivery rate provider interface (Spree::DeliveryRateProvider::Base) that abstracts shipping rate calculation behind a clean contract. The default implementation (Spree::DeliveryRateProvider::Internal) preserves today's ShippingCalculator behavior (flat rate, per-item, weight-based — configured in admin). External providers (EasyPost, ShipStation, Shippo) implement the same interface, enabling real-time carrier rate lookups without monkey-patching.

This follows the same open-closed provider pattern as SearchProvider (shipped in 5.4) and TaxProvider (planned for 6.0).

Problem

  1. Calculators can't call external APIs. ShippingCalculator.compute_package(package) → BigDecimal is a synchronous, stateless computation. There's no way to call EasyPost/UPS/FedEx for real-time rates, handle API failures, or cache responses.

  2. Calculators return a single number. External carriers return multiple service levels (Ground, Express, Overnight) with delivery dates, carrier names, and tracking capabilities. A calculator returns one BigDecimal — it can't model "UPS Ground: $8.99, arrives Thu" vs "UPS Express: $24.99, arrives tomorrow".

  3. No lifecycle hooks. When a customer selects a rate, external services need to "book" it. When an order ships, they need to create a shipment and generate labels. Calculators have no hooks for these events. (The FulfillmentProvider in 6.0-fulfillment-and-delivery.md handles post-purchase lifecycle but NOT rate calculation.)

  4. One calculator per shipping method. Each ShippingMethod has exactly one calculator. But EasyPost returns rates for all carriers/services in one API call. You'd need N shipping methods (one per carrier service) each with a custom calculator that calls the same API — wasteful and fragile.

  5. No access to order context. Calculators receive a Stock::Package with items and weights. But external APIs need: origin address (stock location), destination address, package dimensions, declared value, insurance requirements, residential/commercial flag, hazmat indicators.

Current State

What existsWhereWhat it does
ShippingCalculatorBase classcompute_package(package) → BigDecimal
Calculator::Shipping::FlatRateBuilt-inFixed amount per order
Calculator::Shipping::PerItemBuilt-inPrice × quantity
Calculator::Shipping::FlexiRateBuilt-inTiered pricing (first item + additional)
Calculator::Shipping::FlatPercentItemTotalBuilt-inPercentage of cart total
Calculator::Shipping::PriceSackBuilt-inConditional pricing by threshold
Calculator::Shipping::DigitalDeliveryBuilt-inZero cost for digital products
Stock::EstimatorRate engineIterates shipping methods, calls calculators, creates ShippingRates
Stock::PackageInput modelItems + stock location → passed to calculators
ShippingMethodConfig modelZone + calculator + display settings
ShippingRateResult modelcost + tax_rate + selected flag

Key flow today

Checkout::GetShippingRates
  → Stock::Coordinator.packages(order)
    → Packer → Splitters → Package objects
  → Stock::Estimator.shipping_rates(package)
    → shipping_methods(package)  # filter by zone, availability, currency
    → shipping_method.calculator.compute(package)  # ONE number per method
    → ShippingRate.new(cost: result)
  → Customer sees list of rates, selects one

Why calculators alone can't solve this

Same pattern as tax — calculators answer "how much?" but can't answer "what options are available?" or "what happens after selection?":

  1. Stateless per-item — Calculator gets a Package, returns a BigDecimal. Can't make API calls, handle errors, cache results, or return structured data (carrier, service level, delivery date).
  2. One result per method — Each ShippingMethod + Calculator = one rate. External APIs return multiple rates per call.
  3. No booking/lifecycle — After customer selects a rate, external services need to book it, generate labels, track shipment. Calculators have no post-selection hooks.
  4. No provider initialization — Calculators don't receive API credentials, can't be configured per-store.

The provider replaces calculators entirely in 6.0:

Provider (rate calculation)
  └─ Internal provider → reads DeliveryMethod pricing config → computes directly
  └─ EasyPost provider → EasyPost API → returns multiple DeliveryRate objects

The Calculator base class, ShippingCalculator, CalculatedAdjustments concern, and all Calculator::Shipping::* classes are removed. The Internal provider implements rate computation directly from DeliveryMethod preferences (flat rate amount, per-item amount, weight thresholds).

Key Decisions (do not deviate without discussion)

Provider replaces Stock::Estimator and Calculator entirely

  • Stock::Estimator is removed. The provider is the only entry point for delivery rate estimation. Zone filtering and method availability are handled inside the provider (or as shared utilities).
  • ShippingCalculator and all Calculator::Shipping::* classes are removed. No more polymorphic has_one :calculator on DeliveryMethod.
  • DeliveryMethod becomes pure configuration — stores name, zone, fulfillment type, pricing preferences (flat amount, per-item amount, weight thresholds, etc.) as preferences or JSON columns. No calculator association.
  • All call sites that called Stock::Estimator (Checkout::GetShippingRates, Cart::EstimateShippingRates) call the provider via Spree.delivery_rate_provider.

Provider returns structured rate objects, not BigDecimal

ruby
provider.estimate_rates(package, delivery_methods)
# → [
#   DeliveryRateEstimate.new(
#     delivery_method: method,
#     cost: 8.99,
#     carrier: 'UPS',
#     service_level: 'Ground',
#     estimated_delivery_date: Date.parse('2026-03-25'),
#     metadata: { easypost_rate_id: 'rate_abc' }
#   ),
#   ...
# ]

The Internal provider wraps each calculator.compute(package) result in a DeliveryRateEstimate with no carrier/service metadata (same as today).

Credentials live in Spree::Integration, not the provider

Providers are stateless strategy objects — they define behavior (estimate rates). 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_easypost) ships both an Integration subclass and a Provider subclass:

ruby
# Integration — stores credentials, provides admin UI
class Spree::Integrations::EasyPost < Spree::Integration
  preference :api_key, :string
  preference :mode, :string, default: 'test'  # test or production

  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

# Provider — behavioral contract, reads credentials from Integration at runtime
class SpreeEasyPost::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 calculation using client ...
  end

  private

  def client
    @client ||= begin
      store = delivery_method.stores.first  # or however the method resolves its store
      integration = store.integrations.active.find_by!(type: self.class.integration_class)
      EasyPost::Client.new(api_key: integration.preferred_api_key)
    end
  end
end

Admin flow:

  1. Admin navigates to Integrations → connects EasyPost (enters API key, tests connection)
  2. Admin navigates to DeliveryMethod "Standard Shipping" → selects "EasyPost" as rate provider
  3. At runtime, the provider reads credentials from the Integration record

Internal providers don't need an Integration. Spree::DeliveryRateProvider::Internal reads DeliveryMethod pricing preferences directly — no external credentials.

Provider availability depends on Integration status. The DeliveryMethod admin UI should only offer providers whose Integration is active for that store.

Provider is per delivery method

Unlike search (global) and tax (per-market), delivery rate providers are per delivery method. One store can have:

  • "Free Shipping" → Spree::DeliveryRateProvider::Internal (flat rate: $0)
  • "Standard Shipping" → SpreeEasyPost::DeliveryRateProvider (UPS Ground rates)
  • "Express Shipping" → SpreeEasyPost::DeliveryRateProvider (UPS Express rates)
  • "Local Pickup" → Spree::DeliveryRateProvider::Internal (flat rate: $0)
ruby
class Spree::DeliveryMethod < Spree.base_class
  # Replaces the old calculator association
  attribute :rate_provider, :string, default: 'Spree::DeliveryRateProvider::Internal'

  def rate_provider_instance
    rate_provider.constantize.new(self)
  end
end

The provider receives the DeliveryMethod (not store) — it reads API keys, carrier config, and pricing from the method's preferences/metadata.

FulfillmentProvider handles post-purchase, DeliveryRateProvider handles pre-purchase

  • DeliveryRateProvider — "What shipping options are available and how much do they cost?" (checkout)
  • FulfillmentProvider — "Create a shipment, generate labels, track delivery" (post-purchase)

These are separate concerns. A store might use EasyPost for rates but manual fulfillment, or internal rates but ShipStation for fulfillment.

One rate per delivery method, cached API calls across methods

Each DeliveryMethod returns one rate. But multiple DeliveryMethods can share the same external provider (e.g., "UPS Ground" and "UPS Express" both use the EasyPost provider). The provider should cache the API call per package (using RequestStore or similar) so a single EasyPost API call serves rates for all UPS methods.

DeliveryMethod "UPS Ground"   → EasyPost provider → API call (cached) → extract Ground rate
DeliveryMethod "UPS Express"  → EasyPost provider → API call (cached) → extract Express rate
DeliveryMethod "Free Shipping"→ Internal provider  → flat rate: $0

Design Details

Provider Base Class

ruby
module Spree
  module DeliveryRateProvider
    class Base
      attr_reader :delivery_method

      def initialize(delivery_method)
        @delivery_method = delivery_method
      end

      # Estimate delivery rate for a package.
      # Returns a DeliveryRateEstimate or nil if not available.
      #
      # @param package [Spree::Stock::Package] items + stock location
      # @return [DeliveryRateEstimate, nil]
      def estimate(package)
        raise NotImplementedError
      end
    end

    DeliveryRateEstimate = Struct.new(
      :delivery_method,
      :cost,
      :carrier,
      :service_level,
      :estimated_delivery_date,
      :estimated_transit_days,
      :metadata,
      keyword_init: true
    )
  end
end

Internal Provider (default)

Computes rate directly from DeliveryMethod pricing preferences — no Calculator indirection:

ruby
module Spree
  module DeliveryRateProvider
    class Internal < Base
      def estimate(package)
        cost = compute_cost(package)
        return nil unless cost

        DeliveryRateEstimate.new(
          delivery_method: delivery_method,
          cost: cost,
          carrier: nil,
          service_level: nil,
          estimated_delivery_date: estimated_date,
          estimated_transit_days: delivery_method.estimated_transit_business_days_min,
          metadata: {}
        )
      end

      private

      def compute_cost(package)
        case delivery_method.pricing_type
        when 'flat_rate'
          delivery_method.preferred_amount
        when 'per_item'
          delivery_method.preferred_amount * package.quantity
        when 'weight'
          delivery_method.preferred_amount * package.weight
        when 'percent'
          package.item_total * (delivery_method.preferred_amount / 100.0)
        when 'free'
          0
        end
      end

      def estimated_date
        return nil unless delivery_method.estimated_transit_business_days_min

        delivery_method.estimated_transit_business_days_min.business_days.from_now
      end
    end
  end
end

DeliveryMethod#pricing_type and #preferred_amount replace the polymorphic calculator association with simple columns/preferences.

External Provider Example (EasyPost)

Each EasyPost-backed DeliveryMethod stores its carrier + service in metadata. Note how the provider reads credentials from its Spree::Integration record (see "Credentials live in Spree::Integration" above):

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)
      # Get rate for this specific carrier+service from EasyPost
      shipment = find_or_create_easypost_shipment(package)
      rate = shipment.rates.detect do |r|
        r.carrier == delivery_method.metadata['carrier'] &&
          r.service == delivery_method.metadata['service']
      end

      return nil unless rate

      Spree::DeliveryRateProvider::DeliveryRateEstimate.new(
        delivery_method: delivery_method,
        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: shipment.id }
      )
    end

    private

    def client
      @client ||= begin
        store = delivery_method.stores.first
        integration = store.integrations.active.find_by!(type: self.class.integration_class)
        EasyPost::Client.new(api_key: integration.preferred_api_key)
      end
    end

    # Cache the EasyPost shipment per package to avoid duplicate API calls
    # (multiple DeliveryMethods may share the same EasyPost provider)
    def find_or_create_easypost_shipment(package)
      cache_key = "easypost_shipment_#{package.stock_location.id}_#{package.order.id}"
      RequestStore.store[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

Admin setup: create DeliveryMethods for each carrier+service, set rate_provider: 'SpreeEasyPost::DeliveryRateProvider' and metadata: { carrier: 'UPS', service: 'Ground' }.

Integration Point: Checkout Services

Each DeliveryMethod's provider is called directly — no Estimator intermediary:

ruby
class Spree::Checkout::GetDeliveryRates
  def call(order:)
    order.fulfillments.each do |fulfillment|
      package = fulfillment.to_package
      methods = available_delivery_methods(order, fulfillment)

      rates = methods.filter_map do |method|
        provider = method.rate_provider_instance  # each method has its own provider
        estimate = provider.estimate(package)
        next unless estimate

        fulfillment.delivery_rates.build(
          delivery_method: estimate.delivery_method,
          cost: estimate.cost,
          carrier: estimate.carrier,
          service_level: estimate.service_level,
          estimated_delivery_date: estimate.estimated_delivery_date,
          metadata: estimate.metadata
        )
      end

      # Select cheapest as default
      rates.min_by(&:cost)&.update(selected: true) if rates.any?
    end
  end
end

Each DeliveryMethod returns one rate from its provider. The checkout service collects all rates and lets the customer choose.

Migration Path

Phase 1: Provider interface + Internal provider

  • Create Spree::DeliveryRateProvider::Base, ::Internal, ::DeliveryRateEstimate
  • Register Spree.delivery_rate_provider on Spree module
  • Internal provider wraps existing calculator.compute(package) — zero behavior change
  • No changes to Estimator yet — just the interface

Phase 2: Wire into Estimator

  • Modify Stock::Estimator#calculate_shipping_rates to delegate to provider
  • Internal provider produces identical results to current calculator flow
  • All existing tests pass unchanged

Phase 3: Enrich DeliveryRate/ShippingRate model

  • Add carrier, service_level, estimated_delivery_date, metadata columns to ShippingRate (or DeliveryRate after rename)
  • Serialize provider-specific data in metadata (e.g., easypost_rate_id)
  • Update Store API serializer to expose new fields

Phase 4: Reference external provider

  • Document how to build an EasyPost/Shippo provider
  • Ship as a how-to guide (like custom-search-provider)

Relationship to Other 6.0 Plans

Fulfillment & Delivery (6.0-fulfillment-and-delivery.md)

That plan renames models and adds FulfillmentProvider for post-purchase lifecycle (create shipment, track, cancel). This plan adds DeliveryRateProvider for pre-purchase rate calculation. They're complementary:

Pre-purchase:  DeliveryRateProvider → "How much does shipping cost?"
Post-purchase: FulfillmentProvider  → "Create shipment, generate label, track delivery"

A DeliveryMethod has both:

  • rate_provider (for rate calculation) — replaces calculator association
  • fulfillment_provider (for post-purchase) — from fulfillment plan
  • Rate provider is per-method (different methods can use different providers)

Tax Provider (6.0-tax-provider.md)

Same open-closed pattern:

  • Tax: TaxProvider::Internal wraps TaxRate + Calculator::DefaultTax
  • Delivery: DeliveryRateProvider::Internal wraps ShippingMethod.calculator
  • Both have lifecycle methods the internal provider no-ops on

Search Provider (shipped in 5.4)

Established the pattern. All three follow the same open-closed architecture:

SearchTaxDelivery Rates
ScopeGlobal (Spree.search_provider)Per-Market (Market#tax_provider)Per-method (DeliveryMethod#rate_provider)
ReplacesRansack/ILIKETaxRate.adjust + CalculatorStock::Estimator + Calculator
InternalDatabase (SQL)Reads TaxRate data directlyReads DeliveryMethod prefs directly
ExternalMeilisearchAvalara / TaxJarEasyPost / Shippo
CalculatorsN/ARemoved in 6.0Removed in 6.0
CredentialsSpree::IntegrationSpree::IntegrationSpree::Integration

All external providers follow the same credential pattern: credentials live in a Spree::Integration subclass (per-store, admin-managed), the provider reads them at runtime. See "Credentials live in Spree::Integration" section above.

DeliveryZone replaces Zone for shipping

The current generic Zone (country or state members) is replaced by DeliveryZone which adds postal code range support:

ruby
class Spree::DeliveryZone < Spree.base_class
  has_prefix_id :dz

  has_many :delivery_zone_members, dependent: :destroy
  has_many :delivery_method_zones, dependent: :destroy
  has_many :delivery_methods, through: :delivery_method_zones

  validates :name, presence: true

  def include?(address)
    delivery_zone_members.any? { |member| member.match?(address) }
  end
end

class Spree::DeliveryZoneMember < Spree.base_class
  belongs_to :delivery_zone

  # Polymorphic: country, state, or postal code range
  attribute :member_type, :string  # 'country', 'state', 'postal_code'
  belongs_to :country, class_name: 'Spree::Country', optional: true
  belongs_to :state, class_name: 'Spree::State', optional: true

  # For postal code ranges
  attribute :postal_code_from, :string
  attribute :postal_code_to, :string

  def match?(address)
    case member_type
    when 'country'
      address.country_id == country_id
    when 'state'
      address.state_id == state_id
    when 'postal_code'
      address.zipcode.to_s >= postal_code_from.to_s &&
        address.zipcode.to_s <= postal_code_to.to_s
    end
  end
end

This enables delivery scenarios that the current Zone model can't handle:

  • "Free delivery to postcodes 10001-10099" (local delivery)
  • "Express available in London only" (state + postal code)
  • "International shipping to EU countries" (country-level, same as today)

Tax doesn't use zones at all — TaxRate links directly to country/state (see 6.0-tax-provider.md). The generic Spree::Zone model is dropped entirely in 6.0. A shared data migration (rake task spree:migrate_zones, defined in the Tax Provider plan) converts existing Zone records used by ShippingMethod/DeliveryMethod into DeliveryZone records, and Zone records used by TaxRate into direct country/state FKs. After migration, spree_zones and spree_zone_members tables are removed.

Constraints on Current Work

  • Don't add new shipping calculators for external carrier APIs. The calculator pattern can't support them properly. Use the provider interface instead.
  • Don't monkey-patch Stock::Estimator. The provider interface is the intended seam.
  • New ShippingRate/DeliveryRate fields should be designed for the enriched model (carrier, service_level, estimated_delivery_date, metadata).
  • FulfillmentProvider is separate. Don't mix rate calculation into the fulfillment provider.

Open Questions

  1. Rate caching. External API calls during checkout are expensive (~500ms). Should the provider cache rates for a configurable TTL (e.g., 5 minutes)? Or should caching be the provider's responsibility? Leaning toward provider responsibility — different APIs have different caching needs.

  2. Multi-package rates. Some carriers offer discounts for shipping multiple packages together. Should the provider receive all packages at once, or one at a time? Current Estimator processes packages independently. Leaning toward provider receives one package at a time (simpler, matches current flow), with a future enhancement for multi-package optimization.

  3. Rate validation at completion. Rates can expire between checkout and payment. Should the provider have a validate_rate(shipping_rate) method called at order completion? Or re-estimate and compare?

References

  • Current estimator: spree/core/app/models/spree/stock/estimator.rb
  • Current calculators: spree/core/app/models/spree/calculator/shipping/
  • Current shipping method: spree/core/app/models/spree/shipping_method.rb
  • Current shipping rate: spree/core/app/models/spree/shipping_rate.rb
  • Stock coordinator: spree/core/app/models/spree/stock/coordinator.rb
  • Fulfillment plan: 6.0-fulfillment-and-delivery.md
  • Tax provider plan: 6.0-tax-provider.md
  • Search provider (reference implementation): spree/core/app/models/spree/search_provider/