Reconciler Stock & Inventory
Unpaid orders keep stock allocated indefinitely
A customer starts checkout, the order line gets created, and Saleor reserves the stock right then. Then the payment never lands. No card charge, no bank transfer, nothing. The order just sits there, and so does the stock it grabbed, because nothing in Saleor ever automatically lets it go unless you told it to. Here is why that allocation outlives the order and a script that finds the stuck ones and safely frees them.
Saleor allocates warehouse stock the moment an order line is created at checkout completion, but the only built-in mechanism that ever releases it automatically is a Celery beat task tied to the per-channel OrderSettings.expireOrdersAfter setting, and that task only touches orders still in UNCONFIRMED status with no payment transaction attached. expireOrdersAfter defaults to null, meaning do not expire any orders, so unless a merchant configures it per channel, the order and its allocation sit untouched no matter how old they get. Run a small Python or Node.js script that pages through orders, flags the ones that are old, unpaid, and past that native expiration window, then cancels the safe ones with orderCancel and only reports the partially fulfilled ones for a human to review. Full code, tests, and a dry run guard are below.
The problem in plain words
Stock reservation in Saleor happens early, at the moment checkout completes and an order line is created. That is by design. It stops two shoppers from buying the last unit of the same thing at the same time. But reserving stock and confirming payment are two separate steps, and Saleor only ever wires up an automatic way to undo the first step for one narrow situation.
That situation is an order that is still UNCONFIRMED, has no payment transaction attached, and has aged past a number of minutes a merchant configured on the channel through expireOrdersAfter. A Celery beat task checks for exactly that combination and expires the order, which releases the allocation as a side effect. Everything else falls outside its reach: an order already at UNFULFILLED because a payment attempt started but was later voided or refunded, an order whose channel never had expireOrdersAfter set because the default is null, or an order that is PARTIALLY_FULFILLED so it was never going to qualify for expiration in the first place. The stock stays reserved, the order stays open, and nothing reconciles the two.
Why it happens
expireOrdersAfterdefaults tonullon every channel, which literally means do not expire any orders, so a merchant has to opt in per channel before the native cleanup ever runs at all.- Even when it is configured, the Celery beat task only expires orders still at
UNCONFIRMED. An order that moved toUNFULFILLED, for instance because a payment attempt was authorized and later voided or refunded, is no longer eligible for that automatic expiration path, no matter how long it sits. - A
PARTIALLY_FULFILLEDorder can hold an unpaid, unfulfilled remainder on some lines while other lines already shipped. That order was never a candidate for automatic expiration and needs its own handling, since cancelling it outright would orphan the part that already left the warehouse. - Nothing in Saleor reconciles an order's payment state against its allocation state on a schedule. The allocation and the order simply sit next to each other, correct at the moment they were created, and slowly drift apart from what actually happened with the money.
None of this throws an error or shows up on a dashboard. The warehouse team just sees quantityAvailable numbers that seem lower than they should be, and the reason is a pile of unpaid orders from days or weeks ago holding onto stock nobody is ever going to ship. See the citations at the end for the exact GitHub issue and the order expiration and status docs.
You cannot fix this by turning on expireOrdersAfter alone. It only ever looks at UNCONFIRMED orders, so anything that slipped past that status, or a channel where the setting was never touched, is still your problem to clean up. The safe pattern is to treat cancellation and deallocation as two different tiers: fully cancel the orders where nothing shipped, and only ever flag the ones where part of the order already went out the door, because a human has to decide what happens to those, not a script.
The fix, as a flow
The script runs on a schedule. It pages through orders, keeps the ones that are old, unpaid, and still holding an allocation, and classifies each one with a single pure function. Orders that are UNCONFIRMED or fully UNFULFILLED get cancelled outright with orderCancel, which is Saleor's own mechanism for releasing every allocation on an order. Orders that are PARTIALLY_FULFILLED are never auto-cancelled. They are reported so a person can decide which unfulfilled lines to release by hand.
Build it step by step
Get an app token with order read and write access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read and manage orders, since the repair calls orderCancel. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export STALE_AFTER_HOURS="72"
export DRY_RUN="true" # start safe, this script never writes without it off
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export STALE_AFTER_HOURS="72"
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
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;
}
Page through orders and read the fields the decision needs
Ask for orders(first, after, filter, sortBy) and read back status, isPaid, paymentStatus, created, and the channel's orderSettings.expireOrdersAfter, plus each line's quantity and quantityFulfilled so you can confirm the order is actually holding an allocated but unfulfilled quantity. Page with a cursor so the job handles a large backlog.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor, sortBy: { field: CREATION_DATE, direction: ASC }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
isPaid
paymentStatus
created
channel { slug orderSettings { expireOrdersAfter } }
lines { id quantity quantityFulfilled variant { id } }
}
}
}
}"""
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["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) {
orders(first: 50, after: $cursor, sortBy: { field: CREATION_DATE, direction: ASC }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
isPaid
paymentStatus
created
channel { slug orderSettings { expireOrdersAfter } }
lines { id quantity quantityFulfilled variant { id } }
}
}
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a plain order shape and the current time and returns one of three answers: OK, CANCEL, or DEALLOCATE_ONLY. A pure function like this is easy to read and test, which we do later. It returns OK immediately for anything already paid or already resolved (cancelled, expired, or fulfilled). For an UNCONFIRMED order whose channel still has native expiration configured and has not yet aged past it, it also returns OK, because the built-in Celery task will still catch that one. Only once an order is old, unpaid, and stuck in UNFULFILLED, UNCONFIRMED, or PARTIALLY_FULFILLED does it return CANCEL (nothing shipped, safe to cancel outright) or DEALLOCATE_ONLY (part already shipped, must not cancel).
RESOLVED_STATUSES = {"CANCELED", "EXPIRED", "FULFILLED"}
STUCK_STATUSES = {"UNFULFILLED", "UNCONFIRMED", "PARTIALLY_FULFILLED"}
UNPAID_PAYMENT_STATUSES = {"NOT_CHARGED", "REFUNDED", "VOIDED", "CANCELED"}
def classify_stuck_order(order, now, stale_after_hours):
if order["status"] in RESOLVED_STATUSES or order["isPaid"]:
return "OK"
age_hours = (now - order["createdAt"]).total_seconds() / 3600
expire_after = order.get("channelExpireOrdersAfterMin")
if order["status"] == "UNCONFIRMED" and expire_after is not None:
if age_hours * 60 < expire_after:
return "OK" # native expiration will still handle it
if (
age_hours > stale_after_hours
and order["paymentStatus"] in UNPAID_PAYMENT_STATUSES
and order["status"] in STUCK_STATUSES
):
if order["status"] == "PARTIALLY_FULFILLED":
return "DEALLOCATE_ONLY"
return "CANCEL" # UNCONFIRMED or UNFULFILLED, nothing shipped
return "OK"
const RESOLVED_STATUSES = new Set(["CANCELED", "EXPIRED", "FULFILLED"]);
const STUCK_STATUSES = new Set(["UNFULFILLED", "UNCONFIRMED", "PARTIALLY_FULFILLED"]);
const UNPAID_PAYMENT_STATUSES = new Set(["NOT_CHARGED", "REFUNDED", "VOIDED", "CANCELED"]);
export function classifyStuckOrder(order, now, staleAfterHours) {
if (RESOLVED_STATUSES.has(order.status) || order.isPaid) return "OK";
const ageHours = (now.getTime() - new Date(order.createdAt).getTime()) / 3600000;
const expireAfter = order.channelExpireOrdersAfterMin;
if (order.status === "UNCONFIRMED" && expireAfter !== null && expireAfter !== undefined) {
if (ageHours * 60 < expireAfter) return "OK"; // native expiration will still handle it
}
if (
ageHours > staleAfterHours &&
UNPAID_PAYMENT_STATUSES.has(order.paymentStatus) &&
STUCK_STATUSES.has(order.status)
) {
if (order.status === "PARTIALLY_FULFILLED") return "DEALLOCATE_ONLY";
return "CANCEL"; // UNCONFIRMED or UNFULFILLED, nothing shipped
}
return "OK";
}
Cancel the safe ones, only flag the rest
When the decision is CANCEL, call the orderCancel mutation. Saleor's core logic cancels the order and automatically releases every stock allocation tied to its lines, the same thing the native expireOrdersAfter Celery task does for UNCONFIRMED orders. When the decision is DEALLOCATE_ONLY, never call orderCancel. Log it as a flagged order for a human to look at, since a partially fulfilled order has already shipped part of itself and cancelling it outright would orphan that fulfillment.
ORDER_CANCEL = """
mutation($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}"""
def cancel_order(order_id):
result = gql(ORDER_CANCEL, {"id": order_id})["orderCancel"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["status"]
const ORDER_CANCEL = `
mutation($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}`;
async function cancelOrder(orderId) {
const result = (await gql(ORDER_CANCEL, { id: orderId })).orderCancel;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.status;
}
Wire it together with a dry run guard
The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs what it would do: {orderId, number, previousStatus, action}. Only when DRY_RUN=false does it actually call orderCancel for the CANCEL tier. The DEALLOCATE_ONLY tier never calls a mutation from this script at all, it defaults to flag-only and stays that way until a human confirms which unfulfilled lines to release.
Always start with DRY_RUN=true and read the report before flipping it off. Cancelling an order is a real, visible action for the customer and it releases stock other shoppers can then buy, so only automate the tier that is provably safe, orders where nothing has shipped, and leave partially fulfilled orders to a person every time.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through orders, classifies each with the pure function, cancels the safe tier under DRY_RUN=false, and only ever reports the partially fulfilled tier for manual review.
"""Find Saleor orders that are old, unpaid, and still holding a stock
allocation nothing is going to release, because expireOrdersAfter defaults
to null and only ever covers UNCONFIRMED orders with no payment attached
(see saleor/saleor#11257, Order Expiration and Order Status docs).
This script never cancels a partially fulfilled order. Under DRY_RUN=true
(the default) it only logs what it would do. When DRY_RUN=false, it calls
orderCancel for UNCONFIRMED or fully UNFULFILLED orders, which releases
every allocation on the order as a side effect. PARTIALLY_FULFILLED orders
are only ever flagged for a human, never auto-cancelled. Run on a schedule.
Safe to run again and again.
"""
import os
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_stuck_orders")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
STALE_AFTER_HOURS = float(os.environ.get("STALE_AFTER_HOURS", "72"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RESOLVED_STATUSES = {"CANCELED", "EXPIRED", "FULFILLED"}
STUCK_STATUSES = {"UNFULFILLED", "UNCONFIRMED", "PARTIALLY_FULFILLED"}
UNPAID_PAYMENT_STATUSES = {"NOT_CHARGED", "REFUNDED", "VOIDED", "CANCELED"}
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor, sortBy: { field: CREATION_DATE, direction: ASC }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
isPaid
paymentStatus
created
channel { slug orderSettings { expireOrdersAfter } }
lines { id quantity quantityFulfilled variant { id } }
}
}
}
}"""
ORDER_CANCEL = """
mutation($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}"""
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 classify_stuck_order(order, now, stale_after_hours):
if order["status"] in RESOLVED_STATUSES or order["isPaid"]:
return "OK"
age_hours = (now - order["createdAt"]).total_seconds() / 3600
expire_after = order.get("channelExpireOrdersAfterMin")
if order["status"] == "UNCONFIRMED" and expire_after is not None:
if age_hours * 60 < expire_after:
return "OK" # native expiration will still handle it
if (
age_hours > stale_after_hours
and order["paymentStatus"] in UNPAID_PAYMENT_STATUSES
and order["status"] in STUCK_STATUSES
):
if order["status"] == "PARTIALLY_FULFILLED":
return "DEALLOCATE_ONLY"
return "CANCEL" # UNCONFIRMED or UNFULFILLED, nothing shipped
return "OK"
def has_open_allocation(order):
for line in order.get("lines") or []:
if line["quantity"] > line["quantityFulfilled"]:
return True
return False
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def cancel_order(order_id):
result = gql(ORDER_CANCEL, {"id": order_id})["orderCancel"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["status"]
def to_plain(node):
channel = node.get("channel") or {}
settings = channel.get("orderSettings") or {}
return {
"id": node["id"],
"number": node["number"],
"status": node["status"],
"isPaid": node["isPaid"],
"paymentStatus": node["paymentStatus"],
"createdAt": datetime.datetime.fromisoformat(node["created"].replace("Z", "+00:00")),
"channelExpireOrdersAfterMin": settings.get("expireOrdersAfter"),
"lines": node["lines"],
}
def run():
now = datetime.datetime.now(datetime.timezone.utc)
cancelled = 0
flagged = 0
for node in all_orders():
order = to_plain(node)
if not has_open_allocation(order):
continue
decision = classify_stuck_order(order, now, STALE_AFTER_HOURS)
if decision == "OK":
continue
log.info(
"%s",
{
"orderId": order["id"],
"number": order["number"],
"previousStatus": order["status"],
"action": "orderCancel" if decision == "CANCEL" else "flag_for_review",
},
)
if decision == "CANCEL":
if not DRY_RUN:
cancel_order(order["id"])
cancelled += 1
else:
flagged += 1
log.info(
"Done. %d order(s) %s, %d order(s) flagged for human review.",
cancelled, "to cancel" if DRY_RUN else "cancelled", flagged,
)
if __name__ == "__main__":
run()
/**
* Find Saleor orders that are old, unpaid, and still holding a stock
* allocation nothing is going to release, because expireOrdersAfter defaults
* to null and only ever covers UNCONFIRMED orders with no payment attached
* (see saleor/saleor#11257, Order Expiration and Order Status docs).
*
* This script never cancels a partially fulfilled order. Under DRY_RUN=true
* (the default) it only logs what it would do. When DRY_RUN=false, it calls
* orderCancel for UNCONFIRMED or fully UNFULFILLED orders, which releases
* every allocation on the order as a side effect. PARTIALLY_FULFILLED orders
* are only ever flagged for a human, never auto-cancelled. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/unpaid-orders-retain-allocated-stock/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const STALE_AFTER_HOURS = Number(process.env.STALE_AFTER_HOURS || 72);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RESOLVED_STATUSES = new Set(["CANCELED", "EXPIRED", "FULFILLED"]);
const STUCK_STATUSES = new Set(["UNFULFILLED", "UNCONFIRMED", "PARTIALLY_FULFILLED"]);
const UNPAID_PAYMENT_STATUSES = new Set(["NOT_CHARGED", "REFUNDED", "VOIDED", "CANCELED"]);
export function classifyStuckOrder(order, now, staleAfterHours) {
if (RESOLVED_STATUSES.has(order.status) || order.isPaid) return "OK";
const ageHours = (now.getTime() - new Date(order.createdAt).getTime()) / 3600000;
const expireAfter = order.channelExpireOrdersAfterMin;
if (order.status === "UNCONFIRMED" && expireAfter !== null && expireAfter !== undefined) {
if (ageHours * 60 < expireAfter) return "OK"; // native expiration will still handle it
}
if (
ageHours > staleAfterHours &&
UNPAID_PAYMENT_STATUSES.has(order.paymentStatus) &&
STUCK_STATUSES.has(order.status)
) {
if (order.status === "PARTIALLY_FULFILLED") return "DEALLOCATE_ONLY";
return "CANCEL"; // UNCONFIRMED or UNFULFILLED, nothing shipped
}
return "OK";
}
export function hasOpenAllocation(order) {
return (order.lines || []).some((line) => line.quantity > line.quantityFulfilled);
}
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) {
orders(first: 50, after: $cursor, sortBy: { field: CREATION_DATE, direction: ASC }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
isPaid
paymentStatus
created
channel { slug orderSettings { expireOrdersAfter } }
lines { id quantity quantityFulfilled variant { id } }
}
}
}
}`;
const ORDER_CANCEL = `
mutation($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function cancelOrder(orderId) {
const result = (await gql(ORDER_CANCEL, { id: orderId })).orderCancel;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.status;
}
function toPlain(node) {
const settings = node.channel?.orderSettings || {};
return {
id: node.id,
number: node.number,
status: node.status,
isPaid: node.isPaid,
paymentStatus: node.paymentStatus,
createdAt: node.created,
channelExpireOrdersAfterMin: settings.expireOrdersAfter ?? null,
lines: node.lines,
};
}
export async function run() {
const now = new Date();
let cancelled = 0;
let flagged = 0;
for await (const node of allOrders()) {
const order = toPlain(node);
if (!hasOpenAllocation(order)) continue;
const decision = classifyStuckOrder(order, now, STALE_AFTER_HOURS);
if (decision === "OK") continue;
console.log({
orderId: order.id,
number: order.number,
previousStatus: order.status,
action: decision === "CANCEL" ? "orderCancel" : "flag_for_review",
});
if (decision === "CANCEL") {
if (!DRY_RUN) await cancelOrder(order.id);
cancelled++;
} else {
flagged++;
}
}
console.log(
`Done. ${cancelled} order(s) ${DRY_RUN ? "to cancel" : "cancelled"}, ${flagged} order(s) flagged for human review.`
);
}
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 orders lose their stock allocation. Because classify_stuck_order is pure, taking the current time as an argument instead of reading the clock itself, the test needs no network and no Saleor account. It just feeds in plain order objects and a fixed timestamp and checks the answer.
import datetime
from reconcile_stuck_orders import classify_stuck_order
NOW = datetime.datetime(2026, 7, 10, tzinfo=datetime.timezone.utc)
def order(**over):
base = {
"status": "UNFULFILLED",
"isPaid": False,
"paymentStatus": "NOT_CHARGED",
"createdAt": NOW - datetime.timedelta(hours=100),
"channelExpireOrdersAfterMin": None,
}
base.update(over)
return base
def test_ok_when_already_paid():
assert classify_stuck_order(order(isPaid=True), NOW, 72) == "OK"
def test_ok_when_already_cancelled():
assert classify_stuck_order(order(status="CANCELED"), NOW, 72) == "OK"
def test_ok_when_recent():
o = order(createdAt=NOW - datetime.timedelta(hours=10))
assert classify_stuck_order(o, NOW, 72) == "OK"
def test_cancel_when_unfulfilled_stale_and_unpaid():
assert classify_stuck_order(order(), NOW, 72) == "CANCEL"
def test_cancel_when_unconfirmed_and_no_native_expiration_configured():
o = order(status="UNCONFIRMED", channelExpireOrdersAfterMin=None)
assert classify_stuck_order(o, NOW, 72) == "CANCEL"
def test_ok_when_unconfirmed_and_native_expiration_has_not_elapsed():
o = order(status="UNCONFIRMED", channelExpireOrdersAfterMin=999999, createdAt=NOW - datetime.timedelta(hours=100))
assert classify_stuck_order(o, NOW, 72) == "OK"
def test_deallocate_only_when_partially_fulfilled():
assert classify_stuck_order(order(status="PARTIALLY_FULFILLED"), NOW, 72) == "DEALLOCATE_ONLY"
def test_ok_when_paid_status_not_unpaid():
o = order(paymentStatus="FULLY_CHARGED")
assert classify_stuck_order(o, NOW, 72) == "OK"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStuckOrder, hasOpenAllocation } from "./reconcile-stuck-orders.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600000).toISOString();
const order = (over = {}) => ({
status: "UNFULFILLED",
isPaid: false,
paymentStatus: "NOT_CHARGED",
createdAt: hoursAgo(100),
channelExpireOrdersAfterMin: null,
...over,
});
test("OK when already paid", () => {
assert.equal(classifyStuckOrder(order({ isPaid: true }), NOW, 72), "OK");
});
test("OK when already cancelled", () => {
assert.equal(classifyStuckOrder(order({ status: "CANCELED" }), NOW, 72), "OK");
});
test("OK when recent", () => {
assert.equal(classifyStuckOrder(order({ createdAt: hoursAgo(10) }), NOW, 72), "OK");
});
test("CANCEL when unfulfilled, stale, and unpaid", () => {
assert.equal(classifyStuckOrder(order(), NOW, 72), "CANCEL");
});
test("CANCEL when unconfirmed and no native expiration configured", () => {
const o = order({ status: "UNCONFIRMED", channelExpireOrdersAfterMin: null });
assert.equal(classifyStuckOrder(o, NOW, 72), "CANCEL");
});
test("OK when unconfirmed and native expiration has not elapsed", () => {
const o = order({ status: "UNCONFIRMED", channelExpireOrdersAfterMin: 999999, createdAt: hoursAgo(100) });
assert.equal(classifyStuckOrder(o, NOW, 72), "OK");
});
test("DEALLOCATE_ONLY when partially fulfilled", () => {
assert.equal(classifyStuckOrder(order({ status: "PARTIALLY_FULFILLED" }), NOW, 72), "DEALLOCATE_ONLY");
});
test("OK when payment status is not one of the unpaid states", () => {
assert.equal(classifyStuckOrder(order({ paymentStatus: "FULLY_CHARGED" }), NOW, 72), "OK");
});
test("hasOpenAllocation is true when a line has unfulfilled quantity", () => {
const withLines = { lines: [{ quantity: 3, quantityFulfilled: 1 }] };
assert.equal(hasOpenAllocation(withLines), true);
});
test("hasOpenAllocation is false when every line is fully fulfilled", () => {
const withLines = { lines: [{ quantity: 3, quantityFulfilled: 3 }] };
assert.equal(hasOpenAllocation(withLines), false);
});
Case studies
A month of dead orders quietly capped a bestseller
A homeware store let customers pay by bank transfer through a custom checkout flow. Some buyers never sent the money, so those orders sat at UNFULFILLED, never touched by expireOrdersAfter because that setting had never been turned on for the channel. Over a month, dozens of these accumulated on the store's top seller, each holding a couple of units.
Running the reconciler in dry run surfaced the whole list in one pass. Every one of them was UNCONFIRMED or fully UNFULFILLED with nothing shipped, so the safe tier cancelled all of them with orderCancel, and the bestseller's real available quantity jumped back to where it should have been.
The order that could not be auto-cancelled, and should not have been
A multi-item order had one line ship early while the customer's card was later declined and the payment voided. The order sat at PARTIALLY_FULFILLED, unpaid, well past the stale threshold, but still holding an allocation on its unshipped line.
The classifier correctly returned DEALLOCATE_ONLY instead of CANCEL, so the script only logged it for review. Support looked at the fulfillment record, decided to release just the unshipped line's stock by hand, and left the already-shipped part of the order alone, exactly the outcome an automatic cancel would have broken.
After this runs on a schedule, an unpaid order stops being a silent hold on your inventory. The orders where nothing shipped get cancelled and their stock goes back into circulation automatically, and the trickier partially fulfilled ones land in front of a person instead of getting auto-cancelled into an orphaned fulfillment. expireOrdersAfter still does its narrow job for fresh UNCONFIRMED orders. This script picks up everything that setting was never going to reach.
FAQ
Why does an unpaid Saleor order still hold my stock?
Saleor allocates warehouse stock the moment an order line is created at checkout completion, but only its own Celery beat task tied to the per-channel expireOrdersAfter setting ever automatically releases that allocation, and only for orders still in UNCONFIRMED status with no payment transaction attached. expireOrdersAfter defaults to null, meaning do not expire any orders, so unless a merchant configures it, the order and its allocation sit untouched.
What does expireOrdersAfter actually control in Saleor?
expireOrdersAfter is a per-channel setting in OrderSettings that tells a Celery beat task how many minutes an UNCONFIRMED order can sit without a payment transaction before Saleor automatically expires it and releases its stock allocation. It only ever applies to orders still at UNCONFIRMED. An order that already moved to UNFULFILLED or PARTIALLY_FULFILLED is outside its reach even if the payment was later voided or refunded.
Is it safe to script a fix for orders stuck holding stock unpaid?
Cancelling is safe only for orders that are still UNCONFIRMED or fully UNFULFILLED, because orderCancel releases every allocation on the order and nothing has shipped yet. A PARTIALLY_FULFILLED order must never be auto-cancelled, since part of it already left the warehouse, so the safe pattern is to flag those for a human to release the unfulfilled remainder by hand.
Related field notes
Citations
On the problem:
- Canceling the orders that haven't been paid. github.com/saleor/saleor/issues/11257
- Saleor Commerce Documentation: Order Expiration. docs.saleor.io/developer/checkout/order-expiration
- Saleor Commerce Documentation: Order Status. docs.saleor.io/developer/checkout/order-status
On the solution:
- Saleor Commerce Documentation: the orderCancel mutation. docs.saleor.io/api-reference/orders/mutations/order-cancel
- Saleor Commerce Documentation: the orders query. docs.saleor.io/api-reference/orders/queries/orders
- Saleor Commerce Documentation: the OrderSettings object. docs.saleor.io/api-reference/miscellaneous/objects/order-settings
Stuck on a tricky one?
If you have a problem in Saleor checkout, stock, channels, 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 free up some stuck stock for you?
If this saved you from a pile of unpaid orders quietly capping your inventory, 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