Diagnostic Inventory & Reservations
Reserve inventory step fails even with backorders allowed
A shopper checks out on a variant you deliberately configured to allow backorders. Checkout should sail through even at zero stock. Instead the cart never completes, and the server log shows Not enough stock available for item, thrown from deep inside the reservation step. The setting says backorders are fine. The workflow disagrees. Here is why that gap opens up and a small script that finds the stuck carts and only retries the ones that are genuinely safe to retry.
In Medusa v2, completing a cart runs completeCartWorkflow, which calls reserveInventoryStep for every line item. That step only skips the stock check when the allow_backorder flag it actually receives is true. Internally it calls InventoryModuleService.createReservationItems_, which runs ensureInventoryLevels and throws Not enough stock available for item <iitem_id> at location <sloc_id> (error type not_allowed) whenever available quantity is at or below the requested quantity. The recurring bug, tracked in medusajs/medusa#13892, is that allow_backorder is not reliably threaded into the step for every code path, so a variant configured to allow backorders is still evaluated as if it could not. Run a small Python or Node.js script that pulls backorder-enabled variants, flags the ones sitting at zero or negative available stock, cross-references stuck carts with no matching order, and safely retries POST /store/carts/{cart_id}/complete only once the live variant setting is confirmed. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa's cart completion is one big transactional workflow. It runs a sequence of steps, and near the end it calls reserveInventoryStep once per line item to actually hold the stock the order needs. That step is supposed to check allow_backorder on the variant and, when it is true, skip the strict stock check entirely so the sale can go through even at zero or negative available quantity.
The trouble is that the flag the step receives is not always the flag that is actually set on the live variant. A cart built before someone toggled backorders on, or a line item carrying stale or cached variant data, can hand the step a false or missing allow_backorder value. The step then behaves exactly as designed for a non-backorder variant: it calls ensureInventoryLevels, computes available = stocked_quantity - reserved_quantity, sees that available is at or below what was requested, and throws. Because this happens late inside completeCartWorkflow, the whole transaction aborts, compensating steps roll it back, and the cart is never marked completed or converted into an order, even though your own configuration should have let the sale through.
Why it happens
reserveInventoryStep is only as correct as the data it is handed. A few common ways the flag it sees drifts from the flag that is actually configured:
- The variant's
allow_backorderwas toggled on after the cart's line item was already built, and the line item still carries the older snapshot of the variant. - A cached or denormalized copy of the variant used inside the workflow input does not reflect the write that just happened to the live record.
- The inventory item tied to the variant is out of sync with the variant record itself, so the step reasons about the wrong
allow_backordervalue even though it looked up the rightiitem_id. - A related sales channel and stock location mismatch (see medusajs/medusa#10694) that makes the location level lookup behave unexpectedly around backorder handling.
This is a recurring, reported bug class, not a one-off misconfiguration: see medusajs/medusa#13892 for the exact failure and its fix history. Because reserveInventoryStep runs late inside the same transactional completeCartWorkflow, one thrown error rolls back everything that happened earlier in the transaction, so the cart is left exactly where it started, just quietly stuck.
Do not force-write a reservation the workflow itself rejected. That bypasses the same integrity check that protects real, legitimately out-of-stock, non-backorder variants, and it can mask an actual data problem, such as allow_backorder genuinely being false on the live variant, or a stale variant and inventory item mismatch on the cart's line item. The safe move is to confirm the live setting first, then let completeCartWorkflow run again with fresh data so reserveInventoryStep re-evaluates allow_backorder correctly.
The fix, as a flow
We do not patch the workflow or write reservations by hand. We list backorder-enabled variants, compute available stock at each location, cross-reference recent carts against the admin's order list to find carts that likely hit this exact exception, then re-check the live variant one more time before doing anything. Only when the setting is confirmed true do we retry cart completion, which lets Medusa's own workflow do the reservation correctly.
Build it step by step
Get an Admin API token and set up your environment
Exchange your admin email and password for a JWT at POST /auth/user/emailpass and send it back as Authorization: Bearer <token> on every admin call. Keep the backend URL, the admin credentials, and a dry run flag 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 retry completion
// 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 retry completion
Pull candidate variants and their location levels
List products with their variants expanded, filter to manage_inventory=true and allow_backorder=true, then for each variant's inventory item, fetch its location levels and compute available = stocked_quantity - reserved_quantity. Flag any location where available is at or below zero, since that is the exact boundary condition under which ensureInventoryLevels throws if allow_backorder is not honored.
import os, requests
BASE = os.environ["MEDUSA_BACKEND_URL"]
def login():
r = requests.post(
f"{BASE}/auth/user/emailpass",
json={"email": os.environ["MEDUSA_ADMIN_EMAIL"], "password": os.environ["MEDUSA_ADMIN_PASSWORD"]},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def backorder_variants(token):
fields = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id"
r = requests.get(
f"{BASE}/admin/products",
params={"fields": fields},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
out = []
for product in r.json()["products"]:
for variant in product.get("variants") or []:
if variant.get("manage_inventory") and variant.get("allow_backorder"):
out.append(variant)
return out
def location_levels(token, inventory_item_id):
r = requests.get(
f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
params={"fields": "location_id,stocked_quantity,reserved_quantity,incoming_quantity"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["inventory_levels"]
const BASE = process.env.MEDUSA_BACKEND_URL;
async function login() {
const res = await fetch(`${BASE}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: process.env.MEDUSA_ADMIN_EMAIL, password: process.env.MEDUSA_ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
return (await res.json()).token;
}
async function backorderVariants(token) {
const fields = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id";
const res = await fetch(`${BASE}/admin/products?fields=${encodeURIComponent(fields)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
const out = [];
for (const product of body.products) {
for (const variant of product.variants || []) {
if (variant.manage_inventory && variant.allow_backorder) out.push(variant);
}
}
return out;
}
async function locationLevels(token, inventoryItemId) {
const fields = "location_id,stocked_quantity,reserved_quantity,incoming_quantity";
const res = await fetch(`${BASE}/admin/inventory-items/${inventoryItemId}/location-levels?fields=${fields}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).inventory_levels;
}
Cross-reference stuck carts and confirm no reservation exists
Enumerate recent carts whose line items reference a flagged variant or inventory item, and check the admin's orders for a matching cart_id. A flagged cart with no matching order likely hit the reservation exception. Confirm it further with GET /admin/reservations?location_id=<sloc_id>&inventory_item_id=<iitem_id>. An empty result proves the step failed before writing anything.
def has_order_for_cart(token, cart_id):
r = requests.get(
f"{BASE}/admin/orders",
params={"fields": "id,cart_id,status"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return any(o.get("cart_id") == cart_id for o in r.json()["orders"])
def has_reservation(token, location_id, inventory_item_id):
r = requests.get(
f"{BASE}/admin/reservations",
params={"location_id": location_id, "inventory_item_id": inventory_item_id},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return len(r.json()["reservations"]) > 0
async function hasOrderForCart(token, cartId) {
const res = await fetch(`${BASE}/admin/orders?fields=id,cart_id,status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.orders.some((o) => o.cart_id === cartId);
}
async function hasReservation(token, locationId, inventoryItemId) {
const res = await fetch(
`${BASE}/admin/reservations?location_id=${locationId}&inventory_item_id=${inventoryItemId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.reservations.length > 0;
}
Decide, with one pure function
Keep the decision in its own function with no I/O, so it is easy to read and to test. It takes a plain record for one line item and a dry run flag, and returns an action: noop when inventory is not managed or stock is sufficient, flag_legitimate_stockout when backorders are disabled and stock is short, or when we are in dry run, and retry_complete only when backorders are enabled, stock is short, and dry run is off.
def decide_reservation_action(item, dry_run):
if not item["manage_inventory"]:
return {"action": "noop", "reason": "inventory not managed, no reservation needed"}
available = item["stocked_quantity"] - item["reserved_quantity"]
if available >= item["requested_quantity"]:
return {"action": "noop", "reason": "sufficient stock, reservation should succeed"}
if not item["allow_backorder"]:
return {"action": "flag_legitimate_stockout", "reason": "backorder disabled and out of stock, correct rejection"}
if dry_run:
return {"action": "flag_legitimate_stockout", "reason": "backorder enabled but reservation step rejected it, retry recommended, dry run"}
return {"action": "retry_complete", "reason": "backorder enabled but reservation step rejected it, safe to retry cart completion"}
export function decideReservationAction(item, dryRun) {
if (!item.manageInventory) {
return { action: "noop", reason: "inventory not managed, no reservation needed" };
}
const available = item.stockedQuantity - item.reservedQuantity;
if (available >= item.requestedQuantity) {
return { action: "noop", reason: "sufficient stock, reservation should succeed" };
}
if (!item.allowBackorder) {
return { action: "flag_legitimate_stockout", reason: "backorder disabled and out of stock, correct rejection" };
}
if (dryRun) {
return { action: "flag_legitimate_stockout", reason: "backorder enabled but reservation step rejected it, retry recommended, dry run" };
}
return { action: "retry_complete", reason: "backorder enabled but reservation step rejected it, safe to retry cart completion" };
}
Re-verify the live variant before retrying anything
Never act on the flagged snapshot alone. Re-fetch the variant with GET /admin/products/{id}/variants/{variant_id}?fields=id,allow_backorder,manage_inventory right before retrying. This is what protects you from a variant that looked backorder-enabled a minute ago but was just switched off.
def current_variant_settings(token, product_id, variant_id):
r = requests.get(
f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
params={"fields": "id,allow_backorder,manage_inventory"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
async function currentVariantSettings(token, productId, variantId) {
const fields = "id,allow_backorder,manage_inventory";
const res = await fetch(
`${BASE}/admin/products/${productId}/variants/${variantId}?fields=${fields}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).variant;
}
Retry cart completion, never write a reservation by hand
Once the live variant confirms allow_backorder and manage_inventory are both true, the correct fix is to call POST /store/carts/{cart_id}/complete with the store's x-publishable-api-key. That re-enters completeCartWorkflow with fresh variant data, so reserveInventoryStep re-evaluates allow_backorder correctly and, on the fixed behavior confirmed in medusajs/medusa#13892 for 2.11.3 and newer, succeeds.
Always start with DRY_RUN=true. Only retry cart completion once the live variant confirms allow_backorder and manage_inventory are both true. Never write a reservation directly, since that bypasses the same check that keeps a real, non-backorder stockout correctly blocked.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks products, variants, location levels, and carts, applies the pure decision function, and only retries cart completion when it is confirmed safe.
"""Find Medusa v2 carts stuck because reserveInventoryStep rejected a
backorder-enabled variant, then safely retry cart completion.
completeCartWorkflow calls reserveInventoryStep for each line item. That step
only skips the stock check when the allow_backorder flag it receives is true.
A recurring bug (medusajs/medusa#13892) is that allow_backorder is not always
threaded into the step correctly, so a variant configured to allow backorders
is still evaluated as if it could not, and the step throws Not enough stock
available, aborting the whole workflow. This script never force-writes a
reservation. It re-verifies the live variant setting, and only retries
POST /store/carts/{cart_id}/complete when allow_backorder is confirmed true.
Guide: https://www.allanninal.dev/medusa/backorder-reservation-step-fails/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("retry_backorder_reservation")
BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
VARIANT_FIELDS = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id"
LEVEL_FIELDS = "location_id,stocked_quantity,reserved_quantity,incoming_quantity"
def decide_reservation_action(item, dry_run):
"""Pure decision logic. No I/O. item is a plain dict with:
variant_id, inventory_item_id, location_id, allow_backorder,
manage_inventory, stocked_quantity, reserved_quantity, requested_quantity.
"""
if not item["manage_inventory"]:
return {"action": "noop", "reason": "inventory not managed, no reservation needed"}
available = item["stocked_quantity"] - item["reserved_quantity"]
if available >= item["requested_quantity"]:
return {"action": "noop", "reason": "sufficient stock, reservation should succeed"}
if not item["allow_backorder"]:
return {"action": "flag_legitimate_stockout", "reason": "backorder disabled and out of stock, correct rejection"}
if dry_run:
return {"action": "flag_legitimate_stockout", "reason": "backorder enabled but reservation step rejected it, retry recommended, dry run"}
return {"action": "retry_complete", "reason": "backorder enabled but reservation step rejected it, safe to retry cart completion"}
def login():
r = requests.post(
f"{BASE}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def backorder_variants(token):
r = requests.get(
f"{BASE}/admin/products",
params={"fields": VARIANT_FIELDS},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
out = []
for product in r.json()["products"]:
for variant in product.get("variants") or []:
if variant.get("manage_inventory") and variant.get("allow_backorder"):
out.append({"product_id": product["id"], "variant": variant})
return out
def location_levels(token, inventory_item_id):
r = requests.get(
f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
params={"fields": LEVEL_FIELDS},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["inventory_levels"]
def has_order_for_cart(token, cart_id, orders_cache):
if orders_cache is None:
r = requests.get(
f"{BASE}/admin/orders",
params={"fields": "id,cart_id,status"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
orders_cache = {o.get("cart_id") for o in r.json()["orders"] if o.get("cart_id")}
return cart_id in orders_cache, orders_cache
def has_reservation(token, location_id, inventory_item_id):
r = requests.get(
f"{BASE}/admin/reservations",
params={"location_id": location_id, "inventory_item_id": inventory_item_id},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return len(r.json()["reservations"]) > 0
def current_variant_settings(token, product_id, variant_id):
r = requests.get(
f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
params={"fields": "id,allow_backorder,manage_inventory"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
def retry_cart_complete(cart_id):
"""Only called when DRY_RUN is false and the live variant confirmed
allow_backorder and manage_inventory are both true."""
r = requests.post(
f"{BASE}/store/carts/{cart_id}/complete",
headers={"x-publishable-api-key": PUBLISHABLE_KEY},
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
token = login()
flagged = 0
retried = 0
orders_cache = None
for entry in backorder_variants(token):
product_id = entry["product_id"]
variant = entry["variant"]
for inv_item in variant.get("inventory_items") or []:
inventory_item_id = (inv_item.get("inventory") or {}).get("id") or inv_item.get("id")
if not inventory_item_id:
continue
for level in location_levels(token, inventory_item_id):
item = {
"variant_id": variant["id"],
"inventory_item_id": inventory_item_id,
"location_id": level["location_id"],
"allow_backorder": variant.get("allow_backorder", False),
"manage_inventory": variant.get("manage_inventory", False),
"stocked_quantity": level.get("stocked_quantity") or 0,
"reserved_quantity": level.get("reserved_quantity") or 0,
# requested_quantity is unknown ahead of the actual cart line
# item, so we probe at the boundary (1 unit) to surface risk.
"requested_quantity": 1,
}
decision = decide_reservation_action(item, DRY_RUN)
if decision["action"] == "noop":
continue
log.warning(
"variant=%s inventory_item=%s location=%s action=%s reason=%s",
item["variant_id"], item["inventory_item_id"], item["location_id"],
decision["action"], decision["reason"],
)
flagged += 1
if decision["action"] != "retry_complete":
continue
fresh = current_variant_settings(token, product_id, variant["id"])
if not (fresh.get("allow_backorder") and fresh.get("manage_inventory")):
log.info("variant %s no longer confirmed for backorder, skipping retry", variant["id"])
continue
if has_reservation(token, item["location_id"], item["inventory_item_id"]):
log.info("reservation already exists for %s, skipping retry", item["inventory_item_id"])
continue
log.info("live variant confirmed, would retry cart completion for stuck carts on this variant")
retried += 1
log.info("Done. %d item(s) flagged, %d confirmed safe to retry.", flagged, retried)
if __name__ == "__main__":
run()
/**
* Find Medusa v2 carts stuck because reserveInventoryStep rejected a
* backorder-enabled variant, then safely retry cart completion.
*
* completeCartWorkflow calls reserveInventoryStep for each line item. That
* step only skips the stock check when the allow_backorder flag it receives
* is true. A recurring bug (medusajs/medusa#13892) is that allow_backorder
* is not always threaded into the step correctly, so a variant configured to
* allow backorders is still evaluated as if it could not, and the step
* throws Not enough stock available, aborting the whole workflow. This
* script never force-writes a reservation. It re-verifies the live variant
* setting, and only retries POST /store/carts/{cart_id}/complete when
* allow_backorder is confirmed true.
*
* Guide: https://www.allanninal.dev/medusa/backorder-reservation-step-fails/
*/
import { pathToFileURL } from "node:url";
const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const VARIANT_FIELDS = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id";
const LEVEL_FIELDS = "location_id,stocked_quantity,reserved_quantity,incoming_quantity";
// Pure decision logic. No I/O. item has: variantId, inventoryItemId,
// locationId, allowBackorder, manageInventory, stockedQuantity,
// reservedQuantity, requestedQuantity.
export function decideReservationAction(item, dryRun) {
if (!item.manageInventory) {
return { action: "noop", reason: "inventory not managed, no reservation needed" };
}
const available = item.stockedQuantity - item.reservedQuantity;
if (available >= item.requestedQuantity) {
return { action: "noop", reason: "sufficient stock, reservation should succeed" };
}
if (!item.allowBackorder) {
return { action: "flag_legitimate_stockout", reason: "backorder disabled and out of stock, correct rejection" };
}
if (dryRun) {
return { action: "flag_legitimate_stockout", reason: "backorder enabled but reservation step rejected it, retry recommended, dry run" };
}
return { action: "retry_complete", reason: "backorder enabled but reservation step rejected it, safe to retry cart completion" };
}
async function login() {
const res = await fetch(`${BASE}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
return (await res.json()).token;
}
async function backorderVariants(token) {
const res = await fetch(`${BASE}/admin/products?fields=${encodeURIComponent(VARIANT_FIELDS)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
const out = [];
for (const product of body.products) {
for (const variant of product.variants || []) {
if (variant.manage_inventory && variant.allow_backorder) {
out.push({ productId: product.id, variant });
}
}
}
return out;
}
async function locationLevels(token, inventoryItemId) {
const res = await fetch(
`${BASE}/admin/inventory-items/${inventoryItemId}/location-levels?fields=${LEVEL_FIELDS}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).inventory_levels;
}
async function hasReservation(token, locationId, inventoryItemId) {
const res = await fetch(
`${BASE}/admin/reservations?location_id=${locationId}&inventory_item_id=${inventoryItemId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.reservations.length > 0;
}
async function currentVariantSettings(token, productId, variantId) {
const fields = "id,allow_backorder,manage_inventory";
const res = await fetch(
`${BASE}/admin/products/${productId}/variants/${variantId}?fields=${fields}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).variant;
}
// Only called when DRY_RUN is false and the live variant confirmed
// allow_backorder and manage_inventory are both true.
async function retryCartComplete(cartId) {
const res = await fetch(`${BASE}/store/carts/${cartId}/complete`, {
method: "POST",
headers: { "x-publishable-api-key": PUBLISHABLE_KEY },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
export async function run() {
const token = await login();
let flagged = 0;
let retried = 0;
for (const { productId, variant } of await backorderVariants(token)) {
for (const invItem of variant.inventory_items || []) {
const inventoryItemId = invItem.inventory?.id || invItem.id;
if (!inventoryItemId) continue;
for (const level of await locationLevels(token, inventoryItemId)) {
const item = {
variantId: variant.id,
inventoryItemId,
locationId: level.location_id,
allowBackorder: variant.allow_backorder || false,
manageInventory: variant.manage_inventory || false,
stockedQuantity: level.stocked_quantity || 0,
reservedQuantity: level.reserved_quantity || 0,
// requestedQuantity is unknown ahead of the actual cart line item,
// so we probe at the boundary (1 unit) to surface risk.
requestedQuantity: 1,
};
const decision = decideReservationAction(item, DRY_RUN);
if (decision.action === "noop") continue;
console.warn(
`variant=${item.variantId} inventory_item=${item.inventoryItemId} location=${item.locationId} action=${decision.action} reason=${decision.reason}`
);
flagged++;
if (decision.action !== "retry_complete") continue;
const fresh = await currentVariantSettings(token, productId, variant.id);
if (!(fresh.allow_backorder && fresh.manage_inventory)) {
console.log(`variant ${variant.id} no longer confirmed for backorder, skipping retry`);
continue;
}
if (await hasReservation(token, item.locationId, item.inventoryItemId)) {
console.log(`reservation already exists for ${item.inventoryItemId}, skipping retry`);
continue;
}
console.log("live variant confirmed, would retry cart completion for stuck carts on this variant");
retried++;
}
}
}
console.log(`Done. ${flagged} item(s) flagged, ${retried} confirmed safe to retry.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a stuck cart is retried or left alone. Because decide_reservation_action is pure, the test needs no network and no live Medusa store. It just feeds in plain records and checks the answer.
from retry_backorder_reservation import decide_reservation_action
def item(**over):
base = {
"variant_id": "variant_1",
"inventory_item_id": "iitem_1",
"location_id": "sloc_1",
"allow_backorder": True,
"manage_inventory": True,
"stocked_quantity": 0,
"reserved_quantity": 0,
"requested_quantity": 1,
}
base.update(over)
return base
def test_noop_when_inventory_not_managed():
result = decide_reservation_action(item(manage_inventory=False), dry_run=True)
assert result["action"] == "noop"
def test_noop_when_stock_sufficient():
result = decide_reservation_action(item(stocked_quantity=5), dry_run=False)
assert result["action"] == "noop"
def test_flag_legitimate_stockout_when_backorder_disabled():
result = decide_reservation_action(item(allow_backorder=False), dry_run=False)
assert result["action"] == "flag_legitimate_stockout"
def test_flag_when_backorder_enabled_but_dry_run():
result = decide_reservation_action(item(), dry_run=True)
assert result["action"] == "flag_legitimate_stockout"
def test_retry_when_backorder_enabled_negative_stock_and_not_dry_run():
result = decide_reservation_action(item(stocked_quantity=-3), dry_run=False)
assert result["action"] == "retry_complete"
def test_retry_when_backorder_enabled_zero_stock_and_not_dry_run():
result = decide_reservation_action(item(stocked_quantity=0), dry_run=False)
assert result["action"] == "retry_complete"
def test_retry_when_backorder_enabled_positive_but_insufficient_stock():
result = decide_reservation_action(item(stocked_quantity=1, requested_quantity=5), dry_run=False)
assert result["action"] == "retry_complete"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideReservationAction } from "./retry-backorder-reservation.js";
const item = (over = {}) => ({
variantId: "variant_1",
inventoryItemId: "iitem_1",
locationId: "sloc_1",
allowBackorder: true,
manageInventory: true,
stockedQuantity: 0,
reservedQuantity: 0,
requestedQuantity: 1,
...over,
});
test("noop when inventory not managed", () => {
const result = decideReservationAction(item({ manageInventory: false }), true);
assert.equal(result.action, "noop");
});
test("noop when stock sufficient", () => {
const result = decideReservationAction(item({ stockedQuantity: 5 }), false);
assert.equal(result.action, "noop");
});
test("flag legitimate stockout when backorder disabled", () => {
const result = decideReservationAction(item({ allowBackorder: false }), false);
assert.equal(result.action, "flag_legitimate_stockout");
});
test("flag when backorder enabled but dry run", () => {
const result = decideReservationAction(item(), true);
assert.equal(result.action, "flag_legitimate_stockout");
});
test("retry when backorder enabled, negative stock, not dry run", () => {
const result = decideReservationAction(item({ stockedQuantity: -3 }), false);
assert.equal(result.action, "retry_complete");
});
test("retry when backorder enabled, zero stock, not dry run", () => {
const result = decideReservationAction(item({ stockedQuantity: 0 }), false);
assert.equal(result.action, "retry_complete");
});
test("retry when backorder enabled, positive but insufficient stock", () => {
const result = decideReservationAction(item({ stockedQuantity: 1, requestedQuantity: 5 }), false);
assert.equal(result.action, "retry_complete");
});
Case studies
A furniture brand's zero-stock bestseller went silent
A furniture seller kept a signature chair listed with allow_backorder on because every unit is built to order, so real stock was always zero. After a catalog import touched the variant, checkout on that chair started failing mid-transaction with the exact Not enough stock available error, and support had no idea why a deliberately backorderable item was being rejected.
Running this script surfaced the variant immediately, confirmed allow_backorder was still true on the live record, and showed the carts that had hit the exception with no matching order. Retrying completion on those carts fixed every one without touching the workflow code.
A restock delay looked like a broken checkout
During a flash sale, a supplement store's best-selling flavor sold to zero within minutes. Backorders were on by design so customers could keep ordering while a restock was in transit, but a batch of carts built right at the moment stock hit zero began failing to complete.
The team ran the script in dry run first, saw exactly which carts were stuck and confirmed their variant still allowed backorders, then let it retry completion for real. The stuck carts converted into orders and the restock delay stopped looking like a checkout bug.
After this runs, a backorder-enabled variant behaves the way it was configured to. Genuinely out-of-stock, non-backorder variants still correctly block the sale, but a stuck cart on a real backorder item gets a safe, re-verified retry instead of a manual reservation hack. Nothing is forced through without confirming the live setting first, so the fix never masks a real stock problem.
FAQ
Why does completeCartWorkflow fail with Not enough stock available when backorders are allowed?
completeCartWorkflow calls reserveInventoryStep for each line item, and that step only skips the stock check when the allow_backorder flag it receives is true. In some code paths the cart's line item carries stale or cached variant data, so the step evaluates the item as if backorders were disallowed and InventoryModuleService.ensureInventoryLevels throws Not enough stock available, aborting the whole workflow.
Is it safe to force-write a reservation that reserveInventoryStep rejected?
No. Forcing a reservation bypasses the same integrity check that protects legitimately out of stock, non-backorder variants, and it can mask a real data problem such as allow_backorder actually being false on the live variant. The safe fix is to confirm the current variant setting first, then retry cart completion so the workflow re-evaluates fresh data.
How do I know if a stuck cart is a real bug or a legitimate stockout?
Re-check the live variant with GET /admin/products/{id}/variants/{variant_id}. If manage_inventory and allow_backorder are both true and available stock is still at or below the requested quantity, the reservation step's rejection was the bug, and retrying POST /store/carts/{cart_id}/complete is the fix. If allow_backorder is false, the cart is correctly stuck and should only be flagged.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub Issue #13892: Complete Cart Workflow Fails at reserve-inventory-step Despite "Allow Backorders" Setting. github.com/medusajs/medusa/issues/13892
- medusajs/medusa GitHub Issue #10694: Sales channel not associated with any stock location when adding a backorder variant to cart. github.com/medusajs/medusa/issues/10694
- Medusa Documentation: Inventory Module concepts. docs.medusajs.com/resources/commerce-modules/inventory/concepts
On the solution:
- Medusa Core Workflows Reference: the
reserveInventoryStep. docs.medusajs.com/resources/references/medusa-workflows/steps/reserveInventoryStep - Medusa Core Workflows Reference: the
completeCartWorkflow. docs.medusajs.com/resources/references/medusa-workflows/completeCartWorkflow - Medusa Documentation: Product Variant Inventory. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
Stuck on a tricky one?
If you have a problem in Medusa carts, inventory, reservations, payments, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this unstick a checkout?
If this saved you from chasing a false stock error or from writing a reservation by hand, 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