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 DeliveryMethod rename Author: Damian + Claude Last updated: 2026-04-05
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).
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.
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".
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.)
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.
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.
| What exists | Where | What it does |
|---|---|---|
ShippingCalculator | Base class | compute_package(package) → BigDecimal |
Calculator::Shipping::FlatRate | Built-in | Fixed amount per order |
Calculator::Shipping::PerItem | Built-in | Price × quantity |
Calculator::Shipping::FlexiRate | Built-in | Tiered pricing (first item + additional) |
Calculator::Shipping::FlatPercentItemTotal | Built-in | Percentage of cart total |
Calculator::Shipping::PriceSack | Built-in | Conditional pricing by threshold |
Calculator::Shipping::DigitalDelivery | Built-in | Zero cost for digital products |
Stock::Estimator | Rate engine | Iterates shipping methods, calls calculators, creates ShippingRates |
Stock::Package | Input model | Items + stock location → passed to calculators |
ShippingMethod | Config model | Zone + calculator + display settings |
ShippingRate | Result model | cost + tax_rate + selected flag |
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
Same pattern as tax — calculators answer "how much?" but can't answer "what options are available?" or "what happens after selection?":
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).
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.Stock::Estimator (Checkout::GetShippingRates, Cart::EstimateShippingRates) call the provider via Spree.delivery_rate_provider.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).
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:
preferences columncan_connect? hook tests credentials before savingactive boolean flagA provider gem (e.g., spree_easypost) ships both an Integration subclass and a Provider subclass:
# 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:
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.
Unlike search (global) and tax (per-market), delivery rate providers are per delivery method. One store can have:
Spree::DeliveryRateProvider::Internal (flat rate: $0)SpreeEasyPost::DeliveryRateProvider (UPS Ground rates)SpreeEasyPost::DeliveryRateProvider (UPS Express rates)Spree::DeliveryRateProvider::Internal (flat rate: $0)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.
These are separate concerns. A store might use EasyPost for rates but manual fulfillment, or internal rates but ShipStation for fulfillment.
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
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
Computes rate directly from DeliveryMethod pricing preferences — no Calculator indirection:
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.
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):
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' }.
Each DeliveryMethod's provider is called directly — no Estimator intermediary:
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.
Spree::DeliveryRateProvider::Base, ::Internal, ::DeliveryRateEstimateSpree.delivery_rate_provider on Spree modulecalculator.compute(package) — zero behavior changeStock::Estimator#calculate_shipping_rates to delegate to providercarrier, service_level, estimated_delivery_date, metadata columns to ShippingRate (or DeliveryRate after rename)easypost_rate_id)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 associationfulfillment_provider (for post-purchase) — from fulfillment planSame open-closed pattern:
TaxProvider::Internal wraps TaxRate + Calculator::DefaultTaxDeliveryRateProvider::Internal wraps ShippingMethod.calculatorEstablished the pattern. All three follow the same open-closed architecture:
| Search | Tax | Delivery Rates | |
|---|---|---|---|
| Scope | Global (Spree.search_provider) | Per-Market (Market#tax_provider) | Per-method (DeliveryMethod#rate_provider) |
| Replaces | Ransack/ILIKE | TaxRate.adjust + Calculator | Stock::Estimator + Calculator |
| Internal | Database (SQL) | Reads TaxRate data directly | Reads DeliveryMethod prefs directly |
| External | Meilisearch | Avalara / TaxJar | EasyPost / Shippo |
| Calculators | N/A | Removed in 6.0 | Removed in 6.0 |
| Credentials | Spree::Integration | Spree::Integration | Spree::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.
The current generic Zone (country or state members) is replaced by DeliveryZone which adds postal code range support:
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:
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.
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.
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.
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?
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.rb6.0-fulfillment-and-delivery.md6.0-tax-provider.mdspree/core/app/models/spree/search_provider/