Reconciler Stock & Inventory
Negative quantities recorded on backorder paid orders
A product's stock_available row shows a quantity like -3 or -12, and it has sat that way for weeks. Nobody sold stock they did not have on purpose, but the number will not come back on its own. Here is why PrestaShop can drive stock below zero on backorder paid orders and race conditions, and a small script that tells real oversell demand apart from drift and repairs only the drift.
PrestaShop decrements ps_stock_available.quantity at order validation without a transactional row lock tied to the final payment confirmation, so when a product allows backorders, or stock enforcement is momentarily bypassed, concurrent checkouts or an order transitioning through a backorder paid state can each subtract from an already-zero or already-reserved line and drive quantity below zero, with nothing in core to self heal it. Run a script that pulls every negative stock_availables row, cross-references the orders and order states that touch it, and feeds the result into a pure function that decides clamp_to_zero or flag_manual_review per row. Only the drift rows get a PUT to reset quantity to 0. Full code, tests, and a dry run guard are below.
The problem in plain words
When an order is validated, PrestaShop subtracts the ordered quantity from the product's stock_available row through StockAvailable::updateQuantity. That subtraction is meant to happen once per order, at the moment the order becomes real.
The trouble is that this decrement is not tied to a transactional row lock against the final payment confirmation. If a product allows backorders, or if stock enforcement gets bypassed for a moment during checkout, two shoppers can check out for the last unit at nearly the same time, or an order can pass through a non-logable or backorder-paid state that still triggers a subtraction. Each of those subtracts the ordered amount from a stock line that is already at zero or already fully reserved. The result is a quantity, and sometimes a physical_quantity, that goes negative. Nothing in core reconciles that number against reserved_quantity or the order_detail history afterward, so once a line goes negative it stays negative indefinitely until someone manually corrects it.
Why it happens
PrestaShop's stock decrement was built to be fast at the moment of order validation, not to serialize against every other checkout touching the same line or to distinguish a real payment confirmation from an in-progress order state. That tradeoff shows up in a few recurring ways:
- A product allows backorders, so
StockAvailable::updateQuantitykeeps subtracting past zero on purpose, and the resulting negative number then gets treated the same as accidental drift by anyone glancing at a report. - Two concurrent checkouts validate for the same product at nearly the same moment, and each decrement runs against the stock line without a transactional lock tied to the final payment confirmation, so both subtractions land even though only one unit existed.
- An order transitions through a non-logable or backorder-paid order state, for example
PS_OS_OUTOFSTOCK_PAID, and the stock hook fires again on a line that was already reserved or already zero. - Stock enforcement is momentarily bypassed, for example by a custom module, an import, or a webservice call that writes an order without going through the normal validation path that would otherwise check availability first.
This is a long-standing, reproducible core bug rather than a one-off misconfiguration. It has been confirmed across PrestaShop 1.7.5.x through 1.7.7.x and is still reported in later trackers: the stock deduction logic in StockAvailable::updateQuantity and the order state change hooks do not reconcile against reserved_quantity or order_detail history, so once a line goes negative it stays negative indefinitely unless someone corrects it by hand. See the citations at the end for the exact reports.
A negative quantity is not automatically a bug. If a product explicitly allows backorders and there is a genuine open backorder paid order still waiting on stock, the negative number is an honest signal of real oversell depth, and zeroing it out would quietly erase that liability from the merchant's view. The number only counts as drift worth repairing when backorders should have been denied, or when there is no matching open backorder paid order left to justify the deficit. That distinction is exactly what the decision function below encodes, so the script never overwrites a real backorder queue by mistake.
The fix, as a flow
We never touch every negative row the same way. The script pulls the negative stock_availables rows, cross-references each one against the orders and order states that plausibly caused it, and passes the result through a pure function that classifies each row as no correction needed, safe to clamp to zero, or needing a human to reconcile stock or trigger a reorder. Only the clamp rows get written, and only when dry run is off.
Build it step by step
Get a webservice key with the right permissions
In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to stock_availables, orders, order_details, and order_states, plus write access to stock_availables. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, change to false to write
Pull every stock row and filter to the negative ones
PrestaShop's filter range syntax on signed integers is unreliable, so rather than trust ?filter[quantity]=[,-1] on the server, fetch the full stock_availables list and filter client side. For each hit, capture id, id_product, id_product_attribute, quantity, out_of_stock, and depends_on_stock.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def negative_stock_rows():
data = api_get("stock_availables", {"display": "full", "limit": "0,1000"})
rows = data.get("stock_availables") or []
out = []
for r in rows:
quantity = int(r.get("quantity") or 0)
if quantity >= 0:
continue
out.append({
"id": int(r["id"]),
"id_product": int(r["id_product"]),
"id_product_attribute": int(r.get("id_product_attribute") or 0),
"quantity": quantity,
"out_of_stock": int(r.get("out_of_stock") or 0),
"depends_on_stock": str(r.get("depends_on_stock")) in ("1", "true", "True"),
})
return out
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function negativeStockRows() {
const data = await apiGet("stock_availables", { display: "full", limit: "0,1000" });
const rows = data.stock_availables || [];
return rows
.map((r) => ({
id: Number(r.id),
id_product: Number(r.id_product),
id_product_attribute: Number(r.id_product_attribute || 0),
quantity: Number(r.quantity || 0),
out_of_stock: Number(r.out_of_stock || 0),
depends_on_stock: String(r.depends_on_stock) === "1" || r.depends_on_stock === true,
}))
.filter((r) => r.quantity < 0);
}
Cross-reference the orders that plausibly caused it
For each negative row, pull the order_details lines for that product, join to the parent order to read current_state, then look up that state in order_states by name and flags, not by an assumed id, to see whether it resolves to a paid, backorder style status such as PS_OS_OUTOFSTOCK_PAID.
def order_details_for_product(id_product):
data = api_get("order_details", {"display": "full", "filter[product_id]": id_product})
return data.get("order_details") or []
def order_by_id(id_order):
data = api_get(f"orders/{id_order}", {"display": "full"})
return data.get("order") or {}
def order_state_by_id(id_order_state):
data = api_get(f"order_states/{id_order_state}", {"display": "full"})
return data.get("order_state") or {}
def has_open_backorder_paid_order(id_product):
for line in order_details_for_product(id_product):
order = order_by_id(line["id_order"])
state = order_state_by_id(order.get("current_state"))
paid = str(state.get("paid")) in ("1", "true", "True")
name = str(state.get("name") or "")
product_quantity = int(line.get("product_quantity") or 0)
if paid and "backorder" in name.lower() and product_quantity < 0:
return True
return False
async function orderDetailsForProduct(idProduct) {
const data = await apiGet("order_details", { display: "full", "filter[product_id]": idProduct });
return data.order_details || [];
}
async function orderById(idOrder) {
const data = await apiGet(`orders/${idOrder}`, { display: "full" });
return data.order || {};
}
async function orderStateById(idOrderState) {
const data = await apiGet(`order_states/${idOrderState}`, { display: "full" });
return data.order_state || {};
}
async function hasOpenBackorderPaidOrder(idProduct) {
for (const line of await orderDetailsForProduct(idProduct)) {
const order = await orderById(line.id_order);
const state = await orderStateById(order.current_state);
const paid = String(state.paid) === "1" || state.paid === true;
const name = String(state.name || "");
const productQuantity = Number(line.product_quantity || 0);
if (paid && name.toLowerCase().includes("backorder") && productQuantity < 0) return true;
}
return false;
}
Decide, with one pure function
Keep the actual decision in its own function that takes the current quantity and the product's backorder policy and returns a corrected quantity plus an action tag. If the quantity is not negative there is nothing to do. If depends_on_stock is off, the value is meaningless for decrement purposes, so it is left alone but flagged. If backorders are explicitly allowed and a genuine open backorder paid order still justifies the deficit, it is flagged for a human or a replenishment workflow rather than zeroed. Everything else negative is drift, and gets clamped to zero.
def clamp_negative_stock(quantity, depends_on_stock, out_of_stock_policy, has_pending_backorder_paid):
"""
out_of_stock_policy: 0 = deny, 1 = allow (backorder), 2 = use global default
Returns (new_quantity, action) where action in {"noop", "clamp_to_zero", "flag_manual_review"}.
"""
if quantity >= 0:
return (quantity, "noop")
if not depends_on_stock:
return (quantity, "flag_manual_review")
if out_of_stock_policy == 1 and has_pending_backorder_paid:
return (quantity, "flag_manual_review")
return (0, "clamp_to_zero")
// outOfStockPolicy: 0 = deny, 1 = allow (backorder), 2 = use global default
// Returns [newQuantity, action] where action is "noop", "clamp_to_zero", or "flag_manual_review".
export function clampNegativeStock(quantity, dependsOnStock, outOfStockPolicy, hasPendingBackorderPaid) {
if (quantity >= 0) return [quantity, "noop"];
if (!dependsOnStock) return [quantity, "flag_manual_review"];
if (outOfStockPolicy === 1 && hasPendingBackorderPaid) return [quantity, "flag_manual_review"];
return [0, "clamp_to_zero"];
}
Repair only the clamp rows, through the documented schema
For a row classified clamp_to_zero, fetch the resource's schema once with ?schema=synopsis, then GET the existing row, patch only the quantity field to 0, and leave depends_on_stock, out_of_stock, id_product, and id_product_attribute untouched before you PUT it back. Rows classified flag_manual_review are never written through the API. They only go into a report for a human to reconcile stock or trigger a supplier reorder, since core offers no safe, idempotent webservice call to selectively re-derive the correct quantity from order_detail history.
def clamp_stock_row_to_zero(id_stock_available):
schema = api_get("stock_availables", {"schema": "synopsis"})
current = api_get(f"stock_availables/{id_stock_available}", {"display": "full"})
row = current.get("stock_available") or {}
row["quantity"] = 0 # only quantity changes; depends_on_stock and out_of_stock stay as-is
r = requests.put(
f"{BASE_URL}/api/stock_availables/{id_stock_available}",
params={"output_format": "JSON"},
json={"stock_available": row},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
async function clampStockRowToZero(idStockAvailable) {
await apiGet("stock_availables", { schema: "synopsis" });
const current = await apiGet(`stock_availables/${idStockAvailable}`, { display: "full" });
const row = current.stock_available || {};
row.quantity = 0; // only quantity changes; depends_on_stock and out_of_stock stay as-is
const url = new URL(`${BASE_URL}/api/stock_availables/${idStockAvailable}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ stock_available: row }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
Wire it together with a dry run guard
The run loop pulls every negative row, cross-references orders and order states to decide the backorder policy and whether a genuine open backorder paid order exists, runs the pure function, and logs every row with its action. Leave DRY_RUN on for the first few runs so it only reports. Once you trust the split between clamp and flag, switch it off so it writes only the clamp rows.
Always start with DRY_RUN=true. This script never writes a row classified flag_manual_review, and for rows it does write, it only ever changes quantity, never depends_on_stock, out_of_stock, id_product, or id_product_attribute.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever writes rows the pure function classifies as drift, never rows that represent real, still-open backorder demand.
"""Find and repair negative PrestaShop stock quantities from backorder paid orders.
PrestaShop decrements ps_stock_available.quantity at order validation without a
transactional row lock tied to the final payment confirmation. When a product allows
backorders, or stock enforcement is momentarily bypassed, concurrent checkouts or an
order passing through a backorder paid state can each subtract from an already-zero or
already-reserved line, driving quantity below zero with nothing in core to self heal it.
This pulls every negative stock_availables row, cross-references the orders and order
states that plausibly caused it, and classifies each row as no correction needed, safe
to clamp to zero, or needing a human to reconcile stock or trigger a reorder. Only the
clamp rows are ever written, and only quantity changes. 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("negative_quantity_backorder")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def negative_stock_rows():
data = api_get("stock_availables", {"display": "full", "limit": "0,1000"})
rows = data.get("stock_availables") or []
out = []
for r in rows:
quantity = int(r.get("quantity") or 0)
if quantity >= 0:
continue
out.append({
"id": int(r["id"]),
"id_product": int(r["id_product"]),
"id_product_attribute": int(r.get("id_product_attribute") or 0),
"quantity": quantity,
"out_of_stock": int(r.get("out_of_stock") or 0),
"depends_on_stock": str(r.get("depends_on_stock")) in ("1", "true", "True"),
})
return out
def order_details_for_product(id_product):
data = api_get("order_details", {"display": "full", "filter[product_id]": id_product})
return data.get("order_details") or []
def order_by_id(id_order):
data = api_get(f"orders/{id_order}", {"display": "full"})
return data.get("order") or {}
def order_state_by_id(id_order_state):
data = api_get(f"order_states/{id_order_state}", {"display": "full"})
return data.get("order_state") or {}
def has_open_backorder_paid_order(id_product):
for line in order_details_for_product(id_product):
order = order_by_id(line["id_order"])
state = order_state_by_id(order.get("current_state"))
paid = str(state.get("paid")) in ("1", "true", "True")
name = str(state.get("name") or "")
product_quantity = int(line.get("product_quantity") or 0)
if paid and "backorder" in name.lower() and product_quantity < 0:
return True
return False
def clamp_negative_stock(quantity, depends_on_stock, out_of_stock_policy, has_pending_backorder_paid):
"""
out_of_stock_policy: 0 = deny, 1 = allow (backorder), 2 = use global default
Returns (new_quantity, action) where action in {"noop", "clamp_to_zero", "flag_manual_review"}.
"""
if quantity >= 0:
return (quantity, "noop")
if not depends_on_stock:
return (quantity, "flag_manual_review")
if out_of_stock_policy == 1 and has_pending_backorder_paid:
return (quantity, "flag_manual_review")
return (0, "clamp_to_zero")
def clamp_stock_row_to_zero(id_stock_available):
api_get("stock_availables", {"schema": "synopsis"})
current = api_get(f"stock_availables/{id_stock_available}", {"display": "full"})
row = current.get("stock_available") or {}
row["quantity"] = 0
r = requests.put(
f"{BASE_URL}/api/stock_availables/{id_stock_available}",
params={"output_format": "JSON"},
json={"stock_available": row},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
rows = negative_stock_rows()
clamped = 0
flagged = 0
for row in rows:
has_backorder = has_open_backorder_paid_order(row["id_product"])
_, action = clamp_negative_stock(
row["quantity"], row["depends_on_stock"], row["out_of_stock"], has_backorder
)
if action == "noop":
continue
if action == "flag_manual_review":
log.warning(
"Flag for review: stock_available %s product %s attribute %s quantity %s",
row["id"], row["id_product"], row["id_product_attribute"], row["quantity"],
)
flagged += 1
continue
log.warning(
"Drift: stock_available %s product %s attribute %s quantity %s -> 0 (%s)",
row["id"], row["id_product"], row["id_product_attribute"], row["quantity"],
"would clamp" if DRY_RUN else "clamping",
)
if not DRY_RUN:
clamp_stock_row_to_zero(row["id"])
clamped += 1
log.info(
"Done. %d row(s) %s, %d row(s) flagged for manual review.",
clamped, "to clamp" if DRY_RUN else "clamped", flagged,
)
if __name__ == "__main__":
run()
/**
* Find and repair negative PrestaShop stock quantities from backorder paid orders.
*
* PrestaShop decrements ps_stock_available.quantity at order validation without a
* transactional row lock tied to the final payment confirmation. When a product allows
* backorders, or stock enforcement is momentarily bypassed, concurrent checkouts or an
* order passing through a backorder paid state can each subtract from an already-zero or
* already-reserved line, driving quantity below zero with nothing in core to self heal it.
*
* This pulls every negative stock_availables row, cross-references the orders and order
* states that plausibly caused it, and classifies each row as no correction needed, safe
* to clamp to zero, or needing a human to reconcile stock or trigger a reorder. Only the
* clamp rows are ever written, and only quantity changes. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/negative-quantity-on-backorder-orders/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://example.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "dummy_key";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function negativeStockRows() {
const data = await apiGet("stock_availables", { display: "full", limit: "0,1000" });
const rows = data.stock_availables || [];
return rows
.map((r) => ({
id: Number(r.id),
id_product: Number(r.id_product),
id_product_attribute: Number(r.id_product_attribute || 0),
quantity: Number(r.quantity || 0),
out_of_stock: Number(r.out_of_stock || 0),
depends_on_stock: String(r.depends_on_stock) === "1" || r.depends_on_stock === true,
}))
.filter((r) => r.quantity < 0);
}
async function orderDetailsForProduct(idProduct) {
const data = await apiGet("order_details", { display: "full", "filter[product_id]": idProduct });
return data.order_details || [];
}
async function orderById(idOrder) {
const data = await apiGet(`orders/${idOrder}`, { display: "full" });
return data.order || {};
}
async function orderStateById(idOrderState) {
const data = await apiGet(`order_states/${idOrderState}`, { display: "full" });
return data.order_state || {};
}
async function hasOpenBackorderPaidOrder(idProduct) {
for (const line of await orderDetailsForProduct(idProduct)) {
const order = await orderById(line.id_order);
const state = await orderStateById(order.current_state);
const paid = String(state.paid) === "1" || state.paid === true;
const name = String(state.name || "");
const productQuantity = Number(line.product_quantity || 0);
if (paid && name.toLowerCase().includes("backorder") && productQuantity < 0) return true;
}
return false;
}
// outOfStockPolicy: 0 = deny, 1 = allow (backorder), 2 = use global default
// Returns [newQuantity, action] where action is "noop", "clamp_to_zero", or "flag_manual_review".
export function clampNegativeStock(quantity, dependsOnStock, outOfStockPolicy, hasPendingBackorderPaid) {
if (quantity >= 0) return [quantity, "noop"];
if (!dependsOnStock) return [quantity, "flag_manual_review"];
if (outOfStockPolicy === 1 && hasPendingBackorderPaid) return [quantity, "flag_manual_review"];
return [0, "clamp_to_zero"];
}
async function clampStockRowToZero(idStockAvailable) {
await apiGet("stock_availables", { schema: "synopsis" });
const current = await apiGet(`stock_availables/${idStockAvailable}`, { display: "full" });
const row = current.stock_available || {};
row.quantity = 0;
const url = new URL(`${BASE_URL}/api/stock_availables/${idStockAvailable}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ stock_available: row }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
export async function run() {
const rows = await negativeStockRows();
let clamped = 0;
let flagged = 0;
for (const row of rows) {
const hasBackorder = await hasOpenBackorderPaidOrder(row.id_product);
const [, action] = clampNegativeStock(row.quantity, row.depends_on_stock, row.out_of_stock, hasBackorder);
if (action === "noop") continue;
if (action === "flag_manual_review") {
console.warn(
`Flag for review: stock_available ${row.id} product ${row.id_product} attribute ${row.id_product_attribute} quantity ${row.quantity}`
);
flagged++;
continue;
}
console.warn(
`Drift: stock_available ${row.id} product ${row.id_product} attribute ${row.id_product_attribute} quantity ${row.quantity} -> 0 (${DRY_RUN ? "would clamp" : "clamping"})`
);
if (!DRY_RUN) await clampStockRowToZero(row.id);
clamped++;
}
console.log(`Done. ${clamped} row(s) ${DRY_RUN ? "to clamp" : "clamped"}, ${flagged} row(s) flagged for manual review.`);
}
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 rows get zeroed and which get preserved as real oversell demand. Because clamp_negative_stock is pure, the test needs no PrestaShop instance and no network. It just feeds in plain values and checks the answer.
from negative_quantity_backorder import clamp_negative_stock
def test_noop_when_quantity_is_not_negative():
assert clamp_negative_stock(5, True, 0, False) == (5, "noop")
def test_noop_when_quantity_is_exactly_zero():
assert clamp_negative_stock(0, True, 1, False) == (0, "noop")
def test_flag_when_not_tracked_by_depends_on_stock():
assert clamp_negative_stock(-2, False, 0, False) == (-2, "flag_manual_review")
def test_flag_when_backorders_allowed_and_real_demand_open():
assert clamp_negative_stock(-4, True, 1, True) == (-4, "flag_manual_review")
def test_clamp_when_backorders_denied():
assert clamp_negative_stock(-3, True, 0, False) == (0, "clamp_to_zero")
def test_clamp_when_backorders_allowed_but_no_open_backorder_paid_order():
assert clamp_negative_stock(-1, True, 1, False) == (0, "clamp_to_zero")
def test_clamp_when_global_default_policy_and_no_open_demand():
assert clamp_negative_stock(-7, True, 2, False) == (0, "clamp_to_zero")
import { test } from "node:test";
import assert from "node:assert/strict";
import { clampNegativeStock } from "./negative-quantity-backorder.js";
test("noop when quantity is not negative", () => {
assert.deepEqual(clampNegativeStock(5, true, 0, false), [5, "noop"]);
});
test("noop when quantity is exactly zero", () => {
assert.deepEqual(clampNegativeStock(0, true, 1, false), [0, "noop"]);
});
test("flag when not tracked by depends_on_stock", () => {
assert.deepEqual(clampNegativeStock(-2, false, 0, false), [-2, "flag_manual_review"]);
});
test("flag when backorders allowed and real demand open", () => {
assert.deepEqual(clampNegativeStock(-4, true, 1, true), [-4, "flag_manual_review"]);
});
test("clamp when backorders denied", () => {
assert.deepEqual(clampNegativeStock(-3, true, 0, false), [0, "clamp_to_zero"]);
});
test("clamp when backorders allowed but no open backorder paid order", () => {
assert.deepEqual(clampNegativeStock(-1, true, 1, false), [0, "clamp_to_zero"]);
});
test("clamp when global default policy and no open demand", () => {
assert.deepEqual(clampNegativeStock(-7, true, 2, false), [0, "clamp_to_zero"]);
});
Case studies
The drop that oversold by nine units
A sneaker store ran a timed drop on a limited colorway with backorders switched off in principle, but a caching layer in front of checkout let a burst of near-simultaneous submits through before the stock check settled. By the time the drop ended, the product's stock_available row read -9, and the merchant assumed it was a display bug.
The reconciler confirmed there was no open backorder paid order left to justify the deficit and no explicit backorder policy on the product, so it classified all nine units as drift. Clamping the row to zero matched the real fulfillable count, and the merchant used the flagged report separately to decide which of the oversold orders to refund.
The preorder that looked broken but was not
A hardware brand sold a preorder item with backorders explicitly allowed, expecting to fulfill from an incoming shipment. Its stock_available.quantity sat at -40 for weeks, and a new operations hire almost zeroed it out during a routine stock cleanup, thinking it was corruption.
Running the reconciler first stopped that mistake. It found forty units of quantity tied to genuinely open, paid backorder orders, so every row was flagged for manual review instead of clamped. The -40 stayed exactly as it was, an honest count of real demand waiting on the shipment.
After this runs on a schedule, a negative stock_available.quantity stops being a mystery. Drift from race conditions or a denied backorder policy gets clamped back to zero automatically, while genuine oversell demand from an allowed backorder policy stays visible and untouched until a human reconciles stock or triggers a reorder. Nobody accidentally erases a real backorder queue, and nobody has to keep staring at a negative number wondering if it is safe to fix.
FAQ
Why does PrestaShop let stock_available.quantity go negative on a backorder paid order?
PrestaShop decrements stock_available.quantity at order validation without a transactional row lock tied to the final payment confirmation. When a product allows backorders, or stock enforcement is momentarily bypassed, two concurrent checkouts or an order moving through a backorder paid state can each subtract the ordered amount from an already-zero or already-reserved stock line, driving quantity below zero. Nothing in core self heals it afterward.
Is a negative stock quantity always a bug I should fix?
No. If the product explicitly allows backorders and there is a genuine open backorder paid order still waiting for stock, the negative number is an accurate signal of real oversell depth, not corruption. Zeroing it out would silently hide that liability from the merchant. It only counts as drift worth clamping when backorders should have been denied or there is no matching open backorder paid order to justify the deficit.
Can I fix this with a single PUT to stock_availables?
Only for rows that a pure decision function classifies as drift, not for rows that represent real backorder demand. For a drift row you GET the existing stock_availables resource, patch only the quantity field to 0 while leaving depends_on_stock, out_of_stock, id_product, and id_product_attribute untouched, and PUT it back. Rows flagged for manual review are never written through the API since core exposes no safe way to re-derive the correct number from order_detail history alone.
Related field notes
Citations
On the problem:
- negative qty in orders ( On backorder (paid) ). github.com/PrestaShop/PrestaShop/issues/18700
- ps_stock_available updated wrongly on order when products out of stocks. github.com/PrestaShop/PrestaShop/issues/27631
- Stock quantity are not verified at the last step of the checkout. github.com/PrestaShop/PrestaShop/issues/10762
On the solution:
- PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables/
- PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_details/
- PrestaShop Developer Documentation: Order states webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_states/
Stuck on a tricky one?
If you have a problem in PrestaShop stock, orders, order states, or the Webservice API 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 settle your negative stock?
If this saved you a pile of confused stock counts or a wrongly zeroed backorder queue, 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