Diagnostic
Editing a carrier assigns it a new id and orphans old references
A staff member opens a carrier in the back office, tweaks the delivery price, and saves. Nothing about the change looks dangerous. But PrestaShop just quietly retired the carrier's old id_carrier and gave it a brand new one. Any module, integration, or old order that cached that original id is now pointing at a row marked deleted. Here is why a simple carrier edit does this on purpose, and a script that finds every order left behind.
PrestaShop's Carrier::update() never modifies the carrier row in place. Because historical orders must keep showing the shipping price and tax rules that were active when they were placed, every backoffice edit inserts a brand new row with a new id_carrier, copies over the stable id_reference, and marks the old row deleted=1 with active=0. Any order, module, or script that cached the old id_carrier now points at a dead row. Run a Python or Node.js script that pulls every carrier with GET /api/carriers?display=full&output_format=JSON, pulls every order or order_carrier row, and flags any id_carrier that is missing entirely or present with deleted=1. It also resolves the current live successor sharing the same id_reference, so a human can decide whether to remap. Full code, tests, and citations are below.
The problem in plain words
When an order is placed, PrestaShop writes the chosen carrier's id into orders.id_carrier and into a matching order_carrier row, along with the shipping cost and tax that applied at that moment. That link is meant to preserve history: whatever the customer was actually charged should stay attached to that order forever, even if the store later changes its shipping setup.
The trouble is what "changes its shipping setup" means in PrestaShop's own code. Editing a carrier's price, delay, zones, or logo in the back office does not update the existing carrier row. It inserts a new row with a new id_carrier, copies the id_reference onto it so the edit history stays linked, and then soft-deletes the old row by setting deleted=1 and active=0. The old id still exists in the database, but it is gone from every dropdown, most webservice list calls, and any place that expects deleted=0. Anything that cached that old id_carrier, whether that is a module, a one-off integration script, or simply an order placed before the edit, is now pointing at a row the store considers dead.
Why it happens
This is long-documented core behavior, not a regression. A few things make it easy to run into:
- PrestaShop needs historical orders to keep showing the exact shipping price, tax rule, and carrier name that applied when the order was placed. Updating a carrier row in place would silently rewrite that history for every past order that used it, so the core avoids that by never touching the old row.
Carrier::update()instead duplicates the row: it inserts a newid_carrier, copies the stableid_referenceonto the new row so the edit lineage is traceable, and setsdeleted=1plusactive=0on the old row rather than issuing a hardDELETE.- Any external system, module, or one-off script that cached the old
id_carrierinstead ofid_referencenow points at a row withdeleted=1, so joins against it silently return no active carrier. - Old orders are affected the same way:
orders.id_carrierandorder_carrier.id_carrierstill contain the id that existed at checkout time, orphaning them from whatever the "live" carrier definition has become. - Module authors are expected to listen for the
actionCarrierUpdatehook, which PrestaShop fires with both the old and new ids specifically so modules can repoint their own stored references. Code that skips this hook is exactly the code that ends up with stale ids.
None of this corrupts what a customer was actually charged. What breaks is any downstream system's ability to look the carrier back up by the id it originally saw. See the citations at the end for the exact issues and docs.
Do not auto-rewrite orders.id_carrier or order_carrier.id_carrier. PrestaShop stores the historical shipping cost and tax alongside that id specifically so past orders reflect what the customer was actually charged. Silently repointing it to a new id_carrier changes financial and historical fidelity and can misrepresent the tax rule applied at sale time. The safe default is to flag and report: find every orphaned order, resolve the current live successor sharing the same id_reference, and leave the decision to a human, only writing a remap when it is explicitly confirmed for a specific order.
The fix, as a flow
We do not touch orders by default. We add a job that pulls every carrier, builds a map of id to its deleted and active flags plus its id_reference, pulls every order's id_carrier (or the order_carrier join rows), and classifies each one as orphaned or fine using a pure function. For anything orphaned, it looks up the successor row sharing the same id_reference so the report tells a human exactly which live carrier the order logically maps to today.
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 carriers, orders, and order_carriers, plus write access to orders only if you plan to run confirmed remaps. 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
Pull every carrier and build the id and id_reference maps
Call GET /api/carriers?display=full&output_format=JSON. Note that deleted is not a filterable field in the webservice, so you cannot filter server-side by deleted=1. Fetch the full set and filter client-side. Build a map keyed by id to {deleted, active, id_reference, name}, so any order's id_carrier can be resolved locally without another round trip.
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 carriers_by_id():
# deleted is not filterable server-side, so pull the full set and filter here.
data = api_get("carriers", params={"display": "full"})
carriers = data.get("carriers") or []
by_id = {}
for c in carriers:
cid = int(c["id"])
by_id[cid] = {
"id": cid,
"id_reference": int(c.get("id_reference") or 0),
"deleted": str(c.get("deleted")) == "1",
"active": str(c.get("active")) == "1",
"name": c.get("name"),
}
return by_id
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 carriersById() {
// deleted is not filterable server-side, so pull the full set and filter here.
const data = await apiGet("carriers", { display: "full" });
const carriers = data.carriers || [];
const byId = {};
for (const c of carriers) {
const cid = Number(c.id);
byId[cid] = {
id: cid,
id_reference: Number(c.id_reference || 0),
deleted: String(c.deleted) === "1",
active: String(c.active) === "1",
name: c.name,
};
}
return byId;
}
List orders and their stored carrier id
Call GET /api/orders?display=full&output_format=JSON, or paginate with filter[current_state] to scope a batch. For each order read id, reference, and id_carrier. You can also read GET /api/order_carriers?display=full&output_format=JSON for the join rows, which carry id_carrier alongside id_order_invoice, weight, and shipping_cost_tax_excl at the time of shipment.
def list_orders(current_state=None):
params = {"display": "full"}
if current_state:
params["filter[current_state]"] = current_state
data = api_get("orders", params=params)
return data.get("orders") or []
def order_carrier_rows(id_order=None):
params = {"display": "full"}
if id_order:
params["filter[id_order]"] = id_order
data = api_get("order_carriers", params=params)
return data.get("order_carriers") or []
async function listOrders(currentState) {
const params = { display: "full" };
if (currentState) params["filter[current_state]"] = currentState;
const data = await apiGet("orders", params);
return data.orders || [];
}
async function orderCarrierRows(idOrder) {
const params = { display: "full" };
if (idOrder) params["filter[id_order]"] = idOrder;
const data = await apiGet("order_carriers", params);
return data.order_carriers || [];
}
Decide, with one pure function
Keep the decision in its own function that takes only the list of orders and the carrier map, plain data structures only. For each order, look up its id_carrier in the map. If the entry is missing, or present with deleted true, the order is orphaned. Then scan the map for the row sharing the same id_reference where deleted is false, preferring one that is also active, and return it as the suggested successor. No network, no side effects, fully unit-testable with in-memory dicts.
def find_orphaned_carrier_refs(orders, carriers_by_id):
results = []
for order in orders:
id_order = order.get("id_order", order.get("id"))
id_carrier = order.get("id_carrier")
id_carrier = int(id_carrier) if id_carrier not in (None, "") else 0
entry = carriers_by_id.get(id_carrier)
if entry is None:
reason = "missing"
elif entry.get("deleted"):
reason = "deleted"
else:
continue
id_reference = (entry or {}).get("id_reference")
successor = None
if id_reference is not None:
candidates = [
c for c in carriers_by_id.values()
if c.get("id_reference") == id_reference and not c.get("deleted")
]
candidates.sort(key=lambda c: c.get("active") is not True)
successor = candidates[0]["id"] if candidates else None
results.append({
"id_order": id_order,
"old_id_carrier": id_carrier,
"orphan_reason": reason,
"id_reference": id_reference,
"successor_id_carrier": successor,
})
return results
export function findOrphanedCarrierRefs(orders, carriersById) {
const results = [];
for (const order of orders) {
const idOrder = order.id_order ?? order.id;
const rawIdCarrier = order.id_carrier;
const idCarrier = rawIdCarrier === null || rawIdCarrier === undefined || rawIdCarrier === ""
? 0
: Number(rawIdCarrier);
const entry = carriersById[idCarrier];
let reason;
if (!entry) {
reason = "missing";
} else if (entry.deleted) {
reason = "deleted";
} else {
continue;
}
const idReference = entry ? entry.id_reference : null;
let successor = null;
if (idReference !== null && idReference !== undefined) {
const candidates = Object.values(carriersById).filter(
(c) => c.id_reference === idReference && !c.deleted
);
candidates.sort((a, b) => Number(b.active) - Number(a.active));
successor = candidates.length ? candidates[0].id : null;
}
results.push({
id_order: idOrder,
old_id_carrier: idCarrier,
orphan_reason: reason,
id_reference: idReference,
successor_id_carrier: successor,
});
}
return results;
}
Report by default, remap only with an explicit confirm
Log one report row per orphaned order with the id, the dead id_carrier, the reason, the id_reference, and the candidate successor. The only guarded write is PUT /api/orders/{id_order} updating id_carrier to the resolved successor id, and it must stay behind DRY_RUN=true by default plus a per-order allowlist, since it mutates order history.
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 remap_order_carrier(order, new_id_carrier):
order["id_carrier"] = new_id_carrier
return api_put(f"orders/{order['id']}", {"order": order})
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 remapOrderCarrier(order, newIdCarrier) {
order.id_carrier = newIdCarrier;
return apiPut(`orders/${order.id}`, { order });
}
Wire it together with a dry run guard and an allowlist
The loop ties every piece together: build the carrier map once, list orders, call find_orphaned_carrier_refs, and log a report row for anything orphaned. DRY_RUN defaults to true and makes zero write requests. Even with DRY_RUN=false, a remap only runs for orders whose id appears in an explicit CONFIRM_ORDER_IDS allowlist, so nothing gets remapped by accident.
Always start with DRY_RUN=true. Treat every report row as a lead for staff to confirm, not an automatic fix. Only remap orders.id_carrier when the user has explicitly confirmed a specific order, such as when a shop closed a carrier account and wants historical reporting joins to resolve, and list only those order ids in the allowlist.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, builds the carrier map once, flags every orphaned order with a pure function, reports every problem, and only ever writes a remap for an order id explicitly listed in the confirm allowlist.
"""Detect PrestaShop orders whose id_carrier was orphaned by a carrier edit.
PrestaShop's Carrier::update() never modifies the carrier row in place. Because
historical orders must keep showing the shipping price and tax rules that were active
when they were placed, every backoffice edit inserts a brand new row with a new
id_carrier, copies the stable id_reference onto it, and marks the old row deleted=1
with active=0. Any order, module, or script that cached the old id_carrier now points
at a dead row, so joins against it silently return no active carrier. This is long
documented core behavior, and module authors are expected to listen for the
actionCarrierUpdate hook, fired with both the old and new ids, to repoint their own
stored references.
This script only reports by default. It never rewrites orders.id_carrier unless
DRY_RUN is explicitly false and the order id is listed in CONFIRM_ORDER_IDS, since a
silent remap can misrepresent what the customer was actually charged.
Run on a schedule against recent orders. 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("find_orphaned_carriers")
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"
CURRENT_STATE_FILTER = os.environ.get("CURRENT_STATE_FILTER", "")
CONFIRM_ORDER_IDS = {
int(v) for v in os.environ.get("CONFIRM_ORDER_IDS", "").split(",") if v.strip()
}
AUTH = (PRESTASHOP_WS_KEY, "")
def find_orphaned_carrier_refs(orders, carriers_by_id):
"""Pure decision logic, no I/O.
orders: [{"id_order": int, "id_carrier": int, ...}]
carriers_by_id: {id: {"id", "id_reference", "deleted", "active", ...}}
Returns a list of {"id_order", "old_id_carrier", "orphan_reason", "id_reference",
"successor_id_carrier"} for every order whose id_carrier is missing from the map
or present with deleted True.
"""
results = []
for order in orders:
id_order = order.get("id_order", order.get("id"))
id_carrier = order.get("id_carrier")
id_carrier = int(id_carrier) if id_carrier not in (None, "") else 0
entry = carriers_by_id.get(id_carrier)
if entry is None:
reason = "missing"
elif entry.get("deleted"):
reason = "deleted"
else:
continue
id_reference = (entry or {}).get("id_reference")
successor = None
if id_reference is not None:
candidates = [
c for c in carriers_by_id.values()
if c.get("id_reference") == id_reference and not c.get("deleted")
]
candidates.sort(key=lambda c: c.get("active") is not True)
successor = candidates[0]["id"] if candidates else None
results.append({
"id_order": id_order,
"old_id_carrier": id_carrier,
"orphan_reason": reason,
"id_reference": id_reference,
"successor_id_carrier": successor,
})
return results
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 carriers_by_id():
# deleted is not filterable server-side, so pull the full set and filter here.
data = api_get("carriers", params={"display": "full"})
carriers = data.get("carriers") or []
by_id = {}
for c in carriers:
cid = int(c["id"])
by_id[cid] = {
"id": cid,
"id_reference": int(c.get("id_reference") or 0),
"deleted": str(c.get("deleted")) == "1",
"active": str(c.get("active")) == "1",
"name": c.get("name"),
}
return by_id
def list_orders(current_state=None):
params = {"display": "full"}
if current_state:
params["filter[current_state]"] = current_state
data = api_get("orders", params=params)
return data.get("orders") or []
def remap_order_carrier(order, new_id_carrier):
order["id_carrier"] = new_id_carrier
return api_put(f"orders/{order['id']}", {"order": order})
def run():
carrier_map = carriers_by_id()
orders = list_orders(CURRENT_STATE_FILTER or None)
orphaned = find_orphaned_carrier_refs(orders, carrier_map)
remapped = 0
for row in orphaned:
log.warning(
"Orphaned order carrier. id_order=%s old_id_carrier=%s reason=%s "
"id_reference=%s successor_id_carrier=%s",
row["id_order"], row["old_id_carrier"], row["orphan_reason"],
row["id_reference"], row["successor_id_carrier"],
)
allowed = row["id_order"] in CONFIRM_ORDER_IDS
if not DRY_RUN and allowed and row["successor_id_carrier"]:
order = next(o for o in orders if o.get("id_order", o.get("id")) == row["id_order"])
remap_order_carrier(order, row["successor_id_carrier"])
remapped += 1
log.info("Remapped id_order=%s to id_carrier=%s.", row["id_order"], row["successor_id_carrier"])
elif not DRY_RUN and not allowed:
log.info("Skipping id_order=%s: not in CONFIRM_ORDER_IDS allowlist.", row["id_order"])
log.info("Done. %d order(s) orphaned, %d remapped. DRY_RUN=%s", len(orphaned), remapped, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop orders whose id_carrier was orphaned by a carrier edit.
*
* PrestaShop's Carrier::update() never modifies the carrier row in place. Because
* historical orders must keep showing the shipping price and tax rules that were
* active when they were placed, every backoffice edit inserts a brand new row with a
* new id_carrier, copies the stable id_reference onto it, and marks the old row
* deleted=1 with active=0. Any order, module, or script that cached the old
* id_carrier now points at a dead row, so joins against it silently return no active
* carrier. This is long documented core behavior, and module authors are expected to
* listen for the actionCarrierUpdate hook, fired with both the old and new ids, to
* repoint their own stored references.
*
* This script only reports by default. It never rewrites orders.id_carrier unless
* DRY_RUN is explicitly false and the order id is listed in CONFIRM_ORDER_IDS, since a
* silent remap can misrepresent what the customer was actually charged.
*
* Guide: https://www.allanninal.dev/prestashop/carrier-edit-creates-new-id/
*/
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 CURRENT_STATE_FILTER = process.env.CURRENT_STATE_FILTER || "";
const CONFIRM_ORDER_IDS = new Set(
(process.env.CONFIRM_ORDER_IDS || "")
.split(",")
.map((v) => v.trim())
.filter(Boolean)
.map(Number)
);
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision logic, no I/O.
*
* orders: [{ id_order, id_carrier, ... }]
* carriersById: { [id]: { id, id_reference, deleted, active, ... } }
*
* Returns a list of { id_order, old_id_carrier, orphan_reason, id_reference,
* successor_id_carrier } for every order whose id_carrier is missing from the map or
* present with deleted true.
*/
export function findOrphanedCarrierRefs(orders, carriersById) {
const results = [];
for (const order of orders) {
const idOrder = order.id_order ?? order.id;
const rawIdCarrier = order.id_carrier;
const idCarrier = rawIdCarrier === null || rawIdCarrier === undefined || rawIdCarrier === ""
? 0
: Number(rawIdCarrier);
const entry = carriersById[idCarrier];
let reason;
if (!entry) {
reason = "missing";
} else if (entry.deleted) {
reason = "deleted";
} else {
continue;
}
const idReference = entry ? entry.id_reference : null;
let successor = null;
if (idReference !== null && idReference !== undefined) {
const candidates = Object.values(carriersById).filter(
(c) => c.id_reference === idReference && !c.deleted
);
candidates.sort((a, b) => Number(b.active) - Number(a.active));
successor = candidates.length ? candidates[0].id : null;
}
results.push({
id_order: idOrder,
old_id_carrier: idCarrier,
orphan_reason: reason,
id_reference: idReference,
successor_id_carrier: successor,
});
}
return results;
}
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 carriersById() {
// deleted is not filterable server-side, so pull the full set and filter here.
const data = await apiGet("carriers", { display: "full" });
const carriers = data.carriers || [];
const byId = {};
for (const c of carriers) {
const cid = Number(c.id);
byId[cid] = {
id: cid,
id_reference: Number(c.id_reference || 0),
deleted: String(c.deleted) === "1",
active: String(c.active) === "1",
name: c.name,
};
}
return byId;
}
async function listOrders(currentState) {
const params = { display: "full" };
if (currentState) params["filter[current_state]"] = currentState;
const data = await apiGet("orders", params);
return data.orders || [];
}
async function remapOrderCarrier(order, newIdCarrier) {
order.id_carrier = newIdCarrier;
return apiPut(`orders/${order.id}`, { order });
}
export async function run() {
const carrierMap = await carriersById();
const orders = await listOrders(CURRENT_STATE_FILTER || undefined);
const orphaned = findOrphanedCarrierRefs(orders, carrierMap);
let remapped = 0;
for (const row of orphaned) {
console.warn(
`Orphaned order carrier. id_order=${row.id_order} old_id_carrier=${row.old_id_carrier} reason=${row.orphan_reason} id_reference=${row.id_reference} successor_id_carrier=${row.successor_id_carrier}`
);
const allowed = CONFIRM_ORDER_IDS.has(row.id_order);
if (!DRY_RUN && allowed && row.successor_id_carrier) {
const order = orders.find((o) => (o.id_order ?? o.id) === row.id_order);
await remapOrderCarrier(order, row.successor_id_carrier);
remapped++;
console.log(`Remapped id_order=${row.id_order} to id_carrier=${row.successor_id_carrier}.`);
} else if (!DRY_RUN && !allowed) {
console.log(`Skipping id_order=${row.id_order}: not in CONFIRM_ORDER_IDS allowlist.`);
}
}
console.log(`Done. ${orphaned.length} order(s) orphaned, ${remapped} remapped. 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 orphan-detection function is the part most worth testing, because it decides which orders get reported as broken and what successor gets suggested. Because we kept find_orphaned_carrier_refs pure, taking only plain orders and a plain carrier map, the tests need no network and no PrestaShop store.
from find_orphaned_carriers import find_orphaned_carrier_refs
CARRIERS = {
3: {"id": 3, "id_reference": 100, "deleted": True, "active": False, "name": "Regional Post"},
9: {"id": 9, "id_reference": 100, "deleted": False, "active": True, "name": "Regional Post"},
5: {"id": 5, "id_reference": 200, "deleted": False, "active": True, "name": "Express"},
}
def test_ok_when_carrier_is_live():
orders = [{"id_order": 1, "id_carrier": 5}]
assert find_orphaned_carrier_refs(orders, CARRIERS) == []
def test_orphaned_when_deleted_with_successor():
orders = [{"id_order": 2, "id_carrier": 3}]
result = find_orphaned_carrier_refs(orders, CARRIERS)
assert result == [{
"id_order": 2,
"old_id_carrier": 3,
"orphan_reason": "deleted",
"id_reference": 100,
"successor_id_carrier": 9,
}]
def test_orphaned_when_id_missing_entirely():
orders = [{"id_order": 3, "id_carrier": 999}]
result = find_orphaned_carrier_refs(orders, CARRIERS)
assert result[0]["orphan_reason"] == "missing"
assert result[0]["successor_id_carrier"] is None
def test_orphaned_when_id_carrier_is_zero_or_missing():
orders = [{"id_order": 4, "id_carrier": 0}, {"id_order": 5}]
result = find_orphaned_carrier_refs(orders, CARRIERS)
assert [r["id_order"] for r in result] == [4, 5]
assert all(r["orphan_reason"] == "missing" for r in result)
def test_falls_back_to_id_field_when_id_order_absent():
orders = [{"id": 7, "id_carrier": 3}]
result = find_orphaned_carrier_refs(orders, CARRIERS)
assert result[0]["id_order"] == 7
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedCarrierRefs } from "./find-orphaned-carriers.js";
const CARRIERS = {
3: { id: 3, id_reference: 100, deleted: true, active: false, name: "Regional Post" },
9: { id: 9, id_reference: 100, deleted: false, active: true, name: "Regional Post" },
5: { id: 5, id_reference: 200, deleted: false, active: true, name: "Express" },
};
test("ok when carrier is live", () => {
const orders = [{ id_order: 1, id_carrier: 5 }];
assert.deepEqual(findOrphanedCarrierRefs(orders, CARRIERS), []);
});
test("orphaned when deleted with successor", () => {
const orders = [{ id_order: 2, id_carrier: 3 }];
const result = findOrphanedCarrierRefs(orders, CARRIERS);
assert.deepEqual(result, [{
id_order: 2,
old_id_carrier: 3,
orphan_reason: "deleted",
id_reference: 100,
successor_id_carrier: 9,
}]);
});
test("orphaned when id missing entirely", () => {
const orders = [{ id_order: 3, id_carrier: 999 }];
const result = findOrphanedCarrierRefs(orders, CARRIERS);
assert.equal(result[0].orphan_reason, "missing");
assert.equal(result[0].successor_id_carrier, null);
});
test("orphaned when id_carrier is zero or missing", () => {
const orders = [{ id_order: 4, id_carrier: 0 }, { id_order: 5 }];
const result = findOrphanedCarrierRefs(orders, CARRIERS);
assert.deepEqual(result.map((r) => r.id_order), [4, 5]);
assert.ok(result.every((r) => r.orphan_reason === "missing"));
});
test("falls back to id field when id_order absent", () => {
const orders = [{ id: 7, id_carrier: 3 }];
const result = findOrphanedCarrierRefs(orders, CARRIERS);
assert.equal(result[0].id_order, 7);
});
Case studies
The rate calculator that stopped matching orders
A logistics module cached id_carrier the first time a merchant configured it, instead of tracking id_reference. Months later the merchant nudged the carrier's delivery delay text in the back office, PrestaShop duplicated the row, and the module's cached id silently pointed at a soft-deleted carrier. Its per-order rate lookups started returning nothing for every new order, and nobody connected it to a routine settings tweak.
Running the diagnostic against recent orders surfaced the exact id_reference the module should have been tracking, and the successor id it needed to switch to. The fix was in the module's own configuration, not in PrestaShop, once the report made the drift visible.
The store that wanted historical reports to resolve cleanly
A shop closed its account with a regional courier and deleted the carrier. Hundreds of old orders had shipped through it, and a reporting dashboard that joined on id_carrier started showing gaps for that whole shipping lane in its historical breakdowns.
The team ran the script in dry run first, reviewed every flagged order and its id_reference, confirmed there was no live successor since the account was gone for good, and decided to leave the orders as-is rather than force a remap. The report gave them the full picture before they made that call, instead of guessing from a broken join.
After this runs against your orders, a carrier edit stops being an invisible source of dead references. Staff get a dated report with every affected order's id, its dead id_carrier, the reason, the id_reference, and a candidate successor carrier, and the only write ever attempted is a remap explicitly confirmed per order. Financial and historical fidelity on old orders stays untouched unless a human says otherwise.
FAQ
Why does editing a PrestaShop carrier change its id_carrier?
PrestaShop's Carrier::update does not modify the carrier row in place. Historical orders need to keep showing the shipping price and tax rules that were active when they were placed, so PrestaShop inserts a brand new row with a new id_carrier, copies the stable id_reference onto it, and marks the old row deleted=1 while setting active=0. The carrier keeps working for new orders, but the id_carrier itself changes on every backoffice edit.
What is the difference between id_carrier and id_reference?
id_carrier identifies one specific row in the carrier table, and that row changes every time the carrier is edited because PrestaShop duplicates it. id_reference is a separate id that stays the same across every edit of the same logical carrier, so it links the whole lineage of old and new rows together. Anything that needs to survive edits, like a module's stored settings, should track id_reference, not id_carrier.
Is it safe to repoint old orders to the new carrier id automatically?
Not by default. orders.id_carrier and order_carrier.id_carrier store the shipping cost, tax, and carrier name that were actually charged at the time of sale, so silently repointing them to a new id_carrier can misrepresent what the customer paid and which tax rule applied. The safe default is to flag orphaned orders and report the current successor carrier for a human to review, only writing a remap when it is explicitly confirmed per order.
Related field notes
Citations
On the problem:
- PrestaShop/PrestaShop GitHub issue #38914: Editing Carrier Gives New Id To Carrier. github.com/PrestaShop/PrestaShop/issues/38914
- PrestaShop/PrestaShop GitHub issue #25697: Editing deleted / inactive carrier shouldn't be possible. github.com/PrestaShop/PrestaShop/issues/25697
- PrestaShop Forums: Editing Carrier Gives New Id To Carrier, bug reports thread. prestashop.com/forums/topic/510525-editing-carrier-gives-new-id-to-carrier
On the solution:
- PrestaShop Developer Documentation: Carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/carriers/
- PrestaShop Developer Documentation: Carrier modules, including the actionCarrierUpdate hook. devdocs.prestashop-project.org/9/modules/carrier/
- PrestaShop Developer Documentation: Order carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_carriers/
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 orphaned carrier reference?
If this saved you a confusing support ticket or a broken reporting join, 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