Skip to content

Diagnostic Vouchers & Cart Rules

Per customer voucher limit ignored for guest checkouts sharing an email

The cart rule says "total available for each user: 1." One code, one customer, ever. Then the same email address shows up on the back office three, four, six times, each order redeeming the exact same voucher. Nobody shared the code and nobody found a workaround. The gap is identity, not configuration. Here is why PrestaShop never recognizes that a repeat guest is the same person, how to find every voucher this happened to, and a script that reports the damage without touching a single already-placed order.

Python and Node.js PrestaShop Webservice API Report only (no order writes)
A support agent with a headset
Photo by BaljkanN 4 on Unsplash
The short answer

PrestaShop's cart rule validity check enforces quantity_per_user by counting how many prior orders reference the rule for the cart's id_customer. Guest checkout never reuses or merges an existing account by email. Every guest order creates a brand new customer record, and therefore a brand new id_customer, even when the exact same email address is typed in again. Since that fresh id_customer has zero recorded uses, quantity_per_user=1 never blocks the next guest order under the same email. This is the same architectural gap tracked in PrestaShop's own issue tracker as guest accounts never becoming a single customer account (issue #10122), and confirmed to still bypass quantity_per_user in 1.7.8.9 (issue #16370). Run a small Python or Node.js script that lists cart rules with quantity_per_user=1, pulls every order that redeemed each one through order_cart_rules, resolves each order's id_customer to a customers email, and groups by (id_cart_rule, email) instead of (id_cart_rule, id_customer). Any email that redeems the same voucher more times than quantity_per_user allows, across different guest id_customer values, gets reported for a human to review. It never cancels or edits an order. Full code, tests, and the decision function are below.

The problem in plain words

A cart rule with "total available for each user" set to 1 is supposed to mean one discount per person, forever, no matter how many times they come back. PrestaShop enforces that inside CartRule::checkValidity by asking, in effect, "has id_customer X already used this rule?"

That question only works if the same person always maps to the same id_customer. A logged-in account does, so the cap holds. Guest checkout does not. PrestaShop never looks up whether an email address has ordered as a guest before. It simply creates a new customer row for every guest order, gives it a new id_customer, and links the order to that. The email typed into the form is stored on that new customer row, but it is never used to search for a prior guest with the same email. So from the cart rule's point of view, every guest checkout looks like a first-time customer, no matter how many times the same person has already redeemed the code.

Guest checkout #1 same@email.com Guest checkout #2 same@email.com Guest checkout #3 same@email.com New id_customer every single time no email lookup, no merge quantity_per_user reads zero prior uses for the new id_customer Applied again
Each guest order gets a fresh id_customer that has never used the voucher before, so quantity_per_user never sees a repeat, no matter how many times the same email checks out.

Why it happens

This is a documented gap in PrestaShop core, not a cart rule misconfiguration. A few concrete ways stores end up with the same voucher hitting the same person repeatedly:

See the citations at the end for the exact issue threads this behavior is reported and reproduced in.

The key insight

The cart rule's own quantity_per_user field is correctly set. The bug is not in the number, it is in what PrestaShop counts as "the same user." So the audit has to group redemptions by something PrestaShop does not use internally: the customer's email address, pulled from the customers resource for each distinct id_customer that redeemed the rule. Any email appearing more than quantity_per_user times against the same id_cart_rule, across different guest id_customer values, is the real per-user overage that the store rule was supposed to prevent.

The fix, as a flow

We never touch an already-placed order. The script pulls every cart rule with quantity_per_user=1, pulls every order that redeemed each one through order_cart_rules, skips orders sitting in an error or cancelled state, resolves each surviving order's id_customer to an email, and reports any (id_cart_rule, email) pair whose count exceeds quantity_per_user. A human then decides whether to contact the repeat guest, adjust future orders, or leave it as a one-time leak.

Auditor job runs on demand Read limited cart rules quantity_per_user = 1 Resolve orders to email order_cart_rules, customers Same email over per-user cap? yes no, within limit, skip Report overage for manual review
Only an email that redeems a voucher more times than quantity_per_user allows, across different guest id_customer values, gets reported, and the report is for a human to review, never an automatic order edit.

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 cart_rules, order_cart_rules, orders, and customers 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

List limited vouchers, then read every order and customer they touch

Filter cart_rules to quantity_per_user=1 to get the vouchers meant to be capped per customer. For each one, read order_cart_rules filtered to that id_cart_rule to get every id_order that redeemed it. Read each order for its id_customer and current_state, skipping orders in an error or cancelled state. Then read each distinct id_customer from customers to get the email.

step3.py
ERROR_STATE_IDS = {6, 8}  # PS_OS_ERROR, PS_OS_CANCELED (adjust to your store's order_states)

def limited_cart_rules():
    data = api_get("cart_rules", {"filter[quantity_per_user]": 1, "display": "full"})
    rules = data.get("cart_rules") or []
    return [
        {
            "id": int(r["id"]),
            "code": r.get("code") or "",
            "quantity_per_user": int(r["quantity_per_user"]),
            "quantity": int(r["quantity"]),
        }
        for r in rules
    ]

def order_cart_rule_links(cart_rule_id):
    data = api_get("order_cart_rules", {"filter[id_cart_rule]": cart_rule_id, "display": "full"})
    return data.get("order_cart_rules") or []

def get_order(order_id):
    return api_get(f"orders/{order_id}")["order"]

def get_customer_email(customer_id):
    return api_get(f"customers/{customer_id}")["customer"].get("email") or ""
step3.js
const ERROR_STATE_IDS = new Set([6, 8]); // PS_OS_ERROR, PS_OS_CANCELED (adjust to your store's order_states)

async function limitedCartRules() {
  const data = await apiGet("cart_rules", { "filter[quantity_per_user]": 1, display: "full" });
  const rules = data.cart_rules || [];
  return rules.map((r) => ({
    id: Number(r.id),
    code: r.code || "",
    quantityPerUser: Number(r.quantity_per_user),
    quantity: Number(r.quantity),
  }));
}

async function orderCartRuleLinks(cartRuleId) {
  const data = await apiGet("order_cart_rules", { "filter[id_cart_rule]": cartRuleId, display: "full" });
  return data.order_cart_rules || [];
}

async function getOrder(orderId) {
  return (await apiGet(`orders/${orderId}`)).order;
}

async function getCustomerEmail(customerId) {
  const customer = (await apiGet(`customers/${customerId}`)).customer;
  return customer.email || "";
}
4

Decide, with one pure function

Keep the decision in its own function that takes the already-fetched cart rules, order-to-rule links, orders, and customers, and returns the overused entries. It builds an id_customer to email lookup, an id_order to id_customer lookup that skips error and cancelled orders, then groups redemption counts by (id_cart_rule, email) and flags any pair whose count exceeds quantity_per_user.

decide.py
ERROR_STATE_IDS = {6, 8}  # PS_OS_ERROR, PS_OS_CANCELED

def find_overused_vouchers(cart_rules, order_cart_rules, orders, customers):
    rules_by_id = {int(r["id_cart_rule"] if "id_cart_rule" in r else r["id"]): r for r in cart_rules}
    email_by_customer = {int(c["id"]): (c.get("email") or "") for c in customers}

    customer_by_order = {}
    for o in orders:
        if int(o["current_state"]) in ERROR_STATE_IDS:
            continue
        if o.get("id_customer"):
            customer_by_order[int(o["id"])] = int(o["id_customer"])

    counts = {}  # (id_cart_rule, email) -> {"count": n, "id_orders": [...]}
    for link in order_cart_rules:
        id_cart_rule = int(link["id_cart_rule"])
        id_order = int(link["id_order"])
        id_customer = customer_by_order.get(id_order)
        if id_customer is None:
            continue  # order excluded (error/cancelled) or unknown
        email = email_by_customer.get(id_customer, "")
        key = (id_cart_rule, email)
        entry = counts.setdefault(key, {"count": 0, "id_orders": []})
        entry["count"] += 1
        entry["id_orders"].append(id_order)

    flagged = []
    for (id_cart_rule, email), entry in counts.items():
        rule = rules_by_id.get(id_cart_rule)
        if not rule:
            continue
        quantity_per_user = int(rule["quantity_per_user"])
        if entry["count"] > quantity_per_user:
            flagged.append({
                "id_cart_rule": id_cart_rule,
                "code": rule.get("code") or "",
                "email": email,
                "quantity_per_user": quantity_per_user,
                "actual_uses": entry["count"],
                "id_orders": sorted(entry["id_orders"]),
            })
    flagged.sort(key=lambda f: (f["id_cart_rule"], f["email"]))
    return flagged
decide.js
const ERROR_STATE_IDS = new Set([6, 8]); // PS_OS_ERROR, PS_OS_CANCELED

export function findOverusedVouchers(cartRules, orderCartRules, orders, customers) {
  const rulesById = new Map(cartRules.map((r) => [Number(r.id), r]));
  const emailByCustomer = new Map(customers.map((c) => [Number(c.id), c.email || ""]));

  const customerByOrder = new Map();
  for (const o of orders) {
    if (ERROR_STATE_IDS.has(Number(o.current_state))) continue;
    if (o.id_customer) customerByOrder.set(Number(o.id), Number(o.id_customer));
  }

  const counts = new Map(); // "idCartRule::email" -> { count, idOrders }
  for (const link of orderCartRules) {
    const idCartRule = Number(link.id_cart_rule);
    const idOrder = Number(link.id_order);
    const idCustomer = customerByOrder.get(idOrder);
    if (idCustomer === undefined) continue; // order excluded (error/cancelled) or unknown
    const email = emailByCustomer.get(idCustomer) || "";
    const key = `${idCartRule}::${email}`;
    const entry = counts.get(key) || { idCartRule, email, count: 0, idOrders: [] };
    entry.count += 1;
    entry.idOrders.push(idOrder);
    counts.set(key, entry);
  }

  const flagged = [];
  for (const entry of counts.values()) {
    const rule = rulesById.get(entry.idCartRule);
    if (!rule) continue;
    const quantityPerUser = Number(rule.quantity_per_user);
    if (entry.count > quantityPerUser) {
      flagged.push({
        idCartRule: entry.idCartRule,
        code: rule.code || "",
        email: entry.email,
        quantityPerUser,
        actualUses: entry.count,
        idOrders: [...entry.idOrders].sort((a, b) => a - b),
      });
    }
  }
  flagged.sort((a, b) => a.idCartRule - b.idCartRule || a.email.localeCompare(b.email));
  return flagged;
}
5

Report, and only optionally disable further use

Always log the full report for a human to review: the email, the id_cart_rule, the code, the count, and the list of id_order. Never cancel or refund an already-placed order automatically. The only optional write is disabling further redemptions of that specific code, and it only fires when DRY_RUN is false and a human has approved that id_cart_rule, sending PUT cart_rules/{id} with active=0. With DRY_RUN true it only logs the PUT body it would have sent.

Run it safe

Always start with DRY_RUN=true. Orders that already redeemed the voucher are never touched by this script under any setting, because cancelling or refunding a live order to undo an already-applied discount risks real financial and customer-service side effects. The only write this script can ever make is setting a cart rule's active flag to 0 to stop future redemptions, and even that only fires after a human reviews the report and approves the specific id_cart_rule.

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 disabling further redemptions of an already-overused voucher.

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.
audit_guest_voucher_reuse.py
"""Find PrestaShop "one use per customer" cart rules that a guest checkout redeemed
more than once under the same email address.

CartRule::checkValidity enforces quantity_per_user by counting prior orders against
id_customer. Guest checkout never reuses or merges an existing account by email:
every guest order creates a brand new customer record, and therefore a brand new
id_customer, even when the same email is entered again. Because that fresh
id_customer always shows zero prior uses, quantity_per_user=1 never blocks a repeat
guest order under the same email (PrestaShop/PrestaShop #10122, #16370).

This script only reports. The optional, DRY_RUN-guarded corrective step only disables
further redemptions of the voucher by setting active=0; it never cancels, edits, or
refunds an order that already redeemed it. 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("audit_guest_voucher_reuse")

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ERROR_STATE_IDS = {6, 8}  # PS_OS_ERROR, PS_OS_CANCELED (adjust to your store's order_states)


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 limited_cart_rules():
    data = api_get("cart_rules", {"filter[quantity_per_user]": 1, "display": "full"})
    rules = data.get("cart_rules") or []
    return [
        {
            "id": int(r["id"]),
            "code": r.get("code") or "",
            "quantity_per_user": int(r["quantity_per_user"]),
            "quantity": int(r["quantity"]),
        }
        for r in rules
    ]


def order_cart_rule_links(cart_rule_id):
    data = api_get("order_cart_rules", {"filter[id_cart_rule]": cart_rule_id, "display": "full"})
    links = data.get("order_cart_rules") or []
    return [{"id_cart_rule": cart_rule_id, "id_order": int(link["id_order"])} for link in links]


def get_order(order_id):
    o = api_get(f"orders/{order_id}")["order"]
    return {
        "id": int(o["id"]),
        "id_customer": int(o["id_customer"]) if o.get("id_customer") else None,
        "current_state": int(o["current_state"]),
    }


def get_customer(customer_id):
    c = api_get(f"customers/{customer_id}")["customer"]
    return {"id": int(c["id"]), "email": c.get("email") or ""}


def find_overused_vouchers(cart_rules, order_cart_rules, orders, customers):
    rules_by_id = {int(r["id"]): r for r in cart_rules}
    email_by_customer = {int(c["id"]): (c.get("email") or "") for c in customers}

    customer_by_order = {}
    for o in orders:
        if int(o["current_state"]) in ERROR_STATE_IDS:
            continue
        if o.get("id_customer"):
            customer_by_order[int(o["id"])] = int(o["id_customer"])

    counts = {}  # (id_cart_rule, email) -> {"count": n, "id_orders": [...]}
    for link in order_cart_rules:
        id_cart_rule = int(link["id_cart_rule"])
        id_order = int(link["id_order"])
        id_customer = customer_by_order.get(id_order)
        if id_customer is None:
            continue
        email = email_by_customer.get(id_customer, "")
        key = (id_cart_rule, email)
        entry = counts.setdefault(key, {"count": 0, "id_orders": []})
        entry["count"] += 1
        entry["id_orders"].append(id_order)

    flagged = []
    for (id_cart_rule, email), entry in counts.items():
        rule = rules_by_id.get(id_cart_rule)
        if not rule:
            continue
        quantity_per_user = int(rule["quantity_per_user"])
        if entry["count"] > quantity_per_user:
            flagged.append({
                "id_cart_rule": id_cart_rule,
                "code": rule.get("code") or "",
                "email": email,
                "quantity_per_user": quantity_per_user,
                "actual_uses": entry["count"],
                "id_orders": sorted(entry["id_orders"]),
            })
    flagged.sort(key=lambda f: (f["id_cart_rule"], f["email"]))
    return flagged


def disable_further_use(cart_rule_id):
    body = {"cart_rule": {"id": cart_rule_id, "active": 0}}
    if DRY_RUN:
        log.info("Dry run: would PUT cart_rules/%s %s", cart_rule_id, body)
        return None
    return api_put(f"cart_rules/{cart_rule_id}", body)


def run():
    cart_rules = limited_cart_rules()

    all_links = []
    order_ids = set()
    for rule in cart_rules:
        links = order_cart_rule_links(rule["id"])
        all_links.extend(links)
        order_ids.update(link["id_order"] for link in links)

    orders = [get_order(order_id) for order_id in order_ids]
    customer_ids = {o["id_customer"] for o in orders if o["id_customer"]}
    customers = [get_customer(customer_id) for customer_id in customer_ids]

    report = find_overused_vouchers(cart_rules, all_links, orders, customers)
    if not report:
        log.info("No per-customer voucher overuse found across %d limited cart rule(s).", len(cart_rules))
        return

    for entry in report:
        log.warning("Voucher overuse detected: %s", entry)

    log.info("Done. %d overused voucher/email pair(s). Report ready for manual review.", len(report))


if __name__ == "__main__":
    run()
audit-guest-voucher-reuse.js
/**
 * Find PrestaShop "one use per customer" cart rules that a guest checkout redeemed
 * more than once under the same email address.
 *
 * CartRule::checkValidity enforces quantity_per_user by counting prior orders against
 * id_customer. Guest checkout never reuses or merges an existing account by email:
 * every guest order creates a brand new customer record, and therefore a brand new
 * id_customer, even when the same email is entered again. Because that fresh
 * id_customer always shows zero prior uses, quantity_per_user=1 never blocks a repeat
 * guest order under the same email (PrestaShop/PrestaShop #10122, #16370).
 *
 * This script only reports. The optional, DRY_RUN-guarded corrective step only disables
 * further redemptions of the voucher by setting active=0; it never cancels, edits, or
 * refunds an order that already redeemed it. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/voucher-per-user-limit-ignored-for-guests/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ERROR_STATE_IDS = new Set([6, 8]); // PS_OS_ERROR, PS_OS_CANCELED (adjust to your store's order_states)

export function findOverusedVouchers(cartRules, orderCartRules, orders, customers) {
  const rulesById = new Map(cartRules.map((r) => [Number(r.id), r]));
  const emailByCustomer = new Map(customers.map((c) => [Number(c.id), c.email || ""]));

  const customerByOrder = new Map();
  for (const o of orders) {
    if (ERROR_STATE_IDS.has(Number(o.current_state))) continue;
    if (o.id_customer) customerByOrder.set(Number(o.id), Number(o.id_customer));
  }

  const counts = new Map(); // "idCartRule::email" -> { idCartRule, email, count, idOrders }
  for (const link of orderCartRules) {
    const idCartRule = Number(link.id_cart_rule);
    const idOrder = Number(link.id_order);
    const idCustomer = customerByOrder.get(idOrder);
    if (idCustomer === undefined) continue;
    const email = emailByCustomer.get(idCustomer) || "";
    const key = `${idCartRule}::${email}`;
    const entry = counts.get(key) || { idCartRule, email, count: 0, idOrders: [] };
    entry.count += 1;
    entry.idOrders.push(idOrder);
    counts.set(key, entry);
  }

  const flagged = [];
  for (const entry of counts.values()) {
    const rule = rulesById.get(entry.idCartRule);
    if (!rule) continue;
    const quantityPerUser = Number(rule.quantity_per_user);
    if (entry.count > quantityPerUser) {
      flagged.push({
        idCartRule: entry.idCartRule,
        code: rule.code || "",
        email: entry.email,
        quantityPerUser,
        actualUses: entry.count,
        idOrders: [...entry.idOrders].sort((a, b) => a - b),
      });
    }
  }
  flagged.sort((a, b) => a.idCartRule - b.idCartRule || a.email.localeCompare(b.email));
  return flagged;
}

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 limitedCartRules() {
  const data = await apiGet("cart_rules", { "filter[quantity_per_user]": 1, display: "full" });
  const rules = data.cart_rules || [];
  return rules.map((r) => ({
    id: Number(r.id),
    code: r.code || "",
    quantity_per_user: Number(r.quantity_per_user),
    quantity: Number(r.quantity),
  }));
}

async function orderCartRuleLinks(cartRuleId) {
  const data = await apiGet("order_cart_rules", { "filter[id_cart_rule]": cartRuleId, display: "full" });
  const links = data.order_cart_rules || [];
  return links.map((link) => ({ id_cart_rule: cartRuleId, id_order: Number(link.id_order) }));
}

async function getOrder(orderId) {
  const o = (await apiGet(`orders/${orderId}`)).order;
  return {
    id: Number(o.id),
    id_customer: o.id_customer ? Number(o.id_customer) : null,
    current_state: Number(o.current_state),
  };
}

async function getCustomer(customerId) {
  const c = (await apiGet(`customers/${customerId}`)).customer;
  return { id: Number(c.id), email: c.email || "" };
}

async function disableFurtherUse(cartRuleId) {
  const body = { cart_rule: { id: cartRuleId, active: 0 } };
  if (DRY_RUN) {
    console.log(`Dry run: would PUT cart_rules/${cartRuleId}`, body);
    return null;
  }
  return apiPut(`cart_rules/${cartRuleId}`, body);
}

export async function run() {
  const cartRules = await limitedCartRules();

  const allLinks = [];
  const orderIds = new Set();
  for (const rule of cartRules) {
    const links = await orderCartRuleLinks(rule.id);
    allLinks.push(...links);
    for (const link of links) orderIds.add(link.id_order);
  }

  const orders = [];
  for (const orderId of orderIds) orders.push(await getOrder(orderId));

  const customerIds = new Set(orders.filter((o) => o.id_customer).map((o) => o.id_customer));
  const customers = [];
  for (const customerId of customerIds) customers.push(await getCustomer(customerId));

  const report = findOverusedVouchers(cartRules, allLinks, orders, customers);
  if (report.length === 0) {
    console.log(`No per-customer voucher overuse found across ${cartRules.length} limited cart rule(s).`);
    return;
  }

  for (const entry of report) console.warn("Voucher overuse detected:", entry);
  console.log(`Done. ${report.length} overused voucher/email pair(s). Report ready for manual review.`);
}

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 guest emails get reported as overusing a voucher. Because find_overused_vouchers is pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_voucher_guest_reuse.py
from audit_guest_voucher_reuse import find_overused_vouchers

RULE = {"id": 42, "code": "WELCOME10", "quantity_per_user": 1, "quantity": 500}


def order(**over):
    base = {"id": 1, "id_customer": 10, "current_state": 2}
    base.update(over)
    return base


def customer(id_, email):
    return {"id": id_, "email": email}


def link(id_order, id_cart_rule=42):
    return {"id_cart_rule": id_cart_rule, "id_order": id_order}


def test_no_overage_when_email_used_once():
    orders = [order(id=1, id_customer=10)]
    customers = [customer(10, "same@example.com")]
    result = find_overused_vouchers([RULE], [link(1)], orders, customers)
    assert result == []


def test_flags_same_email_across_different_guest_customers():
    orders = [
        order(id=1, id_customer=10),
        order(id=2, id_customer=11),
        order(id=3, id_customer=12),
    ]
    customers = [
        customer(10, "same@example.com"),
        customer(11, "same@example.com"),
        customer(12, "same@example.com"),
    ]
    links = [link(1), link(2), link(3)]
    result = find_overused_vouchers([RULE], links, orders, customers)
    assert len(result) == 1
    assert result[0]["email"] == "same@example.com"
    assert result[0]["actual_uses"] == 3
    assert result[0]["id_orders"] == [1, 2, 3]


def test_different_emails_are_not_grouped_together():
    orders = [order(id=1, id_customer=10), order(id=2, id_customer=11)]
    customers = [customer(10, "a@example.com"), customer(11, "b@example.com")]
    links = [link(1), link(2)]
    result = find_overused_vouchers([RULE], links, orders, customers)
    assert result == []


def test_excludes_cancelled_and_error_orders():
    orders = [
        order(id=1, id_customer=10, current_state=2),
        order(id=2, id_customer=11, current_state=8),  # PS_OS_CANCELED
    ]
    customers = [customer(10, "same@example.com"), customer(11, "same@example.com")]
    links = [link(1), link(2)]
    result = find_overused_vouchers([RULE], links, orders, customers)
    assert result == []


def test_respects_higher_quantity_per_user():
    rule = {"id": 7, "code": "VIP2", "quantity_per_user": 2, "quantity": 50}
    orders = [order(id=1, id_customer=10), order(id=2, id_customer=11)]
    customers = [customer(10, "same@example.com"), customer(11, "same@example.com")]
    links = [link(1, 7), link(2, 7)]
    result = find_overused_vouchers([rule], links, orders, customers)
    assert result == []


def test_flagged_list_sorted_by_cart_rule_then_email():
    orders = [
        order(id=1, id_customer=10),
        order(id=2, id_customer=11),
        order(id=3, id_customer=12),
        order(id=4, id_customer=13),
    ]
    customers = [
        customer(10, "z@example.com"),
        customer(11, "z@example.com"),
        customer(12, "a@example.com"),
        customer(13, "a@example.com"),
    ]
    links = [link(1), link(2), link(3), link(4)]
    result = find_overused_vouchers([RULE], links, orders, customers)
    emails = [entry["email"] for entry in result]
    assert emails == sorted(emails)
voucher-guest-reuse.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOverusedVouchers } from "./audit-guest-voucher-reuse.js";

const RULE = { id: 42, code: "WELCOME10", quantity_per_user: 1, quantity: 500 };

const order = (over = {}) => ({ id: 1, id_customer: 10, current_state: 2, ...over });
const customer = (id, email) => ({ id, email });
const link = (idOrder, idCartRule = 42) => ({ id_cart_rule: idCartRule, id_order: idOrder });

test("no overage when email used once", () => {
  const orders = [order({ id: 1, id_customer: 10 })];
  const customers = [customer(10, "same@example.com")];
  const result = findOverusedVouchers([RULE], [link(1)], orders, customers);
  assert.deepEqual(result, []);
});

test("flags same email across different guest customers", () => {
  const orders = [
    order({ id: 1, id_customer: 10 }),
    order({ id: 2, id_customer: 11 }),
    order({ id: 3, id_customer: 12 }),
  ];
  const customers = [
    customer(10, "same@example.com"),
    customer(11, "same@example.com"),
    customer(12, "same@example.com"),
  ];
  const links = [link(1), link(2), link(3)];
  const result = findOverusedVouchers([RULE], links, orders, customers);
  assert.equal(result.length, 1);
  assert.equal(result[0].email, "same@example.com");
  assert.equal(result[0].actualUses, 3);
  assert.deepEqual(result[0].idOrders, [1, 2, 3]);
});

test("different emails are not grouped together", () => {
  const orders = [order({ id: 1, id_customer: 10 }), order({ id: 2, id_customer: 11 })];
  const customers = [customer(10, "a@example.com"), customer(11, "b@example.com")];
  const links = [link(1), link(2)];
  const result = findOverusedVouchers([RULE], links, orders, customers);
  assert.deepEqual(result, []);
});

test("excludes cancelled and error orders", () => {
  const orders = [
    order({ id: 1, id_customer: 10, current_state: 2 }),
    order({ id: 2, id_customer: 11, current_state: 8 }), // PS_OS_CANCELED
  ];
  const customers = [customer(10, "same@example.com"), customer(11, "same@example.com")];
  const links = [link(1), link(2)];
  const result = findOverusedVouchers([RULE], links, orders, customers);
  assert.deepEqual(result, []);
});

test("respects higher quantity_per_user", () => {
  const rule = { id: 7, code: "VIP2", quantity_per_user: 2, quantity: 50 };
  const orders = [order({ id: 1, id_customer: 10 }), order({ id: 2, id_customer: 11 })];
  const customers = [customer(10, "same@example.com"), customer(11, "same@example.com")];
  const links = [link(1, 7), link(2, 7)];
  const result = findOverusedVouchers([rule], links, orders, customers);
  assert.deepEqual(result, []);
});

test("flagged list sorted by cart rule then email", () => {
  const orders = [
    order({ id: 1, id_customer: 10 }),
    order({ id: 2, id_customer: 11 }),
    order({ id: 3, id_customer: 12 }),
    order({ id: 4, id_customer: 13 }),
  ];
  const customers = [
    customer(10, "z@example.com"),
    customer(11, "z@example.com"),
    customer(12, "a@example.com"),
    customer(13, "a@example.com"),
  ];
  const links = [link(1), link(2), link(3), link(4)];
  const result = findOverusedVouchers([RULE], links, orders, customers);
  const emails = result.map((entry) => entry.email);
  const sorted = [...emails].sort();
  assert.deepEqual(emails, sorted);
});

Case studies

Welcome code

One new-customer discount, six guest orders

A skincare brand ran a "WELCOME10" code capped at quantity_per_user 1 to reward first-time buyers. A regular customer who preferred checking out as a guest applied the code on six separate orders over two months, always with the same email, and it worked every time because each order created a new guest id_customer.

The audit script pulled order_cart_rules for that cart rule id, resolved all six orders to the same email through customers, and reported the overage with all six order ids. The brand reached out to the customer directly instead of cancelling any order, and added a required account step for that specific promotion going forward.

Referral program

A friend code redeemed by the same person twice

A subscription box store gave every referral link a one-per-person code. A customer used the link once with an account, then again as a guest with the same email a month later when checking out on a different device. The cart rule correctly blocked a second use on the logged-in account, but the guest order sailed through untouched.

Running the auditor monthly grouped the guest order's email against every other redemption of that referral code, including the earlier logged-in order, and surfaced the pair as an overage even though the two orders had completely different id_customer values. The store now checks the email match at referral redemption time as an extra guard.

What good looks like

After this runs, a voucher meant to be one per customer that slipped past that limit through a guest checkout is never a silent leak. Every overused code comes with the exact email and orders involved, sorted so the pattern is obvious at a glance, and no order that already redeemed the code is ever cancelled or edited automatically. A human reviews the report and decides the fair outcome, and optionally turns the code off for future redemptions once they are satisfied.

FAQ

Why does a one per customer voucher still work for the same guest email twice?

PrestaShop's quantity_per_user check on a cart rule counts prior redemptions against id_customer. Guest checkout does not reuse or merge accounts by email, so every guest order creates a brand new customer record with a fresh id_customer, even when the same email is entered again. Since that new id_customer has zero prior uses, the per-user cap never triggers.

Is this a store misconfiguration I can fix in the cart rule settings?

No. This is a documented architectural gap in PrestaShop core, tracked in GitHub issues 10122 and 16370, where guest orders are never merged into a single customer account by email. There is no cart rule setting that changes how quantity_per_user resolves a guest's identity, so the fix has to happen outside the rule itself, by auditing redemptions grouped by email.

Is it safe to automatically cancel or refund the extra guest orders?

No. Cancelling or refunding a live order to undo an already applied voucher risks real financial and customer service side effects, so the correct action is to flag the overuse for merchant review, not to auto correct it. The only write worth automating, and only with explicit opt in, is disabling further redemptions of that specific code going forward.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #16370: "Quantity per user" is not considered for guests orders. github.com/PrestaShop/PrestaShop/issues/16370
  2. PrestaShop GitHub Issue #10122: Creating a customer account from a guest email account creates a second account. github.com/PrestaShop/PrestaShop/issues/10122
  3. PrestaShop 1.7 documentation: Cart Rules user guide. docs.prestashop-project.org 1.7 documentation cart rules

On the solution:

  1. PrestaShop Developer Documentation: the cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources cart_rules
  2. PrestaShop Developer Documentation: the order_cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources order_cart_rules
  3. PrestaShop Developer Documentation: the orders Webservice resource. devdocs.prestashop-project.org webservice resources orders

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 voucher before it cost you more?

If this saved you from an overused discount 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