Back to Spree

Typed Stock Movements

docs/plans/6.0-typed-stock-movements.md

5.4.215.9 KB
Original Source

Typed Stock Movements

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

Summary

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.

Problem

1. No semantic meaning

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.

2. Polymorphic originator

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

3. No allocation tracking

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.

4. No movement lifecycle

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.

Current State

WhatHow it works
StockMovementquantity (int) + originator (polymorphic) + stock_item_id
Shipment shipsstock_location.unstock(variant, qty, shipment) → creates movement with negative qty
Return receivedStockMovement.create!(stock_item_id:, quantity: +qty, originator: return_authorization)
Stock transfersource.unstock(...) + destination.restock(...) → two movements, linked by StockTransfer originator
Manual adjustmentstock_item.adjust_count_on_hand(value) → no movement record (!)
Fulfillment changecurrent_location.restock(...) + desired_location.unstock(...)
After createupdate_stock_item_quantity → calls stock_item.adjust_count_on_hand
Immutablereadonly? returns true after persist — movements can't be edited

Originator types in practice: Spree::Shipment, Spree::ReturnAuthorization, Spree::StockTransfer, nil.

Key Decisions (do not deviate without discussion)

  • Single table with 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.
  • Concrete FKs replace polymorphic originator. Separate nullable FKs: shipment_id, order_id, return_authorization_id, stock_transfer_id. Each movement has at most one set.
  • Five defined kinds:
    • 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)
  • Movements are immutable. Existing 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.
  • Keep StockLocation.restock/unstock methods but they create typed movements instead of generic ones.

Design Details

StockMovement model (rewritten)

ruby
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

StockItem changes

ruby
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

Quantifier changes

ruby
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 location
  • allocated_count = units earmarked for orders awaiting shipment
  • reserved_quantity = units held during checkout (from StockReservations plan)

StockLocation changes

ruby
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

Order lifecycle integration

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.

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

Admin API

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:

json
{
  "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:

json
{
  "id": "si_xyz",
  "count_on_hand": 50,
  "allocated_count": 8,
  "available_count": 42,
  "backorderable": false
}

Migration Path

Phase 1: Schema changes

ruby
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

Phase 2: Data migration (rake task)

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

Phase 3: Model update

  • Add kind validation, concrete FK associations
  • Update update_stock_item_quantities callback with kind-based logic
  • Update StockLocation.restock/unstock to create typed movements
  • Add StockLocation.allocate/release/adjust methods
  • Update Quantifier to use available_count
  • Wire allocation into order completion flow
  • Wire release into order cancellation flow

Phase 4: Remove polymorphic originator

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

Phase 5: API + serializer updates

  • Add kind, concrete FK IDs to StockMovement serializer
  • Add allocated_count, available_count to StockItem serializer
  • Admin dashboard: stock movement history with kind filters

Constraints on Current Work

  • Don't create StockMovements directly. Use StockLocation.restock/unstock/allocate/release/adjust methods which will create properly typed movements.
  • Don't rely on originator_type for business logic. Use the kind column / scopes instead.
  • Use Quantifier#total_on_hand for availability. Don't query count_on_hand directly — the quantifier accounts for allocations and reservations.

Resolved Questions

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

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

Open Questions

None at this time.

References

  • Current stock movement: spree/core/app/models/spree/stock_movement.rb
  • Current stock location restock/unstock: spree/core/app/models/spree/stock_location.rb:96-114
  • Current shipment unstock: spree/core/app/models/spree/shipment.rb:491
  • Current return restock: spree/core/app/models/spree/return_item.rb:201
  • Related plan: 6.0-stock-reservations.md (reservations layer on top of availability)
  • Related plan: 6.0-split-adjustments.md (same pattern: replace polymorphic with concrete FKs)