Diagnostic
Order stored with no carrier and free shipping the customer was not entitled to
An order lands in the back office with no shipping method on file and a shipping total of zero, but nobody ever earned that free shipping. The customer's cart never qualified for a voucher, and yet the order acts as if it did. Here is why PrestaShop's checkout can save an order like this, and a script that finds every order this has already happened to.
PrestaShop computes the carrier and free-shipping eligibility from the cart across several sequential AJAX steps: address, carrier, cart rules, payment. If a customer submits or resubmits the order between the moment a free-shipping voucher is validated and the moment the carrier is persisted, the order can save with id_carrier = 0 while total_shipping_tax_incl and total_shipping_tax_excl are already zeroed, because those steps are not committed as one atomic transaction (PrestaShop core issues #22391, #11172, #20667). Run a Python or Node.js script that pulls orders with GET /api/orders?filter[id_carrier]=0, keeps the ones with zero shipping totals, then cross-checks GET /api/order_cart_rules for a row with free_shipping = 1. If nothing earned it, the order is a true positive: no carrier, no shipping charge, no entitlement behind it. Full code, tests, and citations are below.
The problem in plain words
During PrestaShop checkout, nothing about the order is final until the very last step. The customer picks an address, picks a carrier, a voucher gets validated against the cart, and only then does payment run. Each of those is its own request against the same Cart and Context objects, and PrestaShop's own Cart::getDeliveryOption() and id_carrier fields get written across that sequence, not inside one transaction that covers the whole checkout.
That gap is small, but it is real. If a customer submits the order, or reloads and resubmits, right in the window between a free-shipping voucher being validated against the cart and the carrier actually being persisted, the order that gets created can carry id_carrier = 0 while the shipping totals are already zero from the voucher pass. The order looks like it earned free shipping. It did not. It just got created in the middle of a computation that never finished lining up.
Why it happens
PrestaShop's checkout was not built as one atomic write. A few things push this into the open:
- Address selection, carrier selection, cart-rule and voucher application, and payment run as separate sequential AJAX steps against the same Cart and Context object, not as a single database transaction.
- PrestaShop core issue #22391 documents
id_carrieron the cart staying at 0 even after a carrier was chosen in the UI, becauseContext's cart was not pre-set with the current carrier. - PrestaShop core issue #11172 documents free-shipping cart rules interacting with carrier restrictions in a way that can block carrier selection entirely, leaving no carrier committed to the order.
- PrestaShop core issue #20667 documents shipping totals being left at zero without a valid carrier or
order_carriersrow backing them, sometimes alongside a display bug where a legitimately-applied global free-shipping rule is not itemized inorder_cart_rules. - A customer who reloads or double-submits during checkout can trigger a second order creation that lands after the voucher pass zeroed shipping but before the carrier write completed.
The result is an order with no shipping method on file and nothing charged for it, even though the cart the customer actually built never qualified for a free-shipping rule. See the citations at the end for the exact issues and docs.
This is not safe to auto-fix. Assigning a carrier and a shipping cost to an order that may already be paid or shipped is a pricing decision: which carrier rate applies, whether to charge the customer after the fact, whether to eat the cost as goodwill. Those are calls only a human should make. The safe default is to detect the shape, compute what shipping should have cost read-only, and hand a report to a person, never to write a carrier or a total onto the order directly.
The fix, as a flow
We do not touch orders by default. We add a job that scans recent orders for id_carrier = 0 with zero shipping totals, cross-checks each one's order_cart_rules to see whether free shipping was actually earned, and reports every true positive with an expected shipping cost computed read-only from the customer's address zone and the cheapest eligible carrier. A human decides what happens next.
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, carts, order_cart_rules, cart_rules, and carriers, plus write access to order_histories only if you plan to move confirmed orders into a review state. 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 REVIEW_STATE_ID="30" # your "Awaiting shipping review" order state id
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 REVIEW_STATE_ID="30" // your "Awaiting shipping review" order state id
export DRY_RUN="true" // start safe, only reports by default
Find orders with no carrier and zero shipping
Call GET /api/orders filtered by id_carrier=0 and a date range, asking only for the fields the decision needs. That returns every order in the "no valid carrier" fallback state. We filter again in code for both shipping totals being exactly zero, since a filter on id_carrier alone can include orders that legitimately have no shipping cost for another reason.
import os, requests
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")
ORDER_FIELDS = "[id,reference,id_cart,id_customer,id_carrier,total_shipping_tax_incl,total_shipping_tax_excl,total_paid,date_add]"
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 orders_with_no_carrier(date_from, date_to):
data = api_get("orders", params={
"display": ORDER_FIELDS,
"filter[id_carrier]": "0",
"filter[date_add]": f"[{date_from},{date_to}]",
"limit": "0,1000",
})
return data.get("orders") or []
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;
const ORDER_FIELDS = "[id,reference,id_cart,id_customer,id_carrier,total_shipping_tax_incl,total_shipping_tax_excl,total_paid,date_add]";
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 ordersWithNoCarrier(dateFrom, dateTo) {
const data = await apiGet("orders", {
display: ORDER_FIELDS,
"filter[id_carrier]": "0",
"filter[date_add]": `[${dateFrom},${dateTo}]`,
limit: "0,1000",
});
return data.orders || [];
}
Pull the order's cart rules and check the earned flag
For each flagged order, call GET /api/order_cart_rules?filter[id_order]={id}&display=full. Each row carries id_cart_rule and free_shipping. Also pull the referenced cart_rules resource to confirm the rule itself is a free-shipping rule and to read its minimum_amount and carrier restriction, which rules out a legitimately-applied rule that just was not itemized correctly, a known separate display bug (PrestaShop #20667).
def order_cart_rules(id_order):
data = api_get("order_cart_rules", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_cart_rules") or []
def cart_rule_by_id(id_cart_rule):
data = api_get(f"cart_rules/{id_cart_rule}", params={})
return (data or {}).get("cart_rule")
def cart_rules_map(order_cart_rule_rows):
return {
int(row["id_cart_rule"]): cart_rule_by_id(row["id_cart_rule"])
for row in order_cart_rule_rows
if row.get("id_cart_rule")
}
async function orderCartRules(idOrder) {
const data = await apiGet("order_cart_rules", { "filter[id_order]": idOrder, display: "full" });
return data.order_cart_rules || [];
}
async function cartRuleById(idCartRule) {
const data = await apiGet(`cart_rules/${idCartRule}`, {});
return data?.cart_rule;
}
async function cartRulesMap(orderCartRuleRows) {
const map = {};
for (const row of orderCartRuleRows) {
if (!row.id_cart_rule) continue;
map[Number(row.id_cart_rule)] = await cartRuleById(row.id_cart_rule);
}
return map;
}
Decide, with one pure function
Keep the classification in its own function that takes only plain data: the order's carrier and shipping fields, the order's linked cart rule rows, and a map of cart rule details. It touches no network, so it is trivial to unit test with fixture objects.
def classify_shipping_integrity(order, order_cart_rules, cart_rules):
no_carrier = int(order.get("id_carrier", 0) or 0) == 0
zero_shipping = (
float(order.get("total_shipping_tax_incl", 0) or 0) == 0
and float(order.get("total_shipping_tax_excl", 0) or 0) == 0
)
if not no_carrier or not zero_shipping:
return {"flagged": False, "reason": "carrier_and_shipping_consistent"}
earned_free_shipping = any(
str(link.get("free_shipping")) == "1"
and cart_rules.get(int(link["id_cart_rule"]))
and str(cart_rules[int(link["id_cart_rule"])].get("free_shipping")) == "1"
for link in order_cart_rules
if link.get("id_cart_rule")
)
if earned_free_shipping:
return {"flagged": False, "reason": "free_shipping_legitimately_earned"}
return {"flagged": True, "reason": "no_carrier_zero_shipping_unearned", "severity": "high"}
export function classifyShippingIntegrity(order, orderCartRules, cartRules) {
const noCarrier = Number(order.id_carrier) === 0;
const zeroShipping =
parseFloat(order.total_shipping_tax_incl) === 0 &&
parseFloat(order.total_shipping_tax_excl) === 0;
if (!noCarrier || !zeroShipping) {
return { flagged: false, reason: "carrier_and_shipping_consistent" };
}
const earnedFreeShipping = orderCartRules.some((link) => {
const rule = cartRules[link.id_cart_rule];
return link.free_shipping === "1" && rule && rule.free_shipping === "1";
});
if (earnedFreeShipping) {
return { flagged: false, reason: "free_shipping_legitimately_earned" };
}
return { flagged: true, reason: "no_carrier_zero_shipping_unearned", severity: "high" };
}
Compute an expected shipping cost, read-only
For each true positive, compute what shipping should have cost, without writing anything. Read the customer's default address zone and pull GET /api/carriers?filter[deleted]=0&filter[active]=1 to find the cheapest carrier eligible for that zone. This number goes in the report as a starting point for the human who decides what actually happens to the order.
def active_carriers():
data = api_get("carriers", params={"filter[deleted]": "0", "filter[active]": "1", "display": "full"})
return data.get("carriers") or []
def cheapest_carrier_price(carriers, id_zone):
prices = []
for c in carriers:
zones = (c.get("zones") or {}).get("zone")
zone_ids = {int(z["id"]) for z in zones} if isinstance(zones, list) else (
{int(zones["id"])} if zones else set()
)
if id_zone in zone_ids:
price = c.get("shipping_external") or c.get("price") or 0
prices.append(float(price))
return min(prices) if prices else None
def build_report_row(order, id_zone, carriers):
return {
"id": order["id"],
"reference": order.get("reference"),
"id_cart": order.get("id_cart"),
"id_customer": order.get("id_customer"),
"id_carrier": order.get("id_carrier"),
"expected_shipping_cost": cheapest_carrier_price(carriers, id_zone),
"reason": "no_carrier_zero_shipping_unearned",
}
async function activeCarriers() {
const data = await apiGet("carriers", { "filter[deleted]": "0", "filter[active]": "1", display: "full" });
return data.carriers || [];
}
function cheapestCarrierPrice(carriers, idZone) {
const prices = [];
for (const c of carriers) {
const zones = c.zones?.zone;
const zoneIds = Array.isArray(zones) ? zones.map((z) => Number(z.id)) : zones ? [Number(zones.id)] : [];
if (zoneIds.includes(idZone)) prices.push(Number(c.shipping_external || c.price || 0));
}
return prices.length ? Math.min(...prices) : null;
}
function buildReportRow(order, idZone, carriers) {
return {
id: order.id,
reference: order.reference,
id_cart: order.id_cart,
id_customer: order.id_customer,
id_carrier: order.id_carrier,
expected_shipping_cost: cheapestCarrierPrice(carriers, idZone),
reason: "no_carrier_zero_shipping_unearned",
};
}
Wire it together with a dry run guard
The loop ties every piece together: list flagged orders, cross-check each against its cart rules with the pure function, and write a report row for every true positive. DRY_RUN defaults to true and the script never calls PUT on the orders resource. The only optional write, gated behind DRY_RUN=false and explicit human confirmation, appends an order_histories row moving the order to an internal review state, never editing id_carrier or the totals directly.
Always start with DRY_RUN=true. This script never writes a carrier or a shipping total onto an order. Treat every report row as a lead for staff to check against what the customer actually paid, and only move an order to review once a human has confirmed it belongs there.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, scans recent orders, classifies each one with a pure function, computes an expected shipping cost read-only, writes a report, and only ever appends an order_histories row under explicit confirmation.
"""Detect PrestaShop orders stored with no carrier and unearned free shipping.
Checkout computes the carrier and free-shipping eligibility across several sequential
AJAX steps: address, carrier, cart rules, payment. If a customer submits or resubmits the
order between a free-shipping voucher being validated against the cart and the carrier
being persisted, the order can save with id_carrier = 0 while total_shipping_tax_incl and
total_shipping_tax_excl are already zero, because those steps are not committed as one
atomic transaction (core issues #22391, #11172, #20667).
This script only reads and reports by default. It never calls PUT on the orders resource
to inject a carrier or a shipping total, since that write path is documented as unreliable
for id_carrier and total_shipping under some workflows (issues #19906, #32622).
Retroactively pricing an already-placed order is a human pricing decision.
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_missing_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_FROM = os.environ.get("DATE_FROM", "2026-06-01")
DATE_TO = os.environ.get("DATE_TO", "2026-07-11")
REVIEW_STATE_ID = int(os.environ.get("REVIEW_STATE_ID", "0") or 0)
AUTH = (PRESTASHOP_WS_KEY, "")
ORDER_FIELDS = "[id,reference,id_cart,id_customer,id_carrier,total_shipping_tax_incl,total_shipping_tax_excl,total_paid,date_add]"
def classify_shipping_integrity(order, order_cart_rules, cart_rules):
"""Pure decision logic, no I/O.
order: {id_carrier, total_shipping_tax_incl, total_shipping_tax_excl}
order_cart_rules: rows already linked to this order, each {id_cart_rule, free_shipping}
cart_rules: map id_cart_rule -> {free_shipping, minimum_amount, carrier_restriction}
"""
no_carrier = int(order.get("id_carrier", 0) or 0) == 0
zero_shipping = (
float(order.get("total_shipping_tax_incl", 0) or 0) == 0
and float(order.get("total_shipping_tax_excl", 0) or 0) == 0
)
if not no_carrier or not zero_shipping:
return {"flagged": False, "reason": "carrier_and_shipping_consistent"}
earned_free_shipping = any(
str(link.get("free_shipping")) == "1"
and cart_rules.get(int(link["id_cart_rule"]))
and str(cart_rules[int(link["id_cart_rule"])].get("free_shipping")) == "1"
for link in order_cart_rules
if link.get("id_cart_rule")
)
if earned_free_shipping:
return {"flagged": False, "reason": "free_shipping_legitimately_earned"}
return {"flagged": True, "reason": "no_carrier_zero_shipping_unearned", "severity": "high"}
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_post(path, body):
r = requests.post(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def orders_with_no_carrier(date_from, date_to):
data = api_get("orders", params={
"display": ORDER_FIELDS,
"filter[id_carrier]": "0",
"filter[date_add]": f"[{date_from},{date_to}]",
"limit": "0,1000",
})
return data.get("orders") or []
def order_cart_rules(id_order):
data = api_get("order_cart_rules", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_cart_rules") or []
def cart_rule_by_id(id_cart_rule):
data = api_get(f"cart_rules/{id_cart_rule}", params={})
return (data or {}).get("cart_rule")
def cart_rules_map(order_cart_rule_rows):
return {
int(row["id_cart_rule"]): cart_rule_by_id(row["id_cart_rule"])
for row in order_cart_rule_rows
if row.get("id_cart_rule")
}
def cart_by_id(id_cart):
data = api_get(f"carts/{id_cart}", params={})
return (data or {}).get("cart")
def address_zone(id_address):
data = api_get(f"addresses/{id_address}", params={})
address = (data or {}).get("address") or {}
id_state = address.get("id_state")
if not id_state or int(id_state) == 0:
return None
state_data = api_get(f"states/{id_state}", params={})
state = (state_data or {}).get("state") or {}
id_zone = state.get("id_zone")
return int(id_zone) if id_zone else None
def active_carriers():
data = api_get("carriers", params={"filter[deleted]": "0", "filter[active]": "1", "display": "full"})
return data.get("carriers") or []
def cheapest_carrier_price(carriers, id_zone):
if id_zone is None:
return None
prices = []
for c in carriers:
zones = (c.get("zones") or {}).get("zone")
zone_ids = {int(z["id"]) for z in zones} if isinstance(zones, list) else (
{int(zones["id"])} if zones else set()
)
if id_zone in zone_ids:
price = c.get("shipping_external") or c.get("price") or 0
prices.append(float(price))
return min(prices) if prices else None
def build_report_row(order, id_zone, carriers):
return {
"id": order["id"],
"reference": order.get("reference"),
"id_cart": order.get("id_cart"),
"id_customer": order.get("id_customer"),
"id_carrier": order.get("id_carrier"),
"expected_shipping_cost": cheapest_carrier_price(carriers, id_zone),
"reason": "no_carrier_zero_shipping_unearned",
}
def move_to_review(id_order):
if not REVIEW_STATE_ID:
raise RuntimeError("REVIEW_STATE_ID is not set; refusing to write order_histories.")
body = {"order_history": {"id_order": id_order, "id_order_state": REVIEW_STATE_ID}}
api_post("order_histories", body)
def run():
orders = orders_with_no_carrier(DATE_FROM, DATE_TO)
carriers = active_carriers()
flagged = 0
moved = 0
for order in orders:
oc_rows = order_cart_rules(order["id"])
rules = cart_rules_map(oc_rows)
result = classify_shipping_integrity(order, oc_rows, rules)
if not result["flagged"]:
continue
flagged += 1
cart = cart_by_id(order.get("id_cart")) or {}
id_zone = address_zone(cart.get("id_address_delivery")) if cart.get("id_address_delivery") else None
row = build_report_row(order, id_zone, carriers)
log.warning(
"Unearned free shipping. id=%s reference=%s id_cart=%s expected_shipping_cost=%s",
row["id"], row["reference"], row["id_cart"], row["expected_shipping_cost"],
)
if not DRY_RUN:
move_to_review(order["id"])
moved += 1
log.info("Moved id_order=%s to the awaiting shipping review state.", order["id"])
log.info("Done. %d order(s) flagged, %d moved to review. DRY_RUN=%s", flagged, moved, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop orders stored with no carrier and unearned free shipping.
*
* Checkout computes the carrier and free-shipping eligibility across several sequential
* AJAX steps: address, carrier, cart rules, payment. If a customer submits or resubmits
* the order between a free-shipping voucher being validated against the cart and the
* carrier being persisted, the order can save with id_carrier = 0 while
* total_shipping_tax_incl and total_shipping_tax_excl are already zero, because those
* steps are not committed as one atomic transaction (core issues #22391, #11172, #20667).
*
* This script only reads and reports by default. It never calls PUT on the orders
* resource to inject a carrier or a shipping total, since that write path is documented
* as unreliable for id_carrier and total_shipping under some workflows (issues #19906,
* #32622). Retroactively pricing an already-placed order is a human pricing decision.
*
* Guide: https://www.allanninal.dev/prestashop/order-missing-carrier-unearned-free-shipping/
*/
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_FROM = process.env.DATE_FROM || "2026-06-01";
const DATE_TO = process.env.DATE_TO || "2026-07-11";
const REVIEW_STATE_ID = Number(process.env.REVIEW_STATE_ID || 0);
const ORDER_FIELDS = "[id,reference,id_cart,id_customer,id_carrier,total_shipping_tax_incl,total_shipping_tax_excl,total_paid,date_add]";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision logic, no I/O.
*
* order: {id_carrier, total_shipping_tax_incl, total_shipping_tax_excl}
* orderCartRules: array of {id_cart_rule, free_shipping} rows already linked to this order
* cartRules: map id_cart_rule -> {free_shipping, minimum_amount, carrier_restriction}
*/
export function classifyShippingIntegrity(order, orderCartRules, cartRules) {
const noCarrier = Number(order.id_carrier) === 0;
const zeroShipping =
parseFloat(order.total_shipping_tax_incl) === 0 &&
parseFloat(order.total_shipping_tax_excl) === 0;
if (!noCarrier || !zeroShipping) {
return { flagged: false, reason: "carrier_and_shipping_consistent" };
}
const earnedFreeShipping = orderCartRules.some((link) => {
const rule = cartRules[link.id_cart_rule];
return link.free_shipping === "1" && rule && rule.free_shipping === "1";
});
if (earnedFreeShipping) {
return { flagged: false, reason: "free_shipping_legitimately_earned" };
}
return { flagged: true, reason: "no_carrier_zero_shipping_unearned", severity: "high" };
}
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 apiPost(path, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
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 ${path}`);
return res.json();
}
async function ordersWithNoCarrier(dateFrom, dateTo) {
const data = await apiGet("orders", {
display: ORDER_FIELDS,
"filter[id_carrier]": "0",
"filter[date_add]": `[${dateFrom},${dateTo}]`,
limit: "0,1000",
});
return data.orders || [];
}
async function orderCartRules(idOrder) {
const data = await apiGet("order_cart_rules", { "filter[id_order]": idOrder, display: "full" });
return data.order_cart_rules || [];
}
async function cartRuleById(idCartRule) {
const data = await apiGet(`cart_rules/${idCartRule}`, {});
return data?.cart_rule;
}
async function cartRulesMap(orderCartRuleRows) {
const map = {};
for (const row of orderCartRuleRows) {
if (!row.id_cart_rule) continue;
map[Number(row.id_cart_rule)] = await cartRuleById(row.id_cart_rule);
}
return map;
}
async function cartById(idCart) {
const data = await apiGet(`carts/${idCart}`, {});
return data?.cart;
}
async function addressZone(idAddress) {
const data = await apiGet(`addresses/${idAddress}`, {});
const address = data?.address || {};
const idState = address.id_state;
if (!idState || Number(idState) === 0) return null;
const stateData = await apiGet(`states/${idState}`, {});
const state = stateData?.state || {};
return state.id_zone ? Number(state.id_zone) : null;
}
async function activeCarriers() {
const data = await apiGet("carriers", { "filter[deleted]": "0", "filter[active]": "1", display: "full" });
return data.carriers || [];
}
function cheapestCarrierPrice(carriers, idZone) {
if (idZone === null || idZone === undefined) return null;
const prices = [];
for (const c of carriers) {
const zones = c.zones?.zone;
const zoneIds = Array.isArray(zones) ? zones.map((z) => Number(z.id)) : zones ? [Number(zones.id)] : [];
if (zoneIds.includes(idZone)) prices.push(Number(c.shipping_external || c.price || 0));
}
return prices.length ? Math.min(...prices) : null;
}
function buildReportRow(order, idZone, carriers) {
return {
id: order.id,
reference: order.reference,
id_cart: order.id_cart,
id_customer: order.id_customer,
id_carrier: order.id_carrier,
expected_shipping_cost: cheapestCarrierPrice(carriers, idZone),
reason: "no_carrier_zero_shipping_unearned",
};
}
async function moveToReview(idOrder) {
if (!REVIEW_STATE_ID) throw new Error("REVIEW_STATE_ID is not set; refusing to write order_histories.");
await apiPost("order_histories", { order_history: { id_order: idOrder, id_order_state: REVIEW_STATE_ID } });
}
export async function run() {
const orders = await ordersWithNoCarrier(DATE_FROM, DATE_TO);
const carriers = await activeCarriers();
let flagged = 0;
let moved = 0;
for (const order of orders) {
const ocRows = await orderCartRules(order.id);
const rules = await cartRulesMap(ocRows);
const result = classifyShippingIntegrity(order, ocRows, rules);
if (!result.flagged) continue;
flagged++;
const cart = order.id_cart ? (await cartById(order.id_cart)) || {} : {};
const idZone = cart.id_address_delivery ? await addressZone(cart.id_address_delivery) : null;
const row = buildReportRow(order, idZone, carriers);
console.warn(
`Unearned free shipping. id=${row.id} reference=${row.reference} id_cart=${row.id_cart} expected_shipping_cost=${row.expected_shipping_cost}`
);
if (!DRY_RUN) {
await moveToReview(order.id);
moved++;
console.log(`Moved id_order=${order.id} to the awaiting shipping review state.`);
}
}
console.log(`Done. ${flagged} order(s) flagged, ${moved} moved to review. 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 a true positive. Because we kept classify_shipping_integrity pure, taking only plain order data, cart rule links, and a rules map, the tests need no network and no PrestaShop store.
from check_missing_carrier import classify_shipping_integrity
def order(**over):
base = {"id_carrier": 0, "total_shipping_tax_incl": "0.000000", "total_shipping_tax_excl": "0.000000"}
base.update(over)
return base
def test_flagged_when_no_carrier_and_zero_shipping_and_nothing_earned():
result = classify_shipping_integrity(order(), [], {})
assert result["flagged"] is True
assert result["reason"] == "no_carrier_zero_shipping_unearned"
def test_not_flagged_when_carrier_present():
o = order(id_carrier=3)
assert classify_shipping_integrity(o, [], {})["flagged"] is False
def test_not_flagged_when_shipping_not_zero():
o = order(total_shipping_tax_incl="4.990000")
assert classify_shipping_integrity(o, [], {})["flagged"] is False
def test_not_flagged_when_free_shipping_legitimately_earned():
links = [{"id_cart_rule": 7, "free_shipping": "1"}]
rules = {7: {"free_shipping": "1", "minimum_amount": "0"}}
result = classify_shipping_integrity(order(), links, rules)
assert result["flagged"] is False
assert result["reason"] == "free_shipping_legitimately_earned"
def test_flagged_when_cart_rule_link_says_no_free_shipping():
links = [{"id_cart_rule": 7, "free_shipping": "0"}]
rules = {7: {"free_shipping": "1"}}
assert classify_shipping_integrity(order(), links, rules)["flagged"] is True
def test_flagged_when_referenced_rule_missing_from_map():
links = [{"id_cart_rule": 9, "free_shipping": "1"}]
assert classify_shipping_integrity(order(), links, {})["flagged"] is True
def test_flagged_when_rule_itself_is_not_free_shipping():
links = [{"id_cart_rule": 7, "free_shipping": "1"}]
rules = {7: {"free_shipping": "0"}}
assert classify_shipping_integrity(order(), links, rules)["flagged"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyShippingIntegrity } from "./check-missing-carrier.js";
const order = (over = {}) => ({
id_carrier: 0,
total_shipping_tax_incl: "0.000000",
total_shipping_tax_excl: "0.000000",
...over,
});
test("flagged when no carrier and zero shipping and nothing earned", () => {
const result = classifyShippingIntegrity(order(), [], {});
assert.equal(result.flagged, true);
assert.equal(result.reason, "no_carrier_zero_shipping_unearned");
});
test("not flagged when carrier present", () => {
assert.equal(classifyShippingIntegrity(order({ id_carrier: 3 }), [], {}).flagged, false);
});
test("not flagged when shipping not zero", () => {
assert.equal(classifyShippingIntegrity(order({ total_shipping_tax_incl: "4.990000" }), [], {}).flagged, false);
});
test("not flagged when free shipping legitimately earned", () => {
const links = [{ id_cart_rule: 7, free_shipping: "1" }];
const rules = { 7: { free_shipping: "1", minimum_amount: "0" } };
const result = classifyShippingIntegrity(order(), links, rules);
assert.equal(result.flagged, false);
assert.equal(result.reason, "free_shipping_legitimately_earned");
});
test("flagged when cart rule link says no free shipping", () => {
const links = [{ id_cart_rule: 7, free_shipping: "0" }];
const rules = { 7: { free_shipping: "1" } };
assert.equal(classifyShippingIntegrity(order(), links, rules).flagged, true);
});
test("flagged when referenced rule missing from map", () => {
const links = [{ id_cart_rule: 9, free_shipping: "1" }];
assert.equal(classifyShippingIntegrity(order(), links, {}).flagged, true);
});
test("flagged when rule itself is not free shipping", () => {
const links = [{ id_cart_rule: 7, free_shipping: "1" }];
const rules = { 7: { free_shipping: "0" } };
assert.equal(classifyShippingIntegrity(order(), links, rules).flagged, true);
});
Case studies
The customer who clicked place order twice
A homeware store noticed a handful of orders each month with no shipping method and a shipping total of zero, even though the customers had never used a coupon. Support assumed it was a one off UI glitch until the pattern kept repeating on slow connections and mobile checkouts.
Running the diagnostic against a month of orders surfaced every one of them with the same shape: id_carrier = 0, zero shipping, and no order_cart_rules row with free_shipping = 1 behind any of them. The team could see exactly which orders needed a human to price shipping after the fact, instead of guessing from a support ticket.
The flash sale that raced its own carrier step
During a flash sale, a store's checkout got heavy traffic and a burst of orders landed with free shipping that nobody's cart had actually earned. The store's real free-shipping voucher required a minimum spend, but these orders were all under it.
The cross-check against cart_rules confirmed the minimum amount was never met on the flagged carts, ruling out the itemization display bug and leaving a clean list of true positives. Finance used the expected shipping cost in the report to decide, order by order, whether to bill the difference or absorb it.
After this runs against your orders, an order with no carrier and unearned free shipping stops being invisible until a customer asks where their tracking number is. Staff get a dated report with the exact id, reference, id_cart, and a read-only expected shipping cost for every affected order, and the script never touches orders.id_carrier or the totals. The only write it can make, with explicit confirmation, is an order_histories row moving the order into review.
FAQ
Why does a PrestaShop order have id_carrier = 0 and zero shipping?
Checkout computes the carrier and free-shipping eligibility across several sequential AJAX steps: address, carrier, cart rules, payment. If the customer submits or resubmits the order between the moment a free-shipping voucher is validated and the moment the carrier is persisted, the order can save with id_carrier = 0 while the shipping totals are already zeroed from the voucher pass, because those steps are not committed as one atomic transaction. PrestaShop core issue #22391 documents id_carrier staying at 0 even after a carrier was chosen, and issues #11172 and #20667 document free-shipping cart rules leaving shipping totals at zero without a valid carrier or order_carriers row behind them.
How do I tell if the free shipping was actually earned?
Pull the order's linked rows from GET /api/order_cart_rules filtered by id_order. If every row has free_shipping = 0, or there are no rows at all, the order carries zero shipping with nothing behind it, which is a true positive. Cross-check the referenced cart_rules resource for minimum_amount and carrier restrictions before flagging, since PrestaShop issue #20667 documents order_cart_rules sometimes failing to itemize a global free-shipping rule that was in fact legitimately applied.
Is it safe to assign a carrier and shipping cost to the order automatically?
No. Retroactively pricing shipping on an order that may already be paid or shipped is a financial decision, not a data repair, since it involves picking a carrier rate and deciding whether to charge the customer after the fact or absorb the cost. The safe pattern is to compute an expected shipping cost read-only and write it to a report, then, only with explicit human confirmation, move the order to an internal review state through order_histories. Never call PUT on the orders resource to inject a carrier or total, since that write path is documented as unreliable for id_carrier and total_shipping under some workflows in issues #19906 and #32622.
Related field notes
Citations
On the problem:
- PrestaShop Forums: A user got away with free shipping, order was stored with id_carrier=0. prestashop.com/forums/topic/1023365
- PrestaShop/PrestaShop GitHub issue #22391: Context's cart needs "id_carrier" to be pre-set with the current carrier. github.com/PrestaShop/PrestaShop/issues/22391
- PrestaShop/PrestaShop GitHub issue #11172: No carrier available in checkout with free shipping cart rule. github.com/PrestaShop/PrestaShop/issues/11172
On the solution:
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
- PrestaShop Developer Documentation: Order cart rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_cart_rules/
- PrestaShop Developer Documentation: Order carriers webservice resource. devdocs.prestashop-project.org/9/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 unearned free shipping order?
If this saved you a confusing revenue reconciliation or a shipping cost nobody noticed was missing, 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