Skip to content

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.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A warehouse of boxes and pallets
Photo by Ashley on Unsplash
The short answer

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.

Checkout A validates quantity 0 minus 1 Checkout B validates quantity 0 minus 1 no row lock tied to payment Both subtract from the same stock line quantity = -1 or worse Stuck forever Core never reconciles quantity against reserved_quantity or order_detail history
Two subtractions race against the same stock line with no lock tied to final payment confirmation. The line goes negative and nothing in core brings it back.

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:

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.

The key insight

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.

Pull negative rows stock_availables quantity < 0 Cross-reference orders order_details, order_states clamp_negative_stock pure decision function Real backorder demand or drift? real demand: flag_manual_review drift clamp_to_zero PUT quantity=0
Only rows the pure function classifies as drift get written. Rows that represent real, still-open backorder demand are flagged for a human, never zeroed.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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
step2.js
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);
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

decide.py
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")
decide.js
// 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"];
}
5

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.

apply.py
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()
apply.js
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();
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
negative_quantity_backorder.py
"""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()
negative-quantity-backorder.js
/**
 * 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.

test_negative_quantity_backorder.py
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")
negative-quantity-backorder.test.js
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

Flash sale

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.

Genuine backorder

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.

What good looks like

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:

  1. negative qty in orders ( On backorder (paid) ). github.com/PrestaShop/PrestaShop/issues/18700
  2. ps_stock_available updated wrongly on order when products out of stocks. github.com/PrestaShop/PrestaShop/issues/27631
  3. Stock quantity are not verified at the last step of the checkout. github.com/PrestaShop/PrestaShop/issues/10762

On the solution:

  1. PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables/
  2. PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_details/
  3. 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.

Contact me on LinkedIn

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

Back to all PrestaShop field notes