Diagnostic Orders & Fulfillment
Editing a confirmed order reverts it to draft status
Someone fixes a shipping address or tweaks a line on an order that is already confirmed and paid. Nothing looks wrong in the moment. But the order's status silently flips back to DRAFT, and from that second on it is invisible to every normal fulfillment queue, even though the money is already sitting in a Payment or TransactionItem record. Here is why Saleor lets this happen and a script that finds the orders it happened to.
Only orders created with draftOrderCreate are meant to start in DRAFT and stay freely editable. Once an order is placed and confirmed it should move through UNCONFIRMED to UNFULFILLED and become effectively line-locked. Historically, the dashboard and API reused the same draft-order line and address mutations against placed orders, so calling them on a confirmed order can silently set order.status back to DRAFT instead of rejecting the edit. Because the OrderStatusFilter enum used by the orders query has no DRAFT value, that order then vanishes from every normal UNFULFILLED or READY_TO_FULFILL queue, even though it still has a Payment or TransactionItem record attached. Run a small Python or Node.js script that pages through every order with no status filter, reads the raw status field client-side, and flags any order sitting at DRAFT that also carries an active or charged payment or a transaction. Full code, tests, and a dry run guard are below.
The problem in plain words
Saleor's order model treats DRAFT as a starting point, not a state a real order should ever fall back into. A draft order created by staff through draftOrderCreate is meant to be edited freely, its lines added and removed, its address changed, right up until draftOrderComplete turns it into a live order. Once that happens, or once a customer places an order through checkout, the order is expected to move forward through UNCONFIRMED and into UNFULFILLED, and its lines are supposed to become effectively locked against the same casual editing a draft allows.
The trouble is that the dashboard and the API have historically leaned on the same underlying mutations to edit lines and addresses on both draft orders and placed orders. When one of those mutations gets called against an order that is already confirmed, instead of Saleor rejecting the edit or routing it through a proper amendment flow, the order's status field can get silently written back to DRAFT. Nobody clicked anything that said "revert to draft." A staff member just fixed a typo in the shipping address, or removed a line that was ordered by mistake, and the order quietly stopped being a confirmed order at all.
Why it happens
- In Saleor's order model, only orders created through
draftOrderCreateare meant to start inDRAFTand stay freely editable, lines, shipping address, and all, untildraftOrderCompletefinalizes them. - Once an order is placed or confirmed, it is expected to move through
UNCONFIRMEDintoUNFULFILLEDand become effectively line-locked. There is no supported path back toDRAFTfor a real order. - The dashboard and API have historically reused the same draft-order line and address mutations for placed orders. Calling them against a confirmed order can silently flip
order.statusback toDRAFTinstead of rejecting the edit or routing it through a proper amendment flow, a behavior tracked in saleor/saleor#4978. - The customer-facing
OrderStatusFilterenum, used by theordersquery and the staff dashboard queues, has noDRAFTvalue. Any order pushed back intoDRAFTthis way vanishes from the normalUNFULFILLEDandREADY_TO_FULFILLqueues even though it still hasPaymentorTransactionItemrecords attached.
None of this throws an error. The mutation succeeds, the edit applies, and the only visible sign anything happened is a status field nobody was watching. Money was taken on an order nobody is tracking anymore, and it usually only surfaces when a customer asks why their paid order never shipped. See the citations at the end for the exact GitHub threads and the order status docs.
You cannot find these orders by filtering the normal way. Any query restricted to filter: { status: [...] } on the operational statuses will never catch a DRAFT order, because DRAFT is not a member of OrderStatusFilter. Detection has to pull every order, unfiltered, and inspect the raw status field client-side. And there is no safe way to auto-repair a reverted order, since Saleor exposes no reverse of draftOrderComplete. The only forward paths risk double-charging or mis-allocating stock if run blindly, so the right default is to flag these orders for a human to review, not to touch them automatically.
The fix, as a flow
The script runs on a schedule. It pages through every order with no status filter at all, since the filter itself cannot see DRAFT, and reads each order's raw status, payments, and transactions. A single pure function decides whether an order is an orphaned draft, meaning it sits at DRAFT while still carrying a payment or transaction record that a legitimate fresh draft order would never have. Anything flagged gets logged with its id, number, and payment ids for staff review. Nothing gets completed or cancelled automatically.
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. 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 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 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 every order with no status filter
This is the part that is easy to get wrong. OrderStatusFilter has no DRAFT value, so a query restricted with filter: { status: [...] } will never return a reverted order. Ask for orders(first, after) with no status filter at all, and read back id, number, status, created, and each order's payments { id isActive chargeStatus } and transactions { id }. Page with a cursor so the job covers the whole order history, or bound it with a channel and a created date range if your store is large.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
created
payments { id isActive chargeStatus }
transactions { 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: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
created
payments { id isActive chargeStatus }
transactions { 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 the already-fetched order shape and returns true or false, with no network or database calls of its own. A legitimate draft order never accumulates a captured payment before draftOrderComplete runs, so the rule is strict on purpose: the order must be sitting at DRAFT, and it must also have either an active payment whose charge status is not NOT_CHARGED, or at least one transaction. Only that combination is reachable through the edit-reverts-to-draft defect.
def is_orphaned_draft_with_payment(order):
if order.get("status") != "DRAFT":
return False
payments = order.get("payments") or []
has_charged_payment = any(
p.get("isActive") and p.get("chargeStatus") != "NOT_CHARGED" for p in payments
)
has_transaction = len(order.get("transactions") or []) > 0
return has_charged_payment or has_transaction
export function isOrphanedDraftWithPayment(order) {
if (order.status !== "DRAFT") return false;
const payments = order.payments || [];
const hasChargedPayment = payments.some(
(p) => p.isActive && p.chargeStatus !== "NOT_CHARGED"
);
const hasTransaction = (order.transactions || []).length > 0;
return hasChargedPayment || hasTransaction;
}
Report flagged orders, do not auto-repair by default
When an order is flagged, log its id, number, and payment ids for staff review. Do not call draftOrderComplete or orderCancel automatically. There is no safe generic way to "un-revert" an order back to its prior confirmed status, since Saleor exposes no reverse of draftOrderComplete. The only forward paths are draftOrderComplete, which reallocates stock and turns DRAFT back into UNFULFILLED, effectively re-confirming the order, or orderCancel if the edit was erroneous. Since a payment is already attached, calling either blindly risks double-charging or mis-allocating stock.
# Opt-in only. Not called by the default flag-and-report flow. Run only after
# a human has reviewed the line and address diff on the flagged order.
DRAFT_ORDER_COMPLETE = """
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { id status }
errors { field code message }
}
}"""
def complete_draft_order(order_id):
result = gql(DRAFT_ORDER_COMPLETE, {"id": order_id})["draftOrderComplete"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["status"]
// Opt-in only. Not called by the default flag-and-report flow. Run only after
// a human has reviewed the line and address diff on the flagged order.
const DRAFT_ORDER_COMPLETE = `
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { id status }
errors { field code message }
}
}`;
async function completeDraftOrder(orderId) {
const result = (await gql(DRAFT_ORDER_COMPLETE, { id: orderId })).draftOrderComplete;
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 a report entry for each flagged order: {orderId, number, paymentIds}. It never calls draftOrderComplete from the default path, since restoring the order needs a person to confirm the line and address diff first. Only when DRY_RUN=false and an operator has manually reviewed the diff should the guarded path run, and even then it should verify the order's status reads UNFULFILLED afterward before moving on. Run the flag pass on a schedule, for example once an hour.
This script's default behavior is report-only, and it should stay that way for almost every store. There is no safe way to guess whether a reverted order's current lines and address are still what the customer actually wants. Only wire in the guarded draftOrderComplete path after a human has reviewed the diff, 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 every order with no status filter, classifies each with the pure function, and reports every orphaned draft for staff review. The completion path stays commented out by default, since flagging is the safe behavior for this issue.
"""Flag Saleor orders that were reverted to DRAFT by editing a confirmed
order, because the dashboard and API have historically reused draft-order
line and address mutations against placed orders, which can silently write
order.status back to DRAFT instead of rejecting the edit (see
saleor/saleor#4978, saleor/saleor#3987, and the order status docs).
Because OrderStatusFilter has no DRAFT value, these orders are invisible to
any query filtered by status, so this script pages through every order with
no status filter and inspects the raw status field client-side.
This script never calls draftOrderComplete or orderCancel by default. Under
DRY_RUN=true (the default) it only logs a report entry for each flagged
order for staff review. The guarded repair path (complete_draft_order) is
opt-in only, meant to run after a human has reviewed the line and address
diff, and should only ever run with DRY_RUN=false. Run on a schedule. 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("flag_orphaned_drafts")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
created
payments { id isActive chargeStatus }
transactions { id }
}
}
}
}"""
# Opt-in only. Not called by the default flag-and-report flow. Run only after
# a human has reviewed the line and address diff on the flagged order.
DRAFT_ORDER_COMPLETE = """
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { 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 is_orphaned_draft_with_payment(order):
if order.get("status") != "DRAFT":
return False
payments = order.get("payments") or []
has_charged_payment = any(
p.get("isActive") and p.get("chargeStatus") != "NOT_CHARGED" for p in payments
)
has_transaction = len(order.get("transactions") or []) > 0
return has_charged_payment or has_transaction
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 complete_draft_order(order_id):
"""Opt-in only. Never called by run(). Wire in yourself only after a human
has reviewed the line and address diff on the flagged order."""
result = gql(DRAFT_ORDER_COMPLETE, {"id": order_id})["draftOrderComplete"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["status"]
def run():
flagged = 0
for order in all_orders():
if not is_orphaned_draft_with_payment(order):
continue
payment_ids = [p["id"] for p in (order.get("payments") or [])]
report_entry = {
"orderId": order["id"],
"number": order["number"],
"paymentIds": payment_ids,
"transactionCount": len(order.get("transactions") or []),
}
log.warning("Orphaned draft order found. %s %s", report_entry,
"(dry run, reporting only)" if DRY_RUN else "(reporting only)")
flagged += 1
log.info("Done. %d orphaned draft order(s) flagged for staff review.", flagged)
if __name__ == "__main__":
run()
/**
* Flag Saleor orders that were reverted to DRAFT by editing a confirmed
* order, because the dashboard and API have historically reused draft-order
* line and address mutations against placed orders, which can silently write
* order.status back to DRAFT instead of rejecting the edit (see
* saleor/saleor#4978, saleor/saleor#3987, and the order status docs).
*
* Because OrderStatusFilter has no DRAFT value, these orders are invisible to
* any query filtered by status, so this script pages through every order with
* no status filter and inspects the raw status field client-side.
*
* This script never calls draftOrderComplete or orderCancel by default. Under
* DRY_RUN=true (the default) it only logs a report entry for each flagged
* order for staff review. The guarded repair path (completeDraftOrder) is
* opt-in only, meant to run after a human has reviewed the line and address
* diff, and should only ever run with DRY_RUN=false. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/editing-order-reverts-to-draft/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function isOrphanedDraftWithPayment(order) {
if (order.status !== "DRAFT") return false;
const payments = order.payments || [];
const hasChargedPayment = payments.some(
(p) => p.isActive && p.chargeStatus !== "NOT_CHARGED"
);
const hasTransaction = (order.transactions || []).length > 0;
return hasChargedPayment || hasTransaction;
}
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: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
status
created
payments { id isActive chargeStatus }
transactions { id }
}
}
}
}`;
// Opt-in only. Not called by the default flag-and-report flow. Run only after
// a human has reviewed the line and address diff on the flagged order.
const DRAFT_ORDER_COMPLETE = `
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { 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 only after a human has
// reviewed the line and address diff on the flagged order.
async function completeDraftOrder(orderId) {
const result = (await gql(DRAFT_ORDER_COMPLETE, { id: orderId })).draftOrderComplete;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.status;
}
export async function run() {
let flagged = 0;
for await (const order of allOrders()) {
if (!isOrphanedDraftWithPayment(order)) continue;
const paymentIds = (order.payments || []).map((p) => p.id);
const reportEntry = {
orderId: order.id,
number: order.number,
paymentIds,
transactionCount: (order.transactions || []).length,
};
console.warn("Orphaned draft order found.", reportEntry, DRY_RUN ? "(dry run, reporting only)" : "(reporting only)");
flagged++;
}
console.log(`Done. ${flagged} orphaned draft order(s) flagged for staff 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 get reported as orphaned. Because is_orphaned_draft_with_payment is pure, taking only the already-fetched order shape and making no network or database calls of its own, the test needs no Saleor account. It just feeds in plain objects and checks the answer.
from flag_orphaned_drafts import is_orphaned_draft_with_payment
def order(**over):
base = {
"status": "DRAFT",
"payments": [{"id": "UGF5bWVudDox", "isActive": True, "chargeStatus": "FULLY_CHARGED"}],
"transactions": [],
}
base.update(over)
return base
def test_flagged_when_draft_with_charged_active_payment():
assert is_orphaned_draft_with_payment(order()) is True
def test_flagged_when_draft_with_transaction_and_no_payment():
o = order(payments=[], transactions=[{"id": "VHJhbnNhY3Rpb25JdGVtOjE="}])
assert is_orphaned_draft_with_payment(o) is True
def test_not_flagged_when_not_draft():
o = order(status="UNFULFILLED")
assert is_orphaned_draft_with_payment(o) is False
def test_not_flagged_when_draft_with_no_payment_or_transaction():
o = order(payments=[], transactions=[])
assert is_orphaned_draft_with_payment(o) is False
def test_not_flagged_when_payment_is_not_charged():
o = order(payments=[{"id": "UGF5bWVudDox", "isActive": True, "chargeStatus": "NOT_CHARGED"}])
assert is_orphaned_draft_with_payment(o) is False
def test_not_flagged_when_payment_is_inactive():
o = order(payments=[{"id": "UGF5bWVudDox", "isActive": False, "chargeStatus": "FULLY_CHARGED"}])
assert is_orphaned_draft_with_payment(o) is False
def test_flagged_when_multiple_payments_and_only_one_qualifies():
o = order(payments=[
{"id": "UGF5bWVudDox", "isActive": False, "chargeStatus": "FULLY_CHARGED"},
{"id": "UGF5bWVudDoy", "isActive": True, "chargeStatus": "PARTIALLY_CHARGED"},
])
assert is_orphaned_draft_with_payment(o) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrphanedDraftWithPayment } from "./flag-orphaned-drafts.js";
const order = (over = {}) => ({
status: "DRAFT",
payments: [{ id: "UGF5bWVudDox", isActive: true, chargeStatus: "FULLY_CHARGED" }],
transactions: [],
...over,
});
test("flagged when draft with a charged active payment", () => {
assert.equal(isOrphanedDraftWithPayment(order()), true);
});
test("flagged when draft with a transaction and no payment", () => {
const o = order({ payments: [], transactions: [{ id: "VHJhbnNhY3Rpb25JdGVtOjE=" }] });
assert.equal(isOrphanedDraftWithPayment(o), true);
});
test("not flagged when status is not DRAFT", () => {
assert.equal(isOrphanedDraftWithPayment(order({ status: "UNFULFILLED" })), false);
});
test("not flagged when draft has no payment or transaction", () => {
assert.equal(isOrphanedDraftWithPayment(order({ payments: [], transactions: [] })), false);
});
test("not flagged when payment is not charged", () => {
const o = order({ payments: [{ id: "UGF5bWVudDox", isActive: true, chargeStatus: "NOT_CHARGED" }] });
assert.equal(isOrphanedDraftWithPayment(o), false);
});
test("not flagged when payment is inactive", () => {
const o = order({ payments: [{ id: "UGF5bWVudDox", isActive: false, chargeStatus: "FULLY_CHARGED" }] });
assert.equal(isOrphanedDraftWithPayment(o), false);
});
test("flagged when multiple payments and only one qualifies", () => {
const o = order({
payments: [
{ id: "UGF5bWVudDox", isActive: false, chargeStatus: "FULLY_CHARGED" },
{ id: "UGF5bWVudDoy", isActive: true, chargeStatus: "PARTIALLY_CHARGED" },
],
});
assert.equal(isOrphanedDraftWithPayment(o), true);
});
Case studies
A typo fix that quietly unshipped an order
A customer messaged support to fix a misspelled street name on an order that was already paid and confirmed. The support agent used the same address edit screen they used for draft orders every day. The address updated correctly, but the order's status silently became DRAFT, and it dropped out of the fulfillment queue the warehouse worked from that afternoon.
Nobody noticed until the customer asked where their package was, three days past the normal delivery window. Running the flag script against the full order history surfaced it in the very first pass, since it still carried an active, fully charged payment. Staff reviewed the address diff, confirmed it was correct, and completed the order by hand.
Removing a duplicate line reverted the whole order
A warehouse picker flagged a duplicate line on a confirmed order, and an admin removed it using the line-editing mutation they were used to from draft orders. The duplicate was gone, but so was the order's confirmed status, and it sat at DRAFT for a week with a captured payment attached, invisible to every report the finance team ran.
The team started running the flag script hourly as a safety net across every channel. It caught this order and two similar ones within the first day of running, each with its payment ids attached to the log line, and staff completed each one after checking the remaining lines matched what the customer actually paid for.
After this runs on a schedule, an order that got silently reverted to DRAFT stops being invisible. It shows up in a report within an hour of the edit that caused it, with its number and payment ids right there for whoever reviews it. Nothing gets completed or cancelled behind anyone's back, since the only safe way to decide what a reverted order should become is a person who can see exactly what changed and confirm it against what the customer actually paid for.
FAQ
Why does editing a confirmed Saleor order change its status back to DRAFT?
Only orders created through draftOrderCreate are meant to start in DRAFT and stay freely editable. Once an order is placed and confirmed it should move through UNCONFIRMED to UNFULFILLED. The dashboard and API have historically reused the same draft-order line and address mutations against a confirmed order, and calling them against a placed order can silently set order.status back to DRAFT instead of rejecting the edit, even though a payment is already attached.
Why do these reverted orders disappear from the orders queue?
The OrderStatusFilter enum used by the orders query and the staff dashboard queues has no DRAFT value. Any order pushed back into DRAFT this way falls out of the normal UNFULFILLED and READY_TO_FULFILL views even though it still has a Payment or TransactionItem record attached, so the order is still taking up captured money but nobody is tracking it in the usual queues.
Can I just call draftOrderComplete to fix an order that reverted to DRAFT?
Not automatically. Saleor has no reverse of draftOrderComplete, so the only forward paths are draftOrderComplete, which reallocates stock and turns the order back into UNFULFILLED, or orderCancel if the edit was a mistake. Since a payment or transaction is already attached, calling draftOrderComplete blindly risks double charging or mis-allocating stock, so a script should only flag these orders and let a human review the line and address diff before completing or cancelling.
Related field notes
Citations
On the problem:
- Editing order change it's status to DRAFT. github.com/saleor/saleor/issues/4978
- Updating order in dashboard. github.com/saleor/saleor/issues/3987
- Saleor Commerce Documentation: Order Status. docs.saleor.io/developer/checkout/order-status
On the solution:
- Saleor Commerce Documentation: the orderConfirm mutation. docs.saleor.io/api-reference/orders/mutations/order-confirm
- Saleor Commerce Documentation: the OrderStatusFilter enum. docs.saleor.io/api-reference/orders/enums/order-status-filter
- Saleor Commerce Documentation: the Order object. docs.saleor.io/api-reference/orders/objects/order
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 reverted order for you?
If this saved you from a customer chasing a paid order that quietly vanished from the queue, 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