Skip to content

Diagnostic Orders & Order States

Order history entries appear out of chronological order

You pull an order's history to see how it got to its current state, and the rows do not read top to bottom the way the story actually happened. Two states landed in the same second. The row PrestaShop calls the latest is not the one the order's own current_state field points to. Here is why the history table can drift out of order, how to detect it without touching anything, and a safe way to correct it once a human confirms what really happened.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Cardboard boxes in a crate
Photo by Claudio Schwarz on Unsplash
The short answer

orders.current_state is a denormalized pointer meant to move forward only through OrderHistory::changeIdOrderState(), which inserts a new order_history row stamped with date_add = NOW() at insert time, the write time, not necessarily the true business time of the transition. When modules or webservice clients fire several state changes in quick succession, rows can land within the same second or out of the order the states logically occurred in, so current_state can end up pointing to a state that disagrees with the row that truly happened last. Run a small Python or Node.js script that pulls each order's current_state and its full order_histories, sorts by (date_add, id) to find the true latest row, and flags any order where they disagree. It never rewrites history. Full code, tests, and the pure decision function are below.

The problem in plain words

An order in PrestaShop carries one field, orders.current_state, that is supposed to always match whatever state the order is really in. Every time the state changes, PrestaShop is supposed to insert a new row into order_history and update that pointer together, through OrderHistory::changeIdOrderState().

The trouble is the timestamp on that history row, date_add, records when the database write happened, not when the state change was truly decided to occur. If a webservice client retries a POST, if a batch script replays old orders, or if the actionOrderStatusPostUpdate hook fires more state changes synchronously, several order_history rows can be inserted within the same second, or in an order that does not match the states' real sequence. Sort those rows by date_add alone and you can get the wrong row as "the latest," one that disagrees with what current_state actually says.

Retried POST two state changes fire fast changeIdOrderState() runs twice, back to back date_add = write time Two rows, same second order_history.date_add Order disagrees
The timestamp reflects when the row was written, not the true business order of the states, so the row order and current_state can disagree.

Why it happens

This is not a store misconfiguration, it is how changeIdOrderState() is built. A few concrete ways stores end up with a chronology mismatch:

PrestaShop core itself has acknowledged this. GitHub issue #20772, "Chronology problem with actionOrderStatusPostUpdate," was closed as "expected as is," confirming the history table's insertion order is not guaranteed to match intended business chronology when multiple state changes are chained. See the citations at the end for the exact threads and docs.

The key insight

date_add alone is not a reliable sort key, because it only records write time and can collide at second granularity. id_order_history is auto-increment and always reflects true insertion order, so the safe sort key is the tuple (date_add, id), using id as the tiebreaker. Once you have the true latest row that way, compare its id_order_state to the order's current_state. If they disagree, or if two rows share an identical date_add with different states, that is a chronology violation worth a human's attention, not something to silently patch.

The fix, as a flow

We never touch current_state or reorder order_history rows directly. The script pulls each order's current_state, pulls its full order_histories, sorts by (date_add, id) to find the true latest row, and compares. Anything that disagrees gets reported. Only with a human's confirmation and DRY_RUN off does it append one new, correctly ordered order_history row through the normal webservice path.

Audit job runs on demand Read current_state and order_histories Sort by date_add, id find true latest row Latest = current_state? yes, no violation no Report, then POST new history row
Only orders where the true latest row disagrees with current_state are flagged, and only a confirmed run appends a new, correctly ordered history row.

Build it step by step

1

Enable the Webservice API and get a key

In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the orders, order_histories, and order_states resources. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

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

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

Talk to the Webservice API

Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and POST and raises on a bad status.

step2.py
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_post(path, body):
    r = requests.post(
        f"{BASE_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPost(path, body) {
  const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
    method: "POST",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
3

Pull an order's current state and its full history

Read the order to get its authoritative current_state, then read every order_histories row for that order id with display=full so you get id, id_order_state, and date_add back for each row.

step3.py
def order_current_state(id_order):
    data = api_get(f"orders/{id_order}", {"output_format": "JSON"})
    return int(data["order"]["current_state"])

def order_history_rows(id_order):
    data = api_get("order_histories", {
        "filter[id_order]": id_order,
        "display": "full",
        "sort": "id_desc",
    })
    rows = data.get("order_histories") or []
    if isinstance(rows, dict):
        rows = [rows]
    return [
        {"id": int(r["id"]), "id_order_state": int(r["id_order_state"]), "date_add": r["date_add"]}
        for r in rows
    ]
step3.js
async function orderCurrentState(idOrder) {
  const data = await apiGet(`orders/${idOrder}`, { output_format: "JSON" });
  return Number(data.order.current_state);
}

async function orderHistoryRows(idOrder) {
  const data = await apiGet("order_histories", {
    "filter[id_order]": idOrder,
    display: "full",
    sort: "id_desc",
  });
  let rows = data.order_histories || [];
  if (!Array.isArray(rows)) rows = [rows];
  return rows.map((r) => ({
    id: Number(r.id),
    id_order_state: Number(r.id_order_state),
    date_add: r.date_add,
  }));
}
4

Decide, with one pure function

Keep the decision in its own function that takes the history rows and the order's current_state and returns whether there is a violation. Sort by the tuple (date_add, id), using id as the tiebreaker since date_add can collide at second granularity. The true latest row is whatever sorts last. If its id_order_state disagrees with current_state, that is a mismatch. If two adjacent rows share an identical date_add with different states, that is an ambiguous order worth flagging too.

decide.py
def find_chronology_violation(history_rows, current_state):
    if not history_rows:
        return None
    ordered = sorted(history_rows, key=lambda r: (r["date_add"], r["id"]))
    latest = ordered[-1]
    if latest["id_order_state"] != current_state:
        return {
            "reason": "current_state_mismatch",
            "latest_history_state": latest["id_order_state"],
            "current_state": current_state,
            "latest_id": latest["id"],
        }
    for prev, nxt in zip(ordered, ordered[1:]):
        if prev["date_add"] == nxt["date_add"] and prev["id_order_state"] != nxt["id_order_state"]:
            return {"reason": "duplicate_timestamp_ambiguous_order", "rows": [prev, nxt]}
    return None
decide.js
export function findChronologyViolation(historyRows, currentState) {
  if (!historyRows || historyRows.length === 0) return null;
  const ordered = [...historyRows].sort((a, b) => {
    if (a.date_add < b.date_add) return -1;
    if (a.date_add > b.date_add) return 1;
    return a.id - b.id;
  });
  const latest = ordered[ordered.length - 1];
  if (latest.id_order_state !== currentState) {
    return {
      reason: "current_state_mismatch",
      latest_history_state: latest.id_order_state,
      current_state: currentState,
      latest_id: latest.id,
    };
  }
  for (let i = 0; i < ordered.length - 1; i++) {
    const prev = ordered[i];
    const next = ordered[i + 1];
    if (prev.date_add === next.date_add && prev.id_order_state !== next.id_order_state) {
      return { reason: "duplicate_timestamp_ambiguous_order", rows: [prev, next] };
    }
  }
  return null;
}
5

Never patch history directly, append instead

When a human confirms the true intended state, the safe corrective action is a new, correctly ordered row through order_histories, never editing current_state or deleting and reordering rows. The webservice's addWs() path calls changeIdOrderState() under the hood, so it sets date_add to now, updates current_state, and refires the normal hooks and emails.

apply.py
def append_correct_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,
        }
    }
    return api_post("order_histories", body)
apply.js
async function appendCorrectHistory(idOrder, idOrderState, idEmployee) {
  const body = {
    order_history: {
      id_order: idOrder,
      id_order_state: idOrderState,
      id_employee: idEmployee,
    },
  };
  return apiPost("order_histories", body);
}
6

Wire it together with a dry run guard

The loop pulls each order's current_state and history, runs it through the pure decision function, and logs every violation it finds. On the first runs, leave DRY_RUN on so the script only prints the planned POST payload per flagged order and never calls the API. Only with DRY_RUN=false and an explicit correct state does it POST, one order at a time, then re-fetch order_histories to verify the new row's date_add and id is now the max.

Run it safe

Always start with DRY_RUN=true. This is flag and report only. Order state must never be corrected by editing orders.current_state directly or by deleting or reordering order_history rows, since that bypasses stock, voucher, and notification side effects tied to changeIdOrderState(). The only write this script ever performs is a fresh order_histories POST, once a human has confirmed the correct state.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and the only write it ever performs is appending one new order_histories row per confirmed order, through the normal webservice path.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
chronology_audit.py
"""Flag PrestaShop orders whose order_history rows are out of chronological order.

order_history.date_add records write time, not true business time, so
current_state can end up disagreeing with the row that actually happened last.
This script reports only. It never edits current_state or deletes/reorders
order_history rows. Only with DRY_RUN=false and an explicit correct state does
it append one new, correctly ordered order_history row per confirmed order.
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("chronology_audit")

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


def api_get(path, params=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_post(path, body):
    r = requests.post(
        f"{BASE_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def find_chronology_violation(history_rows, current_state):
    if not history_rows:
        return None
    ordered = sorted(history_rows, key=lambda r: (r["date_add"], r["id"]))
    latest = ordered[-1]
    if latest["id_order_state"] != current_state:
        return {
            "reason": "current_state_mismatch",
            "latest_history_state": latest["id_order_state"],
            "current_state": current_state,
            "latest_id": latest["id"],
        }
    for prev, nxt in zip(ordered, ordered[1:]):
        if prev["date_add"] == nxt["date_add"] and prev["id_order_state"] != nxt["id_order_state"]:
            return {"reason": "duplicate_timestamp_ambiguous_order", "rows": [prev, nxt]}
    return None


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


def order_history_rows(id_order):
    data = api_get("order_histories", {
        "filter[id_order]": id_order,
        "display": "full",
        "sort": "id_desc",
    })
    rows = data.get("order_histories") or []
    if isinstance(rows, dict):
        rows = [rows]
    return [
        {"id": int(r["id"]), "id_order_state": int(r["id_order_state"]), "date_add": r["date_add"]}
        for r in rows
    ]


def order_ids_to_check():
    data = api_get("orders", {"display": "full", "limit": "0,200"})
    orders = data.get("orders") or []
    if isinstance(orders, dict):
        orders = [orders]
    return [int(o["id"]) for o in orders]


def append_correct_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,
        }
    }
    return api_post("order_histories", body)


def run():
    flagged = 0
    for id_order in order_ids_to_check():
        current_state = order_current_state(id_order)
        rows = order_history_rows(id_order)
        violation = find_chronology_violation(rows, current_state)
        if violation is None:
            continue
        flagged += 1
        log.warning("Order %s chronology violation: %s", id_order, violation)
        if DRY_RUN:
            log.info(
                "DRY RUN: would POST order_histories %s",
                {"order_history": {"id_order": id_order, "id_order_state": "", "id_employee": ""}},
            )
        else:
            log.info("Skipping write: correct state must be confirmed by a human before calling "
                      "append_correct_history(id_order, correct_state, id_employee) explicitly.")
    log.info("Done. %d order(s) flagged for review.", flagged)


if __name__ == "__main__":
    run()
chronology-audit.js
/**
 * Flag PrestaShop orders whose order_history rows are out of chronological order.
 *
 * order_history.date_add records write time, not true business time, so
 * current_state can end up disagreeing with the row that actually happened last.
 * This script reports only. It never edits current_state or deletes/reorders
 * order_history rows. Only with DRY_RUN=false and an explicit correct state does
 * it append one new, correctly ordered order_history row per confirmed order.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/order-history-out-of-chronological-order/
 */
import { pathToFileURL } from "node:url";

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

export function findChronologyViolation(historyRows, currentState) {
  if (!historyRows || historyRows.length === 0) return null;
  const ordered = [...historyRows].sort((a, b) => {
    if (a.date_add < b.date_add) return -1;
    if (a.date_add > b.date_add) return 1;
    return a.id - b.id;
  });
  const latest = ordered[ordered.length - 1];
  if (latest.id_order_state !== currentState) {
    return {
      reason: "current_state_mismatch",
      latest_history_state: latest.id_order_state,
      current_state: currentState,
      latest_id: latest.id,
    };
  }
  for (let i = 0; i < ordered.length - 1; i++) {
    const prev = ordered[i];
    const next = ordered[i + 1];
    if (prev.date_add === next.date_add && prev.id_order_state !== next.id_order_state) {
      return { reason: "duplicate_timestamp_ambiguous_order", rows: [prev, next] };
    }
  }
  return null;
}

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

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPost(path, body) {
  const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
    method: "POST",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function orderCurrentState(idOrder) {
  const data = await apiGet(`orders/${idOrder}`, { output_format: "JSON" });
  return Number(data.order.current_state);
}

async function orderHistoryRows(idOrder) {
  const data = await apiGet("order_histories", {
    "filter[id_order]": idOrder,
    display: "full",
    sort: "id_desc",
  });
  let rows = data.order_histories || [];
  if (!Array.isArray(rows)) rows = [rows];
  return rows.map((r) => ({
    id: Number(r.id),
    id_order_state: Number(r.id_order_state),
    date_add: r.date_add,
  }));
}

async function orderIdsToCheck() {
  const data = await apiGet("orders", { display: "full", limit: "0,200" });
  let orders = data.orders || [];
  if (!Array.isArray(orders)) orders = [orders];
  return orders.map((o) => Number(o.id));
}

async function appendCorrectHistory(idOrder, idOrderState, idEmployee) {
  const body = {
    order_history: {
      id_order: idOrder,
      id_order_state: idOrderState,
      id_employee: idEmployee,
    },
  };
  return apiPost("order_histories", body);
}

export async function run() {
  let flagged = 0;
  const orderIds = await orderIdsToCheck();
  for (const idOrder of orderIds) {
    const currentState = await orderCurrentState(idOrder);
    const rows = await orderHistoryRows(idOrder);
    const violation = findChronologyViolation(rows, currentState);
    if (violation === null) continue;
    flagged++;
    console.warn(`Order ${idOrder} chronology violation:`, violation);
    if (DRY_RUN) {
      console.log(
        `DRY RUN: would POST order_histories`,
        { order_history: { id_order: idOrder, id_order_state: "", id_employee: "" } }
      );
    } else {
      console.log(
        "Skipping write: correct state must be confirmed by a human before calling " +
        "appendCorrectHistory(idOrder, correctState, idEmployee) explicitly."
      );
    }
  }
  console.log(`Done. ${flagged} order(s) flagged for review.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which orders get flagged for a human to review. Because find_chronology_violation is pure, the test needs no network and no PrestaShop store. It just feeds in plain lists of history rows and checks the answer.

test_order_history_chronology.py
from chronology_audit import find_chronology_violation


def row(id, id_order_state, date_add):
    return {"id": id, "id_order_state": id_order_state, "date_add": date_add}


def test_no_rows_no_violation():
    assert find_chronology_violation([], 2) is None


def test_agreeing_current_state_no_violation():
    rows = [row(1, 1, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:05:00")]
    assert find_chronology_violation(rows, 2) is None


def test_current_state_mismatch_detected():
    rows = [row(1, 1, "2026-07-10 10:00:00"), row(2, 3, "2026-07-10 10:05:00")]
    result = find_chronology_violation(rows, 2)
    assert result["reason"] == "current_state_mismatch"
    assert result["latest_history_state"] == 3
    assert result["current_state"] == 2
    assert result["latest_id"] == 2


def test_id_used_as_tiebreaker_when_date_add_equal():
    # Same date_add, but id order shows state 4 truly landed last, and current_state agrees.
    rows = [row(5, 3, "2026-07-10 10:00:00"), row(6, 4, "2026-07-10 10:00:00")]
    assert find_chronology_violation(rows, 4) is None


def test_duplicate_timestamp_ambiguous_order_flagged():
    rows = [row(5, 3, "2026-07-10 10:00:00"), row(6, 4, "2026-07-10 10:00:00")]
    result = find_chronology_violation(rows, 3)
    assert result["reason"] == "current_state_mismatch"


def test_duplicate_timestamp_flagged_even_when_state_agrees_elsewhere():
    rows = [
        row(1, 1, "2026-07-10 09:00:00"),
        row(2, 2, "2026-07-10 10:00:00"),
        row(3, 5, "2026-07-10 10:00:00"),
    ]
    result = find_chronology_violation(rows, 5)
    assert result["reason"] == "duplicate_timestamp_ambiguous_order"
    assert len(result["rows"]) == 2
order-history-chronology.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findChronologyViolation } from "./chronology-audit.js";

const row = (id, id_order_state, date_add) => ({ id, id_order_state, date_add });

test("no rows means no violation", () => {
  assert.equal(findChronologyViolation([], 2), null);
});

test("agreeing current_state has no violation", () => {
  const rows = [row(1, 1, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:05:00")];
  assert.equal(findChronologyViolation(rows, 2), null);
});

test("current_state mismatch is detected", () => {
  const rows = [row(1, 1, "2026-07-10 10:00:00"), row(2, 3, "2026-07-10 10:05:00")];
  const result = findChronologyViolation(rows, 2);
  assert.equal(result.reason, "current_state_mismatch");
  assert.equal(result.latest_history_state, 3);
  assert.equal(result.current_state, 2);
  assert.equal(result.latest_id, 2);
});

test("id is used as tiebreaker when date_add is equal", () => {
  const rows = [row(5, 3, "2026-07-10 10:00:00"), row(6, 4, "2026-07-10 10:00:00")];
  assert.equal(findChronologyViolation(rows, 4), null);
});

test("duplicate timestamp with wrong current_state still reports mismatch", () => {
  const rows = [row(5, 3, "2026-07-10 10:00:00"), row(6, 4, "2026-07-10 10:00:00")];
  const result = findChronologyViolation(rows, 3);
  assert.equal(result.reason, "current_state_mismatch");
});

test("duplicate timestamp flagged even when state agrees elsewhere", () => {
  const rows = [
    row(1, 1, "2026-07-10 09:00:00"),
    row(2, 2, "2026-07-10 10:00:00"),
    row(3, 5, "2026-07-10 10:00:00"),
  ];
  const result = findChronologyViolation(rows, 5);
  assert.equal(result.reason, "duplicate_timestamp_ambiguous_order");
  assert.equal(result.rows.length, 2);
});

Case studies

Retried webservice call

A timeout caused a duplicate state jump

An integration posted a state change to mark an order shipped, the request timed out on the client side before the response came back, and the integration retried it automatically. Both calls succeeded, and a follow up automation fired a third change moments later, all landing within the same second.

The audit script flagged the order because the true latest history row, by id, did not match current_state. A human checked the shipping carrier's own timeline, confirmed the order really had shipped, and the team appended one correct history row through the webservice instead of touching the table by hand.

Batch backfill

Replaying old orders scrambled the sort order

A migration script replayed a batch of old orders into a new PrestaShop install to preserve their history, inserting order_history rows for events that had actually happened weeks apart. Every replayed row got a date_add from the moment the migration ran, so sorting by timestamp alone made the whole batch look like it happened in one second.

Running the audit in dry run surfaced every order where the batch's id order and the store's current_state disagreed. The team cross checked a handful of orders against the original store's export before appending any correcting rows, catching a scripting mistake in the migration itself before it touched more orders.

What good looks like

After an audit run, every order where current_state disagrees with its true latest history row has been surfaced for a human to check, and nothing was silently rewritten. The only write the script ever makes is a fresh, correctly ordered order_history row, added through the same path the admin's own state change button uses, so stock, vouchers, and customer emails all fire the way they are supposed to.

FAQ

Why does PrestaShop order history end up out of order?

Every order_history row is stamped with date_add at the moment it is written by changeIdOrderState(), not the true business time of the transition. When several state changes happen in quick succession, from hooks, retried webservice calls, or batch scripts, multiple rows can land within the same second or in a different order than the states logically occurred, so current_state can end up disagreeing with the row that truly happened last.

Is this a bug I can report to PrestaShop?

It has already been reported. GitHub issue 20772, Chronology problem with actionOrderStatusPostUpdate, was closed as expected as is, which confirms the order_history table's insertion order is not guaranteed to match intended business chronology when multiple state changes are chained. Treat it as a known limitation to detect and work around, not something core will change.

Is it safe to fix a chronology mismatch by editing the database directly?

No. Editing orders.current_state directly or deleting and reordering order_history rows bypasses the stock, voucher, and notification side effects tied to changeIdOrderState(), and can desync the order further. The safe fix, once a human confirms the true intended state, is to append a new order_history row through the webservice, which calls changeIdOrderState() under the hood and refires the normal hooks and emails.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #20772: Chronology problem with actionOrderStatusPostUpdate. github.com/PrestaShop/PrestaShop/issues/20772
  2. PrestaShop GitHub Issue #24474: actionOrderStatusPostUpdate hook should have the old order status in params. github.com/PrestaShop/PrestaShop/issues/24474
  3. PrestaShop-1.6 source: classes/order/OrderHistory.php, the changeIdOrderState() implementation. github.com/PrestaShop/PrestaShop-1.6/blob/master/classes/order/OrderHistory.php

On the solution:

  1. PrestaShop Developer Documentation: the order_histories Webservice resource. devdocs.prestashop-project.org webservice resources order_histories
  2. PrestaShop Developer Documentation: the order_states Webservice resource. devdocs.prestashop-project.org webservice resources order_states
  3. PrestaShop Developer Documentation: the orders Webservice resource. devdocs.prestashop-project.org 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.

Contact me on LinkedIn

Did this untangle your order history?

If this saved you from a confusing support ticket or a wrong state 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

Back to all PrestaShop field notes