docs/plans/6.0-typed-stock-movements.md
Status: Draft Target: Spree 6.0 Depends on: Stock Reservations (6.0-stock-reservations.md), Remove Master Variant (6.0-remove-master-variant.md), Cart/Order Split (6.0-cart-order-split.md) Author: Damian + Claude Last updated: 2026-03-22
Replace the generic Spree::StockMovement (quantity + polymorphic originator) with typed movement records that explicitly describe what happened to stock and why. Each type has concrete FKs to its cause — no polymorphic originator.
This gives merchants a full audit trail ("10 units allocated to order or_abc, 3 units shipped in ship_xyz, 2 units returned via RA ra_456") instead of today's opaque list of positive/negative numbers.
Today's StockMovement is just { quantity: -3, originator_type: 'Spree::Shipment', originator_id: 42 }. You can't answer "how many units are allocated to pending orders?" or "how many units were returned this month?" without parsing originator types and guessing the business meaning of positive vs negative quantities.
belongs_to :originator, polymorphic: true — same problem as polymorphic Adjustment. Can't eager load, can't JOIN cleanly. Originator can be Shipment, ReturnAuthorization, StockTransfer, or nil (manual adjustment).
When an order is placed, stock should be allocated (reserved for fulfillment) before it's shipped (physically removed). Today there's no allocation concept — stock decrements happen at various points (shipment creation, shipment shipping) depending on configuration, with no clear audit of the transition.
There's no way to trace the lifecycle of a unit: received → allocated → shipped. Or: received → allocated → released (order canceled). Each step is just an anonymous +/- on count_on_hand.
| What | How it works |
|---|---|
StockMovement | quantity (int) + originator (polymorphic) + stock_item_id |
| Shipment ships | stock_location.unstock(variant, qty, shipment) → creates movement with negative qty |
| Return received | StockMovement.create!(stock_item_id:, quantity: +qty, originator: return_authorization) |
| Stock transfer | source.unstock(...) + destination.restock(...) → two movements, linked by StockTransfer originator |
| Manual adjustment | stock_item.adjust_count_on_hand(value) → no movement record (!) |
| Fulfillment change | current_location.restock(...) + desired_location.unstock(...) |
| After create | update_stock_item_quantity → calls stock_item.adjust_count_on_hand |
| Immutable | readonly? returns true after persist — movements can't be edited |
Originator types in practice: Spree::Shipment, Spree::ReturnAuthorization, Spree::StockTransfer, nil.
kind column (not separate tables per type). Stock movements are all the same shape (stock_item, quantity, timestamp, reason) — splitting into 5 tables would add complexity without benefit. The kind column is an indexed string, not a polymorphic type.shipment_id, order_id, return_authorization_id, stock_transfer_id. Each movement has at most one set.received — stock added (purchase order, manual receipt, return restock)allocated — stock earmarked for an order (on order completion or payment authorization)shipped — allocated stock physically sent (on shipment ship)released — allocated stock returned to available (order canceled, fulfillment changed)adjusted — manual correction (admin inventory count, shrinkage, damage)readonly? pattern stays. To reverse a movement, create a counterpart (e.g., released reverses allocated).count_on_hand semantics change. Today it's the only number. After:
count_on_hand = physical stock at location (unchanged by allocation, only by receipt/ship/adjust)allocated_quantity = derived from movements (SUM of allocated - shipped - released)available_quantity = count_on_hand - allocated_quantity - reserved_quantity (from StockReservations plan)StockItem gets allocated_count denormalized column — updated on allocation/release/ship movements. Avoids SUM query on every availability check.StockLocation.restock/unstock methods but they create typed movements instead of generic ones.class Spree::StockMovement < Spree.base_class
has_prefix_id :sm
KINDS = %w[received allocated shipped released adjusted].freeze
include Spree::StockMovement::Webhooks
include Spree::StockMovement::CustomEvents
publishes_lifecycle_events
belongs_to :stock_item, class_name: 'Spree::StockItem', inverse_of: :stock_movements
# Concrete FKs — at most one is set per movement
belongs_to :order, class_name: 'Spree::Order', optional: true
belongs_to :shipment, class_name: 'Spree::Shipment', optional: true
belongs_to :return_authorization, class_name: 'Spree::ReturnAuthorization', optional: true
belongs_to :stock_transfer, class_name: 'Spree::StockTransfer', optional: true
validates :stock_item, :quantity, :kind, presence: true
validates :kind, inclusion: { in: KINDS }
validates :quantity, numericality: { other_than: 0, only_integer: true }
validates :reason, presence: true, if: -> { kind == 'adjusted' }
after_create :update_stock_item_quantities
scope :recent, -> { order(created_at: :desc) }
scope :by_kind, ->(kind) { where(kind: kind) }
scope :received, -> { by_kind('received') }
scope :allocated, -> { by_kind('allocated') }
scope :shipped, -> { by_kind('shipped') }
scope :released, -> { by_kind('released') }
scope :adjusted, -> { by_kind('adjusted') }
scope :for_order, ->(order) { where(order: order) }
scope :for_shipment, ->(shipment) { where(shipment: shipment) }
delegate :variant, :variant_id, to: :stock_item, allow_nil: true
def readonly?
persisted?
end
private
def update_stock_item_quantities
return unless stock_item.should_track_inventory?
case kind
when 'received', 'adjusted'
# Changes physical count
stock_item.adjust_count_on_hand(quantity)
when 'allocated'
# Earmarks stock — no count_on_hand change, increment allocated_count
stock_item.increment!(:allocated_count, quantity.abs)
when 'shipped'
# Physically removed — decrement count_on_hand, decrement allocated_count
stock_item.adjust_count_on_hand(-quantity.abs)
stock_item.decrement!(:allocated_count, quantity.abs)
when 'released'
# Returns to available — decrement allocated_count only
stock_item.decrement!(:allocated_count, quantity.abs)
end
end
end
class Spree::StockItem < Spree.base_class
# Existing
# count_on_hand — physical stock at location
# New denormalized column
# allocated_count — units earmarked for pending orders
def available_count
count_on_hand - allocated_count
end
# Used by Quantifier (replaces raw count_on_hand)
def available?
available_count > 0 || backorderable?
end
end
class Spree::Stock::Quantifier
def total_on_hand
@total_on_hand ||= if variant.should_track_inventory?
available_stock - reserved_quantity
else
BigDecimal::INFINITY
end
end
# Physical stock minus allocated (pending fulfillment)
def available_stock
if association_loaded?
stock_items.sum(&:available_count)
else
stock_items.sum('count_on_hand - allocated_count')
end
end
# From StockReservations plan
def reserved_quantity
return 0 unless Spree::Config[:stock_reservations_enabled]
Spree::StockReservation.active.where(stock_item: stock_items).sum(:quantity)
end
end
Full availability formula:
available = count_on_hand - allocated_count - reserved_quantity
Where:
count_on_hand = physical units at locationallocated_count = units earmarked for orders awaiting shipmentreserved_quantity = units held during checkout (from StockReservations plan)class Spree::StockLocation < Spree.base_class
# Restock: units received at location
def restock(variant, quantity, originator = nil)
stock_item = stock_item_or_create(variant)
stock_item.stock_movements.create!(
quantity: quantity,
kind: 'received',
stock_transfer: originator.is_a?(Spree::StockTransfer) ? originator : nil,
return_authorization: originator.is_a?(Spree::ReturnAuthorization) ? originator : nil
)
end
# Unstock: units physically leaving location (shipped)
def unstock(variant, quantity, shipment)
stock_item = stock_item_or_create(variant)
stock_item.stock_movements.create!(
quantity: quantity,
kind: 'shipped',
shipment: shipment,
order: shipment.order
)
end
# Allocate: earmark units for an order
def allocate(variant, quantity, order)
stock_item = stock_item_or_create(variant)
stock_item.stock_movements.create!(
quantity: quantity,
kind: 'allocated',
order: order
)
end
# Release: return allocated units to available
def release(variant, quantity, order)
stock_item = stock_item_or_create(variant)
stock_item.stock_movements.create!(
quantity: quantity,
kind: 'released',
order: order
)
end
# Adjust: manual inventory correction
def adjust(variant, quantity, reason:)
stock_item = stock_item_or_create(variant)
stock_item.stock_movements.create!(
quantity: quantity,
kind: 'adjusted',
reason: reason
)
end
end
With the Cart/Order split (6.0-cart-order-split.md), the order state machine is gone. cart.complete! calls Spree::Carts::Complete which creates a new Order. Stock allocation happens at this point — when the cart converts to an order.
# On cart completion (Spree::Carts::Complete):
# Reservation (from StockReservations plan) is released, allocation created
order.line_items.each do |li|
stock_location = select_stock_location(li.variant)
stock_location.allocate(li.variant, li.quantity, order)
end
# Release checkout reservation (reservation plan handles this)
# On shipment ship:
# Convert allocation to shipped (count_on_hand decremented, allocated_count decremented)
shipment.inventory_units.each do |iu|
shipment.stock_location.unstock(iu.variant, iu.quantity, shipment)
end
# On order cancellation:
# Release allocated stock
order.line_items.each do |li|
stock_location.release(li.variant, li.quantity, order)
end
# On return received:
# Restock units
stock_location.restock(variant, quantity, return_authorization)
GET /api/v3/admin/stock_movements?filter[stock_item_id]=si_xxx
GET /api/v3/admin/stock_movements?filter[kind]=allocated&filter[order_id]=or_abc
Response:
{
"id": "sm_k5nR8xLq",
"kind": "allocated",
"quantity": 3,
"stock_item_id": "si_xyz",
"order_id": "or_abc",
"shipment_id": null,
"return_authorization_id": null,
"stock_transfer_id": null,
"reason": null,
"created_at": "2026-03-16T14:30:00Z"
}
StockItem response gains allocated_count and available_count:
{
"id": "si_xyz",
"count_on_hand": 50,
"allocated_count": 8,
"available_count": 42,
"backorderable": false
}
class AddTypedStockMovements < ActiveRecord::Migration[7.2]
def change
# Add kind and concrete FKs to existing table
add_column :spree_stock_movements, :kind, :string
add_column :spree_stock_movements, :order_id, :bigint
add_column :spree_stock_movements, :shipment_id, :bigint
add_column :spree_stock_movements, :return_authorization_id, :bigint
add_column :spree_stock_movements, :stock_transfer_id, :bigint
add_column :spree_stock_movements, :reason, :string
add_index :spree_stock_movements, :kind
add_index :spree_stock_movements, :order_id
add_index :spree_stock_movements, :shipment_id
# Add allocated_count to stock_items
add_column :spree_stock_items, :allocated_count, :integer, null: false, default: 0
end
end
# rake spree:type_stock_movements
#
# For each existing StockMovement:
# originator_type == 'Spree::Shipment' && quantity < 0
# → kind: 'shipped', shipment_id: originator_id, order_id: shipment.order_id
# originator_type == 'Spree::Shipment' && quantity > 0
# → kind: 'released', shipment_id: originator_id (fulfillment change restock)
# originator_type == 'Spree::ReturnAuthorization'
# → kind: 'received', return_authorization_id: originator_id
# originator_type == 'Spree::StockTransfer' && quantity > 0
# → kind: 'received', stock_transfer_id: originator_id
# originator_type == 'Spree::StockTransfer' && quantity < 0
# → kind: 'shipped', stock_transfer_id: originator_id
# originator_type == nil
# → kind: 'adjusted', reason: 'Legacy manual adjustment'
#
# Note: No historical 'allocated' movements exist (allocation didn't exist before).
# allocated_count on StockItems starts at 0 — only new orders create allocations.
kind validation, concrete FK associationsupdate_stock_item_quantities callback with kind-based logicStockLocation.restock/unstock to create typed movementsStockLocation.allocate/release/adjust methodsQuantifier to use available_countclass RemoveOriginatorFromStockMovements < ActiveRecord::Migration[7.2]
def change
remove_column :spree_stock_movements, :originator_type
remove_column :spree_stock_movements, :originator_id
# kind is now required
change_column_null :spree_stock_movements, :kind, false
end
end
kind, concrete FK IDs to StockMovement serializerallocated_count, available_count to StockItem serializerStockLocation.restock/unstock/allocate/release/adjust methods which will create properly typed movements.originator_type for business logic. Use the kind column / scopes instead.Quantifier#total_on_hand for availability. Don't query count_on_hand directly — the quantifier accounts for allocations and reservations.Allocation timing. Stock is allocated on cart completion — when cart.complete! creates the Order (see 6.0-cart-order-split.md). The checkout reservation (from 6.0-stock-reservations.md) is released at the same time. Flow: customer in checkout → reservation holds stock → cart completes → reservation released, allocation created → shipment ships → allocation converted to shipped.
Stock transfer movements. Reuse shipped/received with a stock_transfer_id FK. A transfer is just shipping from one location and receiving at another. No new kinds needed.
None at this time.
spree/core/app/models/spree/stock_movement.rbspree/core/app/models/spree/stock_location.rb:96-114spree/core/app/models/spree/shipment.rb:491spree/core/app/models/spree/return_item.rb:2016.0-stock-reservations.md (reservations layer on top of availability)6.0-split-adjustments.md (same pattern: replace polymorphic with concrete FKs)