docs/plans/6.0-stock-reservations.md
Status: Shipped in 5.5 (PR #13978); 6.0 refinements (Cart/Order split integration, allocated_count term) still pending
Target: Spree 5.5 (Store API + Admin API ✓), refined in 6.0 (Cart/Order split integration, allocated_count term)
Depends on: None for 5.5. In 6.0: Cart/Order split (6.0-cart-order-split.md), Typed Stock Movements (6.0-typed-stock-movements.md)
Author: Damian + Claude
Last updated: 2026-05-20
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. In 5.5 the allocated_count term is omitted — it lands with Typed Stock Movements in 6.0. The 5.5 formula is count_on_hand - active_reservations.Spree::Api::V3::Storefront::*) only. Legacy Rails storefront (Spree::CheckoutController) and v2 API are intentionally not wired. Other customers' availability still reflects reservations (Quantifier subtracts them globally) — they just can't create their own from non-v3 paths. Release-note this clearly.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_minutes overrides Spree::Config[:default_stock_reservation_ttl_minutes] (default: 10). Resolved by Spree::StockReservation.ttl_for(order). TTL deliberately lives on Store, not StockLocation — it's a checkout-experience policy, not a warehouse property. A multi-location cart never has to merge conflicting TTLs from different warehouses. If future merchants need differentiation, the natural next move is splitting TTL by guest vs authenticated customer on the Store, not by location. BOPIS lead time belongs on StockLocation#pickup_ready_in_minutes (see 6.0-fulfillment-and-delivery.md), which is a different concept.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.!backorderable? — non-backorderable items get a reservation; backorderable items don't (the merchant has opted into selling phantom stock so a hold adds nothing). This matches how Shopify (inventoryPolicy: DENY blocks at 0) and Sylius (tracked → hold) consolidate tracking + reservation behind one toggle. No separate reserves_stock field — track_inventory (does counting happen at all?) and backorderable (oversell allowed?) together fully determine the behavior.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.minutes
update!(expires_at: Time.current + duration)
end
def release!
destroy!
end
end
The same shape works for both 5.5 and 6.0. Spree::StockItem#allocated_count is a Ruby shim returning 0 in 5.5; 6.0 Typed Stock Movements (6.0-typed-stock-movements.md) replaces the shim with a real column updated by typed movements, and the Rails column accessor takes precedence over the method automatically. The formulas below continue to mean exactly what their names say through the transition.
class Spree::Stock::Quantifier
# Purchasable now: physical pool minus already-allocated units minus
# active checkout reservations. Clamped at zero. Returns infinity for
# variants that don't track inventory.
def total_on_hand
@total_on_hand ||= if variant.should_track_inventory?
[available_stock - reserved_quantity, 0].max
else
BigDecimal::INFINITY
end
end
# Physical pool minus already-allocated units, summed across the
# variant's active stock items.
def available_stock
if association_loaded?
stock_items.sum(&:available_count)
elsif self.class.allocated_count_column?
stock_items.sum('count_on_hand - allocated_count')
else
stock_items.sum(:count_on_hand)
end
end
# Units currently held by active checkout reservations across the variant's
# stock items. EXISTS-guarded so non-checkout traffic stays one query.
def reserved_quantity
return 0 unless Spree::Config[:stock_reservations_enabled]
active_reservations = Spree::StockReservation.active.where(stock_item_id: stock_items.map(&:id))
active_reservations.exists? ? active_reservations.sum(:quantity) : 0
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.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.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.
Hooks live in Store API controllers, not in core services. Legacy Spree::CheckoutController and v2 API are not wired in 5.5.
In Spree::Api::V3::Storefront::CheckoutController#next (or wherever the cart→address transition happens via Store API), after the successful state transition:
Spree::StockReservations::Reserve.call(order: @order)
In every Store API checkout endpoint that mutates the order during checkout (#update, address selection, shipping selection, payment selection):
Spree::StockReservations::Extend.call(order: @order)
In Spree::Api::V3::Storefront::CheckoutController#complete (or after the existing completion service), after the standard count_on_hand decrement runs:
Spree::StockReservations::Release.call(order: @order)
In Spree::Api::V3::Storefront::CartController#empty and the order cancel endpoint:
Spree::StockReservations::Release.call(order: @order)
In Spree::Api::V3::Storefront::CartItemsController#create, #update, #destroy — only if the order is past the cart state (i.e., a reservation already exists or should exist):
Spree::StockReservations::Reserve.call(order: @order) unless @order.cart? || @order.complete? || @order.canceled?
LineItem destroy auto-cleans the reservation via the has_many :stock_reservations, dependent: :destroy association added to LineItem, Order, and StockItem in Phase 1.
With the Cart/Order split (6.0-cart-order-split.md), reserve fires from Spree::Carts::Complete when the cart converts to an order, extend fires from Spree::Carts::Update, and release fires from Spree::Carts::Empty and order cancellation. This consolidates the per-controller hooks above into core services.
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_minutes = 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 reservations (5.5 formula: count_on_hand - reserved_quantity)dependent: :destroy from LineItem and Order to clean up reservationsReserve into Store API checkout next/transition endpointExtend into Store API checkout update endpointsRelease into Store API order complete + cart empty + order cancel endpointsReserve on cart item create/update/destroy when order is in checkoutExpireJob to the standard Spree job schedule (sidekiq-cron / solid_queue)reserved_quantity / available_quantity to Store variant serializerSpree::Api::V3::Admin::StockReservationsController (read-only index/show)Spree::Carts::Complete, Spree::Carts::Update, Spree::Carts::Emptyallocated_count (depends on Typed Stock Movements)reserve_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)