Skip to content

Diagnostic

Physical quantity, reserved quantity, and virtual quantity fall out of sync

A PrestaShop product carries three related stock numbers: physical_quantity on the shelf, reserved_quantity held for unshipped or unpaid orders, and quantity, the sellable number shown to customers. They are supposed to always add up. In real stores they quietly stop adding up, and nothing in the core ever notices or repairs it. Here is why that happens and a script that recomputes the true reserved figure from open orders and flags every row where the numbers disagree.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A large warehouse
Photo by Ruchindra Gunasekara on Unsplash
The short answer

PrestaShop's stock_availables table stores physical_quantity, reserved_quantity, and quantity, and the invariant is physical_quantity = quantity + reserved_quantity. The core only enforces this through specific code paths tied to order_states flags, and documented bugs plus direct writes from modules or CSV import break it. Run a Python or Node.js script that reads each stock row with GET /api/stock_availables, recomputes the true reserved quantity by walking order_details and checking each order's current state against order_states, and flags any row where the formula or the reserved figure disagrees. The only sanctioned write is to quantity, and only when explicitly authorized. Full code, tests, and citations are below.

The problem in plain words

Every product or combination in PrestaShop has a row in stock_availables with three numbers. physical_quantity is what is actually on the shelf. reserved_quantity is the slice of that physical stock already promised to orders that have not shipped or been paid yet. quantity is what is left to sell, the virtual number the storefront and the API both read. In theory physical_quantity always equals quantity plus reserved_quantity, so the three numbers never disagree.

In practice they do. The core only keeps that equation true through a narrow set of code paths that fire when an order moves between states with specific logable, paid, and shipped flags. Refund and cancel-then-revalidate cycles can double-credit reserved stock. An out-of-stock order can force quantity down to -1 while reserved_quantity only moves to 1, so the formula never balances again. Multistore setups sharing stock across shops can zero out reserved_quantity outright. On top of that, merchants and modules routinely write stock_availables.quantity directly through the webservice or a CSV import without touching reserved_quantity at all. PrestaShop has no reconciliation job, so once the numbers drift, they stay drifted.

physical_quantity = quantity + reserved_quantity the invariant that should always hold Refund or cancel then revalidate double-credits reserved Out-of-stock order quantity forced to -1 reserved only moves to 1 Multistore share stock zeroes reserved_quantity Direct API or CSV write sets quantity alone stock_availables row drifts, and never self-heals
Four common paths, refund cycles, out-of-stock orders, multistore resets, and direct writes, each push the three numbers apart, and PrestaShop has no job that reconciles them.

Why it happens

The invariant only holds when every write to stock_availables goes through the exact code paths the core expects. A few well documented ways it slips:

Each of these is its own bug or gap, tracked separately by PrestaShop's own issue tracker, and each one leaves the store with a stock row that lies about how much is really sellable. Because there is no built-in reconciliation job, the gap just sits there until someone notices sales figures or availability look wrong. See the citations at the end for the exact issues and docs.

The key insight

physical_quantity and reserved_quantity are core-managed fields. PrestaShop's own stock documentation discourages writing them directly, because the core expects to control them through order state transitions and stock movements, not through a script. So the safe repair is not "recompute everything and write it all back." It is "recompute the true reserved quantity from open orders, then write only quantity," leaving the other two fields for a human to fix at the source, by re-triggering the correct order history transition or running a back-office stock regularization.

The fix, as a flow

We do not touch live stock during checkout. We add a job that reads each product's stock row, independently recomputes the reserved quantity by walking its open orders and their current states, and compares that against what is stored. Anything that disagrees gets flagged and logged. Only when a human explicitly authorizes a write does the job correct quantity, and even then it leaves reserved_quantity and physical_quantity untouched.

Read stock row GET stock_availables Walk open orders order_details, order_states checkStockInvariant compare formula and reserved figure In sync? formula and reserved yes no write no, flag it If authorized: PUT quantity only reserved and physical left untouched
The job only writes when a human explicitly authorizes it, and even then it corrects quantity alone, leaving reserved_quantity and physical_quantity for a human to fix at the source.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with access to stock_availables, order_details, order_histories, and order_states. 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 PRODUCT_IDS="12,45,103"
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 PRODUCT_IDS="12,45,103"
export DRY_RUN="true"   // start safe, change to false to write
2

Read the stored stock row

For each product or combination, call GET /api/stock_availables?filter[id_product]=[X]&display=full&output_format=JSON to read id, id_product, id_product_attribute, the virtual quantity, physical_quantity, and reserved_quantity. This is the row we are about to check.

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()

def stock_available_rows(id_product):
    data = api_get("stock_availables", params={"filter[id_product]": id_product, "display": "full"})
    return data.get("stock_availables") or []
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function stockAvailableRows(idProduct) {
  const data = await apiGet("stock_availables", { "filter[id_product]": idProduct, display: "full" });
  return data.stock_availables || [];
}
3

Recompute reserved quantity from open orders

Call GET /api/order_details?filter[product_id]=[X]&display=full&output_format=JSON to get every order line for the product, with id_order and product_quantity. For each id_order, read its current state from order_histories sorted by date_add descending, then look up that state's paid and shipped flags on GET /api/order_states/[id_state]. An order that is not both paid and shipped still holds its quantity in reserve.

step3.py
def order_state_is_reserving(id_state, state_cache):
    if id_state in state_cache:
        return state_cache[id_state]
    data = api_get(f"order_states/{id_state}")
    state = data.get("order_state", {})
    paid = str(state.get("paid", "0")) == "1"
    shipped = str(state.get("shipped", "0")) == "1"
    reserving = not (paid and shipped)
    state_cache[id_state] = reserving
    return reserving

def current_state_for_order(id_order):
    data = api_get("order_histories", params={
        "filter[id_order]": id_order, "display": "full",
        "sort": "date_add_DESC", "limit": "1",
    })
    histories = data.get("order_histories") or []
    return histories[0]["id_order_state"] if histories else None

def compute_reserved_quantity(id_product, id_product_attribute, state_cache):
    data = api_get("order_details", params={"filter[product_id]": id_product, "display": "full"})
    reserved = 0
    for line in data.get("order_details") or []:
        if id_product_attribute is not None and str(line.get("product_attribute_id")) != str(id_product_attribute):
            continue
        id_state = current_state_for_order(line.get("id_order"))
        if id_state is None:
            continue
        if order_state_is_reserving(id_state, state_cache):
            reserved += int(line.get("product_quantity", 0))
    return reserved
step3.js
async function orderStateIsReserving(idState, stateCache) {
  if (stateCache.has(idState)) return stateCache.get(idState);
  const data = await apiGet(`order_states/${idState}`);
  const state = data.order_state || {};
  const paid = String(state.paid) === "1";
  const shipped = String(state.shipped) === "1";
  const reserving = !(paid && shipped);
  stateCache.set(idState, reserving);
  return reserving;
}

async function currentStateForOrder(idOrder) {
  const data = await apiGet("order_histories", {
    "filter[id_order]": idOrder, display: "full", sort: "date_add_DESC", limit: "1",
  });
  const histories = data.order_histories || [];
  return histories.length ? histories[0].id_order_state : null;
}

async function computeReservedQuantity(idProduct, idProductAttribute, stateCache) {
  const data = await apiGet("order_details", { "filter[product_id]": idProduct, display: "full" });
  let reserved = 0;
  for (const line of data.order_details || []) {
    if (idProductAttribute != null && String(line.product_attribute_id) !== String(idProductAttribute)) continue;
    const idState = await currentStateForOrder(line.id_order);
    if (idState == null) continue;
    if (await orderStateIsReserving(idState, stateCache)) reserved += Number(line.product_quantity || 0);
  }
  return reserved;
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the stored stock row and the recomputed reserved quantity and returns the diagnosis. It checks two things independently: whether physical_quantity equals quantity plus reserved_quantity, the formula, and whether the stored reserved_quantity matches what we recomputed. Either one failing means the row is out of sync, and it also hands back the quantity the row should have.

decide.py
def checkStockInvariant(stock_row, computed_reserved_quantity):
    formula_violation = stock_row["physicalQuantity"] != stock_row["quantity"] + stock_row["reservedQuantity"]
    reserved_mismatch = stock_row["reservedQuantity"] != computed_reserved_quantity
    expected_quantity = stock_row["physicalQuantity"] - computed_reserved_quantity
    in_sync = not formula_violation and not reserved_mismatch
    return {
        "inSync": in_sync,
        "formulaViolation": formula_violation,
        "reservedMismatch": reserved_mismatch,
        "expectedQuantity": expected_quantity,
    }
decide.js
export function checkStockInvariant(stockRow, computedReservedQuantity) {
  const formulaViolation = stockRow.physicalQuantity !== stockRow.quantity + stockRow.reservedQuantity;
  const reservedMismatch = stockRow.reservedQuantity !== computedReservedQuantity;
  const expectedQuantity = stockRow.physicalQuantity - computedReservedQuantity;
  const inSync = !formulaViolation && !reservedMismatch;
  return { inSync, formulaViolation, reservedMismatch, expectedQuantity };
}
5

Only write quantity, and only when authorized

physical_quantity and reserved_quantity are core-managed and read-only by convention, so the script never writes them. When DRY_RUN=false is explicitly set, the only sanctioned write is PUT /api/stock_availables/{id}?output_format=JSON with the full resource body, setting quantity to physical_quantity minus the recomputed reserved quantity. The response and the logs make clear that a human still needs to re-trigger the correct order history transition or run a back-office stock regularization to fix the underlying drift.

apply.py
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 repair_quantity(stock_row_raw, expected_quantity):
    body = {
        "id": stock_row_raw["id"],
        "id_product": stock_row_raw["id_product"],
        "id_product_attribute": stock_row_raw["id_product_attribute"],
        "id_shop": stock_row_raw.get("id_shop", "1"),
        "quantity": expected_quantity,
        "depends_on_stock": stock_row_raw.get("depends_on_stock", "0"),
        "out_of_stock": stock_row_raw.get("out_of_stock", "2"),
    }
    return api_put(f"stock_availables/{stock_row_raw['id']}", "stock_available", body)
apply.js
async function apiPut(path, resourceKey, body) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ [resourceKey]: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
  return res.json();
}

async function repairQuantity(rawRow, expectedQuantity) {
  const body = {
    id: rawRow.id,
    id_product: rawRow.id_product,
    id_product_attribute: rawRow.id_product_attribute,
    id_shop: rawRow.id_shop || "1",
    quantity: expectedQuantity,
    depends_on_stock: rawRow.depends_on_stock || "0",
    out_of_stock: rawRow.out_of_stock || "2",
  };
  return apiPut(`stock_availables/${rawRow.id}`, "stock_available", body);
}
6

Wire it together with a dry run guard

The loop ties every piece together: read the stock row, recompute the reserved quantity, run it through checkStockInvariant, log any violation with the exact figures, and only write when DRY_RUN is off. Leave DRY_RUN on for the first runs and review the flagged rows before you ever let it write. Run it on a schedule that matches how often orders change state, for example a few times a day.

Run it safe

Always start with DRY_RUN=true, and treat a flagged row as a signal to fix the underlying cause, a refund cycle, an out-of-stock order, a multistore reset, or a stray direct write, not just a number to overwrite. The script corrects quantity alone; it never touches physical_quantity or reserved_quantity.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, recomputes reserved stock from real orders, respects the dry run flag, and is safe to run again and again because it only ever writes quantity, and only for rows it has already proven are out of sync.

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.
check_stock_invariant.py
"""Detect PrestaShop stock rows where physical, reserved, and virtual quantity disagree.

PrestaShop's StockAvailable model stores three numbers per product or combination that
should always reconcile: physical_quantity (units on the shelf), reserved_quantity (units
allocated to unshipped or unpaid orders), and quantity (the virtual sellable quantity,
physical minus reserved). The core only maintains that invariant through specific code
paths keyed off order_states flags, and documented core bugs plus direct writes from
modules, CSV import, or the webservice let the three fields drift apart.

This script recomputes the expected reserved quantity by walking open orders for each
product, compares it against the stored stock_availables row, and flags any mismatch.
Because physical_quantity and reserved_quantity are core-managed and read-only by
convention, the only sanctioned write (when DRY_RUN=false) is to stock_availables.quantity,
set to physical_quantity minus the recomputed reserved quantity. reserved_quantity and
physical_quantity are never written; a human is notified to re-trigger the correct
order_histories transition or run a back-office stock regularization.

Run on a schedule. 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("check_stock_invariant")

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, "")

# Orders in a non-final reserving state (not logable/paid yet, and not shipped) still
# hold their product_quantity as reserved stock. A state that is both paid and shipped,
# or explicitly logable as a final delivered/refused/cancelled state, no longer reserves.
NON_RESERVING_WHEN = {"shipped", "paid"}


def checkStockInvariant(stock_row, computed_reserved_quantity):
    """Pure decision function, no I/O.

    stock_row: {quantity, physicalQuantity, reservedQuantity}
    computed_reserved_quantity: recomputed by walking open orders

    Returns {inSync, formulaViolation, reservedMismatch, expectedQuantity}.
    """
    formula_violation = stock_row["physicalQuantity"] != stock_row["quantity"] + stock_row["reservedQuantity"]
    reserved_mismatch = stock_row["reservedQuantity"] != computed_reserved_quantity
    expected_quantity = stock_row["physicalQuantity"] - computed_reserved_quantity
    in_sync = not formula_violation and not reserved_mismatch
    return {
        "inSync": in_sync,
        "formulaViolation": formula_violation,
        "reservedMismatch": reserved_mismatch,
        "expectedQuantity": expected_quantity,
    }


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 order_state_is_reserving(id_state, state_cache):
    """A state still reserves stock unless it is both paid and shipped."""
    if id_state in state_cache:
        return state_cache[id_state]
    data = api_get(f"order_states/{id_state}")
    state = data.get("order_state", {})
    paid = str(state.get("paid", "0")) == "1"
    shipped = str(state.get("shipped", "0")) == "1"
    reserving = not (paid and shipped)
    state_cache[id_state] = reserving
    return reserving


def current_state_for_order(id_order):
    data = api_get("order_histories", params={
        "filter[id_order]": id_order,
        "display": "full",
        "sort": "date_add_DESC",
        "limit": "1",
    })
    histories = data.get("order_histories") or []
    if not histories:
        return None
    return histories[0].get("id_order_state")


def compute_reserved_quantity(id_product, id_product_attribute, state_cache):
    """Walk open orders for a product/combination and sum reserved units."""
    filters = {
        "filter[product_id]": id_product,
        "display": "full",
    }
    data = api_get("order_details", params=filters)
    details = data.get("order_details") or []
    reserved = 0
    for line in details:
        line_attribute = line.get("product_attribute_id")
        if id_product_attribute is not None and str(line_attribute) != str(id_product_attribute):
            continue
        id_order = line.get("id_order")
        id_state = current_state_for_order(id_order)
        if id_state is None:
            continue
        if order_state_is_reserving(id_state, state_cache):
            reserved += int(line.get("product_quantity", 0))
    return reserved


def stock_available_rows(id_product):
    data = api_get("stock_availables", params={
        "filter[id_product]": id_product,
        "display": "full",
    })
    return data.get("stock_availables") or []


def repair_quantity(stock_row_raw, expected_quantity):
    body = {
        "id": stock_row_raw["id"],
        "id_product": stock_row_raw["id_product"],
        "id_product_attribute": stock_row_raw["id_product_attribute"],
        "id_shop": stock_row_raw.get("id_shop", "1"),
        "quantity": expected_quantity,
        "depends_on_stock": stock_row_raw.get("depends_on_stock", "0"),
        "out_of_stock": stock_row_raw.get("out_of_stock", "2"),
    }
    return api_put(f"stock_availables/{stock_row_raw['id']}", "stock_available", body)


def run(product_ids):
    state_cache = {}
    flagged = 0
    for id_product in product_ids:
        for raw in stock_available_rows(id_product):
            id_product_attribute = raw.get("id_product_attribute")
            stock_row = {
                "quantity": int(raw["quantity"]),
                "physicalQuantity": int(raw["physical_quantity"]),
                "reservedQuantity": int(raw["reserved_quantity"]),
            }
            computed_reserved = compute_reserved_quantity(id_product, id_product_attribute, state_cache)
            result = checkStockInvariant(stock_row, computed_reserved)
            if result["inSync"]:
                continue
            flagged += 1
            log.warning(
                "Product %s attribute %s out of sync. stored quantity=%s physical=%s reserved=%s "
                "computed_reserved=%s expected_quantity=%s formulaViolation=%s reservedMismatch=%s",
                id_product, id_product_attribute, stock_row["quantity"], stock_row["physicalQuantity"],
                stock_row["reservedQuantity"], computed_reserved, result["expectedQuantity"],
                result["formulaViolation"], result["reservedMismatch"],
            )
            if not DRY_RUN:
                repair_quantity(raw, result["expectedQuantity"])
                log.info(
                    "Wrote stock_availables/%s quantity=%s. reserved_quantity and physical_quantity left "
                    "untouched; re-trigger the correct order_histories transition or run a back-office "
                    "stock regularization to fix the underlying drift.",
                    raw["id"], result["expectedQuantity"],
                )
    log.info("Done. %d stock row(s) %s.", flagged, "flagged" if DRY_RUN else "flagged and repaired")


if __name__ == "__main__":
    product_ids_env = os.environ.get("PRODUCT_IDS", "")
    ids = [p.strip() for p in product_ids_env.split(",") if p.strip()]
    if not ids:
        log.error("Set PRODUCT_IDS to a comma separated list of id_product values to check.")
    else:
        run(ids)
check-stock-invariant.js
/**
 * Detect PrestaShop stock rows where physical, reserved, and virtual quantity disagree.
 *
 * PrestaShop's StockAvailable model stores three numbers per product or combination that
 * should always reconcile: physical_quantity (units on the shelf), reserved_quantity (units
 * allocated to unshipped or unpaid orders), and quantity (the virtual sellable quantity,
 * physical minus reserved). The core only maintains that invariant through specific code
 * paths keyed off order_states flags, and documented core bugs plus direct writes from
 * modules, CSV import, or the webservice let the three fields drift apart.
 *
 * This script recomputes the expected reserved quantity by walking open orders for each
 * product, compares it against the stored stock_availables row, and flags any mismatch.
 * Because physical_quantity and reserved_quantity are core-managed and read-only by
 * convention, the only sanctioned write (when DRY_RUN=false) is to stock_availables.quantity,
 * set to physical_quantity minus the recomputed reserved quantity. reserved_quantity and
 * physical_quantity are never written; a human is notified to re-trigger the correct
 * order_histories transition or run a back-office stock regularization.
 *
 * Guide: https://www.allanninal.dev/prestashop/stock-quantity-formula-drift/
 */
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";

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

/**
 * Pure decision function, no I/O.
 * stockRow: { quantity, physicalQuantity, reservedQuantity }
 * computedReservedQuantity: recomputed by walking open orders
 * Returns { inSync, formulaViolation, reservedMismatch, expectedQuantity }.
 */
export function checkStockInvariant(stockRow, computedReservedQuantity) {
  const formulaViolation = stockRow.physicalQuantity !== stockRow.quantity + stockRow.reservedQuantity;
  const reservedMismatch = stockRow.reservedQuantity !== computedReservedQuantity;
  const expectedQuantity = stockRow.physicalQuantity - computedReservedQuantity;
  const inSync = !formulaViolation && !reservedMismatch;
  return { inSync, formulaViolation, reservedMismatch, expectedQuantity };
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

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

async function orderStateIsReserving(idState, stateCache) {
  if (stateCache.has(idState)) return stateCache.get(idState);
  const data = await apiGet(`order_states/${idState}`);
  const state = data.order_state || {};
  const paid = String(state.paid) === "1";
  const shipped = String(state.shipped) === "1";
  const reserving = !(paid && shipped);
  stateCache.set(idState, reserving);
  return reserving;
}

async function currentStateForOrder(idOrder) {
  const data = await apiGet("order_histories", {
    "filter[id_order]": idOrder,
    display: "full",
    sort: "date_add_DESC",
    limit: "1",
  });
  const histories = data.order_histories || [];
  if (!histories.length) return null;
  return histories[0].id_order_state;
}

async function computeReservedQuantity(idProduct, idProductAttribute, stateCache) {
  const data = await apiGet("order_details", {
    "filter[product_id]": idProduct,
    display: "full",
  });
  const details = data.order_details || [];
  let reserved = 0;
  for (const line of details) {
    if (idProductAttribute != null && String(line.product_attribute_id) !== String(idProductAttribute)) continue;
    const idState = await currentStateForOrder(line.id_order);
    if (idState == null) continue;
    if (await orderStateIsReserving(idState, stateCache)) {
      reserved += Number(line.product_quantity || 0);
    }
  }
  return reserved;
}

async function stockAvailableRows(idProduct) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": idProduct,
    display: "full",
  });
  return data.stock_availables || [];
}

async function repairQuantity(rawRow, expectedQuantity) {
  const body = {
    id: rawRow.id,
    id_product: rawRow.id_product,
    id_product_attribute: rawRow.id_product_attribute,
    id_shop: rawRow.id_shop || "1",
    quantity: expectedQuantity,
    depends_on_stock: rawRow.depends_on_stock || "0",
    out_of_stock: rawRow.out_of_stock || "2",
  };
  return apiPut(`stock_availables/${rawRow.id}`, "stock_available", body);
}

export async function run(productIds) {
  const stateCache = new Map();
  let flagged = 0;
  for (const idProduct of productIds) {
    for (const raw of await stockAvailableRows(idProduct)) {
      const idProductAttribute = raw.id_product_attribute;
      const stockRow = {
        quantity: Number(raw.quantity),
        physicalQuantity: Number(raw.physical_quantity),
        reservedQuantity: Number(raw.reserved_quantity),
      };
      const computedReserved = await computeReservedQuantity(idProduct, idProductAttribute, stateCache);
      const result = checkStockInvariant(stockRow, computedReserved);
      if (result.inSync) continue;
      flagged++;
      console.warn(
        `Product ${idProduct} attribute ${idProductAttribute} out of sync. stored quantity=${stockRow.quantity} ` +
          `physical=${stockRow.physicalQuantity} reserved=${stockRow.reservedQuantity} computed_reserved=${computedReserved} ` +
          `expected_quantity=${result.expectedQuantity} formulaViolation=${result.formulaViolation} reservedMismatch=${result.reservedMismatch}`
      );
      if (!DRY_RUN) {
        await repairQuantity(raw, result.expectedQuantity);
        console.log(
          `Wrote stock_availables/${raw.id} quantity=${result.expectedQuantity}. reserved_quantity and ` +
            `physical_quantity left untouched; re-trigger the correct order_histories transition or run a ` +
            `back-office stock regularization to fix the underlying drift.`
        );
      }
    }
  }
  console.log(`Done. ${flagged} stock row(s) ${DRY_RUN ? "flagged" : "flagged and repaired"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const productIdsEnv = process.env.PRODUCT_IDS || "";
  const ids = productIdsEnv.split(",").map((s) => s.trim()).filter(Boolean);
  if (!ids.length) {
    console.error("Set PRODUCT_IDS to a comma separated list of id_product values to check.");
    process.exit(1);
  } else {
    run(ids).catch((err) => { console.error(err); process.exit(1); });
  }
}

Add a test

The invariant check is the part most worth testing, because it decides which stock rows get flagged and, eventually, corrected. Because we kept checkStockInvariant pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_formula_invariant.py
from check_stock_invariant import checkStockInvariant


def stock_row(**over):
    base = {"quantity": 7, "physicalQuantity": 10, "reservedQuantity": 3}
    base.update(over)
    return base


def test_in_sync_when_formula_and_reserved_match():
    result = checkStockInvariant(stock_row(), 3)
    assert result["inSync"] is True
    assert result["formulaViolation"] is False
    assert result["reservedMismatch"] is False
    assert result["expectedQuantity"] == 7


def test_formula_violation_when_physical_not_equal_quantity_plus_reserved():
    row = stock_row(quantity=7, physicalQuantity=10, reservedQuantity=1)
    result = checkStockInvariant(row, 1)
    assert result["formulaViolation"] is True
    assert result["reservedMismatch"] is False
    assert result["inSync"] is False
    assert result["expectedQuantity"] == 9


def test_reserved_mismatch_when_computed_differs_from_stored():
    row = stock_row(quantity=7, physicalQuantity=10, reservedQuantity=3)
    result = checkStockInvariant(row, 5)
    assert result["reservedMismatch"] is True
    assert result["formulaViolation"] is False
    assert result["inSync"] is False
    assert result["expectedQuantity"] == 5


def test_both_violations_can_be_true_at_once():
    row = stock_row(quantity=7, physicalQuantity=10, reservedQuantity=1)
    result = checkStockInvariant(row, 5)
    assert result["formulaViolation"] is True
    assert result["reservedMismatch"] is True
    assert result["inSync"] is False
    assert result["expectedQuantity"] == 5


def test_out_of_stock_forced_negative_quantity_is_flagged():
    # Documented core bug: quantity forced to -1 while reserved_quantity goes to 1.
    row = stock_row(quantity=-1, physicalQuantity=0, reservedQuantity=1)
    result = checkStockInvariant(row, 0)
    assert result["formulaViolation"] is False
    assert result["reservedMismatch"] is True
    assert result["inSync"] is False
    assert result["expectedQuantity"] == 0


def test_zero_reserved_after_multistore_share_stock_reset():
    row = stock_row(quantity=10, physicalQuantity=10, reservedQuantity=0)
    result = checkStockInvariant(row, 4)
    assert result["reservedMismatch"] is True
    assert result["expectedQuantity"] == 6
    assert result["inSync"] is False
check-stock-invariant.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkStockInvariant } from "./check-stock-invariant.js";

const stockRow = (over = {}) => ({ quantity: 7, physicalQuantity: 10, reservedQuantity: 3, ...over });

test("in sync when formula and reserved match", () => {
  const result = checkStockInvariant(stockRow(), 3);
  assert.equal(result.inSync, true);
  assert.equal(result.formulaViolation, false);
  assert.equal(result.reservedMismatch, false);
  assert.equal(result.expectedQuantity, 7);
});

test("formula violation when physical does not equal quantity plus reserved", () => {
  const row = stockRow({ quantity: 7, physicalQuantity: 10, reservedQuantity: 1 });
  const result = checkStockInvariant(row, 1);
  assert.equal(result.formulaViolation, true);
  assert.equal(result.reservedMismatch, false);
  assert.equal(result.inSync, false);
  assert.equal(result.expectedQuantity, 9);
});

test("reserved mismatch when computed differs from stored", () => {
  const row = stockRow({ quantity: 7, physicalQuantity: 10, reservedQuantity: 3 });
  const result = checkStockInvariant(row, 5);
  assert.equal(result.reservedMismatch, true);
  assert.equal(result.formulaViolation, false);
  assert.equal(result.inSync, false);
  assert.equal(result.expectedQuantity, 5);
});

test("both violations can be true at once", () => {
  const row = stockRow({ quantity: 7, physicalQuantity: 10, reservedQuantity: 1 });
  const result = checkStockInvariant(row, 5);
  assert.equal(result.formulaViolation, true);
  assert.equal(result.reservedMismatch, true);
  assert.equal(result.inSync, false);
  assert.equal(result.expectedQuantity, 5);
});

test("out of stock forced negative quantity is flagged", () => {
  // Documented core bug: quantity forced to -1 while reserved_quantity goes to 1.
  const row = stockRow({ quantity: -1, physicalQuantity: 0, reservedQuantity: 1 });
  const result = checkStockInvariant(row, 0);
  assert.equal(result.formulaViolation, false);
  assert.equal(result.reservedMismatch, true);
  assert.equal(result.inSync, false);
  assert.equal(result.expectedQuantity, 0);
});

test("zero reserved after multistore share stock reset", () => {
  const row = stockRow({ quantity: 10, physicalQuantity: 10, reservedQuantity: 0 });
  const result = checkStockInvariant(row, 4);
  assert.equal(result.reservedMismatch, true);
  assert.equal(result.expectedQuantity, 6);
  assert.equal(result.inSync, false);
});

Case studies

Refund cycle

The store where stock kept climbing after refunds

A homeware shop noticed that a handful of frequently returned products showed more sellable stock than a physical count ever confirmed. The pattern traced back to orders that were refunded, then re-validated by support to fix an unrelated address issue, which credited the reserved stock back a second time.

Running the diagnostic against those product IDs surfaced exactly which stock rows had a reserved figure higher than any open order justified. The team recomputed and corrected quantity, then changed their support process to avoid revalidating a refunded order at all.

CSV import

The catalog sync that silently ignored reserved stock

A multistore seller ran a nightly CSV import from their warehouse system straight into stock_availables.quantity to keep counts fresh. The import had no idea some of that physical stock was already reserved for unpaid orders, so it kept overwriting quantity as if nothing was reserved.

Adding the diagnostic as a follow-up step after every import caught the products where quantity no longer matched physical minus reserved, and the sanctioned write kept the customer-facing number honest until the import script itself was fixed.

What good looks like

After this runs on a schedule, every stock row you check has been independently verified against the orders that actually justify holding stock in reserve. Formula violations and reserved mismatches get logged with the exact numbers, quantity gets corrected when you authorize it, and physical_quantity and reserved_quantity stay in the hands of the core and the humans who understand why they drifted in the first place.

FAQ

Why do PrestaShop stock quantities stop adding up?

PrestaShop only keeps physical_quantity, reserved_quantity, and quantity reconciled through specific core code paths tied to order state flags. Documented core bugs around refunds, cancel-then-revalidate cycles, out-of-stock orders, and multistore shared stock leave the numbers apart, and modules or CSV imports that write quantity directly make it worse. There is no built-in job that fixes this on its own.

Is it safe to write physical_quantity or reserved_quantity directly?

No. PrestaShop's own stock documentation treats those two fields as core-managed, and writing them directly is discouraged because the core expects to control them through order state transitions and stock movements. The safe write is to quantity alone, recomputed as physical_quantity minus the true reserved amount, while a human fixes the underlying drift through the back office.

How do I recompute the true reserved quantity for a product?

Walk every order_details line for the product, look up each order's current state through order_histories, and check that state's paid and shipped flags on order_states. Any order that is not both paid and shipped still holds its ordered quantity in reserve, so summing those quantities gives the expected reserved_quantity to compare against the stored value.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Product Physical quantity is wrongly increased when Order status is changed. github.com/PrestaShop/PrestaShop/issues/36024
  2. PrestaShop GitHub: ps_stock_available updated wrongly on order when products are out of stock. github.com/PrestaShop/PrestaShop/issues/27631
  3. PrestaShop GitHub: Reserved stock issue. github.com/PrestaShop/PrestaShop/issues/22756

On the solution:

  1. PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock/
  2. PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables/
  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 untangle your stock numbers?

If this saved you a manual audit or a wrong stock count, 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