docs/plans/6.0-fulfillment-and-delivery.md
Status: Draft Target: Spree 6.0 Depends on: ProductType (6.0-product-types.md), Normalize state→status (6.0-normalize-state-to-status.md), Typed Stock Movements (6.0-typed-stock-movements.md) Author: Damian + Claude Last updated: 2026-03-26
Rename the shipping domain to use delivery/fulfillment vocabulary, drop ShippingCategory (replaced by ProductType), and add a FulfillmentProvider strategy interface for extensible fulfillment types (shipping, pickup, digital, local delivery, custom).
This is three changes in one:
"Shipping" vocabulary is too narrow. Shipment, ShippingMethod, ShippingRate, ShippingCategory — all assume carrier delivery. Click-and-collect, digital delivery, local courier, and in-store fulfillment don't fit. Digital delivery is currently hacked via a special Calculator subclass (DigitalDelivery) rather than being a proper fulfillment type.
ShippingCategory is a redundant model. It exists solely to answer "which delivery methods are available for this product?" — but ProductType already defines what a product is. Adding fulfillment_types to ProductType eliminates an entire model + join table.
No provider abstraction. There's no interface for 3rd-party fulfillment services. Integrations (EasyPost, ShipStation) have to monkey-patch Shipment or wrap the Calculator. A clean provider interface enables plug-and-play fulfillment integrations.
InventoryUnit is a misleading name. It represents "items in a fulfillment" — every competitor calls this FulfillmentItem or FulfillmentLine.
| Current (5.4) | 6.0 | Prefix ID | Table |
|---|---|---|---|
Spree::Shipment | Spree::Fulfillment | ful_ | spree_fulfillments |
Spree::ShippingMethod | Spree::DeliveryMethod | dm_ | spree_delivery_methods |
Spree::ShippingRate | Spree::DeliveryRate | dr_ | spree_delivery_rates |
Spree::ShippingMethodZone | Spree::DeliveryMethodZone | — | spree_delivery_method_zones |
Spree::InventoryUnit | Spree::FulfillmentItem | fi_ | spree_fulfillment_items |
Spree::ShippingCategory | Dropped | — | spree_shipping_categories dropped |
Spree::ShippingMethodCategory | Dropped | — | spree_shipping_method_categories dropped |
| — (new) | Spree::DeliveryMethodStockLocation | — | spree_delivery_method_stock_locations |
ProductType gains a fulfillment_types array column:
class Spree::ProductType < Spree.base_class
# Array of allowed fulfillment types for products of this type
# Default: ['shipping']
# Examples: ['shipping', 'pickup'], ['digital'], ['shipping', 'pickup', 'pickup_point', 'local_delivery']
attribute :fulfillment_types, default: ['shipping']
validates :fulfillment_types, presence: true
end
Products without a ProductType default to ['shipping'] (backward compatible).
At checkout, available delivery methods are filtered:
# Collect all fulfillment types needed by items in the cart
required_types = order.line_items.map { |li| li.product.product_type&.fulfillment_types || ['shipping'] }.flatten.uniq
# Find delivery methods matching those types
DeliveryMethod.where(fulfillment_type: required_types).eligible_for(order)
For mixed carts (physical + digital), separate fulfillments are created automatically — one per fulfillment type.
Edge case: method-level restrictions. If a specific product needs to exclude certain delivery methods (e.g., fragile item can't use express), use Product.excluded_delivery_method_ids (simple array column or M2M) — checked at rate calculation time. This replaces the rare ShippingCategory-as-restriction use case without a dedicated model.
class Spree::DeliveryMethod < Spree.base_class
# Built-in types
FULFILLMENT_TYPES = %w[shipping pickup pickup_point digital local_delivery].freeze
# Open string — developers can register custom types
validates :fulfillment_type, presence: true
# No inclusion validation — extensible via:
# Spree.fulfillment_types << 'same_day_courier'
end
shipping — carrier delivery to customer's addresspickup — customer collects from merchant's own location (store, warehouse)pickup_point — carrier delivers to third-party point (InPost locker, DHL ServicePoint, UPS Access Point), customer collects theredigital — digital download, no physical deliverylocal_delivery — merchant delivers within a local radiusclass Spree::FulfillmentProvider::Base
# Can this provider handle this fulfillment?
def can_fulfill?(fulfillment)
true
end
# Create the fulfillment with 3rd-party (e.g., call EasyPost/ShipStation)
# Returns hash with tracking info: { tracking_number: '...', tracking_url: '...' }
def create_fulfillment(fulfillment)
raise NotImplementedError
end
# Cancel a fulfillment with 3rd-party
def cancel_fulfillment(fulfillment)
raise NotImplementedError
end
# Get tracking URL for a fulfillment
def tracking_url(fulfillment)
nil
end
# Get shipping label documents
def documents(fulfillment)
[]
end
end
Built-in providers:
Spree::FulfillmentProvider::Manual — admin manually marks as shipped (default, current behavior)Spree::FulfillmentProvider::Digital — auto-generates DigitalLink on order completionSpree::FulfillmentProvider::Pickup — marks ready-for-pickup at merchant locationSpree::FulfillmentProvider::PickupPoint — marks ready-for-pickup at third-party point (carrier handles routing)3rd-party examples:
SpreeEasyPost::FulfillmentProvider — creates EasyPost shipments, returns labelsSpreeShipStation::FulfillmentProvider — pushes to ShipStation APIDeliveryMethod stores the provider class name:
class Spree::DeliveryMethod < Spree.base_class
# String class name of the provider
# Default: 'Spree::FulfillmentProvider::Manual'
attribute :fulfillment_provider, :string, default: 'Spree::FulfillmentProvider::Manual'
def provider
@provider ||= fulfillment_provider.constantize.new
end
end
Registration:
# config/initializers/spree.rb
Spree.fulfillment_types << 'same_day_courier'
Spree.fulfillment_providers << MyApp::FulfillmentProvider::SameDayCourier
Industry consensus (Shopify, Medusa, Saleor): merchant pickup locations are stock locations with a flag, not a separate model. Spree follows this.
Two distinct pickup scenarios:
| Scenario | fulfillment_type | Location source | Address stored on Fulfillment |
|---|---|---|---|
| Merchant pickup (collect from store) | pickup | StockLocation with pickup_enabled: true | None — stock_location_id already has the address |
| Third-party pickup point (InPost, DHL locker) | pickup_point | Provider API → ephemeral results | pickup_point_data JSON — frozen snapshot of selected point |
class Spree::StockLocation < Spree.base_class
KINDS = %w[warehouse store fulfillment_center].freeze
attribute :kind, :string, default: 'warehouse'
attribute :pickup_enabled, :boolean, default: false
attribute :pickup_stock_policy, :string, default: 'local' # 'local' or 'any'
attribute :pickup_ready_in_minutes, :integer # e.g., 120 (2 hours)
attribute :pickup_instructions, :text
scope :pickup_enabled, -> { where(pickup_enabled: true) }
end
kind — categorizes the location (warehouse, store, fulfillment_center). Open string, extensible.pickup_enabled — whether customers can collect from this location.pickup_stock_policy — 'local' means only items physically in stock at this location can be picked up. 'any' means items can be transferred from other locations (ship-to-store).pickup_ready_in_minutes — estimated lead time shown to customer.pickup_instructions — displayed after order placement ("Enter through the back door").Third-party pickup points (InPost, DHL ServicePoint, UPS Access Point) are not stored in the database. There are 20,000+ InPost lockers in Poland alone — these are fetched from carrier APIs at checkout time and returned as ephemeral value objects.
# Value object — not persisted
Spree::PickupPointOption = Struct.new(
:external_id, # "inpost-PL-WAW-42"
:name, # "InPost Locker PL-WAW-42"
:provider, # "inpost"
:address, # { street:, city:, postal_code:, country_code:, lat:, lng: }
:opening_hours, # optional
:kind, # "locker", "servicepoint", "store"
:metadata, # provider-specific data
keyword_init: true
)
module Spree
module PickupPointProvider
class Base
attr_reader :delivery_method
def initialize(delivery_method)
@delivery_method = delivery_method
end
# Returns nearby pickup points for a given address
# @param address [Spree::Address] customer's address (or lat/lng)
# @param limit [Integer] max results
# @return [Array<PickupPointOption>]
def find_nearby(address:, limit: 10)
raise NotImplementedError
end
# Validates a selected point is still available
# @param external_id [String]
# @return [PickupPointOption, nil]
def find_by_external_id(external_id)
raise NotImplementedError
end
end
end
end
DeliveryMethod stores the pickup point provider class (only for pickup_point type):
class Spree::DeliveryMethod < Spree.base_class
attribute :pickup_point_provider, :string # 'SpreeInpost::PickupPointProvider', nil for non-pickup-point methods
def pickup_point_provider_instance
return nil unless pickup_point_provider.present?
pickup_point_provider.constantize.new(self)
end
end
When the customer selects a third-party pickup point, its data is frozen as JSON on the Fulfillment. No FK — the data is ephemeral from the provider:
class Spree::Fulfillment < Spree.base_class
# For pickup_point fulfillments — stores the selected point snapshot
attribute :pickup_point_data, :json
# {
# external_id: "inpost-PL-WAW-42",
# name: "InPost Locker PL-WAW-42",
# provider: "inpost",
# address: { street: "ul. Marszałkowska 1", city: "Warsaw", postal_code: "00-001", country_code: "PL" }
# }
end
1. GET /delivery_methods?fulfillment_type=pickup_point
→ Returns delivery methods that use pickup point providers
2. GET /delivery_methods/:id/pickup_points?latitude=52.23&longitude=21.01
→ Calls the method's PickupPointProvider.find_nearby
→ Returns list of PickupPointOption objects
3. POST /checkout/select_delivery_method
{ delivery_method_id: "dm_xxx", pickup_point_external_id: "inpost-PL-WAW-42" }
→ Validates point via provider.find_by_external_id
→ Stores pickup_point_data on Fulfillment
1. GET /delivery_methods?fulfillment_type=pickup
→ Returns delivery methods for merchant pickup
2. GET /delivery_methods/:id/pickup_locations
→ Returns StockLocations with pickup_enabled that have stock for cart items
3. POST /checkout/select_delivery_method
{ delivery_method_id: "dm_xxx", stock_location_id: "sl_abc" }
→ Sets fulfillment.stock_location_id
| Merchant pickup | Third-party pickup point | |
|---|---|---|
fulfillment_type | pickup | pickup_point |
| Location model | StockLocation (with pickup_enabled) | None — ephemeral from provider API |
| Provider interface | N/A (built-in) | PickupPointProvider::Base |
| Stored on Fulfillment | stock_location_id (existing FK) | pickup_point_data (JSON) |
| Stock validation | Yes (pickup_stock_policy: 'local' or 'any') | No (carrier handles routing) |
| Address for customer | stock_location.address | pickup_point_data['address'] |
class Spree::DeliveryMethod < Spree.base_class
has_prefix_id :dm
acts_as_paranoid
include Spree::SingleStoreResource
include Spree::CalculatedAdjustments # pricing calculator (unchanged)
include Spree::Metafields
include Spree::Metadata
include Spree::DisplayOn
has_many :delivery_rates, class_name: 'Spree::DeliveryRate', inverse_of: :delivery_method
has_many :fulfillments, through: :delivery_rates
has_many :delivery_method_zones, class_name: 'Spree::DeliveryMethodZone'
has_many :zones, through: :delivery_method_zones
# Pickup: which locations can customers collect from?
has_many :delivery_method_stock_locations, class_name: 'Spree::DeliveryMethodStockLocation'
has_many :pickup_locations, through: :delivery_method_stock_locations,
source: :stock_location, class_name: 'Spree::StockLocation'
belongs_to :tax_category, -> { with_deleted }, class_name: 'Spree::TaxCategory', optional: true
validates :name, :display_on, :fulfillment_type, presence: true
validates :estimated_transit_business_days_min, numericality: { greater_than_or_equal_to: 1 }, allow_nil: true
validates :estimated_transit_business_days_max, numericality: { greater_than_or_equal_to: 1 }, allow_nil: true
# Fulfillment type: open string
attribute :fulfillment_type, :string, default: 'shipping'
attribute :fulfillment_provider, :string, default: 'Spree::FulfillmentProvider::Manual'
attribute :pickup_point_provider, :string # only for pickup_point type, e.g. 'SpreeInpost::PickupPointProvider'
scope :by_fulfillment_type, ->(type) { where(fulfillment_type: type) }
scope :shipping, -> { by_fulfillment_type('shipping') }
scope :pickup, -> { by_fulfillment_type('pickup') }
scope :pickup_point, -> { by_fulfillment_type('pickup_point') }
scope :digital, -> { by_fulfillment_type('digital') }
scope :local_delivery, -> { by_fulfillment_type('local_delivery') }
# Geographic eligibility (unchanged logic, new method name)
def include?(address)
return true if digital?
return true unless zones.any?
zones.includes(:zone_members).any? { |zone| zone.include?(address) }
end
def digital?
fulfillment_type == 'digital'
end
def pickup?
fulfillment_type == 'pickup'
end
def pickup_point?
fulfillment_type == 'pickup_point'
end
def requires_address?
!digital? && !pickup?
end
def pickup_point_provider_instance
return nil unless pickup_point_provider.present?
pickup_point_provider.constantize.new(self)
end
def provider
@provider ||= fulfillment_provider.constantize.new
end
end
class Spree::Fulfillment < Spree.base_class
has_prefix_id :ful
belongs_to :order, class_name: 'Spree::Order', touch: true
belongs_to :address, class_name: 'Spree::Address', optional: true # nil for pickup/digital
belongs_to :stock_location, -> { with_deleted }, class_name: 'Spree::StockLocation'
# For pickup_point fulfillments — frozen snapshot of selected third-party point
attribute :pickup_point_data, :json
has_many :fulfillment_items, class_name: 'Spree::FulfillmentItem', dependent: :delete_all
has_many :variants, through: :fulfillment_items
has_many :delivery_rates, -> { order(:cost) }, class_name: 'Spree::DeliveryRate', dependent: :delete_all
has_many :delivery_methods, through: :delivery_rates
has_one :selected_delivery_rate, -> { where(selected: true) }, class_name: 'Spree::DeliveryRate'
# Copied from selected delivery method for easy querying
attribute :fulfillment_type, :string
# State machine (uses status per normalize plan)
# Unified naming — no "shipped" (too narrow), use "fulfilled"
state_machine :status, initial: :pending do
event :ready do
transition from: :pending, to: :ready
end
event :fulfill do
transition from: %i[ready canceled], to: :fulfilled
end
event :cancel do
transition to: :canceled, from: %i[pending ready]
end
event :resume do
transition from: :canceled, to: :pending
end
# Pickup-specific
event :mark_ready_for_pickup do
transition from: :pending, to: :ready_for_pickup
end
event :mark_picked_up do
transition from: :ready_for_pickup, to: :fulfilled
end
end
after_transition to: :fulfilled, do: :set_fulfilled_at
scope :by_fulfillment_type, ->(type) { where(fulfillment_type: type) }
scope :digital, -> { by_fulfillment_type('digital') }
scope :pickup, -> { by_fulfillment_type('pickup') }
scope :pickup_point, -> { by_fulfillment_type('pickup_point') }
scope :shipping, -> { by_fulfillment_type('shipping') }
def digital?
fulfillment_type == 'digital'
end
def pickup?
fulfillment_type == 'pickup'
end
def pickup_point?
fulfillment_type == 'pickup_point'
end
def delivery_method
selected_delivery_rate&.delivery_method
end
end
class Spree::FulfillmentItem < Spree.base_class
has_prefix_id :fi
belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant'
belongs_to :order, class_name: 'Spree::Order'
belongs_to :fulfillment, class_name: 'Spree::Fulfillment', touch: true, optional: true
belongs_to :line_item, class_name: 'Spree::LineItem'
has_many :return_items, class_name: 'Spree::ReturnItem', inverse_of: :fulfillment_item
validates :quantity, numericality: { greater_than: 0 }
state_machine :status, initial: :on_hand do
event :fill_backorder do
transition to: :on_hand, from: :backordered
end
event :ship do
transition to: :shipped, if: :allow_ship?
end
event :return do
transition to: :returned, from: :shipped
end
end
scope :backordered, -> { where(status: 'backordered') }
scope :on_hand, -> { where(status: 'on_hand') }
scope :shipped, -> { where(status: 'shipped') }
scope :returned, -> { where(status: 'returned') }
end
class Spree::Order < Spree.base_class
has_many :fulfillments, class_name: 'Spree::Fulfillment', dependent: :destroy
has_many :fulfillment_items, class_name: 'Spree::FulfillmentItem', dependent: :destroy
# Convenience
def digital_fulfillments
fulfillments.digital
end
def requires_delivery?
line_items.any? { |li| li.product.product_type&.fulfillment_types&.include?('shipping') }
end
end
class Spree::ProductType < Spree.base_class
# From 6.0-product-types.md, plus:
attribute :fulfillment_types, default: ['shipping']
validates :fulfillment_types, presence: true
def digital?
fulfillment_types == ['digital']
end
def requires_shipping?
fulfillment_types.include?('shipping')
end
end
Product convenience (replaces shipping_category checks):
class Spree::Product < Spree.base_class
# Remove: belongs_to :shipping_category
# ProductType handles delivery eligibility
def digital?
product_type&.digital? || false
end
def fulfillment_types
product_type&.fulfillment_types || ['shipping']
end
end
# Available delivery methods for an order
class Spree::DeliveryMethods::Available
def call(order:)
# Collect fulfillment types from all items
required_types = order.line_items
.includes(product: :product_type)
.flat_map { |li| li.product.fulfillment_types }
.uniq
methods = order.store.delivery_methods
.active
.where(fulfillment_type: required_types)
.select { |dm| dm.include?(order.ship_address) }
# For pickup methods, filter by stock availability at pickup locations
methods.select do |dm|
if dm.pickup?
dm.pickup_locations.any? { |loc| loc.stocks_all?(order.variants) }
else
true
end
end
end
end
class RenamShippingToDeliveryAndFulfillment < ActiveRecord::Migration[7.2]
def change
rename_table :spree_shipments, :spree_fulfillments
rename_table :spree_shipping_methods, :spree_delivery_methods
rename_table :spree_shipping_rates, :spree_delivery_rates
rename_table :spree_shipping_method_zones, :spree_delivery_method_zones
rename_table :spree_inventory_units, :spree_fulfillment_items
# Rename FK columns
rename_column :spree_delivery_rates, :shipment_id, :fulfillment_id
rename_column :spree_delivery_rates, :shipping_method_id, :delivery_method_id
rename_column :spree_fulfillment_items, :shipment_id, :fulfillment_id
rename_column :spree_delivery_method_zones, :shipping_method_id, :delivery_method_id
# Add fulfillment_type to delivery_methods
add_column :spree_delivery_methods, :fulfillment_type, :string, null: false, default: 'shipping'
add_column :spree_delivery_methods, :fulfillment_provider, :string, null: false, default: 'Spree::FulfillmentProvider::Manual'
add_index :spree_delivery_methods, :fulfillment_type
# Pickup point provider on delivery methods (only for pickup_point type)
add_column :spree_delivery_methods, :pickup_point_provider, :string
# Add fulfillment_type to fulfillments (copied from selected delivery method)
add_column :spree_fulfillments, :fulfillment_type, :string
# Pickup point data on fulfillments (frozen snapshot of selected third-party point)
add_column :spree_fulfillments, :pickup_point_data, :json
# Add fulfillment_types to product_types (from product-types plan)
add_column :spree_product_types, :fulfillment_types, :string, null: false, default: 'shipping'
# Stored as comma-separated or JSON array depending on DB
# Pickup locations join table
create_table :spree_delivery_method_stock_locations do |t|
t.references :delivery_method, null: false
t.references :stock_location, null: false
t.timestamps
end
add_index :spree_delivery_method_stock_locations,
[:delivery_method_id, :stock_location_id],
unique: true, name: 'idx_dm_stock_locations_unique'
# StockLocation: pickup capabilities
add_column :spree_stock_locations, :kind, :string, null: false, default: 'warehouse'
add_column :spree_stock_locations, :pickup_enabled, :boolean, null: false, default: false
add_column :spree_stock_locations, :pickup_stock_policy, :string, null: false, default: 'local'
add_column :spree_stock_locations, :pickup_ready_in_minutes, :integer
add_column :spree_stock_locations, :pickup_instructions, :text
add_index :spree_stock_locations, :pickup_enabled
# Fulfillment: rename shipped_at → fulfilled_at
rename_column :spree_fulfillments, :shipped_at, :fulfilled_at
# Order: rename shipment_state, shipment_total
rename_column :spree_orders, :shipment_state, :fulfillment_status
rename_column :spree_orders, :shipment_total, :delivery_total
# SingleStoreResource: add store_id to delivery_methods (replacing join table)
add_column :spree_delivery_methods, :store_id, :bigint
add_index :spree_delivery_methods, :store_id
end
end
rake spree:migrate_shipping_to_delivery
Set fulfillment_type on delivery methods:
Copy fulfillment_type to existing fulfillments from their selected delivery method
Migrate ShippingCategory → ProductType.fulfillment_types:
Migrate store scoping:
Drop spree_shipping_categories and spree_shipping_method_categories tables
Spree::Shipment = Spree::Fulfillment
Spree::ShippingMethod = Spree::DeliveryMethod
Spree::ShippingRate = Spree::DeliveryRate
Spree::InventoryUnit = Spree::FulfillmentItem
Spree::ShippingCategory = nil # removed, not aliased
Spree::FulfillmentProvider::Base, ::Manual, ::Digital, ::Pickup, ::PickupPointSpree::PickupPointProvider::Base and Spree::PickupPointOption structSpree.fulfillment_providers registry/shipments → /fulfillments, /shipping_methods → /delivery_methods, /shipping_rates → /delivery_ratesfulfillment_type, pickup_locations, pickup_point_provider on delivery methodspickup_point_data on fulfillmentsGET /delivery_methods/:id/pickup_locations (merchant pickup — returns StockLocations)GET /delivery_methods/:id/pickup_points?latitude=X&longitude=Y (third-party — calls PickupPointProvider)spree_shipping_categories tableshipping_category_id from spree_productsshipping_method or shipment references.DigitalDelivery calculator pattern is being replaced by fulfillment_type column.Unified fulfillment status naming. Rename shipment_state → fulfillment_status on Order. Rename shipped_at → fulfilled_at on Fulfillment. Status values become fulfillment-neutral: pending, ready, fulfilled (replaces shipped), canceled. Pickup-specific: ready_for_pickup, picked_up (both map to fulfilled in the order-level rollup). Digital: auto-transitions to fulfilled on order completion. Mixed carts: all fulfillments must reach fulfilled/picked_up for order fulfillment_status to be fulfilled. Any partial = partially_fulfilled.
SingleStoreResource for both DeliveryMethod and PaymentMethod. Drop the multi-store join table pattern (StorePaymentMethod, StoreShippingMethod). Both become belongs_to :store with SingleStoreResource concern. In practice, different stores have different currencies, zones, and provider accounts — sharing the same config across stores is rare and creates more complexity than it saves. If a merchant wants the same config on two stores, they create two records. This also applies to PaymentMethod — add a separate decision to decisions.md for that change.
fulfillment_types as JSON column on ProductType. JSON works across PostgreSQL, MySQL, and SQLite. Stored as ["shipping", "pickup"]. Rails handles serialization automatically with serialize :fulfillment_types, coder: JSON or attribute :fulfillment_types, :json, default: ['shipping'].
None at this time.
spree/core/app/models/spree/shipping_method.rb, spree/core/app/models/spree/shipment.rb, spree/core/app/models/spree/shipping_category.rb6.0-product-types.md (ProductType gains fulfillment_types)6.0-typed-stock-movements.md (stock movements reference Fulfillment instead of Shipment)6.0-normalize-state-to-status.md (Fulfillment uses status column)