Reconciler Orders & Order States
Order gets stuck permanently on one status with no history advancing
An order has been sitting on "Awaiting payment" or "Processing" for two weeks, and nobody moved it. The order looks untouched, but so does its history. Here is why PrestaShop can leave orders.current_state and the order_history table disagreeing with each other, and a small script that tells apart a truly stuck order from a hidden desync, then repairs it the one safe way.
PrestaShop keeps order status in two places: the denormalized current_state column on the order, and the append-only order_history table that core advances through Order::setCurrentState(). A webservice PUT to the orders resource can set current_state in the payload without reliably going through that method, so order_history never gets a new row. Run a script that polls in-progress orders, compares each one's last update against today, and cross-checks the newest order_histories row against the order's own current_state. Where they agree and both are old, flag the order as genuinely stuck. Where they disagree, that is the desync bug itself. Repair only happens by posting a corrective row to order_histories, and only when a human has confirmed the real state. Full code, tests, and a dry run guard are below.
The problem in plain words
PrestaShop does not store an order's status as one single fact. There is a current_state column on the order itself, a fast cached answer for "what state is this order in right now." And there is the order_history table, an append-only log of every state the order has ever passed through, exposed on the webservice as order_histories. Core code keeps these two in step by calling Order::setCurrentState(), which both writes current_state and inserts a new order_history row, together, in the same operation.
The webservice lets an integrator PUT the orders resource directly and include current_state in the body. That request can return 200 as if it worked. But that path does not reliably run through setCurrentState(). Depending on the version, it has either silently done nothing to the stored state, or thrown a database error on the id_employee column partway through the history insert. Either way, order_history never gets a new row. Any script or report that reads the last transition from order_history sees a status frozen at an implausible age, even though the request that supposedly changed it "succeeded."
Why it happens
The core assumption in PrestaShop is that status always changes through one method, which touches both tables at once. A few recurring ways integrations break that assumption:
- An integration PUTs the
ordersresource with a newcurrent_statevalue, expecting it to behave like changing the state from the backoffice screen, but the webservice write path for that resource does not reliably callsetCurrentState()orOrderHistory::addWithemail(). - On some PrestaShop versions, that same PUT throws a database error tied to the
id_employeecolumn while it is trying to insert the history row, so the write fails partway through andorder_historyis left exactly as it was. - A retrying integration sees the 200 response, assumes the state changed, and never checks back, so a genuinely failed transition goes unnoticed for weeks.
- A report or dashboard reads only
orders.current_stateand never cross-checksorder_histories, so it cannot tell a real, current status from one that silently failed to update.
This is a documented, recurring pattern rather than a one-off glitch. PrestaShop's own core issue tracker has multiple reports of the webservice failing to advance order history correctly on a PUT to orders, including duplicate history rows and outright failed writes. See the citations at the end for the exact reports.
Do not trust orders.current_state alone, and do not write to it directly either. The authoritative record of what actually happened to an order is order_history, an append-only log, not the cached column. Comparing the two tells you something a single read cannot: if current_state and the newest order_histories row agree and both are old, the order is genuinely stuck and needs a human decision. If they disagree, that gap is the desync bug itself, not a stall, and the fix is the same either way: never PUT current_state, only ever POST a new row to order_histories.
The fix, as a flow
We never PUT the order's current_state. Instead the script polls orders sitting on an in-progress state, flags the ones that have gone stale, and produces a dry run report by default. Only when an operator has separately confirmed the real state, for example that a payment gateway shows the charge as captured, does the script post a corrective order_histories row, which is the same mechanism a normal state change in the backoffice uses.
Build it step by step
Get a webservice key with the right permissions
In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to orders, order_histories, and order_states, plus write access to order_histories for the repair step. 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 DRY_RUN="true" # start safe, change to false to write
// 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
Build the terminal state set from order_states
Do not hardcode which state ids count as "finished." Pull the full order_states list and build the terminal set from the shop's own configuration, states like Delivered, Canceled, Refunded, or Payment error. An order sitting on one of these is done, not stuck.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
TERMINAL_STATE_NAMES = {"delivered", "canceled", "cancelled", "refunded", "payment error"}
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 terminal_state_ids():
data = api_get("order_states", {"display": "full"})
states = data.get("order_states") or []
ids = set()
for s in states:
name = str(s.get("name") or "").strip().lower()
if name in TERMINAL_STATE_NAMES or str(s.get("shipped")) in ("1", "true", "True"):
ids.add(int(s["id"]))
return ids
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";
const TERMINAL_STATE_NAMES = new Set(["delivered", "canceled", "cancelled", "refunded", "payment error"]);
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 terminalStateIds() {
const data = await apiGet("order_states", { display: "full" });
const states = data.order_states || [];
const ids = new Set();
for (const s of states) {
const name = String(s.name || "").trim().toLowerCase();
if (TERMINAL_STATE_NAMES.has(name) || String(s.shipped) === "1" || s.shipped === true) {
ids.add(Number(s.id));
}
}
return ids;
}
Poll in-progress orders and their history
For each in-progress state, such as Awaiting payment, Processing, or Preparation, list orders currently on it, then read back each order's own order_histories rows sorted newest first. The first row is the latest known transition, independent of what the cached current_state column says.
def orders_in_state(id_order_state):
data = api_get("orders", {"display": "full", "filter[current_state]": id_order_state})
return data.get("orders") or []
def latest_history_row(id_order):
data = api_get("order_histories", {
"display": "full",
"filter[id_order]": id_order,
"sort": "date_add_DESC",
})
rows = data.get("order_histories") or []
return rows[0] if rows else None
async function ordersInState(idOrderState) {
const data = await apiGet("orders", { display: "full", "filter[current_state]": idOrderState });
return data.orders || [];
}
async function latestHistoryRow(idOrder) {
const data = await apiGet("order_histories", {
display: "full",
"filter[id_order]": idOrder,
sort: "date_add_DESC",
});
const rows = data.order_histories || [];
return rows[0] || null;
}
Decide, with one pure function
Keep the actual decision in its own function with no I/O. It takes the order's current_state, the id_order_state of the newest history row, the last update timestamp, the terminal state set, and a threshold, and returns a plain true or false. An order is flagged only when it is not terminal, it has been idle longer than the threshold, and the history genuinely agrees with the cached state, meaning it really has not advanced, not just that the cache failed to catch up.
from datetime import datetime
def is_order_stuck(current_state_id, last_history_state_id, last_update_iso,
now_iso, terminal_state_ids, stale_days_threshold=5):
if current_state_id in terminal_state_ids:
return False
last_dt = datetime.fromisoformat(last_update_iso)
now_dt = datetime.fromisoformat(now_iso)
days_idle = (now_dt - last_dt).days
if days_idle <= stale_days_threshold:
return False
return last_history_state_id == current_state_id
export function isOrderStuck(currentStateId, lastHistoryStateId, lastUpdateIso,
nowIso, terminalStateIds, staleDaysThreshold = 5) {
if (terminalStateIds.has(currentStateId)) return false;
const lastMs = Date.parse(lastUpdateIso);
const nowMs = Date.parse(nowIso);
const daysIdle = Math.floor((nowMs - lastMs) / 86400000);
if (daysIdle <= staleDaysThreshold) return false;
return lastHistoryStateId === currentStateId;
}
Repair only through order_histories, and only with confirmation
Never PUT orders.current_state directly. That path is the likely source of this exact bug, and it skips the email, stock, and logable hooks a real state change fires. The only supported repair is posting a corrective row to order_histories, and only for the narrow case where an operator has confirmed the true state outside the script, for example a payment gateway confirming the charge was captured.
def post_corrective_history(id_order, id_order_state, id_employee):
body = {
"order_history": {
"id_order": id_order,
"id_order_state": id_order_state,
"id_employee": id_employee,
}
}
r = requests.post(
f"{BASE_URL}/api/order_histories",
params={"output_format": "JSON", "sendemail": "0"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
async function postCorrectiveHistory(idOrder, idOrderState, idEmployee) {
const url = new URL(`${BASE_URL}/api/order_histories`);
url.searchParams.set("output_format", "JSON");
url.searchParams.set("sendemail", "0");
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, id_employee: idEmployee },
}),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
Wire it together with a dry run guard
The run loop pulls the terminal state set, walks each in-progress state, checks every order against is_order_stuck, and logs the id, days idle, and last history row for every one it flags. That report is the default output. The corrective POST only runs when DRY_RUN is false and a specific order has been approved for repair with its confirmed state, so auto-repair stays opt-in per order rather than something the script decides on its own.
Always start with DRY_RUN=true. This script never writes current_state directly and never force-advances a business-meaningful state like Shipped or Payment accepted on its own. Flag-and-report is the default. Repair is opt-in, one order at a time, once a human has confirmed the real state.
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 reports by default and only repairs the specific orders you approve.
"""Detect and safely repair PrestaShop orders stuck permanently on one status.
PrestaShop keeps order status in two places: the denormalized current_state
column on the order, and the append-only order_history table that core keeps
in step through Order::setCurrentState(). A webservice PUT to the orders
resource can set current_state in the payload without reliably calling that
method, so order_history never gets a new row and the order looks frozen.
This polls in-progress orders, builds the terminal state set from order_states
instead of hardcoding it, and flags an order as stuck only when its cached
current_state agrees with the newest order_histories row and both are older
than the stale threshold. Flag-and-report is the default. Repair only ever
posts a corrective order_histories row, and only for a specific approved
order id, never a direct write to current_state. Safe to run again and again.
"""
import os
import logging
import requests
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("order_stuck_on_stale_status")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STALE_DAYS_THRESHOLD = float(os.environ.get("STALE_DAYS_THRESHOLD", "5"))
BOT_EMPLOYEE_ID = int(os.environ.get("PRESTASHOP_BOT_EMPLOYEE_ID", "0"))
IN_PROGRESS_STATE_IDS = [
int(x) for x in os.environ.get("IN_PROGRESS_STATE_IDS", "1,2,3").split(",") if x.strip()
]
# Set to an order id and its confirmed state id to approve a single repair.
APPROVED_ORDER_ID = os.environ.get("APPROVED_ORDER_ID")
APPROVED_ORDER_STATE_ID = os.environ.get("APPROVED_ORDER_STATE_ID")
TERMINAL_STATE_NAMES = {"delivered", "canceled", "cancelled", "refunded", "payment error"}
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 terminal_state_ids():
data = api_get("order_states", {"display": "full"})
states = data.get("order_states") or []
ids = set()
for s in states:
name = str(s.get("name") or "").strip().lower()
if name in TERMINAL_STATE_NAMES or str(s.get("shipped")) in ("1", "true", "True"):
ids.add(int(s["id"]))
return ids
def orders_in_state(id_order_state):
data = api_get("orders", {"display": "full", "filter[current_state]": id_order_state})
return data.get("orders") or []
def latest_history_row(id_order):
data = api_get("order_histories", {
"display": "full",
"filter[id_order]": id_order,
"sort": "date_add_DESC",
})
rows = data.get("order_histories") or []
return rows[0] if rows else None
def is_order_stuck(current_state_id, last_history_state_id, last_update_iso,
now_iso, terminal_state_ids_set, stale_days_threshold=5):
"""
Pure decision logic (no I/O):
- current_state_id: orders.current_state from GET /api/orders/{id}
- last_history_state_id: id_order_state of the most recent row from
GET /api/order_histories?filter[id_order]={id}&sort=date_add_DESC (first row)
- last_update_iso: orders.date_upd (or the date_add of that latest history row)
- now_iso: current timestamp used by the poller
- terminal_state_ids_set: set of id_order_state values considered final
- stale_days_threshold: implausible number of days with no advancement
Returns True (flag as stuck) when:
1) current_state_id is not in terminal_state_ids_set, AND
2) days_between(last_update_iso, now_iso) > stale_days_threshold, AND
3) last_history_state_id == current_state_id
(history genuinely hasn't advanced -- distinguishes a truly stuck
order from one where order_histories moved on but the cached
orders.current_state failed to sync, which is a desync, not a stall)
"""
if current_state_id in terminal_state_ids_set:
return False
last_dt = datetime.fromisoformat(last_update_iso)
now_dt = datetime.fromisoformat(now_iso)
days_idle = (now_dt - last_dt).days
if days_idle <= stale_days_threshold:
return False
return last_history_state_id == current_state_id
def post_corrective_history(id_order, id_order_state, id_employee):
body = {
"order_history": {
"id_order": id_order,
"id_order_state": id_order_state,
"id_employee": id_employee,
}
}
r = requests.post(
f"{BASE_URL}/api/order_histories",
params={"output_format": "JSON", "sendemail": "0"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
def find_stuck_orders():
terminal_ids = terminal_state_ids()
now_iso = datetime.now(timezone.utc).isoformat()
stuck = []
for id_state in IN_PROGRESS_STATE_IDS:
for order in orders_in_state(id_state):
id_order = int(order["id"])
current_state_id = int(order.get("current_state", id_state))
last_update_iso = order.get("date_upd") or order.get("date_add")
if not last_update_iso:
continue
history_row = latest_history_row(id_order)
last_history_state_id = int(history_row["id_order_state"]) if history_row else current_state_id
if is_order_stuck(current_state_id, last_history_state_id, last_update_iso,
now_iso, terminal_ids, STALE_DAYS_THRESHOLD):
stuck.append({
"id_order": id_order,
"current_state": current_state_id,
"last_history_state": last_history_state_id,
"last_update": last_update_iso,
})
return stuck
def run():
stuck = find_stuck_orders()
for item in stuck:
log.warning(
"Order %s stuck on state %s since %s (history agrees: %s)",
item["id_order"], item["current_state"], item["last_update"],
item["last_history_state"] == item["current_state"],
)
if not DRY_RUN and APPROVED_ORDER_ID and APPROVED_ORDER_STATE_ID:
id_order = int(APPROVED_ORDER_ID)
id_state = int(APPROVED_ORDER_STATE_ID)
log.info("Repairing order %s with confirmed state %s", id_order, id_state)
post_corrective_history(id_order, id_state, BOT_EMPLOYEE_ID)
log.info("Done. %d order(s) flagged as stuck.", len(stuck))
if __name__ == "__main__":
run()
/**
* Detect and safely repair PrestaShop orders stuck permanently on one status.
*
* PrestaShop keeps order status in two places: the denormalized current_state
* column on the order, and the append-only order_history table that core keeps
* in step through Order::setCurrentState(). A webservice PUT to the orders
* resource can set current_state in the payload without reliably calling that
* method, so order_history never gets a new row and the order looks frozen.
*
* This polls in-progress orders, builds the terminal state set from order_states
* instead of hardcoding it, and flags an order as stuck only when its cached
* current_state agrees with the newest order_histories row and both are older
* than the stale threshold. Flag-and-report is the default. Repair only ever
* posts a corrective order_histories row, and only for a specific approved
* order id, never a direct write to current_state. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/order-stuck-on-stale-status/
*/
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";
const STALE_DAYS_THRESHOLD = Number(process.env.STALE_DAYS_THRESHOLD || 5);
const BOT_EMPLOYEE_ID = Number(process.env.PRESTASHOP_BOT_EMPLOYEE_ID || 0);
const IN_PROGRESS_STATE_IDS = (process.env.IN_PROGRESS_STATE_IDS || "1,2,3")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.map(Number);
// Set to an order id and its confirmed state id to approve a single repair.
const APPROVED_ORDER_ID = process.env.APPROVED_ORDER_ID;
const APPROVED_ORDER_STATE_ID = process.env.APPROVED_ORDER_STATE_ID;
const TERMINAL_STATE_NAMES = new Set(["delivered", "canceled", "cancelled", "refunded", "payment error"]);
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 terminalStateIds() {
const data = await apiGet("order_states", { display: "full" });
const states = data.order_states || [];
const ids = new Set();
for (const s of states) {
const name = String(s.name || "").trim().toLowerCase();
if (TERMINAL_STATE_NAMES.has(name) || String(s.shipped) === "1" || s.shipped === true) {
ids.add(Number(s.id));
}
}
return ids;
}
async function ordersInState(idOrderState) {
const data = await apiGet("orders", { display: "full", "filter[current_state]": idOrderState });
return data.orders || [];
}
async function latestHistoryRow(idOrder) {
const data = await apiGet("order_histories", {
display: "full",
"filter[id_order]": idOrder,
sort: "date_add_DESC",
});
const rows = data.order_histories || [];
return rows[0] || null;
}
/**
* Pure decision logic (no I/O). See the Python version's docstring for the
* full rule: an order is flagged only when it is not terminal, has been idle
* longer than the threshold, and the newest history row agrees with the
* cached current_state, meaning it genuinely has not advanced.
*/
export function isOrderStuck(currentStateId, lastHistoryStateId, lastUpdateIso,
nowIso, terminalStateIds, staleDaysThreshold = 5) {
if (terminalStateIds.has(currentStateId)) return false;
const lastMs = Date.parse(lastUpdateIso);
const nowMs = Date.parse(nowIso);
const daysIdle = Math.floor((nowMs - lastMs) / 86400000);
if (daysIdle <= staleDaysThreshold) return false;
return lastHistoryStateId === currentStateId;
}
async function postCorrectiveHistory(idOrder, idOrderState, idEmployee) {
const url = new URL(`${BASE_URL}/api/order_histories`);
url.searchParams.set("output_format", "JSON");
url.searchParams.set("sendemail", "0");
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, id_employee: idEmployee },
}),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function findStuckOrders() {
const terminalIds = await terminalStateIds();
const nowIso = new Date().toISOString();
const stuck = [];
for (const idState of IN_PROGRESS_STATE_IDS) {
for (const order of await ordersInState(idState)) {
const idOrder = Number(order.id);
const currentStateId = Number(order.current_state ?? idState);
const lastUpdateIso = order.date_upd || order.date_add;
if (!lastUpdateIso) continue;
const historyRow = await latestHistoryRow(idOrder);
const lastHistoryStateId = historyRow ? Number(historyRow.id_order_state) : currentStateId;
if (isOrderStuck(currentStateId, lastHistoryStateId, lastUpdateIso, nowIso, terminalIds, STALE_DAYS_THRESHOLD)) {
stuck.push({
id_order: idOrder,
current_state: currentStateId,
last_history_state: lastHistoryStateId,
last_update: lastUpdateIso,
});
}
}
}
return stuck;
}
export async function run() {
const stuck = await findStuckOrders();
for (const item of stuck) {
console.warn(
`Order ${item.id_order} stuck on state ${item.current_state} since ${item.last_update} (history agrees: ${item.last_history_state === item.current_state})`
);
}
if (!DRY_RUN && APPROVED_ORDER_ID && APPROVED_ORDER_STATE_ID) {
const idOrder = Number(APPROVED_ORDER_ID);
const idState = Number(APPROVED_ORDER_STATE_ID);
console.log(`Repairing order ${idOrder} with confirmed state ${idState}`);
await postCorrectiveHistory(idOrder, idState, BOT_EMPLOYEE_ID);
}
console.log(`Done. ${stuck.length} order(s) flagged as stuck.`);
}
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 orders get reported as genuinely stuck versus merely idle or desynced. Because is_order_stuck is pure and takes the current time as a parameter, the test needs no PrestaShop instance and no network. It just feeds in plain values and checks the answer.
from order_stuck_on_stale_status import is_order_stuck
TERMINAL = {5, 6, 7, 8} # Delivered, Canceled, Refunded, Payment error
NOW = "2026-07-10T00:00:00+00:00"
def test_flags_order_stuck_when_state_matches_history_and_stale():
assert is_order_stuck(2, 2, "2026-06-20T00:00:00+00:00", NOW, TERMINAL, 5) is True
def test_not_stuck_when_state_is_terminal():
assert is_order_stuck(6, 6, "2026-06-20T00:00:00+00:00", NOW, TERMINAL, 5) is False
def test_not_stuck_when_recent():
assert is_order_stuck(2, 2, "2026-07-08T00:00:00+00:00", NOW, TERMINAL, 5) is False
def test_not_stuck_when_history_disagrees_with_current_state():
# order_histories advanced to state 3 but orders.current_state is still 2:
# this is a desync, not a stall, so it should not be flagged as "stuck"
assert is_order_stuck(2, 3, "2026-06-20T00:00:00+00:00", NOW, TERMINAL, 5) is False
def test_exactly_at_threshold_is_not_flagged():
assert is_order_stuck(2, 2, "2026-07-05T00:00:00+00:00", NOW, TERMINAL, 5) is False
def test_one_day_past_threshold_is_flagged():
assert is_order_stuck(2, 2, "2026-07-04T00:00:00+00:00", NOW, TERMINAL, 5) is True
def test_custom_threshold_is_respected():
assert is_order_stuck(2, 2, "2026-07-08T00:00:00+00:00", NOW, TERMINAL, 1) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrderStuck } from "./order-stuck-on-stale-status.js";
const TERMINAL = new Set([5, 6, 7, 8]); // Delivered, Canceled, Refunded, Payment error
const NOW = "2026-07-10T00:00:00Z";
test("flags order stuck when state matches history and stale", () => {
assert.equal(isOrderStuck(2, 2, "2026-06-20T00:00:00Z", NOW, TERMINAL, 5), true);
});
test("not stuck when state is terminal", () => {
assert.equal(isOrderStuck(6, 6, "2026-06-20T00:00:00Z", NOW, TERMINAL, 5), false);
});
test("not stuck when recent", () => {
assert.equal(isOrderStuck(2, 2, "2026-07-08T00:00:00Z", NOW, TERMINAL, 5), false);
});
test("not stuck when history disagrees with current_state (desync, not a stall)", () => {
assert.equal(isOrderStuck(2, 3, "2026-06-20T00:00:00Z", NOW, TERMINAL, 5), false);
});
test("exactly at threshold is not flagged", () => {
assert.equal(isOrderStuck(2, 2, "2026-07-05T00:00:00Z", NOW, TERMINAL, 5), false);
});
test("one day past threshold is flagged", () => {
assert.equal(isOrderStuck(2, 2, "2026-07-04T00:00:00Z", NOW, TERMINAL, 5), true);
});
test("custom threshold is respected", () => {
assert.equal(isOrderStuck(2, 2, "2026-07-08T00:00:00Z", NOW, TERMINAL, 1), true);
});
Case studies
The order that "paid" for three weeks straight
A B2B store integrated a bank reconciliation tool that PUT the orders resource directly to mark an order paid once the transfer cleared. The PUT returned 200 every time, so the integration considered its job done and moved on to the next order.
Three weeks later, someone noticed the order was still shown as Awaiting payment in reports pulled from order_histories, even though the storefront order page looked fine. The reconciler flagged it immediately, since current_state and the newest history row agreed and both were stale. The fix was not more retries. It was switching the integration to POST order_histories instead of PUTting orders, which is the supported path.
The order that looked stuck but wasn't
A store's internal dashboard read only orders.current_state and flagged an order as stuck on Processing for eleven days. The operations team was ready to manually intervene until they ran the reconciler, which cross-checked order_histories and found a newer Shipped row that the cached current_state column had never picked up.
That was the real bug: a webservice write path had updated the history table but left the order's own current_state stale. The order was not stuck at all, the cache was. Distinguishing the two cases up front kept the team from touching an order that had, in fact, already shipped.
After this runs on a schedule, stuck orders and desynced orders show up as two different, clearly labeled problems instead of one confusing pile. Nobody force-advances a business-meaningful state without a human confirming it first, and every repair goes through order_histories, the same mechanism a real state change in the backoffice uses, so email, stock, and logable hooks fire the way they should.
FAQ
Why does a PrestaShop order stay on the same status for days?
PrestaShop stores order status in two places: the denormalized current_state column on the order and the append-only order_history table. Normally core code advances both together through setCurrentState. But a webservice PUT to the orders resource can set current_state in the payload without reliably going through that method, so order_history never gets a new row and the order looks frozen.
Can I just PUT the orders resource to change current_state and fix it?
No. PUTting orders.current_state directly is the likely source of this exact bug. It can silently no-op, or on some versions error on the id_employee column during the history insert, and either way it skips the email, stock, and logable hooks that a real state change triggers. The supported write path is posting a new row to order_histories.
How do I tell a genuinely stuck order from a desync bug?
Compare orders.current_state to the id_order_state of the newest row from order_histories for that order. If they match and both are old, the order is genuinely stuck and needs a human to confirm the real state before you touch it. If they differ, the cached current_state and the history table have desynced, which is the underlying bug itself.
Related field notes
Citations
On the problem:
- Order History not updated (GitHub Issue #33238). github.com/PrestaShop/PrestaShop/issues/33238
- Update order with Webservice adds new order's status line (GitHub Issue #11154). github.com/PrestaShop/PrestaShop/issues/11154
- When the order status is changed in the webservice, the OrderHistory instance is duplicated (GitHub Issue #22011). github.com/PrestaShop/PrestaShop/issues/22011
On the solution:
- PrestaShop Developer Documentation: Order histories webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_histories/
- PrestaShop Developer Documentation: Order states webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_states/
- PrestaShop Developer Documentation: Webservice getting started. devdocs.prestashop-project.org/8/webservice/getting-started/
Stuck on a tricky one?
If you have a problem in PrestaShop 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 unstick your orders?
If this saved you a pile of confused status reports or a manual reconciliation, 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