Back to Spree

Stock Reservations

docs/plans/6.0-stock-reservations.md

5.5.017.5 KB
Original Source

Stock Reservations

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

Summary

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.

Problem

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:

  1. Two customers can checkout the same last unit simultaneously. Both see it as available, both enter payment details, one fails at completion.
  2. Failure happens at the worst moment — after address, shipping, and payment entry. Maximum friction, maximum abandonment.
  3. Flash sales and limited drops are unusable — high-demand items oversell because there's no reservation window.
  4. No visibility into pending demand — merchants can't see how much stock is "in checkout" vs truly available.

Every modern commerce platform solves this with time-limited reservations.

Key Decisions (do not deviate without discussion)

  • New model 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.
  • Available quantity = 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.
  • Store API only in 5.5. Reservation create/extend/release hooks fire from Store API endpoints (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.
  • Configurable triggerSpree::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.
  • Configurable TTL per store, with global defaultstore.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.
  • Reservations extend on activity — each checkout interaction (address update, shipping selection, payment) resets expires_at to prevent expiry during active checkout.
  • Background job expires reservationsSpree::StockReservations::ExpireJob runs periodically, deletes expired records. Simple DELETE WHERE expires_at < NOW().
  • On order completion: reservation deleted, count_on_hand decremented — the existing stock decrement logic stays, reservation is just cleaned up.
  • Reservation gate is !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.
  • Reservation is optional — stores can disable it entirely via Spree::Config[:stock_reservations_enabled] = false (default: true). When disabled, behavior is identical to today.

Design Details

StockReservation model

ruby
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

Quantifier changes

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.

ruby
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

Reservation service

ruby
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

Release service

ruby
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

Extend service (on checkout activity)

ruby
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

Expire job

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

Integration points (5.5 — Store API only)

Hooks live in Store API controllers, not in core services. Legacy Spree::CheckoutController and v2 API are not wired in 5.5.

Reserve on entering checkout

In Spree::Api::V3::Storefront::CheckoutController#next (or wherever the cart→address transition happens via Store API), after the successful state transition:

ruby
Spree::StockReservations::Reserve.call(order: @order)

Extend on checkout activity

In every Store API checkout endpoint that mutates the order during checkout (#update, address selection, shipping selection, payment selection):

ruby
Spree::StockReservations::Extend.call(order: @order)

Release on order completion

In Spree::Api::V3::Storefront::CheckoutController#complete (or after the existing completion service), after the standard count_on_hand decrement runs:

ruby
Spree::StockReservations::Release.call(order: @order)

Release on cart empty / order cancel

In Spree::Api::V3::Storefront::CartController#empty and the order cancel endpoint:

ruby
Spree::StockReservations::Release.call(order: @order)

Re-reserve on cart line item changes

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

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

Integration points (6.0)

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.

Store API

New read-only field on variants:

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

Configuration

ruby
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

Database schema

ruby
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

Migration Path

This is purely additive — no existing tables or models are modified (beyond Quantifier logic).

Spree 5.5

Phase 1: Model + migration + services

  • Create StockReservation model and table
  • Create Reserve, Release, Extend, ExpireJob services
  • Update Quantifier to subtract reservations (5.5 formula: count_on_hand - reserved_quantity)
  • Add configuration options
  • Add dependent: :destroy from LineItem and Order to clean up reservations

Phase 2: Store API integration

  • Wire Reserve into Store API checkout next/transition endpoint
  • Wire Extend into Store API checkout update endpoints
  • Wire Release into Store API order complete + cart empty + order cancel endpoints
  • Re-Reserve on cart item create/update/destroy when order is in checkout
  • Add ExpireJob to the standard Spree job schedule (sidekiq-cron / solid_queue)

Phase 3: API surface

  • Add reserved_quantity / available_quantity to Store variant serializer
  • Add Spree::Api::V3::Admin::StockReservationsController (read-only index/show)
  • Admin SPA consumes the Admin API; legacy Rails admin gets nothing in 5.5

Spree 6.0

  • Move integration hooks from Store API controllers into Spree::Carts::Complete, Spree::Carts::Update, Spree::Carts::Empty
  • Update Quantifier formula to include allocated_count (depends on Typed Stock Movements)
  • Cart-level reservation option: if reserve_stock_on: :cart, wire Reserve into Cart::AddItem and Cart::SetQuantity

Constraints on Current Work

  • Don't modify StockItem.count_on_hand for reservations. Reservations are a separate layer. count_on_hand only changes on order completion, stock adjustments, and shipment.
  • Use Quantifier#total_on_hand for availability checks. Don't query count_on_hand directly — the quantifier will handle the reservation subtraction.
  • New checkout services should call StockReservations::Extend after any successful step to keep reservations alive.

Open Questions

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

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

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

References

  • Current stock system: spree/core/app/models/spree/stock_item.rb, spree/core/app/models/spree/stock/quantifier.rb
  • Current stock check at completion: spree/core/app/models/spree/order.rb:600 (ensure_line_items_are_in_stock)
  • Industry pattern: time-limited reservations with TTL expiry
  • Related plan: 6.0-cart-order-split.md (reservation created when Cart converts to Order)