Repair Inventory and stock

Oversold variant goes negative

A support ticket says a customer got an order confirmation for something that was not actually in the warehouse. You open the inventory item in the Medusa admin and the available quantity reads a negative number. Nothing crashed. No error was logged. The stock just went underwater. Here is why Medusa lets reserved quantity outrun stocked quantity under concurrent checkout traffic, and a script that finds every oversold location level and helps you repair it safely.

Python and Node.js Medusa Admin API Dry run by default, confirm required to write
The short answer

Medusa computes available stock as stocked_quantity minus reserved_quantity on an inventory level, and that check runs inside the reserve-inventory workflow step. Under concurrent checkout traffic, retried webhooks, or a direct external write to stock levels, the read then write on that level is not always serialized, so reserved_quantity can end up higher than stocked_quantity. Medusa does not enforce a non-negative constraint on stocked_quantity, so the row just sits there with negative available stock. Run a small Python or Node.js script that lists inventory items with their location levels, flags any level where available is negative or reservations exceed stock (skipping variants with allow_backorder on, since that is expected there), cross-checks against live reservations, and in dry run only logs the recount a human should approve before a --confirm write resets the level.

The problem in plain words

In Medusa v2, every inventory item can sit at more than one stock location, and each location has its own level with a stocked_quantity and a reserved_quantity. Available stock for that level is simply stocked_quantity - reserved_quantity, and the reserve-inventory workflow step compares that against the requested quantity, honoring allow_backorder when it is set on the variant.

That check is only as good as the write that follows it. When two carts complete checkout close together, when a webhook gets retried after a timeout and reprocesses the same reservation, or when a custom integration or ERP sync writes stocked_quantity directly outside the reservation flow, the read-then-write is not always serialized. One reservation gets counted twice, or the stock gets decremented after a reservation already accounted for it, and reserved_quantity ends up bigger than stocked_quantity. There is no database constraint stopping stocked_quantity from going negative or stopping reserved_quantity from exceeding it, so the row just persists that way. Nothing errors. Nothing rolls back. The number quietly goes negative and stays negative until a human notices and fixes it by hand.

Cart A checkout reserves last unit Cart B checkout near simultaneous Reserve-inventory step read then write, not always serialized under load no error, no rollback reserved > stocked no CHECK constraint Available goes negative
Two near-simultaneous checkouts, or a bad external write, race past a reserve-inventory check that is not always serialized. Nothing stops reserved quantity from exceeding stocked quantity.

Why it happens

Medusa's availability check is correct as written, but it depends on the write happening exactly once per reservation and on stock levels only changing through the reservation flow. A few concrete ways that breaks down:

This is a common source of confusion because Medusa's admin UI will show the negative number plainly, but nothing proactively tells you it happened or why. There is also a known issue where cancelling a fulfillment does not behave correctly once a variant's stock is already negative, which compounds the mess. See the citations at the end for the exact docs and issue.

The key insight

A negative available quantity is a symptom, not something to paper over with a guess. Writing an arbitrary stocked_quantity like zero can mask a real fulfillment or accounting problem: maybe those units really were sold and need restocking, maybe a reservation is stuck and should be removed instead. The safe pattern is to recompute a real count from data Medusa already has, primarily the sum of legitimate open reservations tied to unfulfilled orders, log that recommendation for a human to review, and only write it after an explicit confirmation. Backorder-enabled variants are excluded entirely, because going negative there is by design.

The fix, as a flow

We do not touch checkout and we do not silently rewrite stock. We list every inventory item's location levels, flag the ones where reserved exceeds stocked or available is negative on a variant that does not allow backorder, cross-check against the live reservations for that item and location to rule out a stuck duplicate, and in dry run only log the proposed real count. A write only happens with an explicit human confirmation.

List inventory levels stocked, reserved Compute decision available, isOversold Cross-check reservations sum of open reservations Oversold and no backorder? yes no, level is fine Log recount write needs --confirm
The script only recommends a recount. It never writes a location level without an explicit confirmation, because an arbitrary stocked_quantity can mask a real fulfillment problem.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend and an admin user with rights to read inventory items, reservations, and products. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.

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, a write also needs --confirm
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, a write also needs --confirm
2

Authenticate against the Admin API

Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.

step2.py
import os, requests

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]
step2.js
import Medusa from "@medusajs/js-sdk";

const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;

const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });

async function login() {
  await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
  return sdk;
}
3

List inventory items with their location levels

Ask for every inventory item's SKU and expand location_levels plus the location on each one, paging with limit and offset so a large catalog is fully covered. Each level carries the raw stocked_quantity and reserved_quantity we need for the decision.

step3.py
def list_inventory_items(token):
    headers = {"Authorization": f"Bearer {token}"}
    fields = "id,sku,*location_levels,*location_levels.location"
    out, offset, limit = [], 0, 200
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/inventory-items",
            params={"fields": fields, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["inventory_items"])
        offset += limit
        if offset >= body["count"]:
            return out
step3.js
async function listInventoryItems(sdk) {
  const fields = "id,sku,*location_levels,*location_levels.location";
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const body = await sdk.admin.inventoryItem.list({ fields, limit, offset });
    out.push(...body.inventory_items);
    offset += limit;
    if (offset >= body.count) return out;
  }
}
4

Decide, with one pure function

Keep the decision in a function with no network calls, so it is easy to read and easy to test. Available is stocked_quantity minus reserved_quantity. A level is oversold only when available is negative or reserved exceeds stocked, and the variant does not allow backorder, since a backorder variant is expected to go negative by design. When flagged, the proposed count is the larger of the current stocked quantity and the open reservations total, so the fix never drops below what live orders have already reserved.

decide.py
def decide_inventory_repair(level, open_reservations_total):
    """Pure: level is {inventoryItemId, locationId, stockedQuantity,
    reservedQuantity, allowBackorder}. Returns the repair decision."""
    stocked = level["stockedQuantity"]
    reserved = level["reservedQuantity"]
    available = stocked - reserved

    is_oversold = (available < 0 or reserved > stocked) and not level["allowBackorder"]

    if not is_oversold:
        return {
            "isOversold": False,
            "available": available,
            "reason": "ok",
            "proposedStockedQuantity": None,
        }

    reason = "reserved_exceeds_stock" if reserved > stocked else "negative_available"
    proposed = max(stocked, open_reservations_total)
    return {
        "isOversold": True,
        "available": available,
        "reason": reason,
        "proposedStockedQuantity": proposed,
    }
decide.js
export function decideInventoryRepair(level, openReservationsTotal) {
  // Pure: level is { inventoryItemId, locationId, stockedQuantity,
  // reservedQuantity, allowBackorder }. Returns the repair decision.
  const { stockedQuantity: stocked, reservedQuantity: reserved, allowBackorder } = level;
  const available = stocked - reserved;

  const isOversold = (available < 0 || reserved > stocked) && !allowBackorder;

  if (!isOversold) {
    return { isOversold: false, available, reason: "ok", proposedStockedQuantity: null };
  }

  const reason = reserved > stocked ? "reserved_exceeds_stock" : "negative_available";
  const proposedStockedQuantity = Math.max(stocked, openReservationsTotal);
  return { isOversold: true, available, reason, proposedStockedQuantity };
}
5

Cross-check against live reservations

For every flagged level, pull the open reservations for that inventory item and location, and sum their quantity. If the total does not match reserved_quantity, the level's reserved number is likely stuck or duplicated rather than a true oversell, and that sum is exactly the open_reservations_total the decision function needs.

reservations.py
def open_reservations_total(token, inventory_item_id, location_id):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 200
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/reservations",
            params={
                "location_id": location_id,
                "inventory_item_id": inventory_item_id,
                "limit": limit,
                "offset": offset,
            },
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["reservations"])
        offset += limit
        if offset >= body["count"]:
            break
    return sum(res["quantity"] for res in out)
reservations.js
async function openReservationsTotal(sdk, inventoryItemId, locationId) {
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const body = await sdk.admin.reservation.list({
      location_id: locationId,
      inventory_item_id: inventoryItemId,
      limit,
      offset,
    });
    out.push(...body.reservations);
    offset += limit;
    if (offset >= body.count) break;
  }
  return out.reduce((sum, res) => sum + res.quantity, 0);
}
6

Log the proposed recount, write only with --confirm

In dry run, only log {inventory_item_id, location_id, current stocked_quantity, current reserved_quantity, computed available, proposed realCount} for a human to review. Only when DRY_RUN is false and the run was passed --confirm, call POST /admin/inventory-items/{inventory_item_id}/location-levels/{location_id} with {"stocked_quantity": realCount} to reset the level to the reconciled count. This is a direct correction, not a delta adjustment, and it never runs without that explicit confirmation.

Run it safe

Always start with DRY_RUN=true and review the logged recounts before anything writes. Writing an arbitrary stocked_quantity can mask a real fulfillment or accounting problem, so the script requires an explicit --confirm flag before it issues the location-level write, and it never bulk-deletes reservations without matching each one to a live, unfulfilled order first.

The full code

Here is the complete script in one file for each language. It authenticates, lists inventory items and their location levels, computes the repair decision with a pure function, cross-checks live reservations, and logs or writes the recount depending on DRY_RUN and --confirm.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

repair_oversold_inventory.py
"""Find Medusa inventory levels where reserved quantity exceeds stocked
quantity (an oversold variant with negative available stock).
Never writes a location level without --confirm. Safe to run again and again.
"""
import os
import sys
import logging

import requests

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

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ITEM_FIELDS = "id,sku,*location_levels,*location_levels.location"


def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_inventory_items(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 200
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/inventory-items",
            params={"fields": ITEM_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["inventory_items"])
        offset += limit
        if offset >= body["count"]:
            return out


def open_reservations_total(token, inventory_item_id, location_id):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 200
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/reservations",
            params={
                "location_id": location_id,
                "inventory_item_id": inventory_item_id,
                "limit": limit,
                "offset": offset,
            },
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["reservations"])
        offset += limit
        if offset >= body["count"]:
            break
    return sum(res["quantity"] for res in out)


def decide_inventory_repair(level, open_reservations_total_value):
    """Pure: no I/O. level is {inventoryItemId, locationId, stockedQuantity,
    reservedQuantity, allowBackorder}."""
    stocked = level["stockedQuantity"]
    reserved = level["reservedQuantity"]
    available = stocked - reserved

    is_oversold = (available < 0 or reserved > stocked) and not level["allowBackorder"]

    if not is_oversold:
        return {
            "isOversold": False,
            "available": available,
            "reason": "ok",
            "proposedStockedQuantity": None,
        }

    reason = "reserved_exceeds_stock" if reserved > stocked else "negative_available"
    proposed = max(stocked, open_reservations_total_value)
    return {
        "isOversold": True,
        "available": available,
        "reason": reason,
        "proposedStockedQuantity": proposed,
    }


def write_location_level(token, inventory_item_id, location_id, real_count):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/inventory-items/{inventory_item_id}/location-levels/{location_id}",
        json={"stocked_quantity": real_count},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    confirm = "--confirm" in sys.argv
    token = get_token()
    items = list_inventory_items(token)

    flagged = 0
    for item in items:
        for lvl in item.get("location_levels") or []:
            level = {
                "inventoryItemId": item["id"],
                "locationId": lvl["location_id"],
                "stockedQuantity": lvl["stocked_quantity"],
                "reservedQuantity": lvl["reserved_quantity"],
                "allowBackorder": bool(lvl.get("allow_backorder")),
            }
            reserved_total = open_reservations_total(token, level["inventoryItemId"], level["locationId"])
            decision = decide_inventory_repair(level, reserved_total)
            if not decision["isOversold"]:
                continue

            flagged += 1
            log.warning(
                "Inventory item %s (sku %s) at location %s: %s. stocked=%s reserved=%s "
                "available=%s proposed realCount=%s",
                item["id"], item.get("sku"), level["locationId"], decision["reason"],
                level["stockedQuantity"], level["reservedQuantity"],
                decision["available"], decision["proposedStockedQuantity"],
            )

            if not DRY_RUN and confirm:
                write_location_level(
                    token, level["inventoryItemId"], level["locationId"],
                    decision["proposedStockedQuantity"],
                )
                log.info("Wrote stocked_quantity=%s for item %s at location %s.",
                         decision["proposedStockedQuantity"], item["id"], level["locationId"])

    if flagged == 0:
        log.info("No oversold inventory levels found across %d item(s).", len(items))
        return

    if DRY_RUN or not confirm:
        log.info("Done. %d level(s) flagged. Re-run with DRY_RUN=false and --confirm to write.", flagged)
    else:
        log.info("Done. %d level(s) flagged and repaired.", flagged)


if __name__ == "__main__":
    run()
repair-oversold-inventory.js
/**
 * Find Medusa inventory levels where reserved quantity exceeds stocked
 * quantity (an oversold variant with negative available stock).
 * Never writes a location level without --confirm. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";

const BASE_URL = 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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ITEM_FIELDS = "id,sku,*location_levels,*location_levels.location";

export function decideInventoryRepair(level, openReservationsTotal) {
  // Pure: no I/O. level is { inventoryItemId, locationId, stockedQuantity,
  // reservedQuantity, allowBackorder }.
  const { stockedQuantity: stocked, reservedQuantity: reserved, allowBackorder } = level;
  const available = stocked - reserved;

  const isOversold = (available < 0 || reserved > stocked) && !allowBackorder;

  if (!isOversold) {
    return { isOversold: false, available, reason: "ok", proposedStockedQuantity: null };
  }

  const reason = reserved > stocked ? "reserved_exceeds_stock" : "negative_available";
  const proposedStockedQuantity = Math.max(stocked, openReservationsTotal);
  return { isOversold: true, available, reason, proposedStockedQuantity };
}

async function login() {
  const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
  await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
  return sdk;
}

async function listInventoryItems(sdk) {
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const body = await sdk.admin.inventoryItem.list({ fields: ITEM_FIELDS, limit, offset });
    out.push(...body.inventory_items);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function openReservationsTotal(sdk, inventoryItemId, locationId) {
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const body = await sdk.admin.reservation.list({
      location_id: locationId,
      inventory_item_id: inventoryItemId,
      limit,
      offset,
    });
    out.push(...body.reservations);
    offset += limit;
    if (offset >= body.count) break;
  }
  return out.reduce((sum, res) => sum + res.quantity, 0);
}

async function writeLocationLevel(sdk, inventoryItemId, locationId, realCount) {
  return sdk.admin.inventoryItem.updateLocationLevel(inventoryItemId, locationId, {
    stocked_quantity: realCount,
  });
}

export async function run() {
  const confirm = process.argv.includes("--confirm");
  const sdk = await login();
  const items = await listInventoryItems(sdk);

  let flagged = 0;
  for (const item of items) {
    for (const lvl of item.location_levels || []) {
      const level = {
        inventoryItemId: item.id,
        locationId: lvl.location_id,
        stockedQuantity: lvl.stocked_quantity,
        reservedQuantity: lvl.reserved_quantity,
        allowBackorder: Boolean(lvl.allow_backorder),
      };
      const reservedTotal = await openReservationsTotal(sdk, level.inventoryItemId, level.locationId);
      const decision = decideInventoryRepair(level, reservedTotal);
      if (!decision.isOversold) continue;

      flagged++;
      console.warn(
        `Inventory item ${item.id} (sku ${item.sku}) at location ${level.locationId}: ${decision.reason}. ` +
        `stocked=${level.stockedQuantity} reserved=${level.reservedQuantity} ` +
        `available=${decision.available} proposed realCount=${decision.proposedStockedQuantity}`
      );

      if (!DRY_RUN && confirm) {
        await writeLocationLevel(sdk, level.inventoryItemId, level.locationId, decision.proposedStockedQuantity);
        console.log(`Wrote stocked_quantity=${decision.proposedStockedQuantity} for item ${item.id} at location ${level.locationId}.`);
      }
    }
  }

  if (flagged === 0) {
    console.log(`No oversold inventory levels found across ${items.length} item(s).`);
    return;
  }

  if (DRY_RUN || !confirm) {
    console.log(`Done. ${flagged} level(s) flagged. Re-run with DRY_RUN=false and --confirm to write.`);
  } else {
    console.log(`Done. ${flagged} level(s) flagged and repaired.`);
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part most worth testing, because it decides which level gets flagged and what the proposed recount is. Because it is pure, the tests feed in plain objects and a reservation total, no Medusa backend required.

test_oversold_decision.py
from repair_oversold_inventory import decide_inventory_repair


def level(**over):
    base = {
        "inventoryItemId": "iitem_1",
        "locationId": "sloc_1",
        "stockedQuantity": 10,
        "reservedQuantity": 4,
        "allowBackorder": False,
    }
    base.update(over)
    return base


def test_ok_when_available_is_non_negative():
    result = decide_inventory_repair(level(), 4)
    assert result == {
        "isOversold": False,
        "available": 6,
        "reason": "ok",
        "proposedStockedQuantity": None,
    }


def test_flags_reserved_exceeds_stock():
    result = decide_inventory_repair(level(stockedQuantity=5, reservedQuantity=8), 8)
    assert result["isOversold"] is True
    assert result["reason"] == "reserved_exceeds_stock"
    assert result["available"] == -3
    assert result["proposedStockedQuantity"] == 8


def test_flags_when_stocked_itself_is_negative():
    # available < 0 always implies reserved > stocked, so this is still
    # reported as reserved_exceeds_stock.
    result = decide_inventory_repair(level(stockedQuantity=-2, reservedQuantity=0), 0)
    assert result["isOversold"] is True
    assert result["reason"] in ("reserved_exceeds_stock", "negative_available")
    assert result["available"] == -2


def test_backorder_variant_is_never_flagged():
    result = decide_inventory_repair(level(stockedQuantity=5, reservedQuantity=8, allowBackorder=True), 8)
    assert result["isOversold"] is False
    assert result["reason"] == "ok"


def test_proposed_count_never_drops_below_open_reservations():
    result = decide_inventory_repair(level(stockedQuantity=5, reservedQuantity=8), 12)
    assert result["proposedStockedQuantity"] == 12


def test_proposed_count_never_drops_below_current_stock():
    result = decide_inventory_repair(level(stockedQuantity=9, reservedQuantity=10), 3)
    assert result["proposedStockedQuantity"] == 9
oversold-decision.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideInventoryRepair } from "./repair-oversold-inventory.js";

const level = (over = {}) => ({
  inventoryItemId: "iitem_1",
  locationId: "sloc_1",
  stockedQuantity: 10,
  reservedQuantity: 4,
  allowBackorder: false,
  ...over,
});

test("ok when available is non-negative", () => {
  const result = decideInventoryRepair(level(), 4);
  assert.deepEqual(result, {
    isOversold: false,
    available: 6,
    reason: "ok",
    proposedStockedQuantity: null,
  });
});

test("flags reserved exceeds stock", () => {
  const result = decideInventoryRepair(level({ stockedQuantity: 5, reservedQuantity: 8 }), 8);
  assert.equal(result.isOversold, true);
  assert.equal(result.reason, "reserved_exceeds_stock");
  assert.equal(result.available, -3);
  assert.equal(result.proposedStockedQuantity, 8);
});

test("flags when stocked itself is negative", () => {
  const result = decideInventoryRepair(level({ stockedQuantity: -2, reservedQuantity: 0 }), 0);
  assert.equal(result.isOversold, true);
  assert.ok(["reserved_exceeds_stock", "negative_available"].includes(result.reason));
  assert.equal(result.available, -2);
});

test("backorder variant is never flagged", () => {
  const result = decideInventoryRepair(level({ stockedQuantity: 5, reservedQuantity: 8, allowBackorder: true }), 8);
  assert.equal(result.isOversold, false);
  assert.equal(result.reason, "ok");
});

test("proposed count never drops below open reservations", () => {
  const result = decideInventoryRepair(level({ stockedQuantity: 5, reservedQuantity: 8 }), 12);
  assert.equal(result.proposedStockedQuantity, 12);
});

test("proposed count never drops below current stock", () => {
  const result = decideInventoryRepair(level({ stockedQuantity: 9, reservedQuantity: 10 }), 3);
  assert.equal(result.proposedStockedQuantity, 9);
});

Case studies

Flash sale

The last unit sold twice in the same second

A store ran a flash sale on a single limited-run item with one unit left in stock. Two shoppers on different devices completed checkout within the same second. Both reservations succeeded, and the inventory level ended up with reserved_quantity at two against a stocked_quantity of one, leaving available at negative one with no error in the logs.

The audit script flagged the level immediately as reserved_exceeds_stock, cross-checked the two open reservations against real unfulfilled orders, and logged a proposed recount of two so the team could see exactly what to buy or apologize for, instead of shipping a promise they could not keep.

ERP sync

The nightly stock sync that ignored reservations

A merchant ran a nightly job that pushed warehouse counts from an external ERP straight into Medusa's stocked_quantity, overwriting whatever was there. The sync had no idea how many units were already reserved by same-day orders, so it regularly set the count below the reserved total, leaving several variants oversold every morning.

Running the script in dry run after the sync surfaced the exact SKUs and locations affected, with the reservation-aware recount that respected orders already in flight. The team fixed the sync to add stock on top of reservations instead of replacing the count outright, and the morning oversells stopped.

What good looks like

Run this audit after a sales spike, after any external stock sync, or on a regular schedule alongside other inventory checks. It never rewrites a location level on its own. It tells you exactly which levels are oversold, why, and the recount that respects every legitimate open reservation, and it only writes once a human passes --confirm. Backorder-enabled variants stay untouched, since going negative there is expected, not a bug.

FAQ

Why does a Medusa variant show negative available stock?

Available stock is computed as stocked_quantity minus reserved_quantity on an inventory level. Under concurrent checkout traffic, retried webhooks, or a direct external write to stock levels, the reserve-inventory step's read then write is not always serialized, so reserved_quantity can end up higher than stocked_quantity. Medusa has no non-negative CHECK constraint on stocked_quantity, so the row simply persists with negative available stock until someone corrects it.

Is a negative available quantity always a bug?

No. When a variant has allow_backorder enabled, a negative or zero available quantity is expected by design, since Medusa intentionally lets reservations exceed stock for backorder items. The problem is specifically reserved quantity exceeding stocked quantity on a variant where backorder is not enabled.

Is it safe to fix an oversold inventory level with a script?

Yes, when the script only recomputes a proposed stocked_quantity from real data such as the sum of open reservations tied to unfulfilled orders, runs in dry run first so a human reviews the numbers, and requires an explicit confirm step before it writes a location level. It should never guess a value like zero, and it should never bulk-delete reservations without matching each one to a live order.

Related field notes

Citations

On the problem:

  1. Medusa Documentation: Inventory module concepts, including stocked quantity, reserved quantity, and allow_backorder. docs.medusajs.com/resources/commerce-modules/inventory/concepts
  2. medusajs/medusa GitHub issue #12796: cancel fulfillment not working when an item has negative stock. github.com/medusajs/medusa/issues/12796
  3. Medusa Admin User Guide: manage reservations. docs.medusajs.com/user-guide/inventory/reservations

On the solution:

  1. Medusa Documentation: the Inventory module. docs.medusajs.com/resources/commerce-modules/inventory
  2. Medusa Documentation: the Inventory module in flows, including the reserve-inventory workflow step. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
  3. 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 pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch your oversold stock?

If this saved you a broken promise to a customer or a messy inventory reconciliation, 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