Back to Spree

Stock Reservations

docs/plans/6.0-stock-reservations.md

5.4.214.2 KB
Original Source

Stock Reservations

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

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. When Typed Stock Movements ships, allocated_count is included; before that, it's zero.
  • 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 storestore.preferred_stock_reservation_ttl (default: 10 minutes). Merchants can tune this based on their checkout complexity.
  • 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.
  • Backorderable items skip reservation — if StockItem.backorderable?, no reservation needed (unlimited supply).
  • 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
    update!(expires_at: Time.current + duration)
  end

  def release!
    destroy!
  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)
  # 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

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

Checkout service (reserve on entering checkout)

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

Checkout activity (extend on each step)

ruby
# In Spree::Carts::Update, after any successful step update:
Spree::StockReservations::Extend.call(order: order)

Order completion (release reservation, decrement stock)

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

Cart emptied / order canceled

ruby
# In Spree::Carts::Empty and order cancellation:
Spree::StockReservations::Release.call(order: order)

Line item quantity changed

ruby
# In Spree::Cart::SetQuantity, after quantity update:
# Re-run reservation to adjust quantities
Spree::StockReservations::Reserve.call(order: order)

Line item removed

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

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

Phase 1: Model + migration + services

  • Create StockReservation model and table
  • Create Reserve, Release, Extend, ExpireJob services
  • Update Quantifier to subtract reservations
  • Add configuration options

Phase 2: Checkout integration

  • Wire Reserve into checkout entry
  • Wire Extend into checkout step updates
  • Wire Release into order completion and cancellation
  • Add ExpireJob to recurring job schedule

Phase 3: API + admin visibility

  • Add reserved_quantity / available_quantity to variant serializer
  • Add admin API for stock reservations
  • Admin dashboard: show reservations on stock management page

Phase 4: Cart-level reservation (optional)

  • If reserve_stock_on: :cart, wire Reserve into Cart::AddItem and Cart::SetQuantity
  • More aggressive but prevents add-to-cart for truly out-of-stock items

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)