Skip to content

Reconciler

Order created via webservice with no current state set at all

An order comes in through the webservice, and it looks fine at a glance: a real id, a real reference, real order lines. But open it and current_state reads 0, or something that never actually happened, and the order_history timeline underneath is completely empty. Not stale, not mismatched, just missing from the start. Here is why POSTing to /api/orders can create an order that never really entered the state machine, and a script that finds every one of these and backfills it the safe way.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Cardboard boxes on a rack
Photo by EJ Li on Unsplash
The short answer

The authoritative way to change an order's status in PrestaShop is OrderHistory::changeIdOrderState(), which writes a row to order_history and updates the denormalized orders.current_state column together, while also firing emails and stock and invoice logic. The webservice orders resource, however, exposes current_state as a plain writable field on the order object itself, and a POST to /api/orders does not go through that service layer at all. Omit current_state or set it directly and PrestaShop stores the order with no matching order_history row, sometimes at current_state=0. Run a Python or Node.js script that lists orders with filter[current_state]=0, confirms each one is truly stateless with an empty GET /api/order_histories result, resolves a sane state from the order's own payment facts, and repairs it with a POST to order_histories, never a direct write to the order. Full code, tests, and citations are below.

The problem in plain words

In the PrestaShop back office, an order never changes status by itself. Something always calls OrderHistory::changeIdOrderState(), and that one call does three things at once: it inserts a row into order_history recording exactly what happened and when, it updates the fast-read current_state column on the order, and it fires whatever emails, stock adjustments, and invoice generation that state is supposed to trigger.

The webservice does not know about that contract. When it exposes the orders resource for reading and writing, current_state shows up as just another field on the order object, the same as reference or total_paid. A client building an order integration can reasonably assume that setting current_state on the order it POSTs is enough, the same way setting any other field is enough. It is not. The order gets created, but nothing runs the state machine, so order_history stays empty and current_state is whatever got stored, often 0, sometimes a value that was never actually applied through a real transition.

Client POSTs order current_state set on the order /api/orders resource writes the row directly changeIdOrderState() never called at all order_history stays empty current_state=0 or unapplied the order was never actually run through the state machine
Setting current_state on the order object looks like it should work, the same as any other field. It does not run the state machine, so the order is created with no history behind it.

Why it happens

The webservice's orders resource is a generic CRUD mapping over the orders table's columns, not a wrapper around the order lifecycle. Documented and reported ways this gap shows up:

Either way, the order exists in the database without ever entering the real state machine, so nothing about payment confirmation, stock reservation tied to a state, or transition emails ever ran for it. See the citations at the end for the exact threads and issues.

The key insight

An order with no history at all is not a formatting problem, it is an order the state machine has never seen. You cannot fix that by writing a nicer value into current_state, because that repeats the same mistake that created the gap. The only correct fix is to give the order the state transition it was supposed to get in the first place, by POSTing to order_histories, the same call changeIdOrderState() makes internally, so the order finally gets a real history row and its current_state is refreshed as a side effect, not as a direct write.

The fix, as a flow

We never PUT current_state onto an order. We add a job that lists candidate orders at current_state=0, confirms each is truly stateless by checking that order_histories is empty for it, resolves a sane state from the order's own payment facts, and posts that resolution through order_histories, exactly the endpoint the back office uses.

List candidates GET orders, current_state=0 Confirm stateless GET order_histories empty? resolve_backfill_state paid amount vs. total, order_states Resolved? yes no, flag for review POST history Only via order_histories, never a direct write to orders.current_state
The job only backfills through order_histories, the same call the back office makes. When the correct state cannot be resolved with confidence, the order is flagged for a human instead of guessed at.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with read access to orders, order_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.

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, only logs by default
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, only logs by default
2

List orders that look stateless

Call GET /api/orders?output_format=JSON&display=full&filter[current_state]=0&limit=200 to find candidates whose current_state reads 0. Treat this as a candidate list, not a final answer, since current_state can be denormalized and stale in other ways too.

step2.py
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 candidate_orders():
    data = api_get("orders", params={"display": "full", "filter[current_state]": "0", "limit": "200"})
    return data.get("orders") or []
step2.js
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 candidateOrders() {
  const data = await apiGet("orders", { display: "full", "filter[current_state]": "0", limit: "200" });
  return data.orders || [];
}
3

Confirm each candidate is truly stateless

For every candidate id, call GET /api/order_histories?output_format=JSON&display=full&filter[id_order]=[id]&limit=1. Only treat the order as confirmed stateless when this comes back empty. This matters because current_state alone can be misleading, an order can carry a stale or denormalized value while still having history rows behind it, which is a different problem with a different fix.

step3.py
def is_stateless(id_order):
    data = api_get("order_histories", params={
        "display": "full",
        "filter[id_order]": id_order,
        "limit": "1",
    })
    rows = data.get("order_histories") or []
    return len(rows) == 0

def order_states():
    data = api_get("order_states", params={"display": "full"})
    return data.get("order_states") or []
step3.js
async function isStateless(idOrder) {
  const data = await apiGet("order_histories", {
    display: "full",
    "filter[id_order]": idOrder,
    limit: "1",
  });
  const rows = data.order_histories || [];
  return rows.length === 0;
}

async function orderStates() {
  const data = await apiGet("order_states", { display: "full" });
  return data.order_states || [];
}
4

Decide, with one pure function

Keep the resolution in its own function that takes the order's plain fields and the list of available order states and returns the id to backfill, or None when it cannot decide safely. It never calls the network. If total_paid_real covers total_paid and there is a logable, non-hidden state that reads as a paid confirmation, use that. Otherwise fall back to the lowest-id logable, non-hidden state that reads as an awaiting-payment state. Anything ambiguous, such as no matching states at all, returns None so the order gets flagged for a human instead of guessed at.

decide.py
PAID_HINTS = ("payment accepted", "paiement accepté", "paid")
AWAITING_HINTS = ("awaiting", "en attente")

def resolve_backfill_state(order, order_states):
    if order.get("current_state") not in (0, None):
        return None

    def usable(s):
        return str(s.get("logable", "0")) in ("1", "true", "True") and \
               str(s.get("hidden", "0")) not in ("1", "true", "True")

    candidates = [s for s in order_states if usable(s)]
    if not candidates:
        return None

    total_paid = float(order.get("total_paid") or 0)
    total_paid_real = float(order.get("total_paid_real") or 0)

    if total_paid > 0 and total_paid_real >= total_paid:
        paid_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in PAID_HINTS)]
        if len(paid_states) == 1:
            return int(paid_states[0]["id"])
        return None

    awaiting_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in AWAITING_HINTS)]
    if not awaiting_states:
        return None
    return int(min(awaiting_states, key=lambda s: int(s["id"]))["id"])
decide.js
const PAID_HINTS = ["payment accepted", "paiement accepté", "paid"];
const AWAITING_HINTS = ["awaiting", "en attente"];

function usable(s) {
  const logable = String(s.logable ?? "0");
  const hidden = String(s.hidden ?? "0");
  return ["1", "true", "True"].includes(logable) && !["1", "true", "True"].includes(hidden);
}

export function resolveBackfillState(order, orderStates) {
  if (order.current_state !== 0 && order.current_state != null) return null;

  const candidates = orderStates.filter(usable);
  if (candidates.length === 0) return null;

  const totalPaid = Number(order.total_paid || 0);
  const totalPaidReal = Number(order.total_paid_real || 0);

  if (totalPaid > 0 && totalPaidReal >= totalPaid) {
    const paidStates = candidates.filter((s) =>
      PAID_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
    );
    if (paidStates.length === 1) return Number(paidStates[0].id);
    return null;
  }

  const awaitingStates = candidates.filter((s) =>
    AWAITING_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
  );
  if (awaitingStates.length === 0) return null;
  return Number(awaitingStates.reduce((a, b) => (Number(b.id) < Number(a.id) ? b : a)).id);
}
5

Repair through order_histories, never the order directly

When a state resolves, POST a new resource to /api/order_histories with {"order_history": {"id_order": id, "id_order_state": resolved_state_id}}. This is the same call OrderHistory::changeIdOrderState() triggers internally, so it inserts the history row and refreshes current_state as a side effect, along with whatever emails and stock logic that state carries. Never PUT current_state straight onto /api/orders, that is the exact bug this fix exists for.

repair.py
def backfill_via_history(id_order, resolved_state_id):
    body = {"order_history": {"id_order": id_order, "id_order_state": resolved_state_id}}
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function backfillViaHistory(idOrder, resolvedStateId) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const body = { order_history: { id_order: idOrder, id_order_state: resolvedStateId } };
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together: list candidates, confirm each is stateless, resolve a state from its payment facts and the shop's order states, and log the pair. DRY_RUN defaults to true, so the script only ever logs the (id_order, resolved_state_id) pairs it would create until you flip it off. Run it on a schedule that matches how often your integration creates orders, for example once an hour.

Run it safe

Always start with DRY_RUN=true. Never PUT current_state directly onto /api/orders, since that repeats the exact mistake this fix exists to correct. When resolve_backfill_state returns None, leave the order alone and let a human decide, rather than guessing at a state the order never actually reached.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, confirms every candidate is truly stateless before touching it, resolves a safe state from payment facts, respects the dry run flag, and only ever writes through order_histories.

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.
backfill_stateless_orders.py
"""Find and backfill PrestaShop orders created via webservice with no state at all.

OrderHistory::changeIdOrderState() is the only code path that both writes an
order_history row and updates the denormalized orders.current_state column, while
also firing the emails, stock, and invoice logic tied to that state. The webservice
orders resource exposes current_state as a plain writable field on the order object,
so a POST to /api/orders that omits it, or sets it directly, never runs the state
machine at all. The order is created with current_state at 0 (or an unapplied value)
and zero rows in order_history. This is documented on the PrestaShop forums under
"Create order via webservice won't set current state," and the reverse case, an
update adding an unexpected history row, is tracked as GitHub issue #11154.

This script only ever repairs through order_histories, the same call the back
office makes internally. It never writes current_state directly onto an order,
since that is the exact bug being fixed. Run on a schedule. Safe to run again
and again, because a repaired order will show up with a real history row on the
next pass and no longer match the stateless filter.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_stateless_orders")

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")

PAID_HINTS = ("payment accepted", "paiement accepté", "paid")
AWAITING_HINTS = ("awaiting", "en attente")


def resolve_backfill_state(order, order_states):
    """Pure decision function, no I/O.

    order: dict with id_order, current_state, total_paid, total_paid_real, payment, valid.
    order_states: list of dicts, each with id, name, logable, hidden.
    Returns the id_order_state to backfill, or None when no safe decision can be made.
    """
    if order.get("current_state") not in (0, None):
        return None

    def usable(s):
        return str(s.get("logable", "0")) in ("1", "true", "True") and \
               str(s.get("hidden", "0")) not in ("1", "true", "True")

    candidates = [s for s in order_states if usable(s)]
    if not candidates:
        return None

    total_paid = float(order.get("total_paid") or 0)
    total_paid_real = float(order.get("total_paid_real") or 0)

    if total_paid > 0 and total_paid_real >= total_paid:
        paid_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in PAID_HINTS)]
        if len(paid_states) == 1:
            return int(paid_states[0]["id"])
        return None

    awaiting_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in AWAITING_HINTS)]
    if not awaiting_states:
        return None
    return int(min(awaiting_states, key=lambda s: int(s["id"]))["id"])


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 candidate_orders():
    data = api_get("orders", params={"display": "full", "filter[current_state]": "0", "limit": "200"})
    return data.get("orders") or []


def is_stateless(id_order):
    data = api_get("order_histories", params={
        "display": "full",
        "filter[id_order]": id_order,
        "limit": "1",
    })
    rows = data.get("order_histories") or []
    return len(rows) == 0


def order_states():
    data = api_get("order_states", params={"display": "full"})
    return data.get("order_states") or []


def backfill_via_history(id_order, resolved_state_id):
    body = {"order_history": {"id_order": id_order, "id_order_state": resolved_state_id}}
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    states = order_states()
    flagged = 0
    repaired = 0
    for order in candidate_orders():
        id_order = order["id"]
        if not is_stateless(id_order):
            continue
        flagged += 1
        resolved = resolve_backfill_state(order, states)
        if resolved is None:
            log.warning("Order id_order=%s is stateless but could not be safely resolved. Flagging for review.", id_order)
            continue
        log.info("Order id_order=%s stateless. %s id_order_state=%s",
                  id_order, "would backfill to" if DRY_RUN else "backfilling to", resolved)
        if not DRY_RUN:
            backfill_via_history(id_order, resolved)
            repaired += 1
    log.info("Done. %d stateless order(s) found, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)


if __name__ == "__main__":
    run()
backfill-stateless-orders.js
/**
 * Find and backfill PrestaShop orders created via webservice with no state at all.
 *
 * OrderHistory::changeIdOrderState() is the only code path that both writes an
 * order_history row and updates the denormalized orders.current_state column, while
 * also firing the emails, stock, and invoice logic tied to that state. The webservice
 * orders resource exposes current_state as a plain writable field on the order object,
 * so a POST to /api/orders that omits it, or sets it directly, never runs the state
 * machine at all. The order is created with current_state at 0 (or an unapplied value)
 * and zero rows in order_history. This is documented on the PrestaShop forums under
 * "Create order via webservice won't set current state," and the reverse case, an
 * update adding an unexpected history row, is tracked as GitHub issue #11154.
 *
 * This script only ever repairs through order_histories, the same call the back
 * office makes internally. It never writes current_state directly onto an order,
 * since that is the exact bug being fixed. Run on a schedule. Safe to run again
 * and again, because a repaired order will show up with a real history row on the
 * next pass and no longer match the stateless filter.
 *
 * Guide: https://www.allanninal.dev/prestashop/webservice-order-created-without-state/
 */
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";

const PAID_HINTS = ["payment accepted", "paiement accepté", "paid"];
const AWAITING_HINTS = ["awaiting", "en attente"];

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

function usable(s) {
  const logable = String(s.logable ?? "0");
  const hidden = String(s.hidden ?? "0");
  return ["1", "true", "True"].includes(logable) && !["1", "true", "True"].includes(hidden);
}

/**
 * Pure decision function, no I/O.
 *
 * order: object with id_order, current_state, total_paid, total_paid_real, payment, valid.
 * orderStates: array of objects, each with id, name, logable, hidden.
 * Returns the id_order_state to backfill, or null when no safe decision can be made.
 */
export function resolveBackfillState(order, orderStates) {
  if (order.current_state !== 0 && order.current_state != null) return null;

  const candidates = orderStates.filter(usable);
  if (candidates.length === 0) return null;

  const totalPaid = Number(order.total_paid || 0);
  const totalPaidReal = Number(order.total_paid_real || 0);

  if (totalPaid > 0 && totalPaidReal >= totalPaid) {
    const paidStates = candidates.filter((s) =>
      PAID_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
    );
    if (paidStates.length === 1) return Number(paidStates[0].id);
    return null;
  }

  const awaitingStates = candidates.filter((s) =>
    AWAITING_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
  );
  if (awaitingStates.length === 0) return null;
  return Number(awaitingStates.reduce((a, b) => (Number(b.id) < Number(a.id) ? b : a)).id);
}

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 candidateOrders() {
  const data = await apiGet("orders", { display: "full", "filter[current_state]": "0", limit: "200" });
  return data.orders || [];
}

async function isStateless(idOrder) {
  const data = await apiGet("order_histories", {
    display: "full",
    "filter[id_order]": idOrder,
    limit: "1",
  });
  const rows = data.order_histories || [];
  return rows.length === 0;
}

async function orderStates() {
  const data = await apiGet("order_states", { display: "full" });
  return data.order_states || [];
}

async function backfillViaHistory(idOrder, resolvedStateId) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const body = { order_history: { id_order: idOrder, id_order_state: resolvedStateId } };
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
  return res.json();
}

export async function run() {
  const states = await orderStates();
  let flagged = 0;
  let repaired = 0;
  for (const order of await candidateOrders()) {
    const idOrder = order.id;
    if (!(await isStateless(idOrder))) continue;
    flagged++;
    const resolved = resolveBackfillState(order, states);
    if (resolved === null) {
      console.warn(`Order id_order=${idOrder} is stateless but could not be safely resolved. Flagging for review.`);
      continue;
    }
    console.log(`Order id_order=${idOrder} stateless. ${DRY_RUN ? "would backfill to" : "backfilling to"} id_order_state=${resolved}`);
    if (!DRY_RUN) {
      await backfillViaHistory(idOrder, resolved);
      repaired++;
    }
  }
  console.log(`Done. ${flagged} stateless order(s) found, ${repaired} repaired. DRY_RUN=${DRY_RUN}`);
}

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 state gets written back to a real order. Because we kept resolve_backfill_state pure, the test needs no network and no PrestaShop store. It just feeds in plain dicts and lists and checks the answer.

test_webservice_backfill.py
from backfill_stateless_orders import resolve_backfill_state

STATES = [
    {"id": "1", "name": "Awaiting check payment", "logable": "1", "hidden": "0"},
    {"id": "2", "name": "Payment accepted", "logable": "1", "hidden": "0"},
    {"id": "3", "name": "Awaiting bank wire payment", "logable": "1", "hidden": "0"},
    {"id": "6", "name": "Canceled", "logable": "0", "hidden": "0"},
    {"id": "7", "name": "Refunded", "logable": "0", "hidden": "1"},
]


def order(**over):
    base = {"id_order": 42, "current_state": 0, "total_paid": 100.0, "total_paid_real": 0.0,
            "payment": "Bank wire", "valid": False}
    base.update(over)
    return base


def test_returns_none_when_current_state_is_already_set():
    assert resolve_backfill_state(order(current_state=2), STATES) is None


def test_resolves_paid_state_when_fully_paid():
    assert resolve_backfill_state(order(total_paid_real=100.0), STATES) == 2


def test_resolves_paid_state_when_overpaid():
    assert resolve_backfill_state(order(total_paid_real=105.0), STATES) == 2


def test_resolves_lowest_awaiting_state_when_unpaid():
    assert resolve_backfill_state(order(), STATES) == 1


def test_returns_none_when_no_awaiting_states_exist():
    no_awaiting = [s for s in STATES if "wire" not in s["name"].lower() and "check" not in s["name"].lower()]
    assert resolve_backfill_state(order(), no_awaiting) is None


def test_returns_none_when_no_logable_states_exist():
    hidden_only = [dict(s, logable="0") for s in STATES]
    assert resolve_backfill_state(order(total_paid_real=100.0), hidden_only) is None


def test_returns_none_when_current_state_is_none():
    assert resolve_backfill_state(order(current_state=0), []) is None


def test_zero_total_paid_falls_back_to_awaiting():
    assert resolve_backfill_state(order(total_paid=0.0, total_paid_real=0.0), STATES) == 1
webservice-backfill.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveBackfillState } from "./backfill-stateless-orders.js";

const STATES = [
  { id: "1", name: "Awaiting check payment", logable: "1", hidden: "0" },
  { id: "2", name: "Payment accepted", logable: "1", hidden: "0" },
  { id: "3", name: "Awaiting bank wire payment", logable: "1", hidden: "0" },
  { id: "6", name: "Canceled", logable: "0", hidden: "0" },
  { id: "7", name: "Refunded", logable: "0", hidden: "1" },
];

const order = (over = {}) => ({
  id_order: 42,
  current_state: 0,
  total_paid: 100.0,
  total_paid_real: 0.0,
  payment: "Bank wire",
  valid: false,
  ...over,
});

test("returns null when current_state is already set", () => {
  assert.equal(resolveBackfillState(order({ current_state: 2 }), STATES), null);
});

test("resolves paid state when fully paid", () => {
  assert.equal(resolveBackfillState(order({ total_paid_real: 100.0 }), STATES), 2);
});

test("resolves paid state when overpaid", () => {
  assert.equal(resolveBackfillState(order({ total_paid_real: 105.0 }), STATES), 2);
});

test("resolves lowest awaiting state when unpaid", () => {
  assert.equal(resolveBackfillState(order(), STATES), 1);
});

test("returns null when no awaiting states exist", () => {
  const noAwaiting = STATES.filter((s) => !/wire|check/i.test(s.name));
  assert.equal(resolveBackfillState(order(), noAwaiting), null);
});

test("returns null when no logable states exist", () => {
  const hiddenOnly = STATES.map((s) => ({ ...s, logable: "0" }));
  assert.equal(resolveBackfillState(order({ total_paid_real: 100.0 }), hiddenOnly), null);
});

test("returns null when current_state is 0 and no states given", () => {
  assert.equal(resolveBackfillState(order({ current_state: 0 }), []), null);
});

test("zero total paid falls back to awaiting", () => {
  assert.equal(resolveBackfillState(order({ total_paid: 0.0, total_paid_real: 0.0 }), STATES), 1);
});

Case studies

Marketplace sync

The connector that only set current_state on the order

A marketplace integration pushed orders into PrestaShop through /api/orders, setting current_state in the same request body as everything else, the same way it worked on the platform it was ported from. Weeks later, finance noticed a batch of orders that back-office reports treated as unpaid even though the connector logs showed a state value had been sent.

Running the diagnostic against filter[current_state]=0 only caught part of it, since some of these orders had a non-zero value with still no history behind them, until the team also checked order_histories directly for every recent connector order. Once confirmed stateless, the resolver used each order's total_paid_real to backfill the correct state through order_histories, and the connector was fixed to POST a history record after creation instead of setting the field directly.

Draft order import

The bulk import that left current_state at 0

A migration script imported thousands of historical orders through the webservice, omitting current_state entirely because the source system did not track it the same way. Every imported order sat at current_state=0 with an empty history, and the back office's default order list quietly excluded them from the usual status filters, so nobody noticed until a customer asked about an order that "did not exist" in support's view.

The script ran in dry run first across the whole import batch, logging the resolved state for every order based on its recorded payment totals. The team reviewed the log, confirmed the payment-based resolution looked right, and ran it for real, giving every imported order the history entry the migration never wrote.

What good looks like

After this runs on a schedule, no order the webservice created can sit indefinitely with an empty history and a meaningless current_state. Every repair goes through order_histories, the same path the back office uses, so invoices, emails, and stock effects fire the way they would for any normal transition. Orders the resolver cannot confidently place are left alone for a human, so nothing gets a guessed state it never earned.

FAQ

Why does an order created through the PrestaShop webservice have no state at all?

The webservice orders resource exposes current_state as a plain writable field on the order object, but POSTing to /api/orders does not run the order through OrderHistory::changeIdOrderState(), which is the only code path that both sets current_state and writes the matching order_history row. A client that omits current_state or sets it directly gets an order stored with current_state at 0 or an unintended value and zero rows in order_history, because only a follow-up POST to order_histories actually triggers a real state transition.

Is it safe to fix these orders by setting current_state directly on the order?

No. PUTting current_state directly onto /api/orders is the exact bug being fixed, since it bypasses the invoice, email, and stock side effects that only run through OrderHistory::changeIdOrderState(). The safe repair is to POST a new resource to /api/order_histories with the order id and the resolved state id, the same call the back office makes internally, so the order gets a real history row and its current_state is refreshed as a side effect.

How do I detect orders that were created without a real state?

List orders with current_state=0 using GET /api/orders?filter[current_state]=0, then for each candidate call GET /api/order_histories?filter[id_order]=[id]&limit=1. If that returns an empty order_histories array, the order is confirmed stateless regardless of what current_state shows, since current_state can be stale or denormalized. Cross-check GET /api/order_states to resolve a sane default state to backfill.

Related field notes

Citations

On the problem:

  1. PrestaShop Forums: Create order via webservice won't set current state. forum.prestashop.com/topic/1023675-create-order-via-webservice-wont-set-current-state/
  2. PrestaShop Forums: How set a state for an order at creation time? forum.prestashop.com/topic/840953-how-set-a-state-for-an-order-at-creation-time/
  3. PrestaShop GitHub: Update order with Webservice adds new order's status line. Issue #11154. github.com/PrestaShop/PrestaShop/issues/11154

On the solution:

  1. PrestaShop Developer Documentation: Order histories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_histories/
  2. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
  3. 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.

Contact me on LinkedIn

Did this untangle a stateless order?

If this saved you a confusing support ticket or a manual audit of 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

Back to all PrestaShop field notes