Skip to content

Diagnostic Stock Locations & Sales Channels

Medusa inventory decremented at the wrong stock location

An order comes in through your marketplace channel, and the confirmation email looks completely normal. But the warehouse tied to your main storefront just lost a unit it never shipped, while the warehouse that actually owns that channel never noticed the sale. Nothing errors. Nothing logs a warning. The only sign is a stock count that slowly drifts away from what is actually on the shelf. Here is why a reservation can land at a location that has nothing to do with the channel the order came from, and a small script that finds every order where that happened.

Python and Node.js Medusa Admin API Safe by default (dry run)
Plastic storage boxes
Photo by Adrian Sulyok on Unsplash
The short answer

In Medusa v2, a stock location's availability for a sale is supposed to be scoped by the sales channel the order was placed through, using the SalesChannelLocation link between the Stock Location and Sales Channel modules. A bug tracked as medusajs/medusa#10658 meant prepareConfirmInventoryInput, used by the cart completion and order edit workflows, collected every stock location tied to the product's inventory item into its candidate set without filtering by the order's own sales channel. When a product was stocked at more than one location, the reservation step could pick whichever location happened to be first in that unfiltered set, so an order placed through one channel could decrement stock that belongs to a completely different channel's warehouse. It was fixed in PR #10661. Run a small Python or Node.js script that cross-references each order's actual reservations against the stock locations linked to its sales channel, and reports every mismatch for a human to review. Full code, tests, and a dry run guard are below.

The problem in plain words

In Medusa v2, a sales channel is not supposed to see every stock location in the store. It only sees the ones linked to it through SalesChannelLocation, a stored link between the Stock Location module and the Sales Channel module. That link is what lets two warehouses serve two different storefronts out of the same Medusa instance without one channel accidentally selling the other's stock.

When a product is stocked at only one location, this scoping never comes up, because there is only one candidate location to reserve against anyway. The trouble starts when the same inventory item has location levels at more than one place, for example a warehouse tied to Channel A and a separate warehouse tied to Channel B. The reservation and fulfillment workflow is meant to pick a location level only from the set linked to the order's own sales channel. The bug in prepareConfirmInventoryInput, used by cart completion and by confirmOrderEditRequestWorkflow, built its candidate list from every stock location tied to the inventory item, with no filter for sales_channels?.id === salesChannelId. So an order placed through Channel B could still end up reserving, and later decrementing, stock at Channel A's location.

Order placed through Channel B Collect all locations on the inventory item no channel filter applied wrong candidate wins Reserve at Channel A's location Wrong stock pool decremented
The order came through Channel B, but the candidate set was never filtered down to Channel B's own linked locations, so the reservation quietly landed at Channel A's warehouse instead.

Why it happens

Since the reservation step is supposed to filter stock locations by the order's own sales channel and the bug skipped that filter, the failure only shows up once a product actually sits at more than one location. A few common ways stores end up here:

This is a common source of confusion because nothing about the order looks wrong. The customer gets their item, the order shows as fulfilled, and the mismatch only shows up later as unexplained drift between two warehouses' stock counts, usually discovered during a cycle count or when one location runs out sooner than the sales history for that location would suggest. See the citations at the end for the exact issue, the related checkout issue it surfaced alongside, and the fix.

The key insight

A mismatched reservation is not a stock count problem you can fix by editing a number. It is a scope problem: the reservation happened at a location outside the set the order's sales channel is actually linked to. Moving a reservation after the fact means crediting back the wrong location and debiting the correct one, and if the order already shipped, that adjustment can conflict with stock that is truly gone. So the safe pattern is "detect and report every mismatch," and only let a script correct a reservation that has not yet been fulfilled, always behind a dry run guard.

The fix, as a flow

We do not touch live orders directly. We add a check that, for each order, resolves the stock locations linked to its sales channel, reads back what the reservation actually used, and decides: if the reservation's location is in the channel's linked set, it is correct and left alone; if it is not, the order is flagged as a mismatch for a human to review before anything gets corrected.

Fetch order + channel GET /admin/orders/{id} Resolve linked locations *stock_locations on channel Read actual reservation GET /admin/reservations Location in linked set? yes, leave alone no flag mismatch human reviews it
The script only reports a mismatch. It never rewrites a reservation on its own, since crediting one location and debiting another safely requires a human to confirm the order has not shipped yet.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

Fetch an order with its sales channel and line items

Ask for the order with fields=id,display_id,sales_channel_id,*items,*items.variant. This gives you the exact sales channel the order was placed through and every line item, which is where the reservation trail starts.

step2.py
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_order(token, order_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/orders/{order_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,display_id,sales_channel_id,*items,*items.variant"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["order"]
step2.js
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 getOrder(orderId) {
  const { order } = await sdk.admin.order.retrieve(orderId, {
    fields: "id,display_id,sales_channel_id,*items,*items.variant",
  });
  return order;
}
3

Resolve the stock locations linked to that sales channel

Call GET /admin/sales-channels/{id} with fields=id,*stock_locations to get the set of stock location ids the order's channel is actually linked to. This is the same SalesChannelLocation link the reservation step is supposed to filter by.

step3.py
def get_sales_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"] or []
    return [loc["id"] for loc in locations]
step3.js
async function getSalesChannelLocationIds(salesChannelId) {
  const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
    fields: "id,*stock_locations",
  });
  return (sales_channel.stock_locations || []).map((loc) => loc.id);
}
4

Read the location levels and the actual reservation

For each line item's variant, resolve its inventory item with *inventory_items.inventory.location_levels, then pull every location level with GET /admin/inventory-items/{inventory_item_id}/location-levels. Then read what actually happened with GET /admin/reservations?line_item_id={line_item_id}, which returns the real location_id the reservation used.

step4.py
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()
    return r.json()["inventory_levels"]

def get_reservations_for_line_item(token, line_item_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/reservations",
        headers={"Authorization": f"Bearer {token}"},
        params={
            "line_item_id": line_item_id,
            "fields": "id,location_id,inventory_item_id,quantity,line_item_id",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["reservations"]
step4.js
async function getLocationLevels(inventoryItemId) {
  const { inventory_levels } = await sdk.admin.inventoryItem.listLocationLevels(inventoryItemId, {
    fields: "location_id,stocked_quantity,reserved_quantity",
  });
  return inventory_levels;
}

async function getReservationsForLineItem(lineItemId) {
  const { reservations } = await sdk.admin.reservation.list({
    line_item_id: lineItemId,
    fields: "id,location_id,inventory_item_id,quantity,line_item_id",
  });
  return reservations;
}
5

Decide, with one pure function

Keep the decision in its own function that takes the inventory item's location levels, the sales channel's linked location ids, and the reservation's actual location id, and returns the expected location plus whether it is a mismatch. It filters the location levels down to the ones linked to the channel, picks the first as the expected location, and compares it to what actually happened. No I/O, so it is directly testable with fixtures.

decide.py
def pick_expected_location_id(location_levels, sales_channel_location_ids, actual_location_id):
    linked_ids = set(sales_channel_location_ids)
    matches = [lvl for lvl in location_levels if lvl.get("location_id") in linked_ids]
    expected_location_id = matches[0]["location_id"] if matches else None
    is_mismatch = expected_location_id is not None and expected_location_id != actual_location_id
    return {"expected_location_id": expected_location_id, "is_mismatch": is_mismatch}
decide.js
export function pickExpectedLocationId(locationLevels, salesChannelLocationIds, actualLocationId) {
  const linkedIds = new Set(salesChannelLocationIds);
  const matches = locationLevels.filter((lvl) => linkedIds.has(lvl.location_id));
  const expectedLocationId = matches.length ? matches[0].location_id : null;
  const isMismatch = expectedLocationId !== null && expectedLocationId !== actualLocationId;
  return { expectedLocationId, isMismatch };
}
6

Wire it together, report only, correct only behind dry run

The run loop walks each order's line items, resolves the expected location, and logs a mismatch when found. It never rewrites a reservation on its own. When DRY_RUN is off and a mismatched order's items are not yet fulfilled, the script only logs the corrective plan, deleting the wrong reservation and recreating it at the expected location with the same inventory_item_id, line_item_id, and quantity, still gated behind the flag. Anything already fulfilled or shipped always falls back to a flag for manual stock adjustment.

Run it safe

Always start with DRY_RUN=true. Never let a script silently move a reservation for an order whose items have already been fulfilled or shipped, since that adjustment can conflict with stock that is truly gone. Flag those for a human to fix by hand.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs every mismatch it finds, and never writes a corrective reservation change unless dry run is off and the order's items are still unfulfilled.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
find_wrong_stock_location.py
"""Find Medusa orders whose reservation decremented stock at the wrong location.

A stock location's availability for a sale is supposed to be scoped by the
sales channel the order was placed through, using the SalesChannelLocation
link between the Stock Location and Sales Channel modules. A bug tracked as
medusajs/medusa issue 10658 meant the cart completion and order edit
workflows could collect every stock location tied to an inventory item
without filtering by the order's own sales channel, so a reservation could
land at a location that belongs to a different channel entirely. This walks
recent orders, resolves the expected location with a pure function, and
reports every mismatch. It never rewrites a reservation on its own; a
corrective plan is only logged, and only for orders whose items are not yet
fulfilled. Run once, or on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/medusa/inventory-wrong-stock-location/
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_wrong_stock_location")

BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
ADMIN_PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDER_LIMIT = int(os.environ.get("ORDER_LIMIT", "50"))


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_recent_orders(token, limit):
    r = requests.get(
        f"{BACKEND_URL}/admin/orders",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,display_id,sales_channel_id,*items,*items.variant", "limit": limit},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["orders"]


def get_sales_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"] or []
    return [loc["id"] for loc in locations]


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()
    return r.json()["inventory_levels"]


def get_reservations_for_line_item(token, line_item_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/reservations",
        headers={"Authorization": f"Bearer {token}"},
        params={
            "line_item_id": line_item_id,
            "fields": "id,location_id,inventory_item_id,quantity,line_item_id",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["reservations"]


def pick_expected_location_id(location_levels, sales_channel_location_ids, actual_location_id):
    """Pure decision function. No I/O.

    location_levels: [{"location_id": str, "stocked_quantity": number}, ...]
    sales_channel_location_ids: [str, ...]
    actual_location_id: str

    Returns {"expected_location_id": str | None, "is_mismatch": bool}.
    """
    linked_ids = set(sales_channel_location_ids)
    matches = [lvl for lvl in location_levels if lvl.get("location_id") in linked_ids]
    expected_location_id = matches[0]["location_id"] if matches else None
    is_mismatch = expected_location_id is not None and expected_location_id != actual_location_id
    return {"expected_location_id": expected_location_id, "is_mismatch": is_mismatch}


def item_is_fulfilled(item):
    return (item.get("fulfilled_quantity") or 0) > 0


def run():
    token = get_admin_token()
    orders = get_recent_orders(token, ORDER_LIMIT)

    channel_location_cache = {}
    flagged = 0
    corrected = 0

    for order in orders:
        sales_channel_id = order.get("sales_channel_id")
        if not sales_channel_id:
            continue
        if sales_channel_id not in channel_location_cache:
            channel_location_cache[sales_channel_id] = get_sales_channel_location_ids(token, sales_channel_id)
        linked_ids = channel_location_cache[sales_channel_id]

        for item in order.get("items") or []:
            variant = item.get("variant") or {}
            inventory_items = variant.get("inventory_items") or []
            for inv in inventory_items:
                inventory_item_id = (inv.get("inventory") or {}).get("id") or inv.get("inventory_item_id")
                if not inventory_item_id:
                    continue
                location_levels = get_location_levels(token, inventory_item_id)
                reservations = get_reservations_for_line_item(token, item["id"])
                for reservation in reservations:
                    decision = pick_expected_location_id(location_levels, linked_ids, reservation["location_id"])
                    if not decision["is_mismatch"]:
                        continue

                    flagged += 1
                    log.warning(
                        "Order %s: reservation %s used location %s, expected one linked to sales channel %s (%s)",
                        order.get("display_id"), reservation["id"], reservation["location_id"],
                        sales_channel_id, decision["expected_location_id"],
                    )

                    if item_is_fulfilled(item):
                        log.warning(
                            "Order %s: item already fulfilled, flagging for manual stock adjustment only",
                            order.get("display_id"),
                        )
                        continue

                    log.info(
                        "%s reservation %s: location %s -> %s",
                        "Would correct" if DRY_RUN else "Correcting",
                        reservation["id"], reservation["location_id"], decision["expected_location_id"],
                    )
                    if not DRY_RUN:
                        # Deliberately left as a logged plan. Recreating a reservation at the
                        # correct location is a destructive two-step write (delete then
                        # re-create) and should only run after an operator has confirmed the
                        # order is genuinely unfulfilled and the target location is correct.
                        log.warning(
                            "Order %s: DRY_RUN is off, but this script only reports. "
                            "Confirm manually, then delete reservation %s and recreate it "
                            "with location_id=%s before shipping.",
                            order.get("display_id"), reservation["id"], decision["expected_location_id"],
                        )
                    corrected += 1

    log.info("Done. %d mismatch(es) found, %d eligible for a guarded correction.", flagged, corrected)


if __name__ == "__main__":
    run()
find-wrong-stock-location.js
/**
 * Find Medusa orders whose reservation decremented stock at the wrong location.
 *
 * A stock location's availability for a sale is supposed to be scoped by the
 * sales channel the order was placed through, using the SalesChannelLocation
 * link between the Stock Location and Sales Channel modules. A bug tracked as
 * medusajs/medusa issue 10658 meant the cart completion and order edit
 * workflows could collect every stock location tied to an inventory item
 * without filtering by the order's own sales channel, so a reservation could
 * land at a location that belongs to a different channel entirely. This walks
 * recent orders, resolves the expected location with a pure function, and
 * reports every mismatch. It never rewrites a reservation on its own; a
 * corrective plan is only logged, and only for orders whose items are not yet
 * fulfilled. Run once, or on a schedule.
 *
 * Guide: https://www.allanninal.dev/medusa/inventory-wrong-stock-location/
 */
import { pathToFileURL } from "node:url";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ORDER_LIMIT = Number(process.env.ORDER_LIMIT || 50);

export function pickExpectedLocationId(locationLevels, salesChannelLocationIds, actualLocationId) {
  const linkedIds = new Set(salesChannelLocationIds);
  const matches = locationLevels.filter((lvl) => linkedIds.has(lvl.location_id));
  const expectedLocationId = matches.length ? matches[0].location_id : null;
  const isMismatch = expectedLocationId !== null && expectedLocationId !== actualLocationId;
  return { expectedLocationId, isMismatch };
}

function itemIsFulfilled(item) {
  return (item.fulfilled_quantity || 0) > 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 getRecentOrders(sdk, limit) {
  const { orders } = await sdk.admin.order.list({
    fields: "id,display_id,sales_channel_id,*items,*items.variant",
    limit,
  });
  return orders;
}

async function getSalesChannelLocationIds(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 getLocationLevels(sdk, inventoryItemId) {
  const { inventory_levels } = await sdk.admin.inventoryItem.listLocationLevels(inventoryItemId, {
    fields: "location_id,stocked_quantity,reserved_quantity",
  });
  return inventory_levels;
}

async function getReservationsForLineItem(sdk, lineItemId) {
  const { reservations } = await sdk.admin.reservation.list({
    line_item_id: lineItemId,
    fields: "id,location_id,inventory_item_id,quantity,line_item_id",
  });
  return reservations;
}

export async function run() {
  const sdk = await getSdk();
  const orders = await getRecentOrders(sdk, ORDER_LIMIT);

  const channelLocationCache = new Map();
  let flagged = 0;
  let corrected = 0;

  for (const order of orders) {
    const salesChannelId = order.sales_channel_id;
    if (!salesChannelId) continue;
    if (!channelLocationCache.has(salesChannelId)) {
      channelLocationCache.set(salesChannelId, await getSalesChannelLocationIds(sdk, salesChannelId));
    }
    const linkedIds = channelLocationCache.get(salesChannelId);

    for (const item of order.items || []) {
      const variant = item.variant || {};
      const inventoryItems = variant.inventory_items || [];
      for (const inv of inventoryItems) {
        const inventoryItemId = inv.inventory?.id || inv.inventory_item_id;
        if (!inventoryItemId) continue;
        const locationLevels = await getLocationLevels(sdk, inventoryItemId);
        const reservations = await getReservationsForLineItem(sdk, item.id);

        for (const reservation of reservations) {
          const decision = pickExpectedLocationId(locationLevels, linkedIds, reservation.location_id);
          if (!decision.isMismatch) continue;

          flagged++;
          console.warn(
            `Order ${order.display_id}: reservation ${reservation.id} used location ${reservation.location_id}, expected one linked to sales channel ${salesChannelId} (${decision.expectedLocationId})`
          );

          if (itemIsFulfilled(item)) {
            console.warn(`Order ${order.display_id}: item already fulfilled, flagging for manual stock adjustment only`);
            continue;
          }

          console.log(
            `${DRY_RUN ? "Would correct" : "Correcting"} reservation ${reservation.id}: location ${reservation.location_id} -> ${decision.expectedLocationId}`
          );
          if (!DRY_RUN) {
            // Deliberately left as a logged plan. Recreating a reservation at the correct
            // location is a destructive two-step write (delete then re-create) and should
            // only run after an operator has confirmed the order is genuinely unfulfilled
            // and the target location is correct.
            console.warn(
              `Order ${order.display_id}: DRY_RUN is off, but this script only reports. Confirm manually, then delete reservation ${reservation.id} and recreate it with location_id=${decision.expectedLocationId} before shipping.`
            );
          }
          corrected++;
        }
      }
    }
  }

  console.log(`Done. ${flagged} mismatch(es) found, ${corrected} eligible for a guarded correction.`);
}

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 real reservation gets flagged as sitting at the wrong warehouse. Because we kept pick_expected_location_id pure, the test needs no network and no Medusa backend. It just feeds in plain arrays and strings and checks the answer.

test_inventory_location_mismatch.py
from find_wrong_stock_location import pick_expected_location_id


def level(location_id, stocked_quantity=10):
    return {"location_id": location_id, "stocked_quantity": stocked_quantity}


def test_single_location_matches_and_is_not_a_mismatch():
    result = pick_expected_location_id([level("sloc_a")], ["sloc_a"], "sloc_a")
    assert result == {"expected_location_id": "sloc_a", "is_mismatch": False}


def test_multiple_channel_linked_locations_picks_first_match():
    levels = [level("sloc_b"), level("sloc_a")]
    result = pick_expected_location_id(levels, ["sloc_a", "sloc_b"], "sloc_b")
    assert result["expected_location_id"] == "sloc_b"
    assert result["is_mismatch"] is False


def test_reservation_at_unlinked_location_is_a_mismatch():
    levels = [level("sloc_a")]
    result = pick_expected_location_id(levels, ["sloc_a"], "sloc_z")
    assert result == {"expected_location_id": "sloc_a", "is_mismatch": True}


def test_no_matching_location_returns_none_and_no_mismatch():
    levels = [level("sloc_z")]
    result = pick_expected_location_id(levels, ["sloc_a"], "sloc_z")
    assert result == {"expected_location_id": None, "is_mismatch": False}


def test_reservation_already_correct_is_not_flagged():
    levels = [level("sloc_a"), level("sloc_b")]
    result = pick_expected_location_id(levels, ["sloc_a", "sloc_b"], "sloc_a")
    assert result["is_mismatch"] is False
mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { pickExpectedLocationId } from "./find-wrong-stock-location.js";

const level = (locationId, stockedQuantity = 10) => ({ location_id: locationId, stocked_quantity: stockedQuantity });

test("single location matches and is not a mismatch", () => {
  const result = pickExpectedLocationId([level("sloc_a")], ["sloc_a"], "sloc_a");
  assert.deepEqual(result, { expectedLocationId: "sloc_a", isMismatch: false });
});

test("multiple channel linked locations picks first match", () => {
  const levels = [level("sloc_b"), level("sloc_a")];
  const result = pickExpectedLocationId(levels, ["sloc_a", "sloc_b"], "sloc_b");
  assert.equal(result.expectedLocationId, "sloc_b");
  assert.equal(result.isMismatch, false);
});

test("reservation at unlinked location is a mismatch", () => {
  const levels = [level("sloc_a")];
  const result = pickExpectedLocationId(levels, ["sloc_a"], "sloc_z");
  assert.deepEqual(result, { expectedLocationId: "sloc_a", isMismatch: true });
});

test("no matching location returns null and no mismatch", () => {
  const levels = [level("sloc_z")];
  const result = pickExpectedLocationId(levels, ["sloc_a"], "sloc_z");
  assert.deepEqual(result, { expectedLocationId: null, isMismatch: false });
});

test("reservation already correct is not flagged", () => {
  const levels = [level("sloc_a"), level("sloc_b")];
  const result = pickExpectedLocationId(levels, ["sloc_a", "sloc_b"], "sloc_a");
  assert.equal(result.isMismatch, false);
});

Case studies

Marketplace expansion

Two warehouses, one inventory item, one quiet drift

A brand added a marketplace channel and stocked the same bestselling item at a second warehouse dedicated to that channel, alongside the original warehouse tied to their main storefront. For weeks, marketplace orders shipped correctly to customers, but the reservation behind each one had been decrementing the main storefront's warehouse instead of the marketplace warehouse.

Running the detection script against three months of orders surfaced dozens of mismatched reservations, all pointing the same direction. Nothing needed correcting for orders already shipped, since those were flagged for a manual stock adjustment, but the pattern made it clear the marketplace warehouse's counts had been silently wrong the whole time.

Order edits

An order edit that quietly reused the wrong pool

Support edited a wholesale order to add an extra unit after the customer called in. The edit went through confirmOrderEditRequestWorkflow, which at the time read the order's own sales_channel_id field instead of the resolved value from the workflow's transform input, so the new reservation it created ignored the channel scoping entirely.

The script flagged that single reservation immediately, since its location did not appear in the wholesale channel's linked stock location set. Because the order was still unfulfilled, an operator confirmed the correct warehouse and manually recreated the reservation there before the order shipped.

What good looks like

After this runs, every order's reservation is checked against the stock locations its own sales channel is actually linked to, and any mismatch surfaces with the exact expected location right next to what actually happened. Nothing gets silently rewritten. Orders still in a reserved state get a clear, dry run guarded correction path, and anything already fulfilled goes straight to a human for a manual stock adjustment, so the physical counts and the two channels' books stay honest.

FAQ

Why did my Medusa order decrement stock at the wrong location?

A product stocked at more than one location can be linked to multiple sales channels through the SalesChannelLocation link. A bug tracked as medusajs/medusa issue 10658 meant the cart completion and order edit workflows collected every location tied to the inventory item without filtering by the order's own sales channel, so the reservation step could pick a location that belongs to a different channel than the one the order was placed through.

Is it safe to automatically move a reservation to the correct location?

Not once the order has been fulfilled or shipped. Moving a reservation after the fact means crediting the wrong location and debiting the correct one, and that can conflict with stock that has already left the building. The safe default is to flag every mismatch, and only let a dry run guarded script recreate a reservation at the correct location while the order is still just reserved, never after fulfillment.

How do I check which stock location a Medusa reservation actually used?

Call GET /admin/reservations with line_item_id and fields=id,location_id,inventory_item_id,quantity,line_item_id. The location_id on the reservation is the actual location. Compare it against the set of stock location ids linked to the order's sales_channel_id, which you get from GET /admin/sales-channels/{id} with fields=id,*stock_locations. If the reservation's location_id is not in that set, the decrement happened at the wrong place.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Inventory Reduction Not Reflecting Correct Stock Location for Sales Channels. github.com/medusajs/medusa/issues/10658
  2. GitHub Issue: Can not check out with items from different stock locations even if they are stocked. github.com/medusajs/medusa/issues/10561
  3. GitHub Pull Request: fix(core-flows): select stock locations for reservation from correct SC. github.com/medusajs/medusa/pull/10661

On the solution:

  1. Medusa Documentation: Links between Stock Location Module and Other Modules. docs.medusajs.com/resources/commerce-modules/stock-location/links-to-other-modules
  2. Medusa Documentation: Stock Location Module. docs.medusajs.com/resources/commerce-modules/stock-location
  3. 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.

Contact me on LinkedIn

Did this explain a stock count that would not add up?

If this saved you from chasing a phantom inventory drift across two warehouses, 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

Back to all Medusa field notes