Diagnostic Stock Locations & Sales Channels
Checkout blocked for carts split across stock locations
A shopper adds two items to the cart, one is stocked in the East warehouse and the other in the West warehouse, and both locations are linked to the sales channel. Total stock is fine. Each item on its own is fine. But checkout fails at the reservation step, with no obvious reason why. Here is why Medusa v2 picks the wrong location for a split cart and a small script that finds the carts stuck on it.
When a cart's line items are stocked across two or more distinct stock locations that are all linked to the cart's sales channel, Medusa v2's confirm-inventory preparation step, prepare-confirm-inventory-input.ts, builds a single flattened list of candidate location IDs from all items combined, instead of keeping each item's valid locations scoped to itself. The downstream reserve-inventory workflow step then picks the first location in that merged list and tries to reserve every line item's quantity there, so an item only stocked at the second location fails to reserve. This is a known, still open upstream bug, medusajs/medusa#10561, not a merchant misconfiguration. Run a small Python or Node.js script that pulls the channel's linked stock locations, checks each cart item's real per-location availability, and flags any cart whose items have no single shared location even though each item has stock somewhere. Full code, tests, and a dry run guard are below.
The problem in plain words
In Medusa v2, completing a cart runs a workflow that ends with a step that reserves inventory for every line item. Before that step runs, a preparation function looks at each line item's inventory item, finds which stock locations could cover it, and puts together the input the reservation step will use.
The bug is in that preparation step. Instead of keeping each line item's own list of valid locations attached to that item, prepare-confirm-inventory-input.ts merges every item's candidate locations into one flat list for the whole cart. The reserve-inventory step then reads that merged list, picks the first location in it, and tries to reserve every single line item's quantity there. If item A is stocked at the East warehouse and item B is only stocked at the West warehouse, and East happens to come first in the merged list, item B has zero stock at East and the reservation for it fails. It fails even though the channel has plenty of stock for item B, just not at the location the step guessed.
Why it happens
Since the confirm-inventory preparation step flattens every item's candidate locations into a shared list before the reservation step ever runs, the reservation step never had per-item location information to work with in the first place. A few common ways stores end up hitting it:
- A sales channel is linked to two or more stock locations on purpose, for example a main warehouse and a regional overflow warehouse, and inventory is deliberately split between them.
- A popular item sells through at one location while a slower item still has stock at another, so a cart that happens to combine both crosses the boundary.
- A multi-location rollout added a second warehouse mid-migration, and some SKUs were only ever stocked at the new location while older SKUs stayed at the original one.
- Backorder and safety stock settings make it look like an item is purchasable everywhere, when in reality only one location has real stocked quantity for it.
This is a common source of confusion because nothing about the products or the sales channel looks wrong. Each item shows in stock on the storefront, the channel's linked locations look correct, and the failure only shows up at the very end of checkout, at the reservation step, with an error that does not point at which item or which location was the problem. This is a known, still open bug, tracked at medusajs/medusa#10561, and it shows up alongside related reports of inventory reduction not reflecting the correct location and cart completion failing at the reservation step for other reasons. See the citations at the end for the exact issues and docs.
The bug is not that stock is missing. It is that the reservation step loses track of which location goes with which item. So the fix is not "add more stock" or "relink the sales channel." It is "figure out, per item, which locations could actually cover it, and notice when no single location covers every item even though each item individually has somewhere it could be reserved." That is exactly what detection needs to compute, one item at a time, before touching anything.
The fix, as a flow
We do not touch the live cart completion workflow, and we do not guess a location on Medusa's behalf inside core. We add a check that reads the sales channel's linked stock locations, reads each stuck cart's line items and their real per-location stock, and computes each item's own valid locations. When those per-item sets have nothing in common, but every item still has at least one valid location of its own, the cart is flagged as a case of this bug.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: 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
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 DRY_RUN="true" // start safe, change to false to write
Get the sales channel's linked stock locations
The fields query param prefixes a relation with * to include it, so *stock_locations returns the set of sloc_ ids the channel actually resolves against. Only locations in this set count toward a valid reservation target.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
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 get_channel_location_ids(token, sales_channel_id):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels/{sales_channel_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*stock_locations"},
timeout=30,
)
r.raise_for_status()
locations = r.json()["sales_channel"]["stock_locations"]
return [loc["id"] for loc in locations]
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
return sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function getChannelLocationIds(salesChannelId) {
const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
fields: "id,*stock_locations",
});
return sales_channel.stock_locations.map((loc) => loc.id);
}
Read the stuck cart's items and each item's inventory item
Fetch the cart with its items and variants expanded, then look up each variant's inventory item id. This is the id you need to read location levels, since stock is tracked per inventory item, not per variant directly.
def get_cart_items(token, cart_id):
r = requests.get(
f"{BACKEND_URL}/store/carts/{cart_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,sales_channel_id,*items,items.variant"},
timeout=30,
)
r.raise_for_status()
return r.json()["cart"]
def get_variant_inventory_item_id(token, product_id, variant_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}/variants/{variant_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*inventory_items,inventory_items.inventory.id"},
timeout=30,
)
r.raise_for_status()
items = r.json()["variant"]["inventory_items"]
return items[0]["inventory"]["id"] if items else None
async function getCartItems(cartId) {
const { cart } = await sdk.store.cart.retrieve(cartId, {
fields: "id,sales_channel_id,*items,items.variant",
});
return cart;
}
async function getVariantInventoryItemId(productId, variantId) {
const { variant } = await sdk.admin.product.retrieveVariant(productId, variantId, {
fields: "id,*inventory_items,inventory_items.inventory.id",
});
const items = variant.inventory_items;
return items.length ? items[0].inventory.id : null;
}
Read each inventory item's location levels
List the location levels for the inventory item, and compute available quantity as stocked_quantity - reserved_quantity. This is the real, per-location number the reservation step needs, not the aggregate quantity shown on the product.
def get_location_levels(token, inventory_item_id):
r = requests.get(
f"{BACKEND_URL}/admin/inventory-items/{inventory_item_id}/location-levels",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "location_id,stocked_quantity,reserved_quantity"},
timeout=30,
)
r.raise_for_status()
levels = r.json()["inventory_levels"]
return [
{
"locationId": lvl["location_id"],
"stockedQuantity": lvl["stocked_quantity"],
"reservedQuantity": lvl["reserved_quantity"],
}
for lvl in levels
]
async function getLocationLevels(inventoryItemId) {
const { inventory_levels } = await sdk.admin.inventoryItem.listLocationLevels(inventoryItemId, {
fields: "location_id,stocked_quantity,reserved_quantity",
});
return inventory_levels.map((lvl) => ({
locationId: lvl.location_id,
stockedQuantity: lvl.stocked_quantity,
reservedQuantity: lvl.reserved_quantity,
}));
}
Decide, with one pure function
Keep the decision in its own function that takes each item's line item id, inventory item id, and required quantity, a lookup of location levels by inventory item, and the channel's location ids. It returns each item's own list of valid locations. A cart is affected when every item individually has at least one valid location, but the intersection across all items is empty, the exact signature of this bug.
def resolve_item_locations(items, levels_by_inventory_item, channel_location_ids):
channel_set = set(channel_location_ids)
results = []
for item in items:
levels = levels_by_inventory_item.get(item["inventoryItemId"], [])
valid_location_ids = [
lvl["locationId"]
for lvl in levels
if lvl["locationId"] in channel_set
and (lvl["stockedQuantity"] - lvl["reservedQuantity"]) >= item["requiredQty"]
]
results.append({"lineItemId": item["lineItemId"], "validLocationIds": valid_location_ids})
return results
def is_affected_cart(item_locations):
if not item_locations:
return False
if any(len(entry["validLocationIds"]) == 0 for entry in item_locations):
return False
shared = set(item_locations[0]["validLocationIds"])
for entry in item_locations[1:]:
shared &= set(entry["validLocationIds"])
return len(shared) == 0
export function resolveItemLocations(items, levelsByInventoryItem, channelLocationIds) {
const channelSet = new Set(channelLocationIds);
return items.map((item) => {
const levels = levelsByInventoryItem[item.inventoryItemId] || [];
const validLocationIds = levels
.filter(
(lvl) =>
channelSet.has(lvl.locationId) &&
lvl.stockedQuantity - lvl.reservedQuantity >= item.requiredQty
)
.map((lvl) => lvl.locationId);
return { lineItemId: item.lineItemId, validLocationIds };
});
}
export function isAffectedCart(itemLocations) {
if (!itemLocations.length) return false;
if (itemLocations.some((entry) => entry.validLocationIds.length === 0)) return false;
const shared = itemLocations.reduce(
(acc, entry) => new Set([...acc].filter((id) => entry.validLocationIds.includes(id))),
new Set(itemLocations[0].validLocationIds)
);
return shared.size === 0;
}
Confirm the signature with reservations, then wire it together
Before flagging, check GET /admin/reservations?line_item_id=... and confirm no reservation exists for one or more of the cart's items, the actual "stuck at reservation" signature. Only as a manual mitigation, guarded by DRY_RUN, create explicit reservations per item at its own correct location with POST /admin/reservations, using each item's own valid location, not the first one in any merged list. Leave DRY_RUN on until you have reviewed the flagged carts.
Always start with DRY_RUN=true. This script never edits the sales channel, the stock locations, or the cart. Its only possible write is creating an explicit per-item reservation as a manual mitigation for a cart you have reviewed, and even that only fires when DRY_RUN is false. The durable fix is a Medusa core-flows upgrade that resolves issue 10561, not a standing workaround.
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 only ever proposes a reservation payload per item at that item's own valid location.
"""Detect Medusa v2 carts stuck at checkout because their items are stocked
across two or more stock locations, a known upstream bug (medusajs/medusa#10561).
The confirm-inventory preparation step, prepare-confirm-inventory-input.ts, merges
every line item's valid stock locations into one flattened list instead of keeping
each item's valid locations scoped to itself. The reserve-inventory step then picks
the first location in that merged list and tries to reserve every item there, so an
item only stocked at a different location fails to reserve, even though the channel
has enough total stock. This lists a cart's items, computes each item's own valid
locations from real per-location stock, and flags the cart when no single location
covers every item, though each item has stock somewhere. Auto-repair is unsafe, since
Medusa v2 has no supported endpoint to force per-item reservation at cart completion,
so the only write here is an optional, DRY_RUN-guarded manual reservation per item at
its own correct location, meant as a one-off mitigation while you upgrade past the bug.
Guide: https://www.allanninal.dev/medusa/checkout-blocked-multi-location-cart/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_multi_location_cart")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
CART_ID = os.environ.get("CART_ID", "").strip() or None
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 get_channel_location_ids(token, sales_channel_id):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels/{sales_channel_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*stock_locations"},
timeout=30,
)
r.raise_for_status()
locations = r.json()["sales_channel"]["stock_locations"]
return [loc["id"] for loc in locations]
def get_cart(token, cart_id):
r = requests.get(
f"{BACKEND_URL}/store/carts/{cart_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,sales_channel_id,*items,items.variant"},
timeout=30,
)
r.raise_for_status()
return r.json()["cart"]
def get_variant_inventory_item_id(token, product_id, variant_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}/variants/{variant_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*inventory_items,inventory_items.inventory.id"},
timeout=30,
)
r.raise_for_status()
items = r.json()["variant"]["inventory_items"]
return items[0]["inventory"]["id"] if items else None
def get_location_levels(token, inventory_item_id):
r = requests.get(
f"{BACKEND_URL}/admin/inventory-items/{inventory_item_id}/location-levels",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "location_id,stocked_quantity,reserved_quantity"},
timeout=30,
)
r.raise_for_status()
levels = r.json()["inventory_levels"]
return [
{
"locationId": lvl["location_id"],
"stockedQuantity": lvl["stocked_quantity"],
"reservedQuantity": lvl["reserved_quantity"],
}
for lvl in levels
]
def has_reservation(token, line_item_id):
r = requests.get(
f"{BACKEND_URL}/admin/reservations",
headers={"Authorization": f"Bearer {token}"},
params={"line_item_id": line_item_id},
timeout=30,
)
r.raise_for_status()
return len(r.json().get("reservations", [])) > 0
def create_reservation(token, line_item_id, inventory_item_id, location_id, quantity):
r = requests.post(
f"{BACKEND_URL}/admin/reservations",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={
"line_item_id": line_item_id,
"inventory_item_id": inventory_item_id,
"location_id": location_id,
"quantity": quantity,
},
timeout=30,
)
r.raise_for_status()
return r.json()
def resolve_item_locations(items, levels_by_inventory_item, channel_location_ids):
"""Pure decision function. No I/O.
items: [{"lineItemId": str, "inventoryItemId": str, "requiredQty": int}, ...]
levels_by_inventory_item: {inventoryItemId: [{"locationId": str, "stockedQuantity": int,
"reservedQuantity": int}, ...]}
channel_location_ids: [str, ...]
Returns [{"lineItemId": str, "validLocationIds": [str, ...]}, ...], one entry per
item, in the same order, where validLocationIds is every channel-linked location
that has enough available stock (stockedQuantity - reservedQuantity) for that item.
"""
channel_set = set(channel_location_ids)
results = []
for item in items:
levels = levels_by_inventory_item.get(item["inventoryItemId"], [])
valid_location_ids = [
lvl["locationId"]
for lvl in levels
if lvl["locationId"] in channel_set
and (lvl["stockedQuantity"] - lvl["reservedQuantity"]) >= item["requiredQty"]
]
results.append({"lineItemId": item["lineItemId"], "validLocationIds": valid_location_ids})
return results
def is_affected_cart(item_locations):
"""Pure decision function. No I/O.
A cart is a stuck-at-reservation candidate when every item has at least one valid
location of its own, but no single location is valid for every item at once, the
signature of medusajs/medusa#10561. An empty cart, or a cart where some item has
zero valid locations (a real out-of-stock case, not this bug), is not affected.
"""
if not item_locations:
return False
if any(len(entry["validLocationIds"]) == 0 for entry in item_locations):
return False
shared = set(item_locations[0]["validLocationIds"])
for entry in item_locations[1:]:
shared &= set(entry["validLocationIds"])
return len(shared) == 0
def run():
if not CART_ID:
raise RuntimeError("Set CART_ID to the cart you want to check.")
token = get_admin_token()
cart = get_cart(token, CART_ID)
channel_location_ids = get_channel_location_ids(token, cart["sales_channel_id"])
items = []
inventory_item_by_line_item = {}
for line_item in cart["items"]:
variant = line_item["variant"]
inventory_item_id = get_variant_inventory_item_id(token, variant["product_id"], variant["id"])
if not inventory_item_id:
continue
items.append({
"lineItemId": line_item["id"],
"inventoryItemId": inventory_item_id,
"requiredQty": line_item["quantity"],
})
inventory_item_by_line_item[line_item["id"]] = inventory_item_id
levels_by_inventory_item = {
item["inventoryItemId"]: get_location_levels(token, item["inventoryItemId"])
for item in items
}
item_locations = resolve_item_locations(items, levels_by_inventory_item, channel_location_ids)
affected = is_affected_cart(item_locations)
if not affected:
log.info("Cart %s: not affected. Items share a common valid location, or one has none at all.", CART_ID)
return
unreserved = [entry for entry in item_locations if not has_reservation(token, entry["lineItemId"])]
log.warning(
"Cart %s is affected by medusajs/medusa#10561: no shared location across items, "
"%d item(s) missing a reservation.",
CART_ID, len(unreserved),
)
for entry in item_locations:
log.info(" line item %s valid locations: %s", entry["lineItemId"], entry["validLocationIds"])
for entry in unreserved:
location_id = entry["validLocationIds"][0]
inventory_item_id = inventory_item_by_line_item[entry["lineItemId"]]
required_qty = next(i["requiredQty"] for i in items if i["lineItemId"] == entry["lineItemId"])
payload = {
"line_item_id": entry["lineItemId"],
"inventory_item_id": inventory_item_id,
"location_id": location_id,
"quantity": required_qty,
}
log.info("%s reservation: %s", "Would create" if DRY_RUN else "Creating", payload)
if not DRY_RUN:
create_reservation(token, **{
"line_item_id": payload["line_item_id"],
"inventory_item_id": payload["inventory_item_id"],
"location_id": payload["location_id"],
"quantity": payload["quantity"],
})
log.info("Done. %d item(s) %s a manual reservation.", len(unreserved), "would need" if DRY_RUN else "given")
if __name__ == "__main__":
run()
/**
* Detect Medusa v2 carts stuck at checkout because their items are stocked
* across two or more stock locations, a known upstream bug (medusajs/medusa#10561).
*
* The confirm-inventory preparation step, prepare-confirm-inventory-input.ts, merges
* every line item's valid stock locations into one flattened list instead of keeping
* each item's valid locations scoped to itself. The reserve-inventory step then picks
* the first location in that merged list and tries to reserve every item there, so an
* item only stocked at a different location fails to reserve, even though the channel
* has enough total stock. This lists a cart's items, computes each item's own valid
* locations from real per-location stock, and flags the cart when no single location
* covers every item, though each item has stock somewhere. Auto-repair is unsafe, since
* Medusa v2 has no supported endpoint to force per-item reservation at cart completion,
* so the only write here is an optional, DRY_RUN-guarded manual reservation per item at
* its own correct location, meant as a one-off mitigation while you upgrade past the bug.
*
* Guide: https://www.allanninal.dev/medusa/checkout-blocked-multi-location-cart/
*/
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 CART_ID = (process.env.CART_ID || "").trim() || null;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* Pure decision function. No I/O.
*
* @param {{ lineItemId: string, inventoryItemId: string, requiredQty: number }[]} items
* @param {Record<string, { locationId: string, stockedQuantity: number, reservedQuantity: number }[]>} levelsByInventoryItem
* @param {string[]} channelLocationIds
* @returns {{ lineItemId: string, validLocationIds: string[] }[]}
*/
export function resolveItemLocations(items, levelsByInventoryItem, channelLocationIds) {
const channelSet = new Set(channelLocationIds);
return items.map((item) => {
const levels = levelsByInventoryItem[item.inventoryItemId] || [];
const validLocationIds = levels
.filter(
(lvl) =>
channelSet.has(lvl.locationId) &&
lvl.stockedQuantity - lvl.reservedQuantity >= item.requiredQty
)
.map((lvl) => lvl.locationId);
return { lineItemId: item.lineItemId, validLocationIds };
});
}
/**
* Pure decision function. No I/O.
*
* A cart is a stuck-at-reservation candidate when every item has at least one valid
* location of its own, but no single location is valid for every item at once, the
* signature of medusajs/medusa#10561.
*
* @param {{ lineItemId: string, validLocationIds: string[] }[]} itemLocations
* @returns {boolean}
*/
export function isAffectedCart(itemLocations) {
if (!itemLocations.length) return false;
if (itemLocations.some((entry) => entry.validLocationIds.length === 0)) return false;
const shared = itemLocations.reduce(
(acc, entry) => new Set([...acc].filter((id) => entry.validLocationIds.includes(id))),
new Set(itemLocations[0].validLocationIds)
);
return shared.size === 0;
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
return sdk;
}
async function getChannelLocationIds(sdk, salesChannelId) {
const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
fields: "id,*stock_locations",
});
return sales_channel.stock_locations.map((loc) => loc.id);
}
async function getCart(sdk, cartId) {
const { cart } = await sdk.store.cart.retrieve(cartId, {
fields: "id,sales_channel_id,*items,items.variant",
});
return cart;
}
async function getVariantInventoryItemId(sdk, productId, variantId) {
const { variant } = await sdk.admin.product.retrieveVariant(productId, variantId, {
fields: "id,*inventory_items,inventory_items.inventory.id",
});
const items = variant.inventory_items;
return items.length ? items[0].inventory.id : null;
}
async function getLocationLevels(sdk, inventoryItemId) {
const { inventory_levels } = await sdk.admin.inventoryItem.listLocationLevels(inventoryItemId, {
fields: "location_id,stocked_quantity,reserved_quantity",
});
return inventory_levels.map((lvl) => ({
locationId: lvl.location_id,
stockedQuantity: lvl.stocked_quantity,
reservedQuantity: lvl.reserved_quantity,
}));
}
async function hasReservation(sdk, lineItemId) {
const { reservations } = await sdk.admin.reservation.list({ line_item_id: lineItemId });
return reservations.length > 0;
}
async function createReservation(sdk, payload) {
return sdk.admin.reservation.create(payload);
}
export async function run() {
if (!CART_ID) throw new Error("Set CART_ID to the cart you want to check.");
const sdk = await getSdk();
const cart = await getCart(sdk, CART_ID);
const channelLocationIds = await getChannelLocationIds(sdk, cart.sales_channel_id);
const items = [];
const inventoryItemByLineItem = new Map();
for (const lineItem of cart.items) {
const variant = lineItem.variant;
const inventoryItemId = await getVariantInventoryItemId(sdk, variant.product_id, variant.id);
if (!inventoryItemId) continue;
items.push({ lineItemId: lineItem.id, inventoryItemId, requiredQty: lineItem.quantity });
inventoryItemByLineItem.set(lineItem.id, inventoryItemId);
}
const levelsByInventoryItem = {};
for (const item of items) {
levelsByInventoryItem[item.inventoryItemId] = await getLocationLevels(sdk, item.inventoryItemId);
}
const itemLocations = resolveItemLocations(items, levelsByInventoryItem, channelLocationIds);
const affected = isAffectedCart(itemLocations);
if (!affected) {
console.log(`Cart ${CART_ID}: not affected. Items share a common valid location, or one has none at all.`);
return;
}
const unreserved = [];
for (const entry of itemLocations) {
if (!(await hasReservation(sdk, entry.lineItemId))) unreserved.push(entry);
}
console.warn(
`Cart ${CART_ID} is affected by medusajs/medusa#10561: no shared location across items, ${unreserved.length} item(s) missing a reservation.`
);
for (const entry of itemLocations) {
console.log(` line item ${entry.lineItemId} valid locations: ${JSON.stringify(entry.validLocationIds)}`);
}
for (const entry of unreserved) {
const locationId = entry.validLocationIds[0];
const inventoryItemId = inventoryItemByLineItem.get(entry.lineItemId);
const requiredQty = items.find((i) => i.lineItemId === entry.lineItemId).requiredQty;
const payload = {
line_item_id: entry.lineItemId,
inventory_item_id: inventoryItemId,
location_id: locationId,
quantity: requiredQty,
};
console.log(`${DRY_RUN ? "Would create" : "Creating"} reservation: ${JSON.stringify(payload)}`);
if (!DRY_RUN) await createReservation(sdk, payload);
}
console.log(`Done. ${unreserved.length} item(s) ${DRY_RUN ? "would need" : "given"} a manual reservation.`);
}
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 cart gets flagged as hitting this bug at all. Because we kept resolveItemLocations and isAffectedCart pure, the tests need no network and no Medusa backend. They just feed in plain arrays and objects and check the answer.
from detect_multi_location_cart import resolve_item_locations, is_affected_cart
def level(location_id, stocked, reserved=0):
return {"locationId": location_id, "stockedQuantity": stocked, "reservedQuantity": reserved}
def test_disjoint_locations_flags_the_cart():
items = [
{"lineItemId": "item_a", "inventoryItemId": "iitem_a", "requiredQty": 1},
{"lineItemId": "item_b", "inventoryItemId": "iitem_b", "requiredQty": 1},
]
levels = {
"iitem_a": [level("sloc_east", 5)],
"iitem_b": [level("sloc_west", 5)],
}
item_locations = resolve_item_locations(items, levels, ["sloc_east", "sloc_west"])
assert item_locations == [
{"lineItemId": "item_a", "validLocationIds": ["sloc_east"]},
{"lineItemId": "item_b", "validLocationIds": ["sloc_west"]},
]
assert is_affected_cart(item_locations) is True
def test_shared_location_is_not_affected():
items = [
{"lineItemId": "item_a", "inventoryItemId": "iitem_a", "requiredQty": 1},
{"lineItemId": "item_b", "inventoryItemId": "iitem_b", "requiredQty": 1},
]
levels = {
"iitem_a": [level("sloc_east", 5), level("sloc_west", 5)],
"iitem_b": [level("sloc_west", 5)],
}
item_locations = resolve_item_locations(items, levels, ["sloc_east", "sloc_west"])
assert is_affected_cart(item_locations) is False
def test_item_with_no_valid_location_is_a_real_stockout_not_this_bug():
items = [
{"lineItemId": "item_a", "inventoryItemId": "iitem_a", "requiredQty": 10},
{"lineItemId": "item_b", "inventoryItemId": "iitem_b", "requiredQty": 1},
]
levels = {
"iitem_a": [level("sloc_east", 2)],
"iitem_b": [level("sloc_west", 5)],
}
item_locations = resolve_item_locations(items, levels, ["sloc_east", "sloc_west"])
assert item_locations[0]["validLocationIds"] == []
assert is_affected_cart(item_locations) is False
def test_locations_outside_the_channel_are_excluded():
items = [{"lineItemId": "item_a", "inventoryItemId": "iitem_a", "requiredQty": 1}]
levels = {"iitem_a": [level("sloc_unlinked", 100)]}
item_locations = resolve_item_locations(items, levels, ["sloc_east"])
assert item_locations == [{"lineItemId": "item_a", "validLocationIds": []}]
assert is_affected_cart(item_locations) is False
def test_insufficient_quantity_at_a_location_excludes_it():
items = [{"lineItemId": "item_a", "inventoryItemId": "iitem_a", "requiredQty": 5}]
levels = {"iitem_a": [level("sloc_east", 4)]}
item_locations = resolve_item_locations(items, levels, ["sloc_east"])
assert item_locations[0]["validLocationIds"] == []
def test_empty_cart_is_not_affected():
assert is_affected_cart([]) is False
def test_reserved_quantity_reduces_available_stock():
items = [{"lineItemId": "item_a", "inventoryItemId": "iitem_a", "requiredQty": 3}]
levels = {"iitem_a": [level("sloc_east", 5, reserved=3)]}
item_locations = resolve_item_locations(items, levels, ["sloc_east"])
assert item_locations[0]["validLocationIds"] == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveItemLocations, isAffectedCart } from "./detect-multi-location-cart.js";
const level = (locationId, stocked, reserved = 0) => ({
locationId,
stockedQuantity: stocked,
reservedQuantity: reserved,
});
test("disjoint locations flags the cart", () => {
const items = [
{ lineItemId: "item_a", inventoryItemId: "iitem_a", requiredQty: 1 },
{ lineItemId: "item_b", inventoryItemId: "iitem_b", requiredQty: 1 },
];
const levels = {
iitem_a: [level("sloc_east", 5)],
iitem_b: [level("sloc_west", 5)],
};
const itemLocations = resolveItemLocations(items, levels, ["sloc_east", "sloc_west"]);
assert.deepEqual(itemLocations, [
{ lineItemId: "item_a", validLocationIds: ["sloc_east"] },
{ lineItemId: "item_b", validLocationIds: ["sloc_west"] },
]);
assert.equal(isAffectedCart(itemLocations), true);
});
test("shared location is not affected", () => {
const items = [
{ lineItemId: "item_a", inventoryItemId: "iitem_a", requiredQty: 1 },
{ lineItemId: "item_b", inventoryItemId: "iitem_b", requiredQty: 1 },
];
const levels = {
iitem_a: [level("sloc_east", 5), level("sloc_west", 5)],
iitem_b: [level("sloc_west", 5)],
};
const itemLocations = resolveItemLocations(items, levels, ["sloc_east", "sloc_west"]);
assert.equal(isAffectedCart(itemLocations), false);
});
test("item with no valid location is a real stockout, not this bug", () => {
const items = [
{ lineItemId: "item_a", inventoryItemId: "iitem_a", requiredQty: 10 },
{ lineItemId: "item_b", inventoryItemId: "iitem_b", requiredQty: 1 },
];
const levels = {
iitem_a: [level("sloc_east", 2)],
iitem_b: [level("sloc_west", 5)],
};
const itemLocations = resolveItemLocations(items, levels, ["sloc_east", "sloc_west"]);
assert.deepEqual(itemLocations[0].validLocationIds, []);
assert.equal(isAffectedCart(itemLocations), false);
});
test("locations outside the channel are excluded", () => {
const items = [{ lineItemId: "item_a", inventoryItemId: "iitem_a", requiredQty: 1 }];
const levels = { iitem_a: [level("sloc_unlinked", 100)] };
const itemLocations = resolveItemLocations(items, levels, ["sloc_east"]);
assert.deepEqual(itemLocations, [{ lineItemId: "item_a", validLocationIds: [] }]);
assert.equal(isAffectedCart(itemLocations), false);
});
test("insufficient quantity at a location excludes it", () => {
const items = [{ lineItemId: "item_a", inventoryItemId: "iitem_a", requiredQty: 5 }];
const levels = { iitem_a: [level("sloc_east", 4)] };
const itemLocations = resolveItemLocations(items, levels, ["sloc_east"]);
assert.deepEqual(itemLocations[0].validLocationIds, []);
});
test("empty cart is not affected", () => {
assert.equal(isAffectedCart([]), false);
});
test("reserved quantity reduces available stock", () => {
const items = [{ lineItemId: "item_a", inventoryItemId: "iitem_a", requiredQty: 3 }];
const levels = { iitem_a: [level("sloc_east", 5, 3)] };
const itemLocations = resolveItemLocations(items, levels, ["sloc_east"]);
assert.deepEqual(itemLocations[0].validLocationIds, []);
});
Case studies
The cart that mixed a best seller and a slow mover
A store split inventory across a main warehouse and a smaller overflow warehouse for slower moving SKUs. A shopper added a best selling item, stocked at the main warehouse, alongside a niche accessory that had sold through everywhere except the overflow location. Checkout failed at the very last step with no useful message, and support assumed the accessory was actually out of stock.
Running the detection script against the stuck cart id showed the best seller's only valid location was the main warehouse and the accessory's only valid location was the overflow warehouse, an empty intersection with both items individually in stock. That confirmed it was medusajs/medusa#10561, not a real stockout, and a manual per-item reservation unblocked the order while the team scheduled a core-flows upgrade.
The migration that split stock mid rollout
A merchant was migrating from one location to two during a warehouse split. Half the catalog had already been moved to the new location, half was still on the old one, and the sales channel was linked to both on purpose so nothing would go out of stock during the cutover. Any cart that combined an already-migrated SKU with a not-yet-migrated one started failing at checkout, right in the middle of the migration window.
The team ran the script across a batch of recently stuck cart ids, confirmed every one showed the same disjoint-location signature, and used the flagged output to prioritize which SKUs to finish migrating first, instead of chasing a false inventory shortage.
After this runs, every cart stuck at the reservation step gets a clear answer: is it really out of stock, or is it this location-merging bug. Flagged carts come with the exact per-item valid locations, so a manual reservation, when needed, always targets each item's own correct location instead of guessing. The real fix is tracked against upstream, and this script buys time and clarity until that lands.
FAQ
Why does my Medusa checkout fail when a cart has items from two stock locations?
When a cart's line items are stocked across two or more locations linked to the sales channel, Medusa v2's confirm-inventory preparation step merges every item's valid locations into one flattened list, then the reserve-inventory step picks the first location in that list and tries to reserve every item there. An item that is only stocked at the second location has nothing to reserve at the wrongly chosen first location, so the reservation fails even though total stock across the channel is enough.
Is this a store misconfiguration or a Medusa bug?
It is a known, still open upstream bug in Medusa core, tracked as medusajs/medusa issue 10561. The confirm-inventory input builder does not keep each line item's valid locations scoped to that item, so the reservation step has no way to know an item needs a specific location. No amount of store configuration fixes it, since the flattening happens in Medusa's own workflow step code.
Can I safely auto-fix carts stuck at the reservation step?
Not automatically. Medusa v2 has no supported admin endpoint to force per-item reservation at a specific location during cart completion, so the safe pattern is to flag each stuck cart with its merged-versus-actual location sets, then, only under a DRY_RUN guard, create explicit reservations per item at its own correct location before retrying checkout. The durable fix is upgrading to a Medusa core-flows release that resolves issue 10561.
Related field notes
Citations
On the problem:
- GitHub Issue: Can not check out with items from different stock locations even if they are stocked. github.com/medusajs/medusa/issues/10561
- GitHub Issue: Inventory reduction not reflecting correct stock location for sales channels. github.com/medusajs/medusa/issues/10658
- GitHub Issue: Complete cart workflow fails at reserve-inventory-step despite Allow Backorders setting. github.com/medusajs/medusa/issues/13892
On the solution:
- Medusa Documentation: Inventory Module concepts, reservations, inventory levels, location_id. docs.medusajs.com/resources/commerce-modules/inventory/concepts
- Medusa Admin User Guide: Manage reservations in the Medusa admin. docs.medusajs.com/user-guide/inventory/reservations
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, 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 a stuck checkout?
If this saved you from chasing a phantom out of stock error, 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