Diagnostic
Order's carrier reference becomes invalid after editing order lines
A staff member edits the quantity on an order line to fix a picking mistake. PrestaShop recalculates the shipping, and the order screen throws back "The order carrier ID is invalid." The order was fine an hour ago. Nothing about the customer's shipment actually changed. Here is why a carrier that was deleted or edited months ago can leave an order's carrier reference dead, and a script that finds every order this has already happened to.
PrestaShop never removes a carrier row when you delete it in the back office, it only sets carrier.deleted = 1, and editing a carrier's settings actually duplicates the row under the same id_reference and hides the old one. Either way, old orders keep pointing at an id_carrier that is now invisible, and editing an order's lines can trigger a shipping recalculation that surfaces "The order carrier ID is invalid" (PrestaShop core issue #24307). Run a Python or Node.js script that pulls each order's id_carrier with GET /api/orders, builds a set of valid, non-deleted carrier ids from GET /api/carriers, cross-checks against GET /api/order_carriers, and flags any order whose carrier is zero, missing, or deleted. Full code, tests, and citations are below.
The problem in plain words
When a customer checks out, PrestaShop validates the cart into an order and writes the chosen carrier's id into orders.id_carrier, plus a matching row in order_carrier. That link looks permanent, but it is not. A carrier can be deleted or edited long after the order shipped, and PrestaShop's own back office never keeps that old reference alive for orders that already used it.
"Deleting" a carrier in the back office does not remove its row from the carrier table. It flips deleted to 1, which is enough to hide the carrier from every dropdown, list, and most webservice calls, but the row and its id still exist. Editing a carrier's settings is worse in a subtle way: PrestaShop does not update the existing row in place, it creates a brand new carrier row, re-parents it under the same id_reference as the original, and quietly deletes the old one. So even a carrier a merchant only ever "edited," never deleted on purpose, leaves every historic order pointing at an id that is now gone.
Why it happens
PrestaShop's carrier table was not designed to preserve history for orders that already reference a row. A few things push this into the open:
- Deleting a carrier in the back office only sets
carrier.deleted = 1. The row never leaves the database, but it disappears from every UI dropdown and from the default webservice list, so an order pointing at it looks orphaned even though the id technically still exists. - Editing a carrier's price, delay, or logo does not update the row in place. PrestaShop duplicates it into a new row, re-parents the new row under the same
id_referenceas the original, and marks the old row deleted. Historic orders keep the old, now-dead id. - Editing an order's product lines or quantities in the back office recalculates shipping, and that recalculation path can hit "The order carrier ID is invalid," a regression tracked in PrestaShop core issue #24307, which surfaces the dead reference even though the line edit itself has nothing to do with carriers.
- PrestaShop core issue #17355 documents that once an order's carrier is deleted or edited away, the back office blocks editing that order's shipping and tracking details entirely, so staff cannot even fix the display by hand from the order screen.
- A webservice order-creation bug, #11945, can leave a brand new order with
id_carrier = 0from the start, which is a different path to the same kind of invalid reference.
None of this corrupts what the customer was actually charged or shipped. What breaks is PrestaShop's own ability to look the carrier back up, right at the moment staff try to touch the order again. See the citations at the end for the exact issues and docs.
This is not safe to auto-fix silently. PrestaShop has no supported webservice endpoint to repoint an order at a replacement carrier, and the back office itself refuses to edit shipping on these orders, per issue #17355. Guessing a replacement carrier changes what the customer was promised. The safe default is to flag the order with its dead id_carrier, the reason, and the last known id_reference, so a human decides whether to remap it to the carrier that currently shares that reference or contact the customer directly.
The fix, as a flow
We do not touch orders by default. We add a job that pulls each order's id_carrier, builds the set of currently valid, non-deleted carrier ids, cross-checks against the authoritative order_carrier row, and classifies every order as ok, zero, missing, or deleted. Anything not ok gets reported with enough context for a human to act on. A corrective write only ever runs in the narrow case where a currently active carrier shares the dead carrier's id_reference, and only when explicitly enabled.
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, carriers, and order_carriers, plus write access to orders, order_carriers, and order_histories only if you plan to run confirmed 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 reports 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 reports by default
List the orders and their stored carrier id
Call GET /api/orders?display=full&output_format=JSON, optionally filtered by date_upd to scope recently-edited orders. For each order, read id, reference, and id_carrier.
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 list_orders(date_upd_from=None):
params = {"display": "full"}
if date_upd_from:
params["filter[date_upd]"] = f">[{date_upd_from}]"
data = api_get("orders", params=params)
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 listOrders(dateUpdFrom) {
const params = { display: "full" };
if (dateUpdFrom) params["filter[date_upd]"] = `>[${dateUpdFrom}]`;
const data = await apiGet("orders", params);
return data.orders || [];
}
Build the set of valid, non-deleted carriers
Call GET /api/carriers?display=full&output_format=JSON without a deleted filter, since the default list already hides deleted = 1 rows and we need to see both. Keep only carriers where deleted == "0" in the valid set, and separately track the ids where deleted == "1".
def carrier_sets():
data = api_get("carriers", params={"display": "full", "filter[deleted]": "[0,1]"})
carriers = data.get("carriers") or []
valid_ids = {int(c["id"]) for c in carriers if str(c.get("deleted")) == "0"}
deleted_ids = {int(c["id"]) for c in carriers if str(c.get("deleted")) == "1"}
return valid_ids, deleted_ids
def carrier_by_id(id_carrier):
# Deleted rows remain readable by exact id even though they are hidden from lists.
data = api_get(f"carriers/{id_carrier}", params={})
return (data or {}).get("carrier")
async function carrierSets() {
const data = await apiGet("carriers", { display: "full", "filter[deleted]": "[0,1]" });
const carriers = data.carriers || [];
const validIds = new Set(carriers.filter((c) => String(c.deleted) === "0").map((c) => Number(c.id)));
const deletedIds = new Set(carriers.filter((c) => String(c.deleted) === "1").map((c) => Number(c.id)));
return { validIds, deletedIds };
}
async function carrierById(idCarrier) {
// Deleted rows remain readable by exact id even though they are hidden from lists.
const data = await apiGet(`carriers/${idCarrier}`, {});
return data?.carrier;
}
Decide, with one pure function
Keep the classification in its own function that takes only three plain inputs: the order's id_carrier, the set of valid non-deleted carrier ids, and the set of ids known to be soft-deleted. It returns one of four strings and touches no network, so it is trivial to unit test.
def classify_order_carrier(order_id_carrier, valid_carrier_ids, deleted_carrier_ids):
if order_id_carrier == 0 or order_id_carrier is None:
return "zero"
if order_id_carrier in deleted_carrier_ids:
return "deleted"
if order_id_carrier not in valid_carrier_ids and order_id_carrier not in deleted_carrier_ids:
return "missing"
return "ok"
export function classifyOrderCarrier(orderIdCarrier, validCarrierIds, deletedCarrierIds) {
if (orderIdCarrier === 0 || orderIdCarrier === null || orderIdCarrier === undefined) return "zero";
if (deletedCarrierIds.has(orderIdCarrier)) return "deleted";
if (!validCarrierIds.has(orderIdCarrier) && !deletedCarrierIds.has(orderIdCarrier)) return "missing";
return "ok";
}
Cross-check order_carrier and report by default
For any order the classifier flags, call GET /api/order_carriers?filter[id_order]=<id>&display=full&output_format=JSON to see what carrier was actually billed and shipped against, since it can drift from orders.id_carrier after a line edit. Then fetch the dead carrier's own row by exact id, even when deleted, to recover its id_reference for the report. The script never writes by default. It only emits one record per affected order.
def order_carrier_rows(id_order):
data = api_get("order_carriers", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_carriers") or []
def build_report_row(order, reason, dead_carrier):
return {
"id": order["id"],
"reference": order.get("reference"),
"id_carrier": order.get("id_carrier"),
"carrier_valid": False,
"reason": reason,
"last_known_id_reference": (dead_carrier or {}).get("id_reference"),
}
async function orderCarrierRows(idOrder) {
const data = await apiGet("order_carriers", { "filter[id_order]": idOrder, display: "full" });
return data.order_carriers || [];
}
function buildReportRow(order, reason, deadCarrier) {
return {
id: order.id,
reference: order.reference,
id_carrier: order.id_carrier,
carrier_valid: false,
reason,
last_known_id_reference: deadCarrier ? deadCarrier.id_reference : null,
};
}
Wire it together with a dry run guard
The loop ties every piece together: list orders, build the carrier sets once, classify each order, and log a report row for anything not ok. DRY_RUN defaults to true. Even when explicitly set to false, the only automated write allowed is the narrow case: an active carrier currently shares the dead carrier's id_reference. In that case, PUT the order's id_carrier, PUT the matching order_carriers row, and log the change through order_histories. Otherwise the script always just reports.
Always start with DRY_RUN=true. There is no supported PrestaShop endpoint to repoint an order's carrier, and the back office blocks editing shipping on these orders by design. Treat every report row as a lead for staff to check against the customer's actual shipment, and only ever let the corrective path run for the unambiguous case where a live carrier shares the dead one's id_reference.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, lists orders, builds the valid and deleted carrier sets once, classifies every order's carrier with a pure function, reports every problem, and only ever writes a corrective repoint in the narrow case described above.
"""Detect PrestaShop orders whose carrier reference has gone invalid.
PrestaShop never removes a carrier row when you delete it in the back office, it only
sets carrier.deleted = 1, so old orders keep pointing at an id that is now hidden from
every UI and most webservice lists. Editing a carrier's settings is worse: PrestaShop
duplicates the row under the same id_reference and hides the old one, so historic orders
keep referencing a dead id. Editing an order's product lines can trigger a shipping
recalculation that surfaces "The order carrier ID is invalid" (core issue #24307), and
core issue #17355 documents that the back office then blocks editing that order's
shipping and tracking at all. A webservice bug (#11945) can also leave id_carrier at 0.
This script flags affected orders by default. It never repoints an order's carrier unless
DRY_RUN is explicitly false, and even then it only writes when a currently active carrier
shares the dead carrier's id_reference. Every other case is left for a human.
Run against recent orders 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("check_order_carrier")
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"
DATE_UPD_FROM = os.environ.get("DATE_UPD_FROM", "")
AUTH = (PRESTASHOP_WS_KEY, "")
def classify_order_carrier(order_id_carrier, valid_carrier_ids, deleted_carrier_ids):
"""Pure decision logic, no I/O.
Returns "zero" if order_id_carrier is 0 or None, "deleted" if it is a known
soft-deleted carrier id, "missing" if it is not in either set, otherwise "ok".
"""
if order_id_carrier == 0 or order_id_carrier is None:
return "zero"
if order_id_carrier in deleted_carrier_ids:
return "deleted"
if order_id_carrier not in valid_carrier_ids and order_id_carrier not in deleted_carrier_ids:
return "missing"
return "ok"
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 api_put(path, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def list_orders(date_upd_from=None):
params = {"display": "full"}
if date_upd_from:
params["filter[date_upd]"] = f">[{date_upd_from}]"
data = api_get("orders", params=params)
return data.get("orders") or []
def carrier_sets():
data = api_get("carriers", params={"display": "full", "filter[deleted]": "[0,1]"})
carriers = data.get("carriers") or []
valid_ids = {int(c["id"]) for c in carriers if str(c.get("deleted")) == "0"}
deleted_ids = {int(c["id"]) for c in carriers if str(c.get("deleted")) == "1"}
return valid_ids, deleted_ids
def carrier_by_id(id_carrier):
data = api_get(f"carriers/{id_carrier}", params={})
return (data or {}).get("carrier")
def order_carrier_rows(id_order):
data = api_get("order_carriers", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_carriers") or []
def carrier_with_reference(active_carriers, id_reference):
for c in active_carriers:
if str(c.get("id_reference")) == str(id_reference) and str(c.get("deleted")) == "0":
return c
return None
def build_report_row(order, reason, dead_carrier):
return {
"id": order["id"],
"reference": order.get("reference"),
"id_carrier": order.get("id_carrier"),
"carrier_valid": False,
"reason": reason,
"last_known_id_reference": (dead_carrier or {}).get("id_reference"),
}
def repoint_order_carrier(order, order_carrier_row, new_id_carrier):
order["id_carrier"] = new_id_carrier
api_put(f"orders/{order['id']}", {"order": order})
if order_carrier_row:
order_carrier_row["id_carrier"] = new_id_carrier
api_put(f"order_carriers/{order_carrier_row['id']}", {"order_carrier": order_carrier_row})
api_put("order_histories", {"order_history": {"id_order": order["id"], "id_order_state": order.get("current_state")}})
def run():
orders = list_orders(DATE_UPD_FROM or None)
valid_ids, deleted_ids = carrier_sets()
all_carriers_data = api_get("carriers", params={"display": "full", "filter[deleted]": "[0,1]"})
all_carriers = all_carriers_data.get("carriers") or []
flagged = 0
repaired = 0
for order in orders:
id_carrier = order.get("id_carrier")
id_carrier = int(id_carrier) if id_carrier not in (None, "") else 0
reason = classify_order_carrier(id_carrier, valid_ids, deleted_ids)
if reason == "ok":
continue
flagged += 1
dead_carrier = carrier_by_id(id_carrier) if id_carrier else None
row = build_report_row(order, reason, dead_carrier)
log.warning(
"Invalid order carrier. id=%s reference=%s id_carrier=%s reason=%s last_known_id_reference=%s",
row["id"], row["reference"], row["id_carrier"], row["reason"], row["last_known_id_reference"],
)
if not DRY_RUN and dead_carrier and dead_carrier.get("id_reference"):
replacement = carrier_with_reference(all_carriers, dead_carrier["id_reference"])
if replacement:
oc_rows = order_carrier_rows(order["id"])
oc_row = oc_rows[0] if oc_rows else None
repoint_order_carrier(order, oc_row, int(replacement["id"]))
repaired += 1
log.info("Repointed id_order=%s to active carrier id=%s.", order["id"], replacement["id"])
else:
log.warning("Skipping repair for id_order=%s: no active carrier shares id_reference=%s.",
order["id"], dead_carrier["id_reference"])
log.info("Done. %d order(s) flagged, %d repointed. DRY_RUN=%s", flagged, repaired, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop orders whose carrier reference has gone invalid.
*
* PrestaShop never removes a carrier row when you delete it in the back office, it only
* sets carrier.deleted = 1, so old orders keep pointing at an id that is now hidden from
* every UI and most webservice lists. Editing a carrier's settings is worse: PrestaShop
* duplicates the row under the same id_reference and hides the old one, so historic
* orders keep referencing a dead id. Editing an order's product lines can trigger a
* shipping recalculation that surfaces "The order carrier ID is invalid" (core issue
* #24307), and core issue #17355 documents that the back office then blocks editing that
* order's shipping and tracking at all. A webservice bug (#11945) can also leave
* id_carrier at 0.
*
* This script flags affected orders by default. It never repoints an order's carrier
* unless DRY_RUN is explicitly false, and even then it only writes when a currently
* active carrier shares the dead carrier's id_reference. Every other case is left for a
* human.
*
* Guide: https://www.allanninal.dev/prestashop/order-carrier-invalid-after-line-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";
const DATE_UPD_FROM = process.env.DATE_UPD_FROM || "";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision logic, no I/O.
*
* Returns "zero" if orderIdCarrier is 0, null, or undefined, "deleted" if it is a known
* soft-deleted carrier id, "missing" if it is in neither set, otherwise "ok".
*/
export function classifyOrderCarrier(orderIdCarrier, validCarrierIds, deletedCarrierIds) {
if (orderIdCarrier === 0 || orderIdCarrier === null || orderIdCarrier === undefined) return "zero";
if (deletedCarrierIds.has(orderIdCarrier)) return "deleted";
if (!validCarrierIds.has(orderIdCarrier) && !deletedCarrierIds.has(orderIdCarrier)) return "missing";
return "ok";
}
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 apiPut(path, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
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 ${path}`);
return res.json();
}
async function listOrders(dateUpdFrom) {
const params = { display: "full" };
if (dateUpdFrom) params["filter[date_upd]"] = `>[${dateUpdFrom}]`;
const data = await apiGet("orders", params);
return data.orders || [];
}
async function carrierSets() {
const data = await apiGet("carriers", { display: "full", "filter[deleted]": "[0,1]" });
const carriers = data.carriers || [];
const validIds = new Set(carriers.filter((c) => String(c.deleted) === "0").map((c) => Number(c.id)));
const deletedIds = new Set(carriers.filter((c) => String(c.deleted) === "1").map((c) => Number(c.id)));
return { validIds, deletedIds, carriers };
}
async function carrierById(idCarrier) {
const data = await apiGet(`carriers/${idCarrier}`, {});
return data?.carrier;
}
async function orderCarrierRows(idOrder) {
const data = await apiGet("order_carriers", { "filter[id_order]": idOrder, display: "full" });
return data.order_carriers || [];
}
function carrierWithReference(activeCarriers, idReference) {
return (
activeCarriers.find((c) => String(c.id_reference) === String(idReference) && String(c.deleted) === "0") || null
);
}
function buildReportRow(order, reason, deadCarrier) {
return {
id: order.id,
reference: order.reference,
id_carrier: order.id_carrier,
carrier_valid: false,
reason,
last_known_id_reference: deadCarrier ? deadCarrier.id_reference : null,
};
}
async function repointOrderCarrier(order, orderCarrierRow, newIdCarrier) {
order.id_carrier = newIdCarrier;
await apiPut(`orders/${order.id}`, { order });
if (orderCarrierRow) {
orderCarrierRow.id_carrier = newIdCarrier;
await apiPut(`order_carriers/${orderCarrierRow.id}`, { order_carrier: orderCarrierRow });
}
await apiPut("order_histories", { order_history: { id_order: order.id, id_order_state: order.current_state } });
}
export async function run() {
const orders = await listOrders(DATE_UPD_FROM || undefined);
const { validIds, deletedIds, carriers } = await carrierSets();
let flagged = 0;
let repaired = 0;
for (const order of orders) {
const rawIdCarrier = order.id_carrier;
const idCarrier = rawIdCarrier === null || rawIdCarrier === undefined || rawIdCarrier === "" ? 0 : Number(rawIdCarrier);
const reason = classifyOrderCarrier(idCarrier, validIds, deletedIds);
if (reason === "ok") continue;
flagged++;
const deadCarrier = idCarrier ? await carrierById(idCarrier) : null;
const row = buildReportRow(order, reason, deadCarrier);
console.warn(
`Invalid order carrier. id=${row.id} reference=${row.reference} id_carrier=${row.id_carrier} reason=${row.reason} last_known_id_reference=${row.last_known_id_reference}`
);
if (!DRY_RUN && deadCarrier && deadCarrier.id_reference) {
const replacement = carrierWithReference(carriers, deadCarrier.id_reference);
if (replacement) {
const ocRows = await orderCarrierRows(order.id);
const ocRow = ocRows[0] || null;
await repointOrderCarrier(order, ocRow, Number(replacement.id));
repaired++;
console.log(`Repointed id_order=${order.id} to active carrier id=${replacement.id}.`);
} else {
console.warn(`Skipping repair for id_order=${order.id}: no active carrier shares id_reference=${deadCarrier.id_reference}.`);
}
}
}
console.log(`Done. ${flagged} order(s) flagged, ${repaired} repointed. 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 classification function is the part most worth testing, because it decides which orders get reported as broken. Because we kept classify_order_carrier pure, taking only the order's carrier id and two plain sets, the tests need no network and no PrestaShop store.
from check_order_carrier import classify_order_carrier
VALID = {1, 2, 3}
DELETED = {5, 6}
def test_ok_when_carrier_is_valid():
assert classify_order_carrier(2, VALID, DELETED) == "ok"
def test_zero_when_carrier_id_is_zero():
assert classify_order_carrier(0, VALID, DELETED) == "zero"
def test_zero_when_carrier_id_is_none():
assert classify_order_carrier(None, VALID, DELETED) == "zero"
def test_deleted_when_carrier_is_soft_deleted():
assert classify_order_carrier(5, VALID, DELETED) == "deleted"
def test_missing_when_carrier_is_in_neither_set():
assert classify_order_carrier(99, VALID, DELETED) == "missing"
def test_ok_takes_priority_when_id_appears_valid_only():
assert classify_order_carrier(1, VALID, DELETED) == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderCarrier } from "./check-order-carrier.js";
const VALID = new Set([1, 2, 3]);
const DELETED = new Set([5, 6]);
test("ok when carrier is valid", () => {
assert.equal(classifyOrderCarrier(2, VALID, DELETED), "ok");
});
test("zero when carrier id is zero", () => {
assert.equal(classifyOrderCarrier(0, VALID, DELETED), "zero");
});
test("zero when carrier id is null", () => {
assert.equal(classifyOrderCarrier(null, VALID, DELETED), "zero");
});
test("deleted when carrier is soft deleted", () => {
assert.equal(classifyOrderCarrier(5, VALID, DELETED), "deleted");
});
test("missing when carrier is in neither set", () => {
assert.equal(classifyOrderCarrier(99, VALID, DELETED), "missing");
});
test("ok takes priority when id appears valid only", () => {
assert.equal(classifyOrderCarrier(1, VALID, DELETED), "ok");
});
Case studies
The courier the store stopped using a year ago
A homeware store dropped a regional courier and deleted it from the back office. Hundreds of old orders had shipped through it, but nobody thought about them again until a support agent tried to fix a wrong quantity on one of those old orders. The order screen threw "The order carrier ID is invalid" the moment they saved the line edit.
Running the diagnostic across recently edited orders surfaced the same deleted reason on every order that had used that courier. Staff could see the dead carrier's id_reference in the report and confirmed by hand which current carrier the store had renamed it to, before touching anything.
The carrier that got a new logo and a new id
A merchant updated a carrier's delay text and logo through the normal settings screen, not realizing PrestaShop would duplicate the row under a new id and quietly retire the old one. Weeks later, a warehouse correction on an order line that had shipped under the old carrier id tripped the same invalid carrier error.
The report flagged it as deleted with the old carrier's id_reference attached, which matched the id_reference on the carrier the merchant thought they had just "edited." That match made it an easy, confident repoint instead of a guess.
After this runs against your orders, a carrier that was deleted or edited months ago stops being a landmine that only goes off the next time someone touches an unrelated order line. Staff get a dated report with the exact id, reference, dead id_carrier, reason, and last known id_reference for every affected order, and the only automated write ever attempted is the narrow, unambiguous case where a live carrier already shares that reference.
FAQ
Why does my PrestaShop order say the order carrier ID is invalid?
PrestaShop never actually deletes a carrier when you delete it in the back office, it only sets carrier.deleted to 1 so the carrier disappears from every list while the row still exists. Editing a carrier's settings is worse: PrestaShop duplicates the carrier row and moves the old id under the same id_reference, then hides the old one. Either way, orders that were validated against the old id keep pointing at a carrier the UI now refuses to recognize, and editing the order's lines can trigger a shipping recalculation that surfaces the invalid reference.
Can I edit the shipping or tracking on an order with a deleted carrier?
No. PrestaShop's back office blocks editing shipping and tracking details on an order once its carrier has been deleted or replaced by an edit, which is documented in PrestaShop core issue #17355. The order is not broken for the customer, but staff cannot change the carrier or tracking number through the normal order screen anymore.
Is it safe to automatically repoint an order to a new carrier?
Not by guessing. There is no supported webservice endpoint to repoint an order, and picking the wrong replacement changes what the customer was told they would be charged or how their package would ship. The safe default is to flag the order with its dead id_carrier and the last known id_reference for a human to review. A scripted repair should only ever run when a currently active carrier shares that same id_reference, and even then it should be logged to order_histories.
Related field notes
Citations
On the problem:
- PrestaShop/PrestaShop GitHub issue #24307: BO - Orders page - The order carrier ID is invalid. github.com/PrestaShop/PrestaShop/issues/24307
- PrestaShop/PrestaShop GitHub issue #17355: Can't edit order shipping for deleted/edited carriers. github.com/PrestaShop/PrestaShop/issues/17355
- PrestaShop/PrestaShop GitHub issue #11945: WebService - Create order id_carrier value 0. github.com/PrestaShop/PrestaShop/issues/11945
On the solution:
- PrestaShop Developer Documentation: Carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/carriers/
- PrestaShop Developer Documentation: Order carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_carriers/
- PrestaShop Webservice Developer Documentation: Reference. devdocs.prestashop-project.org/9/webservice/reference/
Stuck on a tricky one?
If you have a problem in PrestaShop orders, carriers, totals, 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 catch an invalid carrier reference?
If this saved you a confusing support ticket or a blocked order edit, 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