Diagnostic Vouchers & Cart Rules
Free gift line quantity doubles when unrelated cart items are removed
A customer qualifies for a free gift, the rule adds one free unit, everything looks right. Then they remove some completely different item from the cart, and the gift line quietly becomes quantity 2, still marked free, still costing nothing, but now handing out twice the product the rule was ever meant to give away. Nobody touched the gift. Nobody edited the rule. Here is why removing an unrelated item is exactly what triggers it, how to find every cart carrying the extra unit, and a script that reports the damage without silently rewriting a customer's cart.
An automatic free-gift cart rule, one with no voucher code and gift_product (plus gift_product_attribute) set, is re-evaluated by Cart::updateQty() on every cart mutation. When you remove an unrelated line item, PrestaShop first drops the cart's applicable rules, recalculates them, and re-adds the gift row through the same "up" quantity operator used for ordinary products. Because the existing ps_cart_product row for the gift (quantity 1, is_gift=1) has not been cleaned up yet at that point in the recalculation, the increment adds 1 to what is already there instead of inserting a fresh row, so the gift line ends up at quantity 2 with no rule authorizing more than one free unit. This was tracked upstream as PrestaShop/PrestaShop#22270 and fixed in 1.7.7.0, but the same class of desync still turns up in forks and custom modules that re-run cart-rule auto-add logic around gift lines on older codebases. Run a small Python or Node.js script that pulls every open cart's rows, cross-checks them against active cart rules where gift_product is set, and flags any cart row matching a gift product whose quantity is greater than 1. Full code, tests, and the decision function are below.
The problem in plain words
An automatic free-gift cart rule promises exactly one thing: buy what qualifies, get one specific product for free, added to the cart without the customer doing anything. PrestaShop enforces "exactly one free unit" by holding a single row for the gift in ps_cart_product, flagged is_gift=1, at quantity 1.
The trouble starts because that gift row is not static. Every time the cart changes, even for a reason that has nothing to do with the gift, PrestaShop drops the cart's currently applicable cart rules and works out which ones still qualify, then re-adds anything an automatic rule grants. Removing an unrelated item is one of those cart changes. The re-add step reaches for the normal "increase quantity" logic, the same code path used when a customer clicks the quantity stepper on a real product. That path assumes it is safe to add to whatever is already in the row. But the old gift row from before the recalculation has not been deleted yet, so the increment lands on top of it: 1 plus 1 becomes 2, and the cart rule that only ever authorized a single free unit now has no idea why there are two.
Why it happens
This traces to how PrestaShop's automatic cart rule pass interacts with normal quantity handling, not to a misconfigured rule. A few concrete ways stores end up with a doubled gift line:
- The cart rule has no voucher code at all, meaning it is an automatic rule with
gift_productandgift_product_attributeset, so PrestaShop applies and re-applies it on its own without the customer typing anything. Cart::updateQty()runs its auto-add-cart-rule pass on every cart mutation, including removing a completely unrelated product, changing the quantity of something else, or updating an address that changes rule eligibility.- The re-add step reuses the same "up" quantity operator that adds to an existing
ps_cart_productrow for a normal product, but at the point it runs, the gift's old row (quantity 1,is_gift=1) has not been deleted from the cart yet, so the increment lands on the stale row instead of inserting a clean one. - The result is a gift line at quantity 2 that no cart rule authorizes, since the rule that grants the gift was always designed to allow exactly one free unit per cart.
This was filed and fixed upstream as PrestaShop/PrestaShop#22270 in 1.7.7.0. A related report, PrestaShop/PrestaShop#21041, covers the adjacent case where a gift product and a manually added unit of the same product are not kept in separate cart rows. Forks and custom modules that call updateQty or run their own cart-rule auto-add logic around gift lines on older codebases can still reproduce the same class of desync. See the citations at the end for the exact threads and specs.
A free-gift cart rule by definition never authorizes more than 1 free unit per cart. So the audit does not need to understand the internals of Cart::updateQty() at all. It only needs two lists: the cart's rows, and the active cart rules whose gift_product is set. Any cart row whose (id_product, id_product_attribute) matches a known gift pair and whose quantity is greater than 1 is, by construction, a violation of the rule that granted it. That is the entire signal, and it holds regardless of which code path caused the desync.
The fix, as a flow
We never rewrite a customer's cart automatically. The script pulls every open cart's rows, pulls active cart rules that grant a gift product, builds a lookup of gift (id_product, id_product_attribute) pairs, and flags any cart row matching a gift pair at quantity greater than 1. Each finding carries the cart id, the product, the observed quantity, the granting rule, and whether the rule's code is empty, meaning it is an automatic rule, matching the reported bug path. A human then opens the cart in the back office and corrects it.
Build it step by step
Enable the Webservice API and get a key
In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the carts and cart_rules resources. 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, logs the intended PUT instead of sending it
// 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, logs the intended PUT instead of sending it
Talk to the Webservice API
Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and PUT and raises on a bad status.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, body):
r = requests.put(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiPut(path, body) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
Read open carts and the cart rules that grant a gift
Pull recent carts with their associations.cart_rows, which gives you each row's id_product, id_product_attribute, and quantity. Separately, pull active cart rules and keep only the ones where gift_product is greater than 0, noting id_product equals gift_product and id_product_attribute equals gift_product_attribute, plus the rule's code, since an empty code means an automatic rule and matches the reported bug path.
def open_carts(date_from, date_to, limit="0,200"):
data = api_get("carts", {
"display": "full",
"filter[date_upd]": f"[{date_from},{date_to}]",
"limit": limit,
})
return data.get("carts") or []
def cart_rows(cart):
assoc = cart.get("associations") or {}
rows = assoc.get("cart_rows") or []
return [
{
"id_product": int(row["id_product"]),
"id_product_attribute": int(row.get("id_product_attribute") or 0),
"quantity": int(row["quantity"]),
}
for row in rows
]
def gift_granting_cart_rules():
data = api_get("cart_rules", {"display": "full", "filter[active]": "1"})
rules = data.get("cart_rules") or []
out = []
for rule in rules:
gift_product = int(rule.get("gift_product") or 0)
if gift_product <= 0:
continue
out.append({
"id_cart_rule": int(rule["id"]),
"gift_product": gift_product,
"gift_product_attribute": int(rule.get("gift_product_attribute") or 0),
"code": rule.get("code") or "",
})
return out
async function openCarts(dateFrom, dateTo, limit = "0,200") {
const data = await apiGet("carts", {
display: "full",
"filter[date_upd]": `[${dateFrom},${dateTo}]`,
limit,
});
return data.carts || [];
}
function cartRows(cart) {
const rows = cart.associations?.cart_rows || [];
return rows.map((row) => ({
idProduct: Number(row.id_product),
idProductAttribute: Number(row.id_product_attribute || 0),
quantity: Number(row.quantity),
}));
}
async function giftGrantingCartRules() {
const data = await apiGet("cart_rules", { display: "full", "filter[active]": "1" });
const rules = data.cart_rules || [];
const out = [];
for (const rule of rules) {
const giftProduct = Number(rule.gift_product || 0);
if (giftProduct <= 0) continue;
out.push({
idCartRule: Number(rule.id),
giftProduct,
giftProductAttribute: Number(rule.gift_product_attribute || 0),
code: rule.code || "",
});
}
return out;
}
Decide, with one pure function
Keep the decision in its own function that takes a cart's rows and the list of gift-granting cart rules and returns the doubled gift lines. It builds a lookup of gift (id_product, id_product_attribute) pairs from rules where gift_product is greater than 0, then flags any cart row matching a gift pair whose quantity is greater than 1, since a free-gift rule by definition never authorizes more than one free unit.
def find_doubled_gift_lines(cart_rows, gift_rules):
gift_lookup = {}
for rule in gift_rules:
if rule["gift_product"] <= 0:
continue
key = (rule["gift_product"], rule["gift_product_attribute"])
gift_lookup[key] = rule
findings = []
for row in cart_rows:
if row["quantity"] <= 1:
continue
key = (row["id_product"], row["id_product_attribute"])
rule = gift_lookup.get(key)
if rule is None:
continue
findings.append({
"id_product": row["id_product"],
"id_product_attribute": row["id_product_attribute"],
"quantity": row["quantity"],
"id_cart_rule": rule["id_cart_rule"],
"is_automatic": rule["code"] == "",
})
return findings
export function findDoubledGiftLines(cartRows, giftRules) {
const giftLookup = new Map();
for (const rule of giftRules) {
if (rule.giftProduct <= 0) continue;
giftLookup.set(`${rule.giftProduct}:${rule.giftProductAttribute}`, rule);
}
const findings = [];
for (const row of cartRows) {
if (row.quantity <= 1) continue;
const rule = giftLookup.get(`${row.idProduct}:${row.idProductAttribute}`);
if (!rule) continue;
findings.push({
idProduct: row.idProduct,
idProductAttribute: row.idProductAttribute,
quantity: row.quantity,
idCartRule: rule.idCartRule,
isAutomatic: rule.code === "",
});
}
return findings;
}
Report, and only optionally correct
Always log the full finding for a human to review: the cart id, the product and attribute, the observed quantity, the granting rule, and whether the rule is automatic. This is not safely auto-fixable through the webservice by default, since rewriting the quantity risks stripping a legitimate paid unit if the same product is also genuinely purchased in that cart. The only optional write is a guarded correction, and it only fires when DRY_RUN is false and no separate non-gift row for the same product exists in the same cart, resetting the quantity for that (id_product, id_product_attribute) to 1.
Always start with DRY_RUN=true. The correct action for most stores is to flag the cart, the product, and the granting rule so a human opens the cart in the back office, confirms no legitimate extra unit was intentionally added, and manually corrects the quantity to 1, or advises the customer to re-trigger cart rule evaluation by re-adding and removing a cart item. If the store is still on an affected 1.7.x build, also plan to upgrade to 1.7.7.0 or later, where issue 22270 was fixed.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and the only write it can ever make is a guarded quantity correction on a confirmed pure-gift row.
"""Find PrestaShop carts where an automatic free-gift line ended up at quantity 2 or
more after an unrelated cart item was removed.
An automatic free-gift cart rule (no voucher code, gift_product and
gift_product_attribute set) is re-evaluated by Cart::updateQty() on every cart
mutation. When the qualifying line item is removed, PrestaShop first drops the cart's
applicable cart rules, recalculates them, and re-adds the gift row through the same
"up" quantity operator used for normal products. Because the gift's existing
ps_cart_product row (quantity 1, is_gift=1) has not been cleaned up yet at that point,
the increment adds 1 to the existing row instead of inserting a fresh one, leaving the
gift line at quantity 2 with no cart rule authorizing more than one free unit. Tracked
upstream as PrestaShop/PrestaShop#22270, fixed in 1.7.7.0; the same class of desync can
still recur in forks or custom modules on older codebases.
This script only reports. The optional, DRY_RUN-guarded corrective step only resets the
quantity to 1 on a cart row confirmed to be a pure gift line (no separate non-gift row
for the same product/attribute exists in the same cart); it never touches a cart row
that also carries a genuinely purchased quantity of the same product. Safe to run
again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_doubled_gift_lines")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DATE_FROM = os.environ.get("DATE_FROM", "2026-07-01")
DATE_TO = os.environ.get("DATE_TO", "2026-07-11")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, body):
r = requests.put(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
def open_carts(date_from, date_to, limit="0,200"):
data = api_get("carts", {
"display": "full",
"filter[date_upd]": f"[{date_from},{date_to}]",
"limit": limit,
})
return data.get("carts") or []
def cart_rows(cart):
assoc = cart.get("associations") or {}
rows = assoc.get("cart_rows") or []
return [
{
"id_product": int(row["id_product"]),
"id_product_attribute": int(row.get("id_product_attribute") or 0),
"quantity": int(row["quantity"]),
}
for row in rows
]
def gift_granting_cart_rules():
data = api_get("cart_rules", {"display": "full", "filter[active]": "1"})
rules = data.get("cart_rules") or []
out = []
for rule in rules:
gift_product = int(rule.get("gift_product") or 0)
if gift_product <= 0:
continue
out.append({
"id_cart_rule": int(rule["id"]),
"gift_product": gift_product,
"gift_product_attribute": int(rule.get("gift_product_attribute") or 0),
"code": rule.get("code") or "",
})
return out
def find_doubled_gift_lines(cart_rows_list, gift_rules):
"""
cart_rows_list: [{"id_product": int, "id_product_attribute": int, "quantity": int}, ...]
gift_rules: [{"id_cart_rule": int, "gift_product": int, "gift_product_attribute": int,
"code": str}, ...]
Returns a list of finding dicts: {"id_product", "id_product_attribute", "quantity",
"id_cart_rule", "is_automatic"}. Rows with quantity <= 1, or with no matching gift
rule, are excluded. is_automatic is True when the matching rule's code is empty,
matching the reported bug's no-code path.
"""
gift_lookup = {}
for rule in gift_rules:
if rule["gift_product"] <= 0:
continue
key = (rule["gift_product"], rule["gift_product_attribute"])
gift_lookup[key] = rule
findings = []
for row in cart_rows_list:
if row["quantity"] <= 1:
continue
key = (row["id_product"], row["id_product_attribute"])
rule = gift_lookup.get(key)
if rule is None:
continue
findings.append({
"id_product": row["id_product"],
"id_product_attribute": row["id_product_attribute"],
"quantity": row["quantity"],
"id_cart_rule": rule["id_cart_rule"],
"is_automatic": rule["code"] == "",
})
return findings
def is_pure_gift_row(cart_rows_list, id_product, id_product_attribute, gift_quantity):
"""True only when the doubled quantity is explained entirely by the gift row,
i.e. there is no separate non-gift row for the same product/attribute in this
cart that would make a quantity rewrite destroy a legitimately purchased unit.
Since PrestaShop keeps gift and normal rows separate when the desync has not
happened, a cart with exactly one row for the product/attribute at the observed
doubled quantity is safe to correct; more than one row means a human must look.
"""
matching = [
r for r in cart_rows_list
if r["id_product"] == id_product and r["id_product_attribute"] == id_product_attribute
]
return len(matching) == 1 and matching[0]["quantity"] == gift_quantity
def correct_gift_quantity(cart_id, cart, id_product, id_product_attribute):
body = {"cart": dict(cart)}
for row in body["cart"].get("associations", {}).get("cart_rows", []):
if int(row["id_product"]) == id_product and int(row.get("id_product_attribute") or 0) == id_product_attribute:
row["quantity"] = 1
if DRY_RUN:
log.info("Dry run: would PUT carts/%s to reset product %s quantity to 1", cart_id, id_product)
return None
return api_put(f"carts/{cart_id}", body)
def run():
gift_rules = gift_granting_cart_rules()
carts = open_carts(DATE_FROM, DATE_TO)
total_findings = 0
for cart in carts:
cart_id = int(cart["id"])
rows = cart_rows(cart)
findings = find_doubled_gift_lines(rows, gift_rules)
for finding in findings:
total_findings += 1
log.warning(
"Cart %s: product %s (attribute %s) at quantity %s, granted by cart rule %s (automatic=%s)",
cart_id, finding["id_product"], finding["id_product_attribute"],
finding["quantity"], finding["id_cart_rule"], finding["is_automatic"],
)
if is_pure_gift_row(rows, finding["id_product"], finding["id_product_attribute"], finding["quantity"]):
correct_gift_quantity(cart_id, cart, finding["id_product"], finding["id_product_attribute"])
else:
log.warning(
"Cart %s: product %s has another non-gift row too, skipping automatic correction",
cart_id, finding["id_product"],
)
log.info("Done. %d doubled gift line(s) found.", total_findings)
if __name__ == "__main__":
run()
/**
* Find PrestaShop carts where an automatic free-gift line ended up at quantity 2 or
* more after an unrelated cart item was removed.
*
* An automatic free-gift cart rule (no voucher code, gift_product and
* gift_product_attribute set) is re-evaluated by Cart::updateQty() on every cart
* mutation. When the qualifying line item is removed, PrestaShop first drops the
* cart's applicable cart rules, recalculates them, and re-adds the gift row through
* the same "up" quantity operator used for normal products. Because the gift's
* existing ps_cart_product row (quantity 1, is_gift=1) has not been cleaned up yet at
* that point, the increment adds 1 to the existing row instead of inserting a fresh
* one, leaving the gift line at quantity 2 with no cart rule authorizing more than one
* free unit. Tracked upstream as PrestaShop/PrestaShop#22270, fixed in 1.7.7.0; the
* same class of desync can still recur in forks or custom modules on older codebases.
*
* This script only reports. The optional, DRY_RUN-guarded corrective step only resets
* the quantity to 1 on a cart row confirmed to be a pure gift line (no separate
* non-gift row for the same product/attribute exists in the same cart); it never
* touches a cart row that also carries a genuinely purchased quantity of the same
* product. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/free-gift-quantity-doubles/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DATE_FROM = process.env.DATE_FROM || "2026-07-01";
const DATE_TO = process.env.DATE_TO || "2026-07-11";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* cartRows: [{idProduct, idProductAttribute, quantity}, ...]
* giftRules: [{idCartRule, giftProduct, giftProductAttribute, code}, ...]
*
* Returns a list of finding objects: {idProduct, idProductAttribute, quantity,
* idCartRule, isAutomatic}. Rows with quantity <= 1, or with no matching gift rule,
* are excluded. isAutomatic is true when the matching rule's code is empty, matching
* the reported bug's no-code path.
*/
export function findDoubledGiftLines(cartRows, giftRules) {
const giftLookup = new Map();
for (const rule of giftRules) {
if (rule.giftProduct <= 0) continue;
giftLookup.set(`${rule.giftProduct}:${rule.giftProductAttribute}`, rule);
}
const findings = [];
for (const row of cartRows) {
if (row.quantity <= 1) continue;
const rule = giftLookup.get(`${row.idProduct}:${row.idProductAttribute}`);
if (!rule) continue;
findings.push({
idProduct: row.idProduct,
idProductAttribute: row.idProductAttribute,
quantity: row.quantity,
idCartRule: rule.idCartRule,
isAutomatic: rule.code === "",
});
}
return findings;
}
/**
* True only when the doubled quantity is explained entirely by the gift row, i.e.
* there is no separate non-gift row for the same product/attribute in this cart that
* would make a quantity rewrite destroy a legitimately purchased unit.
*/
export function isPureGiftRow(cartRows, idProduct, idProductAttribute, giftQuantity) {
const matching = cartRows.filter(
(r) => r.idProduct === idProduct && r.idProductAttribute === idProductAttribute
);
return matching.length === 1 && matching[0].quantity === giftQuantity;
}
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiPut(path, body) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function openCarts(dateFrom, dateTo, limit = "0,200") {
const data = await apiGet("carts", {
display: "full",
"filter[date_upd]": `[${dateFrom},${dateTo}]`,
limit,
});
return data.carts || [];
}
function cartRowsOf(cart) {
const rows = cart.associations?.cart_rows || [];
return rows.map((row) => ({
idProduct: Number(row.id_product),
idProductAttribute: Number(row.id_product_attribute || 0),
quantity: Number(row.quantity),
}));
}
async function giftGrantingCartRules() {
const data = await apiGet("cart_rules", { display: "full", "filter[active]": "1" });
const rules = data.cart_rules || [];
const out = [];
for (const rule of rules) {
const giftProduct = Number(rule.gift_product || 0);
if (giftProduct <= 0) continue;
out.push({
idCartRule: Number(rule.id),
giftProduct,
giftProductAttribute: Number(rule.gift_product_attribute || 0),
code: rule.code || "",
});
}
return out;
}
async function correctGiftQuantity(cartId, cart, idProduct, idProductAttribute) {
const body = { cart: { ...cart } };
const rows = body.cart.associations?.cart_rows || [];
for (const row of rows) {
if (Number(row.id_product) === idProduct && Number(row.id_product_attribute || 0) === idProductAttribute) {
row.quantity = 1;
}
}
if (DRY_RUN) {
console.log(`Dry run: would PUT carts/${cartId} to reset product ${idProduct} quantity to 1`);
return null;
}
return apiPut(`carts/${cartId}`, body);
}
export async function run() {
const giftRules = await giftGrantingCartRules();
const carts = await openCarts(DATE_FROM, DATE_TO);
let totalFindings = 0;
for (const cart of carts) {
const cartId = Number(cart.id);
const rows = cartRowsOf(cart);
const findings = findDoubledGiftLines(rows, giftRules);
for (const finding of findings) {
totalFindings++;
console.warn(
`Cart ${cartId}: product ${finding.idProduct} (attribute ${finding.idProductAttribute}) at quantity ${finding.quantity}, granted by cart rule ${finding.idCartRule} (automatic=${finding.isAutomatic})`
);
if (isPureGiftRow(rows, finding.idProduct, finding.idProductAttribute, finding.quantity)) {
await correctGiftQuantity(cartId, cart, finding.idProduct, finding.idProductAttribute);
} else {
console.warn(`Cart ${cartId}: product ${finding.idProduct} has another non-gift row too, skipping automatic correction`);
}
}
}
console.log(`Done. ${totalFindings} doubled gift line(s) found.`);
}
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 cart rows get reported as a doubled gift. Because find_doubled_gift_lines is pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.
from find_doubled_gift_lines import find_doubled_gift_lines, is_pure_gift_row
GIFT_RULE = {"id_cart_rule": 42, "gift_product": 501, "gift_product_attribute": 0, "code": ""}
def cart_row(**over):
base = {"id_product": 501, "id_product_attribute": 0, "quantity": 1}
base.update(over)
return base
def test_no_finding_when_gift_quantity_is_one():
rows = [cart_row()]
assert find_doubled_gift_lines(rows, [GIFT_RULE]) == []
def test_finding_when_gift_quantity_doubles():
rows = [cart_row(quantity=2)]
findings = find_doubled_gift_lines(rows, [GIFT_RULE])
assert len(findings) == 1
assert findings[0]["quantity"] == 2
assert findings[0]["id_cart_rule"] == 42
assert findings[0]["is_automatic"] is True
def test_no_finding_when_row_does_not_match_any_gift_rule():
rows = [cart_row(id_product=999, quantity=2)]
assert find_doubled_gift_lines(rows, [GIFT_RULE]) == []
def test_no_finding_when_gift_product_is_zero():
rule = {"id_cart_rule": 7, "gift_product": 0, "gift_product_attribute": 0, "code": ""}
rows = [cart_row(quantity=2)]
assert find_doubled_gift_lines(rows, [rule]) == []
def test_is_automatic_false_when_rule_has_a_code():
rule = {"id_cart_rule": 9, "gift_product": 501, "gift_product_attribute": 0, "code": "SUMMER1"}
rows = [cart_row(quantity=2)]
findings = find_doubled_gift_lines(rows, [rule])
assert findings[0]["is_automatic"] is False
def test_matches_on_product_attribute_pair_not_just_product():
rule = {"id_cart_rule": 5, "gift_product": 501, "gift_product_attribute": 3, "code": ""}
rows = [cart_row(id_product_attribute=3, quantity=2), cart_row(id_product_attribute=4, quantity=2)]
findings = find_doubled_gift_lines(rows, [rule])
assert len(findings) == 1
assert findings[0]["id_product_attribute"] == 3
def test_is_pure_gift_row_true_when_only_one_matching_row():
rows = [cart_row(quantity=2)]
assert is_pure_gift_row(rows, 501, 0, 2) is True
def test_is_pure_gift_row_false_when_a_separate_non_gift_row_exists():
rows = [cart_row(quantity=2), cart_row(id_product=501, id_product_attribute=0, quantity=1)]
assert is_pure_gift_row(rows, 501, 0, 2) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDoubledGiftLines, isPureGiftRow } from "./find-doubled-gift-lines.js";
const GIFT_RULE = { idCartRule: 42, giftProduct: 501, giftProductAttribute: 0, code: "" };
const cartRow = (over = {}) => ({ idProduct: 501, idProductAttribute: 0, quantity: 1, ...over });
test("no finding when gift quantity is one", () => {
const rows = [cartRow()];
assert.deepEqual(findDoubledGiftLines(rows, [GIFT_RULE]), []);
});
test("finding when gift quantity doubles", () => {
const rows = [cartRow({ quantity: 2 })];
const findings = findDoubledGiftLines(rows, [GIFT_RULE]);
assert.equal(findings.length, 1);
assert.equal(findings[0].quantity, 2);
assert.equal(findings[0].idCartRule, 42);
assert.equal(findings[0].isAutomatic, true);
});
test("no finding when row does not match any gift rule", () => {
const rows = [cartRow({ idProduct: 999, quantity: 2 })];
assert.deepEqual(findDoubledGiftLines(rows, [GIFT_RULE]), []);
});
test("no finding when gift product is zero", () => {
const rule = { idCartRule: 7, giftProduct: 0, giftProductAttribute: 0, code: "" };
const rows = [cartRow({ quantity: 2 })];
assert.deepEqual(findDoubledGiftLines(rows, [rule]), []);
});
test("isAutomatic is false when the rule has a code", () => {
const rule = { idCartRule: 9, giftProduct: 501, giftProductAttribute: 0, code: "SUMMER1" };
const rows = [cartRow({ quantity: 2 })];
const findings = findDoubledGiftLines(rows, [rule]);
assert.equal(findings[0].isAutomatic, false);
});
test("matches on product and attribute pair, not just product", () => {
const rule = { idCartRule: 5, giftProduct: 501, giftProductAttribute: 3, code: "" };
const rows = [cartRow({ idProductAttribute: 3, quantity: 2 }), cartRow({ idProductAttribute: 4, quantity: 2 })];
const findings = findDoubledGiftLines(rows, [rule]);
assert.equal(findings.length, 1);
assert.equal(findings[0].idProductAttribute, 3);
});
test("isPureGiftRow is true when only one matching row exists", () => {
const rows = [cartRow({ quantity: 2 })];
assert.equal(isPureGiftRow(rows, 501, 0, 2), true);
});
test("isPureGiftRow is false when a separate non-gift row exists", () => {
const rows = [cartRow({ quantity: 2 }), cartRow({ idProduct: 501, idProductAttribute: 0, quantity: 1 })];
assert.equal(isPureGiftRow(rows, 501, 0, 2), false);
});
Case studies
The skincare set that gave away two free samples
A skincare store ran an automatic rule granting a free travel-size cleanser on any order over a spend threshold. Customers routinely added and then removed a sample-size add-on while deciding on their order, and each removal quietly nudged the free cleanser line from quantity 1 to 2. Support only found out when a customer emailed asking why two identical free items shipped.
Running the auditor against a week of carts turned up a dozen carts with the same doubled gift line, all pointing at the same automatic cart rule with an empty code, matching the exact pattern in issue 22270. The team corrected the affected open carts by hand and scheduled the upgrade to 1.7.7.0.
The gift kept doubling every time a size was swapped
A footwear store gave a free pair of laces automatically once a cart crossed a spend threshold. Shoppers frequently swapped sizes, which meant removing one combination and adding another, an action nowhere near the gift line but still enough to trigger PrestaShop's cart rule recalculation on every swap.
The audit flagged the laces at quantity 2 or 3 on carts that had seen multiple size swaps, always against the same automatic rule id. Since none of those carts had a second, genuinely purchased pack of laces, the guarded correction reset each one to quantity 1 without touching any real order line.
After this runs, a doubled free-gift line never ships unnoticed. Every finding comes with the cart id, the exact product, the observed quantity, and the cart rule that granted it, so a human can confirm in seconds that it is the known desync and not a legitimate extra purchase. No cart is rewritten unless the gift row is confirmed pure, and the fix upstream in 1.7.7.0 means newer stores will not see this path at all.
FAQ
Why does my free gift line jump to quantity 2 after I remove a different item?
An automatic free-gift cart rule is re-evaluated on every cart change. When you remove an unrelated line item, PrestaShop recalculates applicable cart rules and re-adds the gift product through the same quantity-increment path used for normal products, before the existing gift row is cleaned up. The increment adds 1 to the row that is already there instead of inserting a fresh one, so the gift ends up at quantity 2 even though the rule only ever authorizes 1 free unit.
Is it safe to auto correct a doubled gift line with a script?
Not automatically. Rewriting the quantity on a cart row through the webservice, or deleting and re-adding it, risks stripping a legitimate paid quantity if the same product also happens to be genuinely purchased in that cart. The safe pattern is to flag the cart, the product, and the granting cart rule for a human to open in the back office and confirm before correcting the quantity to 1.
Does upgrading PrestaShop fix this for good?
The specific desync was tracked upstream as PrestaShop/PrestaShop issue 22270 and fixed in 1.7.7.0, so stores on that version or later should not see this exact path. The same class of desync can still recur in forks or custom modules that re-run cart rule auto-add logic around gift lines without first checking for an existing gift row, so the audit is still worth running even on patched cores.
Related field notes
Citations
On the problem:
- PrestaShop GitHub Issue #22270: Issue with gift vouchers with an empty cart. github.com/PrestaShop/PrestaShop/issues/22270
- PrestaShop GitHub Issue #21041: [FO] gift product and manually added products do not get separated in cart. github.com/PrestaShop/PrestaShop/issues/21041
- PrestaShop Specs: Free Gift front office behavior. build.prestashop-project.org prestashop-specs cart-rules-free-gift
On the solution:
- PrestaShop Developer Documentation: the carts Webservice resource. devdocs.prestashop-project.org webservice resources carts
- PrestaShop Developer Documentation: the cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources cart_rules
- PrestaShop 8 documentation: Cart Rules user guide. docs.prestashop-project.org user guide cart rules
Stuck on a tricky one?
If you have a problem in PrestaShop vouchers, cart rules, orders, 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 doubled gift before it shipped?
If this saved you from an overpaid promotion or an awkward customer conversation, 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