Skip to content

Reconciler Stock & Inventory

Reserved stock quantity drifts from actual pending orders

A product still shows units held in reserved_quantity long after the order that reserved them was cancelled or refunded. Nobody edited stock by hand, but the count never lets go. Here is why PrestaShop's reserved quantity is a counter, not a live answer, and a small script that finds every drifted row and repairs it the way PrestaShop's own core would.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A worker with a tablet in a warehouse
Photo by Rodrigo Rodrigues on Unsplash
The short answer

PrestaShop keeps stock_available.reserved_quantity as a running sum that is only touched as a side effect of order_histories inserts, so it can only move when a state change goes through the normal flow. Run a script that pulls the logable order states, recomputes expected reserved quantity from real open orders, and diffs it against the API's reported reserved_quantity per product and combination. Where they disagree, repair it by reinserting an order_histories row for the order's own current state, which re-triggers PrestaShop's native stock recalculation. Never write reserved_quantity directly. Full code, tests, and a dry run guard are below.

The problem in plain words

When a customer checks out, PrestaShop needs to hold stock so two people cannot buy the last unit at the same time. It does that by bumping reserved_quantity on the product's stock_available row the moment the order reaches a state flagged logable, meaning it counts as a real pending sale.

The trouble is what happens next. That number is not recalculated from scratch by asking "which orders are currently open." It is only ever incremented or decremented as a side effect of order_histories rows being inserted, through StockManager and StockAvailable hooks tied to the normal validateOrder and changeIdOrderState flow. If an order is cancelled or refunded any other way, a bulk edit in the backoffice, a direct database write, a custom module, or a webservice call that changes the order state without going through order_histories, the decrement step is skipped or applied twice. The counter drifts, and it has no way to self correct.

Order placed reserved_quantity +1 Cancelled outside bulk edit, direct DB, module order_histories skipped Decrement never runs no hook was triggered Stuck above zero reserved_quantity is a counter, not a live query, so it never self corrects
The reservation goes up when an order is placed and comes down only through a normal state change. Skip that step and the number is stuck.

Why it happens

PrestaShop's stock model was built to move fast at checkout, not to re-derive the truth on every read. That tradeoff shows up in a few recurring ways stores end up with a permanently wrong reserved_quantity:

This is a well documented, recurring defect rather than a one-off misconfiguration. PrestaShop's own core issue tracker has multiple confirmed reports of incorrect reserved_quantity values and stock corruption tied to order state changes, spanning several major versions. See the citations at the end for the exact reports.

The key insight

reserved_quantity is not something you can trust as a live number, and it is not something you should hand edit either. PrestaShop's own devdocs say not to write it or physical_quantity directly, since they are core managed derived values, and the webservice does not expose a supported write path for reserved_quantity at all. The only way to correct it safely is to make PrestaShop recompute it itself, by re-triggering the same hook a normal state change would fire.

The fix, as a flow

We never touch the stock row directly. Instead the script recomputes what reserved_quantity should be from the orders that are actually still open, compares that to what the API reports, and for every row that disagrees, reinserts an order_histories entry for that order's own current state. That forces PrestaShop's native StockManager hook to run again and settle the number on its own.

Pull order_states find logable ids Sum open order lines qty minus refunded qty Diff vs API stock stock_availables rows Drift found? yes no, skip Repost order_histories re-apply current state StockManager recalculates
The script only repairs rows where the computed reserved quantity really disagrees with what the API reports, and it repairs them through PrestaShop's own hook, never by hand editing the row.

Build it step by step

1

Get a webservice key with the right permissions

In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to order_states, orders, order_details, and stock_availables, plus write access to order_histories. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, change to false to write
2

Find which order states actually reserve stock

Not every order state counts as a pending sale. PrestaShop flags each order_state row with a logable field, and only those states hold a reservation. Pull the full list once and keep the ids where logable is true.

step2.py
import os, requests

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

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

def logable_state_ids():
    data = api_get("order_states", {"display": "full"})
    states = data.get("order_states") or []
    return {int(s["id"]) for s in states if str(s.get("logable")) in ("1", "true", "True")}
step2.js
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";

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

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

async function logableStateIds() {
  const data = await apiGet("order_states", { display: "full" });
  const states = data.order_states || [];
  return new Set(states.filter((s) => String(s.logable) === "1" || s.logable === true).map((s) => Number(s.id)));
}
3

Pull open orders and their lines

List orders whose current_state is one of the logable ids, then read each order's lines to get product_quantity and product_quantity_refunded per product and combination. This is the real, current picture of what should be reserved.

step3.py
def open_orders(logable_ids):
    data = api_get("orders", {"display": "full", "limit": "0,1000"})
    orders = data.get("orders") or []
    return [o for o in orders if int(o.get("current_state", 0)) in logable_ids]

def order_lines(id_order):
    data = api_get("order_details", {"display": "full", "filter[id_order]": id_order})
    return data.get("order_details") or []

def open_order_lines(logable_ids):
    lines = []
    for order in open_orders(logable_ids):
        id_state = int(order["current_state"])
        for line in order_lines(order["id"]):
            lines.append({
                "id_product": int(line["product_id"]),
                "id_product_attribute": int(line.get("product_attribute_id") or 0),
                "product_quantity": int(line.get("product_quantity") or 0),
                "product_quantity_refunded": int(line.get("product_quantity_refunded") or 0),
                "id_order_state": id_state,
            })
    return lines
step3.js
async function openOrders(logableIds) {
  const data = await apiGet("orders", { display: "full", limit: "0,1000" });
  const orders = data.orders || [];
  return orders.filter((o) => logableIds.has(Number(o.current_state)));
}

async function orderLines(idOrder) {
  const data = await apiGet("order_details", { display: "full", "filter[id_order]": idOrder });
  return data.order_details || [];
}

async function openOrderLines(logableIds) {
  const lines = [];
  for (const order of await openOrders(logableIds)) {
    const idState = Number(order.current_state);
    for (const line of await orderLines(order.id)) {
      lines.push({
        id_product: Number(line.product_id),
        id_product_attribute: Number(line.product_attribute_id || 0),
        product_quantity: Number(line.product_quantity || 0),
        product_quantity_refunded: Number(line.product_quantity_refunded || 0),
        id_order_state: idState,
      });
    }
  }
  return lines;
}
4

Decide, with one pure function

Keep the actual comparison in its own function that takes plain lists in and returns plain data out. It filters lines to the logable states, sums quantity minus refunded quantity per product and combination clipped at zero, joins that against the stock rows the API reports, and returns only the rows that actually disagree, with the signed drift. Nothing here talks to the network, so it is simple to test with hand built fixtures.

decide.py
def compute_reserved_drift(open_order_lines, logable_state_ids, stock_rows):
    expected = {}
    for line in open_order_lines:
        if line["id_order_state"] not in logable_state_ids:
            continue
        key = (line["id_product"], line["id_product_attribute"])
        remaining = line["product_quantity"] - line["product_quantity_refunded"]
        if remaining < 0:
            remaining = 0
        expected[key] = expected.get(key, 0) + remaining

    actual_by_key = {
        (row["id_product"], row["id_product_attribute"]): row["reserved_quantity"]
        for row in stock_rows
    }

    keys = set(expected) | set(actual_by_key)
    results = []
    for key in keys:
        expected_reserved = expected.get(key, 0)
        actual_reserved = actual_by_key.get(key, 0)
        if expected_reserved != actual_reserved:
            id_product, id_product_attribute = key
            results.append({
                "id_product": id_product,
                "id_product_attribute": id_product_attribute,
                "expected_reserved": expected_reserved,
                "actual_reserved": actual_reserved,
                "drift": actual_reserved - expected_reserved,
            })
    return results
decide.js
export function computeReservedDrift(openOrderLines, logableStateIds, stockRows) {
  const expected = new Map();
  for (const line of openOrderLines) {
    if (!logableStateIds.has(line.id_order_state)) continue;
    const key = `${line.id_product}:${line.id_product_attribute}`;
    let remaining = line.product_quantity - line.product_quantity_refunded;
    if (remaining < 0) remaining = 0;
    expected.set(key, (expected.get(key) || 0) + remaining);
  }

  const actualByKey = new Map();
  for (const row of stockRows) {
    actualByKey.set(`${row.id_product}:${row.id_product_attribute}`, row.reserved_quantity);
  }

  const keys = new Set([...expected.keys(), ...actualByKey.keys()]);
  const results = [];
  for (const key of keys) {
    const [idProduct, idProductAttribute] = key.split(":").map(Number);
    const expectedReserved = expected.get(key) || 0;
    const actualReserved = actualByKey.get(key) || 0;
    if (expectedReserved !== actualReserved) {
      results.push({
        id_product: idProduct,
        id_product_attribute: idProductAttribute,
        expected_reserved: expectedReserved,
        actual_reserved: actualReserved,
        drift: actualReserved - expectedReserved,
      });
    }
  }
  return results;
}
5

Repair through order_histories, never through the stock row

For a drifted row, do not write reserved_quantity or physical_quantity directly. PrestaShop's Stock FAQ says these are core managed derived values and the webservice does not offer a supported write path for reserved_quantity. Instead, post a fresh order_histories row that re-applies the order's own current, terminal state. That reruns the same StockManager hook a normal state change would trigger, and PrestaShop recalculates the stock row on its own.

apply.py
def resync_order_state(id_order, id_order_state):
    body = {
        "order_history": {
            "id_order": id_order,
            "id_order_state": id_order_state,
        }
    }
    r = requests.post(
        f"{BASE_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function resyncOrderState(idOrder, idOrderState) {
  const url = new URL(`${BASE_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The run loop pulls the logable states, the open order lines, and the current stock rows, computes the drift, and logs every drifted product and combination with the old value versus the computed value. Leave DRY_RUN on for the first few runs so it only reports. Once you trust the list, switch it off so it resyncs each drifted order's state and lets PrestaShop settle the number itself.

Run it safe

Always start with DRY_RUN=true. This script never writes to stock_available directly, it only reposts an order's own existing current state to order_histories, which is the same mechanism PrestaShop's own order state screen uses.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only repairs rows where the computed reservation actually disagrees with what the API reports.

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.
reserved_quantity_drift.py
"""Find and repair PrestaShop reserved_quantity drift from real pending orders.

stock_available.reserved_quantity is a running counter PrestaShop updates as a side
effect of order_histories inserts, not a live query. When an order state changes
outside the normal flow, the decrement can be skipped and the counter never comes
back down. This recomputes the expected reserved quantity from real open orders,
diffs it against the API, and repairs drift by reposting the order's own current
state to order_histories, which re-triggers PrestaShop's native stock recalculation.
Never writes reserved_quantity or physical_quantity directly. 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("reserved_quantity_drift")

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


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


def logable_state_ids():
    data = api_get("order_states", {"display": "full"})
    states = data.get("order_states") or []
    return {int(s["id"]) for s in states if str(s.get("logable")) in ("1", "true", "True")}


def open_orders(logable_ids):
    data = api_get("orders", {"display": "full", "limit": "0,1000"})
    orders = data.get("orders") or []
    return [o for o in orders if int(o.get("current_state", 0)) in logable_ids]


def order_lines(id_order):
    data = api_get("order_details", {"display": "full", "filter[id_order]": id_order})
    return data.get("order_details") or []


def open_order_lines(logable_ids):
    lines = []
    for order in open_orders(logable_ids):
        id_state = int(order["current_state"])
        for line in order_lines(order["id"]):
            lines.append({
                "id_product": int(line["product_id"]),
                "id_product_attribute": int(line.get("product_attribute_id") or 0),
                "product_quantity": int(line.get("product_quantity") or 0),
                "product_quantity_refunded": int(line.get("product_quantity_refunded") or 0),
                "id_order_state": id_state,
            })
    return lines


def stock_rows():
    data = api_get("stock_availables", {"display": "full", "limit": "0,1000"})
    rows = data.get("stock_availables") or []
    return [
        {
            "id_product": int(r["id_product"]),
            "id_product_attribute": int(r.get("id_product_attribute") or 0),
            "reserved_quantity": int(r.get("reserved_quantity") or 0),
        }
        for r in rows
    ]


def order_current_state(id_order):
    data = api_get(f"orders/{id_order}", {"display": "full"})
    return int(data["order"]["current_state"])


def compute_reserved_drift(open_order_lines, logable_ids, stock_rows_list):
    expected = {}
    for line in open_order_lines:
        if line["id_order_state"] not in logable_ids:
            continue
        key = (line["id_product"], line["id_product_attribute"])
        remaining = line["product_quantity"] - line["product_quantity_refunded"]
        if remaining < 0:
            remaining = 0
        expected[key] = expected.get(key, 0) + remaining

    actual_by_key = {
        (row["id_product"], row["id_product_attribute"]): row["reserved_quantity"]
        for row in stock_rows_list
    }

    keys = set(expected) | set(actual_by_key)
    results = []
    for key in keys:
        expected_reserved = expected.get(key, 0)
        actual_reserved = actual_by_key.get(key, 0)
        if expected_reserved != actual_reserved:
            id_product, id_product_attribute = key
            results.append({
                "id_product": id_product,
                "id_product_attribute": id_product_attribute,
                "expected_reserved": expected_reserved,
                "actual_reserved": actual_reserved,
                "drift": actual_reserved - expected_reserved,
            })
    return results


def resync_order_state(id_order, id_order_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": id_order_state}}
    r = requests.post(
        f"{BASE_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def orders_touching_product(open_orders_list, id_product, id_product_attribute):
    matches = []
    for order in open_orders_list:
        for line in order_lines(order["id"]):
            if int(line["product_id"]) == id_product and int(line.get("product_attribute_id") or 0) == id_product_attribute:
                matches.append(order)
                break
    return matches


def run():
    ids = logable_state_ids()
    orders = open_orders(ids)
    lines = []
    for order in orders:
        id_state = int(order["current_state"])
        for line in order_lines(order["id"]):
            lines.append({
                "id_product": int(line["product_id"]),
                "id_product_attribute": int(line.get("product_attribute_id") or 0),
                "product_quantity": int(line.get("product_quantity") or 0),
                "product_quantity_refunded": int(line.get("product_quantity_refunded") or 0),
                "id_order_state": id_state,
            })
    rows = stock_rows()
    drifted = compute_reserved_drift(lines, ids, rows)

    for item in drifted:
        log.warning(
            "Product %s attribute %s drift: expected=%s actual=%s (%s)",
            item["id_product"], item["id_product_attribute"],
            item["expected_reserved"], item["actual_reserved"],
            "would resync" if DRY_RUN else "resyncing",
        )
        if not DRY_RUN:
            touching = orders_touching_product(orders, item["id_product"], item["id_product_attribute"])
            for order in touching:
                resync_order_state(order["id"], int(order["current_state"]))

    log.info("Done. %d drifted product/attribute row(s) %s.", len(drifted), "to resync" if DRY_RUN else "resynced")


if __name__ == "__main__":
    run()
reserved-quantity-drift.js
/**
 * Find and repair PrestaShop reserved_quantity drift from real pending orders.
 *
 * stock_available.reserved_quantity is a running counter PrestaShop updates as a side
 * effect of order_histories inserts, not a live query. When an order state changes
 * outside the normal flow, the decrement can be skipped and the counter never comes
 * back down. This recomputes the expected reserved quantity from real open orders,
 * diffs it against the API, and repairs drift by reposting the order's own current
 * state to order_histories, which re-triggers PrestaShop's native stock recalculation.
 * Never writes reserved_quantity or physical_quantity directly. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/reserved-quantity-drift/
 */
import { pathToFileURL } from "node:url";

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

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

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

async function logableStateIds() {
  const data = await apiGet("order_states", { display: "full" });
  const states = data.order_states || [];
  return new Set(states.filter((s) => String(s.logable) === "1" || s.logable === true).map((s) => Number(s.id)));
}

async function openOrders(logableIds) {
  const data = await apiGet("orders", { display: "full", limit: "0,1000" });
  const orders = data.orders || [];
  return orders.filter((o) => logableIds.has(Number(o.current_state)));
}

async function orderLines(idOrder) {
  const data = await apiGet("order_details", { display: "full", "filter[id_order]": idOrder });
  return data.order_details || [];
}

async function stockRows() {
  const data = await apiGet("stock_availables", { display: "full", limit: "0,1000" });
  const rows = data.stock_availables || [];
  return rows.map((r) => ({
    id_product: Number(r.id_product),
    id_product_attribute: Number(r.id_product_attribute || 0),
    reserved_quantity: Number(r.reserved_quantity || 0),
  }));
}

export function computeReservedDrift(openOrderLines, logableStateIds, stockRowsList) {
  const expected = new Map();
  for (const line of openOrderLines) {
    if (!logableStateIds.has(line.id_order_state)) continue;
    const key = `${line.id_product}:${line.id_product_attribute}`;
    let remaining = line.product_quantity - line.product_quantity_refunded;
    if (remaining < 0) remaining = 0;
    expected.set(key, (expected.get(key) || 0) + remaining);
  }

  const actualByKey = new Map();
  for (const row of stockRowsList) {
    actualByKey.set(`${row.id_product}:${row.id_product_attribute}`, row.reserved_quantity);
  }

  const keys = new Set([...expected.keys(), ...actualByKey.keys()]);
  const results = [];
  for (const key of keys) {
    const [idProduct, idProductAttribute] = key.split(":").map(Number);
    const expectedReserved = expected.get(key) || 0;
    const actualReserved = actualByKey.get(key) || 0;
    if (expectedReserved !== actualReserved) {
      results.push({
        id_product: idProduct,
        id_product_attribute: idProductAttribute,
        expected_reserved: expectedReserved,
        actual_reserved: actualReserved,
        drift: actualReserved - expectedReserved,
      });
    }
  }
  return results;
}

async function resyncOrderState(idOrder, idOrderState) {
  const url = new URL(`${BASE_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function ordersTouchingProduct(openOrdersList, idProduct, idProductAttribute) {
  const matches = [];
  for (const order of openOrdersList) {
    const lines = await orderLines(order.id);
    if (lines.some((l) => Number(l.product_id) === idProduct && Number(l.product_attribute_id || 0) === idProductAttribute)) {
      matches.push(order);
    }
  }
  return matches;
}

export async function run() {
  const ids = await logableStateIds();
  const orders = await openOrders(ids);
  const lines = [];
  for (const order of orders) {
    const idState = Number(order.current_state);
    for (const line of await orderLines(order.id)) {
      lines.push({
        id_product: Number(line.product_id),
        id_product_attribute: Number(line.product_attribute_id || 0),
        product_quantity: Number(line.product_quantity || 0),
        product_quantity_refunded: Number(line.product_quantity_refunded || 0),
        id_order_state: idState,
      });
    }
  }
  const rows = await stockRows();
  const drifted = computeReservedDrift(lines, ids, rows);

  for (const item of drifted) {
    console.warn(
      `Product ${item.id_product} attribute ${item.id_product_attribute} drift: expected=${item.expected_reserved} actual=${item.actual_reserved} (${DRY_RUN ? "would resync" : "resyncing"})`
    );
    if (!DRY_RUN) {
      const touching = await ordersTouchingProduct(orders, item.id_product, item.id_product_attribute);
      for (const order of touching) {
        await resyncOrderState(order.id, Number(order.current_state));
      }
    }
  }

  console.log(`Done. ${drifted.length} drifted product/attribute row(s) ${DRY_RUN ? "to resync" : "resynced"}.`);
}

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

Add a test

The decision function is the part most worth testing, because it decides which products get a corrective order state resync. Because compute_reserved_drift is pure, the test needs no PrestaShop instance and no network. It just feeds in plain lists and checks the answer.

test_reserved_drift.py
from reserved_quantity_drift import compute_reserved_drift

LOGABLE = {2}  # only order state id 2 counts as a pending reservation


def line(**over):
    base = {
        "id_product": 10,
        "id_product_attribute": 0,
        "product_quantity": 2,
        "product_quantity_refunded": 0,
        "id_order_state": 2,
    }
    base.update(over)
    return base


def stock_row(**over):
    base = {"id_product": 10, "id_product_attribute": 0, "reserved_quantity": 2}
    base.update(over)
    return base


def test_no_drift_when_expected_matches_actual():
    assert compute_reserved_drift([line()], LOGABLE, [stock_row()]) == []


def test_drift_when_reserved_quantity_stuck_after_cancellation():
    # no open orders at all, but the stock row still holds reserved units
    result = compute_reserved_drift([], LOGABLE, [stock_row(reserved_quantity=3)])
    assert result == [{
        "id_product": 10,
        "id_product_attribute": 0,
        "expected_reserved": 0,
        "actual_reserved": 3,
        "drift": 3,
    }]


def test_zero_orders_and_zero_stock_produces_no_drift():
    assert compute_reserved_drift([], LOGABLE, []) == []


def test_refunded_partial_line_reduces_expected_reserved():
    l = line(product_quantity=5, product_quantity_refunded=3)  # 2 remaining
    assert compute_reserved_drift([l], LOGABLE, [stock_row(reserved_quantity=2)]) == []
    assert compute_reserved_drift([l], LOGABLE, [stock_row(reserved_quantity=5)]) == [{
        "id_product": 10,
        "id_product_attribute": 0,
        "expected_reserved": 2,
        "actual_reserved": 5,
        "drift": 3,
    }]


def test_non_logable_state_is_excluded_from_expected():
    l = line(id_order_state=99)  # not in LOGABLE
    result = compute_reserved_drift([l], LOGABLE, [stock_row(reserved_quantity=2)])
    assert result == [{
        "id_product": 10,
        "id_product_attribute": 0,
        "expected_reserved": 0,
        "actual_reserved": 2,
        "drift": 2,
    }]


def test_multiple_attributes_per_product_are_tracked_separately():
    lines = [
        line(id_product_attribute=1, product_quantity=1),
        line(id_product_attribute=2, product_quantity=4),
    ]
    rows = [
        stock_row(id_product_attribute=1, reserved_quantity=1),
        stock_row(id_product_attribute=2, reserved_quantity=9),
    ]
    result = compute_reserved_drift(lines, LOGABLE, rows)
    assert result == [{
        "id_product": 10,
        "id_product_attribute": 2,
        "expected_reserved": 4,
        "actual_reserved": 9,
        "drift": 5,
    }]
reserved-quantity-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeReservedDrift } from "./reserved-quantity-drift.js";

const LOGABLE = new Set([2]);

const line = (over = {}) => ({
  id_product: 10,
  id_product_attribute: 0,
  product_quantity: 2,
  product_quantity_refunded: 0,
  id_order_state: 2,
  ...over,
});

const stockRow = (over = {}) => ({ id_product: 10, id_product_attribute: 0, reserved_quantity: 2, ...over });

test("no drift when expected matches actual", () => {
  assert.deepEqual(computeReservedDrift([line()], LOGABLE, [stockRow()]), []);
});

test("drift when reserved_quantity stuck after cancellation", () => {
  const result = computeReservedDrift([], LOGABLE, [stockRow({ reserved_quantity: 3 })]);
  assert.deepEqual(result, [{
    id_product: 10,
    id_product_attribute: 0,
    expected_reserved: 0,
    actual_reserved: 3,
    drift: 3,
  }]);
});

test("zero orders and zero stock produces no drift", () => {
  assert.deepEqual(computeReservedDrift([], LOGABLE, []), []);
});

test("refunded partial line reduces expected reserved", () => {
  const l = line({ product_quantity: 5, product_quantity_refunded: 3 });
  assert.deepEqual(computeReservedDrift([l], LOGABLE, [stockRow({ reserved_quantity: 2 })]), []);
  assert.deepEqual(computeReservedDrift([l], LOGABLE, [stockRow({ reserved_quantity: 5 })]), [{
    id_product: 10,
    id_product_attribute: 0,
    expected_reserved: 2,
    actual_reserved: 5,
    drift: 3,
  }]);
});

test("non-logable state is excluded from expected", () => {
  const l = line({ id_order_state: 99 });
  const result = computeReservedDrift([l], LOGABLE, [stockRow({ reserved_quantity: 2 })]);
  assert.deepEqual(result, [{
    id_product: 10,
    id_product_attribute: 0,
    expected_reserved: 0,
    actual_reserved: 2,
    drift: 2,
  }]);
});

test("multiple attributes per product are tracked separately", () => {
  const lines = [
    line({ id_product_attribute: 1, product_quantity: 1 }),
    line({ id_product_attribute: 2, product_quantity: 4 }),
  ];
  const rows = [
    stockRow({ id_product_attribute: 1, reserved_quantity: 1 }),
    stockRow({ id_product_attribute: 2, reserved_quantity: 9 }),
  ];
  const result = computeReservedDrift(lines, LOGABLE, rows);
  assert.deepEqual(result, [{
    id_product: 10,
    id_product_attribute: 2,
    expected_reserved: 4,
    actual_reserved: 9,
    drift: 5,
  }]);
});

Case studies

Bulk cancellation

The seasonal cleanup that never let go

A homeware store ran a quarterly cleanup that bulk cancelled hundreds of stale, unpaid orders straight from a saved backoffice filter. Weeks later, popular products kept showing lower available stock than the physical count in the warehouse justified, and nobody could explain why.

The reconciler found dozens of products still holding reserved units from orders that had been cancelled months earlier. Once the drifted rows were resynced through order_histories, available stock matched the warehouse count again, and the team now runs the check right after every bulk cancellation.

Custom module

The refund plugin that skipped the hook

A store used a third party refund module that updated the order's state column directly for speed, bypassing the normal order state change screen. Refunds looked correct in the order view, but reserved quantity on the refunded products never came back down.

Running the drift check on a schedule caught it within a day of the first refund. The fix did not touch the module. It just reposted each affected order's already-current refunded state to order_histories, which let PrestaShop's own hook do the recalculation the module had skipped.

What good looks like

After this runs on a schedule, reserved_quantity stays honest even when orders get cancelled or refunded through paths that skip the normal flow. Available stock reflects reality, nobody hand edits a core managed column, and every repair goes through the same order_histories mechanism PrestaShop's own order state screen uses, so physical_quantity and available for sale quantity stay internally consistent too.

FAQ

Why does PrestaShop's reserved_quantity stop matching my real pending orders?

reserved_quantity is a running counter that PrestaShop updates as a side effect of order_histories inserts, not a live query against currently open orders. When an order is cancelled or refunded outside the normal order state flow, such as a bulk edit, a direct database write, or a webservice call that skips order_histories, the decrement step is skipped and the counter never returns to zero.

Can I just fix reserved_quantity by editing the stock_available row directly?

No. PrestaShop's own Stock FAQ says not to modify reserved_quantity or physical_quantity by hand, since they are core managed derived values, and the public webservice does not offer a supported write path for reserved_quantity. Hand editing the row will desync it from physical_quantity and available for sale quantity again the next time an order state changes.

How do I actually repair the drift once I find it?

Insert a corrective order_histories row that re-applies the order's own current terminal state, for example re-posting Canceled or Refunded. That re-triggers PrestaShop's native StockManager recalculation hook, which is the only supported mechanism that keeps physical_quantity, reserved_quantity, and available for sale quantity internally consistent.

Related field notes

Citations

On the problem:

  1. PS 1.7.6 ASM incorrect "reserved_quantity" values. github.com/PrestaShop/PrestaShop/issues/17490
  2. Reserved stock issue. github.com/PrestaShop/PrestaShop/issues/22756
  3. Product Physical quantity is wrongly increased when Order status is changed. github.com/PrestaShop/PrestaShop/issues/36024

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 histories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_histories/

Stuck on a tricky one?

If you have a problem in PrestaShop stock, orders, order states, or the Webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this settle your reserved stock?

If this saved you a pile of confused stock counts or a wrong "in stock" message, 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