Skip to content

Diagnostic Inventory & Reservations

Multi-part product reservations go negative after fulfillment

A bundle sells, gets fulfilled, and everything looks routine. Then someone opens the inventory item behind it and reserved_quantity on the location level reads a negative number. No error, no failed webhook, nothing in the logs. It only shows up when a bundle is modeled as one inventory item with a required_quantity above one, and the workflow that reserves it and the workflow that fulfills it do not agree on the multiplier. Here is why that mismatch happens and a script that finds every drifted row and resyncs it safely.

Python and Node.js Medusa Admin API Flag and report, resync gated by DRY_RUN
A stack of boxes in a warehouse
Photo by Ali Mkumbwa on Unsplash
The short answer

In Medusa v2, a bundled or multi-part variant should reserve each component inventory item at its own required_quantity. When a variant is instead composed of a single inventory item with a required_quantity greater than one, the allocate-items workflow and the fulfillment workflow can disagree on that multiplier, so the location level's reserved_quantity is decremented by a different amount than it was incremented by. Run a small Python or Node.js script that authenticates, lists reservations, sums live reservations per inventory_item_id and location_id, and diffs that sum against the stored reserved_quantity from the location level. By default it only flags and reports under DRY_RUN. Only with an operator's confirmation does it resync the stored value to the exact live sum, one row at a time, skipping anything with an order or fulfillment still in flight.

The problem in plain words

A multi-part or bundled product is supposed to hold several inventory items on one variant, each with its own required_quantity, so selling one unit of the bundle reserves the correct quantity of every component. That is the model the Inventory module expects.

The bug shows up when a bundle is instead built as a single inventory item carrying a required_quantity above one, for example a variant that needs three units of one component per sale. The allocate-items step, run when the order is placed, multiplies correctly and reserves three. But the fulfillment workflow, run later when the order ships, does not always apply that same multiplier when it releases the reservation and decrements stock. One workflow uses the multiplier, the other assumes a quantity of one, and the two sides of the transaction stop matching. Over enough orders, the location level's reserved_quantity row drifts below zero, and nothing about that is visibly wrong until someone reads the raw number.

Order placed bundle, required_quantity 3 allocate-items reserves applies the x3 multiplier Order ships fulfillment workflow runs multiplier dropped releases only x1 instead of x3 reserved goes negative
Reserving a bundle applies the required_quantity multiplier. Releasing it during fulfillment does not always apply the same multiplier, so the location level's reserved_quantity drifts below zero.

Why it happens

The Inventory module supports a variant tied to more than one inventory item, each with its own required_quantity, precisely so a bundle can reserve the right amount of each component. The bug is not that feature, it is a shortcut around it:

This is a common source of confusion because nothing throws. The order completes, the fulfillment completes, and the only evidence is a raw negative integer sitting on a row nobody is watching. See the citations at the end for the exact issues and docs.

The key insight

A negative reserved_quantity is a bookkeeping drift, not something to guess your way out of. Zeroing it or nudging it by an arbitrary offset can hide a real accounting gap between what was reserved and what orders actually need reserved right now. The safe pattern is to compute the true number from data Medusa already has, the live sum of quantity across every reservation tied to that inventory_item_id and location_id, report the drift for a human to review, and only resync to that exact computed sum after an operator confirms, one row at a time.

The fix, as a flow

We do not touch checkout or fulfillment. We authenticate, list reservations and location levels, compute what the live reservations actually add up to for each inventory item and location, and diff that against the stored reserved_quantity. By default we only log the drift. A write only happens when an operator confirms and DRY_RUN is false, and even then it resyncs to the computed sum, never to zero or a guessed number, skipping rows tied to in-flight orders or fulfillments.

List reservations and location levels Sum live reservations per item, per location Diff against stored reserved_quantity Negative or drift ≠ 0? yes no, row is fine Log or resync write needs operator ok
The script only recommends a resync to the live reservations sum. It never zeroes reserved_quantity or applies a guessed offset, and it skips rows with orders or fulfillments still in flight.

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 reservations, inventory items, 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 resync also needs an operator 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 resync also needs an operator 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 multi-part variants and their location levels

Ask /admin/products for variants and expand each variant's inventory items, then read the location_levels for every inventory item involved, paging with limit and offset. What we are hunting for is a variant with exactly one inventory item whose required_quantity is greater than one, since that is the shape that triggers the multiplier mismatch.

step3.py
def list_multipart_variants(token):
    headers = {"Authorization": f"Bearer {token}"}
    fields = "id,title,variants.id,variants.title,*variants.inventory_items,variants.inventory_items.required_quantity"
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/products",
            params={"fields": fields, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for product in body["products"]:
            for variant in product.get("variants", []):
                items = variant.get("inventory_items") or []
                if len(items) == 1 and items[0].get("required_quantity", 1) > 1:
                    out.append({"product": product["title"], "variant": variant, "inventory_item": items[0]})
        offset += limit
        if offset >= body["count"]:
            return out
step3.js
async function listMultipartVariants(sdk) {
  const fields = "id,title,variants.id,variants.title,*variants.inventory_items,variants.inventory_items.required_quantity";
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const body = await sdk.admin.product.list({ fields, limit, offset });
    for (const product of body.products) {
      for (const variant of product.variants || []) {
        const items = variant.inventory_items || [];
        if (items.length === 1 && (items[0].required_quantity ?? 1) > 1) {
          out.push({ product: product.title, variant, inventoryItem: items[0] });
        }
      }
    }
    offset += limit;
    if (offset >= body.count) return out;
  }
}
4

Decide, with one pure function

Keep the decision in a function with no network calls. Given the reserved_quantity stored on a location level and the list of live reservations for that same inventory_item_id and location_id, sum the live reservations, compute the drift as stored minus computed, and flag the row as needing a resync when the stored value is negative or the drift is not zero. This is the same function we unit test later.

decide.py
def compute_reserved_quantity_drift(stored_reserved_quantity, live_reservations):
    """Pure: no I/O. live_reservations is a list of {quantity: number, ...}."""
    computed_reserved = sum(r["quantity"] for r in live_reservations)
    drift = stored_reserved_quantity - computed_reserved
    is_negative_anomaly = stored_reserved_quantity < 0
    needs_resync = is_negative_anomaly or drift != 0
    return {
        "computedReserved": computed_reserved,
        "drift": drift,
        "isNegativeAnomaly": is_negative_anomaly,
        "needsResync": needs_resync,
    }
decide.js
export function computeReservedQuantityDrift(storedReservedQuantity, liveReservations) {
  // Pure: no I/O. liveReservations is an array of { quantity: number, ... }.
  const computedReserved = liveReservations.reduce((sum, r) => sum + r.quantity, 0);
  const drift = storedReservedQuantity - computedReserved;
  const isNegativeAnomaly = storedReservedQuantity < 0;
  const needsResync = isNegativeAnomaly || drift !== 0;
  return { computedReserved, drift, isNegativeAnomaly, needsResync };
}
5

Pull live reservations and the stored location level

For each flagged inventory item, list its reservations and its location levels. Reservations are the ground truth of what live orders currently hold, and the location level carries the reserved_quantity that is supposed to mirror that. We page through both so the audit covers a full catalog and a full reservation history.

reservations.py
def list_reservations(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={
                "inventory_item_id": inventory_item_id,
                "location_id": location_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"]:
            return out


def get_location_level(token, inventory_item_id, location_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/inventory-items/{inventory_item_id}/location-levels",
        params={"location_id": location_id},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    levels = r.json()["inventory_item"]["location_levels"]
    return next(lvl for lvl in levels if lvl["location_id"] == location_id)
reservations.js
async function listReservations(sdk, inventoryItemId, locationId) {
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const body = await sdk.admin.reservation.list({
      inventory_item_id: inventoryItemId,
      location_id: locationId,
      limit,
      offset,
    });
    out.push(...body.reservations);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function getLocationLevel(sdk, inventoryItemId, locationId) {
  const body = await sdk.admin.inventoryItem.retrieveLocationLevels(inventoryItemId, { location_id: locationId });
  return body.inventory_item.location_levels.find((lvl) => lvl.location_id === locationId);
}
6

Resync only with confirmation, one row at a time

In dry run, only log the flagged rows and their computed drift for an operator to review. Only when DRY_RUN is false and the operator has confirmed a specific row, call POST /admin/inventory-items/{id}/location-levels/{location_id} with {"reserved_quantity": computedReserved}, which is the Update Location Level endpoint. Log the before and after value, write one row at a time, and skip any row where an order or fulfillment for that item is still in flight.

Run it safe

Always start with DRY_RUN=true and review every flagged row before anything writes. Never zero reserved_quantity or apply an arbitrary offset. Resync only to the computed sum of live reservations, only one row at a time, and skip any row tied to an order or fulfillment that is still in flight, since resyncing mid-flight can race the very workflow that caused the drift.

The full code

Here is the complete script in one file for each language. It authenticates, lists multi-part variants, sums live reservations per inventory item and location, computes the drift with a pure function, and logs or resyncs the location level depending on DRY_RUN.

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.
resync_negative_reserved.py
"""Find Medusa multi-part product location levels where reserved_quantity
has drifted from the live reservations, typically negative, because
allocate-items and fulfillment disagreed on the required_quantity multiplier.
Flags and reports by default. Only resyncs reserved_quantity to the computed
live sum when DRY_RUN is false and an operator has confirmed. Safe to run
again and again.
"""
import os
import logging

import requests

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

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"
CONFIRM_RESYNC = os.environ.get("CONFIRM_RESYNC", "false").lower() == "true"

PRODUCT_FIELDS = (
    "id,title,variants.id,variants.title,*variants.inventory_items,"
    "variants.inventory_items.required_quantity"
)


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_multipart_variants(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/products",
            params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for product in body["products"]:
            for variant in product.get("variants", []):
                items = variant.get("inventory_items") or []
                if len(items) == 1 and items[0].get("required_quantity", 1) > 1:
                    out.append({
                        "product": product["title"],
                        "variant": variant,
                        "inventory_item": items[0],
                    })
        offset += limit
        if offset >= body["count"]:
            return out


def list_reservations(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={
                "inventory_item_id": inventory_item_id,
                "location_id": location_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"]:
            return out


def get_location_levels(token, inventory_item_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/inventory-items/{inventory_item_id}/location-levels",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["inventory_item"]["location_levels"]


def has_in_flight_order(token, inventory_item_id):
    """Skip a row if any reservation for it still points at an order still
    in progress. A minimal, conservative check: any reservation missing a
    line_item_id is treated as detached and left alone, since we cannot
    confirm it is safe."""
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/reservations",
        params={"inventory_item_id": inventory_item_id, "limit": 1},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    reservations = r.json()["reservations"]
    return any(not res.get("line_item_id") for res in reservations)


def compute_reserved_quantity_drift(stored_reserved_quantity, live_reservations):
    """Pure: no I/O. live_reservations is a list of {quantity: number, ...}."""
    computed_reserved = sum(r["quantity"] for r in live_reservations)
    drift = stored_reserved_quantity - computed_reserved
    is_negative_anomaly = stored_reserved_quantity < 0
    needs_resync = is_negative_anomaly or drift != 0
    return {
        "computedReserved": computed_reserved,
        "drift": drift,
        "isNegativeAnomaly": is_negative_anomaly,
        "needsResync": needs_resync,
    }


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


def run():
    token = get_token()
    variants = list_multipart_variants(token)

    flagged = 0
    resynced = 0
    for entry in variants:
        inventory_item_id = entry["inventory_item"]["id"]
        levels = get_location_levels(token, inventory_item_id)
        for level in levels:
            location_id = level["location_id"]
            live_reservations = list_reservations(token, inventory_item_id, location_id)
            decision = compute_reserved_quantity_drift(level["reserved_quantity"], live_reservations)
            if not decision["needsResync"]:
                continue

            flagged += 1
            log.warning(
                "Product %s variant %s, item %s at location %s: stored=%s live_sum=%s "
                "drift=%s negative=%s",
                entry["product"], entry["variant"]["title"], inventory_item_id, location_id,
                level["reserved_quantity"], decision["computedReserved"],
                decision["drift"], decision["isNegativeAnomaly"],
            )

            if DRY_RUN or not CONFIRM_RESYNC:
                continue

            if has_in_flight_order(token, inventory_item_id):
                log.info("Skipping item %s at location %s, an order or fulfillment looks in flight.",
                          inventory_item_id, location_id)
                continue

            before = level["reserved_quantity"]
            resync_location_level(token, inventory_item_id, location_id, decision["computedReserved"])
            resynced += 1
            log.info("Resynced item %s at location %s. before=%s after=%s",
                      inventory_item_id, location_id, before, decision["computedReserved"])

    if flagged == 0:
        log.info("No drifted reserved_quantity rows found across %d multi-part variant(s).", len(variants))
        return

    if DRY_RUN or not CONFIRM_RESYNC:
        log.info("Done. %d row(s) flagged. Set DRY_RUN=false and CONFIRM_RESYNC=true to resync.", flagged)
    else:
        log.info("Done. %d row(s) flagged, %d resynced.", flagged, resynced)


if __name__ == "__main__":
    run()
resync-negative-reserved.js
/**
 * Find Medusa multi-part product location levels where reserved_quantity
 * has drifted from the live reservations, typically negative, because
 * allocate-items and fulfillment disagreed on the required_quantity multiplier.
 * Flags and reports by default. Only resyncs reserved_quantity to the computed
 * live sum when DRY_RUN is false and an operator has confirmed. Safe to run
 * again and again.
 */
import { pathToFileURL } from "node:url";

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 CONFIRM_RESYNC = (process.env.CONFIRM_RESYNC || "false").toLowerCase() === "true";

const PRODUCT_FIELDS =
  "id,title,variants.id,variants.title,*variants.inventory_items," +
  "variants.inventory_items.required_quantity";

export function computeReservedQuantityDrift(storedReservedQuantity, liveReservations) {
  // Pure: no I/O. liveReservations is an array of { quantity: number, ... }.
  const computedReserved = liveReservations.reduce((sum, r) => sum + r.quantity, 0);
  const drift = storedReservedQuantity - computedReserved;
  const isNegativeAnomaly = storedReservedQuantity < 0;
  const needsResync = isNegativeAnomaly || drift !== 0;
  return { computedReserved, drift, isNegativeAnomaly, needsResync };
}

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

async function listMultipartVariants(sdk) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const body = await sdk.admin.product.list({ fields: PRODUCT_FIELDS, limit, offset });
    for (const product of body.products) {
      for (const variant of product.variants || []) {
        const items = variant.inventory_items || [];
        if (items.length === 1 && (items[0].required_quantity ?? 1) > 1) {
          out.push({ product: product.title, variant, inventoryItem: items[0] });
        }
      }
    }
    offset += limit;
    if (offset >= body.count) return out;
  }
}

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

async function getLocationLevels(sdk, inventoryItemId) {
  const body = await sdk.admin.inventoryItem.retrieveLocationLevels(inventoryItemId, {});
  return body.inventory_item.location_levels;
}

async function hasInFlightOrder(sdk, inventoryItemId) {
  // Skip a row if any reservation for it looks detached from a real order
  // line, since we cannot confirm it is safe to resync while that is true.
  const body = await sdk.admin.reservation.list({ inventory_item_id: inventoryItemId, limit: 1 });
  return body.reservations.some((res) => !res.line_item_id);
}

async function resyncLocationLevel(sdk, inventoryItemId, locationId, computedReserved) {
  return sdk.admin.inventoryItem.updateLocationLevel(inventoryItemId, locationId, {
    reserved_quantity: computedReserved,
  });
}

export async function run() {
  const sdk = await login();
  const variants = await listMultipartVariants(sdk);

  let flagged = 0;
  let resynced = 0;
  for (const entry of variants) {
    const inventoryItemId = entry.inventoryItem.id;
    const levels = await getLocationLevels(sdk, inventoryItemId);
    for (const level of levels) {
      const locationId = level.location_id;
      const liveReservations = await listReservations(sdk, inventoryItemId, locationId);
      const decision = computeReservedQuantityDrift(level.reserved_quantity, liveReservations);
      if (!decision.needsResync) continue;

      flagged++;
      console.warn(
        `Product ${entry.product} variant ${entry.variant.title}, item ${inventoryItemId} ` +
        `at location ${locationId}: stored=${level.reserved_quantity} live_sum=${decision.computedReserved} ` +
        `drift=${decision.drift} negative=${decision.isNegativeAnomaly}`
      );

      if (DRY_RUN || !CONFIRM_RESYNC) continue;

      if (await hasInFlightOrder(sdk, inventoryItemId)) {
        console.log(`Skipping item ${inventoryItemId} at location ${locationId}, an order or fulfillment looks in flight.`);
        continue;
      }

      const before = level.reserved_quantity;
      await resyncLocationLevel(sdk, inventoryItemId, locationId, decision.computedReserved);
      resynced++;
      console.log(`Resynced item ${inventoryItemId} at location ${locationId}. before=${before} after=${decision.computedReserved}`);
    }
  }

  if (flagged === 0) {
    console.log(`No drifted reserved_quantity rows found across ${variants.length} multi-part variant(s).`);
    return;
  }

  if (DRY_RUN || !CONFIRM_RESYNC) {
    console.log(`Done. ${flagged} row(s) flagged. Set DRY_RUN=false and CONFIRM_RESYNC=true to resync.`);
  } else {
    console.log(`Done. ${flagged} row(s) flagged, ${resynced} resynced.`);
  }
}

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 row gets flagged as needing a resync and what the computed correct value is. Because it is pure, the tests feed in a stored number and a plain list of reservations, no Medusa backend required.

test_negative_reserved_drift.py
from resync_negative_reserved import compute_reserved_quantity_drift


def reservations(*quantities):
    return [{"quantity": q} for q in quantities]


def test_no_resync_when_stored_matches_live_sum():
    result = compute_reserved_quantity_drift(5, reservations(2, 3))
    assert result == {
        "computedReserved": 5,
        "drift": 0,
        "isNegativeAnomaly": False,
        "needsResync": False,
    }


def test_negative_stored_is_flagged_even_if_it_matches_a_negative_sum():
    result = compute_reserved_quantity_drift(-3, reservations())
    assert result["isNegativeAnomaly"] is True
    assert result["needsResync"] is True
    assert result["computedReserved"] == 0
    assert result["drift"] == -3


def test_positive_drift_is_flagged():
    result = compute_reserved_quantity_drift(9, reservations(2, 2))
    assert result["computedReserved"] == 4
    assert result["drift"] == 5
    assert result["isNegativeAnomaly"] is False
    assert result["needsResync"] is True


def test_negative_drift_is_flagged():
    result = compute_reserved_quantity_drift(1, reservations(3, 3))
    assert result["computedReserved"] == 6
    assert result["drift"] == -5
    assert result["needsResync"] is True


def test_empty_reservations_with_zero_stored_needs_no_resync():
    result = compute_reserved_quantity_drift(0, reservations())
    assert result["needsResync"] is False
    assert result["isNegativeAnomaly"] is False


def test_bundle_multiplier_mismatch_example():
    # required_quantity 3, allocate-items reserved 2 orders worth (6), but
    # fulfillment only released 1x per order, leaving reserved_quantity at -3.
    result = compute_reserved_quantity_drift(-3, reservations(6))
    assert result["isNegativeAnomaly"] is True
    assert result["computedReserved"] == 6
    assert result["drift"] == -9
    assert result["needsResync"] is True
resync-negative-reserved.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeReservedQuantityDrift } from "./resync-negative-reserved.js";

const reservations = (...quantities) => quantities.map((q) => ({ quantity: q }));

test("no resync when stored matches live sum", () => {
  const result = computeReservedQuantityDrift(5, reservations(2, 3));
  assert.deepEqual(result, {
    computedReserved: 5,
    drift: 0,
    isNegativeAnomaly: false,
    needsResync: false,
  });
});

test("negative stored is flagged even if it matches a negative sum", () => {
  const result = computeReservedQuantityDrift(-3, reservations());
  assert.equal(result.isNegativeAnomaly, true);
  assert.equal(result.needsResync, true);
  assert.equal(result.computedReserved, 0);
  assert.equal(result.drift, -3);
});

test("positive drift is flagged", () => {
  const result = computeReservedQuantityDrift(9, reservations(2, 2));
  assert.equal(result.computedReserved, 4);
  assert.equal(result.drift, 5);
  assert.equal(result.isNegativeAnomaly, false);
  assert.equal(result.needsResync, true);
});

test("negative drift is flagged", () => {
  const result = computeReservedQuantityDrift(1, reservations(3, 3));
  assert.equal(result.computedReserved, 6);
  assert.equal(result.drift, -5);
  assert.equal(result.needsResync, true);
});

test("empty reservations with zero stored needs no resync", () => {
  const result = computeReservedQuantityDrift(0, reservations());
  assert.equal(result.needsResync, false);
  assert.equal(result.isNegativeAnomaly, false);
});

test("bundle multiplier mismatch example", () => {
  // required_quantity 3, allocate-items reserved 2 orders worth (6), but
  // fulfillment only released 1x per order, leaving reserved_quantity at -3.
  const result = computeReservedQuantityDrift(-3, reservations(6));
  assert.equal(result.isNegativeAnomaly, true);
  assert.equal(result.computedReserved, 6);
  assert.equal(result.drift, -9);
  assert.equal(result.needsResync, true);
});

Case studies

Gift set bundle

A three-piece set modeled as one inventory item

A skincare brand sold a gift set as a single variant tied to one inventory item with required_quantity set to three, meant to represent three units of the base product per set sold. Orders reserved correctly at checkout. But the fulfillment workflow released the reservation as if it were quantity one each time a set shipped, so every fulfilled order left the location level's reserved_quantity a little further behind.

The audit script paged through variants, found the single-item, required_quantity greater than one shape, and flagged the location level with a drift of negative three per fulfilled order, along with the exact live reservation sum it should have matched. The team switched the bundle to one inventory item per component at required_quantity one and resynced the drifted rows one at a time.

Subscription refill kit

A refill kit that quietly went negative for months

A subscription box sold a refill kit as one inventory item with a multiplier of four. Nobody was watching the raw reserved_quantity number, so it drifted for months, eventually reading a large negative value that made an unrelated stock report look wrong and triggered a false out-of-stock alarm on a completely different, correctly modeled variant that shared a warehouse dashboard.

Running the script in dry run surfaced every drifted inventory item and location pair with its computed live sum, which let the team fix the reporting confusion in minutes instead of re-auditing the whole catalog by hand. They resynced the confirmed rows and left everything with an order still in flight untouched until it settled.

What good looks like

Run this audit on a schedule alongside your other inventory checks, or right after you spot a suspicious negative number. It never writes anything without an operator's explicit confirmation, and even then it resyncs reserved_quantity to the exact live reservations sum, never to zero and never by a guessed offset. Rows tied to an order or fulfillment still in flight are left alone until that settles, so the fix never races the workflow that caused the drift in the first place.

FAQ

Why does a bundle variant's reserved_quantity go negative after fulfillment?

When a bundled or multi-part variant is modeled as a single inventory item with a required_quantity greater than one, the allocate-items workflow and the fulfillment workflow can disagree on how many units that one line represents. One side applies the required_quantity multiplier and the other does not, so the location level's reserved_quantity is decremented by a different amount than it was incremented by, and the stored value drifts below zero.

Is a negative reserved_quantity always this bundle bug?

Not necessarily, but it is a strong signal. The safe way to confirm it is to sum the live reservations for that inventory_item_id and location_id from GET /admin/reservations and compare the total against the stored reserved_quantity from the location level. If the stored number is negative or does not match the live sum, you have a real drift worth investigating, and multi-part variants are the most common cause confirmed upstream.

Is it safe to fix reserved_quantity with a script?

Yes, when the script only flags and reports by default under DRY_RUN, resyncs reserved_quantity to the exact sum of live reservations rather than zeroing it or applying a guessed offset, writes one inventory_item_id and location_id row at a time with the before and after logged, and skips any row that has an order or fulfillment currently in flight.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #12532: inventory items of multi-part product are not displayed and reserved correctly. github.com/medusajs/medusa/issues/12532
  2. medusajs/medusa GitHub issue #11131: is it a bug that a Reserved is negative? It's weird. github.com/medusajs/medusa/issues/11131

On the solution:

  1. Medusa Documentation: Inventory module concepts, including reserved quantity and required_quantity. docs.medusajs.com/resources/commerce-modules/inventory/concepts
  2. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin

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 negative reservations?

If this saved you a confusing inventory report or a bundle that was quietly drifting, 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