Skip to content

Reconciler Stock & Inventory

Stock quantity goes negative after ordering an out of stock product

A product shows zero stock, a customer orders it anyway, and the order goes through. Nothing in PrestaShop stops it, and once the order is validated the stock row quietly drops to -1, -2, or further. Here is why PrestaShop lets this happen, how to find every row it already happened to, and a script that reports the damage and clamps it back to zero once you say so.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Empty warehouse shelves
Photo by lim woojung on Unsplash
The short answer

PrestaShop lets a product be ordered at zero stock whenever its out_of_stock setting allows it, or when depends_on_stock is 0 for a pack or virtual product. When the order is validated, StockAvailable::updateQuantity() subtracts the ordered amount from ps_stock_available.quantity without first checking that stock is already at zero, so the row goes negative. Run a small Python or Node.js script that pages through /api/stock_availables for rows with quantity below zero, keeps only the ones where depends_on_stock is 1 because those are real stock-tracked products and not benign pack or virtual rows, and reports them. Only after you confirm does it clamp quantity to 0 with a PUT. Full code, tests, and a dry run guard are below.

The problem in plain words

Stock tracking in PrestaShop lives in one table, ps_stock_available, and one field decides whether an order can go through when that number is at zero: out_of_stock. It can be set per product to 0 (deny), 1 (allow, meaning backorders), or 2 (use whatever the store's global PS_ORDER_OUT_OF_STOCK setting says). Packs and virtual products have their own switch, depends_on_stock, and when that is 0 the product is not supposed to be stock-tracked at all.

The trouble is what happens next. Once checkout allows the order, PrestaShop validates it and calls StockAvailable::updateQuantity() to subtract the ordered quantity from what is on hand. That subtraction happens unconditionally. It does not first check whether quantity is already 0. So one order for one unit of a zero stock, orders-allowed product takes quantity straight to -1, and physical_quantity moves with it. Every later state change on that order, shipped, refunded, or cancelled, touches physical_quantity and reserved_quantity again on its own, and the drift compounds from there.

quantity = 0 out_of_stock allows it Order validated checkout accepts it no zero check first updateQuantity() subtracts unconditionally from quantity quantity = -1 and drifts further
Ordering allows the sale, and the decrement runs without ever checking that stock was already at zero.

Why it happens

This is not one misconfiguration, it is a long-standing, still-open behavior in PrestaShop core. A few concrete ways stores end up with negative rows:

This exact behavior is reported and reproduced against PrestaShop 1.7.7.8 and later in the project's own issue tracker, not a one-off store misconfiguration. See the citations at the end for the specific threads and docs.

The key insight

Not every negative row is a defect. A pack or virtual product with depends_on_stock at 0 is not tracking real stock, so a negative value there is benign and should be left alone. The rows worth fixing are the ones with depends_on_stock equal to 1, a simple product that is supposed to be stock-tracked, where quantity is below zero. That single condition is what separates a true oversell defect from a normal backorder ledger entry, and it is the whole decision our script makes.

The fix, as a flow

We never touch a live order or invent positive stock out of nowhere. The script pages through stock_availables for rows where quantity is negative, keeps only the ones with depends_on_stock equal to 1, and reports every one it finds. With DRY_RUN off and an operator's go ahead, it clamps just the quantity field on those rows to 0 and logs the before and after value for every change.

Reconciler job runs on demand List negative rows stock_availables, quantity < 0 Read depends_on_stock and out_of_stock depends_on _stock = 1? yes no, benign, skip Report, then PUT clamp quantity to 0
Only tracked, negative rows get reported, and only a confirmed run clamps quantity, never out_of_stock or depends_on_stock.

Build it step by step

1

Enable the Webservice API and get a key

In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the stock_availables, products, and order_details resources. 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

Talk to the Webservice API

Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and PUT and raises on a bad status.

step2.py
import os, requests

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]

def api_get(path, params=None):
    params = dict(params or {})
    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 api_put(path, body):
    r = requests.put(
        f"{BASE_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
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 qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

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

Pull the candidate stock rows

Ask stock_availables for rows where quantity is between -1000 and -1, using the webservice's range filter syntax. If a given version does not support range filtering on that field, page through with limit and filter for negative quantity client side instead.

step3.py
def negative_stock_rows():
    data = api_get("stock_availables", {
        "display": "full",
        "filter[quantity]": "[-1000,-1]",
    })
    rows = (data.get("stock_availables") or [])
    # Fallback if range filtering is unavailable: page and filter client side.
    if not rows:
        data = api_get("stock_availables", {"display": "full", "limit": "0,1000"})
        rows = [r for r in (data.get("stock_availables") or []) if int(r.get("quantity", 0)) < 0]
    return rows
step3.js
async function negativeStockRows() {
  const data = await apiGet("stock_availables", {
    display: "full",
    "filter[quantity]": "[-1000,-1]",
  });
  let rows = data.stock_availables || [];
  // Fallback if range filtering is unavailable: page and filter client side.
  if (rows.length === 0) {
    const page = await apiGet("stock_availables", { display: "full", limit: "0,1000" });
    rows = (page.stock_availables || []).filter((r) => Number(r.quantity) < 0);
  }
  return rows;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the row's quantity, depends_on_stock, out_of_stock, and the dry run flag, and returns whether it needs a fix. A row that is not negative needs nothing. A negative row where depends_on_stock is not 1, meaning a pack or virtual product, is expected and benign. Only a negative row with depends_on_stock equal to 1 is a real oversell defect.

decide.py
def decide_stock_reconciliation(quantity, depends_on_stock, out_of_stock, dry_run):
    if quantity >= 0:
        return {"needs_fix": False, "new_quantity": None, "reason": "not negative"}
    if depends_on_stock != 1:
        return {
            "needs_fix": False,
            "new_quantity": None,
            "reason": "not stock-tracked (pack/virtual/depends_on_stock=0), negative value expected/benign",
        }
    return {
        "needs_fix": True,
        "new_quantity": None if dry_run else 0,
        "reason": "negative tracked stock from oversell; clamp to zero",
    }
decide.js
export function decideStockReconciliation(quantity, dependsOnStock, outOfStock, dryRun) {
  if (quantity >= 0) {
    return { needsFix: false, newQuantity: null, reason: "not negative" };
  }
  if (dependsOnStock !== 1) {
    return {
      needsFix: false,
      newQuantity: null,
      reason: "not stock-tracked (pack/virtual/depends_on_stock=0), negative value expected/benign",
    };
  }
  return {
    needsFix: true,
    newQuantity: dryRun ? null : 0,
    reason: "negative tracked stock from oversell; clamp to zero",
  };
}
5

Clamp the flagged rows, and only the quantity field

When a row needs a fix and DRY_RUN is off, send a PUT to stock_availables/<id> with the same id_product, id_product_attribute, depends_on_stock, and out_of_stock it already had, and quantity set to 0. Never invent positive stock, and never touch out_of_stock or depends_on_stock.

apply.py
def clamp_to_zero(row):
    body = {
        "stock_available": {
            "id": row["id"],
            "id_product": row["id_product"],
            "id_product_attribute": row["id_product_attribute"],
            "quantity": 0,
            "depends_on_stock": row["depends_on_stock"],
            "out_of_stock": row["out_of_stock"],
        }
    }
    return api_put(f"stock_availables/{row['id']}", body)
apply.js
async function clampToZero(row) {
  const body = {
    stock_available: {
      id: row.id,
      id_product: row.id_product,
      id_product_attribute: row.id_product_attribute,
      quantity: 0,
      depends_on_stock: row.depends_on_stock,
      out_of_stock: row.out_of_stock,
    },
  };
  return apiPut(`stock_availables/${row.id}`, body);
}
6

Wire it together with a dry run guard

The loop pulls the candidate rows, runs each through the pure decision function, and logs the before and after quantity for every row it flags. On the first runs, leave DRY_RUN on so it only reports. Read the list, confirm each one against real order history if you like, then switch it off to let it write.

Run it safe

Always start with DRY_RUN=true. Selling history and reservations matter, so this is a reconciler, not an auto fixer. It only ever clamps quantity to 0 on rows that are already negative and stock-tracked, and it never invents positive stock or touches out_of_stock or depends_on_stock.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever writes the quantity field on rows that are negative and stock-tracked.

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 went negative from an out of stock order,
and safely clamp only the true defects back to zero.

A negative quantity on a pack or virtual product (depends_on_stock = 0) is expected
and benign. Only rows with depends_on_stock = 1, a simple product that is supposed to
be stock-tracked, are a real oversell defect. Reports first. Only writes the quantity
field, and only when DRY_RUN is false. 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("reconcile_negative_stock")

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=None):
    params = dict(params or {})
    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 api_put(path, body):
    r = requests.put(
        f"{BASE_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def decide_stock_reconciliation(quantity, depends_on_stock, out_of_stock, dry_run):
    if quantity >= 0:
        return {"needs_fix": False, "new_quantity": None, "reason": "not negative"}
    if depends_on_stock != 1:
        return {
            "needs_fix": False,
            "new_quantity": None,
            "reason": "not stock-tracked (pack/virtual/depends_on_stock=0), negative value expected/benign",
        }
    return {
        "needs_fix": True,
        "new_quantity": None if dry_run else 0,
        "reason": "negative tracked stock from oversell; clamp to zero",
    }


def negative_stock_rows():
    data = api_get("stock_availables", {
        "display": "full",
        "filter[quantity]": "[-1000,-1]",
    })
    rows = data.get("stock_availables") or []
    if not rows:
        data = api_get("stock_availables", {"display": "full", "limit": "0,1000"})
        rows = [r for r in (data.get("stock_availables") or []) if int(r.get("quantity", 0)) < 0]
    return rows


def clamp_to_zero(row):
    body = {
        "stock_available": {
            "id": row["id"],
            "id_product": row["id_product"],
            "id_product_attribute": row["id_product_attribute"],
            "quantity": 0,
            "depends_on_stock": row["depends_on_stock"],
            "out_of_stock": row["out_of_stock"],
        }
    }
    return api_put(f"stock_availables/{row['id']}", body)


def run():
    flagged = 0
    for row in negative_stock_rows():
        quantity = int(row.get("quantity", 0))
        depends_on_stock = int(row.get("depends_on_stock", 0))
        out_of_stock = int(row.get("out_of_stock", 0))
        decision = decide_stock_reconciliation(quantity, depends_on_stock, out_of_stock, DRY_RUN)
        if not decision["needs_fix"]:
            continue
        old_quantity = quantity
        log.warning(
            "stock_available %s (product %s) quantity=%s -> %s. %s",
            row["id"], row.get("id_product"), old_quantity,
            0 if not DRY_RUN else "0 (dry run)", decision["reason"],
        )
        if not DRY_RUN:
            clamp_to_zero(row)
            log.info("stock_available %s fixed: %s -> 0", row["id"], old_quantity)
        flagged += 1
    log.info("Done. %d row(s) %s.", flagged, "to clamp" if DRY_RUN else "clamped to zero")


if __name__ == "__main__":
    run()
reconcile-negative-stock.js
/**
 * Find PrestaShop stock_available rows that went negative from an out of stock order,
 * and safely clamp only the true defects back to zero.
 *
 * A negative quantity on a pack or virtual product (depends_on_stock = 0) is expected
 * and benign. Only rows with depends_on_stock = 1, a simple product that is supposed to
 * be stock-tracked, are a real oversell defect. Reports first. Only writes the quantity
 * field, and only when DRY_RUN is false. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/negative-stock-quantity-after-order/
 */
import { pathToFileURL } from "node:url";

const BASE_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decideStockReconciliation(quantity, dependsOnStock, outOfStock, dryRun) {
  if (quantity >= 0) {
    return { needsFix: false, newQuantity: null, reason: "not negative" };
  }
  if (dependsOnStock !== 1) {
    return {
      needsFix: false,
      newQuantity: null,
      reason: "not stock-tracked (pack/virtual/depends_on_stock=0), negative value expected/benign",
    };
  }
  return {
    needsFix: true,
    newQuantity: dryRun ? null : 0,
    reason: "negative tracked stock from oversell; clamp to zero",
  };
}

function authHeader() {
  return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}

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

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

async function negativeStockRows() {
  const data = await apiGet("stock_availables", {
    display: "full",
    "filter[quantity]": "[-1000,-1]",
  });
  let rows = data.stock_availables || [];
  if (rows.length === 0) {
    const page = await apiGet("stock_availables", { display: "full", limit: "0,1000" });
    rows = (page.stock_availables || []).filter((r) => Number(r.quantity) < 0);
  }
  return rows;
}

async function clampToZero(row) {
  const body = {
    stock_available: {
      id: row.id,
      id_product: row.id_product,
      id_product_attribute: row.id_product_attribute,
      quantity: 0,
      depends_on_stock: row.depends_on_stock,
      out_of_stock: row.out_of_stock,
    },
  };
  return apiPut(`stock_availables/${row.id}`, body);
}

export async function run() {
  let flagged = 0;
  const rows = await negativeStockRows();
  for (const row of rows) {
    const quantity = Number(row.quantity || 0);
    const dependsOnStock = Number(row.depends_on_stock || 0);
    const outOfStock = Number(row.out_of_stock || 0);
    const decision = decideStockReconciliation(quantity, dependsOnStock, outOfStock, DRY_RUN);
    if (!decision.needsFix) continue;
    const oldQuantity = quantity;
    console.warn(
      `stock_available ${row.id} (product ${row.id_product}) quantity=${oldQuantity} -> ${DRY_RUN ? "0 (dry run)" : 0}. ${decision.reason}`
    );
    if (!DRY_RUN) {
      await clampToZero(row);
      console.log(`stock_available ${row.id} fixed: ${oldQuantity} -> 0`);
    }
    flagged++;
  }
  console.log(`Done. ${flagged} row(s) ${DRY_RUN ? "to clamp" : "clamped to zero"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which rows are a real oversell defect versus a benign pack or virtual row. Because decide_stock_reconciliation is pure, the test needs no network and no PrestaShop store. It just feeds in plain values and checks the answer.

test_negative_stock.py
from reconcile_negative_stock import decide_stock_reconciliation


def test_not_negative_needs_no_fix():
    result = decide_stock_reconciliation(5, 1, 1, True)
    assert result["needs_fix"] is False
    assert result["reason"] == "not negative"


def test_zero_quantity_needs_no_fix():
    result = decide_stock_reconciliation(0, 1, 0, True)
    assert result["needs_fix"] is False


def test_negative_but_not_stock_tracked_is_benign():
    result = decide_stock_reconciliation(-3, 0, 2, True)
    assert result["needs_fix"] is False
    assert "benign" in result["reason"]


def test_negative_and_stock_tracked_needs_fix_dry_run():
    result = decide_stock_reconciliation(-1, 1, 1, True)
    assert result["needs_fix"] is True
    assert result["new_quantity"] is None


def test_negative_and_stock_tracked_clamps_when_not_dry_run():
    result = decide_stock_reconciliation(-4, 1, 1, False)
    assert result["needs_fix"] is True
    assert result["new_quantity"] == 0
    assert "clamp to zero" in result["reason"]
negative-stock.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideStockReconciliation } from "./reconcile-negative-stock.js";

test("not negative needs no fix", () => {
  const result = decideStockReconciliation(5, 1, 1, true);
  assert.equal(result.needsFix, false);
  assert.equal(result.reason, "not negative");
});

test("zero quantity needs no fix", () => {
  const result = decideStockReconciliation(0, 1, 0, true);
  assert.equal(result.needsFix, false);
});

test("negative but not stock tracked is benign", () => {
  const result = decideStockReconciliation(-3, 0, 2, true);
  assert.equal(result.needsFix, false);
  assert.ok(result.reason.includes("benign"));
});

test("negative and stock tracked needs fix in dry run", () => {
  const result = decideStockReconciliation(-1, 1, 1, true);
  assert.equal(result.needsFix, true);
  assert.equal(result.newQuantity, null);
});

test("negative and stock tracked clamps when not dry run", () => {
  const result = decideStockReconciliation(-4, 1, 1, false);
  assert.equal(result.needsFix, true);
  assert.equal(result.newQuantity, 0);
  assert.ok(result.reason.includes("clamp to zero"));
});

Case studies

Backorder override

A best seller quietly went to -14

A home goods store had one popular candle set to allow orders even out of stock, meant as a short term backorder while a new batch shipped. The batch was delayed for weeks, and every sale during that time kept subtracting from an already empty shelf.

The reconciler found the row sitting at -14 with depends_on_stock equal to 1. The team cross checked it against real order history, confirmed only 14 units were truly owed, and ran the script for real to clamp it back to 0 once the new stock actually arrived and was counted in by hand.

Global default

The store-wide setting was the real cause

A multistore catalog had dozens of stock rows drifting negative, and at first it looked like a per-product misconfiguration. It turned out the shop-wide PS_ORDER_OUT_OF_STOCK setting itself allowed ordering when stock ran out, so every product using the per-product default of 2 inherited that behavior.

Running the scan in dry run surfaced every affected row at once instead of one at a time. The team fixed the global setting going forward and used the same script to clean up the rows that had already gone negative.

What good looks like

After a reconciler run, every truly stock-tracked row sits at zero or higher, and every pack or virtual row with an expected negative value was left untouched. Nobody guessed at positive stock that was not really there, and every clamp is logged with its old and new value so it can be checked against real sales later.

FAQ

Why does PrestaShop let stock quantity go negative?

When a product's out_of_stock setting allows ordering, or depends_on_stock is 0 for a pack or virtual product, PrestaShop still validates the order. StockAvailable::updateQuantity() then subtracts the ordered amount from ps_stock_available.quantity without first checking whether stock is already at zero, so a single order on a zero stock item can push quantity to -1 or lower.

Is every negative quantity row a bug?

No. A negative quantity on a row where depends_on_stock is 0, such as a pack or a virtual product, is expected and benign because that row does not track real stock. Only rows with depends_on_stock equal to 1, meaning a simple product that is supposed to be tracked, are a true defect worth fixing.

Is it safe to auto fix negative stock quantities?

Treat it as unsafe to blind auto fix, because selling history and reservations matter. Run the script as a reconciler with DRY_RUN true by default so it only reports. Only after an operator reviews the list and sets DRY_RUN to false does it clamp the flagged rows to zero, and it only ever changes the quantity field, never out_of_stock or depends_on_stock.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #27631: ps_stock_available updated wrongly on order when products out of stocks. github.com/PrestaShop/PrestaShop/issues/27631
  2. PrestaShop GitHub Issue #28420: order on out_of_stock product with a non-logable order_state adds physical_quantity in stock_available. github.com/PrestaShop/PrestaShop/issues/28420
  3. PrestaShop Help Center: Manage the stock of a product. help-center.prestashop.com manage the stock of a product

On the solution:

  1. PrestaShop Developer Documentation: the stock_availables Webservice resource. devdocs.prestashop-project.org webservice resources stock_availables
  2. PrestaShop Developer Documentation: Webservice reference, authentication and filtering. devdocs.prestashop-project.org webservice reference
  3. PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org faq stock

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 clean up your stock numbers?

If this saved you from a wrong stock report or an oversold product, 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