Diagnostic Inventory (MSI)
Magento 2 salable quantity goes negative or allows oversell
A product page shows the item in stock, checkout lets a customer buy it, and yet the salable quantity Magento reports for that SKU is a negative number. Physical stock in the warehouse is fine. Nobody touched the qty column directly. What actually moved is the reservation ledger, a separate append-only log that MSI subtracts from physical stock to compute what is left to sell, and when a compensating entry on that ledger goes missing, the computed number can drift below zero forever. Here is why that invariant breaks and a small script that catches it before it turns into a real oversold order.
MSI never edits one stock number in place. It computes salable quantity as sum(in-stock source_items quantities) - sum(outstanding reservations), and reservations are an append-only ledger. If the compensating reservation for a cancelled or failed order is lost, which is common during 2.3.x upgrades, custom checkout flows, direct database edits, or third-party extensions that bypass the ReservationBuilder, the ledger keeps an orphaned entry that never clears, and the computed salable quantity drifts below zero even though the warehouse is fine. Separately, Backorders set to allow qty below zero can make salable quantity negative on purpose, which is expected, not broken, unless the negative amount no longer matches real open order demand. A script cannot safely rewrite the reservation ledger over REST, so it cross-checks three endpoints, flags the exact SKUs that need attention, and only ever takes the reversible step of pausing sales on a genuinely oversold SKU. Full code, tests, and a dry run guard are below.
The problem in plain words
Before MSI, Magento kept a single qty column per stock item, and every order decremented it directly. MSI replaced that with two separate ideas: physical quantity, the sum of what your source_items actually hold, and reservations, a running log of demand against that stock. Salable quantity is the difference between the two, computed on the fly, not stored as one editable number.
That append-only design is deliberate. Reservations are meant to be auditable and safe under concurrent checkout, since two shoppers buying the last unit at the same instant both get a reservation row instead of racing to update the same integer. But it only stays correct if every reservation that increases demand is eventually matched by a compensating reservation when that demand goes away, for example when an order is cancelled or a payment fails. If that compensating write never happens, the ledger keeps a phantom negative entry forever, and every future read of salable quantity is wrong by exactly that amount, even though the shelves have the stock.
Separately, the Backorders setting can make salable quantity negative on purpose. With Allow Qty Below 0 turned on, Magento is designed to keep selling past zero, and a negative number there is expected. The real bug is when reservations do not reconcile with real open orders on top of that, or when backorders are off and salable quantity is negative anyway, which should be impossible.
Why it happens
- A 2.3.x upgrade path where an older cancel or refund flow does not fully wire into the newer
ReservationBuilder, so the compensating reservation is silently skipped. - A custom checkout flow, a headless storefront, or a third-party extension that writes its own order state changes and bypasses the reservation service entirely.
- Direct database edits, such as manually cancelling an order in the
sales_ordertable without running it through Magento's own cancellation service. - Backorders set to Allow Qty Below 0 combined with
manage_stockenabled, which lets salable quantity go negative by design, but which can mask a true reservation mismatch if nobody is also checking it against real open order demand.
This exact pattern shows up repeatedly in Magento's own issue tracker and community forum, negative salable quantities with no setting that stops them, and reservations that stop reconciling with real inventory after normal store operation. See the citations at the end for the specific threads.
Reservations are an event log, not a value you can safely rewrite. Deleting or editing a reservation row to force salable quantity back to a number you expect can corrupt the ledger or double-correct it, since Magento's own reconciliation tooling assumes every reservation it has ever written is still there. So the right move from the outside, over REST, is never to touch the ledger. It is to detect where the invariant salable = physical - reservations has broken, using the same three data points bin/magento inventory:reservation:list-inconsistencies checks internally, and report exactly which SKUs need the real CLI-only compensation run by an admin.
The fix, as a flow
We do not touch the reservation ledger. We add a job that reads the computed salable quantity, the physical stock from source_items, the open order demand, and the backorder configuration for each SKU, decides whether the numbers reconcile, and either reports it as fine, flags it for CLI-based ledger repair, or, only for a confirmed critical oversell, pauses further sales on that one SKU.
Build it step by step
Get an admin bearer token
The script authenticates like any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_ID="1"
export DRY_RUN="true" # start safe, change to false to allow the pause-sales path
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_ID="1"
export DRY_RUN="true" // start safe, change to false to allow the pause-sales path
Talk to the Magento REST API
Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Read the three data points that must agree
For each SKU, pull the computed salable quantity, the physical quantity from in-stock source_items, the stock item config, and the open order demand. GET /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId} gives the number MSI actually returns to checkout. GET /rest/V1/inventory/source-items filtered by SKU, summed for status 1 rows, gives physical quantity. GET /rest/V1/products/{sku} gives extension_attributes.stock_item.manage_stock and backorders. Open order demand comes from summing qty_ordered on GET /rest/V1/orders filtered to statuses that are not complete, closed, or cancelled.
def get_salable_qty(sku, stock_id):
data = magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")
return float(data)
def get_physical_qty(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
items = magento_get("/inventory/source-items", params)["items"]
return sum(i["quantity"] for i in items if i.get("status") == 1)
def get_stock_item_config(sku):
product = magento_get(f"/products/{sku}")
stock_item = product["extension_attributes"]["stock_item"]
return {
"manageStock": bool(stock_item.get("manage_stock")),
"backorders": int(stock_item.get("backorders", 0)),
}
def get_open_order_qty_total(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "complete,closed,canceled",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "nin",
}
orders = magento_get("/orders", params)["items"]
total = 0.0
for order in orders:
for item in order.get("items", []):
if item.get("sku") == sku:
total += float(item.get("qty_ordered", 0))
return total
async function getSalableQty(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return Number(data);
}
async function getPhysicalQty(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/inventory/source-items", params);
return data.items.filter((i) => i.status === 1).reduce((sum, i) => sum + i.quantity, 0);
}
async function getStockItemConfig(sku) {
const product = await magentoGet(`/products/${sku}`);
const stockItem = product.extension_attributes.stock_item;
return {
manageStock: Boolean(stockItem.manage_stock),
backorders: Number(stockItem.backorders || 0),
};
}
async function getOpenOrderQtyTotal(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "complete,closed,canceled",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "nin",
};
const data = await magentoGet("/orders", params);
let total = 0;
for (const order of data.items) {
for (const item of order.items || []) {
if (item.sku === sku) total += Number(item.qty_ordered || 0);
}
}
return total;
}
Decide, with one pure function
Keep the decision in its own function that takes the four numbers and returns a flag, a severity, and a reason. A pure function like this is easy to read and easy to test, which we do later. Manage stock disabled is a warning, since oversell is not tracked at all. Negative salable quantity with backorders disabled is critical, the impossible state. Negative salable quantity with backorders enabled is expected, unless the magnitude exceeds real open order demand by more than physical stock, which points to phantom reservations. Any mismatch between salable quantity and physical minus open orders is a reconciliation warning regardless of sign.
def decide_salable_qty_action(sku, salable_qty, physical_qty, open_order_qty_total, stock_item_config, tolerance_units=0):
if not stock_item_config.get("manageStock"):
return {
"flag": True,
"severity": "warning",
"reason": "manage_stock disabled: product always shows in-stock, oversell not tracked",
}
backorders = stock_item_config.get("backorders", 0)
if salable_qty < 0 and backorders == 0:
return {
"flag": True,
"severity": "critical",
"reason": "negative salable qty with backorders disabled: true oversell, invariant broken",
}
if salable_qty < 0 and backorders != 0:
if abs(salable_qty) > open_order_qty_total + physical_qty:
return {
"flag": True,
"severity": "critical",
"reason": "reservation total exceeds open order demand: phantom/duplicate reservations",
}
return {"flag": False, "severity": "ok", "reason": "negative salable qty is expected backorder behavior"}
expected_salable = physical_qty - open_order_qty_total
if abs(salable_qty - expected_salable) > tolerance_units:
return {
"flag": True,
"severity": "warning",
"reason": "salable qty does not reconcile with source_items minus open reservations: stale index or lost/duplicated reservation",
}
return {"flag": False, "severity": "ok", "reason": "consistent"}
export function decideSalableQtyAction(sku, salableQty, physicalQty, openOrderQtyTotal, stockItemConfig, toleranceUnits = 0) {
if (!stockItemConfig.manageStock) {
return {
flag: true,
severity: "warning",
reason: "manage_stock disabled: product always shows in-stock, oversell not tracked",
};
}
const backorders = stockItemConfig.backorders || 0;
if (salableQty < 0 && backorders === 0) {
return {
flag: true,
severity: "critical",
reason: "negative salable qty with backorders disabled: true oversell, invariant broken",
};
}
if (salableQty < 0 && backorders !== 0) {
if (Math.abs(salableQty) > openOrderQtyTotal + physicalQty) {
return {
flag: true,
severity: "critical",
reason: "reservation total exceeds open order demand: phantom/duplicate reservations",
};
}
return { flag: false, severity: "ok", reason: "negative salable qty is expected backorder behavior" };
}
const expectedSalable = physicalQty - openOrderQtyTotal;
if (Math.abs(salableQty - expectedSalable) > toleranceUnits) {
return {
flag: true,
severity: "warning",
reason: "salable qty does not reconcile with source_items minus open reservations: stale index or lost/duplicated reservation",
};
}
return { flag: false, severity: "ok", reason: "consistent" };
}
Report by default, pause sales only when confirmed critical
The default output is a structured record per flagged SKU: {sku, stockId, salableQty, physicalQty, openOrderQtyTotal, severity, reason}, for an operator to review. When a SKU is critical and the reason points to a genuine oversell, the only REST-safe corrective step is to stop further sales, not to rewrite history. That means PUT /rest/V1/products/{sku} with extension_attributes.stock_item.is_in_stock=false, which is reversible the moment an admin reconciles the inventory. The actual reservation-ledger fix always stays a CLI operation, bin/magento inventory:reservation:list-inconsistencies -r piped into inventory:reservation:create-compensations, and the script only ever names the SKUs and stock ids that need it.
def pause_sales(sku):
payload = {"product": {"sku": sku, "extension_attributes": {"stock_item": {"is_in_stock": False}}}}
if DRY_RUN:
log.info("DRY_RUN: would PUT /products/%s with %s", sku, payload)
return
r = requests.put(
f"{MAGENTO_URL}/rest/V1/products/{sku}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
async function pauseSales(sku) {
const payload = { product: { sku, extension_attributes: { stock_item: { is_in_stock: false } } } };
if (DRY_RUN) {
console.log(`DRY_RUN: would PUT /products/${sku} with`, JSON.stringify(payload));
return;
}
const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${sku}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
}
Always start with DRY_RUN=true. Never attempt to create, edit, or delete reservation rows over REST, since that ledger is append-only and a direct edit can corrupt or double-correct it. Report critical and warning SKUs, and only ever pause sales, never rewrite inventory history, and always leave the actual reservation compensation to bin/magento inventory:reservation:list-inconsistencies and inventory:reservation:create-compensations run by an admin.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, cross-checks the three REST data sources per SKU, runs the pure decision function, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Flag Magento 2 SKUs where salable quantity has gone negative or oversold, safely.
MSI computes salable quantity as sum(in-stock source_items quantities) minus sum
of outstanding reservations, an append-only ledger. If a compensating reservation
for a cancelled or failed order is lost, the ledger keeps an orphaned entry and
salable quantity drifts below zero forever, even though physical stock is fine.
Backorders set to allow qty below zero can make a negative number expected
instead of broken. Reservations are never rewritten here; the only write this
script performs is pausing further sales (is_in_stock=false) on a confirmed
critical oversell. The actual ledger repair stays a CLI-only operation for an
admin to run. 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("flag_salable_qty_oversell")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
STOCK_ID = os.environ.get("STOCK_ID", "1")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_salable_qty(sku, stock_id):
data = magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")
return float(data)
def get_physical_qty(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
items = magento_get("/inventory/source-items", params)["items"]
return sum(i["quantity"] for i in items if i.get("status") == 1)
def get_stock_item_config(sku):
product = magento_get(f"/products/{sku}")
stock_item = product["extension_attributes"]["stock_item"]
return {
"manageStock": bool(stock_item.get("manage_stock")),
"backorders": int(stock_item.get("backorders", 0)),
}
def get_open_order_qty_total(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "complete,closed,canceled",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "nin",
}
orders = magento_get("/orders", params)["items"]
total = 0.0
for order in orders:
for item in order.get("items", []):
if item.get("sku") == sku:
total += float(item.get("qty_ordered", 0))
return total
def decide_salable_qty_action(sku, salable_qty, physical_qty, open_order_qty_total, stock_item_config, tolerance_units=0):
if not stock_item_config.get("manageStock"):
return {
"flag": True,
"severity": "warning",
"reason": "manage_stock disabled: product always shows in-stock, oversell not tracked",
}
backorders = stock_item_config.get("backorders", 0)
if salable_qty < 0 and backorders == 0:
return {
"flag": True,
"severity": "critical",
"reason": "negative salable qty with backorders disabled: true oversell, invariant broken",
}
if salable_qty < 0 and backorders != 0:
if abs(salable_qty) > open_order_qty_total + physical_qty:
return {
"flag": True,
"severity": "critical",
"reason": "reservation total exceeds open order demand: phantom/duplicate reservations",
}
return {"flag": False, "severity": "ok", "reason": "negative salable qty is expected backorder behavior"}
expected_salable = physical_qty - open_order_qty_total
if abs(salable_qty - expected_salable) > tolerance_units:
return {
"flag": True,
"severity": "warning",
"reason": "salable qty does not reconcile with source_items minus open reservations: stale index or lost/duplicated reservation",
}
return {"flag": False, "severity": "ok", "reason": "consistent"}
def pause_sales(sku):
payload = {"product": {"sku": sku, "extension_attributes": {"stock_item": {"is_in_stock": False}}}}
if DRY_RUN:
log.info("DRY_RUN: would PUT /products/%s with %s", sku, payload)
return
r = requests.put(
f"{MAGENTO_URL}/rest/V1/products/{sku}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
def run(skus=None):
skus = skus or []
flagged = 0
for sku in skus:
salable_qty = get_salable_qty(sku, STOCK_ID)
physical_qty = get_physical_qty(sku)
open_order_qty_total = get_open_order_qty_total(sku)
stock_item_config = get_stock_item_config(sku)
result = decide_salable_qty_action(sku, salable_qty, physical_qty, open_order_qty_total, stock_item_config)
if not result["flag"]:
continue
log.warning(
"SKU %s: %s (salable=%s, physical=%s, openOrders=%s). %s",
sku, result["severity"], salable_qty, physical_qty, open_order_qty_total, result["reason"],
)
if result["severity"] == "critical" and "oversell" in result["reason"]:
pause_sales(sku)
flagged += 1
log.info("Done. %d SKU(s) flagged.", flagged)
if __name__ == "__main__":
run(skus=os.environ.get("CHECK_SKUS", "").split(",") if os.environ.get("CHECK_SKUS") else [])
/**
* Flag Magento 2 SKUs where salable quantity has gone negative or oversold, safely.
*
* MSI computes salable quantity as sum(in-stock source_items quantities) minus
* sum of outstanding reservations, an append-only ledger. If a compensating
* reservation for a cancelled or failed order is lost, the ledger keeps an
* orphaned entry and salable quantity drifts below zero forever, even though
* physical stock is fine. Backorders set to allow qty below zero can make a
* negative number expected instead of broken. Reservations are never rewritten
* here; the only write this script performs is pausing further sales
* (is_in_stock=false) on a confirmed critical oversell. The actual ledger
* repair stays a CLI-only operation for an admin to run. Safe to run again
* and again.
*
* Guide: https://www.allanninal.dev/magento/salable-quantity-negative-oversell/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const STOCK_ID = process.env.STOCK_ID || "1";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideSalableQtyAction(sku, salableQty, physicalQty, openOrderQtyTotal, stockItemConfig, toleranceUnits = 0) {
if (!stockItemConfig.manageStock) {
return {
flag: true,
severity: "warning",
reason: "manage_stock disabled: product always shows in-stock, oversell not tracked",
};
}
const backorders = stockItemConfig.backorders || 0;
if (salableQty < 0 && backorders === 0) {
return {
flag: true,
severity: "critical",
reason: "negative salable qty with backorders disabled: true oversell, invariant broken",
};
}
if (salableQty < 0 && backorders !== 0) {
if (Math.abs(salableQty) > openOrderQtyTotal + physicalQty) {
return {
flag: true,
severity: "critical",
reason: "reservation total exceeds open order demand: phantom/duplicate reservations",
};
}
return { flag: false, severity: "ok", reason: "negative salable qty is expected backorder behavior" };
}
const expectedSalable = physicalQty - openOrderQtyTotal;
if (Math.abs(salableQty - expectedSalable) > toleranceUnits) {
return {
flag: true,
severity: "warning",
reason: "salable qty does not reconcile with source_items minus open reservations: stale index or lost/duplicated reservation",
};
}
return { flag: false, severity: "ok", reason: "consistent" };
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getSalableQty(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return Number(data);
}
async function getPhysicalQty(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/inventory/source-items", params);
return data.items.filter((i) => i.status === 1).reduce((sum, i) => sum + i.quantity, 0);
}
async function getStockItemConfig(sku) {
const product = await magentoGet(`/products/${sku}`);
const stockItem = product.extension_attributes.stock_item;
return {
manageStock: Boolean(stockItem.manage_stock),
backorders: Number(stockItem.backorders || 0),
};
}
async function getOpenOrderQtyTotal(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "complete,closed,canceled",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "nin",
};
const data = await magentoGet("/orders", params);
let total = 0;
for (const order of data.items) {
for (const item of order.items || []) {
if (item.sku === sku) total += Number(item.qty_ordered || 0);
}
}
return total;
}
async function pauseSales(sku) {
const payload = { product: { sku, extension_attributes: { stock_item: { is_in_stock: false } } } };
if (DRY_RUN) {
console.log(`DRY_RUN: would PUT /products/${sku} with`, JSON.stringify(payload));
return;
}
const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${sku}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
}
export async function run(skus = []) {
let flagged = 0;
for (const sku of skus) {
const salableQty = await getSalableQty(sku, STOCK_ID);
const physicalQty = await getPhysicalQty(sku);
const openOrderQtyTotal = await getOpenOrderQtyTotal(sku);
const stockItemConfig = await getStockItemConfig(sku);
const result = decideSalableQtyAction(sku, salableQty, physicalQty, openOrderQtyTotal, stockItemConfig);
if (!result.flag) continue;
console.warn(
`SKU ${sku}: ${result.severity} (salable=${salableQty}, physical=${physicalQty}, openOrders=${openOrderQtyTotal}). ${result.reason}`
);
if (result.severity === "critical" && result.reason.includes("oversell")) {
await pauseSales(sku);
}
flagged++;
}
console.log(`Done. ${flagged} SKU(s) flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const skus = (process.env.CHECK_SKUS || "").split(",").filter(Boolean);
run(skus).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 SKU is left alone, flagged for CLI reservation repair, or paused from further sales. Because we kept decide_salable_qty_action pure, the test needs no network, no database, and no Magento store. It just feeds in the four numbers and checks the answer.
from flag_salable_qty_oversell import decide_salable_qty_action
CONFIG_NO_BACKORDERS = {"manageStock": True, "backorders": 0}
CONFIG_BACKORDERS = {"manageStock": True, "backorders": 1}
def test_ok_when_consistent():
result = decide_salable_qty_action("SKU-1", 5, 10, 5, CONFIG_NO_BACKORDERS)
assert result["flag"] is False
assert result["severity"] == "ok"
def test_warning_when_manage_stock_disabled():
config = {"manageStock": False, "backorders": 0}
result = decide_salable_qty_action("SKU-1", 5, 10, 5, config)
assert result["flag"] is True
assert result["severity"] == "warning"
def test_critical_when_negative_and_backorders_disabled():
result = decide_salable_qty_action("SKU-1", -2, 10, 12, CONFIG_NO_BACKORDERS)
assert result["flag"] is True
assert result["severity"] == "critical"
assert "backorders disabled" in result["reason"]
def test_ok_when_negative_and_backorders_enabled_matching_demand():
result = decide_salable_qty_action("SKU-1", -3, 10, 13, CONFIG_BACKORDERS)
assert result["flag"] is False
assert result["severity"] == "ok"
def test_critical_when_negative_backorders_enabled_but_exceeds_demand():
result = decide_salable_qty_action("SKU-1", -50, 10, 5, CONFIG_BACKORDERS)
assert result["flag"] is True
assert result["severity"] == "critical"
assert "phantom" in result["reason"]
def test_warning_when_salable_does_not_reconcile():
result = decide_salable_qty_action("SKU-1", 8, 10, 5, CONFIG_NO_BACKORDERS)
assert result["flag"] is True
assert result["severity"] == "warning"
assert "does not reconcile" in result["reason"]
def test_ok_when_reconciles_within_tolerance():
result = decide_salable_qty_action("SKU-1", 5, 10, 5, CONFIG_NO_BACKORDERS, tolerance_units=0)
assert result["flag"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideSalableQtyAction } from "./flag-salable-qty-oversell.js";
const CONFIG_NO_BACKORDERS = { manageStock: true, backorders: 0 };
const CONFIG_BACKORDERS = { manageStock: true, backorders: 1 };
test("ok when consistent", () => {
const result = decideSalableQtyAction("SKU-1", 5, 10, 5, CONFIG_NO_BACKORDERS);
assert.equal(result.flag, false);
assert.equal(result.severity, "ok");
});
test("warning when manage_stock disabled", () => {
const config = { manageStock: false, backorders: 0 };
const result = decideSalableQtyAction("SKU-1", 5, 10, 5, config);
assert.equal(result.flag, true);
assert.equal(result.severity, "warning");
});
test("critical when negative and backorders disabled", () => {
const result = decideSalableQtyAction("SKU-1", -2, 10, 12, CONFIG_NO_BACKORDERS);
assert.equal(result.flag, true);
assert.equal(result.severity, "critical");
assert.match(result.reason, /backorders disabled/);
});
test("ok when negative and backorders enabled matching demand", () => {
const result = decideSalableQtyAction("SKU-1", -3, 10, 13, CONFIG_BACKORDERS);
assert.equal(result.flag, false);
assert.equal(result.severity, "ok");
});
test("critical when negative backorders enabled but exceeds demand", () => {
const result = decideSalableQtyAction("SKU-1", -50, 10, 5, CONFIG_BACKORDERS);
assert.equal(result.flag, true);
assert.equal(result.severity, "critical");
assert.match(result.reason, /phantom/);
});
test("warning when salable does not reconcile", () => {
const result = decideSalableQtyAction("SKU-1", 8, 10, 5, CONFIG_NO_BACKORDERS);
assert.equal(result.flag, true);
assert.equal(result.severity, "warning");
assert.match(result.reason, /does not reconcile/);
});
test("ok when reconciles within tolerance", () => {
const result = decideSalableQtyAction("SKU-1", 5, 10, 5, CONFIG_NO_BACKORDERS, 0);
assert.equal(result.flag, false);
});
Case studies
The store where cancellations stopped compensating
A catalog upgraded through several 2.3.x releases kept a customized order cancellation flow written years earlier, before ReservationBuilder was the standard path. Every cancelled order still updated sales_order correctly, but the compensating reservation was never written, so every cancellation quietly left the ledger a little more negative.
Nobody noticed until a top seller's salable quantity read minus 40 with backorders off, an impossible state. The detection job flagged it as critical within the first run, giving the team the exact SKU to hand to bin/magento inventory:reservation:list-inconsistencies instead of guessing which of thousands of orders were the culprit.
The SKU that looked broken but was not
A wholesale SKU had Allow Qty Below 0 turned on deliberately, so staff expected to see it go negative while restock was in transit. A monitoring alert kept firing anyway, every time the number dropped, because nobody had distinguished expected backorder drift from an actual problem.
Once the checks compared the negative salable quantity against real open order demand, the noise stopped. Only when the negative magnitude no longer matched the orders actually in flight did the job flag it, and that happened exactly once, catching a real duplicate reservation the team would have otherwise missed.
After this runs on a schedule, a broken reservation ledger is caught within one detection cycle instead of surviving silently until a customer complains about an oversold order. The alert carries the exact salable, physical, and open order numbers, so whoever responds knows immediately whether it needs the CLI reservation repair or is just expected backorder behavior. Keep the pause-sales step limited to confirmed critical oversells, since that is what keeps the script from fighting a SKU that is backordered on purpose.
FAQ
Why does Magento show a negative salable quantity?
MSI computes salable quantity as the sum of in-stock source_items quantities minus the sum of outstanding reservations, which are an append-only ledger rather than a live decrement of one qty column. If a compensating reservation for a cancelled or failed order is never written, the ledger keeps an orphaned negative entry forever, so the computed salable quantity drifts below zero even though the physical stock is fine. Backorders set to allow qty below zero can also make salable quantity negative on purpose, which is expected rather than broken.
Is a negative salable quantity always a bug?
No. If Backorders is set to allow qty below zero, a negative salable quantity is the intended behavior, since the product is meant to keep selling while it is backordered. It only signals a broken invariant when backorders are disabled, or when the negative amount is larger than the open order demand can explain, which points to orphaned or duplicated reservations instead.
Can a script fix the reservation ledger over the REST API?
Not safely. Reservations are an append-only event log, and editing or deleting entries directly can corrupt the ledger or double-correct it. The REST-safe action is to stop further sales on a genuinely oversold SKU by setting is_in_stock to false or tightening the out of stock threshold. The actual ledger repair, bin/magento inventory:reservation:list-inconsistencies followed by inventory:reservation:create-compensations, is a CLI-only operation for an admin to run.
Related field notes
Citations
On the problem:
- GitHub Issue: MSI setting that prevents negative salable qtys, or some kind of correction process. github.com/magento/inventory/issues/3165
- GitHub Issue: MSI modules allow oversell leading to negative quantity available. github.com/magento/inventory/issues/2722
- Magento Community Forum: inconsistency in product salable quantity, inventory reservations not working properly. community.magento.com inconsistency in product salable quantity
On the solution:
- Adobe Commerce PHP Extensions: Inventory Management API reference. developer.adobe.com/commerce/php/development/components/web-api/inventory-management
- Adobe Commerce Web APIs: manage source items. developer.adobe.com/commerce/webapi/rest/inventory/manage-source-items
- magento/inventory Wiki: configure MSI backorders. github.com/magento/inventory/wiki/Configure-MSI-backorders
Stuck on a tricky one?
If you have a problem in Magento 2 or Adobe Commerce inventory, orders, catalog data, or indexing that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a bad SKU?
If this saved you an oversold order or a confusing negative quantity, 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