Diagnostic Orders & Fulfillment
Fulfillment fails when variant stock equals one
An order comes in for a variant that has exactly one unit sitting in a warehouse. The checkout succeeds, the order is placed, everything looks normal. Then staff try to fulfill it and Saleor throws back INSUFFICIENT_STOCK, on a unit that is sitting right there on the shelf, reserved for this exact order. Here is why Saleor's own allocation math causes that, and a script that flags the boundary case before anyone calls orderFulfill.
Saleor allocates stock at order placement time, not at fulfillment time. The moment a customer checks out, the requested quantity is written straight into Stock.quantityAllocated, and available stock is computed as quantity - quantityAllocated. For a variant whose warehouse row has quantity equal to one, that single unit becomes fully allocated the instant the order is created. So when staff run orderFulfill, the mutation's own re-check sees available = 1 - 1 = 0 and rejects it with INSUFFICIENT_STOCK, even though that one unit was reserved for this exact order. This is a documented boundary condition, tracked as far back as Saleor issue #6136, and it is exactly why orderFulfill exposes an allowStockToBeExceeded escape hatch. A Python or Node.js script below detects the boundary case, confirms it is a false positive rather than a genuine shortage, and drives a dry-run-first guarded repair.
The problem in plain words
Most people assume Saleor checks stock twice: once when the order is placed, and again when it is fulfilled, comparing the same numbers each time. It does not quite work that way. Saleor commits the reservation once, at order placement, by writing the ordered quantity into Stock.quantityAllocated. From that moment on, "available" for that stock row is just quantity - quantityAllocated, and the order's own hold is baked into that number.
That is fine for a variant with ten units in stock and one allocated. Nine still read as available and nobody notices. But when a variant's warehouse stock is exactly one, and that one unit gets allocated to the order that just bought it, the available figure for that stock row drops to zero the instant the order exists. When staff later run orderFulfill, the mutation re-checks the same stock row, sees zero available, and refuses, quoting INSUFFICIENT_STOCK on an order that is not actually oversold. The unit was never double-booked. It is just fully spoken for by the one order trying to claim it.
Why it happens
This is a boundary condition in how allocation and availability are computed, not a data corruption bug. A few concrete ways it shows up:
- A one-off or limited edition variant is stocked at exactly one unit in a single warehouse, so any single sale takes the stock row straight to zero available.
- A low-stock SKU restocked to one unit sells within minutes, and staff try to fulfill it the same day, hitting the boundary before anyone thinks to check
quantityAllocated. - An app or storefront calls
orderFulfillautomatically right after payment confirmation, with no delay for a human to notice the odd rejection reason. - A partial fulfillment already consumed some quantity on a multi-line order, and the remaining line happens to land on a warehouse stock row that is also down to exactly one unit.
This confusion is easy to have, because the error code, INSUFFICIENT_STOCK, sounds like it means "there really is not enough stock." In the general case it does. But the fulfillment mutation cannot tell the difference between "this order's own allocation makes the row read zero" and "somebody else also holds an allocation against the same row." Both look identical from inside the check. Related reports, such as saleor/apps issue #1175 on inaccurate insufficient stock errors during checkout, and saleor/saleor issue #543 on concurrent checkouts allocating more stock than available, both trace back to the same allocation model: a write that happens early and is trusted everywhere after, including in places where it should really be read as "already spoken for by us," not "gone."
You cannot fix this by adding more stock or by editing Stock.quantity by hand, because the physical count was never wrong. The fix is to recompute availability the way orderFulfill should have for this specific order: subtract out the allocation that this order itself is holding before deciding whether stock is really insufficient. If, after that adjustment, the requested quantity still fits, this is the quantity equals one boundary case, and it is safe to proceed. If it does not fit even after the adjustment, there is a genuine shortage from other orders, and that goes to a human, not to a blind retry.
The fix, as a flow
We do not touch Stock.quantity and we do not force a fulfillment blind. We add a script that reads the order's lines and their variant stocks, runs a pure decision function that excludes this order's own allocation from the availability math, and only for the confirmed boundary case retries orderFulfill with allowStockToBeExceeded: true, behind a dry run flag. Anything that is not the confirmed boundary case is reported to a human instead.
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_ORDERS so it can read orders and call orderFulfill. 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 DRY_RUN="true" # start safe, only reports and dry-run-fulfills
// 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 DRY_RUN="true" // start safe, only reports and dry-run-fulfills
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 every query and mutation 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;
}
Read the order's lines and their variant stocks
Pull the order's unfulfilled lines along with each variant's stock per warehouse, reading quantity and quantityAllocated. This is the raw material the decision needs, since the boundary case only shows up when we can see exactly how much of a one-unit stock row this order itself is holding.
ORDER_QUERY = """
query($orderId: ID!) {
order(id: $orderId) {
id
status
lines {
id
quantity
quantityFulfilled
variant {
id
name
stocks {
warehouse { id name }
quantity
quantityAllocated
}
}
}
}
}"""
def get_order(order_id):
return gql(ORDER_QUERY, {"orderId": order_id})["order"]
const ORDER_QUERY = `
query($orderId: ID!) {
order(id: $orderId) {
id
status
lines {
id
quantity
quantityFulfilled
variant {
id
name
stocks {
warehouse { id name }
quantity
quantityAllocated
}
}
}
}
}`;
async function getOrder(orderId) {
return (await gql(ORDER_QUERY, { orderId })).order;
}
Decide, with one pure function
Keep the decision in its own function that takes plain numbers, the stock row, the quantity this order is requesting, and how much this order already holds on that stock, and returns whether the fulfillment should proceed. It excludes this order's own allocation from the availability math, which is exactly the adjustment orderFulfill's own re-check does not make. It never touches the network, which is what makes it easy to test.
def decide_fulfillment_allowed(stock, requested_qty, already_allocated_for_this_order):
"""
Pure decision logic, no I/O.
stock: {"quantity": int, "quantityAllocated": int}
requested_qty: int, the amount this order line still needs fulfilled
already_allocated_for_this_order: int, this order's own hold on this stock row
Returns {"allowed": bool, "reason": str}.
"""
true_available = stock["quantity"] - (stock["quantityAllocated"] - already_allocated_for_this_order)
if requested_qty <= true_available:
if stock["quantity"] == 1 and already_allocated_for_this_order >= stock["quantityAllocated"]:
return {
"allowed": True,
"reason": "BOUNDARY_CASE: quantity=1 fully allocated to this order, fulfillment should proceed",
}
return {"allowed": True, "reason": "OK"}
return {
"allowed": False,
"reason": f"INSUFFICIENT_STOCK: only {true_available} of {requested_qty} requested available",
}
export function decideFulfillmentAllowed(stock, requestedQty, alreadyAllocatedForThisOrder) {
const trueAvailable = stock.quantity - (stock.quantityAllocated - alreadyAllocatedForThisOrder);
if (requestedQty <= trueAvailable) {
if (stock.quantity === 1 && alreadyAllocatedForThisOrder >= stock.quantityAllocated) {
return {
allowed: true,
reason: "BOUNDARY_CASE: quantity=1 fully allocated to this order, fulfillment should proceed",
};
}
return { allowed: true, reason: "OK" };
}
return {
allowed: false,
reason: `INSUFFICIENT_STOCK: only ${trueAvailable} of ${requestedQty} requested available`,
};
}
Dry-run orderFulfill first to confirm the signal
Before trusting the pure function alone, call orderFulfill with allowStockToBeExceeded: false and read errors[].code. Seeing INSUFFICIENT_STOCK come back on a line whose stock quantity is exactly one is the definitive signal, matching what the pure function already computed. Only after that double confirmation do we consider the guarded retry.
FULFILL_MUTATION = """
mutation($orderId: ID!, $lines: [OrderFulfillLineInput!]!, $allowExceed: Boolean!) {
orderFulfill(
order: $orderId,
input: { linesInput: $lines, allowStockToBeExceeded: $allowExceed }
) {
fulfillments { id status }
errors { field code message }
}
}"""
def attempt_fulfill(order_id, lines_input, allow_exceed):
result = gql(
FULFILL_MUTATION,
{"orderId": order_id, "lines": lines_input, "allowExceed": allow_exceed},
)["orderFulfill"]
return result
const FULFILL_MUTATION = `
mutation($orderId: ID!, $lines: [OrderFulfillLineInput!]!, $allowExceed: Boolean!) {
orderFulfill(
order: $orderId,
input: { linesInput: $lines, allowStockToBeExceeded: $allowExceed }
) {
fulfillments { id status }
errors { field code message }
}
}`;
async function attemptFulfill(orderId, linesInput, allowExceed) {
return (
await gql(FULFILL_MUTATION, { orderId, lines: linesInput, allowExceed })
).orderFulfill;
}
Wire it together with a dry run guard
The loop reads the order, checks each line's stock against the pure decision function, and dry-run-fulfills with allowStockToBeExceeded: false to see whether Saleor's own re-check agrees this is INSUFFICIENT_STOCK. When both signals confirm the quantity equals one boundary case, and DRY_RUN is turned off, the script retries with allowStockToBeExceeded: true so the reserved unit actually ships. If the pure function does not confirm the boundary case, meaning true available stock is genuinely short even after excluding this order's own hold, the script stops and reports the SKU instead of forcing anything.
Never set allowStockToBeExceeded: true as a blanket default. Only retry with it when decide_fulfillment_allowed confirms the boundary case for that specific stock row, and always run with DRY_RUN=true first so you can read the intended mutation and variables before anything writes. A false decrement can double-sell later, and a false override can mask a real oversell from another order.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never forces a fulfillment unless the boundary case is confirmed twice, once by the pure function and once by Saleor's own dry-run error response.
"""Detect and repair Saleor orders blocked from fulfillment by the
quantity-equals-one stock allocation boundary case.
Saleor allocates stock at order placement, not at fulfillment time. A variant
whose warehouse Stock.quantity is exactly one becomes fully allocated to its
own order the moment that order is placed. When orderFulfill later re-checks
availability as quantity - quantityAllocated, it reads zero and rejects the
fulfillment with INSUFFICIENT_STOCK, even though the unit is reserved for
this exact order.
This script only retries orderFulfill with allowStockToBeExceeded=true when
the pure decision function AND a dry-run orderFulfill call both confirm the
boundary case. Otherwise it reports the SKU for a human to reconcile. Safe
to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_stock_one_fulfillment")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDER_QUERY = """
query($orderId: ID!) {
order(id: $orderId) {
id
status
lines {
id
quantity
quantityFulfilled
variant {
id
name
stocks {
warehouse { id name }
quantity
quantityAllocated
}
}
}
}
}"""
FULFILL_MUTATION = """
mutation($orderId: ID!, $lines: [OrderFulfillLineInput!]!, $allowExceed: Boolean!) {
orderFulfill(
order: $orderId,
input: { linesInput: $lines, allowStockToBeExceeded: $allowExceed }
) {
fulfillments { id status }
errors { field code message }
}
}"""
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 decide_fulfillment_allowed(stock, requested_qty, already_allocated_for_this_order):
"""
Pure decision logic, no I/O.
stock: {"quantity": int, "quantityAllocated": int}
requested_qty: int, the amount this order line still needs fulfilled
already_allocated_for_this_order: int, this order's own hold on this stock row
Returns {"allowed": bool, "reason": str}.
"""
true_available = stock["quantity"] - (stock["quantityAllocated"] - already_allocated_for_this_order)
if requested_qty <= true_available:
if stock["quantity"] == 1 and already_allocated_for_this_order >= stock["quantityAllocated"]:
return {
"allowed": True,
"reason": "BOUNDARY_CASE: quantity=1 fully allocated to this order, fulfillment should proceed",
}
return {"allowed": True, "reason": "OK"}
return {
"allowed": False,
"reason": f"INSUFFICIENT_STOCK: only {true_available} of {requested_qty} requested available",
}
def get_order(order_id):
return gql(ORDER_QUERY, {"orderId": order_id})["order"]
def attempt_fulfill(order_id, lines_input, allow_exceed):
result = gql(
FULFILL_MUTATION,
{"orderId": order_id, "lines": lines_input, "allowExceed": allow_exceed},
)["orderFulfill"]
return result
def _line_input(line_id, warehouse_id, qty):
return {"orderLineId": line_id, "stocks": [{"warehouse": warehouse_id, "quantity": qty}]}
def check_and_repair(order_id):
order = get_order(order_id)
flagged = []
repaired = []
for line in order["lines"]:
requested = line["quantity"] - line["quantityFulfilled"]
if requested <= 0:
continue
variant = line["variant"]
for stock in variant["stocks"]:
if stock["quantity"] != 1:
continue
# This order's own hold on a quantity=1 row is, at most, the
# requested amount, since nothing else can share a single unit.
already_allocated = min(requested, stock["quantityAllocated"])
decision = decide_fulfillment_allowed(
{"quantity": stock["quantity"], "quantityAllocated": stock["quantityAllocated"]},
requested,
already_allocated,
)
if not decision["allowed"]:
log.warning(
"Order %s line %s: %s. Reporting for manual reconciliation.",
order_id, line["id"], decision["reason"],
)
flagged.append({"line_id": line["id"], "reason": decision["reason"]})
continue
if "BOUNDARY_CASE" not in decision["reason"]:
continue
warehouse_id = stock["warehouse"]["id"]
lines_input = [_line_input(line["id"], warehouse_id, requested)]
dry_result = attempt_fulfill(order_id, lines_input, allow_exceed=False)
codes = [e["code"] for e in dry_result.get("errors", [])]
if "INSUFFICIENT_STOCK" not in codes:
log.info("Order %s line %s: no error on dry check, nothing to repair.", order_id, line["id"])
continue
log.warning(
"Order %s line %s confirmed boundary case (quantity=1, fully self-allocated). %s",
order_id, line["id"], "Would retry with allowStockToBeExceeded=true" if DRY_RUN else "Retrying now",
)
if not DRY_RUN:
real_result = attempt_fulfill(order_id, lines_input, allow_exceed=True)
if real_result.get("errors"):
raise RuntimeError(real_result["errors"])
repaired.append({"line_id": line["id"], "fulfillments": real_result["fulfillments"]})
log.info(
"Done. %d line(s) flagged for manual review, %d line(s) %s.",
len(flagged), len(repaired), "would be repaired" if DRY_RUN else "repaired",
)
return {"flagged": flagged, "repaired": repaired}
def run():
order_id = os.environ["SALEOR_ORDER_ID"]
check_and_repair(order_id)
if __name__ == "__main__":
run()
/**
* Detect and repair Saleor orders blocked from fulfillment by the
* quantity-equals-one stock allocation boundary case.
*
* Saleor allocates stock at order placement, not at fulfillment time. A
* variant whose warehouse Stock.quantity is exactly one becomes fully
* allocated to its own order the moment that order is placed. When
* orderFulfill later re-checks availability as quantity - quantityAllocated,
* it reads zero and rejects the fulfillment with INSUFFICIENT_STOCK, even
* though the unit is reserved for this exact order.
*
* This script only retries orderFulfill with allowStockToBeExceeded=true
* when the pure decision function AND a dry-run orderFulfill call both
* confirm the boundary case. Otherwise it reports the SKU for a human to
* reconcile. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/saleor/fulfillment-blocked-stock-equals-one/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideFulfillmentAllowed(stock, requestedQty, alreadyAllocatedForThisOrder) {
const trueAvailable = stock.quantity - (stock.quantityAllocated - alreadyAllocatedForThisOrder);
if (requestedQty <= trueAvailable) {
if (stock.quantity === 1 && alreadyAllocatedForThisOrder >= stock.quantityAllocated) {
return {
allowed: true,
reason: "BOUNDARY_CASE: quantity=1 fully allocated to this order, fulfillment should proceed",
};
}
return { allowed: true, reason: "OK" };
}
return {
allowed: false,
reason: `INSUFFICIENT_STOCK: only ${trueAvailable} of ${requestedQty} requested available`,
};
}
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 ORDER_QUERY = `
query($orderId: ID!) {
order(id: $orderId) {
id
status
lines {
id
quantity
quantityFulfilled
variant {
id
name
stocks {
warehouse { id name }
quantity
quantityAllocated
}
}
}
}
}`;
const FULFILL_MUTATION = `
mutation($orderId: ID!, $lines: [OrderFulfillLineInput!]!, $allowExceed: Boolean!) {
orderFulfill(
order: $orderId,
input: { linesInput: $lines, allowStockToBeExceeded: $allowExceed }
) {
fulfillments { id status }
errors { field code message }
}
}`;
async function getOrder(orderId) {
return (await gql(ORDER_QUERY, { orderId })).order;
}
async function attemptFulfill(orderId, linesInput, allowExceed) {
return (
await gql(FULFILL_MUTATION, { orderId, lines: linesInput, allowExceed })
).orderFulfill;
}
function lineInput(lineId, warehouseId, qty) {
return { orderLineId: lineId, stocks: [{ warehouse: warehouseId, quantity: qty }] };
}
export async function checkAndRepair(orderId) {
const order = await getOrder(orderId);
const flagged = [];
const repaired = [];
for (const line of order.lines) {
const requested = line.quantity - line.quantityFulfilled;
if (requested <= 0) continue;
for (const stock of line.variant.stocks) {
if (stock.quantity !== 1) continue;
// This order's own hold on a quantity=1 row is, at most, the
// requested amount, since nothing else can share a single unit.
const alreadyAllocated = Math.min(requested, stock.quantityAllocated);
const decision = decideFulfillmentAllowed(
{ quantity: stock.quantity, quantityAllocated: stock.quantityAllocated },
requested,
alreadyAllocated
);
if (!decision.allowed) {
console.warn(`Order ${orderId} line ${line.id}: ${decision.reason}. Reporting for manual reconciliation.`);
flagged.push({ lineId: line.id, reason: decision.reason });
continue;
}
if (!decision.reason.startsWith("BOUNDARY_CASE")) continue;
const warehouseId = stock.warehouse.id;
const linesInput = [lineInput(line.id, warehouseId, requested)];
const dryResult = await attemptFulfill(orderId, linesInput, false);
const codes = (dryResult.errors || []).map((e) => e.code);
if (!codes.includes("INSUFFICIENT_STOCK")) {
console.log(`Order ${orderId} line ${line.id}: no error on dry check, nothing to repair.`);
continue;
}
console.warn(
`Order ${orderId} line ${line.id} confirmed boundary case (quantity=1, fully self-allocated). ` +
`${DRY_RUN ? "Would retry with allowStockToBeExceeded=true" : "Retrying now"}`
);
if (!DRY_RUN) {
const realResult = await attemptFulfill(orderId, linesInput, true);
if (realResult.errors && realResult.errors.length) {
throw new Error(JSON.stringify(realResult.errors));
}
repaired.push({ lineId: line.id, fulfillments: realResult.fulfillments });
}
}
}
console.log(
`Done. ${flagged.length} line(s) flagged for manual review, ${repaired.length} line(s) ` +
`${DRY_RUN ? "would be repaired" : "repaired"}.`
);
return { flagged, repaired };
}
export async function run() {
const orderId = process.env.SALEOR_ORDER_ID;
await checkAndRepair(orderId);
}
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 whether a script forces a fulfillment. Because we kept decide_fulfillment_allowed pure, the test needs no network and no Saleor store. It just feeds in plain numbers and checks the answer, with table-driven cases for quantity equals one, quantityAllocated at zero and one, and requestedQty equal to one.
import pytest
from fix_stock_one_fulfillment import decide_fulfillment_allowed
@pytest.mark.parametrize(
"quantity, quantity_allocated, requested_qty, already_allocated, expect_allowed, reason_prefix",
[
# The classic boundary case: 1 unit, fully allocated to this order, requesting 1.
(1, 1, 1, 1, True, "BOUNDARY_CASE"),
# Plenty of stock, nothing allocated: a plain OK, not the boundary case.
(10, 0, 1, 0, True, "OK"),
# 1 unit, allocated to this order, but another order also holds an allocation
# (quantityAllocated exceeds what this order holds): genuinely short.
(1, 2, 1, 1, False, "INSUFFICIENT_STOCK"),
# 1 unit, not allocated to this order at all, but something else holds it: short.
(1, 1, 1, 0, False, "INSUFFICIENT_STOCK"),
# Zero stock outright, nothing allocated to this order: short.
(0, 0, 1, 0, False, "INSUFFICIENT_STOCK"),
# 1 unit, fully self-allocated, requesting more than 1: still short.
(1, 1, 2, 1, False, "INSUFFICIENT_STOCK"),
# 2 units, 1 allocated to this order, 1 free: OK but not the boundary case
# (quantity != 1).
(2, 1, 1, 1, True, "OK"),
],
)
def test_decide_fulfillment_allowed(
quantity, quantity_allocated, requested_qty, already_allocated, expect_allowed, reason_prefix
):
stock = {"quantity": quantity, "quantityAllocated": quantity_allocated}
result = decide_fulfillment_allowed(stock, requested_qty, already_allocated)
assert result["allowed"] is expect_allowed
assert result["reason"].startswith(reason_prefix)
def test_boundary_case_reason_is_human_readable():
result = decide_fulfillment_allowed({"quantity": 1, "quantityAllocated": 1}, 1, 1)
assert result["reason"] == (
"BOUNDARY_CASE: quantity=1 fully allocated to this order, fulfillment should proceed"
)
def test_insufficient_stock_reason_reports_true_available():
result = decide_fulfillment_allowed({"quantity": 1, "quantityAllocated": 2}, 1, 1)
assert result["reason"] == "INSUFFICIENT_STOCK: only 0 of 1 requested available"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideFulfillmentAllowed } from "./fix-stock-one-fulfillment.js";
const cases = [
// The classic boundary case: 1 unit, fully allocated to this order, requesting 1.
{ quantity: 1, quantityAllocated: 1, requestedQty: 1, alreadyAllocated: 1, expectAllowed: true, reasonPrefix: "BOUNDARY_CASE" },
// Plenty of stock, nothing allocated: a plain OK, not the boundary case.
{ quantity: 10, quantityAllocated: 0, requestedQty: 1, alreadyAllocated: 0, expectAllowed: true, reasonPrefix: "OK" },
// 1 unit, allocated to this order, but another order also holds an allocation: genuinely short.
{ quantity: 1, quantityAllocated: 2, requestedQty: 1, alreadyAllocated: 1, expectAllowed: false, reasonPrefix: "INSUFFICIENT_STOCK" },
// 1 unit, not allocated to this order at all, but something else holds it: short.
{ quantity: 1, quantityAllocated: 1, requestedQty: 1, alreadyAllocated: 0, expectAllowed: false, reasonPrefix: "INSUFFICIENT_STOCK" },
// Zero stock outright, nothing allocated to this order: short.
{ quantity: 0, quantityAllocated: 0, requestedQty: 1, alreadyAllocated: 0, expectAllowed: false, reasonPrefix: "INSUFFICIENT_STOCK" },
// 1 unit, fully self-allocated, requesting more than 1: still short.
{ quantity: 1, quantityAllocated: 1, requestedQty: 2, alreadyAllocated: 1, expectAllowed: false, reasonPrefix: "INSUFFICIENT_STOCK" },
// 2 units, 1 allocated to this order, 1 free: OK but not the boundary case (quantity != 1).
{ quantity: 2, quantityAllocated: 1, requestedQty: 1, alreadyAllocated: 1, expectAllowed: true, reasonPrefix: "OK" },
];
for (const c of cases) {
test(`decideFulfillmentAllowed quantity=${c.quantity} allocated=${c.quantityAllocated} requested=${c.requestedQty} selfAllocated=${c.alreadyAllocated}`, () => {
const stock = { quantity: c.quantity, quantityAllocated: c.quantityAllocated };
const result = decideFulfillmentAllowed(stock, c.requestedQty, c.alreadyAllocated);
assert.equal(result.allowed, c.expectAllowed);
assert.ok(result.reason.startsWith(c.reasonPrefix));
});
}
test("boundary case reason is human readable", () => {
const result = decideFulfillmentAllowed({ quantity: 1, quantityAllocated: 1 }, 1, 1);
assert.equal(
result.reason,
"BOUNDARY_CASE: quantity=1 fully allocated to this order, fulfillment should proceed"
);
});
test("insufficient stock reason reports true available", () => {
const result = decideFulfillmentAllowed({ quantity: 1, quantityAllocated: 2 }, 1, 1);
assert.equal(result.reason, "INSUFFICIENT_STOCK: only 0 of 1 requested available");
});
Case studies
The last unit of a drop refused to fulfill
A streetwear brand ran a limited release where several sizes had exactly one unit in the warehouse. The moment each of those sizes sold, staff moved straight to fulfillment, since the drop demanded fast shipping to build hype, and every single one of those orders bounced with INSUFFICIENT_STOCK.
Running the detection script showed every flagged line was the same shape: quantity of one, quantityAllocated of one, and the allocation entirely belonging to the order trying to fulfill it. The dry-run orderFulfill call confirmed the same INSUFFICIENT_STOCK code, so the team let the script retry with allowStockToBeExceeded true, and the drop shipped on time.
A single restocked unit sold and shipped within the hour
A tools retailer restocked a discontinued part to a single unit while sourcing more, expecting it to sit for a few days. It sold within minutes, and the warehouse team, working from a same-day pick list, hit the fulfillment rejection before anyone had touched the order.
The pure function and the dry-run check both agreed on the boundary case, and the script's log made it obvious this was not a double sale, just the order's own reservation. It fulfilled cleanly once DRY_RUN was turned off for that one confirmed line, and the part shipped the same day.
After this script runs before a fulfillment attempt, a one-unit variant stops looking like an oversell to your team. The confirmed boundary cases retry cleanly with allowStockToBeExceeded, and anything that is a genuine shortage from another order gets reported instead of forced. Nobody has to guess whether INSUFFICIENT_STOCK means "really out of stock" or "Saleor's own math against itself," because the script already tells them which one it is.
FAQ
Why does Saleor reject orderFulfill with INSUFFICIENT_STOCK when the variant has exactly one unit?
Saleor allocates stock the moment an order is placed, not when it is fulfilled. For a warehouse Stock row with quantity equal to one, that single unit becomes fully written to quantityAllocated as soon as the order is created. When staff later call orderFulfill, the mutation re-checks availability as quantity minus quantityAllocated, which reads one minus one, or zero, so it rejects the fulfillment with INSUFFICIENT_STOCK even though the one unit was reserved for this exact order and should be fulfillable.
Is it safe to just set allowStockToBeExceeded to true on every orderFulfill call?
No. That flag tells Saleor to let the fulfillment go through even if it would oversell, so using it blindly on every order can mask a genuine stock shortage caused by other orders. The safe pattern is to first confirm this is the quantity equals one boundary case, meaning the only allocation against that stock is this order's own line, and only then retry with allowStockToBeExceeded true behind a dry run flag. If other orders also hold allocations against the same stock, stop and send the SKU to a human for warehouse reconciliation.
How do I detect the stock equals one boundary case before calling orderFulfill?
Query the order's lines with their variant stocks, reading quantity and quantityAllocated per warehouse, along with quantity and quantityFulfilled per order line. Compute the true available stock as quantity minus quantityAllocated plus whatever this order already holds. If that true available figure covers the requested quantity, the naive zero reading is the false positive Saleor issue 6136 describes, and the record is safe to retry with allowStockToBeExceeded rather than something a human needs to chase down first.
Related field notes
Citations
On the problem:
- Cannot fulfill order when product quantity is 1. github.com/saleor/saleor/issues/6136
- Inaccurate Insufficient Stock Error in Checkout Flow: Unable to place multiple order for the same product. github.com/saleor/apps/issues/1175
- Concurrent checkouts allocate more stocks to customers than the quantity available. github.com/saleor/saleor/issues/543
On the solution:
- Saleor Commerce Documentation: the orderFulfill mutation, including allowStockToBeExceeded. docs.saleor.io/api-reference/orders/mutations/order-fulfill
- Saleor Commerce Documentation: Stock Allocation. docs.saleor.io/developer/stock/stock-allocation
- Saleor Commerce Documentation: the Stock object, including quantity and quantityAllocated. 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 clear up a fulfillment mystery?
If this saved you from chasing a phantom oversell on your last unit in stock, 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