Reconciler
Order current_state field goes stale after order history is edited
An admin removes a wrongly added status line from an order, or a cleanup script deletes some old order_history rows, and the order list keeps showing a status that no longer matches what the history actually says. PrestaShop only ever updates orders.current_state as a side effect of adding a new history row, so editing or deleting a history row directly leaves that column pointing at a status the order no longer supports. Here is why that gap opens up and a script that finds every order where the pointer disagrees with its own history and repairs it safely.
PrestaShop keeps two representations of an order's status: the append-only order_history table, one row per transition, and a denormalized current_state column on the orders row, kept purely as a read-optimization. The core only synchronizes the two inside Order::setCurrentState() and OrderHistory::addWithemail(), which insert a history row and then write that same state into current_state in the same call. Delete or edit a history row directly and that write path is bypassed, so current_state silently diverges from what the history now shows as most recent. Run a Python or Node.js script that pulls every order's stored current_state with GET /api/orders, pulls its history with GET /api/order_histories, recomputes the correct state as the most recent row by date_add, and patches only the ones that disagree. Full code, tests, and citations are below.
The problem in plain words
Every order in PrestaShop carries a current_state field right on the orders table. It is the fast answer to "what state is this order in," and order lists, filters, and exports all read it directly instead of joining against history every time.
Sitting next to it is order_history, an append-only log with one row per transition, each carrying an id_order_state and a date_add. In a healthy order, the newest row in that log always matches current_state. But current_state is not derived from the log on read, it is a cached copy written at the moment a transition happens. If a history row is deleted or edited afterward, by a bad module, a GDPR or cleanup script, a manual database fix, or an admin removing a wrongly-added status line, nothing goes back and recomputes current_state. The column keeps pointing at whatever was last set, and the order list quietly disagrees with the order's own history, exactly the desync reported in PrestaShop GitHub issue #13390.
Why it happens
The core only synchronizes the two representations inside one code path. Anything that touches history outside that path leaves current_state behind. Common ways this happens:
- A bad or poorly-tested module deletes or rewrites rows in
order_historydirectly, for example during a cleanup or migration routine, without calling back intoOrder::setCurrentState()afterward. - A GDPR or data-retention script prunes old history rows for closed orders, not realizing the most recent surviving row no longer matches the order's stored
current_state. - An admin manually removes a wrongly-added status line from an order's history in the database, intending to correct a mistake, without also updating the column that reads from a different place.
- A direct SQL fix during support or migration work edits
id_order_stateon an existing history row instead of inserting a new one, which is exactly the desync tracked in GitHub issue #13390.
Any of these leaves the order list, filters, and exports showing a status nobody can explain from the order's own history, since the timeline underneath tells a different story, or in the zero-history case, no story at all. See the citations at the end for the exact issue and forum threads.
Fixing this is not the same problem as backfilling missing history. Here the history is the trustworthy record, current_state is the stale copy. So the safe repair is a pointer fix only: recompute current_state from the order's existing order_history rows and write that value back to the order. Never insert a new order_history row for this correction, since that would trigger a customer notification email and add yet another entry to a timeline someone already edited. If an order has zero history rows, there is nothing to recompute from, so it gets flagged for review instead of repaired.
The fix, as a flow
We do not touch history at all. We add a job that reads every order's stored current_state, pulls its full history, works out what the most recent row actually says, and patches the order only when the two disagree. Everything with no history at all is skipped and reported instead, since there is no safe state to recompute from.
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 and order_histories, plus write access to orders if you plan to run repairs. 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, only logs by default
// 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, only logs by default
List every order and its stored current_state
Call GET /api/orders?display=[id,current_state,reference]&limit=0&output_format=JSON to pull every order's id, reference, and stored pointer in one pass. Auth is HTTP Basic with the webservice key as the username and a blank password.
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 all_orders():
data = api_get("orders", params={"display": "[id,current_state,reference]", "limit": "0"})
return data.get("orders") or []
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 allOrders() {
const data = await apiGet("orders", { display: "[id,current_state,reference]", limit: "0" });
return data.orders || [];
}
Pull each order's full history
Call GET /api/order_histories?filter[id_order]=[id]&display=[id,id_order_state,date_add]&output_format=JSON for each order id. This resource has no "latest per order" filter, so the rows come back in whatever order the API returns them, and the sorting has to happen client-side.
def order_history_rows(id_order):
data = api_get("order_histories", params={
"filter[id_order]": id_order,
"display": "[id,id_order_state,date_add]",
})
return data.get("order_histories") or []
async function orderHistoryRows(idOrder) {
const data = await apiGet("order_histories", {
"filter[id_order]": idOrder,
display: "[id,id_order_state,date_add]",
});
return data.order_histories || [];
}
Decide, with one pure function
Keep the recompute in its own function that takes the order's history rows and returns the correct current_state, nothing else. It picks the row with the lexicographically-largest date_add, breaking ties by the largest id since order_history ids are auto-increment and insert-ordered. If the list is empty, it returns None, which signals "flag this order, do not repair it," since there is no history left to recompute from.
def compute_correct_current_state(history_rows):
if not history_rows:
return None
best = max(
history_rows,
key=lambda row: (row.get("date_add") or "", int(row["id"])),
)
return int(best["id_order_state"])
export function computeCorrectCurrentState(historyRows) {
if (!historyRows || historyRows.length === 0) return null;
const best = historyRows.reduce((a, b) => {
const aKey = [a.date_add || "", Number(a.id)];
const bKey = [b.date_add || "", Number(b.id)];
if (bKey[0] !== aKey[0]) return bKey[0] > aKey[0] ? b : a;
return bKey[1] > aKey[1] ? b : a;
});
return Number(best.id_order_state);
}
Patch only the pointer, never insert history
When the recomputed state disagrees with the stored current_state, fetch the full order body with GET /api/orders/{id}?output_format=JSON first, since a webservice PUT requires the complete resource and a partial payload nulls out every field you omit. Set current_state on that body, then PUT it back to /api/orders/{id}. This never touches order_histories, so it never sends a customer notification email.
def patch_current_state(id_order, correct_state):
body = api_get(f"orders/{id_order}")
body["order"]["current_state"] = str(correct_state)
r = requests.put(
f"{PRESTASHOP_URL}/api/orders/{id_order}",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
async function patchCurrentState(idOrder, correctState) {
const body = await apiGet(`orders/${idOrder}`);
body.order.current_state = String(correctState);
const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
return res.json();
}
Wire it together with a dry run guard
The loop ties every piece together: list every order, pull its history, recompute the correct state, and only patch when the two disagree. DRY_RUN defaults to true, so the script only ever logs {id_order, stale_current_state, correct_current_state} pairs unless you flip it off. Orders with zero history rows are skipped and flagged in the log, since there is no safe state to recompute from. Run it on a schedule that matches how often modules or scripts touch your order history, for example once a day.
Always start with DRY_RUN=true. Never insert a new order_histories row for this correction, since that both sends a customer notification email and adds yet another entry to a timeline someone already edited. If an order comes back with zero history rows, skip the write and report it instead of guessing a state.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks every order and its history, logs every stale pointer it finds, respects the dry run flag, and patches only the current_state field, never the history table.
"""Detect and repair PrestaShop orders whose current_state has gone stale.
PrestaShop keeps two representations of an order's status: the append-only
order_history table, one row per transition, and a denormalized current_state
column on the orders row, kept purely as a read-optimization for order lists,
filters, and exports. The core only synchronizes these inside
Order::setCurrentState() and OrderHistory::addWithemail(), which insert a new
history row and then write that same state into orders.current_state in the
same call. If a history row is deleted or edited directly, by a bad module, a
GDPR or cleanup script, a manual database fix, or an admin removing a
wrongly-added status line, that write path is bypassed, so current_state keeps
pointing at whatever was last set and silently diverges from what the history
now shows as most recent. This is the desync reported in PrestaShop GitHub
issue #13390.
This script logs every stale pointer it finds. It never inserts a new
order_history row for a correction, since that would trigger a customer
notification email and further pollute an already-edited history. A confirmed
repair only overwrites orders.current_state, and only when DRY_RUN is false.
Orders with zero history rows are skipped and flagged, since there is no safe
state to recompute from.
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("fix_stale_current_state")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
def compute_correct_current_state(history_rows):
"""Pure decision function, no I/O.
history_rows is a list of dicts, each with at least "id", "id_order_state",
and "date_add" as an ISO-ish string, in any order. Returns the
id_order_state of the row with the lexicographically-max date_add,
breaking ties by the largest id (order_history ids are auto-increment and
insert-ordered). Returns None when history_rows is empty, which signals
"flag this order, do not repair it."
"""
if not history_rows:
return None
best = max(
history_rows,
key=lambda row: (row.get("date_add") or "", int(row["id"])),
)
return int(best["id_order_state"])
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 all_orders():
data = api_get("orders", params={"display": "[id,current_state,reference]", "limit": "0"})
return data.get("orders") or []
def order_history_rows(id_order):
data = api_get("order_histories", params={
"filter[id_order]": id_order,
"display": "[id,id_order_state,date_add]",
})
return data.get("order_histories") or []
def patch_current_state(id_order, correct_state):
body = api_get(f"orders/{id_order}")
body["order"]["current_state"] = str(correct_state)
r = requests.put(
f"{PRESTASHOP_URL}/api/orders/{id_order}",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
flagged = 0
repaired = 0
for order in all_orders():
id_order = order["id"]
stale_state = int(order["current_state"])
rows = order_history_rows(id_order)
correct_state = compute_correct_current_state(rows)
if correct_state is None:
flagged += 1
log.warning("Order id_order=%s reference=%s has zero order_history rows. Skipping, flagged for review.",
id_order, order.get("reference"))
continue
if correct_state == stale_state:
continue
flagged += 1
log.info(
"Order id_order=%s reference=%s stale_current_state=%s correct_current_state=%s. %s",
id_order, order.get("reference"), stale_state, correct_state,
"would patch" if DRY_RUN else "patching",
)
if not DRY_RUN:
patch_current_state(id_order, correct_state)
repaired += 1
log.info("Done. %d order(s) flagged, %d %s.", flagged, repaired,
"would be patched" if DRY_RUN else "patched")
if __name__ == "__main__":
run()
/**
* Detect and repair PrestaShop orders whose current_state has gone stale.
*
* PrestaShop keeps two representations of an order's status: the append-only
* order_history table, one row per transition, and a denormalized current_state
* column on the orders row, kept purely as a read-optimization for order lists,
* filters, and exports. The core only synchronizes these inside
* Order::setCurrentState() and OrderHistory::addWithemail(), which insert a new
* history row and then write that same state into orders.current_state in the
* same call. If a history row is deleted or edited directly, by a bad module, a
* GDPR or cleanup script, a manual database fix, or an admin removing a
* wrongly-added status line, that write path is bypassed, so current_state keeps
* pointing at whatever was last set and silently diverges from what the history
* now shows as most recent. This is the desync reported in PrestaShop GitHub
* issue #13390.
*
* This script logs every stale pointer it finds. It never inserts a new
* order_history row for a correction, since that would trigger a customer
* notification email and further pollute an already-edited history. A confirmed
* repair only overwrites orders.current_state, and only when DRY_RUN is false.
* Orders with zero history rows are skipped and flagged, since there is no safe
* state to recompute from.
*
* Guide: https://www.allanninal.dev/prestashop/order-current-state-stale-after-history-edit/
*/
import { pathToFileURL } from "node:url";
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* historyRows is an array of objects, each with at least "id", "id_order_state",
* and "date_add" as an ISO-ish string, in any order. Returns the id_order_state
* of the row with the lexicographically-max date_add, breaking ties by the
* largest id (order_history ids are auto-increment and insert-ordered). Returns
* null when historyRows is empty, which signals "flag this order, do not
* repair it."
*/
export function computeCorrectCurrentState(historyRows) {
if (!historyRows || historyRows.length === 0) return null;
const best = historyRows.reduce((a, b) => {
const aKey = [a.date_add || "", Number(a.id)];
const bKey = [b.date_add || "", Number(b.id)];
if (bKey[0] !== aKey[0]) return bKey[0] > aKey[0] ? b : a;
return bKey[1] > aKey[1] ? b : a;
});
return Number(best.id_order_state);
}
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 allOrders() {
const data = await apiGet("orders", { display: "[id,current_state,reference]", limit: "0" });
return data.orders || [];
}
async function orderHistoryRows(idOrder) {
const data = await apiGet("order_histories", {
"filter[id_order]": idOrder,
display: "[id,id_order_state,date_add]",
});
return data.order_histories || [];
}
async function patchCurrentState(idOrder, correctState) {
const body = await apiGet(`orders/${idOrder}`);
body.order.current_state = String(correctState);
const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
return res.json();
}
export async function run() {
let flagged = 0;
let repaired = 0;
for (const order of await allOrders()) {
const idOrder = order.id;
const staleState = Number(order.current_state);
const rows = await orderHistoryRows(idOrder);
const correctState = computeCorrectCurrentState(rows);
if (correctState === null) {
flagged++;
console.warn(
`Order id_order=${idOrder} reference=${order.reference} has zero order_history rows. Skipping, flagged for review.`
);
continue;
}
if (correctState === staleState) continue;
flagged++;
console.log(
`Order id_order=${idOrder} reference=${order.reference} stale_current_state=${staleState} ` +
`correct_current_state=${correctState}. ${DRY_RUN ? "would patch" : "patching"}`
);
if (!DRY_RUN) {
await patchCurrentState(idOrder, correctState);
repaired++;
}
}
console.log(`Done. ${flagged} order(s) flagged, ${repaired} ${DRY_RUN ? "would be patched" : "patched"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The recompute function is the part most worth testing, because it decides what state gets written back to a real order. Because we kept compute_correct_current_state pure, the test needs no network and no PrestaShop store. It just feeds in plain lists of dicts and checks the answer.
from fix_stale_current_state import compute_correct_current_state
def row(id, id_order_state, date_add):
return {"id": id, "id_order_state": id_order_state, "date_add": date_add}
def test_empty_history_returns_none():
assert compute_correct_current_state([]) is None
def test_single_row_returns_its_state():
assert compute_correct_current_state([row(1, 2, "2026-07-01 10:00:00")]) == 2
def test_picks_most_recent_by_date_add():
rows = [row(1, 1, "2026-07-01 10:00:00"), row(2, 2, "2026-07-05 10:00:00")]
assert compute_correct_current_state(rows) == 2
def test_out_of_order_input_still_picks_latest():
rows = [row(3, 5, "2026-07-09 10:00:00"), row(1, 1, "2026-07-01 10:00:00"), row(2, 2, "2026-07-05 10:00:00")]
assert compute_correct_current_state(rows) == 5
def test_tie_on_date_add_breaks_by_highest_id():
rows = [row(10, 3, "2026-07-05 10:00:00"), row(11, 4, "2026-07-05 10:00:00")]
assert compute_correct_current_state(rows) == 4
def test_row_with_missing_date_add_sorts_first():
rows = [row(1, 9, None), row(2, 2, "2026-07-01 10:00:00")]
assert compute_correct_current_state(rows) == 2
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeCorrectCurrentState } from "./fix-stale-current-state.js";
const row = (id, id_order_state, date_add) => ({ id, id_order_state, date_add });
test("empty history returns null", () => {
assert.equal(computeCorrectCurrentState([]), null);
});
test("single row returns its state", () => {
assert.equal(computeCorrectCurrentState([row(1, 2, "2026-07-01 10:00:00")]), 2);
});
test("picks most recent by date_add", () => {
const rows = [row(1, 1, "2026-07-01 10:00:00"), row(2, 2, "2026-07-05 10:00:00")];
assert.equal(computeCorrectCurrentState(rows), 2);
});
test("out of order input still picks latest", () => {
const rows = [row(3, 5, "2026-07-09 10:00:00"), row(1, 1, "2026-07-01 10:00:00"), row(2, 2, "2026-07-05 10:00:00")];
assert.equal(computeCorrectCurrentState(rows), 5);
});
test("tie on date_add breaks by highest id", () => {
const rows = [row(10, 3, "2026-07-05 10:00:00"), row(11, 4, "2026-07-05 10:00:00")];
assert.equal(computeCorrectCurrentState(rows), 4);
});
test("row with missing date_add sorts first", () => {
const rows = [row(1, 9, null), row(2, 2, "2026-07-01 10:00:00")];
assert.equal(computeCorrectCurrentState(rows), 2);
});
Case studies
The admin who removed a wrong status line
A support agent added a status to an order by mistake, then went into the database to delete that one history row rather than adding another transition on top of it. The order list kept showing the mistaken status long after the row was gone, and nobody connected the two until a customer asked why their order still said "Payment error" when the payment had gone through days earlier.
Running the reconciler across the order book found the mismatch immediately: the history's most recent surviving row said one thing, current_state said another. The team reviewed the dry run log, confirmed it matched what the agent intended, and let the script patch the pointer without touching history again.
The GDPR cleanup that pruned old history
A retention job deleted order_history rows older than a cutoff for closed orders, as part of routine GDPR cleanup. For most orders this was harmless, since the surviving rows still ended on the right status. But for a handful of older orders, the row that got pruned happened to be the last one, leaving current_state pointing at a status with no supporting history at all.
The script's zero-history check caught every one of those orders and flagged them for review instead of guessing a value, since there was nothing safe to recompute from. Staff cross-checked those against archived invoices and set the state manually, while every other stale pointer in the batch got repaired automatically.
After this runs on a schedule, orders.current_state always matches what the order's own history actually says, whenever that history exists. Nothing gets a phantom notification email, and nothing gets a guessed status. Orders with a gap in their history are handed to a human instead of silently patched, so the order list stops lying and the audit trail stays honest.
FAQ
Why does orders.current_state not update when I delete an order_history row?
PrestaShop only writes orders.current_state as a side effect of Order::setCurrentState() and OrderHistory::addWithemail(), the same call that inserts a new order_history row. If a history row is deleted or edited directly, by a bad module, a cleanup script, or a manual database fix, that write path never runs, so current_state keeps pointing at whatever was last set instead of the state the edited history now shows as most recent.
Is it safe to automatically repair a stale current_state?
Yes, as long as the repair only overwrites the current_state pointer and never inserts a new order_history row. Inserting a row would trigger a customer notification email and add another entry to an already edited history. Recomputing current_state from the existing history is a pointer repair, so it is safe to automate once the tie-break logic for picking the most recent row is deterministic.
How do I find orders where current_state disagrees with order_history?
Pull every order's id, reference, and current_state with GET orders, then for each order pull its rows with GET order_histories filtered by id_order. Sort those rows by date_add descending, breaking ties by the highest id, and compare the id_order_state of that top row against the order's current_state. Any mismatch, or any order with zero history rows, is a stale or unsupported pointer.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: ps_orders column current_state not updated after ps_orders_history modification. Issue #13390. github.com/PrestaShop/PrestaShop/issues/13390
- PrestaShop Forums: Query to update order current_state and order_history. prestashop.com/forums/topic/1022800-query-to-update-order-current_state-and-order_history
- PrestaShop GitHub: Order History not updated. Issue #33238. github.com/PrestaShop/PrestaShop/issues/33238
On the solution:
- PrestaShop Developer Documentation: Orders webservice resource, current_state field. devdocs.prestashop-project.org/9/webservice/resources/orders/
- PrestaShop Developer Documentation: Order histories webservice resource, id_order_state, id_order, date_add. devdocs.prestashop-project.org/9/webservice/resources/order_histories/
- PrestaShop Developer Documentation: Order states webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_states/
Stuck on a tricky one?
If you have a problem in PrestaShop orders, order states, stock, 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 fix your stale order status?
If this saved you a confusing support ticket or a manual pass over the order book, 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