Reconciler Stock & Inventory
Product quantity wrongly doubled or changed when order status changes
An order moves from Awaiting payment to Payment accepted, and the stock drops by twice the line quantity. Or a Cancelled order gets reverted, and instead of restoring the stock it had before, PrestaShop adds even more. Nothing about your catalog changed, but the numbers in stock_available no longer match what actually shipped. Here is why PrestaShop's own status engine does this and a small script that finds the affected products and repairs them safely.
PrestaShop's OrderHistory::changeIdOrderState() drives every stock change off the transition between the order's old state and the new one, and it calls StockAvailable::updateQuantity() with a signed delta each time. If that transition is applied twice, such as a webservice PUT to order_histories re-adding the same id_order_state, PrestaShop writes a duplicate history row and repeats the same stock adjustment. Reverting a status is treated the same way, as a fresh transition, so it adjusts stock again instead of restoring the original value. Pull the order's status timeline, re-derive what the stock should be by walking that timeline yourself, diff it against the live value, and only apply a confirmed, dry run guarded correction. Full code, tests, and citations are below.
The problem in plain words
Every time an order's status changes in PrestaShop, a new row goes into order_histories and the engine compares the state you left against the state you are entering. If that comparison crosses from "not logable" to "logable" (roughly: the order now counts as a real sale), stock goes down by the order line's quantity. If it crosses back the other way, stock goes up. That part works as designed.
The trouble is that PrestaShop's engine has no memory of what the stock was before a given transition. It only knows the sign of the difference between the old state's flags and the new state's flags. So if the same target state gets applied a second time, whether through a webservice call that fires the state change twice or an admin action racing with an API call, PrestaShop happily runs the same signed adjustment again. A single Payment accepted transition that should remove 3 units removes 6. A Cancelled order reverted back to Awaiting payment does not restore the pre-cancellation quantity, it just adds the line quantity on top of whatever is already there. This is a documented, reproduced defect in PrestaShop itself, not a store misconfiguration; see the GitHub issues in the citations.
Why it happens
PrestaShop drives stock side effects entirely off comparing order_states flags (logable, shipped, paid) between the order's previous state and its new one, inside OrderHistory::changeIdOrderState(), then calls StockAvailable::updateQuantity() with a signed quantity. A few concrete ways this goes wrong in real stores:
- A webservice integration PUTs to
order_historiesto set a newid_order_state, the request is retried after a timeout, and PrestaShop records the same state change twice, running the stock decrement twice (PrestaShop issue #22011). - An admin employee clicks the status dropdown while an automated sync is also pushing the same status, and both fire the change hook (PrestaShop issue #36024).
- A status is reverted, for example Cancelled back to Awaiting payment, and PrestaShop treats it purely as a new logable-flag transition, incrementing stock again instead of restoring the pre-cancellation quantity.
- Out of stock handling interacts with a state change on the same request, and the two writes to
stock_availables.quantityland in an order that compounds rather than cancels out (PrestaShop issue #27631).
None of this shows up as an error. The order looks fine, the status history looks fine at a glance, and the only symptom is that stock_availables.quantity for one or two products slowly drifts away from what you actually shipped.
The bug is not that PrestaShop calculates the wrong delta. It calculates the right delta for the transition it sees. The bug is that it has no idea whether it already applied that exact transition before. So the fix is not to change PrestaShop's logic, it is to keep our own memory of which state transitions we have already accounted for, replay the order's full history ourselves, and compare our expected total against the live value before touching anything.
The fix, as a flow
We do not patch PrestaShop's stock engine. We read the order's full status timeline, the order lines, the order state flags, and the current stock, independently recompute what stock should be by walking the same signed deltas PrestaShop would apply, and only report or repair when the live value disagrees with our recomputed expectation.
Build it step by step
Get a webservice key
In your PrestaShop admin, go to Advanced Parameters, Webservice, and create a key with access to order_histories, order_details, order_states, and stock_availables (read for the first three, read and write for the last). Keep the shop URL and the key in environment variables, never in the file. Authentication is HTTP Basic with the key as the username and a blank password.
pip install requests
export PRESTASHOP_URL="https://yourstore.example.com"
export PRESTASHOP_WS_KEY="YOURWEBSERVICEKEY"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://yourstore.example.com"
export PRESTASHOP_WS_KEY="YOURWEBSERVICEKEY"
export DRY_RUN="true" // start safe, change to false to write
Talk to the webservice API
Every call goes to {PRESTASHOP_URL}/api/<resource> with Basic auth (key as username, blank password) and ?output_format=JSON so PrestaShop returns JSON instead of its default XML. A small helper wraps GET and PUT and raises on a non-2xx response.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, body):
r = requests.put(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";
function authHeader() {
const token = Buffer.from(`${WS_KEY}:`).toString("base64");
return { Authorization: `Basic ${token}` };
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, { headers: authHeader() });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiPut(path, body) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "PUT",
headers: { ...authHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
Pull the order's status timeline and lines
For a suspect order, fetch its full order_histories timeline sorted by date, its order_details lines for the product quantities, and the order_states flags (logable, shipped) each state carries. Watch for the same id_order_state showing up in two rows within a short window, that is the duplicate-history signature from issue #22011.
def order_history(id_order):
data = api_get("order_histories", {"filter[id_order]": id_order, "display": "full"})
rows = data.get("order_histories") or []
return sorted(rows, key=lambda r: (r.get("date_add") or "", int(r.get("id", 0))))
def order_lines(id_order):
data = api_get("order_details", {"filter[id_order]": id_order, "display": "full"})
return data.get("order_details") or []
def state_flags(id_order_state):
data = api_get(f"order_states/{id_order_state}")
s = data["order_state"]
return {"id": int(id_order_state), "logable": s.get("logable") == "1", "shipped": s.get("shipped") == "1"}
async function orderHistory(idOrder) {
const data = await apiGet("order_histories", { "filter[id_order]": idOrder, display: "full" });
const rows = data.order_histories || [];
return rows.sort((a, b) => (a.date_add || "").localeCompare(b.date_add || "") || Number(a.id) - Number(b.id));
}
async function orderLines(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
async function stateFlags(idOrderState) {
const data = await apiGet(`order_states/${idOrderState}`);
const s = data.order_state;
return { id: Number(idOrderState), logable: s.logable === "1", shipped: s.shipped === "1" };
}
Decide the delta for one transition, with one pure function
This is the exact decision PrestaShop's engine should make for a single transition, isolated from any database call so it can be unit tested directly. It takes the flags of the state you left and the state you are entering, the line quantity, the list of state ids already applied for this order, and the candidate state id. A transition into a state already seen contributes nothing, since applying it again would be the duplicate bug. Otherwise the sign follows whether the order is becoming logable or leaving it.
def expected_stock_delta(from_state, to_state, line_quantity, applied_state_ids_seen, candidate_state_id):
if candidate_state_id in applied_state_ids_seen:
return 0
if not from_state["logable"] and to_state["logable"]:
return -line_quantity
if from_state["logable"] and not to_state["logable"]:
return line_quantity
return 0
export function expectedStockDelta(fromState, toState, lineQuantity, appliedStateIdsSeen, candidateStateId) {
if (appliedStateIdsSeen.includes(candidateStateId)) return 0;
if (!fromState.logable && toState.logable) return lineQuantity === 0 ? 0 : -lineQuantity;
if (fromState.logable && !toState.logable) return lineQuantity;
return 0;
}
Walk the whole timeline and diff against live stock
Starting from a neutral non-logable state, replay every history row through expected_stock_delta, tracking which state ids you have already applied so a duplicate row contributes zero. Sum the deltas per product and combination, then read the live stock_availables quantity and compare. Any mismatch, especially one that is an exact multiple of a line quantity, is a corrupted-stock candidate.
def stock_available(id_product, id_product_attribute):
data = api_get("stock_availables", {
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute or 0,
"display": "full",
})
rows = data.get("stock_availables") or []
return rows[0] if rows else None
def replay_expected_delta(history_rows, line_quantity):
seen = []
total = 0
from_state = {"id": 0, "logable": False, "shipped": False}
for row in history_rows:
to_id = int(row["id_order_state"])
to_state = state_flags(to_id)
total += expected_stock_delta(from_state, to_state, line_quantity, seen, to_id)
seen.append(to_id)
from_state = to_state
return total
async function stockAvailable(idProduct, idProductAttribute) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute || 0,
display: "full",
});
const rows = data.stock_availables || [];
return rows[0] || null;
}
async function replayExpectedDelta(historyRows, lineQuantity) {
const seen = [];
let total = 0;
let fromState = { id: 0, logable: false, shipped: false };
for (const row of historyRows) {
const toId = Number(row.id_order_state);
const toState = await stateFlags(toId);
total += expectedStockDelta(fromState, toState, lineQuantity, seen, toId);
seen.push(toId);
fromState = toState;
}
return total;
}
Report first, repair only with a dry run guard
Do not blind-write a corrected quantity. Other orders on the same product may have sold or restocked between the corruption and the moment you detect it, so a naive overwrite can clobber legitimate stock movement. Emit one record per order, product, and combination with the observed quantity, the expected quantity, and the delta, and require a human to confirm before any write happens. When authorized, re-read the resource immediately before writing so the correction is based on the latest quantity, then PUT quantity = observed_quantity - delta and log the before and after values.
Always start with DRY_RUN=true. Review the flagged mismatches, confirm them by hand, and only then let the script write a compensating stock_availables update. It always re-reads the resource right before writing to base the correction on the latest quantity, since another order may have touched the same stock in between.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, replays each order's status history against the pure decision function, flags any mismatch against live stock, and only writes a compensating change when a human has confirmed it and DRY_RUN is off.
"""Flag and, once confirmed, repair PrestaShop stock corrupted by a duplicate
or reverted order status change.
OrderHistory::changeIdOrderState() applies a signed stock delta for every state
transition it sees, with no memory of transitions it already applied. A duplicate
order_histories row for the same target state, or a reverted status, makes it
apply the same delta again. This script independently replays an order's status
timeline with a pure decision function, diffs the expected quantity against the
live stock_availables value, and reports a record per mismatch. It only writes a
compensating correction when DRY_RUN is false, and it re-reads stock right before
writing so the correction is based on the latest quantity.
Guide: https://www.allanninal.dev/prestashop/stock-quantity-corrupted-on-status-change/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_stock")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
NEUTRAL_STATE = {"id": 0, "logable": False, "shipped": False}
def expected_stock_delta(from_state, to_state, line_quantity, applied_state_ids_seen, candidate_state_id):
if candidate_state_id in applied_state_ids_seen:
return 0
if not from_state["logable"] and to_state["logable"]:
return -line_quantity
if from_state["logable"] and not to_state["logable"]:
return line_quantity
return 0
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, body):
r = requests.put(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
def order_history(id_order):
data = api_get("order_histories", {"filter[id_order]": id_order, "display": "full"})
rows = data.get("order_histories") or []
return sorted(rows, key=lambda r: (r.get("date_add") or "", int(r.get("id", 0))))
def order_lines(id_order):
data = api_get("order_details", {"filter[id_order]": id_order, "display": "full"})
return data.get("order_details") or []
def state_flags(id_order_state):
data = api_get(f"order_states/{id_order_state}")
s = data["order_state"]
return {"id": int(id_order_state), "logable": s.get("logable") == "1", "shipped": s.get("shipped") == "1"}
def stock_available(id_product, id_product_attribute):
data = api_get("stock_availables", {
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute or 0,
"display": "full",
})
rows = data.get("stock_availables") or []
return rows[0] if rows else None
def replay_expected_delta(history_rows, line_quantity, state_flags_fn=state_flags):
seen = []
total = 0
from_state = dict(NEUTRAL_STATE)
for row in history_rows:
to_id = int(row["id_order_state"])
to_state = state_flags_fn(to_id)
total += expected_stock_delta(from_state, to_state, line_quantity, seen, to_id)
seen.append(to_id)
from_state = to_state
return total
def reconcile_order(id_order):
history_rows = order_history(id_order)
findings = []
for line in order_lines(id_order):
id_product = int(line["id_product"])
id_product_attribute = int(line.get("id_product_attribute") or 0)
line_quantity = int(line["product_quantity"])
expected_delta = replay_expected_delta(history_rows, line_quantity)
stock = stock_available(id_product, id_product_attribute)
if stock is None:
continue
observed_quantity = int(stock["quantity"])
expected_quantity = observed_quantity - expected_delta if expected_delta == 0 else None
duplicate_ids = duplicate_history_ids(history_rows)
if expected_delta == 0 and not duplicate_ids:
continue
findings.append({
"id_order": id_order,
"id_product": id_product,
"id_product_attribute": id_product_attribute,
"id_stock_available": int(stock["id"]),
"observed_quantity": observed_quantity,
"expected_delta": expected_delta,
"duplicate_order_histories_ids": duplicate_ids,
})
return findings
def duplicate_history_ids(history_rows):
seen_state_at = {}
duplicates = []
for row in history_rows:
state_id = int(row["id_order_state"])
row_id = int(row["id"])
if state_id in seen_state_at:
duplicates.append(row_id)
else:
seen_state_at[state_id] = row_id
return duplicates
def apply_correction(finding):
"""Compensating write. Only called when DRY_RUN is false and a human confirmed."""
fresh = stock_available(finding["id_product"], finding["id_product_attribute"])
if fresh is None:
raise RuntimeError("stock_availables row disappeared before write")
before = int(fresh["quantity"])
after = before - finding["expected_delta"]
fresh["quantity"] = str(after)
api_put(f"stock_availables/{finding['id_stock_available']}", {"stock_available": fresh})
log.info("Corrected stock_availables %s: %d -> %d", finding["id_stock_available"], before, after)
def run(order_ids):
all_findings = []
for id_order in order_ids:
findings = reconcile_order(id_order)
for finding in findings:
log.warning(
"Order %s product %s (attr %s): observed=%d expected_delta=%d duplicates=%s",
finding["id_order"], finding["id_product"], finding["id_product_attribute"],
finding["observed_quantity"], finding["expected_delta"],
finding["duplicate_order_histories_ids"],
)
all_findings.extend(findings)
if not DRY_RUN:
for finding in all_findings:
apply_correction(finding)
log.info("Done. %d finding(s) %s.", len(all_findings), "to review" if DRY_RUN else "corrected")
return all_findings
if __name__ == "__main__":
ids = [int(x) for x in os.environ.get("ORDER_IDS", "").split(",") if x.strip()]
run(ids)
/**
* Flag and, once confirmed, repair PrestaShop stock corrupted by a duplicate
* or reverted order status change.
*
* OrderHistory::changeIdOrderState() applies a signed stock delta for every state
* transition it sees, with no memory of transitions it already applied. A duplicate
* order_histories row for the same target state, or a reverted status, makes it
* apply the same delta again. This script independently replays an order's status
* timeline with a pure decision function, diffs the expected quantity against the
* live stock_availables value, and reports a record per mismatch. It only writes a
* compensating correction when DRY_RUN is false, and it re-reads stock right before
* writing so the correction is based on the latest quantity.
*
* Guide: https://www.allanninal.dev/prestashop/stock-quantity-corrupted-on-status-change/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://example-store.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "DUMMYKEY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const NEUTRAL_STATE = { id: 0, logable: false, shipped: false };
export function expectedStockDelta(fromState, toState, lineQuantity, appliedStateIdsSeen, candidateStateId) {
if (appliedStateIdsSeen.includes(candidateStateId)) return 0;
if (!fromState.logable && toState.logable) return lineQuantity === 0 ? 0 : -lineQuantity;
if (fromState.logable && !toState.logable) return lineQuantity;
return 0;
}
function authHeader() {
const token = Buffer.from(`${WS_KEY}:`).toString("base64");
return { Authorization: `Basic ${token}` };
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, { headers: authHeader() });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiPut(path, body) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "PUT",
headers: { ...authHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function orderHistory(idOrder) {
const data = await apiGet("order_histories", { "filter[id_order]": idOrder, display: "full" });
const rows = data.order_histories || [];
return rows.sort((a, b) => (a.date_add || "").localeCompare(b.date_add || "") || Number(a.id) - Number(b.id));
}
async function orderLines(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
async function stateFlags(idOrderState) {
const data = await apiGet(`order_states/${idOrderState}`);
const s = data.order_state;
return { id: Number(idOrderState), logable: s.logable === "1", shipped: s.shipped === "1" };
}
async function stockAvailable(idProduct, idProductAttribute) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute || 0,
display: "full",
});
const rows = data.stock_availables || [];
return rows[0] || null;
}
export async function replayExpectedDelta(historyRows, lineQuantity, stateFlagsFn = stateFlags) {
const seen = [];
let total = 0;
let fromState = { ...NEUTRAL_STATE };
for (const row of historyRows) {
const toId = Number(row.id_order_state);
const toState = await stateFlagsFn(toId);
total += expectedStockDelta(fromState, toState, lineQuantity, seen, toId);
seen.push(toId);
fromState = toState;
}
return total;
}
export function duplicateHistoryIds(historyRows) {
const seenStateAt = new Map();
const duplicates = [];
for (const row of historyRows) {
const stateId = Number(row.id_order_state);
const rowId = Number(row.id);
if (seenStateAt.has(stateId)) {
duplicates.push(rowId);
} else {
seenStateAt.set(stateId, rowId);
}
}
return duplicates;
}
async function reconcileOrder(idOrder) {
const historyRows = await orderHistory(idOrder);
const findings = [];
for (const line of await orderLines(idOrder)) {
const idProduct = Number(line.id_product);
const idProductAttribute = Number(line.id_product_attribute || 0);
const lineQuantity = Number(line.product_quantity);
const expectedDelta = await replayExpectedDelta(historyRows, lineQuantity);
const stock = await stockAvailable(idProduct, idProductAttribute);
if (!stock) continue;
const observedQuantity = Number(stock.quantity);
const duplicateIds = duplicateHistoryIds(historyRows);
if (expectedDelta === 0 && duplicateIds.length === 0) continue;
findings.push({
idOrder,
idProduct,
idProductAttribute,
idStockAvailable: Number(stock.id),
observedQuantity,
expectedDelta,
duplicateOrderHistoriesIds: duplicateIds,
});
}
return findings;
}
async function applyCorrection(finding) {
const fresh = await stockAvailable(finding.idProduct, finding.idProductAttribute);
if (!fresh) throw new Error("stock_availables row disappeared before write");
const before = Number(fresh.quantity);
const after = before - finding.expectedDelta;
fresh.quantity = String(after);
await apiPut(`stock_availables/${finding.idStockAvailable}`, { stock_available: fresh });
console.log(`Corrected stock_availables ${finding.idStockAvailable}: ${before} -> ${after}`);
}
export async function run(orderIds) {
const allFindings = [];
for (const idOrder of orderIds) {
const findings = await reconcileOrder(idOrder);
for (const finding of findings) {
console.warn(
`Order ${finding.idOrder} product ${finding.idProduct} (attr ${finding.idProductAttribute}): ` +
`observed=${finding.observedQuantity} expected_delta=${finding.expectedDelta} ` +
`duplicates=${JSON.stringify(finding.duplicateOrderHistoriesIds)}`
);
}
allFindings.push(...findings);
}
if (!DRY_RUN) {
for (const finding of allFindings) {
await applyCorrection(finding);
}
}
console.log(`Done. ${allFindings.length} finding(s) ${DRY_RUN ? "to review" : "corrected"}.`);
return allFindings;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const ids = (process.env.ORDER_IDS || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number);
run(ids).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The part worth testing is the pure decision function, since it decides whether a transition should move stock at all. Because expected_stock_delta takes plain values and returns a number, the test needs no network and no PrestaShop store. It just feeds in state flags and checks the answer.
from reconcile_stock import expected_stock_delta
NOT_LOGABLE = {"id": 1, "logable": False, "shipped": False}
LOGABLE = {"id": 2, "logable": True, "shipped": False}
def test_becoming_logable_decrements_stock():
assert expected_stock_delta(NOT_LOGABLE, LOGABLE, 3, [], 2) == -3
def test_leaving_logable_restocks():
assert expected_stock_delta(LOGABLE, NOT_LOGABLE, 3, [2], 1) == 3
def test_non_logable_to_non_logable_is_a_no_op():
other_not_logable = {"id": 3, "logable": False, "shipped": False}
assert expected_stock_delta(NOT_LOGABLE, other_not_logable, 3, [], 3) == 0
def test_duplicate_transition_to_same_state_is_a_no_op():
assert expected_stock_delta(NOT_LOGABLE, LOGABLE, 3, [2], 2) == 0
def test_logable_to_logable_is_a_no_op():
other_logable = {"id": 4, "logable": True, "shipped": True}
assert expected_stock_delta(LOGABLE, other_logable, 3, [2], 4) == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { expectedStockDelta } from "./reconcile-stock.js";
const NOT_LOGABLE = { id: 1, logable: false, shipped: false };
const LOGABLE = { id: 2, logable: true, shipped: false };
test("becoming logable decrements stock", () => {
assert.equal(expectedStockDelta(NOT_LOGABLE, LOGABLE, 3, [], 2), -3);
});
test("leaving logable restocks", () => {
assert.equal(expectedStockDelta(LOGABLE, NOT_LOGABLE, 3, [2], 1), 3);
});
test("non logable to non logable is a no-op", () => {
const otherNotLogable = { id: 3, logable: false, shipped: false };
assert.equal(expectedStockDelta(NOT_LOGABLE, otherNotLogable, 3, [], 3), 0);
});
test("duplicate transition to same state is a no-op", () => {
assert.equal(expectedStockDelta(NOT_LOGABLE, LOGABLE, 3, [2], 2), 0);
});
test("logable to logable is a no-op", () => {
const otherLogable = { id: 4, logable: true, shipped: true };
assert.equal(expectedStockDelta(LOGABLE, otherLogable, 3, [2], 4), 0);
});
Case studies
The fulfillment integration that doubled every decrement
A store synced order status from a third party fulfillment tool through the webservice, PUTing the same id_order_state to order_histories whenever the tool's own retry logic fired after a slow response. Every retried call created a second history row for a status that was already applied, and stock for the bestselling products quietly dropped twice as fast as real sales.
The team ran the reconciler against the last month of orders in dry run, saw the flagged products where the observed quantity was short by an exact multiple of a line quantity, confirmed the duplicate history rows against the fulfillment tool's own logs, then let a guarded write correct the stock without touching orders that had genuinely sold in between.
The reopened order that kept adding stock back
Customer service routinely cancelled and then reopened orders when a customer changed their mind about an item, moving the status from Awaiting payment to Cancelled and back again. Each reopen was read by PrestaShop as a fresh transition out of a logable-adjacent path, so stock crept upward every time an order bounced back and forth, and the catalog started showing more stock than was physically on the shelf.
Walking the order's full history with the pure decision function showed exactly which reopen contributed a real restock and which one was a repeat of a transition already counted. The store used the flagged report to true up the affected SKUs once, then kept the reconciler running weekly to catch the pattern early.
After this runs regularly, a duplicate or reverted status change no longer silently drifts your stock. The reconciler catches the mismatch, shows you exactly which duplicate history rows caused it, and only ever writes a correction after a human has confirmed it and re-reads the resource first so it never clobbers a sale that happened in between. Stock finally means what it says.
FAQ
Why did my PrestaShop stock quantity double after an order status change?
PrestaShop's OrderHistory::changeIdOrderState() applies a signed stock adjustment every time it sees a transition into or out of a logable order state. If the same target state is applied twice, for example a webservice PUT that re-adds the same id_order_state, PrestaShop creates a duplicate order_histories row and re-runs the same stock adjustment a second time, doubling the decrement or increment.
Why did reverting an order status increase stock instead of restoring it?
Reverting a status, such as moving Cancelled back to Awaiting payment, is treated as a brand new logable-state transition. PrestaShop only knows the sign of the state pair difference, not the original quantity before the first change, so it increments stock again rather than restoring the value it had before the transition happened.
Is it safe to auto correct PrestaShop stock quantity after this bug?
Not by blindly overwriting it. Other orders on the same product may have sold or restocked between the corruption and the moment you detect it, so a naive overwrite can clobber legitimate stock movement. Flag the mismatch, require a human to confirm the expected value, and only then write a compensating change with a dry run guard.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Product Physical quantity is wrongly increased when Order status is changed, issue #36024. github.com/PrestaShop/PrestaShop/issues/36024
- PrestaShop GitHub: when the order status is changed in the webservice, the OrderHistory instance is duplicated, issue #22011. github.com/PrestaShop/PrestaShop/issues/22011
- PrestaShop GitHub: ps_stock_available updated wrongly on order when products out of stock, issue #27631. github.com/PrestaShop/PrestaShop/issues/27631
On the solution:
- PrestaShop Developer Documentation: the
order_historieswebservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_histories - PrestaShop Developer Documentation: the
order_stateswebservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_states - PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock
Stuck on a tricky one?
If you have a problem in PrestaShop orders, stock, catalog, 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 stock numbers?
If this saved you a pile of manual recounts or a wrong inventory report, 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