docs/plans/6.0-fulfillment-and-delivery.md
Status: Draft
Target: Spree 6.0
Depends on: 6.0-product-types.md (fulfillment_types addendum 2026-07-27 — ProductType replaces ShippingCategory; hard blocker, Spree::ProductType does not exist yet), 6.0-cart-order-split.md (where fulfillments/rates live pre-completion), 6.0-order-routing.md (packages come from for_allocation), 6.0-normalize-state-to-status.md. Coordinates with: 6.0-delivery-rate-provider.md (owns store_id on DeliveryMethod; calculators stay per decisions.md 2026-07-27), 5.5-6.0-display-on-to-boolean.md (storefront_visible lands in the same migration wave), 6.0-multi-vendor-marketplace.md (vendor_id, lifecycle events, delivery_amount). Gates: 6.0-split-adjustments.md and 6.0-typed-stock-movements.md are born with fulfillment_id — this plan's Phase 1 must land before both.
Author: Damian + Claude
Last updated: 2026-07-27
Rename the shipping domain to delivery/fulfillment vocabulary, drop ShippingCategory (replaced by ProductType#fulfillment_types), and add a FulfillmentProvider strategy interface for extensible fulfillment types (shipping, pickup, pickup point, digital, local delivery, custom).
Three changes in one:
ProductType#fulfillment_types + DeliveryMethod#fulfillment_type control delivery eligibilityShipmentHandler name-constantize mechanismCalculators are NOT touched by this plan. The 2026-07-27 decision (decisions.md) keeps ShippingCalculator + Calculator::Shipping::*; rate estimation is the delivery-rate-provider plan's domain. Zone replacement ships in 6.0 via 6.0-delivery-zones.md — this plan's Phase 1 births spree_delivery_method_zones pointing at DeliveryZone (born correct, never re-migrated).
This plan is being implemented ahead of itself — the following exists today and must NOT be redone:
FulfillmentSerializer, DeliveryMethodSerializer, DeliveryRateSerializer (+ Admin variants), fulfillments routes and controllers (store carts + admin orders), dual old/new registrations in api/lib/spree/api/dependencies.rb:111-128, 191-213. FulfillmentSerializer already emits status (from state), fulfilled_at (from shipped_at), and a computed fulfillment_type (digital? ? 'digital' : 'shipping').ful_ (Shipment), dm_ (ShippingMethod), fi_ (InventoryUnit), dr_ (via Shipment#selected_delivery_rate_id, whose YARD comment cites this plan).alias_attribute :fulfillment_status, :shipment_state and alias_attribute :delivery_total, :shipment_total (order.rb:211-213) — the Phase 1 column renames must remove these aliases in the same change.Spree::Fulfillments::Create service (core/app/services/spree/fulfillments/create.rb), registered as Spree.fulfillment_create_service.kind, pickup_enabled, pickup_stock_policy, pickup_ready_in_minutes, pickup_instructions + indexes. Do not re-add.shipment references); packages/dashboard/src still has 8 files on old vocabulary.Outstanding: model/table/column renames, the eligibility rework, FulfillmentProvider, lifecycle events, and the legacy-admin/emails surfaces.
| Current (5.x) | 6.0 | Prefix ID | Table |
|---|---|---|---|
Spree::Shipment | Spree::Fulfillment | ful_ (shipped) | spree_fulfillments |
Spree::ShippingMethod | Spree::DeliveryMethod | dm_ (shipped) | spree_delivery_methods |
Spree::ShippingRate | Spree::DeliveryRate | dr_ | spree_delivery_rates |
Spree::ShippingMethodZone | Spree::DeliveryMethodZone | — | spree_delivery_method_zones (born joining Spree::DeliveryZone — see 6.0-delivery-zones.md) |
Spree::InventoryUnit | Spree::FulfillmentItem | fi_ (shipped) | spree_fulfillment_items |
Spree::FulfilmentChanger (sic) | Spree::FulfillmentChanger | — | (PORO — fixes the single-L/double-L collision) |
Spree::ShipmentHandler | removed → FulfillmentProvider | — | — |
Spree::ShippingCategory | Dropped (Phase 6) | — | spree_shipping_categories dropped in 6.1 |
Spree::ShippingMethodCategory | Dropped | — | spree_shipping_method_categories dropped in 6.1 |
| — (new) | Spree::DeliveryMethodStockLocation | — | spree_delivery_method_stock_locations |
Fulfillment numbers: existing H-prefixed numbers are opaque identifiers and stay untouched (unique index on number); new fulfillments generate F-prefixed numbers. No data migration.
quantity column and its validation already exist on InventoryUnit today). Replaces InventoryUnit.ShipmentHandler (whose name-constantize factory can never match — no subclass exists and the seeded method name "Digital Delivery" can't constantize).Package#shipping_methods is the real thing being replacedShippingCategory's load-bearing job is not the model — it's Stock::Package#shipping_methods (package.rb:73-80), which intersects delivery methods through categories and is the sole input to Stock::Estimator#shipping_methods. Drop the category without replacing this and mixed carts get zero rates, tripping ensure_available_shipping_rates and blocking checkout (asserted by order_routing/strategy/parity_spec.rb:426-452).
Replacement semantics:
ProductType#fulfillment_types (JSON array, default ['shipping']; see the 2026-07-27 addendum in 6.0-product-types.md). Products without a ProductType default to ['shipping'] — the reconciliation between optional ProductType and today's effectively-mandatory ShippingCategory (Product presence-validates + auto-assigns one today, product.rb:133, 153, 945-951).DeliveryMethod#fulfillment_type (single string) must be included in every package item's fulfillment_types for the method to be eligible.Package#eligible_delivery_methods replaces Package#shipping_methods: intersect items' fulfillment types, then subtract per-product exclusions. Product.excluded_delivery_method_ids (JSON array column, jsonb-guarded) covers the rare restriction case ("fragile item can't ship express") without a dedicated model.Stock::Splitter::ShippingCategory → Stock::Splitter::FulfillmentType — packages stay homogeneous by fulfillment type, so mixed carts (physical + digital) still split automatically. Splitter::Digital folds into it.Stock::Estimator#shipping_methods (per the delivery-rate-provider plan, which owns filtering — including the marketplace vendor gate). This plan changes the inputs to that filter, not its location. No separate DeliveryMethods::Available service.ShippingMethod#digital?, #requires_zone_check?, and the .digital scope currently test calculator.is_a?(DigitalDelivery) (shipping_method.rb:42, 57, 128; memoized via MEMOIZED_METHODS :17), and Product#digital? derives through a four-hop chain (product → category → methods → calculator class). All of these move to columns: DeliveryMethod#fulfillment_type == 'digital' and ProductType#digital?. The DigitalDelivery calculator stays (calculators are kept) but becomes a plain zero-cost calculator — its available? gating is superseded by fulfillment-type eligibility. Seeds::DigitalDelivery updates to set fulfillment_type: 'digital'.
shipping, pickup (merchant location), pickup_point (third-party point — InPost locker, DHL ServicePoint), digital, local_delivery built in; extensible via Spree.fulfillment_types << 'same_day_courier'. No inclusion validation.
class Spree::FulfillmentProvider::Base
def can_fulfill?(fulfillment) = true
def create_fulfillment(fulfillment) = raise(NotImplementedError) # → { tracking_number:, tracking_url: }
def cancel_fulfillment(fulfillment) = raise(NotImplementedError)
def tracking_url(fulfillment) = nil
def documents(fulfillment) = []
end
Built-ins: Manual (default — current admin-driven behavior), Digital, Pickup, PickupPoint. Third-party: SpreeEasyPost::FulfillmentProvider, SpreeShipStation::FulfillmentProvider. Stored as a string class name on DeliveryMethod, constantized at call time. Registered via Spree.fulfillment_providers.
Digital auto-fulfillment is new behavior, not a refactor. Today digital shipments sit at ready until an admin clicks Ship (Shipment#shippable? exists precisely for that button), and DigitalLinks are created by a checkout state-machine callback (checkout.rb:87-89 — nested inside if states[:payment], non-idempotent, one link per line item regardless of quantity). FulfillmentProvider::Digital takes over both: on order completion it fires fulfill! directly and generates DigitalLinks idempotently, per quantity. This also gives the multi-vendor ledger its "digital vendor order earns immediately after payment" path.
Fulfillment gets publishes_lifecycle_events plus explicit transition events: fulfillment.ready, fulfillment.fulfilled, fulfillment.canceled, fulfillment.resumed. Order-level: when the last fulfillment reaches fulfilled, publish order.fulfilled — the signal 6.0-multi-vendor-marketplace.md's vendor_transfer.due subscriber keys on ("all the vendor order's fulfillments fulfilled"). Order#fully_fulfilled? is the matching queryable predicate.
Webhook contract break: today Shipment publishes shipment.* events and webhooks (Shipment::Webhooks, Shipment::CustomEvents). Event names rename with the model; 6.0 dual-emits shipment.shipped alongside fulfillment.fulfilled for one release with a deprecation note in webhook docs, dropped in 6.1.
vendor_id (nullable) on DeliveryMethod — the column ships in the marketplace plan's schema wave; this plan's model carries belongs_to :vendor, optional: true, replacing the Enterprise VendorConcern decorator seam (shipping_method.rb:10-12). Semantics (owned by the delivery-rate plan's Estimator filter): vendor_id present → offered only on that vendor's packages; nil → marketplace-wide. Store vs vendor are different axes: store = tenant + credential scope (store_id, owned by the delivery-rate plan), vendor = seller within it.
DeliveryMethodStockLocation validates vendor consistency: when both the method and the stock location carry a vendor_id, they must match — otherwise one vendor's pickup counter could serve another vendor's items. Pickup stock checks are package-scoped, never order.variants. pickup_stock_policy: 'any' (ship-to-store transfers) is incompatible across vendor boundaries and is refused when the location is vendor-owned.
Checkout — where fulfillments and delivery rates are created and selected — happens on the Cart under copy-on-completion semantics. Fulfillment and DeliveryRate follow the split-adjustments house pattern: nullable cart_id / order_id with an exactly-one validation, re-pointed at Spree::Carts::Complete (and again at vendor split). The exact shape is confirmed when 6.0-cart-order-split.md is fleshed out (Open Question 1) — but belongs_to :order hard-required is known-wrong and must not be implemented.
Fulfillment#delivery_amount (commission base)delivery_amount = cost + discounts.sum(:amount) — the selected rate's cost net of fulfillment-attached Discount rows (negative, from 6.0-split-adjustments.md). This is the base 6.0-multi-vendor-marketplace.md uses for include_shipping commission (CommissionLine fulfillment-scoped). cost remains the denormalized column it is today.
The rename is shipped → fulfilled (column state → status per the normalize plan), preserving today's guards and hooks, which the earlier draft dropped:
ready keeps its determine_state == 'ready' guard (the #2040 fix — no ready fulfillments on unpaid orders).pend (ready → pending) stays — used when an order becomes unpayable again.resume keeps both conditional targets (canceled → ready when determine_state says so, else canceled → pending).ready_for_pickup is cancelable (cancel accepts it) and reachable from ready (not just pending).after_transition hooks survive: restock/unstock, StateChange audit rows, events + webhooks (renamed per above).update_columns escape hatches that bypass the machine (update_amounts, Shipment#update!, ShipmentHandler#update_order_shipment_state) are rewritten to go through the machine or explicitly documented as freeze-safe.Order rollup keeps backorder. The earlier draft omitted it; backorder short-circuits the whole computation and feeds Order#allow_cancel? and the not_shipped scope. Domain of Order#fulfillment_status: backorder, canceled, partial, pending, ready, fulfilled (renamed from shipped), plus nil. ready_for_pickup/picked_up roll up as ready/fulfilled. Computation semantics are otherwise unchanged (including the [canceled, X] collapse rules).
A customer's merchant-pickup choice writes Order#preferred_stock_location_id, so it flows through the existing PreferredLocation routing rule (6.0-order-routing.md) instead of bypassing routing with a direct stock_location_id write. Fulfillment#stock_location always comes from the routing strategy's for_allocation packages — never a bare detect.
class Spree::DeliveryMethod < Spree.base_class
has_prefix_id :dm
acts_as_paranoid
include Spree::SingleStoreResource # store_id: migration owned by 6.0-delivery-rate-provider.md
include Spree::CalculatedAdjustments # calculators STAY — decisions.md 2026-07-27
include Spree::Metafields
include Spree::Metadata
belongs_to :store, class_name: 'Spree::Store'
belongs_to :vendor, class_name: 'Spree::Vendor', optional: true # marketplace; nil = marketplace-wide
belongs_to :tax_category, -> { with_deleted }, class_name: 'Spree::TaxCategory', optional: true
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', dependent: :destroy
has_many :delivery_zones, through: :delivery_method_zones, class_name: 'Spree::DeliveryZone' # 6.0-delivery-zones.md
has_many :delivery_method_stock_locations, class_name: 'Spree::DeliveryMethodStockLocation', dependent: :destroy
has_many :pickup_locations, through: :delivery_method_stock_locations, source: :stock_location
# storefront_visible boolean replaces Spree::DisplayOn (5.5-6.0-display-on-to-boolean.md,
# converted in this plan's Phase 1 wave). Do NOT include Spree::DisplayOn.
validates :name, :fulfillment_type, presence: true
validates :estimated_transit_business_days_min, :estimated_transit_business_days_max,
numericality: { greater_than_or_equal_to: 1 }, allow_nil: true
attribute :fulfillment_type, :string, default: 'shipping'
attribute :fulfillment_provider, :string, default: 'Spree::FulfillmentProvider::Manual'
attribute :pickup_point_provider, :string # only for pickup_point type
# rate_provider string column comes from 6.0-delivery-rate-provider.md
scope :by_fulfillment_type, ->(type) { where(fulfillment_type: type) }
def digital? = fulfillment_type == 'digital'
def pickup? = fulfillment_type == 'pickup'
def pickup_point? = fulfillment_type == 'pickup_point'
def requires_address? = !digital? && !pickup?
def provider = @provider ||= fulfillment_provider.constantize.new
end
class Spree::Fulfillment < Spree.base_class
has_prefix_id :ful
include Spree::Metadata
publishes_lifecycle_events
# Owner — exactly one (cart pre-completion, order after; re-pointed at Carts::Complete)
belongs_to :order, class_name: 'Spree::Order', touch: true, optional: true
belongs_to :cart, class_name: 'Spree::Cart', optional: true
belongs_to :address, class_name: 'Spree::Address', optional: true # nil for pickup/digital
belongs_to :stock_location, -> { with_deleted }, class_name: 'Spree::StockLocation'
attribute :pickup_point_data, :json # frozen snapshot of the selected third-party point
attribute :fulfillment_type, :string # copied from selected delivery method
has_many :fulfillment_items, class_name: 'Spree::FulfillmentItem', dependent: :delete_all
has_many :delivery_rates, -> { order(:cost) }, class_name: 'Spree::DeliveryRate', dependent: :delete_all
has_one :selected_delivery_rate, -> { where(selected: true) }, class_name: 'Spree::DeliveryRate'
has_many :discounts, class_name: 'Spree::Discount', dependent: :destroy # split-adjustments
has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy
# status column (normalize plan). Guards + hooks preserved from today's machine — see Key Decisions.
state_machine :status, initial: :pending do
event(:ready) { transition from: :pending, to: :ready, if: ->(f) { f.determine_state(f.order) == 'ready' } }
event(:pend) { transition from: :ready, to: :pending }
event(:fulfill) { transition from: %i[ready ready_for_pickup canceled], to: :fulfilled }
event(:cancel) { transition from: %i[pending ready ready_for_pickup], to: :canceled }
event(:resume) { transition from: :canceled, to: :ready, if: ->(f) { f.determine_state(f.order) == 'ready' }
transition from: :canceled, to: :pending }
event(:mark_ready_for_pickup) { transition from: :ready, to: :ready_for_pickup }
end
# after_transition hooks: restock/unstock, StateChange audit, set_fulfilled_at,
# fulfillment.* events + webhooks (dual-emit shipment.* for one release)
def delivery_method = selected_delivery_rate&.delivery_method
# Commission base for multi-vendor include_shipping (cost net of fulfillment discounts)
def delivery_amount = cost + discounts.sum(:amount)
end
FulfillmentItem is today's InventoryUnit renamed — the quantity column, its greater_than: 0 validation, the split/extract machinery, and has_many :return_items (FK renamed inventory_unit_id → fulfillment_item_id) all carry over unchanged.
Merchant pickup: GET /delivery_methods?fulfillment_type=pickup → GET /delivery_methods/:id/pickup_locations (pickup-enabled StockLocations with package-scoped stock) → select with stock_location_id, which writes Order#preferred_stock_location_id (routing rule picks it up). Third-party points: GET /delivery_methods/:id/pickup_points?latitude=&longitude= (calls the method's PickupPointProvider#find_nearby; ephemeral value objects, never persisted) → select with pickup_point_external_id → validated via find_by_external_id, frozen into fulfillment.pickup_point_data. PickupPointProvider::Base + PickupPointOption struct as previously designed.
Phase 1 as previously drafted was one ~20-operation migration; per CLAUDE.md (data transformations never in migrations) and the verification findings it is now split. The earlier draft's migration is void — it re-added shipped columns, targeted the nonexistent spree_product_types, and migrated a spree_store_shipping_methods join table that has never existed (see decisions.md 2026-03-16 correction).
6.0-product-types.md shipped (with the fulfillment_types addendum).6.0-cart-order-split.md fleshed out enough to confirm the owner shape (Open Question 1).6.0-delivery-rate-provider.md Phase 1 (store_id) — independent, can land before or after.6.0-typed-stock-movements.md confirmed born with fulfillment_id (dependency inverted from the earlier draft).delivery_rates.{shipment_id→fulfillment_id, shipping_method_id→delivery_method_id}, fulfillment_items.shipment_id→fulfillment_id, delivery_method_zones.shipping_method_id→delivery_method_id, return_items.inventory_unit_id→fulfillment_item_id (missed by the earlier draft), orders.{shipment_state→fulfillment_status, shipment_total→delivery_total} (removing the shipped aliases in the same change).display_on → storefront_visible boolean on spree_delivery_methods, jointly with 5.5-6.0-display-on-to-boolean.md Phase 2 (answering its coordination request).spree_shipping_rates_join_index — keep its uniqueness, it's the one-rate-per-method invariant).delivery_methods: fulfillment_type (null: false, default 'shipping', indexed), fulfillment_provider (null: false, default Manual), pickup_point_provider.fulfillments: fulfillment_type, pickup_point_data (JSON, jsonb-guarded), cart_id (per owner decision).spree_delivery_method_stock_locations join (unique on the pair).products.excluded_delivery_method_ids (JSON, jsonb-guarded).store_id (delivery-rate plan), vendor_id (marketplace wave).spree:migrate_shipping_to_delivery)spree_state_changes.stateful_type + name, spree_adjustments.adjustable_type, spree_stock_movements.originator_type — 'Spree::Shipment' → 'Spree::Fulfillment'.fulfillment_type backfill: methods with a DigitalDelivery calculator → 'digital' + FulfillmentProvider::Digital; all others 'shipping'. Copy to existing fulfillments from their selected method.Digital ProductType (fulfillment_types: ['digital']) assigned to their products (the sanctioned one-time assignment per the product-types addendum); everything else rides the ['shipping'] default — no ProductType auto-created.display_on value conversion (both/front_end → visible), per the display-on plan.Batched, idempotent, resumable — same guarantees as the split-adjustments migration.
Spree::Shipment = Spree::Fulfillment etc.). ShippingCategory gets no alias — referencing it raises NameError (the earlier draft's = nil would produce confusing NoMethodErrors instead).FulfilmentChanger → FulfillmentChanger; ShipmentHandler removed in favor of providers.reverse_chronological, with_free_shipping_promotion?, Package#shipping_categories SQL, determine_state string literals.Package#eligible_delivery_methods + Splitter::FulfillmentType; digital discriminator moved to columns; Product#digital?/#fulfillment_types on ProductType; remove ShippingCategory validations/auto-assign from Product in this phase (the column drops in 6.1 — products must stay saveable in between).backorder preserved; CanCanCan grants; permitted attributes; seeds (incl. Seeds::DigitalDelivery and the hardcoded 'Digital'/'Default' store category readers); factories (product_factory assigns a shipping category to every product — whole-suite impact).FulfillmentProvider::{Base,Manual,Digital,Pickup,PickupPoint} wired into transitions; PickupPointProvider::Base + option struct; Spree.fulfillment_providers / Spree.fulfillment_types registries.order.fulfilled; shipment.* → fulfillment.* webhook migration with one-release dual-emit.FulfillmentProvider::Digital (idempotent, per-quantity).FulfillmentSerializer#fulfillment_type from computed to column-backed; pickup endpoints (/pickup_locations, /pickup_points); storefront_visible exposure; regenerate the type pipeline. (Most serializer/route work already shipped — see "Already shipped".)shipment_mailer, subscriber, templates → fulfillment vocabulary.shipment vocabulary) + CSV import/export contract (shipping_category is a documented public column — importers get a mapping + deprecation window) + user docs. The legacy Rails admin's 26 shipping files are deleted with spree/admin at 6.0, not renamed — admin fulfillment management lives in the dashboard via Admin API/SDK.spree_shipping_categories + spree_shipping_method_categories, products.shipping_category_id, dual-emitted shipment.* events, the dangling admin_shipping_category_serializer / v2 platform registrations.shipping_method / shipment references. New tables referencing shipments are born with fulfillment_id.Spree::DisplayOn in new models (display-on plan constraint); use storefront_visible.ShipmentHandler — it's removed; fulfillment mechanics belong to providers.6.0-delivery-zones.md (this plan only births the join table correctly).shipped → fulfilled (fulfilled_at); Order column → fulfillment_status with domain backorder | canceled | partial | pending | ready | fulfilled (+ nil) — backorder stays (earlier draft wrongly dropped it). ready_for_pickup/picked_up roll up as ready/fulfilled. Guards, pend, conditional resume, and all transition hooks are preserved.store_id on DeliveryMethod is owned by 6.0-delivery-rate-provider.md Phase 1 (greenfield + derived backfill — there was never a join table; see decisions.md 2026-03-16 correction). The earlier draft's PaymentMethod claim is obsolete — that shipped in 5.6 and was amended 2026-07-23 (PaymentMethodRule).fulfillment_types as JSON column on ProductType (jsonb-guarded), owned by the 6.0-product-types.md addendum.fulfillment.{ready,fulfilled,canceled,resumed} + order.fulfilled; one-release dual-emit of legacy shipment.* webhooks.FulfillmentProvider::Digital (fires fulfill! + DigitalLinks on completion); today nothing auto-fulfills.delivery_amount = cost + fulfillment discounts — the multi-vendor include_shipping commission base.Order#preferred_stock_location_id and the PreferredLocation routing rule; stock checks are package-scoped; pickup_stock_policy: 'any' refused for vendor-owned locations.H numbers stay; new records use F.cart_id/order_id exactly-one is the unified pattern (LineItem included, no polymorphism); Phase 1b's cart_id is unblocked.shipment.* subscribers, or does Enterprise need a longer bridge?ready_for_pickup in the Store API — expose as its own status or map onto ready with fulfillment_type disambiguating?spree/core/app/models/spree/{shipment,shipping_method,shipping_category,inventory_unit}.rbshipment.rb:73-110; rollup: order_updater.rb:140-178; number generator: shipment.rb:7stock/package.rb:73-80 + stock/estimator.rb:61-71; parity spec: spec/models/spree/order_routing/strategy/parity_spec.rb:426-452api/app/serializers/spree/api/v3/fulfillment_serializer.rb, api/lib/spree/api/dependencies.rb:111-1286.0-product-types.md (fulfillment_types addendum), 6.0-delivery-rate-provider.md, 6.0-cart-order-split.md, 6.0-order-routing.md, 6.0-split-adjustments.md, 6.0-typed-stock-movements.md, 6.0-returns-exchanges-claims.md, 6.0-multi-vendor-marketplace.md, 5.5-6.0-display-on-to-boolean.md, decisions.md (2026-07-27 calculators/Zone; 2026-03-16 corrected store-scoping)