Diagnostic Orders & Fulfillment
Order stuck unfulfilled after payment succeeds
The card was charged, the transaction settled, isPaid reads true. Everything about the money looks correct. But the order still shows UNFULFILLED, sitting in the queue as if nothing happened, and no amount of staring at the payment record explains why. Here is why Saleor keeps those two things apart and a script that finds the orders truly stuck between them.
order.status in Saleor is driven purely by whether Fulfillment records exist for the order's lines. It has nothing to do with isPaid or paymentStatus. Capturing a payment, whether through orderMarkAsPaid, a TransactionItem charge, or a payment app, only updates the paid or charged state, and if auto-confirmation is on it can move an order from UNCONFIRMED to UNFULFILLED. It never calls the fulfillment logic itself. Only an explicit orderFulfill call, usually triggered by staff in the dashboard or an app reacting to an ORDER_CONFIRMED or ORDER_FULLY_PAID webhook, ever creates a fulfillment. If that webhook is missing, mis-scoped, or the receiving app is down, the order is correctly paid and permanently stuck. Run a small Python or Node.js script that pages through orders, flags the ones that are paid, unfulfilled, and past a staleness threshold, and reports them for staff to fulfill by hand. Full code, tests, and a dry run guard are below.
The problem in plain words
It is easy to assume that once an order is paid, Saleor takes it the rest of the way. It does not. Saleor treats "has this order been paid for" and "has this order been picked, packed, and shipped" as two entirely separate questions with two entirely separate answers, and nothing wires one to the other automatically.
order.status only ever reflects whether Fulfillment records exist against the order's lines. No fulfillment records means UNFULFILLED, some means PARTIALLY_FULFILLED, and all lines covered means FULFILLED. Meanwhile isPaid and paymentStatus only reflect the transaction or payment state. A payment capturing successfully, through orderMarkAsPaid, a TransactionItem charge event, or a payment app's webhook, updates the paid side of the order. If auto-confirmation is enabled for the channel, it can also flip an unconfirmed order from UNCONFIRMED to UNFULFILLED. But it never touches fulfillment. Fulfillment only ever happens when something explicitly calls orderFulfill, and that call has to come from somewhere: a staff member clicking through the dashboard, a shipping or fulfillment app reacting to an ORDER_CONFIRMED or ORDER_FULLY_PAID webhook, or a custom automation you built yourself. If that webhook subscription was never set up, is scoped to the wrong events, is erroring silently, or the app that receives it is simply down, nothing ever calls orderFulfill. The order sits there, correctly paid, permanently UNFULFILLED, with no built-in retry or timeout to rescue it.
Why it happens
order.statusis computed only from the existence ofFulfillmentrecords on the order's lines. It has no dependency onisPaid,paymentStatus, or any charge or authorize state.- Marking an order paid, whether via
orderMarkAsPaid, a settledTransactionItemcharge, or a payment app webhook, only updates the paid or charged fields on the order. With auto-confirmation on for the channel it can move the order fromUNCONFIRMEDtoUNFULFILLED, but that is the full extent of what it touches. - The only path into a
Fulfillmentrecord is an explicit call to theorderFulfillmutation. That call typically comes from staff in the dashboard, or from a shipping or fulfillment app that subscribed toORDER_CONFIRMEDorORDER_FULLY_PAIDasync webhooks and reacts to them. - If that webhook subscription was never configured, is scoped to the wrong channel or event, throws an error the app swallows silently, or the receiving app's endpoint is simply offline, nothing ever calls
orderFulfillfor that order. There is no built-in timeout, retry queue, or reconciliation job in Saleor core that revisits a paid order and fulfills it later.
None of this raises an error anywhere visible. The order looks completely normal in every payment report, correctly charged and confirmed, while quietly sitting in the fulfillment queue forever. Staff usually discover the backlog only when a customer asks where their order is. See the citations at the end for the exact GitHub thread and the order object docs.
You cannot fix this by watching isPaid. Being paid was never the problem, and it will never become FULFILLED on its own no matter how long you wait. The only thing that ever changes order.status is a fulfillment record actually being created, so the fix has to either create one on purpose or, far more safely, surface the gap to a human who can create it correctly. Auto-creating a fulfillment for an order nobody has picked or packed risks shipping something that was never staged, so the default here is flag and report, not auto-fulfill.
The fix, as a flow
The script runs on a schedule. It pages through recent orders, reads the payment state and the fulfillment list for each, and runs a single pure function to decide whether the order is genuinely stuck. A stuck order gets written to a report, a queue, or a Slack webhook for staff triage. Nothing gets auto-fulfilled unless a team has explicitly opted into that for a narrow, known-safe scenario, and even then it stays behind a dry run guard.
Build it step by step
Get an app token with order read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders. If you later opt into the guarded auto-repair, it also needs permission to manage orders since that path calls orderFulfill. 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_MINUTES="30"
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_MINUTES="30"
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: {channels, updatedAt|created}) and read back id, number, status, isPaid, paymentStatus, created, updatedAt, and each order's fulfillments { id status }. 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
updatedAt
fulfillments { id status }
}
}
}
}"""
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
updatedAt
fulfillments { id status }
}
}
}
}`;
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 {stuck, reason}. A pure function like this is easy to read and test, which we do later. It returns not stuck for anything that already moved past UNFULFILLED, for anything not actually paid, for anything that already has an active (non-cancelled) fulfillment, and for anything still inside the normal staff-processing window. Only when all four checks pass, meaning the order is unfulfilled, paid, has no active fulfillment, and is older than the staleness threshold, does it come back stuck: true.
PAID_CHARGE_STATUSES = {"FULLY_CHARGED", "PARTIALLY_CHARGED"}
def classify_stuck_order(status, is_paid, payment_charge_status, fulfillments,
updated_at, now, stale_minutes=30):
if status != "UNFULFILLED":
return {"stuck": False, "reason": "not_unfulfilled"}
paid = bool(is_paid) or payment_charge_status in PAID_CHARGE_STATUSES
if not paid:
return {"stuck": False, "reason": "not_paid"}
active_fulfillments = [f for f in (fulfillments or []) if f.get("status") != "CANCELED"]
if active_fulfillments:
return {"stuck": False, "reason": "has_active_fulfillment"}
age_minutes = (now - updated_at).total_seconds() / 60
if age_minutes < stale_minutes:
return {"stuck": False, "reason": "within_processing_window"}
return {"stuck": True, "reason": "paid_but_no_fulfillment_past_threshold"}
const PAID_CHARGE_STATUSES = new Set(["FULLY_CHARGED", "PARTIALLY_CHARGED"]);
export function classifyStuckOrder({ status, isPaid, paymentChargeStatus, fulfillments,
updatedAtIso, nowIso, staleMinutes = 30 }) {
if (status !== "UNFULFILLED") return { stuck: false, reason: "not_unfulfilled" };
const paid = isPaid === true || PAID_CHARGE_STATUSES.has(paymentChargeStatus);
if (!paid) return { stuck: false, reason: "not_paid" };
const activeFulfillments = (fulfillments || []).filter((f) => f.status !== "CANCELED");
if (activeFulfillments.length > 0) return { stuck: false, reason: "has_active_fulfillment" };
const ageMinutes = (new Date(nowIso).getTime() - new Date(updatedAtIso).getTime()) / 60000;
if (ageMinutes < staleMinutes) return { stuck: false, reason: "within_processing_window" };
return { stuck: true, reason: "paid_but_no_fulfillment_past_threshold" };
}
Report stuck orders, do not auto-fulfill by default
When an order is stuck, write a report entry with id, number, channel, paidAmount, and ageMinutes, for a queue, a ticket, or a Slack webhook. Do not call orderFulfill automatically. Only if a team explicitly opts into auto-repair for a known-safe scenario, such as digital or gift-card-only orders where auto-fulfill is intentionally configured, should the corrective mutation run, and even then only under DRY_RUN=false with stock pulled fresh for that variant.
# Optional, opt-in only. Not called by the default flag-and-report flow.
ORDER_FULFILL = """
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}"""
def fulfill_order(order_id, lines):
result = gql(ORDER_FULFILL, {"order": order_id, "input": {"lines": lines, "notifyCustomer": True}})["orderFulfill"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["fulfillments"]
// Optional, opt-in only. Not called by the default flag-and-report flow.
const ORDER_FULFILL = `
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}`;
async function fulfillOrder(orderId, lines) {
const result = (await gql(ORDER_FULFILL, { order: orderId, input: { lines, notifyCustomer: true } })).orderFulfill;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.fulfillments;
}
Wire it together with a dry run guard
The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs a report entry for each stuck order: {orderId, number, paidAmount, ageMinutes}. It never calls orderFulfill from the default path at all, since deciding what was actually picked and packed needs a person. Run it on a schedule that matches how quickly staff want to catch a stuck order, for example every fifteen minutes.
This script's default behavior is report-only, and it should stay that way for almost every store. Auto-creating a fulfillment risks shipping something nobody has physically prepared. Only wire in the guarded orderFulfill path for a scenario your team has explicitly decided is safe, and always keep DRY_RUN=true until you have reviewed the exact list it would touch.
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, and reports every stuck order for staff triage. The auto-fulfill path stays commented out by default, since flagging is the safe behavior for this issue.
"""Flag Saleor orders that are paid but still UNFULFILLED because nothing
ever called orderFulfill, because order.status is driven only by whether a
Fulfillment record exists, completely decoupled from isPaid or paymentStatus
(see saleor/saleor#4794, the Order object docs, and the OrderFilterInput docs).
This script never calls orderFulfill by default. Under DRY_RUN=true (the
default) it only logs a report entry for each stuck order for staff triage.
The guarded auto-repair path (fulfill_order) is opt-in only, meant for a
narrow, explicitly configured scenario, and should only ever run with fresh
stock data and DRY_RUN=false. 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("flag_stuck_unfulfilled")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
STALE_AFTER_MINUTES = float(os.environ.get("STALE_AFTER_MINUTES", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAID_CHARGE_STATUSES = {"FULLY_CHARGED", "PARTIALLY_CHARGED"}
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
channel { slug }
total { gross { amount currency } }
totalCharged { amount }
created
updatedAt
fulfillments { id status }
}
}
}
}"""
# Optional, opt-in only. Not called by the default flag-and-report flow.
ORDER_FULFILL = """
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
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 classify_stuck_order(status, is_paid, payment_charge_status, fulfillments,
updated_at, now, stale_minutes=30):
if status != "UNFULFILLED":
return {"stuck": False, "reason": "not_unfulfilled"}
paid = bool(is_paid) or payment_charge_status in PAID_CHARGE_STATUSES
if not paid:
return {"stuck": False, "reason": "not_paid"}
active_fulfillments = [f for f in (fulfillments or []) if f.get("status") != "CANCELED"]
if active_fulfillments:
return {"stuck": False, "reason": "has_active_fulfillment"}
age_minutes = (now - updated_at).total_seconds() / 60
if age_minutes < stale_minutes:
return {"stuck": False, "reason": "within_processing_window"}
return {"stuck": True, "reason": "paid_but_no_fulfillment_past_threshold"}
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 fulfill_order(order_id, lines):
"""Opt-in only. Never called by run(). Wire in yourself for a narrow,
explicitly configured scenario, always with fresh stock data."""
result = gql(ORDER_FULFILL, {"order": order_id, "input": {"lines": lines, "notifyCustomer": True}})["orderFulfill"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["fulfillments"]
def to_plain(node):
return {
"id": node["id"],
"number": node["number"],
"status": node["status"],
"isPaid": node["isPaid"],
"paymentStatus": node["paymentStatus"],
"channel": (node.get("channel") or {}).get("slug"),
"paidAmount": (node.get("totalCharged") or {}).get("amount"),
"updatedAt": datetime.datetime.fromisoformat(node["updatedAt"].replace("Z", "+00:00")),
}
def run():
now = datetime.datetime.now(datetime.timezone.utc)
flagged = 0
for node in all_orders():
order = to_plain(node)
decision = classify_stuck_order(
order["status"], order["isPaid"], order["paymentStatus"],
node["fulfillments"], order["updatedAt"], now, STALE_AFTER_MINUTES,
)
if not decision["stuck"]:
continue
age_minutes = (now - order["updatedAt"]).total_seconds() / 60
report_entry = {
"orderId": order["id"],
"number": order["number"],
"channel": order["channel"],
"paidAmount": order["paidAmount"],
"ageMinutes": round(age_minutes, 1),
}
log.warning("Stuck order found. %s %s", report_entry, "(dry run, reporting only)" if DRY_RUN else "(reporting only)")
flagged += 1
log.info("Done. %d stuck order(s) flagged for staff triage.", flagged)
if __name__ == "__main__":
run()
/**
* Flag Saleor orders that are paid but still UNFULFILLED because nothing
* ever called orderFulfill, because order.status is driven only by whether a
* Fulfillment record exists, completely decoupled from isPaid or paymentStatus
* (see saleor/saleor#4794, the Order object docs, and the OrderFilterInput docs).
*
* This script never calls orderFulfill by default. Under DRY_RUN=true (the
* default) it only logs a report entry for each stuck order for staff triage.
* The guarded auto-repair path (fulfillOrder) is opt-in only, meant for a
* narrow, explicitly configured scenario, and should only ever run with fresh
* stock data and DRY_RUN=false. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/order-stuck-unfulfilled-after-payment/
*/
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_MINUTES = Number(process.env.STALE_AFTER_MINUTES || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAID_CHARGE_STATUSES = new Set(["FULLY_CHARGED", "PARTIALLY_CHARGED"]);
export function classifyStuckOrder({ status, isPaid, paymentChargeStatus, fulfillments,
updatedAtIso, nowIso, staleMinutes = 30 }) {
if (status !== "UNFULFILLED") return { stuck: false, reason: "not_unfulfilled" };
const paid = isPaid === true || PAID_CHARGE_STATUSES.has(paymentChargeStatus);
if (!paid) return { stuck: false, reason: "not_paid" };
const activeFulfillments = (fulfillments || []).filter((f) => f.status !== "CANCELED");
if (activeFulfillments.length > 0) return { stuck: false, reason: "has_active_fulfillment" };
const ageMinutes = (new Date(nowIso).getTime() - new Date(updatedAtIso).getTime()) / 60000;
if (ageMinutes < staleMinutes) return { stuck: false, reason: "within_processing_window" };
return { stuck: true, reason: "paid_but_no_fulfillment_past_threshold" };
}
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
channel { slug }
total { gross { amount currency } }
totalCharged { amount }
created
updatedAt
fulfillments { id status }
}
}
}
}`;
// Optional, opt-in only. Not called by the default flag-and-report flow.
const ORDER_FULFILL = `
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}`;
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;
}
}
// Opt-in only. Never called by run(). Wire in yourself for a narrow,
// explicitly configured scenario, always with fresh stock data.
async function fulfillOrder(orderId, lines) {
const result = (await gql(ORDER_FULFILL, { order: orderId, input: { lines, notifyCustomer: true } })).orderFulfill;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.fulfillments;
}
function toPlain(node) {
return {
id: node.id,
number: node.number,
status: node.status,
isPaid: node.isPaid,
paymentStatus: node.paymentStatus,
channel: node.channel?.slug ?? null,
paidAmount: node.totalCharged?.amount ?? null,
updatedAtIso: node.updatedAt,
};
}
export async function run() {
const nowIso = new Date().toISOString();
let flagged = 0;
for await (const node of allOrders()) {
const order = toPlain(node);
const decision = classifyStuckOrder({
status: order.status,
isPaid: order.isPaid,
paymentChargeStatus: order.paymentStatus,
fulfillments: node.fulfillments,
updatedAtIso: order.updatedAtIso,
nowIso,
staleMinutes: STALE_AFTER_MINUTES,
});
if (!decision.stuck) continue;
const ageMinutes = (new Date(nowIso).getTime() - new Date(order.updatedAtIso).getTime()) / 60000;
const reportEntry = {
orderId: order.id,
number: order.number,
channel: order.channel,
paidAmount: order.paidAmount,
ageMinutes: Math.round(ageMinutes * 10) / 10,
};
console.warn("Stuck order found.", reportEntry, DRY_RUN ? "(dry run, reporting only)" : "(reporting only)");
flagged++;
}
console.log(`Done. ${flagged} stuck order(s) flagged for staff triage.`);
}
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 get reported as stuck. 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 values and checks the answer.
import datetime
from flag_stuck_unfulfilled import classify_stuck_order
NOW = datetime.datetime(2026, 7, 10, tzinfo=datetime.timezone.utc)
def call(**over):
base = {
"status": "UNFULFILLED",
"is_paid": True,
"payment_charge_status": "FULLY_CHARGED",
"fulfillments": [],
"updated_at": NOW - datetime.timedelta(minutes=60),
"now": NOW,
"stale_minutes": 30,
}
base.update(over)
return classify_stuck_order(
base["status"], base["is_paid"], base["payment_charge_status"],
base["fulfillments"], base["updated_at"], base["now"], base["stale_minutes"],
)
def test_stuck_when_paid_unfulfilled_no_fulfillment_and_stale():
result = call()
assert result["stuck"] is True
assert result["reason"] == "paid_but_no_fulfillment_past_threshold"
def test_not_stuck_when_status_is_not_unfulfilled():
result = call(status="FULFILLED")
assert result == {"stuck": False, "reason": "not_unfulfilled"}
def test_not_stuck_when_not_paid():
result = call(is_paid=False, payment_charge_status="NOT_CHARGED")
assert result == {"stuck": False, "reason": "not_paid"}
def test_paid_via_partially_charged_still_counts_as_paid():
result = call(is_paid=False, payment_charge_status="PARTIALLY_CHARGED")
assert result["stuck"] is True
def test_not_stuck_when_active_fulfillment_exists():
result = call(fulfillments=[{"id": "Zg==", "status": "FULFILLED"}])
assert result == {"stuck": False, "reason": "has_active_fulfillment"}
def test_stuck_when_only_fulfillment_is_cancelled():
result = call(fulfillments=[{"id": "Zg==", "status": "CANCELED"}])
assert result["stuck"] is True
def test_not_stuck_within_processing_window():
result = call(updated_at=NOW - datetime.timedelta(minutes=5))
assert result == {"stuck": False, "reason": "within_processing_window"}
def test_exactly_at_threshold_is_stuck():
result = call(updated_at=NOW - datetime.timedelta(minutes=30))
assert result["stuck"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStuckOrder } from "./flag-stuck-unfulfilled.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const minutesAgo = (m) => new Date(NOW.getTime() - m * 60000).toISOString();
const call = (over = {}) => classifyStuckOrder({
status: "UNFULFILLED",
isPaid: true,
paymentChargeStatus: "FULLY_CHARGED",
fulfillments: [],
updatedAtIso: minutesAgo(60),
nowIso: NOW.toISOString(),
staleMinutes: 30,
...over,
});
test("stuck when paid, unfulfilled, no fulfillment, and stale", () => {
const result = call();
assert.equal(result.stuck, true);
assert.equal(result.reason, "paid_but_no_fulfillment_past_threshold");
});
test("not stuck when status is not UNFULFILLED", () => {
assert.deepEqual(call({ status: "FULFILLED" }), { stuck: false, reason: "not_unfulfilled" });
});
test("not stuck when not paid", () => {
const result = call({ isPaid: false, paymentChargeStatus: "NOT_CHARGED" });
assert.deepEqual(result, { stuck: false, reason: "not_paid" });
});
test("paid via PARTIALLY_CHARGED still counts as paid", () => {
const result = call({ isPaid: false, paymentChargeStatus: "PARTIALLY_CHARGED" });
assert.equal(result.stuck, true);
});
test("not stuck when an active fulfillment exists", () => {
const result = call({ fulfillments: [{ id: "Zg==", status: "FULFILLED" }] });
assert.deepEqual(result, { stuck: false, reason: "has_active_fulfillment" });
});
test("stuck when the only fulfillment is cancelled", () => {
const result = call({ fulfillments: [{ id: "Zg==", status: "CANCELED" }] });
assert.equal(result.stuck, true);
});
test("not stuck within the normal processing window", () => {
const result = call({ updatedAtIso: minutesAgo(5) });
assert.deepEqual(result, { stuck: false, reason: "within_processing_window" });
});
test("exactly at the staleness threshold is stuck", () => {
const result = call({ updatedAtIso: minutesAgo(30) });
assert.equal(result.stuck, true);
});
Case studies
A weekend outage quietly stalled every new order
A store's warehouse management system subscribed to ORDER_FULLY_PAID to trigger its own pick-and-pack workflow, which then called orderFulfill back into Saleor. The WMS had a weekend outage nobody noticed until Monday. Every order paid during that window sat correctly charged and completely UNFULFILLED, and nothing in Saleor itself ever flagged the gap.
Running the flag script against the affected window surfaced the exact list in one pass, each with its paid amount and age. Staff worked through the report by hand, fulfilling every order through the dashboard once the WMS was back up, and added an alert on the webhook delivery failures so the next outage gets caught in minutes instead of days.
The subscription that only ever covered one channel
A merchant added a second sales channel for a wholesale storefront but never noticed their fulfillment app's webhook subscription was still scoped to the original channel only. Orders on the new channel paid normally, isPaid flipped to true exactly as expected, but no orderFulfill call ever fired for any of them.
The team ran the flag script on a fifteen minute schedule as a safety net across every channel. It caught the first handful of stuck wholesale orders the same day they were placed, well before a customer had to ask where their order was, which is what led the team to find and fix the missing channel scope on the webhook itself.
After this runs on a schedule, a paid order that never got fulfilled stops being invisible. It shows up in a report within minutes of crossing the staleness threshold, with its channel, paid amount, and age right there for whoever triages it. Nothing gets auto-fulfilled behind anyone's back, since the only thing that changes order.status is a real fulfillment record, and deciding when to create one stays with a person who knows what was actually picked and packed.
FAQ
Why is my Saleor order still UNFULFILLED after the payment is fully charged?
Because order.status in Saleor is driven purely by whether Fulfillment records exist for the order's lines, completely decoupled from isPaid or paymentStatus. Capturing a payment, whether through orderMarkAsPaid, a TransactionItem charge, or a payment app, only updates the paid or charged state. It never itself calls the fulfillment logic. Something has to explicitly call orderFulfill, and if the webhook or app meant to trigger that is missing or down, the order sits paid and unfulfilled indefinitely.
What actually triggers order fulfillment in Saleor?
Only an explicit call to the orderFulfill mutation moves an order's lines into a Fulfillment record. That call can come from staff in the dashboard, a shipping or fulfillment app reacting to an ORDER_CONFIRMED or ORDER_FULLY_PAID async webhook, or a custom automation. Payment capture never triggers it directly, so a missing or erroring webhook subscription leaves the order paid but permanently unfulfilled with no built-in retry.
Is it safe to auto-fulfill orders that are stuck paid and unfulfilled?
Not by default. Auto-creating a fulfillment risks shipping items nobody has actually picked or packed, so the safe pattern is to flag stuck orders for staff triage rather than auto-fulfill them. Only gate an automatic orderFulfill call behind DRY_RUN for a narrow, explicitly opted-in scenario, such as digital or gift-card-only orders, and always pull fresh stock quantities before writing so it never fulfills more than is in stock.
Related field notes
Citations
On the problem:
- Order status remains as UNFULFILLED for dummy payment method. github.com/saleor/saleor/issues/4794
- Cash on delivery: Allow creating orders with the payment status of NOT_CHARGED. github.com/saleor/saleor/discussions/14429
- Canceling the orders that haven't been paid. github.com/saleor/saleor/issues/11257
On the solution:
- Saleor Commerce Documentation: the orderFulfill mutation. docs.saleor.io/api-reference/orders/mutations/order-fulfill
- Saleor Commerce Documentation: the Order object. docs.saleor.io/api-reference/orders/objects/order
- Saleor Commerce Documentation: the OrderFilterInput input type. docs.saleor.io/api-reference/orders/inputs/order-filter-input
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 catch a pile of stuck orders for you?
If this saved you from a customer asking where their order is before you did, 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