Back to Spree

Pre-orders

docs/plans/5.6-preorders.md

5.6.012.4 KB
Original Source

Pre-orders

Status: Implemented — PR #14249 (pending merge) Target: Spree 5.6 Depends on: none (composes with stock + publishing, but couples to neither) Author: Damian (with Claude) Last updated: 2026-07-01

Summary

Let a merchant sell a variant before it's in stock, with a clear "ships by" promise to the customer. Pre-order is modeled the way an operator thinks about it: an out-of-stock selling behavior (alongside backorder), not a publishing/launch concept.

A variant gets a preorderable boolean, a preorder_ships_at datetime, and a backorder_limit integer. When flagged, the variant is labeled a pre-order (and the storefront shows a "Pre-order — ships by <date>" badge). The cap is backorder_limit — a universal oversell ceiling: empty means unlimited, a value bounds how many units may be sold beyond available stock. Because it caps any oversell, it also limits plain backorders (previously unbounded). Pre-order itself adds only the label and the ship-by promise — the tally reuses the existing backorder mechanic (count_on_hand runs negative down to -backorder_limit), no new allocation model.

It also composes with channel publishing: a product scheduled to publish later (future published_at) that has a pre-order variant is surfaced (and buyable) on the Store API before its publish date — the "coming soon, open for pre-order" case. The publish date is the launch (when it becomes a normal live product); the variant's preorder_ships_at is when it actually ships.

Operator mental model (the design driver)

A merchant answers the customer's question — "Can I buy it, and when will I get it?" — with two independent switches:

  1. Visibility (publishing): is it in my catalog? Draft / Active / Scheduled. Unchanged, and unrelated to pre-order.
  2. Out-of-stock behavior (inventory): Don't sell / Backorder / Pre-order (+ ships-by date) — each with an optional limit (backorder_limit) on how far you'll oversell.

Pre-order is just backorder with a delivery promise. It works for every "buy now, ships later" case the merchant lumps together — new launch, restock of a live product, made-to-order — because none of them depend on a future publish date. (An earlier draft gated pre-order on a future published_at; that excluded the common live-restock case and conflated "hide until launch" with "take pre-orders." Decoupling fixes both.) Since pre-order is backorder-with-a-promise, the two share one oversell cap: backorder_limit.

Key Decisions (do not deviate without discussion)

  • preorderable + preorder_ships_at + backorder_limit live on Spree::Variant — variant is the SKU/buy boundary, matching Saleor/Medusa/Shopify. backorder_limit sits on the variant (not the per-location StockItem) matching Vendure's variant-level outOfStockThreshold; it also anticipates 6.0 moving backorderable itself onto Spree::Variant, so the two end up colocated.
  • preorder? is publishing-independent. It reads only the variant flag + ship date, never published_at/available_on. So pre-order works on live products (restocks) exactly as on scheduled ones.
  • The cap is backorder_limit, and it's universal. One integer bounds overselling for both backorder and pre-order: empty = unlimited, a value caps how many units may be sold beyond available stock (count_on_hand runs negative down to -backorder_limit, combined with any on-hand stock). This replaces the old "cap is stock / backorder is unbounded" model.
  • Pre-order (or backorder) is a purchasability source. purchasable? is now in_stock? || oversellable_now?, where oversellable_now? is true when the variant is backorderable or a pre-order and still within its (possibly unlimited) backorder_limit. Enabling pre-order with an empty limit makes the variant buyable (unlimited), just like enabling backorder — you set a number only to cap it.
  • The tally reuses the backorder mechanic — no new allocation model. Oversold units are backordered inventory units; count_on_hand running negative is the running count, and process_backorders fills them on restock. Quantifier#can_supply? enforces the -backorder_limit floor; StockItem lifts its non-negative guard, StockLocation#fill_status backorders the shortfall, and StockReservations::Reserve skips reservation for backorderable or pre-order variants — pre-order rides every backorder seam.
  • Pre-order composes with publishing in one direction. A scheduled (future-published) product with a pre-order variant is pulled forward: visible on the Store API and buyable (the cart's publish gate is lifted for a pre-order variant). It does not re-couple the concept — only Quantifier#can_supply?, the storefront available scope, and the search index/filter consult preorder? to admit the coming-soon case.
  • The ship date is variant-level and channel-agnostic — it reflects when stock arrives, a supply fact, not a per-channel marketing date.
  • Pre-order is a label + a promise. preorder? is presentation: flagged, and either open-ended or before preorder_ships_at. Once the date passes, the variant is a normal product again.

Design Details

Data model

ruby
add_column :spree_variants, :preorderable, :boolean        # no default; nil ⇒ false
add_column :spree_variants, :preorder_ships_at, :datetime  # the "ships by" promise; nil ⇒ open-ended
add_column :spree_variants, :backorder_limit, :integer     # oversell cap; nil ⇒ unlimited

Predicates

ruby
# Spree::Variant — flagged, and not past its ships-by date
def preorder?
  !discontinued? && preorderable? && product.active? && !product.deleted? &&
    (preorder_ships_at.nil? || preorder_ships_at > Time.current)
end

def purchasable?
  in_stock? || oversellable_now?
end
# Can the variant be bought by overselling (backorder OR pre-order)?
# Unlimited when backorder_limit is nil; otherwise only while allowance remains.
def oversellable_now?
  return false unless backorderable? || preorder?
  backorder_limit.nil? || can_supply?(1)
end

# Spree::Product
def preorder?
  default_variant.preorder? || variants.any?(&:preorder?)
end
def preorder_ships_at   # latest ships-by across pre-order variants, for one product-level promise
  [master, *variants].select(&:preorder?).filter_map(&:preorder_ships_at).max
end

The oversell cap (backorder_limit)

Quantifier#can_supply? is the one place the ceiling is enforced:

ruby
def can_supply?(required = 1)
  return false unless variant.available? || variant.preorder?
  return true unless variant.should_track_inventory?

  oversellable = backorderable? || variant.preorder?
  limit = variant.backorder_limit
  return true if oversellable && limit.nil?            # empty ⇒ unlimited

  supplyable = if oversellable
                 [available_stock - reserved_quantity + limit, 0].max  # one clamp: signed on-hand + limit
               else
                 total_on_hand                                          # on-hand only (clamped ≥ 0)
               end
  supplyable >= required
end

The capped-oversell capacity is a single clamp — [signed_on_hand + limit, 0].max — so units already oversold (negative count_on_hand) eat into the limit. StockItem#verify_count_on_hand? lifts its non-negative guard when the variant is backorderable or a pre-order, so oversold units can persist; process_backorders fills them on restock (unchanged).

"Coming soon" — pre-ordering a scheduled product

For a live product, pre-order needs nothing beyond the label (it's already visible and its stock is buyable). For a product scheduled to publish later, three seams admit it before its publish date:

  • Quantifier#can_supply?return false unless variant.available? || variant.preorder?, then the oversell logic above. Pre-order lifts the publish gate; backorder_limit still caps. (AvailabilityValidator delegates to can_supply?, so a sold-out pre-order is a quantity error, not "inactive".)
  • Product.available(..., include_preorderable: true) — also admits future-published products that have an active pre-order variant (an EXISTS against spree_variants for preorderable AND not-past-ships-by). The Store API products controller passes include_preorderable: true, so the listing and the show endpoint surface coming-soon pre-orders.
  • Search — the index carries a preorder flag (product.preorder?); the Meilisearch system_filter_conditions admits future-published docs when preorder = true. The DB provider searches within the pre-order-inclusive scope.

Fulfillment

Oversold units (backorder or pre-order) flow through existing backorder fulfillment: StockLocation#fill_status turns the shortfall into backordered inventory units (for backorderable or pre-order variants), count_on_hand runs negative at shipment finalize, and process_backorders fills the backordered units when stock is added. StockReservations::Reserve exempts pre-orders the same way it exempts backorderable stock (the cap lives in can_supply?, not in reservations). Nothing else special on the cart or checkout path; payment is captured as usual.

API surface (read/write symmetry)

  • Store ProductSerializer: preorder + preorder_ships_at (latest across pre-order variants). Store VariantSerializer: preorder + preorder_ships_at (only while a pre-order). LineItemSerializer: preorder + preorder_ships_at. The store surface does not expose the raw backorder_limit (it's back-office); customer-facing buyability is already reflected by purchasable/in_stock.
  • Admin VariantSerializer: raw preorderable + preorder_ships_at + backorder_limit. Admin API (products#permitted_paramsvariants:) and Spree::PermittedAttributes.variant_attributes accept all three.

Admin UIs

  • Legacy Rails admin — "Available for pre-order" checkbox + "Ships by" date + "Backorder limit" number in the variant inventory form, beside "Track inventory".
  • React dashboard — a Switch + a StoreDatePicker ("Ships by", shown when the toggle is on) + a "Backorder limit" number input (always shown; placeholder "Unlimited") in the VariantEditSheet "Availability" section.

Migration Path

  1. Ship the three additive, nullable columns. No backfill.
  2. Ship predicates + serializers + admin write paths + both UIs (inert until a variant is flagged; backorder_limit inert until an oversell path is enabled).
  3. No data transformation / rake task.

Constraints on Current Work

  • Variant#preorder? relaxes only the publish date — it still gates on product active?/not-deleted and variant not-discontinued, so an archived/draft/discontinued product never becomes supplyable via pre-order. It must not read available_on/published_at directly. The publishing interaction lives at the edges (can_supply?, the available scope, the search filter), which consult preorder? to admit the coming-soon case.
  • backorder_limit is the single oversell ceiling for both backorder and pre-order. Enforce it only in Quantifier#can_supply? (and thus AvailabilityValidator); purchasable?/oversellable_now? reuse can_supply? rather than re-deriving the cap. nil must always mean unlimited.

Open Questions

  • A future "Don't sell / Backorder / Pre-order" single selector in the inventory UI. Once 6.0 moves backorderable from StockItem onto Spree::Variant, backorderable + backorder_limit + preorderable all live on the variant and a unified control becomes natural.
  • Deposits / partial payment for pre-orders (cf. Shopify SellingPlans) — out of scope; pay in full at checkout.

References

  • Prior art: Saleor (ProductVariant.preorder with endDate on the variant + globalThreshold/per-channel preorderThreshold), Shopify (oversell inventoryPolicy DENY/CONTINUE — binary/unbounded — plus SellingPlan PRE_ORDER for the promise), Medusa (allow_backorder boolean — unbounded — + a variant-linked available_date), Vendure (variant-level outOfStockThreshold, negative = oversell floor). All keep the pre-order/ship date on the variant (or selling plan), never tied to channel publishing — which this design matches.
  • The oversell cap follows Saleor/Vendure (both cap; Shopify/Medusa/legacy-Spree are binary/unbounded). Spree's backorder_limit is Vendure's variant-level threshold expressed as a positive "units beyond stock" number, and — matching Saleor — it makes the pre-order flag itself a purchasability source (bounded by the limit).