Reconciler Inventory (MSI)
Salable quantity corrupted by bad reservation compensation
The stock is on the shelf, the source item quantity looks right, and the product still will not sell, or worse, it keeps selling past zero. MSI does not store salable quantity anywhere. It calculates it every time from the source quantity minus every reservation row ever written for that SKU. When one order event fails to write its compensating reservation, that running total is wrong forever, and nothing about placing a new order fixes the old damage. Here is why the gap opens and a small script that finds the exact SKUs it hit.
Magento's Multi Source Inventory computes salable quantity as source item quantity minus the sum of every inventory_reservation row for a SKU and stock, it never stores the number directly. When an order event, place, invoice, ship, cancel, or credit memo, fails to write its compensating reservation, because a cron or async job failed, an upgrade left legacy orders without their initial reservations, or stock got reassigned to a different website mid flight, the running sum of reservations drifts away from the real committed quantity. The reported salable quantity ends up permanently offset, either hiding sellable stock or letting the store oversell, and it never self heals because every later order only stacks another delta on top of the already wrong baseline. Run a small Python or Node.js script that, for each SKU, sums the real source item quantity, reads the reported salable quantity, independently derives the expected salable quantity from open order items, and flags any SKU where the two disagree beyond a rounding tolerance. The script only reports and prints the exact CLI command for compensation, since writing a reservation row is not something REST can do. Full code, tests, and a dry run guard are below.
The problem in plain words
Ask Magento how many units of a SKU you can sell right now, and it does not look up a stored number. It runs a subtraction: take the quantity sitting in every source assigned to that stock, and subtract every reservation ever recorded against that SKU and stock in the inventory_reservation table. Reservations are small signed rows, a negative one when an order is placed, a positive one to give quantity back on a cancel or credit memo, and so on through invoice and shipment.
That design works as long as every order event writes its matching reservation. But some do not. A cron or async job that was supposed to write a compensating reservation can fail partway and never retry. An upgrade that introduces MSI on a store with existing legacy orders can leave those orders with no initial reservation at all, so their later shipments or cancellations write a reservation against a baseline that was never established. A mid flight reassignment of stock to a different website can also break the chain. The moment one of these gaps happens, the running sum of reservations no longer equals the real committed quantity, and the salable number MSI reports is offset from reality by exactly that missed amount, forever.
Why it happens
None of this needs a bug in the subtraction itself, the arithmetic is fine. The gap comes from something upstream failing to write the row it was supposed to write. A few concrete ways it shows up on real stores:
- A cron or asynchronous consumer job responsible for writing the compensating reservation on invoice, shipment, cancellation, or credit memo dies partway through and there is no automatic retry, so that one event's reservation is simply missing.
- An upgrade to a version with MSI enabled runs against a store that already had open legacy orders, and those orders never get an initial reservation written for them, so every later event on that order writes a compensation against a baseline that was never correct.
- A mid flight reassignment of a source or a SKU to a different stock or website changes which stock's reservations apply, and old reservations tied to the previous stock assignment are left orphaned or double counted.
- A partial refund creates an incorrect reservation compensation, a known defect pattern documented by Adobe as ACSD-45424, where the compensating row does not match the quantity that was actually returned to stock.
None of this throws an error a merchant would see. The admin grid can show the source item quantity looking perfectly normal while the storefront salable quantity is zero, or while it lets an order through that should have been blocked. Two numbers, same SKU, and nothing on screen explains the gap between them. See the citations at the end for the exact threads and Adobe knowledge base entry that describe this.
Because MSI never stores salable quantity, the only reliable way to know it is wrong is to compute what it should be from a completely independent source, the open order items, and compare. bin/magento inventory:reservation:list-inconsistencies -r already does exactly this walk internally. This script mirrors that same logic over REST so you get a report without shell access, and it hands off the actual write, the compensation row, to the one supported CLI pair that can create it.
The fix, as a flow
We never write a reservation row from this script, because there is no REST resource for it. Instead, for each SKU we pull the real committed quantity from source items, the MSI reported salable quantity, and the unfulfilled quantity sitting in open orders. We derive an expected salable quantity independently, compare it against what MSI reports, and only when the two disagree beyond a small tolerance do we flag the SKU and print the exact CLI command an operator should run.
Build it step by step
Get an admin token
Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL, credentials, and the stock id in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STOCK_ID="1"
export SKUS="SKU-1001,SKU-1002,SKU-1003"
export RESERVATION_TOLERANCE="0.0001"
export DRY_RUN="true" # report only, this script never writes a reservation row
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STOCK_ID="1"
export SKUS="SKU-1001,SKU-1002,SKU-1003"
export RESERVATION_TOLERANCE="0.0001"
export DRY_RUN="true" // report only, this script never writes a reservation row
Sum the real committed quantity from source items
POST to /rest/V1/integration/admin/token for a bearer token, then use searchCriteria against /rest/V1/inventory/source-items filtered by SKU, and add up the quantity across every source. This is the real stock on hand, independent of anything MSI has calculated.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
def get_token(username, password):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": username, "password": password},
timeout=30,
)
r.raise_for_status()
return r.json()
def source_qty_sum(token, sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/inventory/source-items",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
items = r.json().get("items", [])
return sum(item.get("quantity", 0) for item in items)
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
async function getToken(username, password) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function sourceQtySum(token, sku) {
const params = new URLSearchParams({
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/source-items?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const items = body.items || [];
return items.reduce((sum, item) => sum + (item.quantity || 0), 0);
}
Read the MSI reported salable quantity and open order items
Call /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId} for the number MSI is currently reporting. Then call /rest/V1/orders filtered on status in processing or pending, and sum the unfulfilled quantity of the SKU across those order items. That sum is the open commitment MSI's reservations are supposed to represent.
def reported_salable_qty(token, sku, stock_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/{sku}/{stock_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def open_order_item_qty_sum(token, sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "status",
"searchCriteria[filterGroups][1][filters][0][value]": "pending",
"searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": 200,
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/orders",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
total = 0.0
affected_order_ids = []
for order in r.json().get("items", []):
for line in order.get("items", []):
if line.get("sku") == sku:
qty_unfulfilled = (line.get("qty_ordered", 0) or 0) - (line.get("qty_shipped", 0) or 0) - (line.get("qty_canceled", 0) or 0)
if qty_unfulfilled > 0:
total += qty_unfulfilled
affected_order_ids.append(order.get("entity_id"))
return total, affected_order_ids
async function reportedSalableQty(token, sku, stockId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/${encodeURIComponent(sku)}/${stockId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function openOrderItemQtySum(token, sku) {
const params = new URLSearchParams({
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "status",
"searchCriteria[filterGroups][1][filters][0][value]": "pending",
"searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": "200",
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
let total = 0;
const affectedOrderIds = [];
for (const order of body.items || []) {
for (const line of order.items || []) {
if (line.sku === sku) {
const qtyUnfulfilled = (line.qty_ordered || 0) - (line.qty_shipped || 0) - (line.qty_canceled || 0);
if (qtyUnfulfilled > 0) {
total += qtyUnfulfilled;
affectedOrderIds.push(order.entity_id);
}
}
}
}
return { total, affectedOrderIds };
}
Decide, with one pure function
Keep the reconciliation math in its own function that takes only already fetched numbers, source quantity, reported salable quantity, and open order item quantity, and returns a verdict. It has no I/O, so it is trivial to unit test with fixed inputs and it never depends on the network being up.
def reconcile_salable_qty(source_qty, reported_salable_qty, open_order_item_qty_sum, tolerance=0.0001):
expected_salable_qty = source_qty - open_order_item_qty_sum
delta = reported_salable_qty - expected_salable_qty
is_consistent = abs(delta) <= tolerance
return {
"isConsistent": is_consistent,
"expectedSalableQty": expected_salable_qty,
"delta": delta,
}
export function reconcileSalableQty(sourceQty, reportedSalableQty, openOrderItemQtySum, tolerance = 0.0001) {
const expectedSalableQty = sourceQty - openOrderItemQtySum;
const delta = reportedSalableQty - expectedSalableQty;
const isConsistent = Math.abs(delta) <= tolerance;
return { isConsistent, expectedSalableQty, delta };
}
There is no REST write for this, print the CLI fix instead
Writing a corrective inventory_reservation row is not exposed over the REST API. The one supported way to compensate is the CLI pair Adobe documents: list the inconsistencies, then create the compensations. When a SKU is flagged, the script prints the exact command an operator with shell access should run rather than attempting any write of its own.
COMPENSATION_COMMAND = (
"bin/magento inventory:reservation:list-inconsistencies -r "
"| bin/magento inventory:reservation:create-compensations"
)
def print_compensation_command():
print("No REST endpoint can write a reservation compensation row.")
print("Run this on the server to repair the flagged SKUs:")
print(f" {COMPENSATION_COMMAND}")
const COMPENSATION_COMMAND =
"bin/magento inventory:reservation:list-inconsistencies -r " +
"| bin/magento inventory:reservation:create-compensations";
function printCompensationCommand() {
console.log("No REST endpoint can write a reservation compensation row.");
console.log("Run this on the server to repair the flagged SKUs:");
console.log(` ${COMPENSATION_COMMAND}`);
}
Wire it together with a dry run guard
The loop authenticates once, walks every configured SKU, pulls the three independent numbers, runs the pure reconciliation function, and emits a report row for anything over tolerance. DRY_RUN defaults to true and there is no false path that writes a reservation, this script only ever reads and reports, then prints the CLI command once at the end if anything was flagged.
This script never writes an inventory_reservation row and never calls a REST endpoint that mutates stock. It reports the affected SKUs, stock id, the three raw numbers, the delta, and the open order ids so a human can see exactly what is wrong, then it prints the CLI command for the operator to run. Compensation always happens on the server, never through this script.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever performs reads, plus printing the CLI compensation command when a SKU is flagged.
"""Flag Magento 2 or Adobe Commerce SKUs where MSI salable quantity is corrupted
by a missed reservation compensation.
MSI never stores salable quantity. It computes it as source item quantity minus
the sum of every inventory_reservation row for a SKU and stock. When one order
event, place, invoice, ship, cancel, or credit memo, fails to write its
compensating reservation, that running sum drifts away from the real committed
quantity and the reported salable quantity is permanently offset. This script
cross references source items, the MSI reported salable quantity, and open
order items to independently derive the expected salable quantity, and flags
any SKU where the two disagree beyond a tolerance. It never writes a
reservation row: that can only be done with
bin/magento inventory:reservation:list-inconsistencies -r piped into
bin/magento inventory:reservation:create-compensations. Safe to run again and
again.
"""
import os
import csv
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_salable_qty_corruption")
MAGENTO_URL = os.environ.get("MAGENTO_URL", "https://example.test").rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD", "change-me")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
STOCK_ID = os.environ.get("STOCK_ID", "1")
SKUS = [s.strip() for s in os.environ.get("SKUS", "").split(",") if s.strip()]
RESERVATION_TOLERANCE = float(os.environ.get("RESERVATION_TOLERANCE", "0.0001"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "salable_qty_inconsistencies.csv")
COMPENSATION_COMMAND = (
"bin/magento inventory:reservation:list-inconsistencies -r "
"| bin/magento inventory:reservation:create-compensations"
)
def get_token():
if ADMIN_TOKEN:
return ADMIN_TOKEN
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()
def source_qty_sum(token, sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/inventory/source-items",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
items = r.json().get("items", [])
return sum(item.get("quantity", 0) for item in items)
def reported_salable_qty(token, sku, stock_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/{sku}/{stock_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def open_order_item_qty_sum(token, sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "status",
"searchCriteria[filterGroups][1][filters][0][value]": "pending",
"searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": 200,
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/orders",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
total = 0.0
affected_order_ids = []
for order in r.json().get("items", []):
for line in order.get("items", []):
if line.get("sku") == sku:
qty_unfulfilled = (line.get("qty_ordered", 0) or 0) - (line.get("qty_shipped", 0) or 0) - (line.get("qty_canceled", 0) or 0)
if qty_unfulfilled > 0:
total += qty_unfulfilled
affected_order_ids.append(order.get("entity_id"))
return total, affected_order_ids
def reconcile_salable_qty(source_qty, reported_salable_qty_value, open_order_item_qty_sum_value, tolerance=RESERVATION_TOLERANCE):
expected_salable_qty = source_qty - open_order_item_qty_sum_value
delta = reported_salable_qty_value - expected_salable_qty
is_consistent = abs(delta) <= tolerance
return {
"isConsistent": is_consistent,
"expectedSalableQty": expected_salable_qty,
"delta": delta,
}
def print_compensation_command():
log.warning("No REST endpoint can write a reservation compensation row.")
log.warning("Run this on the server to repair the flagged SKUs:")
log.warning(" %s", COMPENSATION_COMMAND)
def run():
token = get_token()
flagged = []
for sku in SKUS:
src_qty = source_qty_sum(token, sku)
reported_qty = reported_salable_qty(token, sku, STOCK_ID)
open_qty, affected_order_ids = open_order_item_qty_sum(token, sku)
verdict = reconcile_salable_qty(src_qty, reported_qty, open_qty)
if verdict["isConsistent"]:
continue
row = {
"sku": sku,
"stock_id": STOCK_ID,
"source_qty_sum": src_qty,
"reported_salable_qty": reported_qty,
"expected_salable_qty": verdict["expectedSalableQty"],
"delta": verdict["delta"],
"affected_open_order_ids": ";".join(str(i) for i in affected_order_ids),
}
flagged.append(row)
log.info(
"SKU %s stock %s: reported=%s expected=%s delta=%s",
sku, STOCK_ID, reported_qty, verdict["expectedSalableQty"], verdict["delta"],
)
if flagged:
with open(OUTPUT_CSV, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=[
"sku", "stock_id", "source_qty_sum", "reported_salable_qty",
"expected_salable_qty", "delta", "affected_open_order_ids",
])
writer.writeheader()
writer.writerows(flagged)
print_compensation_command()
log.info("Done. %d SKU(s) flagged, %s.", len(flagged), "dry run, nothing written" if DRY_RUN else "report only, no write ever attempted")
if __name__ == "__main__":
run()
/**
* Flag Magento 2 or Adobe Commerce SKUs where MSI salable quantity is corrupted
* by a missed reservation compensation.
*
* MSI never stores salable quantity. It computes it as source item quantity minus
* the sum of every inventory_reservation row for a SKU and stock. When one order
* event, place, invoice, ship, cancel, or credit memo, fails to write its
* compensating reservation, that running sum drifts away from the real committed
* quantity and the reported salable quantity is permanently offset. This script
* cross references source items, the MSI reported salable quantity, and open
* order items to independently derive the expected salable quantity, and flags
* any SKU where the two disagree beyond a tolerance. It never writes a
* reservation row: that can only be done with
* bin/magento inventory:reservation:list-inconsistencies -r piped into
* bin/magento inventory:reservation:create-compensations. Safe to run again and
* again.
*
* Guide: https://www.allanninal.dev/magento/salable-quantity-corrupted-by-reservations/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const STOCK_ID = process.env.STOCK_ID || "1";
const SKUS = (process.env.SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
const RESERVATION_TOLERANCE = Number(process.env.RESERVATION_TOLERANCE || 0.0001);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const COMPENSATION_COMMAND =
"bin/magento inventory:reservation:list-inconsistencies -r " +
"| bin/magento inventory:reservation:create-compensations";
export function reconcileSalableQty(sourceQty, reportedSalableQty, openOrderItemQtySum, tolerance = RESERVATION_TOLERANCE) {
const expectedSalableQty = sourceQty - openOrderItemQtySum;
const delta = reportedSalableQty - expectedSalableQty;
const isConsistent = Math.abs(delta) <= tolerance;
return { isConsistent, expectedSalableQty, delta };
}
async function getToken() {
if (ADMIN_TOKEN) return ADMIN_TOKEN;
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function sourceQtySum(token, sku) {
const params = new URLSearchParams({
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/source-items?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const items = body.items || [];
return items.reduce((sum, item) => sum + (item.quantity || 0), 0);
}
async function reportedSalableQty(token, sku, stockId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/${encodeURIComponent(sku)}/${stockId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function openOrderItemQtySum(token, sku) {
const params = new URLSearchParams({
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "status",
"searchCriteria[filterGroups][1][filters][0][value]": "pending",
"searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": "200",
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
let total = 0;
const affectedOrderIds = [];
for (const order of body.items || []) {
for (const line of order.items || []) {
if (line.sku === sku) {
const qtyUnfulfilled = (line.qty_ordered || 0) - (line.qty_shipped || 0) - (line.qty_canceled || 0);
if (qtyUnfulfilled > 0) {
total += qtyUnfulfilled;
affectedOrderIds.push(order.entity_id);
}
}
}
}
return { total, affectedOrderIds };
}
function printCompensationCommand() {
console.warn("No REST endpoint can write a reservation compensation row.");
console.warn("Run this on the server to repair the flagged SKUs:");
console.warn(` ${COMPENSATION_COMMAND}`);
}
export async function run() {
const token = await getToken();
const flagged = [];
for (const sku of SKUS) {
const srcQty = await sourceQtySum(token, sku);
const reportedQty = await reportedSalableQty(token, sku, STOCK_ID);
const { total: openQty, affectedOrderIds } = await openOrderItemQtySum(token, sku);
const verdict = reconcileSalableQty(srcQty, reportedQty, openQty);
if (verdict.isConsistent) continue;
const row = {
sku,
stock_id: STOCK_ID,
source_qty_sum: srcQty,
reported_salable_qty: reportedQty,
expected_salable_qty: verdict.expectedSalableQty,
delta: verdict.delta,
affected_open_order_ids: affectedOrderIds.join(";"),
};
flagged.push(row);
console.log(`SKU ${sku} stock ${STOCK_ID}: reported=${reportedQty} expected=${verdict.expectedSalableQty} delta=${verdict.delta}`);
}
if (flagged.length) {
printCompensationCommand();
}
console.log(`Done. ${flagged.length} SKU(s) flagged, ${DRY_RUN ? "dry run, nothing written" : "report only, no write ever attempted"}.`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The reconciliation rule is the part most worth testing, because it decides whether a SKU is corrupted or healthy. Since reconcile_salable_qty and reconcileSalableQty are pure, the tests need no network and no Magento instance. They just feed in plain numbers and check the verdict, covering an exact match, the rounding tolerance edge, overcompensation, and a lost reservation.
from flag_salable_qty_corruption import reconcile_salable_qty
def test_exact_match_is_consistent():
result = reconcile_salable_qty(source_qty=100, reported_salable_qty_value=70, open_order_item_qty_sum_value=30)
assert result == {"isConsistent": True, "expectedSalableQty": 70, "delta": 0}
def test_within_rounding_tolerance_is_consistent():
result = reconcile_salable_qty(source_qty=100, reported_salable_qty_value=70.00005, open_order_item_qty_sum_value=30)
assert result["isConsistent"] is True
def test_overcompensation_positive_delta_is_flagged():
result = reconcile_salable_qty(source_qty=100, reported_salable_qty_value=85, open_order_item_qty_sum_value=30)
assert result["isConsistent"] is False
assert result["expectedSalableQty"] == 70
assert result["delta"] == 15
def test_lost_reservation_negative_delta_is_flagged():
result = reconcile_salable_qty(source_qty=100, reported_salable_qty_value=40, open_order_item_qty_sum_value=30)
assert result["isConsistent"] is False
assert result["expectedSalableQty"] == 70
assert result["delta"] == -30
def test_custom_tolerance_is_respected():
result = reconcile_salable_qty(source_qty=100, reported_salable_qty_value=70.01, open_order_item_qty_sum_value=30, tolerance=0.02)
assert result["isConsistent"] is True
def test_just_over_default_tolerance_is_flagged():
result = reconcile_salable_qty(source_qty=100, reported_salable_qty_value=70.001, open_order_item_qty_sum_value=30)
assert result["isConsistent"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileSalableQty } from "./flag-salable-qty-corruption.js";
test("exact match is consistent", () => {
const result = reconcileSalableQty(100, 70, 30);
assert.deepEqual(result, { isConsistent: true, expectedSalableQty: 70, delta: 0 });
});
test("within rounding tolerance is consistent", () => {
const result = reconcileSalableQty(100, 70.00005, 30);
assert.equal(result.isConsistent, true);
});
test("overcompensation positive delta is flagged", () => {
const result = reconcileSalableQty(100, 85, 30);
assert.equal(result.isConsistent, false);
assert.equal(result.expectedSalableQty, 70);
assert.equal(result.delta, 15);
});
test("lost reservation negative delta is flagged", () => {
const result = reconcileSalableQty(100, 40, 30);
assert.equal(result.isConsistent, false);
assert.equal(result.expectedSalableQty, 70);
assert.equal(result.delta, -30);
});
test("custom tolerance is respected", () => {
const result = reconcileSalableQty(100, 70.01, 30, 0.02);
assert.equal(result.isConsistent, true);
});
test("just over default tolerance is flagged", () => {
const result = reconcileSalableQty(100, 70.001, 30);
assert.equal(result.isConsistent, false);
});
Case studies
A shipment event that never wrote its reservation
A furniture store's queue consumer for the shipment compensating reservation crashed during a deploy and was never restarted for that batch. The affected SKUs kept showing zero salable quantity on the storefront for weeks, even though the warehouse had stock sitting on the shelf, because the reservation from the original order was never released back.
Running the script against the affected SKUs turned up a consistent positive delta, expected salable quantity higher than what MSI reported, on every one of them. The operator ran bin/magento inventory:reservation:list-inconsistencies -r piped into create-compensations, and the storefront quantity matched the shelf again within minutes.
Legacy orders left without an initial reservation
An apparel retailer upgraded to a Magento version with MSI enabled while dozens of orders were still open from before the upgrade. Those legacy orders had no initial reservation, so every later cancellation or partial refund against them wrote a compensation against a baseline that was never established, and a handful of popular SKUs began quietly overselling.
The script flagged those SKUs with a negative delta, reported salable quantity higher than the open orders justified, along with the exact affected order ids. That list let the merchandising team pause the SKUs from sale while an operator ran the compensation commands, instead of finding out through a wave of backorder emails.
After running this on a schedule, a corrupted salable quantity stops being invisible. You get a short, dated list of exactly which SKUs disagree, the raw numbers behind the disagreement, which open orders are implicated, and the exact CLI command to hand to whoever has shell access. The compensation itself still belongs to inventory:reservation:create-compensations, but nobody has to discover the gap from a support ticket or an oversold order first.
FAQ
Why is my Magento MSI salable quantity wrong even though the source item quantity looks correct?
MSI never stores salable quantity directly. It computes it on the fly as the source item quantity minus the sum of every inventory_reservation row for that SKU and stock. If one order event, such as a place, invoice, shipment, cancellation, or partial refund, fails to write its compensating reservation because of a failed cron job, a missed upgrade step, or a mid flight stock reassignment, the running sum of reservations no longer matches the real committed quantity, and the reported salable quantity is permanently offset.
Does the salable quantity gap fix itself over time?
No. Every new order, shipment, or refund only adds another reservation delta on top of the already wrong baseline, it never recalculates from scratch. A SKU that is offset today stays offset, and the gap can even grow, until someone runs the compensation process that inserts a corrective reservation row.
Can a script fix a corrupted salable quantity through the REST API?
Not directly. There is no REST endpoint that writes an inventory_reservation row. The supported fix is the CLI pair bin/magento inventory:reservation:list-inconsistencies -r piped into bin/magento inventory:reservation:create-compensations. A script can detect and report the corrupted SKUs over REST, and it can print the exact CLI command to run, but it cannot perform the compensation itself.
Related field notes
Citations
On the problem:
- Inconsistency in product salable quantity, Magento Community Forum. community.magento.com inconsistency-in-product-salable-quantity
- MSI setting that prevents negative salable qtys, magento/inventory issue 3165. github.com/magento/inventory/issues/3165
- ACSD-45424: Incorrect reservation compensation created after partial refund, Adobe Commerce. experienceleague.adobe.com acsd-45424-incorrect-reservation-compensation
On the solution:
- Inventory Management CLI reference, Adobe Commerce. experienceleague.adobe.com commerce-admin inventory/cli
- Check salable quantities, Adobe Commerce Web API. developer.adobe.com commerce/webapi/rest/inventory/check-salable-quantity
- Source algorithms and reservations, Adobe Commerce. experienceleague.adobe.com commerce-admin inventory/basics/selection-reservations
Stuck on a tricky one?
If you have a problem in Magento indexing, cron, MSI stock, or order grid 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 untangle your salable quantity?
If this saved you a confusing oversell or a mystery zero stock ticket, 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