Skip to content

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.

Python and Node.js PrestaShop Webservice API Report only (no cart writes by default)
A worker with a tablet in a warehouse
Photo by Rodrigo Rodrigues on Unsplash
The short answer

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.

Gift row exists quantity 1, is_gift=1 Unrelated item removed triggers Cart::updateQty Rules recalculated, old gift row not yet cleaned Gift re-added via normal "up" quantity path Increment hits old row 1 + 1, not a fresh insert Gift line quantity 2
Removing an item nowhere near the gift is what triggers the recalculation. The old gift row is still there when the re-add happens, so the increment doubles it instead of replacing it.

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:

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.

The key insight

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.

Auditor job runs on demand Read carts and rows cart_rows, id_product, quantity Read gift-granting rules gift_product > 0, code Gift row with quantity > 1? yes no, quantity is 1, skip Report finding for manual review
Only cart rows that match a known gift product pair and sit above quantity 1 get reported, and the report is for a human to review, never an automatic cart rewrite.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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()
step2.js
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();
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
find_doubled_gift_lines.py
"""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-doubled-gift-lines.js
/**
 * 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.

test_free_gift_doubled.py
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
free-gift-doubled.test.js
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

Bundle promotion

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.

Threshold gift

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.

What good looks like

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:

  1. PrestaShop GitHub Issue #22270: Issue with gift vouchers with an empty cart. github.com/PrestaShop/PrestaShop/issues/22270
  2. PrestaShop GitHub Issue #21041: [FO] gift product and manually added products do not get separated in cart. github.com/PrestaShop/PrestaShop/issues/21041
  3. PrestaShop Specs: Free Gift front office behavior. build.prestashop-project.org prestashop-specs cart-rules-free-gift

On the solution:

  1. PrestaShop Developer Documentation: the carts Webservice resource. devdocs.prestashop-project.org webservice resources carts
  2. PrestaShop Developer Documentation: the cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources cart_rules
  3. 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.

Contact me on LinkedIn

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

Back to all PrestaShop field notes