Reconciler Inventory & Reservations
Existing reservation blocks fulfillment of its own order
A customer buys the last unit of a variant. The order is real, paid, and sitting there waiting to ship. But when staff try to fulfill it, Medusa says there is no available stock, so the fulfillment button is a dead end. The stock is not actually missing. It is held by the order's own reservation, and Medusa's availability check never carves that reservation back out before deciding whether to let the fulfillment through. Here is why that deadlock happens and a small script that finds only the orphaned reservations causing it and clears them safely.
Medusa v2 computes an inventory level's available quantity strictly as stocked_quantity minus reserved_quantity for an (inventory_item_id, location_id) pair, and both the admin fulfillment UI and the underlying create-fulfillment checks gate on that number being greater than zero. They do not subtract out the reservation belonging to the very line item or order being fulfilled. So once reserved_quantity reaches stocked_quantity, on the last unit or units sold, the same order that legitimately holds the reservation is told there is zero available and fulfillment is blocked. This gets worse when reservations become orphaned, left behind after an order is canceled or archived, or after a known fulfillment bug fails to delete them, since those stale rows permanently occupy the stock and cause the same deadlock for other, still open orders sharing that inventory item and location. Run a script that scans reservations, resolves each one's order, and deletes only the confirmed orphans. Full code, tests, and a dry run guard are below.
The problem in plain words
Availability in Medusa v2 is a subtraction. Every inventory level has a stocked_quantity, the number of units physically on hand at a location, and a reserved_quantity, the number spoken for by orders and carts that have not shipped yet. Available quantity is stocked_quantity minus reserved_quantity, and nothing more nuanced than that.
That subtraction has no concept of whose reservation is whose. When the admin fulfillment UI, or the workflow step underneath it, checks whether an item can be fulfilled, it looks at the location's available quantity. It never asks "does this particular order's own reservation account for the shortfall." So when the last unit of a variant sells, reserved_quantity climbs to equal stocked_quantity, available quantity hits zero, and the very order that placed that reservation gets told there is nothing to fulfill it with.
Why it happens
The subtraction itself is correct behavior on a healthy store: it is what stops you from overselling. The deadlock shows up because of how it interacts with a few common situations:
- An order legitimately sells the last unit or units of a variant at a location. Its own reservation is exactly what pushes
reserved_quantityup tostocked_quantity, and nothing in the fulfillment path subtracts that reservation back out before checking availability. - An order is canceled or archived, but its reservation is not deleted along with it. GitHub issue #6500 and issue #9821 both describe fulfillment becoming impossible once reservations exhaust available inventory, which matches this exact shape.
- A fulfillment bug sometimes fails to delete the reservation it should have cleaned up. GitHub issue #11266 documents order fulfillment not deleting inventory item reservations in some cases, which leaves the row behind to desync with reality.
- Once an orphaned reservation exists, it permanently occupies part of the stock buffer. It silently blocks fulfillment for any other, still open order that shares the same inventory item and location, not just the order it was originally tied to.
The common thread is that Medusa's admin UI and create-fulfillment checks read a single aggregate number, available quantity at a location, and never ask which reservation is whose. See the citations at the end for the exact issues and docs.
A reservation is only safe to delete once you know it is not backing a real, still open order. That means resolving every reservation's line_item_id against its actual order and checking that order's status and fulfillment status. If the order was canceled or archived, or if it is already fulfilled or shipped and the reservation should have been cleaned up but was not, the reservation is an orphan. Anything tied to an open, unfulfilled order must be left alone, even if its inventory level shows reserved_quantity equal to stocked_quantity, because that is exactly what a legitimate last-unit sale looks like. In that case the fix is not to delete anything, it is to flag the order for a human to review.
The fix, as a flow
We do not touch live checkouts and we never bump stock or force a fulfillment on someone's behalf. The job lists reservations, resolves each one's order, runs a pure decision function to classify it, and only deletes the ones confirmed as orphans. Anything still tied to an open order gets reported for manual review, not auto-fixed.
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 DRY_RUN="true" # start safe, change to false to write
// 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 DRY_RUN="true" // start safe, change to false to write
List reservations and expand each one's order
Ask for every reservation with id, quantity, line_item_id, inventory_item_id, location_id, and created_at, paginating with limit and offset. For any reservation with a line_item_id, expand *line_item.order so you get the order's status and fulfillment_status in the same call. A reservation with no line_item_id is a manual or custom reservation and is skipped entirely.
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_reservations(token):
reservations = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/reservations", {
"fields": "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
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 listReservations(token) {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/reservations", {
fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
limit,
offset,
});
reservations.push(...data.reservations);
offset += limit;
if (offset >= data.count) return reservations;
}
}
Decide, with one pure function
Keep the classification in its own function that takes the reservation, the resolved order info, and the inventory level rows for that location, and returns a plain answer. It never touches the network, so it is trivial to unit test with fixture data. A reservation with no line_item_id is always manual_keep. One whose order or line item cannot be resolved at all is orphan_missing_order. One whose order is canceled or archived is orphan_canceled_order. One whose order is already fulfilled, shipped, or delivered, meaning fulfillment should have deleted this reservation already, is orphan_already_fulfilled. Everything else, a reservation legitimately backing an open, unfulfilled order, is keep.
ORPHAN_ORDER_STATUSES = {"canceled", "archived"}
ALREADY_FULFILLED_STATUSES = {"fulfilled", "shipped", "delivered"}
def classify_reservation(reservation, order_info, levels):
"""Pure decision function. No I/O.
reservation: {"id": str, "line_item_id": str | None, "quantity": int, "location_id": str}
order_info: {"exists": bool, "status": str | None, "fulfillment_status": str | None} | None
levels: [{"location_id": str, "stocked_quantity": int, "reserved_quantity": int}, ...]
Returns "keep" | "orphan_canceled_order" | "orphan_missing_order"
| "orphan_already_fulfilled" | "manual_keep".
"""
if not reservation.get("line_item_id"):
return "manual_keep"
if order_info is None or not order_info.get("exists"):
return "orphan_missing_order"
if order_info.get("status") in ORPHAN_ORDER_STATUSES:
return "orphan_canceled_order"
if order_info.get("fulfillment_status") in ALREADY_FULFILLED_STATUSES:
return "orphan_already_fulfilled"
return "keep"
const ORPHAN_ORDER_STATUSES = new Set(["canceled", "archived"]);
const ALREADY_FULFILLED_STATUSES = new Set(["fulfilled", "shipped", "delivered"]);
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, quantity: number, location_id: string }} reservation
* @param {{ exists: boolean, status?: string, fulfillment_status?: string } | null} orderInfo
* @param {{ location_id: string, stocked_quantity: number, reserved_quantity: number }[]} levels
* @returns {"keep" | "orphan_canceled_order" | "orphan_missing_order" | "orphan_already_fulfilled" | "manual_keep"}
*/
export function classifyReservation(reservation, orderInfo, levels) {
if (!reservation.line_item_id) return "manual_keep";
if (!orderInfo || !orderInfo.exists) return "orphan_missing_order";
if (ORPHAN_ORDER_STATUSES.has(orderInfo.status)) return "orphan_canceled_order";
if (ALREADY_FULFILLED_STATUSES.has(orderInfo.fulfillment_status)) return "orphan_already_fulfilled";
return "keep";
}
Confirm the stuck signature before repair
Before deleting anything, cross-check the flagged reservation's inventory_item_id against GET /admin/inventory-items/{id}/location-levels to confirm reserved_quantity equals stocked_quantity at that location_id. That is the "stuck" signature the issue describes: the orphan is silently consuming the entire stock buffer and blocking fulfillment of legitimate, still open orders sharing that inventory item and location.
def get_location_levels(token, inventory_item_id):
data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels")
return data.get("inventory_levels") or []
def is_stuck_level(levels, location_id):
for level in levels:
if level["location_id"] == location_id:
return level["reserved_quantity"] == level["stocked_quantity"]
return False
async function getLocationLevels(token, inventoryItemId) {
const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`);
return data.inventory_levels || [];
}
function isStuckLevel(levels, locationId) {
const level = levels.find((l) => l.location_id === locationId);
return level ? level.reserved_quantity === level.stocked_quantity : false;
}
Delete only the confirmed orphans, dry run guarded
When a reservation is classified as orphan_canceled_order, orphan_missing_order, or orphan_already_fulfilled, call DELETE /admin/reservations/{id}. That triggers Medusa's inventory workflow to decrement reserved_quantity at the associated location level, freeing capacity so available quantity rises above zero again. A reservation classified as keep or manual_keep is never touched. If one of those still shows reserved_quantity equal to stocked_quantity, report the order for manual review instead of writing anything, since bumping stock or force-creating a fulfillment on someone's behalf is a financial decision, not a safe automated write.
ORPHAN_OUTCOMES = {"orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"}
def admin_delete(token, path):
r = requests.delete(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def delete_reservation(token, reservation_id):
return admin_delete(token, f"/admin/reservations/{reservation_id}")
const ORPHAN_OUTCOMES = new Set(["orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"]);
async function adminDelete(token, path) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
return res.json();
}
async function deleteReservation(token, reservationId) {
return adminDelete(token, `/admin/reservations/${reservationId}`);
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {id, inventory_item_id, location_id, quantity, reason} tuples it would delete, along with the pre and post reserved_quantity that would result. Read the output, agree with it, then switch it off to let it write. Keep or manual_keep reservations that still show a stuck level get printed as a manual review list, never auto-fixed.
Always start with DRY_RUN=true, and never delete a reservation classified as keep or manual_keep. If one of those still shows reserved_quantity equal to stocked_quantity, that is a real order waiting on stock, and the fix is a human decision, either expedite restock or contact the customer, not a script bumping numbers.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever deletes a reservation confirmed as an orphan, never one still backing an open order.
"""Clear Medusa reservations that block fulfillment of their own order.
Medusa v2 computes an inventory level's available quantity as stocked_quantity
minus reserved_quantity, and the admin fulfillment checks gate on that number
being above zero. They never subtract out the reservation belonging to the
order being fulfilled, so once reserved_quantity reaches stocked_quantity on
the last unit sold, the very order that holds the reservation is told there is
zero available. This is worse when reservations are orphaned: left behind
after an order is canceled or archived, or after a fulfillment bug fails to
delete them. This scans reservations, resolves each one's order, and deletes
only the ones confirmed orphaned. Anything tied to an open order is left
alone and, if stuck, reported for manual review.
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("clear_blocking_reservations")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORPHAN_ORDER_STATUSES = {"canceled", "archived"}
ALREADY_FULFILLED_STATUSES = {"fulfilled", "shipped", "delivered"}
ORPHAN_OUTCOMES = {"orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"}
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 admin_delete(token, path):
r = requests.delete(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def classify_reservation(reservation, order_info, levels):
"""Pure decision function. No I/O.
reservation: {"id": str, "line_item_id": str | None, "quantity": int, "location_id": str}
order_info: {"exists": bool, "status": str | None, "fulfillment_status": str | None} | None
levels: [{"location_id": str, "stocked_quantity": int, "reserved_quantity": int}, ...]
Returns "keep" | "orphan_canceled_order" | "orphan_missing_order"
| "orphan_already_fulfilled" | "manual_keep".
"""
if not reservation.get("line_item_id"):
return "manual_keep"
if order_info is None or not order_info.get("exists"):
return "orphan_missing_order"
if order_info.get("status") in ORPHAN_ORDER_STATUSES:
return "orphan_canceled_order"
if order_info.get("fulfillment_status") in ALREADY_FULFILLED_STATUSES:
return "orphan_already_fulfilled"
return "keep"
def list_reservations(token):
reservations = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/reservations", {
"fields": "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
def resolve_order_info(reservation):
"""Turns the expanded *line_item.order payload into order_info, or None if missing."""
line_item = reservation.get("line_item")
if not line_item:
return None
order = line_item.get("order")
if not order:
return None
return {
"exists": True,
"status": order.get("status"),
"fulfillment_status": order.get("fulfillment_status"),
}
def get_location_levels(token, inventory_item_id):
data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels")
return data.get("inventory_levels") or []
def find_level(levels, location_id):
for level in levels:
if level["location_id"] == location_id:
return level
return None
def run():
token = get_admin_token()
reservations = list_reservations(token)
cleared = 0
flagged_for_review = 0
for reservation in reservations:
if not reservation.get("line_item_id"):
continue # manual_keep, never touched
order_info = resolve_order_info(reservation)
levels = get_location_levels(token, reservation["inventory_item_id"])
outcome = classify_reservation(reservation, order_info, levels)
if outcome == "keep":
level = find_level(levels, reservation["location_id"])
if level and level["reserved_quantity"] == level["stocked_quantity"]:
order_id = (reservation.get("line_item") or {}).get("order", {}).get("id")
log.warning(
"Order %s: reservation %s keeps reserved_quantity == stocked_quantity "
"at location %s. Flagging for manual review, not touching stock or fulfillment.",
order_id, reservation["id"], reservation["location_id"],
)
flagged_for_review += 1
continue
if outcome not in ORPHAN_OUTCOMES:
continue
level = find_level(levels, reservation["location_id"])
before_reserved = level["reserved_quantity"] if level else None
stocked = level["stocked_quantity"] if level else None
after_reserved = (before_reserved - reservation["quantity"]) if before_reserved is not None else None
log.warning(
"Reservation %s classified as %s. inventory_item_id=%s location_id=%s quantity=%s "
"reserved_quantity %s -> %s (stocked_quantity=%s). %s",
reservation["id"], outcome, reservation["inventory_item_id"], reservation["location_id"],
reservation["quantity"], before_reserved, after_reserved, stocked,
"Would delete" if DRY_RUN else "Deleting",
)
if not DRY_RUN:
admin_delete(token, f"/admin/reservations/{reservation['id']}")
cleared += 1
log.info(
"Done. %d orphaned reservation(s) %s. %d order(s) flagged for manual review.",
cleared, "to clear" if DRY_RUN else "cleared", flagged_for_review,
)
if __name__ == "__main__":
run()
/**
* Clear Medusa reservations that block fulfillment of their own order.
*
* Medusa v2 computes an inventory level's available quantity as stocked_quantity
* minus reserved_quantity, and the admin fulfillment checks gate on that number
* being above zero. They never subtract out the reservation belonging to the
* order being fulfilled, so once reserved_quantity reaches stocked_quantity on
* the last unit sold, the very order that holds the reservation is told there is
* zero available. This is worse when reservations are orphaned: left behind
* after an order is canceled or archived, or after a fulfillment bug fails to
* delete them. This scans reservations, resolves each one's order, and deletes
* only the ones confirmed orphaned. Anything tied to an open order is left
* alone and, if stuck, reported for manual review.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/reservation-blocks-own-order-fulfillment/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ORPHAN_ORDER_STATUSES = new Set(["canceled", "archived"]);
const ALREADY_FULFILLED_STATUSES = new Set(["fulfilled", "shipped", "delivered"]);
const ORPHAN_OUTCOMES = new Set(["orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"]);
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, quantity: number, location_id: string }} reservation
* @param {{ exists: boolean, status?: string, fulfillment_status?: string } | null} orderInfo
* @param {{ location_id: string, stocked_quantity: number, reserved_quantity: number }[]} levels
* @returns {"keep" | "orphan_canceled_order" | "orphan_missing_order" | "orphan_already_fulfilled" | "manual_keep"}
*/
export function classifyReservation(reservation, orderInfo, levels) {
if (!reservation.line_item_id) return "manual_keep";
if (!orderInfo || !orderInfo.exists) return "orphan_missing_order";
if (ORPHAN_ORDER_STATUSES.has(orderInfo.status)) return "orphan_canceled_order";
if (ALREADY_FULFILLED_STATUSES.has(orderInfo.fulfillment_status)) return "orphan_already_fulfilled";
return "keep";
}
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 adminDelete(token, path) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
return res.json();
}
async function listReservations(token) {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/reservations", {
fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
limit,
offset,
});
reservations.push(...data.reservations);
offset += limit;
if (offset >= data.count) return reservations;
}
}
function resolveOrderInfo(reservation) {
const lineItem = reservation.line_item;
if (!lineItem) return null;
const order = lineItem.order;
if (!order) return null;
return {
exists: true,
status: order.status,
fulfillment_status: order.fulfillment_status,
};
}
async function getLocationLevels(token, inventoryItemId) {
const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`);
return data.inventory_levels || [];
}
function findLevel(levels, locationId) {
return levels.find((l) => l.location_id === locationId) || null;
}
export async function run() {
const token = await getAdminToken();
const reservations = await listReservations(token);
let cleared = 0;
let flaggedForReview = 0;
for (const reservation of reservations) {
if (!reservation.line_item_id) continue; // manual_keep, never touched
const orderInfo = resolveOrderInfo(reservation);
const levels = await getLocationLevels(token, reservation.inventory_item_id);
const outcome = classifyReservation(reservation, orderInfo, levels);
if (outcome === "keep") {
const level = findLevel(levels, reservation.location_id);
if (level && level.reserved_quantity === level.stocked_quantity) {
const orderId = reservation.line_item?.order?.id;
console.warn(
`Order ${orderId}: reservation ${reservation.id} keeps reserved_quantity == stocked_quantity ` +
`at location ${reservation.location_id}. Flagging for manual review, not touching stock or fulfillment.`
);
flaggedForReview++;
}
continue;
}
if (!ORPHAN_OUTCOMES.has(outcome)) continue;
const level = findLevel(levels, reservation.location_id);
const beforeReserved = level ? level.reserved_quantity : undefined;
const stocked = level ? level.stocked_quantity : undefined;
const afterReserved = beforeReserved !== undefined ? beforeReserved - reservation.quantity : undefined;
console.warn(
`Reservation ${reservation.id} classified as ${outcome}. inventory_item_id=${reservation.inventory_item_id} ` +
`location_id=${reservation.location_id} quantity=${reservation.quantity} ` +
`reserved_quantity ${beforeReserved} -> ${afterReserved} (stocked_quantity=${stocked}). ` +
`${DRY_RUN ? "Would delete" : "Deleting"}`
);
if (!DRY_RUN) {
await adminDelete(token, `/admin/reservations/${reservation.id}`);
}
cleared++;
}
console.log(
`Done. ${cleared} orphaned reservation(s) ${DRY_RUN ? "to clear" : "cleared"}. ` +
`${flaggedForReview} order(s) flagged for manual review.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
classify_reservation is the part most worth testing, because it decides which reservations are safe to delete. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures and checks the answer.
from clear_blocking_reservations import classify_reservation
LEVELS = [{"location_id": "sloc_1", "stocked_quantity": 1, "reserved_quantity": 1}]
def reservation(**over):
base = {"id": "res_1", "line_item_id": "item_1", "quantity": 1, "location_id": "sloc_1"}
base.update(over)
return base
def test_manual_keep_when_no_line_item_id():
r = reservation(line_item_id=None)
assert classify_reservation(r, None, LEVELS) == "manual_keep"
def test_orphan_missing_order_when_order_info_is_none():
r = reservation()
assert classify_reservation(r, None, LEVELS) == "orphan_missing_order"
def test_orphan_missing_order_when_order_does_not_exist():
r = reservation()
order_info = {"exists": False}
assert classify_reservation(r, order_info, LEVELS) == "orphan_missing_order"
def test_orphan_canceled_order():
r = reservation()
order_info = {"exists": True, "status": "canceled", "fulfillment_status": "not_fulfilled"}
assert classify_reservation(r, order_info, LEVELS) == "orphan_canceled_order"
def test_orphan_archived_order():
r = reservation()
order_info = {"exists": True, "status": "archived", "fulfillment_status": "not_fulfilled"}
assert classify_reservation(r, order_info, LEVELS) == "orphan_canceled_order"
def test_orphan_already_fulfilled():
r = reservation()
order_info = {"exists": True, "status": "completed", "fulfillment_status": "fulfilled"}
assert classify_reservation(r, order_info, LEVELS) == "orphan_already_fulfilled"
def test_orphan_already_shipped():
r = reservation()
order_info = {"exists": True, "status": "completed", "fulfillment_status": "shipped"}
assert classify_reservation(r, order_info, LEVELS) == "orphan_already_fulfilled"
def test_keep_when_order_is_open_and_unfulfilled():
r = reservation()
order_info = {"exists": True, "status": "pending", "fulfillment_status": "not_fulfilled"}
assert classify_reservation(r, order_info, LEVELS) == "keep"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyReservation } from "./clear-blocking-reservations.js";
const LEVELS = [{ location_id: "sloc_1", stocked_quantity: 1, reserved_quantity: 1 }];
const reservation = (over = {}) => ({
id: "res_1",
line_item_id: "item_1",
quantity: 1,
location_id: "sloc_1",
...over,
});
test("manual_keep when no line_item_id", () => {
const r = reservation({ line_item_id: null });
assert.equal(classifyReservation(r, null, LEVELS), "manual_keep");
});
test("orphan_missing_order when order info is null", () => {
const r = reservation();
assert.equal(classifyReservation(r, null, LEVELS), "orphan_missing_order");
});
test("orphan_missing_order when order does not exist", () => {
const r = reservation();
assert.equal(classifyReservation(r, { exists: false }, LEVELS), "orphan_missing_order");
});
test("orphan_canceled_order", () => {
const r = reservation();
const orderInfo = { exists: true, status: "canceled", fulfillment_status: "not_fulfilled" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_canceled_order");
});
test("orphan_archived_order", () => {
const r = reservation();
const orderInfo = { exists: true, status: "archived", fulfillment_status: "not_fulfilled" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_canceled_order");
});
test("orphan_already_fulfilled", () => {
const r = reservation();
const orderInfo = { exists: true, status: "completed", fulfillment_status: "fulfilled" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_already_fulfilled");
});
test("orphan_already_shipped", () => {
const r = reservation();
const orderInfo = { exists: true, status: "completed", fulfillment_status: "shipped" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_already_fulfilled");
});
test("orphan_already_delivered", () => {
const r = reservation();
const orderInfo = { exists: true, status: "completed", fulfillment_status: "delivered" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_already_fulfilled");
});
test("keep when order is open and unfulfilled", () => {
const r = reservation();
const orderInfo = { exists: true, status: "pending", fulfillment_status: "not_fulfilled" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "keep");
});
test("keep when order is completed but not yet fulfilled", () => {
const r = reservation();
const orderInfo = { exists: true, status: "completed", fulfillment_status: "not_fulfilled" };
assert.equal(classifyReservation(r, orderInfo, LEVELS), "keep");
});
Case studies
A single order deadlocked on its own stock
A small apparel brand sold the very last unit of a limited colorway. The order was paid, the warehouse had the item in hand, but the fulfillment button in the admin was greyed out with zero available stock showing. Staff assumed the inventory count was wrong and spent an hour trying to correct stocked_quantity before realizing the number was accurate, it was the check that was the problem.
Running the classifier in dry run showed that reservation as keep, correctly tied to an open, unfulfilled order, with reserved_quantity equal to stocked_quantity at that location. The script flagged it for manual review instead of deleting anything. The team understood the deadlock was expected for a legitimate last-unit sale and fulfilled the order directly through the workflow that bypasses the availability gate, rather than touching stock numbers.
A cancelled order kept blocking a different customer's fulfillment
An order for the last two units of a SKU was canceled during a support call, but its reservation was never cleaned up. Weeks later, a different customer ordered the same SKU after a restock, and that new order's fulfillment was blocked because the old, canceled order's reservation was still eating the stock at that location.
The scan resolved the stale reservation's line item back to the canceled order and classified it orphan_canceled_order. Deleting it dropped reserved_quantity back down, and the new customer's order fulfilled normally on the very next check, with no changes to stocked_quantity or any fulfillment created on anyone's behalf.
After this runs on a schedule, a deadlock caused by a genuinely orphaned reservation clears itself before anyone notices, and available quantity goes back to reflecting stock a real order can still claim. A legitimate last-unit sale that shows the same reserved-equals-stocked signature is never touched automatically. It is surfaced to a human, because deciding to bump stock or push a fulfillment through by hand is a financial call the script should never make for you.
FAQ
Why does Medusa say an order has zero available stock when it clearly has a reservation?
Medusa computes available quantity as stocked_quantity minus reserved_quantity for an inventory item at a location. It does not subtract out the reservation belonging to the very order you are trying to fulfill. So once reserved_quantity reaches stocked_quantity on the last unit sold, the admin fulfillment check sees zero available and blocks the order even though its own reservation is the legitimate reason the stock is at zero.
Is it safe to delete a reservation to unblock fulfillment?
Only when the reservation is confirmed orphaned: its order was canceled or archived, its order was already fulfilled or shipped so the reservation should have been deleted already, or the order or line item no longer exists. A reservation still tied to an open, unfulfilled order must never be deleted, since that stock is legitimately spoken for.
What happens when you delete a stuck reservation in Medusa?
Deleting a reservation through DELETE /admin/reservations/{id} triggers Medusa's inventory workflow to decrement reserved_quantity at the associated location level. That raises stocked_quantity minus reserved_quantity, the available quantity, back above zero, which is what the fulfillment check reads before allowing a fulfillment.
Related field notes
Citations
On the problem:
- Unable to Fulfill Orders When Reservations Exhaust Available Inventory. Medusa GitHub Issue #6500. github.com/medusajs/medusa/issues/6500
- Fulfilment no longer possible when all items have been reserved. Medusa GitHub Issue #9821. github.com/medusajs/medusa/issues/9821
- Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266
On the solution:
- Medusa Documentation: Inventory Module Concepts, stocked_quantity and reserved_quantity. docs.medusajs.com/resources/commerce-modules/inventory/concepts
- Medusa Admin User Guide: Manage Reservations. docs.medusajs.com/user-guide/inventory/reservations
- Medusa V2 Admin API Reference: Reservations and Inventory Items. docs.medusajs.com/api/admin
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 fulfillment?
If this saved you from a phantom out of stock item or a support ticket about an order that would not ship, 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