Skip to content

Reconciler Stock & Inventory

Negative stock quantities appear even with backorders disabled

The product page says deny backorders. The out_of_stock setting is correct. And yet the stock_available row for that product sits at minus two, minus five, sometimes worse. Nobody hand edited it, or so everyone insists. Here is why PrestaShop can let stock go negative even when it is configured to refuse every one of those sales, and a small script that finds every row where that promise was broken.

Python and Node.js Webservice API Safe by default (flag, do not auto-clamp)
A worker on a ladder in a warehouse
Photo by Kseniia Ilinykh on Unsplash
The short answer

PrestaShop only checks the per-product out_of_stock flag (0 deny, 1 allow, 2 use the global PS_ORDER_OUT_OF_STOCK setting) when a cart turns into an order. It never re-locks or re-verifies the stock_available row at final validation, so two near-simultaneous orders, or an order racing a manual back-office edit or an import, can each decrement the same row past zero even when the resolved policy is deny. In multistore with Share available quantities on, the row is scoped to id_shop_group instead of one shop, so every shop in the group can decrement it, and combination or pack rows that were never scoped correctly can drift negative outside checkout entirely. Run a Python or Node.js script that pulls every stock_available row across every shop, resolves each product's real backorder policy, and reports only the rows where quantity is negative and the policy is genuinely deny. Full code, tests, and a dry run guard are below.

The problem in plain words

PrestaShop keeps one sellable quantity per product, or per product and combination, per shop or shop group, in a table called stock_available. Next to that quantity sits an out_of_stock flag: 0 means deny backorders and stop selling at zero, 1 means allow backorders, and 2 means fall back to the store wide PS_ORDER_OUT_OF_STOCK configuration value.

That flag gets read once, when a cart is converted into an order. What it does not do is lock the row and re-check it again at the final payment and validation step. So if two carts both grab the last unit at nearly the same moment, or a customer completes checkout while an admin edits stock in the back office or a CSV import runs, both writes can land, and the quantity ends up below zero even though the policy said no backorders. The physical, reserved, and virtual quantities are supposed to reconcile too, physical equals virtual plus reserved, but they get recalculated at different lifecycle points, validate order, ship, refund, cancel, so a state transition that fires out of order or only half completes can also leave the row negative with a correctly set deny policy sitting right next to it.

Checkout A validates reads out_of_stock = deny Checkout B validates reads out_of_stock = deny stock_available row quantity = 1, never re-locked no re-check at validation Both orders decrement it quantity ends at -1
Each checkout reads the deny policy correctly, but nothing re-locks the row between them, so both writes land and quantity goes negative.

Why it happens

The key insight

A negative stock_available.quantity next to a deny policy is not proof of a bug you can silently patch. It might be a genuine oversell that needs a refund or a cancellation decision, not a quiet clamp to zero. So the right first move is always to detect and report, cross-referencing every row against the product's real resolved policy, not to auto-correct blindly.

The fix, as a flow

We do not touch checkout at all. We add a job that lists every shop, pulls every stock_available row with a negative quantity, resolves the real backorder policy for each product including the out_of_stock=2 default-inheriting case, and reports only the rows that are genuine violations. Correcting a row is a separate, explicit, opt-in step.

Enumerate shops /api/shops, /api/shop_groups List negative rows stock_availables, quantity < 0 Resolve real policy out_of_stock, PS_ORDER_OUT_OF_STOCK Policy is deny? yes no, not a violation Report row clamp only if asked
Every row is checked against its own product's resolved policy. Only genuine deny violations get reported, and clamping is a separate opt-in step.

Build it step by step

1

Get a Webservice key and enumerate shops

Enable the Webservice under Advanced Parameters, Webservice, and create a key with read access to stock_availables, products, combinations, shops, shop_groups, and configurations. The key is sent as the HTTP Basic username with a blank password. Start by listing every shop, and the shop groups too if multistore Share available quantities is in play.

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, this script only reports by default
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, this script only reports by default
2

Talk to the Webservice API

Every call goes to {PRESTASHOP_URL}/api/<resource> with ?output_format=JSON, authenticated with HTTP Basic using the key as username and an empty password. A small helper handles GET for reads and PUT for the one sanctioned write later.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "";
const AUTH_HEADER = "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");

async function apiGet(path, params = {}) {
  const query = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${query}`, {
    headers: { Authorization: AUTH_HEADER },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
3

List every negative stock_available row

Page through stock_availables filtering on quantity below zero, reading back id, id_product, id_product_attribute, id_shop, id_shop_group, quantity, out_of_stock, and depends_on_stock for every row. Enumerate shops first with /api/shops, and shop groups with /api/shop_groups if Share available quantities is suspected.

step3.py
def all_shops():
    data = api_get("shops", params={"display": "full"})
    return data.get("shops") or []

def negative_stock_rows():
    data = api_get("stock_availables", params={
        "display": "full",
        "filter[quantity]": "[-9999999,-1]",
    })
    return data.get("stock_availables") or []
step3.js
async function allShops() {
  const data = await apiGet("shops", { display: "full" });
  return data.shops || [];
}

async function negativeStockRows() {
  const data = await apiGet("stock_availables", {
    display: "full",
    "filter[quantity]": "[-9999999,-1]",
  });
  return data.stock_availables || [];
}
4

Resolve the real policy, then decide with one pure function

For each affected id_product, read the product-level out_of_stock field. When it is 2, the effective policy falls back to the store wide PS_ORDER_OUT_OF_STOCK configuration, so resolve that once and reuse it. Keep the decision itself in a pure function that takes plain values and returns a decision, no API calls inside it, so it is trivial to test against every combination including the default-inheritance edge case.

decide.py
def classify_stock_violation(quantity, out_of_stock, global_default_deny):
    if out_of_stock == 0:
        policy = "deny"
    elif out_of_stock == 1:
        policy = "allow"
    else:  # out_of_stock == 2, inherit the store wide default
        policy = "deny" if global_default_deny else "allow"

    is_violation = policy == "deny" and quantity < 0
    clamp_to = max(quantity, 0) if is_violation else None
    return {"policy": policy, "is_violation": is_violation, "clamp_to": clamp_to}
decide.js
export function classifyStockViolation(quantity, outOfStock, globalDefaultDeny) {
  let policy;
  if (outOfStock === 0) {
    policy = "deny";
  } else if (outOfStock === 1) {
    policy = "allow";
  } else {
    // outOfStock === 2, inherit the store wide default
    policy = globalDefaultDeny ? "deny" : "allow";
  }

  const isViolation = policy === "deny" && quantity < 0;
  const clampTo = isViolation ? Math.max(quantity, 0) : null;
  return { policy, isViolation, clampTo };
}
5

Resolve the global default and cross-check combinations

When a product's out_of_stock is 2, resolve PS_ORDER_OUT_OF_STOCK once with /api/configurations and cache it, since it is store wide. Cross-check id_product_attribute against /api/combinations so combination rows are flagged too, not just the parent product.

resolve.py
def global_default_deny():
    data = api_get("configurations", params={"filter[name]": "PS_ORDER_OUT_OF_STOCK", "display": "full"})
    configs = data.get("configurations") or []
    if not configs:
        return True  # PrestaShop ships with deny as the safe default
    return str(configs[0].get("value", "0")) == "0"

def product_out_of_stock(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    product = data.get("product", {})
    return int(product.get("out_of_stock", 2))

def combination_exists(id_product_attribute):
    if not id_product_attribute or int(id_product_attribute) == 0:
        return True  # 0 means the row belongs to the product itself, not a combination
    data = api_get(f"combinations/{id_product_attribute}")
    return bool(data.get("combination"))
resolve.js
async function globalDefaultDeny() {
  const data = await apiGet("configurations", { "filter[name]": "PS_ORDER_OUT_OF_STOCK", display: "full" });
  const configs = data.configurations || [];
  if (configs.length === 0) return true; // PrestaShop ships with deny as the safe default
  return String(configs[0].value ?? "0") === "0";
}

async function productOutOfStock(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  return Number(data.product?.out_of_stock ?? 2);
}

async function combinationExists(idProductAttribute) {
  if (!idProductAttribute || Number(idProductAttribute) === 0) return true; // 0 means no combination
  const data = await apiGet(`combinations/${idProductAttribute}`);
  return Boolean(data.combination);
}
6

Report by default, clamp only when explicitly asked

Wire it together into one run. By default the script only emits a report of every genuine violation, because clamping could hide a real oversell that needs a refund or cancellation decision. Only when DRY_RUN=false and an explicit --clamp flag are both given does it write quantity back with a PUT, and it always preserves id_product, id_product_attribute, the shop scoping, depends_on_stock, and out_of_stock exactly as they were, so the write cannot accidentally flip the deny policy itself.

Run it safe

Leave DRY_RUN=true and never pass --clamp until a human has reviewed the report. A negative row with a deny policy is a signal to decide whether to refund or cancel, not a number to quietly zero out.

The full code

Here is the complete script in one file for each language. It enumerates every shop, lists every negative stock_available row, resolves each product's real backorder policy, reports genuine violations, and only writes when explicitly told to.

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.
reconcile_negative_stock.py
"""Find PrestaShop stock_available rows that are negative despite a deny backorder policy.

PrestaShop stores a sellable quantity per (id_product, id_product_attribute, id_shop or
id_shop_group) row in stock_available. The front office and order validation code path
only checks the per-product out_of_stock flag (0 deny, 1 allow, 2 use the global
PS_ORDER_OUT_OF_STOCK setting) when a cart turns into an order. It never re-locks or
re-verifies the row at final payment and validation, so two near-simultaneous orders, or
an order racing a manual back-office edit or an import, can each decrement the same row
past zero even with a deny policy. In multistore with Share available quantities on, the
row is scoped to id_shop_group, so any shop in the group can decrement it, and combination
or pack rows that were never correctly scoped can drift negative outside checkout entirely.

This is unsafe to auto-correct blindly, so the default behavior is to flag and report every
genuine violation: a row only counts when the resolved policy is deny yet quantity is
negative. Only when explicitly run with DRY_RUN=false and --clamp does it write quantity
back as max(existing_quantity, 0), preserving id_product, id_product_attribute, the shop
scoping, depends_on_stock, and out_of_stock unchanged so the write never resets the policy.

Run on a schedule. Safe to run again and again.
"""
import os
import sys
import logging
import requests

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

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def classify_stock_violation(quantity, out_of_stock, global_default_deny):
    """Pure decision function, no I/O.

    quantity: the stored stock_available.quantity, an int that may be negative.
    out_of_stock: the resolved per-row policy code, 0 deny, 1 allow, 2 inherit default.
    global_default_deny: bool, the resolved PS_ORDER_OUT_OF_STOCK store wide setting.

    Returns {policy, is_violation, clamp_to}. is_violation is True only when the
    effective policy is deny and quantity is negative. clamp_to is the value a clamp
    repair would write, max(quantity, 0), and is None when there is no violation.
    """
    if out_of_stock == 0:
        policy = "deny"
    elif out_of_stock == 1:
        policy = "allow"
    else:
        policy = "deny" if global_default_deny else "allow"

    is_violation = policy == "deny" and quantity < 0
    clamp_to = max(quantity, 0) if is_violation else None
    return {"policy": policy, "is_violation": is_violation, "clamp_to": clamp_to}


def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def api_put(path, resource_key, body):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"},
        auth=AUTH,
        json={resource_key: body},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def all_shops():
    data = api_get("shops", params={"display": "full"})
    return data.get("shops") or []


def negative_stock_rows():
    data = api_get("stock_availables", params={
        "display": "full",
        "filter[quantity]": "[-9999999,-1]",
    })
    return data.get("stock_availables") or []


def global_default_deny():
    data = api_get("configurations", params={"filter[name]": "PS_ORDER_OUT_OF_STOCK", "display": "full"})
    configs = data.get("configurations") or []
    if not configs:
        return True  # PrestaShop ships with deny as the safe default
    return str(configs[0].get("value", "0")) == "0"


def product_out_of_stock(id_product, cache):
    if id_product in cache:
        return cache[id_product]
    data = api_get(f"products/{id_product}", params={"display": "full"})
    product = data.get("product", {})
    value = int(product.get("out_of_stock", 2))
    cache[id_product] = value
    return value


def combination_exists(id_product_attribute):
    if not id_product_attribute or int(id_product_attribute) == 0:
        return True  # 0 means the row belongs to the product itself, not a combination
    data = api_get(f"combinations/{id_product_attribute}")
    return bool(data.get("combination"))


def clamp_row(row):
    body = {
        "id": row["id"],
        "id_product": row["id_product"],
        "id_product_attribute": row.get("id_product_attribute", "0"),
        "id_shop": row.get("id_shop", "0"),
        "id_shop_group": row.get("id_shop_group", "0"),
        "quantity": max(int(row["quantity"]), 0),
        "depends_on_stock": row.get("depends_on_stock", "0"),
        "out_of_stock": row.get("out_of_stock", "2"),
    }
    return api_put(f"stock_availables/{row['id']}", "stock_available", body)


def run(clamp=False):
    shops = all_shops()
    log.info("Scanning %d shop(s) for negative stock_available rows.", len(shops))

    default_deny = global_default_deny()
    product_cache = {}
    flagged = []

    for row in negative_stock_rows():
        id_product = row["id_product"]
        id_product_attribute = row.get("id_product_attribute")
        quantity = int(row["quantity"])
        out_of_stock = product_out_of_stock(id_product, product_cache)

        result = classify_stock_violation(quantity, out_of_stock, default_deny)
        if not result["is_violation"]:
            continue

        has_combination = combination_exists(id_product_attribute)
        flagged.append({
            "id_shop": row.get("id_shop"),
            "id_shop_group": row.get("id_shop_group"),
            "id_product": id_product,
            "id_product_attribute": id_product_attribute,
            "quantity": quantity,
            "resolved_out_of_stock_policy": result["policy"],
            "orphaned_combination": id_product_attribute not in (None, "0", 0) and not has_combination,
        })
        log.warning(
            "Violation: shop=%s product=%s attribute=%s quantity=%s policy=%s",
            row.get("id_shop"), id_product, id_product_attribute, quantity, result["policy"],
        )

        if clamp and not DRY_RUN:
            clamp_row(row)
            log.info("Clamped stock_availables/%s quantity to %s.", row["id"], result["clamp_to"])

    log.info(
        "Done. %d violation(s) found. %s",
        len(flagged),
        "Clamped to zero." if clamp and not DRY_RUN else "Reported only, no writes made.",
    )
    return flagged


if __name__ == "__main__":
    clamp_flag = "--clamp" in sys.argv
    run(clamp=clamp_flag)
reconcile-negative-stock.js
/**
 * Find PrestaShop stock_available rows that are negative despite a deny backorder policy.
 *
 * PrestaShop stores a sellable quantity per (id_product, id_product_attribute, id_shop or
 * id_shop_group) row in stock_available. The front office and order validation code path
 * only checks the per-product out_of_stock flag (0 deny, 1 allow, 2 use the global
 * PS_ORDER_OUT_OF_STOCK setting) when a cart turns into an order. It never re-locks or
 * re-verifies the row at final payment and validation, so two near-simultaneous orders, or
 * an order racing a manual back-office edit or an import, can each decrement the same row
 * past zero even with a deny policy. In multistore with Share available quantities on, the
 * row is scoped to id_shop_group, so any shop in the group can decrement it, and combination
 * or pack rows that were never correctly scoped can drift negative outside checkout entirely.
 *
 * This is unsafe to auto-correct blindly, so the default behavior is to flag and report
 * every genuine violation. Only when explicitly run with DRY_RUN=false and --clamp does it
 * write quantity back as max(existing_quantity, 0), preserving id_product,
 * id_product_attribute, the shop scoping, depends_on_stock, and out_of_stock unchanged.
 *
 * Guide: https://www.allanninal.dev/prestashop/negative-stock-despite-backorder-denied/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const AUTH_HEADER = "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");

export function classifyStockViolation(quantity, outOfStock, globalDefaultDeny) {
  let policy;
  if (outOfStock === 0) {
    policy = "deny";
  } else if (outOfStock === 1) {
    policy = "allow";
  } else {
    policy = globalDefaultDeny ? "deny" : "allow";
  }

  const isViolation = policy === "deny" && quantity < 0;
  const clampTo = isViolation ? Math.max(quantity, 0) : null;
  return { policy, isViolation, clampTo };
}

async function apiGet(path, params = {}) {
  const query = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${query}`, {
    headers: { Authorization: AUTH_HEADER },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPut(path, resourceKey, body) {
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?output_format=JSON`, {
    method: "PUT",
    headers: { Authorization: AUTH_HEADER, "Content-Type": "application/json" },
    body: JSON.stringify({ [resourceKey]: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function allShops() {
  const data = await apiGet("shops", { display: "full" });
  return data.shops || [];
}

async function negativeStockRows() {
  const data = await apiGet("stock_availables", {
    display: "full",
    "filter[quantity]": "[-9999999,-1]",
  });
  return data.stock_availables || [];
}

async function globalDefaultDeny() {
  const data = await apiGet("configurations", { "filter[name]": "PS_ORDER_OUT_OF_STOCK", display: "full" });
  const configs = data.configurations || [];
  if (configs.length === 0) return true; // PrestaShop ships with deny as the safe default
  return String(configs[0].value ?? "0") === "0";
}

async function productOutOfStock(idProduct, cache) {
  if (cache.has(idProduct)) return cache.get(idProduct);
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  const value = Number(data.product?.out_of_stock ?? 2);
  cache.set(idProduct, value);
  return value;
}

async function combinationExists(idProductAttribute) {
  if (!idProductAttribute || Number(idProductAttribute) === 0) return true;
  const data = await apiGet(`combinations/${idProductAttribute}`);
  return Boolean(data.combination);
}

async function clampRow(row) {
  const body = {
    id: row.id,
    id_product: row.id_product,
    id_product_attribute: row.id_product_attribute ?? "0",
    id_shop: row.id_shop ?? "0",
    id_shop_group: row.id_shop_group ?? "0",
    quantity: Math.max(Number(row.quantity), 0),
    depends_on_stock: row.depends_on_stock ?? "0",
    out_of_stock: row.out_of_stock ?? "2",
  };
  return apiPut(`stock_availables/${row.id}`, "stock_available", body);
}

export async function run(clamp = false) {
  const shops = await allShops();
  console.log(`Scanning ${shops.length} shop(s) for negative stock_available rows.`);

  const defaultDeny = await globalDefaultDeny();
  const productCache = new Map();
  const flagged = [];

  for (const row of await negativeStockRows()) {
    const idProduct = row.id_product;
    const idProductAttribute = row.id_product_attribute;
    const quantity = Number(row.quantity);
    const outOfStock = await productOutOfStock(idProduct, productCache);

    const result = classifyStockViolation(quantity, outOfStock, defaultDeny);
    if (!result.isViolation) continue;

    const hasCombination = await combinationExists(idProductAttribute);
    flagged.push({
      id_shop: row.id_shop,
      id_shop_group: row.id_shop_group,
      id_product: idProduct,
      id_product_attribute: idProductAttribute,
      quantity,
      resolved_out_of_stock_policy: result.policy,
      orphaned_combination: idProductAttribute && Number(idProductAttribute) !== 0 && !hasCombination,
    });
    console.warn(
      `Violation: shop=${row.id_shop} product=${idProduct} attribute=${idProductAttribute} quantity=${quantity} policy=${result.policy}`
    );

    if (clamp && !DRY_RUN) {
      await clampRow(row);
      console.log(`Clamped stock_availables/${row.id} quantity to ${result.clampTo}.`);
    }
  }

  console.log(
    `Done. ${flagged.length} violation(s) found. ${clamp && !DRY_RUN ? "Clamped to zero." : "Reported only, no writes made."}`
  );
  return flagged;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const clampFlag = process.argv.includes("--clamp");
  run(clampFlag).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

classify_stock_violation is the part worth testing hardest, because it decides which rows are a real problem versus noise, including the out_of_stock=2 default-inheritance edge case. Because it takes plain values and returns a plain decision, the test needs no network and no PrestaShop store.

test_negative_classify.py
from reconcile_negative_stock import classify_stock_violation


def test_deny_and_negative_is_violation():
    result = classify_stock_violation(-3, 0, True)
    assert result == {"policy": "deny", "is_violation": True, "clamp_to": 0}


def test_allow_and_negative_is_not_violation():
    result = classify_stock_violation(-3, 1, True)
    assert result["is_violation"] is False
    assert result["clamp_to"] is None


def test_deny_and_positive_is_not_violation():
    result = classify_stock_violation(5, 0, True)
    assert result["is_violation"] is False


def test_default_inherits_deny_from_global():
    result = classify_stock_violation(-2, 2, True)
    assert result["policy"] == "deny"
    assert result["is_violation"] is True
    assert result["clamp_to"] == 0


def test_default_inherits_allow_from_global():
    result = classify_stock_violation(-2, 2, False)
    assert result["policy"] == "allow"
    assert result["is_violation"] is False
    assert result["clamp_to"] is None


def test_clamp_to_uses_max_of_quantity_and_zero():
    result = classify_stock_violation(-7, 0, True)
    assert result["clamp_to"] == 0
negative-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStockViolation } from "./reconcile-negative-stock.js";

test("deny and negative is a violation", () => {
  const result = classifyStockViolation(-3, 0, true);
  assert.deepEqual(result, { policy: "deny", isViolation: true, clampTo: 0 });
});

test("allow and negative is not a violation", () => {
  const result = classifyStockViolation(-3, 1, true);
  assert.equal(result.isViolation, false);
  assert.equal(result.clampTo, null);
});

test("deny and positive is not a violation", () => {
  const result = classifyStockViolation(5, 0, true);
  assert.equal(result.isViolation, false);
});

test("default inherits deny from global", () => {
  const result = classifyStockViolation(-2, 2, true);
  assert.equal(result.policy, "deny");
  assert.equal(result.isViolation, true);
  assert.equal(result.clampTo, 0);
});

test("default inherits allow from global", () => {
  const result = classifyStockViolation(-2, 2, false);
  assert.equal(result.policy, "allow");
  assert.equal(result.isViolation, false);
  assert.equal(result.clampTo, null);
});

test("clampTo uses max of quantity and zero", () => {
  const result = classifyStockViolation(-7, 0, true);
  assert.equal(result.clampTo, 0);
});

Case studies

Single store, flash sale

A ten unit drop that went to minus four

A single-shop store ran a flash sale on a limited restock. Out_of_stock was correctly set to deny on every affected product. During the first ninety seconds, a burst of checkouts hit at nearly the same moment, and by the time anyone looked, one variant's stock_available row read minus four, with the deny policy still sitting there unchanged.

Running the scan showed exactly four genuine violations, each one a real oversell rather than a misconfiguration. That turned a confusing bug report into four clear refund or backorder-fulfillment decisions instead of a guessing game.

Multistore, shared quantities

A group-scoped row drained by three shops at once

A multistore install had Share available quantities turned on for a shop group of three storefronts selling the same catalog. Nobody had realized that a cron-driven CSV reimport on one shop was writing to the same id_shop_group-scoped stock_available row that the other two shops' checkouts were also decrementing.

The scan flagged rows across all three shops pointing at the same shared group id, with quantity deep in negative territory despite deny being set everywhere. Seeing the id_shop_group value on every flagged row made the shared-quantity misconfiguration obvious at a glance, instead of three separate teams each investigating their own shop.

What good looks like

After this runs on a schedule, every negative stock_available row across every shop gets checked against the product's real backorder policy, not just eyeballed. Genuine violations show up as a short, precise list instead of a pile of confused support tickets, and nothing gets silently clamped away until a human decides that is the right call.

FAQ

Why does PrestaShop show negative stock when backorders are denied?

The checkout only reads the out_of_stock policy at cart to order time. It never re-locks or re-verifies the stock_available row at the final payment step, so two near-simultaneous orders, or an order plus a manual back-office edit or import, can each decrement the same row past zero even though the policy is deny.

Why is this worse in a multistore install with shared quantities?

When Share available quantities is enabled at the shop group level, the stock_available row is scoped to id_shop_group instead of a single id_shop, so order, refund, and cron jobs from every shop in the group decrement the same row. Combination and pack products can also end up with rows that were never correctly scoped to the group, so quantity drifts negative outside the checkout flow entirely.

Is it safe to auto-correct a negative stock_available row?

Not automatically. Clamping quantity to zero can hide a real oversell that needs a refund or cancellation decision instead. The default behavior should be to flag and report, and only correct with an explicit dry run off and an explicit clamp flag, writing quantity only and leaving out_of_stock, id_product, id_product_attribute, and the shop scoping untouched.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issues: Negative stock in my multistore Prestashop, issue #38378. github.com/PrestaShop/PrestaShop/issues/38378
  2. PrestaShop GitHub Issues: ps_stock_available updated wrongly on order when products out of stocks, issue #27631. github.com/PrestaShop/PrestaShop/issues/27631
  3. PrestaShop GitHub Issues: Stock quantity are not verified at the last step of the checkout, issue #10762. github.com/PrestaShop/PrestaShop/issues/10762

On the solution:

  1. PrestaShop Developer Documentation: the stock_availables resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables
  2. PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock
  3. PrestaShop Help Center: Manage the stock of a product. help-center.prestashop.com manage the stock of a product

Stuck on a tricky one?

If you have a problem in PrestaShop stock, orders, 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 catch a real oversell?

If this saved you from silently clamping a row that needed a refund decision, 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