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.
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.
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:
- Two shoppers add the last unit of a variant to their checkouts within the same second. Each checkout independently reads
Stock.quantityminus current reservations, and each read looks fine because the other checkout's claim has not been committed yet. - A stale
checkoutCreateis followed much later by acheckoutShippingAddressUpdateon the same checkout, which triggers its own revalidation against a fresh, unrelated snapshot of stock rather than the quantity it originally reserved. - The optional stock reservation window (
CHECKOUT_TTL_BEFORE_RELEASING_FUNDSand related reservation settings) only tracks reservations made through that same mechanism, so a legacy or duplicate validation path that recomputes availability from scratch does not see another in-flight checkout's hold. - Only
checkoutComplete, through stock allocation, actually decrements or locks stock. Every earlier check is advisory: it answers "was there enough a moment ago," not "is this unit still mine."
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.
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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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"]
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;
}
}
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.
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"]
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;
}
}
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.
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
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;
}
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.
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.
"""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()
/**
* 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.
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) == []
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
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.
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.
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:
- Concurrent checkouts allocate more stocks to customers than the quantity available. github.com/saleor/saleor/issues/543
- Insufficient stock at checkoutShippingAddressUpdate mutation. github.com/saleor/saleor/issues/8257
- 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:
- Saleor Commerce Documentation: Stock Reservation. docs.saleor.io/developer/stock/stock-reservation
- Saleor Commerce Documentation: Stock Allocation. docs.saleor.io/developer/stock-allocation
- 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.
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