Back to Spree

Fulfillment & Delivery Rework

docs/plans/6.0-fulfillment-and-delivery.md

5.4.229.0 KB
Original Source

Fulfillment & Delivery Rework

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

Summary

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:

  1. Rename — Shipment → Fulfillment, ShippingMethod → DeliveryMethod, ShippingRate → DeliveryRate, InventoryUnit → FulfillmentItem
  2. Drop ShippingCategory — ProductType.fulfillment_types controls delivery eligibility
  3. Add FulfillmentProvider — strategy interface for 3rd-party integrations (EasyPost, ShipStation, etc.) and custom fulfillment types

Problem

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

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

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

  4. InventoryUnit is a misleading name. It represents "items in a fulfillment" — every competitor calls this FulfillmentItem or FulfillmentLine.

Entity Rename Map

Current (5.4)6.0Prefix IDTable
Spree::ShipmentSpree::Fulfillmentful_spree_fulfillments
Spree::ShippingMethodSpree::DeliveryMethoddm_spree_delivery_methods
Spree::ShippingRateSpree::DeliveryRatedr_spree_delivery_rates
Spree::ShippingMethodZoneSpree::DeliveryMethodZonespree_delivery_method_zones
Spree::InventoryUnitSpree::FulfillmentItemfi_spree_fulfillment_items
Spree::ShippingCategoryDroppedspree_shipping_categories dropped
Spree::ShippingMethodCategoryDroppedspree_shipping_method_categories dropped
— (new)Spree::DeliveryMethodStockLocationspree_delivery_method_stock_locations

Key Decisions (do not deviate without discussion)

Naming

  • Fulfillment = the physical/digital act of getting items to the customer. The record that tracks "these items are being delivered." Replaces Shipment.
  • DeliveryMethod = the option presented to the customer at checkout ("Standard Shipping", "Express", "In-Store Pickup", "Digital Download"). Replaces ShippingMethod.
  • DeliveryRate = the price quote for a delivery method on a specific fulfillment. Replaces ShippingRate.
  • FulfillmentItem = an item within a fulfillment (variant + quantity). Replaces InventoryUnit.
  • FulfillmentProvider = strategy class that handles the mechanics of creating/tracking/canceling a fulfillment with a 3rd-party or internal system. New.

ProductType replaces ShippingCategory

ProductType gains a fulfillment_types array column:

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

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

Fulfillment types are open strings

ruby
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 address
  • pickup — 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 there
  • digital — digital download, no physical delivery
  • local_delivery — merchant delivers within a local radius

FulfillmentProvider is a strategy (not a DB model)

ruby
class 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 completion
  • Spree::FulfillmentProvider::Pickup — marks ready-for-pickup at merchant location
  • Spree::FulfillmentProvider::PickupPoint — marks ready-for-pickup at third-party point (carrier handles routing)

3rd-party examples:

  • SpreeEasyPost::FulfillmentProvider — creates EasyPost shipments, returns labels
  • SpreeShipStation::FulfillmentProvider — pushes to ShipStation API

DeliveryMethod stores the provider class name:

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

ruby
# config/initializers/spree.rb
Spree.fulfillment_types << 'same_day_courier'
Spree.fulfillment_providers << MyApp::FulfillmentProvider::SameDayCourier

Pickup & Pickup Point architecture

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:

Scenariofulfillment_typeLocation sourceAddress stored on Fulfillment
Merchant pickup (collect from store)pickupStockLocation with pickup_enabled: trueNone — stock_location_id already has the address
Third-party pickup point (InPost, DHL locker)pickup_pointProvider API → ephemeral resultspickup_point_data JSON — frozen snapshot of selected point

StockLocation changes (merchant pickup)

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

PickupPointProvider interface (third-party pickup points)

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.

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

ruby
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

How the selected pickup point is stored

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:

ruby
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

Store API: pickup point selection flow

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

Store API: merchant pickup flow

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

Summary table

Merchant pickupThird-party pickup point
fulfillment_typepickuppickup_point
Location modelStockLocation (with pickup_enabled)None — ephemeral from provider API
Provider interfaceN/A (built-in)PickupPointProvider::Base
Stored on Fulfillmentstock_location_id (existing FK)pickup_point_data (JSON)
Stock validationYes (pickup_stock_policy: 'local' or 'any')No (carrier handles routing)
Address for customerstock_location.addresspickup_point_data['address']

Design Details

DeliveryMethod model

ruby
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

Fulfillment model (renamed from Shipment)

ruby
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

FulfillmentItem model (renamed from InventoryUnit)

ruby
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

Order associations (after)

ruby
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

ProductType integration

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

ruby
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

Checkout: delivery method selection

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

Migration Path

Phase 1: Table renames

ruby
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

Phase 2: Data migration (rake task)

ruby
rake spree:migrate_shipping_to_delivery
  1. Set fulfillment_type on delivery methods:

    • Methods with DigitalDelivery calculator → fulfillment_type: 'digital', fulfillment_provider: 'Spree::FulfillmentProvider::Digital'
    • All others → fulfillment_type: 'shipping' (default)
  2. Copy fulfillment_type to existing fulfillments from their selected delivery method

  3. Migrate ShippingCategory → ProductType.fulfillment_types:

    • Products with "Digital" ShippingCategory → ensure ProductType with fulfillment_types: ['digital']
    • Products with other ShippingCategories → fulfillment_types: ['shipping']
    • Remove shipping_category_id from products
  4. Migrate store scoping:

    • For each StoreShippingMethod join, set delivery_method.store_id = store_id
    • Methods linked to multiple stores: duplicate the record (one per store)
    • Drop spree_store_shipping_methods join table
  5. Drop spree_shipping_categories and spree_shipping_method_categories tables

Phase 3: Model renames + deprecation aliases

  • All model files renamed
  • Deprecation aliases for one release:
    ruby
    Spree::Shipment = Spree::Fulfillment
    Spree::ShippingMethod = Spree::DeliveryMethod
    Spree::ShippingRate = Spree::DeliveryRate
    Spree::InventoryUnit = Spree::FulfillmentItem
    Spree::ShippingCategory = nil  # removed, not aliased
    
  • Update all serializers, controllers, services, factories, specs

Phase 4: FulfillmentProvider interface

  • Create Spree::FulfillmentProvider::Base, ::Manual, ::Digital, ::Pickup, ::PickupPoint
  • Create Spree::PickupPointProvider::Base and Spree::PickupPointOption struct
  • Wire providers into Fulfillment state machine transitions
  • Update Spree.fulfillment_providers registry

Phase 5: API + serializer updates

  • All API endpoints renamed: /shipments/fulfillments, /shipping_methods/delivery_methods, /shipping_rates/delivery_rates
  • New fields: fulfillment_type, pickup_locations, pickup_point_provider on delivery methods
  • New fields: pickup_point_data on fulfillments
  • New endpoint: GET /delivery_methods/:id/pickup_locations (merchant pickup — returns StockLocations)
  • New endpoint: GET /delivery_methods/:id/pickup_points?latitude=X&longitude=Y (third-party — calls PickupPointProvider)
  • Run typelizer pipeline

Phase 6: Cleanup (6.1)

  • Remove all deprecation aliases
  • Drop spree_shipping_categories table
  • Drop shipping_category_id from spree_products
  • Remove ShippingCategory model entirely

Constraints on Current Work

  • Use "delivery method" and "fulfillment" in all new API v3 code. Don't introduce new shipping_method or shipment references.
  • Don't add new features to ShippingCategory. It's being dropped.
  • New calculator subclasses should not encode fulfillment type. DigitalDelivery calculator pattern is being replaced by fulfillment_type column.
  • Don't add new InventoryUnit features. They'll need to be migrated to FulfillmentItem.

Resolved Questions

  1. Unified fulfillment status naming. Rename shipment_statefulfillment_status on Order. Rename shipped_atfulfilled_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.

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

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

Open Questions

None at this time.

References

  • Current shipping models: spree/core/app/models/spree/shipping_method.rb, spree/core/app/models/spree/shipment.rb, spree/core/app/models/spree/shipping_category.rb
  • Related plan: 6.0-product-types.md (ProductType gains fulfillment_types)
  • Related plan: 6.0-typed-stock-movements.md (stock movements reference Fulfillment instead of Shipment)
  • Related plan: 6.0-normalize-state-to-status.md (Fulfillment uses status column)