Skip to content

Diagnostic Checkout & Stock Reservation

Duplicate stock checks still allow double selling

Two customers check out the last unit of the same variant within a second of each other. Both checkouts pass stock validation. Both orders get created. Only one of them should have. Saleor checks available stock again at every checkout step instead of holding one locked reservation for the whole flow, so two non-atomic reads can each look safe on their own while together they oversell the SKU. Here is why it happens and a script that finds the oversold lines after the fact.

Python and Node.js Saleor GraphQL API Safe by default (dry run, report only)
A worker on a ladder in a warehouse
Photo by Kseniia Ilinykh on Unsplash
The short answer

Saleor validates requested quantity against available stock separately at checkoutCreate, checkoutLinesAdd, checkoutShippingAddressUpdate, and checkoutComplete, each time reading the current Stock.quantity minus reservations at that instant rather than holding one locked reservation across the whole flow. Between two of those reads, or across two concurrent checkouts for the same variant, both requests can see enough stock even though their combined demand exceeds what is actually on hand. Only checkoutComplete and stock allocation actually decrement stock, and by then both orders may already exist. Run a Python or Node.js script that cross checks order line allocations against warehouse stock, flags any SKU and warehouse pair where recomputed demand exceeds on hand stock, and leaves the fix to a human. Full code, tests, and a dry run guard are below.

The problem in plain words

A Saleor checkout is not one atomic transaction from cart to order. It is a sequence of separate GraphQL calls, and each one that touches quantity asks the same question on its own: is there enough stock right now? checkoutCreate asks it. checkoutLinesAdd asks it again. checkoutShippingAddressUpdate can trigger a recheck too. checkoutComplete asks it one last time before it finally allocates stock and creates the order.

Each of those checks is correct in isolation. The trouble is what happens between them. Saleor's optional stock reservation window only accounts for reservations made through that same reservation mechanism, and some of these validation paths recompute availability from scratch rather than reading a single held reservation. So if a second checkout for the same variant runs its own check in the gap between two of the first checkout's steps, it can see the same "available" units the first checkout is about to claim. Both look fine. Both proceed. Nothing is actually locked until checkoutComplete allocates stock, and by that point it can be too late for one of them.

Checkout A checkoutCreate sees 1 unit free checkoutLinesAdd passes again checkoutComplete order A created Checkout B (concurrent) checkoutCreate also sees 1 unit free checkoutLinesAdd passes too checkoutComplete order B created Only 1 unit existed both orders claim it
Each checkout step checks stock on its own. Two concurrent checkouts can both pass every check and both create an order, because nothing held a single locked reservation across the whole flow.

Why it happens

This is not a random glitch, it is a structural gap between how validation works and how allocation works. A few concrete ways it shows up:

This has been documented since 2016 in saleor/saleor#543, and the same structural quirk shows up again in #8257, where checkoutShippingAddressUpdate reports insufficient stock at what should structurally be "twice available stock," a symptom of the same non-atomic, repeated validation.

The key insight

You cannot safely undo an oversell by shrinking allocations or cancelling a paid order automatically, because you might cancel the customer who was legitimately first. The safe move is detection, not correction. Cross check what Saleor's orders say was allocated against what the warehouse actually has on hand, and hand every mismatch to a human with the order ids attached.

The fix, as a flow

We do not change checkout behavior or touch Saleor's validation. We add a script that runs after the fact, pulls every recent order with its lines and allocations, pulls every warehouse's stock, and for each SKU and warehouse pair compares the total allocated quantity against the physical on hand quantity. Anything oversold is reported with the offending order ids so a human can decide what to do.

Query orders lines and allocations Query warehouses stocks per SKU Recompute allocated sum per SKU + warehouse Allocated > on hand? yes no, fine Report only no auto correction
The script only ever produces a report row per oversold SKU and warehouse. A human decides whether to recount stock, cancel the newer order, or trigger a backorder or refund.

Build it step by step

1

Get a Saleor auth token

Create a Saleor app token, or sign in with tokenCreate to get a staff JWT, with at least MANAGE_PRODUCTS and MANAGE_ORDERS scope so it can read stocks and orders. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://your-store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export ORDER_WINDOW_DAYS="7"
export DRY_RUN="true"   # start safe, this script only ever reports
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://your-store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export ORDER_WINDOW_DAYS="7"
export DRY_RUN="true"   // start safe, this script only ever reports
2

Talk to the Saleor GraphQL API

Everything is one endpoint, POST with your token in the Authorization: Bearer header. A small helper sends a query and returns the data, and raises if Saleor reports an error. We reuse this helper for both queries below.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List recent orders with their allocations

Ask for orders created in the detection window, with every line's SKU, quantity, and allocations per warehouse. This is what tells you what Saleor believes it already sold and where.

step3.py
ORDERS_QUERY = """
query($cursor: String, $createdGte: DateTime!) {
  orders(first: 100, after: $cursor, filter: { created: { gte: $createdGte } }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        lines {
          id
          productVariant { id sku }
          quantity
          quantityFulfilled
          allocations { id quantity warehouse { id name } }
        }
      }
    }
  }
}"""

def recent_orders(created_gte_iso):
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor, "createdGte": created_gte_iso})["orders"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const ORDERS_QUERY = `
query($cursor: String, $createdGte: DateTime!) {
  orders(first: 100, after: $cursor, filter: { created: { gte: $createdGte } }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        lines {
          id
          productVariant { id sku }
          quantity
          quantityFulfilled
          allocations { id quantity warehouse { id name } }
        }
      }
    }
  }
}`;

async function* recentOrders(createdGteIso) {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor, createdGte: createdGteIso })).orders;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

List warehouse stock per SKU

Ask every warehouse for its stocks, with the on hand quantity and Saleor's own running total of allocated quantity. We compare that running total against what we recompute ourselves in the next step.

step4.py
WAREHOUSES_QUERY = """
query($cursor: String) {
  warehouses(first: 100, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        name
        stocks {
          id
          quantity
          quantityAllocated
          productVariant { id sku }
        }
      }
    }
  }
}"""

def all_warehouse_stocks():
    cursor = None
    while True:
        data = gql(WAREHOUSES_QUERY, {"cursor": cursor})["warehouses"]
        for edge in data["edges"]:
            warehouse = edge["node"]
            for stock in warehouse["stocks"]:
                yield warehouse["id"], warehouse["name"], stock
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step4.js
const WAREHOUSES_QUERY = `
query($cursor: String) {
  warehouses(first: 100, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        name
        stocks {
          id
          quantity
          quantityAllocated
          productVariant { id sku }
        }
      }
    }
  }
}`;

async function* allWarehouseStocks() {
  let cursor = null;
  while (true) {
    const data = (await gql(WAREHOUSES_QUERY, { cursor })).warehouses;
    for (const edge of data.edges) {
      const warehouse = edge.node;
      for (const stock of warehouse.stocks) {
        yield [warehouse.id, warehouse.name, stock];
      }
    }
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
5

Decide, with one pure function

Keep the decision in its own function that takes the plain order and stock data and returns the oversold rows. A pure function like this is easy to read and easy to test, which we do later. For every SKU and warehouse pair, sum the allocated quantity from non-cancelled orders and compare it against the physical on hand quantity. Flag it when the recomputed demand exceeds on hand stock, or when Saleor's own reported allocated total disagrees with what we independently summed, since that mismatch is itself a symptom of the same bug.

decide.py
CANCELLED_STATUSES = {"CANCELED", "CANCELLED"}

def find_oversold_lines(orders, stocks):
    """
    Pure decision logic, no I/O.
    orders: [{"order_id": str, "status": str, "lines": [{"sku": str, "warehouse_id": str, "allocated_qty": int}]}]
    stocks: [{"sku": str, "warehouse_id": str, "on_hand_qty": int, "reported_allocated_qty": int}]
    Returns one dict per (sku, warehouse_id) pair where recomputed demand exceeds physical stock.
    """
    recomputed = {}
    offenders = {}
    for order in orders:
        if (order.get("status") or "").upper() in CANCELLED_STATUSES:
            continue
        for line in order.get("lines", []):
            key = (line["sku"], line["warehouse_id"])
            recomputed[key] = recomputed.get(key, 0) + line["allocated_qty"]
            offenders.setdefault(key, set()).add(order["order_id"])

    results = []
    for stock in stocks:
        key = (stock["sku"], stock["warehouse_id"])
        recomputed_qty = recomputed.get(key, 0)
        on_hand = stock["on_hand_qty"]
        reported = stock["reported_allocated_qty"]
        oversold_by_stock = recomputed_qty - on_hand
        mismatched = recomputed_qty != reported
        if oversold_by_stock > 0 or mismatched:
            results.append({
                "sku": stock["sku"],
                "warehouse_id": stock["warehouse_id"],
                "on_hand_qty": on_hand,
                "recomputed_allocated_qty": recomputed_qty,
                "reported_allocated_qty": reported,
                "oversold_by": max(oversold_by_stock, 0),
                "offending_order_ids": sorted(offenders.get(key, set())),
            })
    return results
decide.js
const CANCELLED_STATUSES = new Set(["CANCELED", "CANCELLED"]);

export function findOversoldLines(orders, stocks) {
  const recomputed = new Map();
  const offenders = new Map();

  for (const order of orders) {
    if (CANCELLED_STATUSES.has((order.status || "").toUpperCase())) continue;
    for (const line of order.lines) {
      const key = `${line.sku}::${line.warehouse_id}`;
      recomputed.set(key, (recomputed.get(key) || 0) + line.allocated_qty);
      if (!offenders.has(key)) offenders.set(key, new Set());
      offenders.get(key).add(order.order_id);
    }
  }

  const results = [];
  for (const stock of stocks) {
    const key = `${stock.sku}::${stock.warehouse_id}`;
    const recomputedQty = recomputed.get(key) || 0;
    const onHand = stock.on_hand_qty;
    const reported = stock.reported_allocated_qty;
    const oversoldByStock = recomputedQty - onHand;
    const mismatched = recomputedQty !== reported;
    if (oversoldByStock > 0 || mismatched) {
      results.push({
        sku: stock.sku,
        warehouse_id: stock.warehouse_id,
        on_hand_qty: onHand,
        recomputed_allocated_qty: recomputedQty,
        reported_allocated_qty: reported,
        oversold_by: Math.max(oversoldByStock, 0),
        offending_order_ids: [...(offenders.get(key) || [])].sort(),
      });
    }
  }
  return results;
}
6

Wire it together, report only, dry run guard

The loop pulls orders and stocks, reshapes them into the plain shapes the pure function expects, and prints one line per oversold SKU and warehouse with the offending order ids. There is no write path in this script at all, not even behind the dry run flag, because unattended repair here is unsafe. DRY_RUN is kept for consistency with the other fixes and to make the intent explicit in logs, but the script never calls a cancel or refund mutation. If a human confirms an oversell after reading the report, the sanctioned repairs are a manual stockBulkUpdate after a physical recount, or an orderFulfillmentCancel or orderCancel plus an orderNoteAdd on the newer order, done by hand.

Run it safe

This script only ever emits a report row per oversold SKU. It never calls stockBulkUpdate, orderCancel, or orderFulfillmentCancel itself. Those are human decisions, logged with the order ids the report gives you.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and is safe to run again and again because it never writes anything, it only reports.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
find_oversold.py
"""Flag Saleor SKUs that were double sold because duplicate, non-atomic stock
checks at checkoutCreate, checkoutLinesAdd, checkoutShippingAddressUpdate, and
checkoutComplete let two concurrent checkouts both pass.

Report only. Never edits stock or cancels an order. Safe to run again and again.
"""
import os
import datetime
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_oversold")

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
ORDER_WINDOW_DAYS = float(os.environ.get("ORDER_WINDOW_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CANCELLED_STATUSES = {"CANCELED", "CANCELLED"}

ORDERS_QUERY = """
query($cursor: String, $createdGte: DateTime!) {
  orders(first: 100, after: $cursor, filter: { created: { gte: $createdGte } }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        lines {
          id
          productVariant { id sku }
          quantity
          quantityFulfilled
          allocations { id quantity warehouse { id name } }
        }
      }
    }
  }
}"""

WAREHOUSES_QUERY = """
query($cursor: String) {
  warehouses(first: 100, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        name
        stocks {
          id
          quantity
          quantityAllocated
          productVariant { id sku }
        }
      }
    }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def find_oversold_lines(orders, stocks):
    """
    Pure decision logic, no I/O.
    orders: [{"order_id": str, "status": str, "lines": [{"sku": str, "warehouse_id": str, "allocated_qty": int}]}]
    stocks: [{"sku": str, "warehouse_id": str, "on_hand_qty": int, "reported_allocated_qty": int}]
    Returns one dict per (sku, warehouse_id) pair where recomputed demand exceeds physical stock:
    [{"sku": str, "warehouse_id": str, "on_hand_qty": int, "recomputed_allocated_qty": int,
      "reported_allocated_qty": int, "oversold_by": int, "offending_order_ids": [str]}]
    """
    recomputed = {}
    offenders = {}
    for order in orders:
        if (order.get("status") or "").upper() in CANCELLED_STATUSES:
            continue
        for line in order.get("lines", []):
            key = (line["sku"], line["warehouse_id"])
            recomputed[key] = recomputed.get(key, 0) + line["allocated_qty"]
            offenders.setdefault(key, set()).add(order["order_id"])

    results = []
    for stock in stocks:
        key = (stock["sku"], stock["warehouse_id"])
        recomputed_qty = recomputed.get(key, 0)
        on_hand = stock["on_hand_qty"]
        reported = stock["reported_allocated_qty"]
        oversold_by_stock = recomputed_qty - on_hand
        mismatched = recomputed_qty != reported
        if oversold_by_stock > 0 or mismatched:
            results.append({
                "sku": stock["sku"],
                "warehouse_id": stock["warehouse_id"],
                "on_hand_qty": on_hand,
                "recomputed_allocated_qty": recomputed_qty,
                "reported_allocated_qty": reported,
                "oversold_by": max(oversold_by_stock, 0),
                "offending_order_ids": sorted(offenders.get(key, set())),
            })
    return results


def recent_orders(created_gte_iso):
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor, "createdGte": created_gte_iso})["orders"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def all_warehouse_stocks():
    cursor = None
    while True:
        data = gql(WAREHOUSES_QUERY, {"cursor": cursor})["warehouses"]
        for edge in data["edges"]:
            warehouse = edge["node"]
            for stock in warehouse["stocks"]:
                yield warehouse["id"], warehouse["name"], stock
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def _flatten_orders():
    created_gte = (
        datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=ORDER_WINDOW_DAYS)
    ).strftime("%Y-%m-%dT%H:%M:%S%z")
    flat = []
    for order in recent_orders(created_gte):
        lines = []
        for line in order["lines"]:
            sku = (line.get("productVariant") or {}).get("sku")
            if not sku:
                continue
            for allocation in line.get("allocations") or []:
                lines.append({
                    "sku": sku,
                    "warehouse_id": allocation["warehouse"]["id"],
                    "allocated_qty": allocation["quantity"],
                })
        flat.append({"order_id": order["id"], "status": order["status"], "lines": lines})
    return flat


def _flatten_stocks():
    flat = []
    for warehouse_id, _name, stock in all_warehouse_stocks():
        sku = (stock.get("productVariant") or {}).get("sku")
        if not sku:
            continue
        flat.append({
            "sku": sku,
            "warehouse_id": warehouse_id,
            "on_hand_qty": stock["quantity"],
            "reported_allocated_qty": stock["quantityAllocated"],
        })
    return flat


def run():
    mode = "dry run" if DRY_RUN else "live"
    log.info("Scanning orders from the last %.0f day(s) (%s, report only)", ORDER_WINDOW_DAYS, mode)
    orders = _flatten_orders()
    stocks = _flatten_stocks()
    oversold = find_oversold_lines(orders, stocks)
    for row in oversold:
        log.warning(
            "OVERSOLD sku=%s warehouse=%s on_hand=%d recomputed_allocated=%d reported_allocated=%d oversold_by=%d orders=%s",
            row["sku"], row["warehouse_id"], row["on_hand_qty"], row["recomputed_allocated_qty"],
            row["reported_allocated_qty"], row["oversold_by"], ",".join(row["offending_order_ids"]),
        )
    log.info("Done. %d SKU/warehouse pair(s) flagged. No stock or orders were changed.", len(oversold))
    return oversold


if __name__ == "__main__":
    run()
find-oversold.js
/**
 * Flag Saleor SKUs that were double sold because duplicate, non-atomic stock
 * checks at checkoutCreate, checkoutLinesAdd, checkoutShippingAddressUpdate, and
 * checkoutComplete let two concurrent checkouts both pass.
 *
 * Report only. Never edits stock or cancels an order. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/saleor/duplicate-stock-checks-allow-oversell/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://demo.saleor.io/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "token_dummy";
const ORDER_WINDOW_DAYS = Number(process.env.ORDER_WINDOW_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CANCELLED_STATUSES = new Set(["CANCELED", "CANCELLED"]);

export function findOversoldLines(orders, stocks) {
  const recomputed = new Map();
  const offenders = new Map();

  for (const order of orders) {
    if (CANCELLED_STATUSES.has((order.status || "").toUpperCase())) continue;
    for (const line of order.lines) {
      const key = `${line.sku}::${line.warehouse_id}`;
      recomputed.set(key, (recomputed.get(key) || 0) + line.allocated_qty);
      if (!offenders.has(key)) offenders.set(key, new Set());
      offenders.get(key).add(order.order_id);
    }
  }

  const results = [];
  for (const stock of stocks) {
    const key = `${stock.sku}::${stock.warehouse_id}`;
    const recomputedQty = recomputed.get(key) || 0;
    const onHand = stock.on_hand_qty;
    const reported = stock.reported_allocated_qty;
    const oversoldByStock = recomputedQty - onHand;
    const mismatched = recomputedQty !== reported;
    if (oversoldByStock > 0 || mismatched) {
      results.push({
        sku: stock.sku,
        warehouse_id: stock.warehouse_id,
        on_hand_qty: onHand,
        recomputed_allocated_qty: recomputedQty,
        reported_allocated_qty: reported,
        oversold_by: Math.max(oversoldByStock, 0),
        offending_order_ids: [...(offenders.get(key) || [])].sort(),
      });
    }
  }
  return results;
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const ORDERS_QUERY = `
query($cursor: String, $createdGte: DateTime!) {
  orders(first: 100, after: $cursor, filter: { created: { gte: $createdGte } }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        lines {
          id
          productVariant { id sku }
          quantity
          quantityFulfilled
          allocations { id quantity warehouse { id name } }
        }
      }
    }
  }
}`;

const WAREHOUSES_QUERY = `
query($cursor: String) {
  warehouses(first: 100, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        name
        stocks {
          id
          quantity
          quantityAllocated
          productVariant { id sku }
        }
      }
    }
  }
}`;

async function* recentOrders(createdGteIso) {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor, createdGte: createdGteIso })).orders;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function* allWarehouseStocks() {
  let cursor = null;
  while (true) {
    const data = (await gql(WAREHOUSES_QUERY, { cursor })).warehouses;
    for (const edge of data.edges) {
      const warehouse = edge.node;
      for (const stock of warehouse.stocks) {
        yield [warehouse.id, warehouse.name, stock];
      }
    }
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function flattenOrders() {
  const createdGte = new Date(Date.now() - ORDER_WINDOW_DAYS * 86400 * 1000).toISOString();
  const flat = [];
  for await (const order of recentOrders(createdGte)) {
    const lines = [];
    for (const line of order.lines) {
      const sku = line.productVariant?.sku;
      if (!sku) continue;
      for (const allocation of line.allocations || []) {
        lines.push({ sku, warehouse_id: allocation.warehouse.id, allocated_qty: allocation.quantity });
      }
    }
    flat.push({ order_id: order.id, status: order.status, lines });
  }
  return flat;
}

async function flattenStocks() {
  const flat = [];
  for await (const [warehouseId, , stock] of allWarehouseStocks()) {
    const sku = stock.productVariant?.sku;
    if (!sku) continue;
    flat.push({
      sku,
      warehouse_id: warehouseId,
      on_hand_qty: stock.quantity,
      reported_allocated_qty: stock.quantityAllocated,
    });
  }
  return flat;
}

export async function run() {
  const mode = DRY_RUN ? "dry run" : "live";
  console.log(`Scanning orders from the last ${ORDER_WINDOW_DAYS} day(s) (${mode}, report only)`);
  const orders = await flattenOrders();
  const stocks = await flattenStocks();
  const oversold = findOversoldLines(orders, stocks);
  for (const row of oversold) {
    console.warn(
      `OVERSOLD sku=${row.sku} warehouse=${row.warehouse_id} on_hand=${row.on_hand_qty} ` +
      `recomputed_allocated=${row.recomputed_allocated_qty} reported_allocated=${row.reported_allocated_qty} ` +
      `oversold_by=${row.oversold_by} orders=${row.offending_order_ids.join(",")}`
    );
  }
  console.log(`Done. ${oversold.length} SKU/warehouse pair(s) flagged. No stock or orders were changed.`);
  return oversold;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which order ids land in a human's report. Because we kept find_oversold_lines pure, the test needs no network and no Saleor store. It just feeds in plain dicts and objects and checks the answer.

test_duplicate_checks.py
from find_oversold import find_oversold_lines


def order(order_id, sku="SKU-1", warehouse_id="wh-1", allocated_qty=1, status="UNFULFILLED"):
    return {
        "order_id": order_id,
        "status": status,
        "lines": [{"sku": sku, "warehouse_id": warehouse_id, "allocated_qty": allocated_qty}],
    }


def stock(sku="SKU-1", warehouse_id="wh-1", on_hand_qty=1, reported_allocated_qty=1):
    return {
        "sku": sku,
        "warehouse_id": warehouse_id,
        "on_hand_qty": on_hand_qty,
        "reported_allocated_qty": reported_allocated_qty,
    }


def test_flags_two_orders_that_claim_the_same_last_unit():
    orders = [order("order-A"), order("order-B")]
    stocks = [stock(on_hand_qty=1, reported_allocated_qty=1)]
    result = find_oversold_lines(orders, stocks)
    assert len(result) == 1
    row = result[0]
    assert row["sku"] == "SKU-1"
    assert row["warehouse_id"] == "wh-1"
    assert row["recomputed_allocated_qty"] == 2
    assert row["oversold_by"] == 1
    assert row["offending_order_ids"] == ["order-A", "order-B"]


def test_no_flag_when_allocated_matches_stock():
    orders = [order("order-A")]
    stocks = [stock(on_hand_qty=1, reported_allocated_qty=1)]
    assert find_oversold_lines(orders, stocks) == []


def test_cancelled_orders_are_excluded_from_recomputed_demand():
    orders = [order("order-A", status="CANCELLED"), order("order-B")]
    stocks = [stock(on_hand_qty=1, reported_allocated_qty=1)]
    assert find_oversold_lines(orders, stocks) == []


def test_flags_when_reported_allocated_disagrees_with_recomputed():
    orders = [order("order-A", allocated_qty=1)]
    stocks = [stock(on_hand_qty=5, reported_allocated_qty=3)]
    result = find_oversold_lines(orders, stocks)
    assert len(result) == 1
    assert result[0]["reported_allocated_qty"] == 3
    assert result[0]["recomputed_allocated_qty"] == 1
    assert result[0]["oversold_by"] == 0


def test_separate_warehouses_are_not_confused():
    orders = [order("order-A", warehouse_id="wh-1"), order("order-B", warehouse_id="wh-2")]
    stocks = [
        stock(warehouse_id="wh-1", on_hand_qty=1, reported_allocated_qty=1),
        stock(warehouse_id="wh-2", on_hand_qty=1, reported_allocated_qty=1),
    ]
    assert find_oversold_lines(orders, stocks) == []
find-oversold.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOversoldLines } from "./find-oversold.js";

const order = (orderId, over = {}) => ({
  order_id: orderId,
  status: "UNFULFILLED",
  lines: [{ sku: "SKU-1", warehouse_id: "wh-1", allocated_qty: 1 }],
  ...over,
});

const stock = (over = {}) => ({
  sku: "SKU-1",
  warehouse_id: "wh-1",
  on_hand_qty: 1,
  reported_allocated_qty: 1,
  ...over,
});

test("flags two orders that claim the same last unit", () => {
  const orders = [order("order-A"), order("order-B")];
  const stocks = [stock()];
  const result = findOversoldLines(orders, stocks);
  assert.equal(result.length, 1);
  assert.equal(result[0].sku, "SKU-1");
  assert.equal(result[0].warehouse_id, "wh-1");
  assert.equal(result[0].recomputed_allocated_qty, 2);
  assert.equal(result[0].oversold_by, 1);
  assert.deepEqual(result[0].offending_order_ids, ["order-A", "order-B"]);
});

test("no flag when allocated matches stock", () => {
  const orders = [order("order-A")];
  const stocks = [stock()];
  assert.deepEqual(findOversoldLines(orders, stocks), []);
});

test("cancelled orders are excluded from recomputed demand", () => {
  const orders = [order("order-A", { status: "CANCELLED" }), order("order-B")];
  const stocks = [stock()];
  assert.deepEqual(findOversoldLines(orders, stocks), []);
});

test("flags when reported allocated disagrees with recomputed", () => {
  const orders = [order("order-A")];
  const stocks = [stock({ on_hand_qty: 5, reported_allocated_qty: 3 })];
  const result = findOversoldLines(orders, stocks);
  assert.equal(result.length, 1);
  assert.equal(result[0].reported_allocated_qty, 3);
  assert.equal(result[0].recomputed_allocated_qty, 1);
  assert.equal(result[0].oversold_by, 0);
});

test("separate warehouses are not confused", () => {
  const orders = [
    order("order-A", { lines: [{ sku: "SKU-1", warehouse_id: "wh-1", allocated_qty: 1 }] }),
    order("order-B", { lines: [{ sku: "SKU-1", warehouse_id: "wh-2", allocated_qty: 1 }] }),
  ];
  const stocks = [
    stock({ warehouse_id: "wh-1", on_hand_qty: 1, reported_allocated_qty: 1 }),
    stock({ warehouse_id: "wh-2", on_hand_qty: 1, reported_allocated_qty: 1 }),
  ];
  assert.deepEqual(findOversoldLines(orders, stocks), []);
});

Case studies

Flash drop

A limited run of two sneaker colorways

A footwear brand released a size run of ten pairs per colorway at the top of the hour. Every pair sold out inside four seconds, and support tickets started arriving within minutes from customers who had a confirmed order but never got shipped. The team assumed a warehouse miscount at first.

Running the script against the order window from the drop showed three SKU and warehouse pairs where recomputed allocated quantity was one unit higher than on hand stock, each with exactly two order ids attached. It was the same concurrent checkoutCreate and checkoutComplete race for the last pair in each size, not a warehouse problem at all.

Wholesale + retail

A shared SKU sold through two channels at once

A homeware brand sold the same variant through a retail channel and a wholesale channel that shared one warehouse. A wholesale buyer's draft order completed within a second of a retail checkout completing for the same last unit, and both showed as valid, paid orders in their respective channel dashboards.

The nightly report flagged the SKU with both order ids attached and a reported allocated quantity that no longer matched the recomputed sum, which is exactly the fingerprint this script looks for. A human reviewed both orders, recounted the physical stock, and manually triggered a backorder workflow for the wholesale order rather than the retail one.

What good looks like

After this runs on a schedule, no oversold SKU sits silently until a customer complains. Every mismatch between recomputed demand and on hand stock lands in a report with the exact order ids attached, so a human can recount stock, cancel the newer order, or start a backorder or refund with full context, instead of guessing which of two paid orders was the real sale.

FAQ

Why does Saleor let two checkouts both pass stock validation for the same SKU?

Saleor checks requested quantity against available stock independently at checkoutCreate, checkoutLinesAdd, checkoutShippingAddressUpdate, and checkoutComplete, reading the current Stock.quantity minus reservations at that instant rather than holding one locked reservation for the whole flow. Between two of those reads, or across two concurrent checkouts, both requests can see a non-negative available quantity even though their combined demand exceeds real stock.

Can this double selling bug be fixed by a script that edits stock directly?

No, it should not be auto corrected. Cancelling or shrinking allocations risks cancelling a legitimately paid order, so the safe approach is to flag oversold SKUs in a report and let a human decide whether to recount stock, cancel the newer order, or trigger a backorder or refund workflow.

How do I detect which SKUs got oversold in Saleor?

Query orders with their lines and allocations, and query warehouses with their stocks, then for each SKU and warehouse pair sum OrderLine allocations quantity across non-cancelled orders and compare it against that Stock's on-hand quantity. When the summed allocated quantity exceeds on-hand stock, or Saleor's own quantityAllocated disagrees with your recomputed sum, that mismatch is the fingerprint of a double sold SKU.

Related field notes

Citations

On the problem:

  1. Concurrent checkouts allocate more stocks to customers than the quantity available. github.com/saleor/saleor/issues/543
  2. Insufficient stock at checkoutShippingAddressUpdate mutation. github.com/saleor/saleor/issues/8257
  3. Inaccurate insufficient stock error in checkout flow, unable to place multiple orders for the same product. github.com/saleor/apps/issues/1175

On the solution:

  1. Saleor Commerce Documentation: Stock Reservation. docs.saleor.io/developer/stock/stock-reservation
  2. Saleor Commerce Documentation: Stock Allocation. docs.saleor.io/developer/stock-allocation
  3. Saleor Commerce Documentation: the Stock object. docs.saleor.io/docs/3.x/api-reference/products/objects/stock

Stuck on a tricky one?

If you have a problem in Saleor checkout, stock, orders, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch an oversold SKU?

If this saved you a support ticket storm or a wrong stock count, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Saleor field notes