Repair
Order created via webservice drops the carrier and shipping cost fields
You POST a new order to the webservice with a real id_carrier and a real total_shipping, the call returns 201, and everything looks fine. Then you fetch the order back and the carrier is 0, or it is the shop's default carrier instead of the one the customer chose, and the shipping cost is recalculated to something else, or it is 0.00 outright. The invoice under- or overstates shipping, and sometimes the order even lands in a payment error state because the totals no longer add up. Here is why PrestaShop's order creation quietly ignores what you submitted and a script that finds and repairs every order this happened to.
The orders webservice resource exposes id_carrier, total_shipping, total_shipping_tax_incl, total_shipping_tax_excl, and carrier_tax_rate as writable fields, but Order::add() does not trust them from the POST body. The order is actually built from the referenced cart (id_cart), and the carrier and shipping amounts get recalculated from the cart's own stored delivery option instead. The result is a created order whose id_carrier reverts to 0 or the shop's default carrier, and whose shipping totals are recalculated or dropped to 0, while the matching order_carriers row may or may not even exist. Run a Python or Node.js script that, for every recently created order, compares the stored id_carrier and shipping totals against what you actually submitted at creation time, and flags or repairs the ones that drifted. Full code, tests, and citations are below.
The problem in plain words
When you create an order through POST /api/orders, the request body can carry id_carrier, total_shipping, total_shipping_tax_incl, total_shipping_tax_excl, and carrier_tax_rate right alongside the customer, address, and product fields. It reads like the webservice is asking you to hand it the final carrier and shipping cost, and it will accept the values without complaint.
But under the hood, Order::add() does not build the order from your POST body. It builds it from the cart referenced by id_cart, and the carrier and shipping amounts are pulled from that cart's own stored delivery option using Cart::getDeliveryOption() and Cart::getPackageShippingCost(), not from what you just submitted. So the values you carefully set on the request are silently discarded the moment PrestaShop rebuilds the order from the cart. The order you get back can have id_carrier at 0, or reverted to the shop's default carrier, and its shipping totals recalculated to whatever the cart's delivery option says, or dropped to 0.00 if the cart never had one properly attached.
Why it happens
This is not a one-off misconfiguration on your shop. It is how PrestaShop core builds an order from the webservice today:
id_carrier,total_shipping,total_shipping_tax_incl,total_shipping_tax_excl, andcarrier_tax_rateare all listed as writable fields on theordersresource, so nothing in the API schema warns you they will be ignored.Order::add()treats the cart referenced byid_cartas the source of truth for the order, not the POST body, so the carrier and shipping cost come fromCart::getDeliveryOption()andCart::getPackageShippingCost()at the moment the order is created.- If the cart never had a delivery option properly stored, the recalculation has nothing to fall back on, so the carrier reverts to 0 or the shop's default and the shipping cost can drop straight to 0.00.
- A correctly linked
order_carriersrow for that order may or may not exist afterward, so the mismatch is not always visible just from glancing at the order resource.
The downstream effect is real money: invoices under- or overstate shipping, and when total_paid no longer reconciles with total_products + total_shipping, the order can even trip a false payment error state. This is a long-standing, still-open core bug, tracked as PrestaShop/PrestaShop issue 19906 and duplicated by issue 32622, acknowledged by maintainers as a known backlog item. See the citations at the end for the exact reports and docs.
You cannot prevent Order::add() from recalculating the carrier and shipping cost from the cart, so the fix is not a different way to POST the order. It is to keep a record of what you actually submitted at creation time, keyed by id_cart or a reference field, and compare that against what got stored once the order exists. Anything that drifted gets repaired with a full PUT, exactly as the webservice requires, never a partial patch.
The fix, as a flow
We do not change how orders are created. We add a job that, for each recently created order, reads back the stored id_carrier and shipping totals plus the matching order_carriers row, compares them against what your own queue or log recorded as submitted at creation time, and flags anything that disagrees. A guarded repair then adds the missing order_carriers row if needed and PUTs the corrected carrier and shipping fields back onto the order.
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 and order_carriers, plus write access to both if you plan to run confirmed repairs, and to order_histories if you want the optional state correction. 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 logs the planned diff 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 logs the planned diff by default
Read back the stored order and its order_carriers row
Call GET /api/orders/{id}?output_format=JSON for the stored id_carrier, total_shipping_tax_incl, and total_shipping_tax_excl, and GET /api/order_carriers?filter[id_order]={id}&display=full&output_format=JSON to see whether a matching order_carriers row exists at all.
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 get_order(id_order):
return api_get(f"orders/{id_order}")["order"]
def get_order_carrier_rows(id_order):
data = api_get("order_carriers", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_carriers") 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 getOrder(idOrder) {
const data = await apiGet(`orders/${idOrder}`);
return data.order;
}
async function getOrderCarrierRows(idOrder) {
const data = await apiGet("order_carriers", { "filter[id_order]": idOrder, display: "full" });
return data.order_carriers || [];
}
Keep the originally submitted values
This detection only works if you kept a record of what you actually sent when the order was created. Log the submitted id_carrier, total_shipping_tax_incl, and total_shipping_tax_excl at POST time, keyed by id_cart or an idempotency reference. Load that log back before comparing.
import json
def load_submitted_log(path):
"""Returns {id_order: {"idCarrier": int, "totalShippingTaxIncl": float,
"totalShippingTaxExcl": float}} keyed by id_order, from your own order queue."""
with open(path) as f:
return json.load(f)
import { readFile } from "node:fs/promises";
async function loadSubmittedLog(path) {
// Returns { [idOrder]: { idCarrier, totalShippingTaxIncl, totalShippingTaxExcl } }
// keyed by id_order, from your own order queue.
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
}
Decide, with one pure function
Keep the comparison in its own function that takes the submitted values, the stored values, and whether an order_carriers row exists, and returns whether a repair is needed, why, and the exact patch to apply. It touches no network, so it is easy to test on its own.
def decide_carrier_shipping_repair(submitted, stored, epsilon=0.01):
reasons = []
if stored["idCarrier"] == 0 or stored["idCarrier"] != submitted["idCarrier"]:
reasons.append("carrier_mismatch")
if abs(stored["totalShippingTaxIncl"] - submitted["totalShippingTaxIncl"]) > epsilon:
reasons.append("shipping_amount_mismatch")
if abs(stored["totalShippingTaxExcl"] - submitted["totalShippingTaxExcl"]) > epsilon:
if "shipping_amount_mismatch" not in reasons:
reasons.append("shipping_amount_mismatch")
if not stored["hasOrderCarrierRow"]:
reasons.append("missing_order_carrier_row")
if not reasons:
return {"needsRepair": False, "reasons": [], "patch": None}
patch = {
"id_carrier": submitted["idCarrier"],
"total_shipping": submitted["totalShippingTaxIncl"],
"total_shipping_tax_incl": submitted["totalShippingTaxIncl"],
"total_shipping_tax_excl": submitted["totalShippingTaxExcl"],
}
return {"needsRepair": True, "reasons": reasons, "patch": patch}
export function decideCarrierShippingRepair(submitted, stored, epsilon = 0.01) {
const reasons = [];
if (stored.idCarrier === 0 || stored.idCarrier !== submitted.idCarrier) {
reasons.push("carrier_mismatch");
}
const inclOff = Math.abs(stored.totalShippingTaxIncl - submitted.totalShippingTaxIncl) > epsilon;
const exclOff = Math.abs(stored.totalShippingTaxExcl - submitted.totalShippingTaxExcl) > epsilon;
if ((inclOff || exclOff) && !reasons.includes("shipping_amount_mismatch")) {
reasons.push("shipping_amount_mismatch");
}
if (!stored.hasOrderCarrierRow) {
reasons.push("missing_order_carrier_row");
}
if (reasons.length === 0) {
return { needsRepair: false, reasons: [], patch: null };
}
const patch = {
id_carrier: submitted.idCarrier,
total_shipping: submitted.totalShippingTaxIncl,
total_shipping_tax_incl: submitted.totalShippingTaxIncl,
total_shipping_tax_excl: submitted.totalShippingTaxExcl,
};
return { needsRepair: true, reasons, patch };
}
Repair with a full PUT, guarded by DRY_RUN
PrestaShop's webservice PUT requires the complete resource representation, not a partial patch, so fetch the full order first, apply the patch fields on top, then PUT it back. If order_carriers was missing entirely, POST a new row with the submitted carrier and shipping cost before touching the order. Only move the order out of a payment error state through order_histories once the totals reconcile, and only if that mismatch is what pushed it there in the first place.
def add_order_carrier_row(id_order, submitted):
r = requests.post(
f"{PRESTASHOP_URL}/api/order_carriers",
params={"output_format": "JSON"},
json={"order_carrier": {
"id_order": id_order,
"id_carrier": submitted["idCarrier"],
"shipping_cost_tax_excl": f"{submitted['totalShippingTaxExcl']:.6f}",
"shipping_cost_tax_incl": f"{submitted['totalShippingTaxIncl']:.6f}",
}},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def apply_order_patch(id_order, patch):
order = get_order(id_order)
order.update(patch)
r = requests.put(
f"{PRESTASHOP_URL}/api/orders/{id_order}",
params={"output_format": "JSON"},
json={"order": order},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def reapply_current_state(id_order, id_order_state):
r = requests.post(
f"{PRESTASHOP_URL}/api/order_histories",
params={"output_format": "JSON"},
json={"order_history": {"id_order": id_order, "id_order_state": id_order_state}},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
async function addOrderCarrierRow(idOrder, submitted) {
const url = new URL(`${PRESTASHOP_URL}/api/order_carriers`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({
order_carrier: {
id_order: idOrder,
id_carrier: submitted.idCarrier,
shipping_cost_tax_excl: submitted.totalShippingTaxExcl.toFixed(6),
shipping_cost_tax_incl: submitted.totalShippingTaxIncl.toFixed(6),
},
}),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_carriers`);
return res.json();
}
async function applyOrderPatch(idOrder, patch) {
const order = await getOrder(idOrder);
Object.assign(order, patch);
const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ order }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
return res.json();
}
async function reapplyCurrentState(idOrder, idOrderState) {
const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
return res.json();
}
Wire it together with a dry run guard
The loop ties every piece together: load the submitted log, read back each order and its order_carriers rows, run decideCarrierShippingRepair, and log the planned before and after diff for every flagged order. DRY_RUN defaults to true, so the script only ever logs the diff unless flipped off. Run it against the orders you created recently, or on a schedule right after your integration submits new orders.
Always start with DRY_RUN=true and read the logged diffs before writing anything. Only move an order out of a payment error state through order_histories once the carrier and shipping totals genuinely reconcile, never as a way to force a status change on its own.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, compares each order against your logged submission, reports every mismatch, respects the dry run flag, and only writes when a real disagreement is found.
"""Detect and repair PrestaShop orders that dropped their carrier or shipping cost.
The orders webservice resource exposes id_carrier, total_shipping,
total_shipping_tax_incl/excl, and carrier_tax_rate as writable fields, but
Order::add() does not persist them as submitted. The order is actually built
from the referenced cart (id_cart), and the carrier and shipping amounts are
recalculated from the cart's own stored delivery option instead of trusted from
the POST body. The result is a created order whose id_carrier reverts to 0 or
the shop's default carrier, and whose total_shipping is recalculated or dropped
to 0, while a correctly-linked order_carriers row may or may not exist.
This is a long-standing, still-open core bug (PrestaShop/PrestaShop#19906,
duplicated by #32622), acknowledged by maintainers as a known backlog item.
This script only logs the planned diff by default. It never writes to an order
or order_carriers unless DRY_RUN is explicitly false. Run against orders you
created recently, keyed by id_order in a local submitted-values log. Safe to
run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_carrier_shipping")
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"
SUBMITTED_LOG_PATH = os.environ.get("SUBMITTED_LOG_PATH", "submitted_orders.json")
AUTH = (PRESTASHOP_WS_KEY, "")
EPSILON = 0.01
def decide_carrier_shipping_repair(submitted, stored, epsilon=EPSILON):
"""Pure decision logic, no I/O.
submitted: {idCarrier, totalShippingTaxIncl, totalShippingTaxExcl}
stored: {idCarrier, totalShippingTaxIncl, totalShippingTaxExcl, hasOrderCarrierRow}
Returns {needsRepair, reasons, patch}. patch is None unless needsRepair is True,
in which case it carries only the corrected order fields.
"""
reasons = []
if stored["idCarrier"] == 0 or stored["idCarrier"] != submitted["idCarrier"]:
reasons.append("carrier_mismatch")
incl_off = abs(stored["totalShippingTaxIncl"] - submitted["totalShippingTaxIncl"]) > epsilon
excl_off = abs(stored["totalShippingTaxExcl"] - submitted["totalShippingTaxExcl"]) > epsilon
if (incl_off or excl_off) and "shipping_amount_mismatch" not in reasons:
reasons.append("shipping_amount_mismatch")
if not stored["hasOrderCarrierRow"]:
reasons.append("missing_order_carrier_row")
if not reasons:
return {"needsRepair": False, "reasons": [], "patch": None}
patch = {
"id_carrier": submitted["idCarrier"],
"total_shipping": submitted["totalShippingTaxIncl"],
"total_shipping_tax_incl": submitted["totalShippingTaxIncl"],
"total_shipping_tax_excl": submitted["totalShippingTaxExcl"],
}
return {"needsRepair": True, "reasons": reasons, "patch": patch}
def load_submitted_log(path):
if not os.path.exists(path):
return {}
with open(path) as f:
return json.load(f)
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 get_order(id_order):
return api_get(f"orders/{id_order}")["order"]
def get_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 stored_values_from(order, carrier_rows):
row = carrier_rows[0] if carrier_rows else None
return {
"idCarrier": int(order.get("id_carrier") or 0),
"totalShippingTaxIncl": float(order.get("total_shipping_tax_incl") or 0),
"totalShippingTaxExcl": float(order.get("total_shipping_tax_excl") or 0),
"hasOrderCarrierRow": row is not None,
}
def add_order_carrier_row(id_order, submitted):
r = requests.post(
f"{PRESTASHOP_URL}/api/order_carriers",
params={"output_format": "JSON"},
json={"order_carrier": {
"id_order": id_order,
"id_carrier": submitted["idCarrier"],
"shipping_cost_tax_excl": f"{submitted['totalShippingTaxExcl']:.6f}",
"shipping_cost_tax_incl": f"{submitted['totalShippingTaxIncl']:.6f}",
}},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def apply_order_patch(id_order, patch):
order = get_order(id_order)
order.update(patch)
r = requests.put(
f"{PRESTASHOP_URL}/api/orders/{id_order}",
params={"output_format": "JSON"},
json={"order": order},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def reapply_current_state(id_order, id_order_state):
r = requests.post(
f"{PRESTASHOP_URL}/api/order_histories",
params={"output_format": "JSON"},
json={"order_history": {"id_order": id_order, "id_order_state": id_order_state}},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
submitted_log = load_submitted_log(SUBMITTED_LOG_PATH)
flagged = 0
repaired = 0
for id_order_str, submitted in submitted_log.items():
id_order = int(id_order_str)
order = get_order(id_order)
carrier_rows = get_order_carrier_rows(id_order)
stored = stored_values_from(order, carrier_rows)
decision = decide_carrier_shipping_repair(submitted, stored)
if not decision["needsRepair"]:
continue
flagged += 1
log.warning(
"Order %s carrier/shipping mismatch. reasons=%s before=%s after=%s",
id_order, decision["reasons"], stored, decision["patch"],
)
if DRY_RUN:
continue
if not stored["hasOrderCarrierRow"]:
add_order_carrier_row(id_order, submitted)
apply_order_patch(id_order, decision["patch"])
if order.get("current_state") and str(order.get("current_state")) == os.environ.get("PS_OS_ERROR", ""):
reapply_current_state(id_order, order["current_state"])
repaired += 1
log.info("Repaired carrier/shipping on id_order=%s.", id_order)
log.info("Done. %d order(s) flagged, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect and repair PrestaShop orders that dropped their carrier or shipping cost.
*
* The orders webservice resource exposes id_carrier, total_shipping,
* total_shipping_tax_incl/excl, and carrier_tax_rate as writable fields, but
* Order::add() does not persist them as submitted. The order is actually built
* from the referenced cart (id_cart), and the carrier and shipping amounts are
* recalculated from the cart's own stored delivery option instead of trusted
* from the POST body. The result is a created order whose id_carrier reverts
* to 0 or the shop's default carrier, and whose total_shipping is recalculated
* or dropped to 0, while a correctly-linked order_carriers row may or may not
* exist.
*
* This is a long-standing, still-open core bug (PrestaShop/PrestaShop#19906,
* duplicated by #32622), acknowledged by maintainers as a known backlog item.
*
* This script only logs the planned diff by default. It never writes to an
* order or order_carriers unless DRY_RUN is explicitly false. Run against
* orders you created recently, keyed by id_order in a local submitted-values
* log. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/webservice-order-drops-carrier-shipping/
*/
import { pathToFileURL } from "node:url";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
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 SUBMITTED_LOG_PATH = process.env.SUBMITTED_LOG_PATH || "submitted_orders.json";
const PS_OS_ERROR = process.env.PS_OS_ERROR || "";
const EPSILON = 0.01;
/**
* Pure decision logic, no I/O.
*
* submitted: { idCarrier, totalShippingTaxIncl, totalShippingTaxExcl }
* stored: { idCarrier, totalShippingTaxIncl, totalShippingTaxExcl, hasOrderCarrierRow }
*
* Returns { needsRepair, reasons, patch }. patch is null unless needsRepair is
* true, in which case it carries only the corrected order fields.
*/
export function decideCarrierShippingRepair(submitted, stored, epsilon = EPSILON) {
const reasons = [];
if (stored.idCarrier === 0 || stored.idCarrier !== submitted.idCarrier) {
reasons.push("carrier_mismatch");
}
const inclOff = Math.abs(stored.totalShippingTaxIncl - submitted.totalShippingTaxIncl) > epsilon;
const exclOff = Math.abs(stored.totalShippingTaxExcl - submitted.totalShippingTaxExcl) > epsilon;
if ((inclOff || exclOff) && !reasons.includes("shipping_amount_mismatch")) {
reasons.push("shipping_amount_mismatch");
}
if (!stored.hasOrderCarrierRow) {
reasons.push("missing_order_carrier_row");
}
if (reasons.length === 0) {
return { needsRepair: false, reasons: [], patch: null };
}
const patch = {
id_carrier: submitted.idCarrier,
total_shipping: submitted.totalShippingTaxIncl,
total_shipping_tax_incl: submitted.totalShippingTaxIncl,
total_shipping_tax_excl: submitted.totalShippingTaxExcl,
};
return { needsRepair: true, reasons, patch };
}
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
async function loadSubmittedLog(path) {
if (!existsSync(path)) return {};
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
}
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 getOrder(idOrder) {
const data = await apiGet(`orders/${idOrder}`);
return data.order;
}
async function getOrderCarrierRows(idOrder) {
const data = await apiGet("order_carriers", { "filter[id_order]": idOrder, display: "full" });
return data.order_carriers || [];
}
function storedValuesFrom(order, carrierRows) {
const row = carrierRows.length > 0 ? carrierRows[0] : null;
return {
idCarrier: Number(order.id_carrier || 0),
totalShippingTaxIncl: Number(order.total_shipping_tax_incl || 0),
totalShippingTaxExcl: Number(order.total_shipping_tax_excl || 0),
hasOrderCarrierRow: row !== null,
};
}
async function addOrderCarrierRow(idOrder, submitted) {
const url = new URL(`${PRESTASHOP_URL}/api/order_carriers`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({
order_carrier: {
id_order: idOrder,
id_carrier: submitted.idCarrier,
shipping_cost_tax_excl: submitted.totalShippingTaxExcl.toFixed(6),
shipping_cost_tax_incl: submitted.totalShippingTaxIncl.toFixed(6),
},
}),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_carriers`);
return res.json();
}
async function applyOrderPatch(idOrder, patch) {
const order = await getOrder(idOrder);
Object.assign(order, patch);
const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ order }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
return res.json();
}
async function reapplyCurrentState(idOrder, idOrderState) {
const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
return res.json();
}
export async function run() {
const submittedLog = await loadSubmittedLog(SUBMITTED_LOG_PATH);
let flagged = 0;
let repaired = 0;
for (const [idOrderStr, submitted] of Object.entries(submittedLog)) {
const idOrder = Number(idOrderStr);
const order = await getOrder(idOrder);
const carrierRows = await getOrderCarrierRows(idOrder);
const stored = storedValuesFrom(order, carrierRows);
const decision = decideCarrierShippingRepair(submitted, stored);
if (!decision.needsRepair) continue;
flagged++;
console.warn(
`Order ${idOrder} carrier/shipping mismatch. reasons=${decision.reasons.join(",")} before=${JSON.stringify(stored)} after=${JSON.stringify(decision.patch)}`
);
if (DRY_RUN) continue;
if (!stored.hasOrderCarrierRow) {
await addOrderCarrierRow(idOrder, submitted);
}
await applyOrderPatch(idOrder, decision.patch);
if (order.current_state && String(order.current_state) === PS_OS_ERROR) {
await reapplyCurrentState(idOrder, order.current_state);
}
repaired++;
console.log(`Repaired carrier/shipping on id_order=${idOrder}.`);
}
console.log(`Done. ${flagged} order(s) flagged, ${repaired} repaired. DRY_RUN=${DRY_RUN}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which orders get repaired and exactly what gets written back. Because we kept decide_carrier_shipping_repair pure, the tests need no network and no PrestaShop store. They just feed in plain dicts and check the answer.
from repair_carrier_shipping import decide_carrier_shipping_repair
def submitted(**over):
base = {"idCarrier": 3, "totalShippingTaxIncl": 5.00, "totalShippingTaxExcl": 4.17}
base.update(over)
return base
def stored(**over):
base = {"idCarrier": 3, "totalShippingTaxIncl": 5.00, "totalShippingTaxExcl": 4.17,
"hasOrderCarrierRow": True}
base.update(over)
return base
def test_no_repair_when_everything_agrees():
result = decide_carrier_shipping_repair(submitted(), stored())
assert result == {"needsRepair": False, "reasons": [], "patch": None}
def test_carrier_dropped_to_zero_is_flagged():
result = decide_carrier_shipping_repair(submitted(), stored(idCarrier=0))
assert result["needsRepair"] is True
assert "carrier_mismatch" in result["reasons"]
assert result["patch"]["id_carrier"] == 3
def test_carrier_reverted_to_different_id_is_flagged():
result = decide_carrier_shipping_repair(submitted(idCarrier=7), stored(idCarrier=2))
assert result["needsRepair"] is True
assert "carrier_mismatch" in result["reasons"]
assert result["patch"]["id_carrier"] == 7
def test_shipping_amount_recalculated_is_flagged():
result = decide_carrier_shipping_repair(submitted(), stored(totalShippingTaxIncl=8.40))
assert result["needsRepair"] is True
assert result["reasons"] == ["shipping_amount_mismatch"]
assert result["patch"]["total_shipping_tax_incl"] == 5.00
def test_missing_order_carrier_row_is_flagged():
result = decide_carrier_shipping_repair(submitted(), stored(hasOrderCarrierRow=False))
assert result["needsRepair"] is True
assert result["reasons"] == ["missing_order_carrier_row"]
def test_multiple_reasons_can_combine():
result = decide_carrier_shipping_repair(
submitted(),
stored(idCarrier=0, totalShippingTaxIncl=0.0, totalShippingTaxExcl=0.0, hasOrderCarrierRow=False),
)
assert set(result["reasons"]) == {"carrier_mismatch", "shipping_amount_mismatch", "missing_order_carrier_row"}
def test_small_rounding_difference_is_not_flagged():
result = decide_carrier_shipping_repair(submitted(), stored(totalShippingTaxIncl=5.004))
assert result["needsRepair"] is False
def test_patch_contains_only_corrected_fields():
result = decide_carrier_shipping_repair(submitted(), stored(idCarrier=0))
assert set(result["patch"].keys()) == {
"id_carrier", "total_shipping", "total_shipping_tax_incl", "total_shipping_tax_excl",
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideCarrierShippingRepair } from "./repair-carrier-shipping.js";
const submitted = (over = {}) => ({
idCarrier: 3, totalShippingTaxIncl: 5.00, totalShippingTaxExcl: 4.17,
...over,
});
const stored = (over = {}) => ({
idCarrier: 3, totalShippingTaxIncl: 5.00, totalShippingTaxExcl: 4.17,
hasOrderCarrierRow: true,
...over,
});
test("no repair when everything agrees", () => {
const result = decideCarrierShippingRepair(submitted(), stored());
assert.deepEqual(result, { needsRepair: false, reasons: [], patch: null });
});
test("carrier dropped to zero is flagged", () => {
const result = decideCarrierShippingRepair(submitted(), stored({ idCarrier: 0 }));
assert.equal(result.needsRepair, true);
assert.ok(result.reasons.includes("carrier_mismatch"));
assert.equal(result.patch.id_carrier, 3);
});
test("carrier reverted to a different id is flagged", () => {
const result = decideCarrierShippingRepair(submitted({ idCarrier: 7 }), stored({ idCarrier: 2 }));
assert.equal(result.needsRepair, true);
assert.ok(result.reasons.includes("carrier_mismatch"));
assert.equal(result.patch.id_carrier, 7);
});
test("shipping amount recalculated is flagged", () => {
const result = decideCarrierShippingRepair(submitted(), stored({ totalShippingTaxIncl: 8.40 }));
assert.equal(result.needsRepair, true);
assert.deepEqual(result.reasons, ["shipping_amount_mismatch"]);
assert.equal(result.patch.total_shipping_tax_incl, 5.00);
});
test("missing order_carrier row is flagged", () => {
const result = decideCarrierShippingRepair(submitted(), stored({ hasOrderCarrierRow: false }));
assert.equal(result.needsRepair, true);
assert.deepEqual(result.reasons, ["missing_order_carrier_row"]);
});
test("multiple reasons can combine", () => {
const result = decideCarrierShippingRepair(
submitted(),
stored({ idCarrier: 0, totalShippingTaxIncl: 0, totalShippingTaxExcl: 0, hasOrderCarrierRow: false })
);
assert.deepEqual(
new Set(result.reasons),
new Set(["carrier_mismatch", "shipping_amount_mismatch", "missing_order_carrier_row"])
);
});
test("small rounding difference is not flagged", () => {
const result = decideCarrierShippingRepair(submitted(), stored({ totalShippingTaxIncl: 5.004 }));
assert.equal(result.needsRepair, false);
});
test("patch contains only corrected fields", () => {
const result = decideCarrierShippingRepair(submitted(), stored({ idCarrier: 0 }));
assert.deepEqual(
new Set(Object.keys(result.patch)),
new Set(["id_carrier", "total_shipping", "total_shipping_tax_incl", "total_shipping_tax_excl"])
);
});
Case studies
The warehouse system that submitted a real carrier every time
A merchant's ERP created orders through the webservice for phone orders, always sending the correct id_carrier and the exact shipping quote given to the customer. Weeks later, finance noticed the shipping revenue on those orders never matched what had been quoted, and some invoices showed the shop's default flat-rate carrier instead of the express courier the customer actually paid for.
Running the detection script against the ERP's own submission log turned up dozens of orders with carrier_mismatch and shipping_amount_mismatch. The repair added the missing order_carriers rows and PUT the correct carrier and shipping cost back, and the invoices finally matched what was quoted.
The order that failed for no visible reason
A mobile app created an order via the webservice with a nonzero shipping cost, but the created order came back with total_shipping_tax_incl at 0.00. Because total_paid no longer reconciled with total_products + total_shipping, the order tripped into a payment error state and support had no obvious explanation for the customer.
The script flagged the order with shipping_amount_mismatch and missing_order_carrier_row, repaired both, and only then re-applied the order's current state through order_histories now that the totals genuinely reconciled, moving it out of the false error state.
After this runs against orders you create through the webservice, a dropped or recalculated carrier and shipping cost gets caught immediately instead of surfacing weeks later in a finance reconciliation. Every flagged order gets an exact before and after diff, the missing order_carriers row is filled in, the order's own fields match what was actually charged, and false payment error states clear only once the totals genuinely agree.
FAQ
Why does an order created through the webservice lose its carrier and shipping cost?
The orders resource lets you submit id_carrier and total_shipping fields, but PrestaShop's Order::add() does not persist them as submitted. It builds the order from the referenced cart instead, and recalculates the carrier and shipping amounts from the cart's own stored delivery option, so the values you posted are overwritten or dropped.
Is this a known PrestaShop bug or a configuration mistake?
It is a known, still-open core bug, tracked as PrestaShop/PrestaShop issue 19906 and duplicated by issue 32622. Maintainers have acknowledged it as a backlog item. It is not something you can fix by changing webservice settings or permissions.
How do I safely repair an order whose carrier or shipping cost was dropped?
Compare the stored id_carrier and total_shipping_tax_incl and total_shipping_tax_excl against the values you originally submitted when creating the order. If they disagree, or the order_carriers row for that order is missing, add or correct the order_carriers row and PUT the full order representation back with the corrected carrier and shipping fields, then only touch order_histories if the mismatch had pushed the order into a payment error state.
Related field notes
Citations
On the problem:
- PrestaShop/PrestaShop GitHub issue #19906: WebService API, create new order, id_carrier and total_shipping problem. github.com/PrestaShop/PrestaShop/issues/19906
- PrestaShop/PrestaShop GitHub issue #32622: WebService API, create new order, id_carrier and total_shipping ignored (duplicate). github.com/PrestaShop/PrestaShop/issues/32622
- PrestaShop Forums: WebService API, create new order, id_carrier and total_shipping is ignored. prestashop.com/forums/topic/1026268
On the solution:
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/8/webservice/resources/orders/
- PrestaShop Developer Documentation: Order carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_carriers/
- PrestaShop Developer Documentation: Carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/carriers/
Stuck on a tricky one?
If you have a problem in PrestaShop orders, totals, stock, or the webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a dropped shipping cost?
If this saved you a wrong invoice or an awkward payment error investigation, 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