Reconciler Inventory and stock
Stuck reservations after cancelled carts
A shopper adds the last unit to their cart, then closes the tab, or the checkout times out, or someone voids the cart by hand outside the normal flow. The cart is gone, but the stock is not free. Reserved quantity keeps sitting against that inventory item as if a real order still needed it. Here is why Medusa v2 leaves these reservation rows behind and a small script that finds only the stale ones and releases them safely.
In Medusa v2, the Inventory Module creates a ReservationItem (res_...) linked to a cart's line_item_id, inventory_item_id, and location_id as soon as stock is reserved, for example during cart completion or payment authorization. Reservations are only cleaned up by specific compensating steps in workflows like completeCartWorkflow or order cancellation. There is no cart cancel workflow that reliably deletes the associated reservations, and ReservationItem has no cart_id field to join back to, so an abandoned, timed out, or manually voided cart leaves orphaned reservation rows that keep counting against reserved_quantity. Run a script that lists reservations, resolves each line_item_id against real orders, and deletes only the ones that are truly orphaned or attached to a canceled order. Full code, tests, and a dry run guard are below.
The problem in plain words
When a cart reserves stock, Medusa writes a ReservationItem row that says, in effect, this many units of this inventory item at this location are spoken for. That row exists independently of the cart. It is not deleted automatically when the cart disappears, because Medusa never guarantees a cart "cancel" step at all. Carts can just stop existing: the browser tab closes, the session times out, or someone manually voids an order outside the normal checkout path.
The reservation itself has no way to tell it has been abandoned. It has a line_item_id, an inventory_item_id, and a location_id, but no cart_id field to check against. So the row just sits there, forever counted as reserved, unless a specific compensating step in a workflow like completeCartWorkflow or order cancellation happens to clean it up. When that step does not run, the reservation is orphaned, and the location level's reserved_quantity stays inflated even though nothing real is holding the stock.
Why it happens
Reservations are created eagerly, the moment stock needs to be held, but they are only released by narrow, specific code paths. A few common ways stores end up with stale rows:
- A cart reserves stock during payment authorization or cart completion, then the shopper abandons the session before checkout finishes, so the completion workflow that would have converted or released the reservation never runs.
- A cart or draft order times out on the storefront or in a background job, and whatever process expires it does not also call the inventory cleanup step.
- Someone manually cancels or voids an order outside the standard cancellation workflow, for example directly in the database or through a custom script, so the compensating step that deletes the reservation never fires.
- The gap runs in the other direction too. GitHub issue #11266 documents fulfillment sometimes not deleting inventory item reservations, confirming that reservation cleanup is not guaranteed across all cancellation and failure paths in v2.
ReservationItem simply was not designed with a back-reference to the cart that created it. There is a line_item_id, but once the cart is gone, or the order tied to that line item is canceled, nothing tells the Inventory Module to go clean up the row. See the citations at the end for the exact issue and docs.
A reservation is only safe to delete once you know, with certainty, that nothing legitimate still needs the stock it holds. That means resolving every reservation's line_item_id against real orders first. If no order has a matching line item, the cart was never completed and the reservation is an orphan. If an order does match but its status is canceled, the reservation should have been released and was not. Anything still active, pending, or processing must be left alone, because deleting that reservation would free stock a real order still holds.
The fix, as a flow
We do not touch live checkouts. The job lists reservations, builds an index of every order's line items so it can resolve each reservation's line_item_id, runs a pure decision function to classify each reservation, and only deletes the ones classified as stale, age gated so nothing mid-checkout gets caught.
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_AFTER_HOURS="24"
export DRY_RUN="true" # start safe, change to false to write
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STALE_AFTER_HOURS="24"
export DRY_RUN="true" // start safe, change to false to write
List reservations with their line items and inventory items
Ask for every reservation with the fields the decision needs: quantity, line_item_id, inventory_item_id, location_id, and created_at. Paginate with limit and offset since a busy store can carry hundreds of these rows.
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,*inventory_item",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
async function listReservations() {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const { reservations: page, count } = await sdk.admin.reservation.list({
fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*inventory_item",
limit,
offset,
});
reservations.push(...page);
offset += limit;
if (offset >= count) return reservations;
}
}
Index every order's line items by their owning order
Pull orders with their items and build a lookup from line_item_id to the order that owns it, along with that order's status. This is what lets the script resolve whether a reservation's cart was ever completed, and if so, whether the order is still active or was canceled.
def build_order_line_item_index(token):
"""Returns { line_item_id: {"orderId": str, "orderStatus": str} }."""
index = {}
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,status,*items",
"limit": limit,
"offset": offset,
})
for order in data["orders"]:
for item in order.get("items") or []:
index[item["id"]] = {"orderId": order["id"], "orderStatus": order["status"]}
offset += limit
if offset >= data["count"]:
return index
async function buildOrderLineItemIndex() {
const index = new Map();
let offset = 0;
const limit = 200;
while (true) {
const { orders, count } = await sdk.admin.order.list({
fields: "id,status,*items",
limit,
offset,
});
for (const order of orders) {
for (const item of order.items || []) {
index.set(item.id, { orderId: order.id, orderStatus: order.status });
}
}
offset += limit;
if (offset >= count) return index;
}
}
Decide, with one pure function
Keep the classification in its own function that takes a reservation, the order line item index, the current time, and the staleness window, 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, or one younger than the safety window, or one whose order is still active is always kept. Only a true orphan or a reservation tied to a canceled order is ever flagged stale.
def classify_reservation(reservation, order_line_item_index, now, stale_after_ms):
"""Pure decision function. No I/O.
reservation: {"id": str, "line_item_id": str | None, "created_at": str (ISO)}
order_line_item_index: {line_item_id: {"orderId": str, "orderStatus": str}}
now: datetime, timezone aware
stale_after_ms: float
Returns "keep" | "stale_orphan" | "stale_canceled_order".
"""
line_item_id = reservation.get("line_item_id")
if not line_item_id:
return "keep"
created_at = datetime.fromisoformat(reservation["created_at"].replace("Z", "+00:00"))
age_ms = (now - created_at).total_seconds() * 1000
if age_ms < stale_after_ms:
return "keep"
match = order_line_item_index.get(line_item_id)
if match is None:
return "stale_orphan"
if match["orderStatus"] == "canceled":
return "stale_canceled_order"
return "keep"
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, created_at: string }} reservation
* @param {Map<string, { orderId: string, orderStatus: string }>} orderLineItemIndex
* @param {Date} now
* @param {number} staleAfterMs
* @returns {"keep" | "stale_orphan" | "stale_canceled_order"}
*/
export function classifyReservation(reservation, orderLineItemIndex, now, staleAfterMs) {
const lineItemId = reservation.line_item_id;
if (!lineItemId) return "keep";
const ageMs = now.getTime() - new Date(reservation.created_at).getTime();
if (ageMs < staleAfterMs) return "keep";
const match = orderLineItemIndex.get(lineItemId);
if (!match) return "stale_orphan";
if (match.orderStatus === "canceled") return "stale_canceled_order";
return "keep";
}
Delete only the stale reservations, and verify the drop
When a reservation is classified as stale, call DELETE /admin/reservations/{id}. This invokes the Inventory Module's deleteReservationItem logic, which decrements reserved_quantity on the corresponding location level. After a real delete, re-fetch the location level and confirm the number actually dropped by the reservation's quantity, so a silent failure never gets logged as a success.
def delete_reservation(token, reservation_id):
r = requests.delete(
f"{BACKEND_URL}/admin/reservations/{reservation_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_location_level(token, inventory_item_id, location_id):
data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels", {
"location_id": location_id,
})
levels = data.get("inventory_levels") or []
return levels[0] if levels else None
async function deleteReservation(reservationId) {
return sdk.admin.reservation.delete(reservationId);
}
async function getLocationLevel(inventoryItemId, locationId) {
const { inventory_levels } = await sdk.admin.inventoryItem.listLocationLevels(inventoryItemId, {
location_id: locationId,
});
return (inventory_levels || [])[0] || null;
}
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 which reservations it would delete, along with the inventory_item_id, location_id, and quantity that would be released. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that fits how many carts your store abandons, for example once an hour.
Always start with DRY_RUN=true, and never lower STALE_AFTER_HOURS below what a slow but legitimate checkout could take. Deleting a reservation still tied to an active order releases stock a real customer's order depends on, so the age gate and the order status check both have to hold before anything is deleted.
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 that is either a true orphan or tied to an order that is already canceled.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Release Medusa reservations orphaned by abandoned or cancelled carts.
When stock is reserved for a cart, Medusa creates a ReservationItem linked to a
line_item_id, inventory_item_id, and location_id. There is no cart cancel workflow
that reliably deletes that reservation, and ReservationItem has no cart_id field to
join back to, so an abandoned, timed out, or manually voided cart leaves the row
behind. This lists reservations, resolves each line_item_id against real orders,
and deletes only the ones that are a true orphan or tied to a canceled order, after
an age gate so an in-flight checkout is never touched.
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("release_stuck_reservations")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
STALE_AFTER_HOURS = float(os.environ.get("STALE_AFTER_HOURS", "24"))
STALE_AFTER_MS = STALE_AFTER_HOURS * 3600 * 1000
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
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_line_item_index, now, stale_after_ms):
"""Pure decision function. No I/O.
reservation: {"id": str, "line_item_id": str | None, "created_at": str (ISO)}
order_line_item_index: {line_item_id: {"orderId": str, "orderStatus": str}}
now: datetime, timezone aware
stale_after_ms: float
Returns "keep" | "stale_orphan" | "stale_canceled_order".
"""
line_item_id = reservation.get("line_item_id")
if not line_item_id:
return "keep"
created_at = datetime.fromisoformat(reservation["created_at"].replace("Z", "+00:00"))
age_ms = (now - created_at).total_seconds() * 1000
if age_ms < stale_after_ms:
return "keep"
match = order_line_item_index.get(line_item_id)
if match is None:
return "stale_orphan"
if match["orderStatus"] == "canceled":
return "stale_canceled_order"
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,*inventory_item",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
def build_order_line_item_index(token):
index = {}
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,status,*items",
"limit": limit,
"offset": offset,
})
for order in data["orders"]:
for item in order.get("items") or []:
index[item["id"]] = {"orderId": order["id"], "orderStatus": order["status"]}
offset += limit
if offset >= data["count"]:
return index
def get_location_level(token, inventory_item_id, location_id):
data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels", {
"location_id": location_id,
})
levels = data.get("inventory_levels") or []
return levels[0] if levels else None
def run():
token = get_admin_token()
reservations = list_reservations(token)
order_line_item_index = build_order_line_item_index(token)
now = datetime.now(timezone.utc)
released = 0
for reservation in reservations:
outcome = classify_reservation(reservation, order_line_item_index, now, STALE_AFTER_MS)
if outcome == "keep":
continue
before = get_location_level(token, reservation["inventory_item_id"], reservation["location_id"])
before_reserved = before.get("reserved_quantity") if before else None
log.warning(
"Reservation %s classified as %s. inventory_item_id=%s location_id=%s quantity=%s. %s",
reservation["id"], outcome, reservation["inventory_item_id"], reservation["location_id"],
reservation["quantity"], "Would delete" if DRY_RUN else "Deleting",
)
if not DRY_RUN:
admin_delete(token, f"/admin/reservations/{reservation['id']}")
after = get_location_level(token, reservation["inventory_item_id"], reservation["location_id"])
after_reserved = after.get("reserved_quantity") if after else None
expected = (before_reserved - reservation["quantity"]) if before_reserved is not None else None
if expected is not None and after_reserved != expected:
log.warning(
" reserved_quantity did not drop as expected for %s: before=%s after=%s expected=%s",
reservation["inventory_item_id"], before_reserved, after_reserved, expected,
)
else:
log.info(" reserved_quantity confirmed: before=%s after=%s", before_reserved, after_reserved)
released += 1
log.info("Done. %d reservation(s) %s.", released, "to release" if DRY_RUN else "released")
if __name__ == "__main__":
run()
/**
* Release Medusa reservations orphaned by abandoned or cancelled carts.
*
* When stock is reserved for a cart, Medusa creates a ReservationItem linked to a
* line_item_id, inventory_item_id, and location_id. There is no cart cancel workflow
* that reliably deletes that reservation, and ReservationItem has no cart_id field to
* join back to, so an abandoned, timed out, or manually voided cart leaves the row
* behind. This lists reservations, resolves each line_item_id against real orders,
* and deletes only the ones that are a true orphan or tied to a canceled order, after
* an age gate so an in-flight checkout is never touched.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/stuck-reservations-after-cancelled-carts/
*/
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_AFTER_HOURS = Number(process.env.STALE_AFTER_HOURS || 24);
const STALE_AFTER_MS = STALE_AFTER_HOURS * 3600 * 1000;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, created_at: string }} reservation
* @param {Map<string, { orderId: string, orderStatus: string }>} orderLineItemIndex
* @param {Date} now
* @param {number} staleAfterMs
* @returns {"keep" | "stale_orphan" | "stale_canceled_order"}
*/
export function classifyReservation(reservation, orderLineItemIndex, now, staleAfterMs) {
const lineItemId = reservation.line_item_id;
if (!lineItemId) return "keep";
const ageMs = now.getTime() - new Date(reservation.created_at).getTime();
if (ageMs < staleAfterMs) return "keep";
const match = orderLineItemIndex.get(lineItemId);
if (!match) return "stale_orphan";
if (match.orderStatus === "canceled") return "stale_canceled_order";
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,*inventory_item",
limit,
offset,
});
reservations.push(...data.reservations);
offset += limit;
if (offset >= data.count) return reservations;
}
}
async function buildOrderLineItemIndex(token) {
const index = new Map();
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,status,*items",
limit,
offset,
});
for (const order of data.orders) {
for (const item of order.items || []) {
index.set(item.id, { orderId: order.id, orderStatus: order.status });
}
}
offset += limit;
if (offset >= data.count) return index;
}
}
async function getLocationLevel(token, inventoryItemId, locationId) {
const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`, {
location_id: locationId,
});
const levels = data.inventory_levels || [];
return levels[0] || null;
}
export async function run() {
const token = await getAdminToken();
const reservations = await listReservations(token);
const orderLineItemIndex = await buildOrderLineItemIndex(token);
const now = new Date();
let released = 0;
for (const reservation of reservations) {
const outcome = classifyReservation(reservation, orderLineItemIndex, now, STALE_AFTER_MS);
if (outcome === "keep") continue;
const before = await getLocationLevel(token, reservation.inventory_item_id, reservation.location_id);
const beforeReserved = before ? before.reserved_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}. ${DRY_RUN ? "Would delete" : "Deleting"}`
);
if (!DRY_RUN) {
await adminDelete(token, `/admin/reservations/${reservation.id}`);
const after = await getLocationLevel(token, reservation.inventory_item_id, reservation.location_id);
const afterReserved = after ? after.reserved_quantity : undefined;
const expected = beforeReserved !== undefined ? beforeReserved - reservation.quantity : undefined;
if (expected !== undefined && afterReserved !== expected) {
console.warn(
` reserved_quantity did not drop as expected for ${reservation.inventory_item_id}: before=${beforeReserved} after=${afterReserved} expected=${expected}`
);
} else {
console.log(` reserved_quantity confirmed: before=${beforeReserved} after=${afterReserved}`);
}
}
released++;
}
console.log(`Done. ${released} reservation(s) ${DRY_RUN ? "to release" : "released"}.`);
}
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 a fixed clock, and checks the answer.
from datetime import datetime, timezone
from release_stuck_reservations import classify_reservation
NOW = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
STALE_AFTER_MS = 24 * 3600 * 1000
def reservation(**over):
base = {"id": "res_1", "line_item_id": "item_1", "created_at": "2026-07-08T00:00:00Z"}
base.update(over)
return base
def test_keep_when_no_line_item_id():
r = reservation(line_item_id=None)
assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "keep"
def test_keep_when_younger_than_stale_window():
r = reservation(created_at="2026-07-09T23:00:00Z")
assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "keep"
def test_stale_orphan_when_no_matching_order():
r = reservation()
assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "stale_orphan"
def test_stale_canceled_order_when_order_is_canceled():
index = {"item_1": {"orderId": "order_1", "orderStatus": "canceled"}}
r = reservation()
assert classify_reservation(r, index, NOW, STALE_AFTER_MS) == "stale_canceled_order"
def test_keep_when_order_is_still_active():
index = {"item_1": {"orderId": "order_1", "orderStatus": "pending"}}
r = reservation()
assert classify_reservation(r, index, NOW, STALE_AFTER_MS) == "keep"
def test_exactly_at_stale_window_is_stale():
r = reservation(created_at="2026-07-09T00:00:00Z")
assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "stale_orphan"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyReservation } from "./release-stuck-reservations.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const STALE_AFTER_MS = 24 * 3600 * 1000;
const reservation = (over = {}) => ({
id: "res_1",
line_item_id: "item_1",
created_at: "2026-07-08T00:00:00Z",
...over,
});
test("keep when no line_item_id", () => {
const r = reservation({ line_item_id: null });
assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "keep");
});
test("keep when younger than stale window", () => {
const r = reservation({ created_at: "2026-07-09T23:00:00Z" });
assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "keep");
});
test("stale orphan when no matching order", () => {
const r = reservation();
assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "stale_orphan");
});
test("stale canceled order when order is canceled", () => {
const index = new Map([["item_1", { orderId: "order_1", orderStatus: "canceled" }]]);
const r = reservation();
assert.equal(classifyReservation(r, index, NOW, STALE_AFTER_MS), "stale_canceled_order");
});
test("keep when order is still active", () => {
const index = new Map([["item_1", { orderId: "order_1", orderStatus: "pending" }]]);
const r = reservation();
assert.equal(classifyReservation(r, index, NOW, STALE_AFTER_MS), "keep");
});
test("exactly at stale window is stale", () => {
const r = reservation({ created_at: "2026-07-09T00:00:00Z" });
assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "stale_orphan");
});
Case studies
The flash sale that locked up its own stock
A store ran a flash sale on a limited run item. Hundreds of shoppers added the last few units to their carts during payment authorization, then closed the tab when the price felt too high at the last step. Each of those carts left a reservation behind, and within an hour the item showed zero available stock even though almost none of it had actually sold.
Running the classifier in dry run surfaced every one of those reservations as stale_orphan, since none of them matched a real order. The team reviewed the list, ran it for real, and the item's true availability came back immediately.
Support cancelled the order, but the stock stayed reserved
A support agent cancelled a customer's order directly through a database fix during an incident, bypassing the normal cancellation workflow. The order's status flipped to canceled, but the compensating step that would have deleted the reservation never ran, so the item stayed reserved for stock nobody needed anymore.
The script's order status check matched the line item to that now canceled order and classified it as stale_canceled_order. Deleting it dropped reserved_quantity back down, confirmed by the location level re-fetch right after.
After this runs on a schedule, reserved quantity always reflects stock that a real, active order still needs. Orphaned reservations from abandoned checkouts and reservations tied to orders that were cancelled outside the normal flow get cleared automatically, and every deletion is verified against the location level before it is counted as done. Real in-flight checkouts are never touched, because the age gate and the order status check both have to agree first.
FAQ
Why does my Medusa inventory still show reserved stock for a cart that no longer exists?
When stock is reserved for a cart, Medusa creates a ReservationItem linked to the cart's line item, inventory item, and location. There is no cart cancel workflow that reliably deletes that reservation, and ReservationItem has no cart_id field to join back to, so an abandoned, timed out, or manually voided cart leaves its reservation rows behind. Those rows keep counting against reserved_quantity even though no live order holds the stock.
Is it safe to delete Medusa reservations with a script?
Yes, when the script only deletes a reservation after confirming its line_item_id does not belong to any existing order, or belongs only to an order whose status is canceled, and only once the reservation is older than a safety window such as 24 hours. That age gate keeps it from racing an in-flight checkout that has not finished yet.
What happens when you delete a ReservationItem in Medusa?
Deleting a reservation calls the Inventory Module's deleteReservationItem logic, which decrements reserved_quantity on the corresponding inventory location level by the reservation's quantity. That frees the stock so stocked_quantity minus reserved_quantity, the available quantity, goes back up immediately.
Related field notes
Citations
On the problem:
- Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266
- Medusa Documentation: Inventory Module Concepts. docs.medusajs.com/resources/commerce-modules/inventory/concepts
- Medusa Admin User Guide: Manage Reservations. docs.medusajs.com/user-guide/inventory/reservations
On the solution:
- Medusa Documentation: Inventory Module. docs.medusajs.com/resources/commerce-modules/inventory
- Medusa Documentation: Inventory Module in Medusa Flows. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
- Medusa Documentation: ReservationItem, Inventory Module Data Models. docs.medusajs.com/resources/references/inventory-next/models/ReservationItem
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 free up your stock?
If this saved you from a phantom out of stock item or a support ticket about inventory that would not add up, 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