Diagnostic
Order detail stock quantity fields disagree with the real order quantity
Every order line in PrestaShop carries two quantity fields that sound like they should always match: product_quantity, the amount actually ordered, and product_quantity_in_stock, a snapshot of whether that item was in stock when the order was placed. They come from separate code paths, and in real stores they quietly disagree, showing a line with one unit ordered and zero units in stock on the very same row. Here is why that happens and a script that finds every mismatched row and reports it for a human to review.
PrestaShop stores three quantity concepts that are never transactionally reconciled: order_detail.product_quantity, what was ordered on the line, order_detail.product_quantity_in_stock, a snapshot computed separately at order-save time, and stock_availables.quantity, the live sellable stock. Because the in-stock snapshot is computed by its own code path instead of being copied from product_quantity, regressions and edge cases leave it at 0 while product_quantity still shows 1 on the same row. Run a Python or Node.js script that reads each order's lines with GET /api/order_details, flags any row where product_quantity is greater than zero and product_quantity_in_stock does not equal product_quantity minus product_quantity_refunded, and writes nothing back. Full code, tests, and citations are below.
The problem in plain words
Open an order in PrestaShop and its detail lines carry a field called product_quantity, which is exactly what it sounds like: the number of units the customer ordered. Right next to it sits product_quantity_in_stock, which looks like it should just be a copy of the same number, recording that yes, this many units were in stock when the order went through.
It is not a copy. product_quantity_in_stock is computed independently, by Product::getQuantity() and the stock logic that runs at order-save time, as its own separate calculation of what the stock situation looked like at that moment. Because it never simply mirrors product_quantity, anything that trips up that calculation, a code regression, disabled stock management, advanced stock management, backorders, or a partial refund, leaves the two numbers apart on the same row. You end up looking at an order line that says one unit ordered and zero units in stock, on the same order, for the same product, at the same time.
Why it happens
The two fields never share a single source of truth, so anything that disturbs the stock computation while leaving the ordered quantity untouched creates a mismatch. Documented and reported ways it happens:
- A core regression, tracked as PrestaShop GitHub issue #16840, where passing an extra
$cartcontext into the computation zeroed outproduct_quantity_in_stockeven though the order line itself was correct. - Stock management disabled for the product at order time, or advanced stock management turned on, both of which change which code path computes the in-stock snapshot and can leave it uncomputed or zero.
- Backorders, where the item is sold with no physical stock available, so the in-stock snapshot legitimately reflects less than what was ordered, but nothing distinguishes that from a bug unless you also check the order context.
- A partial refund, tracked in related reports such as issue #12814 on combination products, where
product_quantity_refundedchanges butproduct_quantity_in_stockis never recomputed to match the new net quantity.
Any one of these leaves an order_detail row that reads correctly on product_quantity but wrong on product_quantity_in_stock, and the webservice simply exposes whatever is already stored. The API read is accurate, the underlying data is not, which is why this is a core data-consistency bug rather than a transport problem. See the citations at the end for the exact issues and docs.
product_quantity_in_stock is a historical snapshot, not a live number, so it is not auto-fixable. Rewriting it to always match product_quantity would erase real backorder and oversell history along with the bugs. So the safe pattern is not "repair every mismatch automatically." It is "detect and report every mismatch," with a human deciding, order by order, whether the stored value is wrong or whether it correctly recorded a real stock shortfall at the time.
The fix, as a flow
We do not write to any order during checkout, and the script never writes to order_details at all. We add a job that walks recent orders, reads every line's stored quantities, and runs them through one pure check. Anything that fails the check becomes a report row for a human, never an automatic correction.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with read access to orders, order_details, and optionally stock_availables for the cross-check. 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.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export ORDER_DATE_FROM="2026-07-01"
export DRY_RUN="true" # start safe, this script only ever reports
// 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 ORDER_DATE_FROM="2026-07-01"
export DRY_RUN="true" // start safe, this script only ever reports
List recent orders
Call GET /api/orders?filter[date_add]=[ORDER_DATE_FROM,]&display=full&output_format=JSON to get the recent order ids you want to audit. You can also scope this to a single order or a date range that matches how far back you want to check.
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 recent_order_ids(date_from):
data = api_get("orders", params={"filter[date_add]": f"{date_from},", "display": "full"})
orders = data.get("orders") or []
return [o["id"] for o in orders]
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 recentOrderIds(dateFrom) {
const data = await apiGet("orders", { "filter[date_add]": `${dateFrom},`, display: "full" });
const orders = data.orders || [];
return orders.map((o) => o.id);
}
Read every line for each order
Call GET /api/order_details?filter[id_order]=[id]&display=full&output_format=JSON to get every line on that order, with id, id_order, product_id, product_quantity, product_quantity_in_stock, and product_quantity_refunded. Optionally cross-check the product's live stock with GET /api/stock_availables?filter[id_product]=[id]&display=full&output_format=JSON to confirm the mismatch is not just a post-order stock depletion being read as if it were the order-time snapshot.
def order_detail_lines(id_order):
data = api_get("order_details", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_details") or []
def live_stock_quantity(id_product):
data = api_get("stock_availables", params={"filter[id_product]": id_product, "display": "full"})
rows = data.get("stock_availables") or []
return int(rows[0]["quantity"]) if rows else None
async function orderDetailLines(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
async function liveStockQuantity(idProduct) {
const data = await apiGet("stock_availables", { "filter[id_product]": idProduct, display: "full" });
const rows = data.stock_availables || [];
return rows.length ? Number(rows[0].quantity) : null;
}
Decide, with one pure function
Keep the comparison in its own function that takes the three stored numbers and returns true or false, nothing else. The rule is a plain integer comparison: the row is inconsistent when product_quantity is greater than zero and product_quantity_in_stock does not equal product_quantity minus product_quantity_refunded. No network calls happen inside it, which is what makes it easy to test on its own.
def is_stock_quantity_inconsistent(product_quantity, product_quantity_in_stock, product_quantity_refunded=0):
if product_quantity <= 0:
return False
return product_quantity_in_stock != (product_quantity - product_quantity_refunded)
export function isStockQuantityInconsistent(productQuantity, productQuantityInStock, productQuantityRefunded = 0) {
if (productQuantity <= 0) return false;
return productQuantityInStock !== (productQuantity - productQuantityRefunded);
}
Report, never repair automatically
When a line is inconsistent, the script never calls PUT on order_details. It emits a report row with id_order, id_order_detail, product_id, product_quantity, and product_quantity_in_stock for a human to look at. If someone confirms a row is genuinely wrong after checking the real stock state at order time, the manual fix is a targeted PUT /api/order_details/{id}?output_format=JSON correcting product_quantity_in_stock alone, done by hand, never as a bulk automated write.
def build_report_row(order_detail, id_order):
return {
"id_order": id_order,
"id_order_detail": order_detail["id"],
"product_id": order_detail.get("product_id"),
"product_quantity": int(order_detail.get("product_quantity", 0)),
"product_quantity_in_stock": int(order_detail.get("product_quantity_in_stock", 0)),
}
function buildReportRow(orderDetail, idOrder) {
return {
id_order: idOrder,
id_order_detail: orderDetail.id,
product_id: orderDetail.product_id,
product_quantity: Number(orderDetail.product_quantity || 0),
product_quantity_in_stock: Number(orderDetail.product_quantity_in_stock || 0),
};
}
Wire it together with a dry run guard
The loop ties every piece together: list recent orders, read every line, run each line through is_stock_quantity_inconsistent, and log a report row for anything that fails. DRY_RUN defaults to true and the script never has a write path for order_details at all, so there is nothing to accidentally turn on. Run it on a schedule that matches how often you want fresh eyes on new orders, for example once a day.
This script only ever reads and reports. It never writes to order_details, because product_quantity_in_stock is a historical snapshot tied to real stock events, and rewriting it automatically can hide a genuine backorder or oversell and corrupt the audit trail. Treat every report row as a lead for a human to investigate, not a queue to auto-correct.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks recent orders and their lines, respects the dry run flag even though it never writes, and is safe to run again and again because the only output is a report.
"""Detect PrestaShop order_detail rows where the stock snapshot disagrees with the order.
Every order_detail row carries product_quantity, what was actually ordered, and
product_quantity_in_stock, a snapshot computed separately at order-save time by
Product::getQuantity() and the stock logic, meant to record whether the item was in
stock when ordered. Because product_quantity_in_stock is computed rather than copied
from product_quantity, regressions in that computation (see PrestaShop GitHub issue
#16840) and edge cases like disabled stock management, advanced stock management,
backorders, or partial refunds can leave product_quantity_in_stock at 0 while
product_quantity still shows the real ordered amount on the same row.
This script never writes to order_details. product_quantity_in_stock is a historical
snapshot tied to real stock events at order time, so rewriting it automatically can
hide a genuine backorder or oversell and corrupt the audit trail. It only detects
inconsistent rows and emits a report line for a human to review. A confirmed fix is a
targeted, manual PUT to order_details/{id} correcting product_quantity_in_stock alone,
never a bulk automated write.
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_order_detail_stock")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
ORDER_DATE_FROM = os.environ.get("ORDER_DATE_FROM", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
def is_stock_quantity_inconsistent(product_quantity, product_quantity_in_stock, product_quantity_refunded=0):
"""Pure decision function, no I/O.
Returns True when product_quantity is positive and product_quantity_in_stock does
not equal product_quantity minus product_quantity_refunded, i.e. the in-stock
snapshot disagrees with the net ordered quantity for that line.
"""
if product_quantity <= 0:
return False
return product_quantity_in_stock != (product_quantity - product_quantity_refunded)
def build_report_row(order_detail, id_order):
return {
"id_order": id_order,
"id_order_detail": order_detail["id"],
"product_id": order_detail.get("product_id"),
"product_quantity": int(order_detail.get("product_quantity", 0)),
"product_quantity_in_stock": int(order_detail.get("product_quantity_in_stock", 0)),
}
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 recent_order_ids(date_from):
params = {"display": "full"}
if date_from:
params["filter[date_add]"] = f"{date_from},"
data = api_get("orders", params=params)
orders = data.get("orders") or []
return [o["id"] for o in orders]
def order_detail_lines(id_order):
data = api_get("order_details", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_details") or []
def run(date_from=None):
date_from = date_from if date_from is not None else ORDER_DATE_FROM
flagged = 0
for id_order in recent_order_ids(date_from):
for line in order_detail_lines(id_order):
product_quantity = int(line.get("product_quantity", 0))
product_quantity_in_stock = int(line.get("product_quantity_in_stock", 0))
product_quantity_refunded = int(line.get("product_quantity_refunded", 0))
if not is_stock_quantity_inconsistent(product_quantity, product_quantity_in_stock, product_quantity_refunded):
continue
row = build_report_row(line, id_order)
flagged += 1
log.warning(
"Inconsistent order_detail. id_order=%s id_order_detail=%s product_id=%s "
"product_quantity=%s product_quantity_in_stock=%s product_quantity_refunded=%s",
row["id_order"], row["id_order_detail"], row["product_id"],
row["product_quantity"], row["product_quantity_in_stock"], product_quantity_refunded,
)
log.info(
"Done. %d order_detail row(s) flagged for review. DRY_RUN=%s (this script never writes to order_details).",
flagged, DRY_RUN,
)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop order_detail rows where the stock snapshot disagrees with the order.
*
* Every order_detail row carries product_quantity, what was actually ordered, and
* product_quantity_in_stock, a snapshot computed separately at order-save time by
* Product::getQuantity() and the stock logic, meant to record whether the item was in
* stock when ordered. Because product_quantity_in_stock is computed rather than copied
* from product_quantity, regressions in that computation (see PrestaShop GitHub issue
* #16840) and edge cases like disabled stock management, advanced stock management,
* backorders, or partial refunds can leave product_quantity_in_stock at 0 while
* product_quantity still shows the real ordered amount on the same row.
*
* This script never writes to order_details. product_quantity_in_stock is a historical
* snapshot tied to real stock events at order time, so rewriting it automatically can
* hide a genuine backorder or oversell and corrupt the audit trail. It only detects
* inconsistent rows and emits a report line for a human to review. A confirmed fix is a
* targeted, manual PUT to order_details/{id} correcting product_quantity_in_stock alone,
* never a bulk automated write.
*
* Guide: https://www.allanninal.dev/prestashop/order-detail-stock-quantity-inconsistent/
*/
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 ORDER_DATE_FROM = process.env.ORDER_DATE_FROM || "";
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.
* Returns true when productQuantity is positive and productQuantityInStock does not
* equal productQuantity minus productQuantityRefunded, i.e. the in-stock snapshot
* disagrees with the net ordered quantity for that line.
*/
export function isStockQuantityInconsistent(productQuantity, productQuantityInStock, productQuantityRefunded = 0) {
if (productQuantity <= 0) return false;
return productQuantityInStock !== (productQuantity - productQuantityRefunded);
}
function buildReportRow(orderDetail, idOrder) {
return {
id_order: idOrder,
id_order_detail: orderDetail.id,
product_id: orderDetail.product_id,
product_quantity: Number(orderDetail.product_quantity || 0),
product_quantity_in_stock: Number(orderDetail.product_quantity_in_stock || 0),
};
}
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 recentOrderIds(dateFrom) {
const params = { display: "full" };
if (dateFrom) params["filter[date_add]"] = `${dateFrom},`;
const data = await apiGet("orders", params);
const orders = data.orders || [];
return orders.map((o) => o.id);
}
async function orderDetailLines(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
export async function run(dateFrom = ORDER_DATE_FROM) {
let flagged = 0;
for (const idOrder of await recentOrderIds(dateFrom)) {
for (const line of await orderDetailLines(idOrder)) {
const productQuantity = Number(line.product_quantity || 0);
const productQuantityInStock = Number(line.product_quantity_in_stock || 0);
const productQuantityRefunded = Number(line.product_quantity_refunded || 0);
if (!isStockQuantityInconsistent(productQuantity, productQuantityInStock, productQuantityRefunded)) continue;
const row = buildReportRow(line, idOrder);
flagged++;
console.warn(
`Inconsistent order_detail. id_order=${row.id_order} id_order_detail=${row.id_order_detail} ` +
`product_id=${row.product_id} product_quantity=${row.product_quantity} ` +
`product_quantity_in_stock=${row.product_quantity_in_stock} product_quantity_refunded=${productQuantityRefunded}`
);
}
}
console.log(
`Done. ${flagged} order_detail row(s) flagged for review. DRY_RUN=${DRY_RUN} (this script never writes to order_details).`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The comparison is the part most worth testing, because it decides which rows get reported. Because we kept is_stock_quantity_inconsistent pure, the test needs no network and no PrestaShop store. It just feeds in plain integers and checks the answer.
from check_order_detail_stock import is_stock_quantity_inconsistent
def test_ordered_one_but_in_stock_zero_is_inconsistent():
assert is_stock_quantity_inconsistent(1, 0) is True
def test_ordered_one_and_in_stock_one_is_consistent():
assert is_stock_quantity_inconsistent(1, 1) is False
def test_ordered_two_one_refunded_in_stock_one_is_consistent():
assert is_stock_quantity_inconsistent(2, 1, 1) is False
def test_ordered_two_one_refunded_in_stock_zero_is_inconsistent():
assert is_stock_quantity_inconsistent(2, 0, 1) is True
def test_zero_quantity_ordered_is_never_inconsistent():
assert is_stock_quantity_inconsistent(0, 0) is False
def test_negative_quantity_ordered_is_never_inconsistent():
assert is_stock_quantity_inconsistent(-1, 0) is False
def test_fully_refunded_line_matching_in_stock_is_consistent():
assert is_stock_quantity_inconsistent(3, 0, 3) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStockQuantityInconsistent } from "./check-order-detail-stock.js";
test("ordered one but in stock zero is inconsistent", () => {
assert.equal(isStockQuantityInconsistent(1, 0), true);
});
test("ordered one and in stock one is consistent", () => {
assert.equal(isStockQuantityInconsistent(1, 1), false);
});
test("ordered two, one refunded, in stock one is consistent", () => {
assert.equal(isStockQuantityInconsistent(2, 1, 1), false);
});
test("ordered two, one refunded, in stock zero is inconsistent", () => {
assert.equal(isStockQuantityInconsistent(2, 0, 1), true);
});
test("zero quantity ordered is never inconsistent", () => {
assert.equal(isStockQuantityInconsistent(0, 0), false);
});
test("negative quantity ordered is never inconsistent", () => {
assert.equal(isStockQuantityInconsistent(-1, 0), false);
});
test("fully refunded line matching in stock is consistent", () => {
assert.equal(isStockQuantityInconsistent(3, 0, 3), false);
});
Case studies
The refund that never squared up
A support team kept getting escalations about an order that "looked wrong" in an inventory dashboard built on top of the webservice. The order had been partially refunded, and product_quantity_refunded had been recorded correctly, but product_quantity_in_stock was still sitting at the pre-refund figure, so the dashboard's own math disagreed with what the order actually said.
Running the diagnostic across the last month of orders surfaced the exact row, along with a handful of others from the same refund workflow. Support flagged them for the merchant to review by hand rather than guessing at a bulk fix, and the dashboard vendor added the same check upstream.
The upgrade that reintroduced a known regression
A store upgraded core and started seeing zeroed product_quantity_in_stock values on new orders again, a pattern that matched a previously fixed regression around the extra cart context in the stock computation. Because nothing in the admin surfaced the discrepancy on its own, it went unnoticed for weeks.
The team scheduled the diagnostic to run nightly against new orders. The first run flagged every affected line since the upgrade, giving them a clear list to hand to their developer alongside the exact GitHub issue the regression matched, instead of trying to reconstruct which orders were affected from memory.
After this runs on a schedule, every order_detail row you check has been independently compared against the ordered quantity it should match. Nothing gets silently rewritten. Instead you get a clear, dated report of exactly which rows disagree, so a human can decide whether it is a genuine bug worth a targeted fix or a real backorder event worth leaving alone, and the audit trail stays intact either way.
FAQ
Why does product_quantity_in_stock not match product_quantity on the same order line?
product_quantity is simply what was ordered on that line. product_quantity_in_stock is a separate snapshot computed at order-save time by its own stock logic, meant to record whether the item was in stock when ordered. Because it is computed rather than copied from product_quantity, regressions in that computation and edge cases like disabled stock management, advanced stock management, backorders, or partial refunds can leave it at 0 while product_quantity still shows the real ordered amount.
Is it safe to overwrite product_quantity_in_stock through the webservice?
No, not automatically. product_quantity_in_stock is a historical snapshot tied to the stock state at order time, so a bulk rewrite can hide a real backorder or oversell event and corrupt the audit trail. The safe pattern is to flag the row for a human, and only after that person verifies the true stock state at order time does a targeted PUT to order_details/{id} correct that one field.
How do I detect these order_detail mismatches with the webservice?
List recent orders with GET orders, then for each order call GET order_details filtered by id_order with display=full to read product_quantity, product_quantity_in_stock, and product_quantity_refunded for every line. A row is inconsistent when product_quantity is greater than zero and product_quantity_in_stock does not equal product_quantity minus product_quantity_refunded.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: OrderDetail->product_quantity_in_stock is wrong. github.com/PrestaShop/PrestaShop/issues/16840
- PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock/
- PrestaShop GitHub: Wrong quantities showing in BO for products with combinations. github.com/PrestaShop/PrestaShop/issues/12814
On the solution:
- PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
- PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables/
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
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.
Did this untangle your order numbers?
If this saved you a manual audit or a confusing support ticket, 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