Reconciler Order Edits & Totals
Order edit blocked by a stuck active order change record
You open an order to edit a line item, process a return, or start an exchange, and Medusa refuses with "An active Order Change is required to proceed." The Admin UI shows nothing in progress. No one on the team is editing this order right now. But every attempt fails the same way, on every workflow, forever. Here is why a single orphaned OrderChange row can permanently block an order and a small script that finds and safely clears it.
Medusa v2 enforces a single-active-order-change invariant per order. getActiveOrderChange_() in the Order module looks for any OrderChange row with status pending or requested, and every edit, return, claim, and exchange workflow calls throwIfOrderChangeIsNotActive before it will proceed. If a prior order-edit workflow crashed mid-flight, timed out, or hit a compensation bug before the change reached a terminal status, that row is left behind forever, and it silently blocks every future attempt even though nothing appears to be happening. Run a script that pages through orders with fields=id,display_id,status,*order_change, flags any order_change that is still pending or requested with no confirmed_at, declined_at, or canceled_at and an updated_at older than a staleness window, and cancels only those with the Order module's cancel method. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa v2 models every order edit, return, claim, and exchange as an OrderChange, a record that tracks a proposed set of actions against an order until it is confirmed, declined, or canceled. Only one of these can be active on an order at a time. That is a deliberate invariant, since letting two edits run against the same order at once would make the resulting totals impossible to reason about.
The enforcement point is getActiveOrderChange_(), which simply asks whether any OrderChange row exists for the order with status pending or requested. Every workflow that touches an order's contents calls throwIfOrderChangeIsNotActive as a guard before it starts. Under normal operation, this is invisible. An edit is requested, confirmed, and its OrderChange moves to a terminal status, freeing the order up for the next one.
The trouble starts when a workflow does not finish cleanly. A server restart mid-edit, a timeout, or a regression in the workflow engine's own compensation logic, such as the one reported against the 2.6.1 to 2.7.0 upgrade path, can leave the OrderChange stuck at pending or requested with confirmed_at, declined_at, and canceled_at all still null. Nothing about that row looks urgent in the Admin UI, because there is no live session attached to it anymore. But getActiveOrderChange_() does not know or care that the workflow that created the row is long gone. It only sees a non-terminal status, and it keeps refusing every new edit, return, claim, and exchange on that order with the same message, indefinitely.
Why it happens
The single-active-change invariant itself is correct and necessary. It is the failure to always reach a terminal status that causes the problem. A few common ways stores end up with an orphaned row:
- An admin starts an order edit, the process restarts or the request times out partway through, and the workflow that would have confirmed or canceled the
OrderChangenever runs to completion. - A workflow engine or compensation bug during an upgrade, such as the regression reported between Medusa 2.6.1 and 2.7.0 in GitHub issue #12173, leaves the row in a non-terminal status even though the user-facing action appeared to fail cleanly.
- A return, claim, or exchange is requested through a custom integration that calls the underlying workflow directly and does not handle a failure path, leaving the change dangling with no UI session left to clean it up.
- Order changes reported as broken in earlier versions, such as the issues in GitHub issue #11408, can leave rows in an inconsistent state that later carries forward even after the immediate bug is patched.
Because there is no public cancel-order-change Admin REST endpoint in v2, order-edits are a sub-resource of their own in v1, but v2 exposes edit, return, claim, and exchange only as order-change workflows, not as a generic cancel-by-id route. That means once a row is stuck, there is no button in the Admin UI to clear it. It just sits there, and the same error keeps appearing. See the citations at the end for the exact threads and docs.
An active OrderChange is not something you can safely finish on someone else's behalf. You do not know what actions it was going to apply, and force-confirming it would push an unknown, possibly partial, set of changes onto the order's totals. The only universally safe corrective write is cancellation to a terminal status, since it simply frees the order up for a fresh edit without touching anything the order already has. So the safe pattern is not "confirm whatever is stuck." It is "cancel only the rows that are unmistakably orphaned, and leave anything a human might genuinely be working on alone."
The fix, as a flow
We do not touch a live edit session. The job pages through orders, reads each one's order_change relation, applies a pure classification function that tells fresh, active work apart from a stale, orphaned row, and cancels only the ones that are unmistakably stuck.
Build it step by step
Authenticate against the Admin API
Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STALE_HOURS="2"
export DRY_RUN="true" # start safe, change to false to cancel for real
// Node 18+ has fetch built in, no dependencies needed
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STALE_HOURS="2"
export DRY_RUN="true" // start safe, change to false to cancel for real
List orders with their active order change
Ask for every order with the order_change relation expanded. This is the computed active-change relation Medusa attaches when it retrieves an order, so it is present only when a non-terminal OrderChange actually exists. Page through with limit and offset so the job handles a large order table.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_orders_with_changes(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,display_id,status,*order_change",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL;
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
return res.json();
}
async function listOrdersWithChanges(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,display_id,status,*order_change",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
Decide, with one pure function
Keep the classification in its own function that takes a plain change shape, the current time, and the staleness threshold, and returns one of three answers. It never touches the network, so it is trivial to unit test with fixture data. A change with any terminal timestamp set is always terminal, matching exactly what getActiveOrderChange_ excludes. A change that is still pending or requested but young is active_fresh and must be left alone, since it may be a live edit session. Only a change that is non-terminal and old past the threshold is active_stale_stuck, the one category the script is allowed to act on.
from datetime import datetime, timezone
ACTIVE_STATUSES = {"pending", "requested"}
def classify_order_change(change, now, stale_hours=2):
"""Pure decision function. No I/O.
change: {"status": str, "confirmed_at": str | None, "declined_at": str | None,
"canceled_at": str | None, "updated_at": str}
now: datetime (tz-aware)
stale_hours: float
Returns "active_fresh" | "active_stale_stuck" | "terminal".
"""
if change.get("confirmed_at") or change.get("declined_at") or change.get("canceled_at"):
return "terminal"
if change.get("status") not in ACTIVE_STATUSES:
return "terminal"
updated = _parse_iso(change["updated_at"])
age_hours = (now - updated).total_seconds() / 3600
if age_hours > stale_hours:
return "active_stale_stuck"
return "active_fresh"
def _parse_iso(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
const ACTIVE_STATUSES = new Set(["pending", "requested"]);
/**
* Pure decision function. No I/O.
*
* @param {{ status: string, confirmed_at: string|null, declined_at: string|null,
* canceled_at: string|null, updated_at: string }} change
* @param {Date} now
* @param {number} staleHours
* @returns {"active_fresh" | "active_stale_stuck" | "terminal"}
*/
export function classifyOrderChange(change, now, staleHours = 2) {
if (change.confirmed_at || change.declined_at || change.canceled_at) return "terminal";
if (!ACTIVE_STATUSES.has(change.status)) return "terminal";
const ageMs = now.getTime() - new Date(change.updated_at).getTime();
const ageHours = ageMs / (1000 * 60 * 60);
if (ageHours > staleHours) return "active_stale_stuck";
return "active_fresh";
}
Cancel only the stuck ones, through the Order module
There is no public cancel-order-change Admin REST endpoint in v2, so the repair runs inside a Medusa run() context, such as one invoked through medusa exec, and resolves the Order module service directly. Calling cancel(orderChangeId) sets canceled_at and canceled_by, moving the row to a terminal status. It never touches the order's totals or line items, it only frees the order up for a new edit. Wrap the call in the module's shared transaction context so it commits or rolls back cleanly.
# Run inside a Medusa exec/run() context so the Order module and its
# shared transaction context are already wired up by the container.
#
# from medusa.utils import Modules
#
# async def run(container):
# order_module = container.resolve(Modules.ORDER)
# if not DRY_RUN:
# await order_module.cancel(order_change_id)
def cancel_stuck_change(order_module_service, order_change_id, dry_run=True):
if dry_run:
return {"order_change_id": order_change_id, "would_cancel": True}
order_module_service.cancel(order_change_id)
return {"order_change_id": order_change_id, "canceled": True}
// Run inside a Medusa exec/run() context so the Order module and its
// shared transaction context are already wired up by the container.
//
// import { Modules } from "@medusajs/framework/utils";
//
// export default async function ({ container }) {
// const orderModuleService = container.resolve(Modules.ORDER);
// if (!DRY_RUN) await orderModuleService.cancel(orderChangeId);
// }
export function cancelStuckChange(orderModuleService, orderChangeId, dryRun = true) {
if (dryRun) return { order_change_id: orderChangeId, would_cancel: true };
orderModuleService.cancel(orderChangeId);
return { order_change_id: orderChangeId, canceled: true };
}
Wire it together with a dry run guard
The loop ties every piece together. For each order that has an order_change, classify it, and log the ones that are active_stale_stuck. On the first few runs, leave DRY_RUN on so the script only reports which ordch_* ids it would cancel. Read the output, confirm none of them are live edit sessions, then switch it off. Run it on a schedule that fits how often your team edits orders, for example once a day.
Always start with DRY_RUN=true, and never force-confirm a stuck order change. Cancellation to a terminal status is the only universally safe corrective write, since it just unblocks new edits without altering anything the order already has. Confirming an unknown set of actions could silently change totals you never intended to touch.
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 is safe to run again and again because it only ever cancels an OrderChange that is unmistakably stale and stuck.
"""Find and safely cancel stuck active OrderChange rows on Medusa v2 orders.
Medusa v2 enforces a single-active-order-change invariant per order.
getActiveOrderChange_() looks for any OrderChange with status pending or
requested, and every edit, return, claim, and exchange workflow calls
throwIfOrderChangeIsNotActive before it will proceed. If a prior workflow
crashed, timed out, or hit a compensation bug before the change reached a
terminal status (confirmed_at, declined_at, or canceled_at set), that row
is left behind and silently blocks every future attempt on the order.
This lists orders with their order_change relation, classifies each one
with a pure function, and cancels only the rows that are non-terminal and
stale past a safety window. Never force-confirms a stuck change, since
cancellation to a terminal status is the only universally safe write.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_stuck_order_change")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
STALE_HOURS = float(os.environ.get("STALE_HOURS", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ACTIVE_STATUSES = {"pending", "requested"}
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def _parse_iso(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def classify_order_change(change, now, stale_hours=2):
"""Pure decision function. No I/O.
change: {"status": str, "confirmed_at": str | None, "declined_at": str | None,
"canceled_at": str | None, "updated_at": str}
now: datetime (tz-aware)
stale_hours: float
Returns "active_fresh" | "active_stale_stuck" | "terminal".
"""
if change.get("confirmed_at") or change.get("declined_at") or change.get("canceled_at"):
return "terminal"
if change.get("status") not in ACTIVE_STATUSES:
return "terminal"
updated = _parse_iso(change["updated_at"])
age_hours = (now - updated).total_seconds() / 3600
if age_hours > stale_hours:
return "active_stale_stuck"
return "active_fresh"
def list_orders_with_changes(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,display_id,status,*order_change",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
def find_stuck_changes(orders, now, stale_hours):
stuck = []
for order in orders:
change = order.get("order_change")
if not change:
continue
outcome = classify_order_change(change, now, stale_hours)
if outcome != "active_stale_stuck":
continue
stuck.append({
"order_id": order["id"],
"display_id": order.get("display_id"),
"order_change_id": change["id"],
"status": change["status"],
"updated_at": change["updated_at"],
})
return stuck
def run():
token = get_admin_token()
orders = list_orders_with_changes(token)
now = datetime.now(timezone.utc)
stuck = find_stuck_changes(orders, now, STALE_HOURS)
for row in stuck:
log.warning(
"Order %s (%s) has a stuck %s OrderChange %s. %s",
row["display_id"], row["order_id"], row["status"], row["order_change_id"],
"would cancel" if DRY_RUN else "cancelling",
)
if not DRY_RUN:
# Cancellation has no public Admin REST route in v2. Run this branch
# inside a Medusa exec/run() context and resolve the Order module:
# order_module = container.resolve(Modules.ORDER)
# order_module.cancel(row["order_change_id"])
pass
log.info("Done. %d stuck order change(s) %s.", len(stuck), "to cancel" if DRY_RUN else "cancelled")
if __name__ == "__main__":
run()
/**
* Find and safely cancel stuck active OrderChange rows on Medusa v2 orders.
*
* Medusa v2 enforces a single-active-order-change invariant per order.
* getActiveOrderChange_() looks for any OrderChange with status pending or
* requested, and every edit, return, claim, and exchange workflow calls
* throwIfOrderChangeIsNotActive before it will proceed. If a prior workflow
* crashed, timed out, or hit a compensation bug before the change reached a
* terminal status (confirmed_at, declined_at, or canceled_at set), that row
* is left behind and silently blocks every future attempt on the order.
*
* This lists orders with their order_change relation, classifies each one
* with a pure function, and cancels only the rows that are non-terminal and
* stale past a safety window. Never force-confirms a stuck change, since
* cancellation to a terminal status is the only universally safe write.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/stuck-active-order-change/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const STALE_HOURS = Number(process.env.STALE_HOURS || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_STATUSES = new Set(["pending", "requested"]);
/**
* Pure decision function. No I/O.
*
* @param {{ status: string, confirmed_at: string|null, declined_at: string|null,
* canceled_at: string|null, updated_at: string }} change
* @param {Date} now
* @param {number} staleHours
* @returns {"active_fresh" | "active_stale_stuck" | "terminal"}
*/
export function classifyOrderChange(change, now, staleHours = 2) {
if (change.confirmed_at || change.declined_at || change.canceled_at) return "terminal";
if (!ACTIVE_STATUSES.has(change.status)) return "terminal";
const ageMs = now.getTime() - new Date(change.updated_at).getTime();
const ageHours = ageMs / (1000 * 60 * 60);
if (ageHours > staleHours) return "active_stale_stuck";
return "active_fresh";
}
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
return res.json();
}
async function listOrdersWithChanges(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,display_id,status,*order_change",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
export function findStuckChanges(orders, now, staleHours) {
const stuck = [];
for (const order of orders) {
const change = order.order_change;
if (!change) continue;
const outcome = classifyOrderChange(change, now, staleHours);
if (outcome !== "active_stale_stuck") continue;
stuck.push({
order_id: order.id,
display_id: order.display_id,
order_change_id: change.id,
status: change.status,
updated_at: change.updated_at,
});
}
return stuck;
}
export async function run() {
const token = await getAdminToken();
const orders = await listOrdersWithChanges(token);
const now = new Date();
const stuck = findStuckChanges(orders, now, STALE_HOURS);
for (const row of stuck) {
console.warn(
`Order ${row.display_id} (${row.order_id}) has a stuck ${row.status} OrderChange ${row.order_change_id}. ${DRY_RUN ? "would cancel" : "cancelling"}`
);
if (!DRY_RUN) {
// Cancellation has no public Admin REST route in v2. Run this branch
// inside a Medusa exec/run() context and resolve the Order module:
// const orderModuleService = container.resolve(Modules.ORDER);
// await orderModuleService.cancel(row.order_change_id);
}
}
console.log(`Done. ${stuck.length} stuck order change(s) ${DRY_RUN ? "to cancel" : "cancelled"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
classify_order_change is the part most worth testing, because it decides which orders get unblocked. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures and a fixed clock, and checks the answer.
from datetime import datetime, timezone
from reconcile_stuck_order_change import classify_order_change
NOW = datetime(2026, 7, 10, 12, 0, 0, tzinfo=timezone.utc)
def change(**over):
base = {
"status": "pending",
"confirmed_at": None,
"declined_at": None,
"canceled_at": None,
"updated_at": "2026-07-10T09:00:00Z",
}
base.update(over)
return base
def test_stale_stuck_when_pending_and_old():
assert classify_order_change(change(), NOW, 2) == "active_stale_stuck"
def test_active_fresh_when_pending_and_recent():
result = classify_order_change(change(updated_at="2026-07-10T11:30:00Z"), NOW, 2)
assert result == "active_fresh"
def test_terminal_when_confirmed_at_set():
result = classify_order_change(change(status="confirmed", confirmed_at="2026-07-10T09:00:00Z"), NOW, 2)
assert result == "terminal"
def test_terminal_when_declined_at_set():
result = classify_order_change(change(status="declined", declined_at="2026-07-10T09:00:00Z"), NOW, 2)
assert result == "terminal"
def test_terminal_when_canceled_at_set():
result = classify_order_change(change(status="canceled", canceled_at="2026-07-10T09:00:00Z"), NOW, 2)
assert result == "terminal"
def test_terminal_when_status_not_active():
result = classify_order_change(change(status="confirmed"), NOW, 2)
assert result == "terminal"
def test_exactly_at_threshold_is_not_yet_stale():
result = classify_order_change(change(updated_at="2026-07-10T10:00:00Z"), NOW, 2)
assert result == "active_fresh"
def test_just_past_threshold_is_stale():
result = classify_order_change(change(updated_at="2026-07-10T09:59:59Z"), NOW, 2)
assert result == "active_stale_stuck"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderChange } from "./reconcile-stuck-order-change.js";
const NOW = new Date("2026-07-10T12:00:00Z");
const change = (over = {}) => ({
status: "pending",
confirmed_at: null,
declined_at: null,
canceled_at: null,
updated_at: "2026-07-10T09:00:00Z",
...over,
});
test("stale stuck when pending and old", () => {
assert.equal(classifyOrderChange(change(), NOW, 2), "active_stale_stuck");
});
test("active fresh when pending and recent", () => {
const result = classifyOrderChange(change({ updated_at: "2026-07-10T11:30:00Z" }), NOW, 2);
assert.equal(result, "active_fresh");
});
test("terminal when confirmed_at set", () => {
const result = classifyOrderChange(change({ status: "confirmed", confirmed_at: "2026-07-10T09:00:00Z" }), NOW, 2);
assert.equal(result, "terminal");
});
test("terminal when declined_at set", () => {
const result = classifyOrderChange(change({ status: "declined", declined_at: "2026-07-10T09:00:00Z" }), NOW, 2);
assert.equal(result, "terminal");
});
test("terminal when canceled_at set", () => {
const result = classifyOrderChange(change({ status: "canceled", canceled_at: "2026-07-10T09:00:00Z" }), NOW, 2);
assert.equal(result, "terminal");
});
test("terminal when status not active", () => {
const result = classifyOrderChange(change({ status: "confirmed" }), NOW, 2);
assert.equal(result, "terminal");
});
test("exactly at threshold is not yet stale", () => {
const result = classifyOrderChange(change({ updated_at: "2026-07-10T10:00:00Z" }), NOW, 2);
assert.equal(result, "active_fresh");
});
test("just past threshold is stale", () => {
const result = classifyOrderChange(change({ updated_at: "2026-07-10T09:59:59Z" }), NOW, 2);
assert.equal(result, "active_stale_stuck");
});
Case studies
The order that would not edit after a version bump
A merchant upgraded from Medusa 2.6.1 to 2.7.0 during a routine maintenance window. A handful of orders that had an in-flight edit at the moment of the upgrade came out the other side permanently stuck, refusing every future edit with the same active order change error, even though the Admin UI showed nothing in progress.
Running the reconciler in dry run surfaced exactly six orders with an order_change row untouched for well over the two hour staleness window. The team confirmed none of them had a live session, then reran with DRY_RUN=false to cancel the orphaned rows. All six orders were editable again within minutes, with their existing totals and line items completely untouched.
The return that timed out during a flash sale
During a high-traffic sale, a returns workflow on one order timed out waiting on a slow downstream call. The workflow never reached a terminal status, and support staff spent the next day unable to process a straightforward exchange on that same order, hitting the same cryptic error every time.
The staleness window meant the script never touched the row while it was still fresh, in case the timeout was about to resolve on its own. Once it crossed two hours with no change, it showed up as active_stale_stuck in the report, was cancelled, and the exchange proceeded normally on the next attempt.
After this runs on a schedule, an orphaned OrderChange never sits blocking an order for more than one reconciliation cycle. The script only ever cancels rows that are unmistakably stale and stuck, so a genuine in-progress edit is never interrupted. Order totals and line items are never touched by the repair itself, since cancellation only frees the order up for the next legitimate edit, return, claim, or exchange.
FAQ
Why does Medusa say an active Order Change is required to proceed when nothing is in progress?
Medusa v2 allows only one non-terminal OrderChange per order at a time. Every edit, return, claim, and exchange workflow checks for an existing OrderChange with status pending or requested before it will start. If a prior workflow crashed, timed out, or hit a bug before it reached a terminal status such as confirmed, declined, or canceled, that row is left behind. It still counts as active even though the Admin UI shows nothing in progress, so every new attempt is blocked.
Is it safe to cancel a stuck OrderChange with a script?
Yes, when the script only cancels OrderChange rows that are still pending or requested, have no confirmed_at, declined_at, or canceled_at set, and have gone untouched for longer than a safe staleness window such as two hours. Cancelling moves the row to a terminal status without altering any of the order's existing totals or line items, which is the only universally safe corrective write. Never force-confirm a stuck change, since that would apply an unknown or partial set of actions to the order.
How do I find the OrderChange that is blocking edits on a Medusa order?
Request the order with fields=id,display_id,status,*order_change from the Admin API. Medusa attaches the active change as a computed relation. If order_change is present with status pending or requested and confirmed_at, declined_at, and canceled_at are all null, that row is the one blocking new edits, returns, claims, and exchanges on that order.
Related field notes
Citations
On the problem:
- GitHub: [Bug]: Cannot Edit Order, An active Order Change is required to proceed (medusajs/medusa #12173). github.com/medusajs/medusa/issues/12173
- GitHub: [Bug]: Order changes don't work in 2.5.0 (medusajs/medusa #11408). github.com/medusajs/medusa/issues/11408
- Medusa Documentation: Order Change, Commerce Modules. docs.medusajs.com/resources/commerce-modules/order/order-change
On the solution:
- Medusa Documentation: requestOrderEditRequestWorkflow, Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/requestOrderEditRequestWorkflow
- Medusa Documentation: OrderChangeDTO, Order Module Reference. docs.medusajs.com/resources/references/order/interfaces/order.OrderChangeDTO
- Medusa Documentation: cancel, Order Module Reference. docs.medusajs.com/resources/references/order/cancel
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows 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 unblock your order?
If this saved you from a permanently stuck order or a risky force-confirm, 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