Diagnostic
Free shipping voucher fails to zero out the shipping cost
The customer enters a free shipping code, the cart summary even shows the discount line, checkout goes through, and the confirmation email still lists a shipping charge. Nothing looks broken from the storefront, and the voucher shows as applied. But the order's shipping total never actually dropped to zero. Here is why PrestaShop's free_shipping flag can be honored on screen and ignored on the order, and a script that finds every order where this happened.
PrestaShop stores a cart rule's free shipping benefit as a boolean flag, free_shipping, on cart_rule and cart_rule_action. That flag only turns into an actual zero shipping cost when the normal cart totals pipeline, Cart::getTotalShippingCost and getPackageShippingCost, runs and the rule passes every restriction check: carrier_restriction, minimum_amount, product or category or group scoping, and combinability with any other cart rule already applied. If the voucher is stacked with a non-combinable rule, the customer's carrier is not in the rule's allowed list, or the order was written directly through the webservice, a bulk import, a POS sync, or a custom checkout instead of going through Cart totals recalculation, the flag never reaches the order's total_shipping and total_shipping_tax_incl fields, and the carrier's full computed cost stays on the order. Run a Python or Node.js script that lists active free shipping cart rules, cross-references each order's associated rules through order_cart_rules, and flags any order where a free_shipping=1 rule is attached but total_shipping_tax_incl is still greater than zero. Full code, tests, and citations are below.
The problem in plain words
A free shipping voucher in PrestaShop is not its own kind of object. It is one flag, free_shipping, sitting on a regular cart rule alongside whatever percentage or amount discount that rule also carries. When the flag is on, PrestaShop is supposed to zero out the shipping cost for the packages the rule covers.
That zeroing out is not automatic just because the flag exists. It only happens when the cart's normal totals calculation actually runs and the rule clears every restriction attached to it. If any one of those checks trips, silently, the flag stays true in the database, the voucher still shows as applied in the cart summary, and yet the shipping line on the finished order keeps its full computed cost. The customer sees a voucher that looks like it worked. The order says otherwise.
Why it happens
The root cause is that free_shipping is a passive flag, not an action. PrestaShop has to actively read it, check it against every restriction on the rule, and apply it during the same totals pass that computes the carrier cost. Documented ways that pass never lands cleanly:
- The voucher is combined with another cart rule that is not marked combinable, so PrestaShop applies one rule and silently drops the other's benefit, confirmed as a display and calculation bug in PrestaShop/PrestaShop issue #18533.
- The rule carries a
carrier_restrictionand the order's actual carrier,id_carrier, is not on that allowed list, so the shipping cost is correctly left in place, but nothing distinguishes this legitimate case from a genuine bug unless you check the restriction yourself. - A rule that pairs a percent discount with
free_shippingapplies the percent discount but not the shipping benefit, tracked in PrestaShop/PrestaShop issue #17489. - The order is created through the webservice, a bulk import, a POS sync, or a custom checkout that writes
total_shippingandtotal_shipping_tax_incldirectly instead of going throughCart::getTotalShippingCostandgetPackageShippingCost, so the flag on the cart rule is never consulted at all.
This is a long-standing, repeatedly reported class of bug rather than one single fixed defect, spanning both display glitches and real calculation misses, as the community forum thread on free shipping vouchers not working properly also shows. So shipping can silently stay nonzero even while the voucher line reads as applied. See the citations at the end for the exact issues and forum thread.
A nonzero carrier_restriction mismatch is not a bug, it is the rule working as configured, and a script has to tell that apart from an actual failure. The safe signal is: the rule is active and free shipping is set, the order date falls in the rule's validity window, the order's carrier is allowed by the rule's carrier_restriction (or the rule has no restriction at all), and after all of that the order's total_shipping_tax_incl is still greater than zero. Only that combination means PrestaShop should have zeroed the shipping and did not.
The fix, as a flow
We do not touch order totals automatically. We add a job that lists every active free shipping cart rule, pulls each order's attached cart rules through order_cart_rules, and runs each pairing through one pure check. Anything flagged becomes a report row naming the order, the voucher code, and the shipping amount that should have been zero, for manual review or a guarded, confirmed fix.
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 cart_rules, orders, and order_cart_rules, plus write access to orders only if you plan to run the guarded repair. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, only reports by default
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, only reports by default
List every active free shipping cart rule
Call GET /api/cart_rules?filter[free_shipping]=1&filter[active]=1&display=full&output_format=JSON to get the full set of vouchers whose benefit includes free shipping. Keep each rule's id, code, carrier_restriction, minimum_amount, date_from, and date_to, since every one of those feeds the decision later.
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 free_shipping_rules():
data = api_get("cart_rules", params={
"filter[free_shipping]": 1,
"filter[active]": 1,
"display": "full",
})
return data.get("cart_rules") 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 freeShippingRules() {
const data = await apiGet("cart_rules", {
"filter[free_shipping]": 1,
"filter[active]": 1,
display: "full",
});
return data.cart_rules || [];
}
Cross-reference each order's attached rules and carrier
For every order worth checking, call GET /api/orders?filter[id]=[id]&display=full&output_format=JSON for the order's totals and its id_carrier, and GET /api/order_cart_rules?filter[id_order]=[id]&display=full&output_format=JSON for the cart rules actually attached to it. Match each attached id_cart_rule against the free shipping rules from step 2 to see whether this order should have had its shipping zeroed.
def order_detail(id_order):
data = api_get(f"orders/{id_order}", params={"display": "full"})
return data.get("order") or {}
def order_cart_rules_for(id_order):
data = api_get("order_cart_rules", params={
"filter[id_order]": id_order,
"display": "full",
})
return data.get("order_cart_rules") or []
async function orderDetail(idOrder) {
const data = await apiGet(`orders/${idOrder}`, { display: "full" });
return data.order || {};
}
async function orderCartRulesFor(idOrder) {
const data = await apiGet("order_cart_rules", {
"filter[id_order]": idOrder,
display: "full",
});
return data.order_cart_rules || [];
}
Decide, with one pure function
Keep the decision in its own function that takes the cart rule, the order, and the order's carrier row, and returns whether this is a genuine violation. It only flags true when the rule is active with free_shipping set, the order's date falls in the rule's validity window, the order's carrier is allowed by carrier_restriction or the rule has no restriction at all, and after all of that total_shipping_tax_incl is still greater than zero. A legitimate carrier mismatch correctly returns false.
from decimal import Decimal
def decide_free_shipping_violation(cart_rule, order, order_carrier):
"""Pure decision function, no I/O.
Returns True (flag as violation) iff the rule is active and free_shipping is set,
the order date falls within [date_from, date_to], the order's carrier is allowed by
carrier_restriction (or there is no restriction), and total_shipping_tax_incl is
still greater than 0.00. Returns False otherwise, including when carrier_restriction
correctly excludes this carrier.
"""
if not cart_rule.get("active"):
return False
if not cart_rule.get("free_shipping"):
return False
order_date = order.get("date_add")
if not (cart_rule.get("date_from") <= order_date <= cart_rule.get("date_to")):
return False
restriction = cart_rule.get("carrier_restriction")
if restriction:
allowed_carriers = restriction if isinstance(restriction, (list, set, tuple)) else [restriction]
if order.get("id_carrier") not in allowed_carriers:
return False
return Decimal(str(order.get("total_shipping_tax_incl", "0"))) > Decimal("0.00")
/**
* Pure decision function, no I/O.
*
* Returns true (flag as violation) iff the rule is active and freeShipping is set, the
* order date falls within [dateFrom, dateTo], the order's carrier is allowed by
* carrierRestriction (or there is no restriction), and totalShippingTaxIncl is still
* greater than 0.00. Returns false otherwise, including when carrierRestriction
* correctly excludes this carrier.
*/
export function decideFreeShippingViolation(cartRule, order, orderCarrier) {
if (!cartRule.active) return false;
if (!cartRule.free_shipping) return false;
const orderDate = order.date_add;
if (!(cartRule.date_from <= orderDate && orderDate <= cartRule.date_to)) return false;
const restriction = cartRule.carrier_restriction;
if (restriction) {
const allowedCarriers = Array.isArray(restriction) ? restriction : [restriction];
if (!allowedCarriers.includes(order.id_carrier)) return false;
}
return Number(order.total_shipping_tax_incl || 0) > 0;
}
Report first, write only when explicitly authorized
Recomputing an order's totals has to reuse PrestaShop's own tax and shipping rules, not a script that blindly writes zero into a field. The default action is to flag the order id, the voucher code, and the nonzero total_shipping_tax_incl and total_shipping_tax_excl values for manual review or a back office recalculation. Only when DRY_RUN is explicitly turned off, and only after the carrier and combinability have been confirmed valid, does the script PUT the order with shipping set to zero and the paid total adjusted to match.
from decimal import Decimal
def build_zero_shipping_payload(order):
"""Build the order payload with shipping zeroed and total_paid adjusted.
Only ever logged, not sent, unless DRY_RUN is explicitly off and the free shipping
rule has already been confirmed valid for this order's carrier.
"""
order = dict(order)
shipping_incl = Decimal(str(order.get("total_shipping_tax_incl", "0")))
shipping_excl = Decimal(str(order.get("total_shipping_tax_excl", "0")))
total_paid_incl = Decimal(str(order.get("total_paid_tax_incl", "0"))) - shipping_incl
total_paid = Decimal(str(order.get("total_paid", "0"))) - shipping_incl
order["total_shipping"] = "0.00"
order["total_shipping_tax_incl"] = "0.00"
order["total_shipping_tax_excl"] = "0.00"
order["total_paid_tax_incl"] = str(total_paid_incl)
order["total_paid"] = str(total_paid)
return order
/**
* Build the order payload with shipping zeroed and total_paid adjusted.
*
* Only ever logged, not sent, unless DRY_RUN is explicitly off and the free shipping
* rule has already been confirmed valid for this order's carrier.
*/
export function buildZeroShippingPayload(order) {
const shippingIncl = Number(order.total_shipping_tax_incl || 0);
const totalPaidIncl = Number(order.total_paid_tax_incl || 0) - shippingIncl;
const totalPaid = Number(order.total_paid || 0) - shippingIncl;
return {
...order,
total_shipping: "0.00",
total_shipping_tax_incl: "0.00",
total_shipping_tax_excl: "0.00",
total_paid_tax_incl: totalPaidIncl.toFixed(2),
total_paid: totalPaid.toFixed(2),
};
}
Wire it together with a dry run guard
The loop ties every piece together: fetch every active free shipping rule once, then for each order in your review window fetch its attached cart rules and carrier, and run each rule and order pairing through decide_free_shipping_violation. Every violation gets a report row with the order id, voucher code, and nonzero shipping amounts. DRY_RUN controls only whether the confirmed order PUT actually executes. Run it on a schedule that matches how often orders come in, for example once a day.
Never blind-write a zero into total_shipping without confirming through order_carriers and order_cart_rules that the free shipping rule was genuinely valid for that order's carrier and was not intentionally excluded by a combinability or priority rule. Always start with DRY_RUN=true, log the before and after diff, and treat every flagged order as a lead for review first.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks every active free shipping rule against every order in scope, reports every violation, and only ever writes a confirmed zero shipping total when DRY_RUN is explicitly turned off.
"""Detect PrestaShop orders where a free shipping voucher applied but shipping stayed nonzero.
PrestaShop stores a cart rule's free shipping benefit as a boolean flag, free_shipping,
on cart_rule and cart_rule_action. That flag only turns into an actual zero shipping
cost when the normal cart totals pipeline, Cart::getTotalShippingCost and
getPackageShippingCost, runs and the rule passes every restriction check: carrier
restriction, minimum amount, product or category or group scoping, and combinability
with other applied rules. If the voucher is combined with a non-combinable rule, the
carrier is not in the allowed list, or the order was written through the webservice, a
bulk import, a POS sync, or a custom checkout instead of Cart totals recalculation, the
flag never reaches total_shipping and total_shipping_tax_incl, and the carrier's full
cost stays on the order. Confirmed as a display and calculation bug in
PrestaShop/PrestaShop issues #18533 and #17489, and reported repeatedly on the
PrestaShop community forums.
Recomputing order totals has to reuse PrestaShop's own tax and shipping rules, not a
script blindly zeroing a field, so the default action is to flag every violation for
manual review or a back office recalculation. A DRY_RUN-guarded write is available only
when explicitly authorized, after confirming through order_carriers and
order_cart_rules that the rule was genuinely valid for the order's carrier.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
from decimal import Decimal
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_free_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"
ORDER_IDS = [o for o in os.environ.get("ORDER_IDS", "").split(",") if o]
AUTH = (PRESTASHOP_WS_KEY, "")
def decide_free_shipping_violation(cart_rule, order, order_carrier):
"""Pure decision function, no I/O.
Returns True (flag as violation) iff the rule is active and free_shipping is set,
the order date falls within [date_from, date_to], the order's carrier is allowed by
carrier_restriction (or there is no restriction), and total_shipping_tax_incl is
still greater than 0.00. Returns False otherwise, including when carrier_restriction
correctly excludes this carrier.
"""
if not cart_rule.get("active"):
return False
if not cart_rule.get("free_shipping"):
return False
order_date = order.get("date_add")
if not order_date:
return False
if not (cart_rule.get("date_from") <= order_date <= cart_rule.get("date_to")):
return False
restriction = cart_rule.get("carrier_restriction")
if restriction:
allowed_carriers = restriction if isinstance(restriction, (list, set, tuple)) else [restriction]
if order.get("id_carrier") not in allowed_carriers:
return False
return Decimal(str(order.get("total_shipping_tax_incl", "0"))) > Decimal("0.00")
def build_zero_shipping_payload(order):
order = dict(order)
shipping_incl = Decimal(str(order.get("total_shipping_tax_incl", "0")))
total_paid_incl = Decimal(str(order.get("total_paid_tax_incl", "0"))) - shipping_incl
total_paid = Decimal(str(order.get("total_paid", "0"))) - shipping_incl
order["total_shipping"] = "0.00"
order["total_shipping_tax_incl"] = "0.00"
order["total_shipping_tax_excl"] = "0.00"
order["total_paid_tax_incl"] = str(total_paid_incl)
order["total_paid"] = str(total_paid)
return order
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, payload):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
json=payload,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def free_shipping_rules():
data = api_get("cart_rules", params={
"filter[free_shipping]": 1,
"filter[active]": 1,
"display": "full",
})
return data.get("cart_rules") or []
def order_detail(id_order):
data = api_get(f"orders/{id_order}", params={"display": "full"})
return data.get("order") or {}
def order_cart_rules_for(id_order):
data = api_get("order_cart_rules", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_cart_rules") or []
def scan_orders(order_ids):
rules_by_id = {str(r["id"]): r for r in free_shipping_rules()}
flagged = []
for id_order in order_ids:
order = order_detail(id_order)
if not order:
continue
for link in order_cart_rules_for(id_order):
rule = rules_by_id.get(str(link.get("id_cart_rule")))
if not rule:
continue
violation = decide_free_shipping_violation(rule, order, {})
if violation:
flagged.append({
"id_order": id_order,
"id_cart_rule": rule["id"],
"voucher_code": rule.get("code"),
"id_carrier": order.get("id_carrier"),
"total_shipping_tax_incl": order.get("total_shipping_tax_incl"),
"total_shipping_tax_excl": order.get("total_shipping_tax_excl"),
"order": order,
})
return flagged
def repair_order(row):
payload = build_zero_shipping_payload(row["order"])
log.info(
"%s order %s: would set total_shipping_tax_incl from %s to 0.00 (voucher %s)",
"DRY RUN" if DRY_RUN else "REPAIRING",
row["id_order"], row["total_shipping_tax_incl"], row["voucher_code"],
)
if not DRY_RUN:
api_put(f"orders/{row['id_order']}", payload)
def run():
violations = scan_orders(ORDER_IDS)
for row in violations:
log.warning(
"Free shipping voucher not applied. id_order=%s code=%s id_carrier=%s "
"total_shipping_tax_incl=%s total_shipping_tax_excl=%s",
row["id_order"], row["voucher_code"], row["id_carrier"],
row["total_shipping_tax_incl"], row["total_shipping_tax_excl"],
)
repair_order(row)
log.info(
"Done. %d order(s) with an unapplied free shipping voucher %s.",
len(violations), "would be fixed" if DRY_RUN else "fixed",
)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop orders where a free shipping voucher applied but shipping stayed nonzero.
*
* PrestaShop stores a cart rule's free shipping benefit as a boolean flag, free_shipping,
* on cart_rule and cart_rule_action. That flag only turns into an actual zero shipping
* cost when the normal cart totals pipeline, Cart::getTotalShippingCost and
* getPackageShippingCost, runs and the rule passes every restriction check: carrier
* restriction, minimum amount, product or category or group scoping, and combinability
* with other applied rules. If the voucher is combined with a non-combinable rule, the
* carrier is not in the allowed list, or the order was written through the webservice, a
* bulk import, a POS sync, or a custom checkout instead of Cart totals recalculation, the
* flag never reaches total_shipping and total_shipping_tax_incl, and the carrier's full
* cost stays on the order. Confirmed as a display and calculation bug in
* PrestaShop/PrestaShop issues #18533 and #17489, and reported repeatedly on the
* PrestaShop community forums.
*
* Recomputing order totals has to reuse PrestaShop's own tax and shipping rules, not a
* script blindly zeroing a field, so the default action is to flag every violation for
* manual review or a back office recalculation. A DRY_RUN-guarded write is available only
* when explicitly authorized, after confirming through order_carriers and
* order_cart_rules that the rule was genuinely valid for the order's carrier.
*
* Guide: https://www.allanninal.dev/prestashop/free-shipping-voucher-not-applied/
*/
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 ORDER_IDS = (process.env.ORDER_IDS || "").split(",").filter(Boolean);
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* Returns true (flag as violation) iff the rule is active and freeShipping is set, the
* order date falls within [dateFrom, dateTo], the order's carrier is allowed by
* carrierRestriction (or there is no restriction), and totalShippingTaxIncl is still
* greater than 0.00. Returns false otherwise, including when carrierRestriction
* correctly excludes this carrier.
*/
export function decideFreeShippingViolation(cartRule, order, orderCarrier) {
if (!cartRule.active) return false;
if (!cartRule.free_shipping) return false;
const orderDate = order.date_add;
if (!orderDate) return false;
if (!(cartRule.date_from <= orderDate && orderDate <= cartRule.date_to)) return false;
const restriction = cartRule.carrier_restriction;
if (restriction) {
const allowedCarriers = Array.isArray(restriction) ? restriction : [restriction];
if (!allowedCarriers.includes(order.id_carrier)) return false;
}
return Number(order.total_shipping_tax_incl || 0) > 0;
}
export function buildZeroShippingPayload(order) {
const shippingIncl = Number(order.total_shipping_tax_incl || 0);
const totalPaidIncl = Number(order.total_paid_tax_incl || 0) - shippingIncl;
const totalPaid = Number(order.total_paid || 0) - shippingIncl;
return {
...order,
total_shipping: "0.00",
total_shipping_tax_incl: "0.00",
total_shipping_tax_excl: "0.00",
total_paid_tax_incl: totalPaidIncl.toFixed(2),
total_paid: totalPaid.toFixed(2),
};
}
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, payload) {
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(payload),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function freeShippingRules() {
const data = await apiGet("cart_rules", {
"filter[free_shipping]": 1,
"filter[active]": 1,
display: "full",
});
return data.cart_rules || [];
}
async function orderDetail(idOrder) {
const data = await apiGet(`orders/${idOrder}`, { display: "full" });
return data.order || {};
}
async function orderCartRulesFor(idOrder) {
const data = await apiGet("order_cart_rules", { "filter[id_order]": idOrder, display: "full" });
return data.order_cart_rules || [];
}
async function scanOrders(orderIds) {
const rules = await freeShippingRules();
const rulesById = new Map(rules.map((r) => [String(r.id), r]));
const flagged = [];
for (const idOrder of orderIds) {
const order = await orderDetail(idOrder);
if (!order || !Object.keys(order).length) continue;
for (const link of await orderCartRulesFor(idOrder)) {
const rule = rulesById.get(String(link.id_cart_rule));
if (!rule) continue;
const violation = decideFreeShippingViolation(rule, order, {});
if (violation) {
flagged.push({
id_order: idOrder,
id_cart_rule: rule.id,
voucher_code: rule.code,
id_carrier: order.id_carrier,
total_shipping_tax_incl: order.total_shipping_tax_incl,
total_shipping_tax_excl: order.total_shipping_tax_excl,
order,
});
}
}
}
return flagged;
}
async function repairOrder(row) {
const payload = buildZeroShippingPayload(row.order);
console.log(
`${DRY_RUN ? "DRY RUN" : "REPAIRING"} order ${row.id_order}: would set total_shipping_tax_incl from ${row.total_shipping_tax_incl} to 0.00 (voucher ${row.voucher_code})`
);
if (!DRY_RUN) await apiPut(`orders/${row.id_order}`, payload);
}
export async function run() {
const violations = await scanOrders(ORDER_IDS);
for (const row of violations) {
console.warn(
`Free shipping voucher not applied. id_order=${row.id_order} code=${row.voucher_code} ` +
`id_carrier=${row.id_carrier} total_shipping_tax_incl=${row.total_shipping_tax_incl} ` +
`total_shipping_tax_excl=${row.total_shipping_tax_excl}`
);
await repairOrder(row);
}
console.log(
`Done. ${violations.length} order(s) with an unapplied free shipping voucher ${DRY_RUN ? "would be fixed" : "fixed"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get reported as a genuine free shipping failure versus a legitimate carrier exclusion. Because we kept decide_free_shipping_violation pure, the test needs no network and no PrestaShop store. It just feeds in plain dictionaries and checks the answer, including the carrier restriction boundary.
from check_free_shipping import decide_free_shipping_violation
DATE_FROM = "2026-07-01 00:00:00"
DATE_TO = "2026-07-31 23:59:59"
def cart_rule(**over):
base = {
"id": 7,
"code": "FREESHIP",
"active": True,
"free_shipping": True,
"carrier_restriction": None,
"date_from": DATE_FROM,
"date_to": DATE_TO,
}
base.update(over)
return base
def order(**over):
base = {
"id_carrier": 2,
"date_add": "2026-07-15 10:00:00",
"total_shipping_tax_incl": "5.99",
}
base.update(over)
return base
def test_flags_when_eligible_and_shipping_nonzero():
assert decide_free_shipping_violation(cart_rule(), order(), {}) is True
def test_no_violation_when_shipping_already_zero():
assert decide_free_shipping_violation(cart_rule(), order(total_shipping_tax_incl="0.00"), {}) is False
def test_no_violation_when_rule_inactive():
assert decide_free_shipping_violation(cart_rule(active=False), order(), {}) is False
def test_no_violation_when_free_shipping_not_set():
assert decide_free_shipping_violation(cart_rule(free_shipping=False), order(), {}) is False
def test_no_violation_when_order_date_outside_window():
assert decide_free_shipping_violation(cart_rule(), order(date_add="2026-08-05 00:00:00"), {}) is False
def test_no_violation_when_carrier_excluded_by_restriction():
rule = cart_rule(carrier_restriction=[3, 4])
assert decide_free_shipping_violation(rule, order(id_carrier=2), {}) is False
def test_flags_when_carrier_is_in_restriction_list():
rule = cart_rule(carrier_restriction=[2, 3])
assert decide_free_shipping_violation(rule, order(id_carrier=2), {}) is True
def test_no_violation_when_missing_order_date():
assert decide_free_shipping_violation(cart_rule(), order(date_add=None), {}) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideFreeShippingViolation } from "./check-free-shipping.js";
const DATE_FROM = "2026-07-01 00:00:00";
const DATE_TO = "2026-07-31 23:59:59";
const cartRule = (over = {}) => ({
id: 7,
code: "FREESHIP",
active: true,
free_shipping: true,
carrier_restriction: null,
date_from: DATE_FROM,
date_to: DATE_TO,
...over,
});
const order = (over = {}) => ({
id_carrier: 2,
date_add: "2026-07-15 10:00:00",
total_shipping_tax_incl: "5.99",
...over,
});
test("flags when eligible and shipping nonzero", () => {
assert.equal(decideFreeShippingViolation(cartRule(), order(), {}), true);
});
test("no violation when shipping already zero", () => {
assert.equal(decideFreeShippingViolation(cartRule(), order({ total_shipping_tax_incl: "0.00" }), {}), false);
});
test("no violation when rule inactive", () => {
assert.equal(decideFreeShippingViolation(cartRule({ active: false }), order(), {}), false);
});
test("no violation when free_shipping not set", () => {
assert.equal(decideFreeShippingViolation(cartRule({ free_shipping: false }), order(), {}), false);
});
test("no violation when order date outside window", () => {
assert.equal(decideFreeShippingViolation(cartRule(), order({ date_add: "2026-08-05 00:00:00" }), {}), false);
});
test("no violation when carrier excluded by restriction", () => {
const rule = cartRule({ carrier_restriction: [3, 4] });
assert.equal(decideFreeShippingViolation(rule, order({ id_carrier: 2 }), {}), false);
});
test("flags when carrier is in restriction list", () => {
const rule = cartRule({ carrier_restriction: [2, 3] });
assert.equal(decideFreeShippingViolation(rule, order({ id_carrier: 2 }), {}), true);
});
test("no violation when missing order date", () => {
assert.equal(decideFreeShippingViolation(cartRule(), order({ date_add: null }), {}), false);
});
Case studies
The bundle code that ate the shipping benefit
An outdoor gear store ran a seasonal bundle discount alongside a separate free shipping code for loyalty members. Customers who had both in the cart at once got the percentage discount every time, but roughly a third of those orders still carried the full carrier cost. Support kept hearing "the voucher said free shipping" and had no fast way to confirm which orders were actually affected.
Running the diagnostic against a month of orders surfaced the pattern immediately: every affected order had the loyalty rule attached through order_cart_rules with free_shipping=1, but the bundle rule was not marked combinable, so PrestaShop had applied only one benefit. The team fixed the combinability setting going forward and used the report to refund shipping on the affected orders by hand.
The POS sync that skipped totals recalculation
A retailer syncing in-store sales into PrestaShop through a custom POS integration wrote orders directly via the webservice, including a free shipping loyalty voucher on qualifying purchases. Because the integration set total_shipping from its own POS-side shipping table rather than going through Cart totals, the free shipping flag on the attached cart rule was never consulted, and every synced order kept a nonzero shipping charge.
The diagnostic flagged the entire batch of synced orders in one run, all sharing the same free shipping rule id and a shipping total that never dropped to zero. With that clear pattern in hand, the retailer fixed the POS integration to zero the shipping field when the loyalty rule applied, and used the guarded repair, with DRY_RUN off only after confirming the carrier match, to correct the already-synced backlog.
After this runs on a schedule, an order where the free shipping flag never made it to the total shows up as a clear report row naming the order, the voucher code, and the exact amount that should have been zero, instead of a customer complaint or a quiet loss on your own shipping subsidy. Legitimate carrier exclusions are correctly left alone, and any real fix only ever happens after a human or a guarded, confirmed write reuses PrestaShop's own totals logic.
FAQ
Why does my PrestaShop free shipping voucher not remove the shipping cost?
The free_shipping flag on a cart rule is only turned into a zero shipping total when the normal cart totals pipeline runs and the rule passes every restriction check, including carrier_restriction, minimum_amount, and combinability with other applied rules. If the voucher is combined with a non-combinable rule, the customer's carrier is not in the allowed list, or the order was written through the webservice or a custom checkout instead of Cart totals recalculation, the flag never reaches total_shipping and total_shipping_tax_incl, so the carrier's full cost stays on the order.
How do I find orders where a free shipping voucher was not actually applied?
List active cart rules with free_shipping=1, then for each order pull its associated cart rules through order_cart_rules or the order's associations.cart_rules. Flag any order where an associated rule has free_shipping=1 but the order's total_shipping_tax_incl is greater than 0.00, after confirming the order's carrier is not legitimately excluded by that rule's carrier_restriction.
Is it safe to zero out the shipping total with a script once I find these orders?
Not by default. Recomputing an order's totals needs to reuse PrestaShop's own tax and shipping rules, not a script that blindly writes zero into a field. The safe default is to report the affected order IDs, the voucher code, and the nonzero shipping amounts for manual review or a back office recalculation, and only write through the webservice when DRY_RUN is explicitly turned off and the free shipping rule has been confirmed valid for that order's carrier.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: FO checkout, the shipping cost is incorrect in the cart detail when using a cart rule "Free shipping." Issue #18533. github.com/PrestaShop/PrestaShop/issues/18533
- PrestaShop GitHub: Cart rule with percent discount and free shipping does not apply free shipping. Issue #17489. github.com/PrestaShop/PrestaShop/issues/17489
- PrestaShop Forums: Cart rules, free shipping, vouchers not working properly. prestashop.com/forums/topic/279227-cart-rules-free-shipping-vouchers-not-working-properly
On the solution:
- PrestaShop Developer Documentation: Cart rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/cart_rules/
- 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/8/webservice/resources/order_cart_rules/
Stuck on a tricky one?
If you have a problem in PrestaShop orders, payments, vouchers, 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 untangle your shipping totals?
If this saved you a confusing support ticket or a quiet loss on your own shipping subsidy, 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