docs/plans/6.0-stock-reservations.md
Status: Design finalized, implementation not started Target: Spree 6.0 Depends on: Cart/Order split (6.0-cart-order-split.md) Author: Damian + Claude Last updated: 2026-03-22
Add time-limited stock reservations during checkout to prevent overselling. When a customer enters checkout, their line item quantities are soft-held against StockItem.count_on_hand with a configurable TTL. Other customers see reduced availability. If the reservation expires (abandoned checkout), stock is automatically released. On order completion, the reservation converts to a permanent decrement of count_on_hand.
This is purely additive — the existing StockItem, StockMovement, and InventoryUnit models are unchanged. Reservations are a new layer on top.
Today, stock is only checked at the moment of order completion (ensure_line_items_are_in_stock). No stock is held during checkout. This means:
Every modern commerce platform solves this with time-limited reservations.
Spree::StockReservation — concrete FKs to stock_item, line_item, order. No polymorphism.StockItem.count_on_hand is never modified by reservations. It only changes on order completion (decrement) or stock adjustment. Reservations are a soft hold computed at query time.count_on_hand - allocated_count - active_reservations — the Quantifier is updated to subtract both allocations (from 6.0-typed-stock-movements.md) and active reservations from physical stock. When Typed Stock Movements ships, allocated_count is included; before that, it's zero.Spree::Config[:reserve_stock_on] controls when reservations are created: :checkout (when entering checkout, default) or :cart (when adding to cart). Cart-level is more aggressive, checkout-level is the standard.store.preferred_stock_reservation_ttl (default: 10 minutes). Merchants can tune this based on their checkout complexity.expires_at to prevent expiry during active checkout.Spree::StockReservations::ExpireJob runs periodically, deletes expired records. Simple DELETE WHERE expires_at < NOW().count_on_hand decremented — the existing stock decrement logic stays, reservation is just cleaned up.StockItem.backorderable?, no reservation needed (unlimited supply).Spree::Config[:stock_reservations_enabled] = false (default: true). When disabled, behavior is identical to today.class Spree::StockReservation < Spree.base_class
has_prefix_id :res
belongs_to :stock_item, class_name: 'Spree::StockItem'
belongs_to :line_item, class_name: 'Spree::LineItem'
belongs_to :order, class_name: 'Spree::Order'
validates :stock_item, :line_item, :order, :quantity, :expires_at, presence: true
validates :quantity, numericality: { greater_than: 0, only_integer: true }
validates :line_item_id, uniqueness: { scope: :stock_item_id }
scope :active, -> { where('expires_at > ?', Time.current) }
scope :expired, -> { where('expires_at <= ?', Time.current) }
scope :for_variant, ->(variant) { joins(:stock_item).where(spree_stock_items: { variant_id: variant.id }) }
scope :for_order, ->(order) { where(order: order) }
def active?
expires_at > Time.current
end
def expired?
!active?
end
def extend!(duration = nil)
duration ||= order.store.preferred_stock_reservation_ttl.minutes
update!(expires_at: Time.current + duration)
end
def release!
destroy!
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)
# allocated_count comes from 6.0-typed-stock-movements.md (zero until that ships)
def available_stock
if association_loaded?
stock_items.sum(&:available_count)
else
stock_items.sum('count_on_hand - allocated_count')
end
end
def reserved_quantity
return 0 unless Spree::Config[:stock_reservations_enabled]
@reserved_quantity ||= Spree::StockReservation
.active
.where(stock_item: stock_items)
.sum(:quantity)
end
# Physical count without reservations or allocations (for admin/reporting)
def raw_count_on_hand
if association_loaded?
stock_items.sum(&:count_on_hand)
else
stock_items.sum(:count_on_hand)
end
end
end
module Spree
module StockReservations
class Reserve
prepend Spree::ServiceModule::Base
def call(order:)
return success(order) unless Spree::Config[:stock_reservations_enabled]
ttl = order.store.preferred_stock_reservation_ttl.minutes
expires_at = Time.current + ttl
ApplicationRecord.transaction do
order.line_items.includes(variant: :stock_items).each do |line_item|
next unless line_item.variant.should_track_inventory?
stock_item = select_stock_item(line_item.variant)
next if stock_item.nil? || stock_item.backorderable?
# Check availability (count_on_hand minus other reservations)
available = stock_item.count_on_hand - other_reservations(stock_item, order)
if available < line_item.quantity
return failure(line_item, Spree.t(:insufficient_stock_for_reservation,
item: line_item.variant.name, available: [available, 0].max))
end
# Upsert reservation
reservation = Spree::StockReservation.find_or_initialize_by(
stock_item: stock_item,
line_item: line_item,
order: order
)
reservation.quantity = line_item.quantity
reservation.expires_at = expires_at
reservation.save!
end
end
success(order)
end
private
def select_stock_item(variant)
variant.stock_items.detect { |si| si.stock_location.active? && si.available? }
end
def other_reservations(stock_item, exclude_order)
Spree::StockReservation
.active
.where(stock_item: stock_item)
.where.not(order: exclude_order)
.sum(:quantity)
end
end
end
end
module Spree
module StockReservations
class Release
prepend Spree::ServiceModule::Base
def call(order:)
Spree::StockReservation.where(order: order).delete_all
success(order)
end
end
end
end
module Spree
module StockReservations
class Extend
prepend Spree::ServiceModule::Base
def call(order:)
return success(order) unless Spree::Config[:stock_reservations_enabled]
ttl = order.store.preferred_stock_reservation_ttl.minutes
expires_at = Time.current + ttl
Spree::StockReservation
.where(order: order)
.update_all(expires_at: expires_at, updated_at: Time.current)
success(order)
end
end
end
end
module Spree
module StockReservations
class ExpireJob < Spree::BaseJob
queue_as Spree.queues.stock_reservations
def perform
Spree::StockReservation.expired.delete_all
end
end
end
end
Scheduled via sidekiq-cron or solid_queue recurring task — runs every 1 minute.
# In Spree::Checkout::Next or Spree::Checkout::Update
# When order transitions from 'cart' to 'address' (first checkout step):
after_transition from: :cart do |order|
Spree::StockReservations::Reserve.call(order: order)
end
With the Cart/Order split (6.0-cart-order-split.md), this happens in Spree::Carts::Complete when the cart converts to an order.
# In Spree::Carts::Update, after any successful step update:
Spree::StockReservations::Extend.call(order: order)
# In Spree::Carts::Complete, after order is finalized:
# Existing stock decrement logic runs (InventoryUnit creation, count_on_hand decrement)
# Then clean up the reservation:
Spree::StockReservations::Release.call(order: order)
# In Spree::Carts::Empty and order cancellation:
Spree::StockReservations::Release.call(order: order)
# In Spree::Cart::SetQuantity, after quantity update:
# Re-run reservation to adjust quantities
Spree::StockReservations::Reserve.call(order: order)
# In Spree::Cart::RemoveLineItem:
# Reservation for that line item auto-deleted via dependent: :destroy on LineItem
# Or explicit release:
Spree::StockReservation.where(line_item: line_item).delete_all
New read-only field on variants:
{
"id": "variant_k5nR8xLq",
"in_stock": true,
"total_on_hand": 5,
"reserved_quantity": 2,
"available_quantity": 3
}
available_quantity = total_on_hand (already reflects reservations via Quantifier).
Admin API gets reservation visibility:
GET /api/v3/admin/stock_reservations?filter[variant_id]=variant_xxx
{
"id": "res_abc",
"stock_item_id": "si_xyz",
"line_item_id": "li_123",
"order_id": "or_456",
"quantity": 2,
"expires_at": "2026-03-16T14:35:00Z",
"active": true
}
Spree::Config[:stock_reservations_enabled] = true # default: true
Spree::Config[:reserve_stock_on] = :checkout # :checkout or :cart
# Per-store TTL (minutes)
store.preferred_stock_reservation_ttl = 10 # default: 10
class CreateSpreeStockReservations < ActiveRecord::Migration[7.2]
def change
create_table :spree_stock_reservations do |t|
t.references :stock_item, null: false
t.references :line_item, null: false
t.references :order, null: false
t.integer :quantity, null: false
t.datetime :expires_at, null: false
t.timestamps
end
add_index :spree_stock_reservations, [:stock_item_id, :line_item_id], unique: true,
name: 'idx_stock_reservations_item_line_item'
add_index :spree_stock_reservations, :expires_at,
name: 'idx_stock_reservations_expires_at'
add_index :spree_stock_reservations, :order_id
end
end
This is purely additive — no existing tables or models are modified (beyond Quantifier logic).
StockReservation model and tableReserve, Release, Extend, ExpireJob servicesQuantifier to subtract reservationsReserve into checkout entryExtend into checkout step updatesRelease into order completion and cancellationExpireJob to recurring job schedulereserved_quantity / available_quantity to variant serializerreserve_stock_on: :cart, wire Reserve into Cart::AddItem and Cart::SetQuantityStockItem.count_on_hand for reservations. Reservations are a separate layer. count_on_hand only changes on order completion, stock adjustments, and shipment.Quantifier#total_on_hand for availability checks. Don't query count_on_hand directly — the quantifier will handle the reservation subtraction.StockReservations::Extend after any successful step to keep reservations alive.Multi-location reservation. When a store has multiple stock locations, should the reservation lock stock at a specific location (determined at reservation time) or float until fulfillment? Locking early is simpler but may lead to suboptimal warehouse selection. Floating requires re-checking at completion.
Reservation failure UX. When stock runs out during reservation (another customer completed first), should the checkout: (a) block the transition with an error, (b) auto-adjust the quantity down and notify, or (c) allow proceeding with a backorder warning? Probably (a) by default with (b) as an option.
Reservation visibility in Store API. Should the Store API expose that an item has "only 3 left" (accounting for reservations) to create urgency? Or only expose boolean in_stock? This is a merchandising decision that could be store-configurable.
spree/core/app/models/spree/stock_item.rb, spree/core/app/models/spree/stock/quantifier.rbspree/core/app/models/spree/order.rb:600 (ensure_line_items_are_in_stock)6.0-cart-order-split.md (reservation created when Cart converts to Order)