Skip to content

Diagnostic Vouchers & Cart Rules

Single use voucher redeemed more than its allowed quantity

The cart rule says quantity 1. One code, one use, ever. Then two, three, sometimes a dozen paid orders show up in the back office all discounted by the same "single use" voucher. Nobody edited the rule and nobody found a second code. The gap is timing, not configuration. Here is why PrestaShop lets a single-use voucher slip past its own limit under concurrent checkouts, how to find every order it happened to, and a script that reports the damage without touching a single already-paid order.

Python and Node.js PrestaShop Webservice API Report only (no order writes)
An industrial interior with a crane
Photo by Declan Sun on Unsplash
The short answer

PrestaShop's cart rule validity check, CartRule::checkValidity, reads the voucher's remaining quantity and each customer's quantity_per_user usage when the code is applied to the cart, and again when the order is placed. Those two reads-then-writes are not wrapped in a locking transaction. Under concurrent checkouts, two customers can each pass "quantity remaining greater than zero" before either order's validation step decrements the cart rule's used count, so both orders get created referencing the same single-use voucher. A related gap is that quantity_per_user is checked against id_customer, so guest checkouts can bypass the per-user cap entirely. Run a small Python or Node.js script that pulls the cart rule, pulls every valid order that actually used it through order_cart_rules, counts distinct uses against quantity and quantity_per_user, and reports every offending voucher with the order ids and customers involved. 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 quantity set to 1 is supposed to mean exactly one order, ever, gets the discount. PrestaShop enforces that by checking, at the moment a code is applied and again when the order is validated, whether the rule still has quantity left and whether this customer is still under their quantity_per_user cap.

The trouble is that check-then-decrement is two separate steps with nothing locking them together. Customer A applies the code. Customer B applies the same code a second later. Both checks read "quantity remaining: 1" because neither order has finished validating yet. Both carts proceed to payment. Both orders get created and both reference the cart rule. Only after both are written does anyone notice the rule that was supposed to allow one use now shows two, three, or more orders against it. The voucher configuration was correct the whole time. The race between the check and the decrement is what let it through twice.

Customer A applies code reads quantity remaining: 1 Customer B applies code reads quantity remaining: 1 No locking between check and decrement Order A validated rule used, quantity=0 Order B validated rule used again anyway Two paid orders, one code
Both customers pass the same "quantity remaining greater than zero" check because neither order has finished validating yet. The check and the decrement are never atomic.

Why it happens

This is a documented gap in PrestaShop core, not a store misconfiguration. A few concrete ways stores end up with a voucher overused:

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

The key insight

Only valid, paid or processing orders count against a voucher's limit. An order that was cancelled or errored before payment should not be counted as a use, even if order_cart_rules still has a row for it. So the audit has to filter to orders in a "valid" order_state first, then count how many of those reference the cart rule, then compare that count to quantity overall and to quantity_per_user per customer. Either cap being exceeded is what makes a voucher worth flagging.

The fix, as a flow

We never touch an already-placed order. The script pulls the cart rule's definition, pulls every order that used it through order_cart_rules, keeps only the ones sitting in a valid, paid order state, counts total uses and per-customer uses, and reports the voucher the moment either the overall quantity or a customer's quantity_per_user is exceeded. A human then decides whether to honor the first order by date and refund or contact the customer for the rest.

Auditor job runs on demand Read cart rule quantity, quantity_per_user Read valid orders using it order_cart_rules, paid states Total or per-user count exceeds cap? yes no, within limits, skip Report overage for manual review
Only vouchers where a valid order count exceeds quantity or quantity_per_user get 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, and orders 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 CART_RULE_ID="42"
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 CART_RULE_ID="42"
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 the voucher and every order that used it

Pull the cart rule for its quantity, quantity_per_user, and code. Then pull every order-to-rule link from order_cart_rules filtered to this id_cart_rule, and cross-check each referenced order's current_state against the valid, paid order states so cancelled or errored orders never count as a use.

step3.py
VALID_STATE_IDS = {2, 3, 4, 5}  # payment accepted, processing, shipped, delivered

def get_cart_rule(cart_rule_id):
    data = api_get(f"cart_rules/{cart_rule_id}")
    rule = data["cart_rule"]
    return {
        "id": int(rule["id"]),
        "code": rule.get("code") or "",
        "quantity": int(rule["quantity"]),
        "quantity_per_user": int(rule["quantity_per_user"]),
    }

def orders_using_rule(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 []
    rows = []
    for link in links:
        order_id = int(link["id_order"])
        order_data = api_get(f"orders/{order_id}", {"display": "full"})["order"]
        rows.append({
            "id_order": order_id,
            "id_customer": int(order_data["id_customer"]) if order_data.get("id_customer") else None,
            "current_state": int(order_data["current_state"]),
            "date_add": order_data["date_add"],
        })
    return [r for r in rows if r["current_state"] in VALID_STATE_IDS]
step3.js
const VALID_STATE_IDS = new Set([2, 3, 4, 5]); // payment accepted, processing, shipped, delivered

async function getCartRule(cartRuleId) {
  const data = await apiGet(`cart_rules/${cartRuleId}`);
  const rule = data.cart_rule;
  return {
    id: Number(rule.id),
    code: rule.code || "",
    quantity: Number(rule.quantity),
    quantityPerUser: Number(rule.quantity_per_user),
  };
}

async function ordersUsingRule(cartRuleId) {
  const data = await apiGet("order_cart_rules", { "filter[id_cart_rule]": cartRuleId, display: "full" });
  const links = data.order_cart_rules || [];
  const rows = [];
  for (const link of links) {
    const orderId = Number(link.id_order);
    const order = (await apiGet(`orders/${orderId}`, { display: "full" })).order;
    rows.push({
      idOrder: orderId,
      idCustomer: order.id_customer ? Number(order.id_customer) : null,
      currentState: Number(order.current_state),
      dateAdd: order.date_add,
    });
  }
  return rows.filter((r) => VALID_STATE_IDS.has(r.currentState));
}
4

Decide, with one pure function

Keep the decision in its own function that takes the cart rule and the pre-filtered list of valid orders using it, and returns the overage report or nothing. It counts total valid uses against quantity, groups by id_customer and compares each group against quantity_per_user, and flags the voucher if either cap is exceeded.

decide.py
def find_voucher_overuse(cart_rule, orders_using_rule):
    total_uses = len(orders_using_rule)
    per_user_counts = {}
    for order in orders_using_rule:
        cust = order.get("id_customer")
        per_user_counts[cust] = per_user_counts.get(cust, 0) + 1

    per_user_violations = {
        cust: count for cust, count in per_user_counts.items()
        if count > cart_rule["quantity_per_user"]
    }

    total_overage = total_uses > cart_rule["quantity"]
    if not total_overage and not per_user_violations:
        return None

    offending_ids = sorted(o["id_order"] for o in orders_using_rule)
    return {
        "cart_rule_id": cart_rule["id"],
        "code": cart_rule["code"],
        "quantity_limit": cart_rule["quantity"],
        "total_uses": total_uses,
        "overage_count": max(0, total_uses - cart_rule["quantity"]),
        "offending_order_ids": offending_ids,
        "per_user_violations": per_user_violations,
    }
decide.js
export function findVoucherOveruse(cartRule, ordersUsingRule) {
  const totalUses = ordersUsingRule.length;
  const perUserCounts = new Map();
  for (const order of ordersUsingRule) {
    const cust = order.idCustomer;
    perUserCounts.set(cust, (perUserCounts.get(cust) || 0) + 1);
  }

  const perUserViolations = {};
  for (const [cust, count] of perUserCounts) {
    if (count > cartRule.quantityPerUser) perUserViolations[cust] = count;
  }

  const totalOverage = totalUses > cartRule.quantity;
  if (!totalOverage && Object.keys(perUserViolations).length === 0) return null;

  const offendingOrderIds = ordersUsingRule.map((o) => o.idOrder).sort((a, b) => a - b);
  return {
    cartRuleId: cartRule.id,
    code: cartRule.code,
    quantityLimit: cartRule.quantity,
    totalUses,
    overageCount: Math.max(0, totalUses - cartRule.quantity),
    offendingOrderIds,
    perUserViolations,
  };
}
5

Report, and only optionally disable further use

Always log the full overage report for a human to review. Never cancel or edit an already-paid order automatically. The only optional write is disabling the cart rule for any future, not-yet-validated cart, and it only fires when DRY_RUN is false, sending PUT cart_rules/{id} with quantity=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 used the voucher are not touched by this script under any setting. The only write this script can ever make is setting the cart rule's quantity to 0 to stop future use, and even that only fires when DRY_RUN is explicitly turned off after a human reviews the report.

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 use 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_voucher_overuse.py
"""Find PrestaShop single-use cart rules that were redeemed on more orders than their
quantity or quantity_per_user allows.

CartRule::checkValidity reads a voucher's remaining quantity and a customer's prior
quantity_per_user usage at apply time and again at order validation, but those reads
and writes are not wrapped in a locking transaction. Under concurrent checkouts, two
orders can each pass the check before either one's validation decrements the used
count, so a single-use voucher can end up referenced by more than one paid order.
quantity_per_user is also checked against id_customer, so guest checkouts can bypass
the per-user cap.

This script only reports. The optional, DRY_RUN-guarded corrective step only disables
further use of the voucher by setting quantity to 0; it never cancels, edits, or
refunds an order that already used 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_voucher_overuse")

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

VALID_STATE_IDS = {2, 3, 4, 5}  # payment accepted, processing, shipped, delivered


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 get_cart_rule(cart_rule_id):
    data = api_get(f"cart_rules/{cart_rule_id}")
    rule = data["cart_rule"]
    return {
        "id": int(rule["id"]),
        "code": rule.get("code") or "",
        "quantity": int(rule["quantity"]),
        "quantity_per_user": int(rule["quantity_per_user"]),
    }


def orders_using_rule(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 []
    rows = []
    for link in links:
        order_id = int(link["id_order"])
        order_data = api_get(f"orders/{order_id}", {"display": "full"})["order"]
        rows.append({
            "id_order": order_id,
            "id_customer": int(order_data["id_customer"]) if order_data.get("id_customer") else None,
            "current_state": int(order_data["current_state"]),
            "date_add": order_data["date_add"],
        })
    return [r for r in rows if r["current_state"] in VALID_STATE_IDS]


def find_voucher_overuse(cart_rule, orders_using_rule_list):
    total_uses = len(orders_using_rule_list)
    per_user_counts = {}
    for order in orders_using_rule_list:
        cust = order.get("id_customer")
        per_user_counts[cust] = per_user_counts.get(cust, 0) + 1

    per_user_violations = {
        cust: count for cust, count in per_user_counts.items()
        if count > cart_rule["quantity_per_user"]
    }

    total_overage = total_uses > cart_rule["quantity"]
    if not total_overage and not per_user_violations:
        return None

    offending_ids = sorted(o["id_order"] for o in orders_using_rule_list)
    return {
        "cart_rule_id": cart_rule["id"],
        "code": cart_rule["code"],
        "quantity_limit": cart_rule["quantity"],
        "total_uses": total_uses,
        "overage_count": max(0, total_uses - cart_rule["quantity"]),
        "offending_order_ids": offending_ids,
        "per_user_violations": per_user_violations,
    }


def disable_further_use(cart_rule_id):
    body = {"cart_rule": {"id": cart_rule_id, "quantity": 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_rule = get_cart_rule(CART_RULE_ID)
    valid_orders = orders_using_rule(CART_RULE_ID)

    report = find_voucher_overuse(cart_rule, valid_orders)
    if report is None:
        log.info("Cart rule %s (%s) is within its quantity and quantity_per_user limits.",
                  cart_rule["id"], cart_rule["code"])
        return

    log.warning("Voucher overuse detected: %s", report)
    disable_further_use(CART_RULE_ID)
    log.info("Done. Report ready for manual review.")


if __name__ == "__main__":
    run()
audit-voucher-overuse.js
/**
 * Find PrestaShop single-use cart rules that were redeemed on more orders than their
 * quantity or quantity_per_user allows.
 *
 * CartRule::checkValidity reads a voucher's remaining quantity and a customer's prior
 * quantity_per_user usage at apply time and again at order validation, but those reads
 * and writes are not wrapped in a locking transaction. Under concurrent checkouts, two
 * orders can each pass the check before either one's validation decrements the used
 * count, so a single-use voucher can end up referenced by more than one paid order.
 * quantity_per_user is also checked against id_customer, so guest checkouts can bypass
 * the per-user cap.
 *
 * This script only reports. The optional, DRY_RUN-guarded corrective step only disables
 * further use of the voucher by setting quantity to 0; it never cancels, edits, or
 * refunds an order that already used it. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/voucher-redeemed-beyond-quantity-limit/
 */
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 CART_RULE_ID = Number(process.env.CART_RULE_ID || 42);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const VALID_STATE_IDS = new Set([2, 3, 4, 5]); // payment accepted, processing, shipped, delivered

export function findVoucherOveruse(cartRule, ordersUsingRule) {
  const totalUses = ordersUsingRule.length;
  const perUserCounts = new Map();
  for (const order of ordersUsingRule) {
    const cust = order.idCustomer;
    perUserCounts.set(cust, (perUserCounts.get(cust) || 0) + 1);
  }

  const perUserViolations = {};
  for (const [cust, count] of perUserCounts) {
    if (count > cartRule.quantityPerUser) perUserViolations[cust] = count;
  }

  const totalOverage = totalUses > cartRule.quantity;
  if (!totalOverage && Object.keys(perUserViolations).length === 0) return null;

  const offendingOrderIds = ordersUsingRule.map((o) => o.idOrder).sort((a, b) => a - b);
  return {
    cartRuleId: cartRule.id,
    code: cartRule.code,
    quantityLimit: cartRule.quantity,
    totalUses,
    overageCount: Math.max(0, totalUses - cartRule.quantity),
    offendingOrderIds,
    perUserViolations,
  };
}

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 getCartRule(cartRuleId) {
  const data = await apiGet(`cart_rules/${cartRuleId}`);
  const rule = data.cart_rule;
  return {
    id: Number(rule.id),
    code: rule.code || "",
    quantity: Number(rule.quantity),
    quantityPerUser: Number(rule.quantity_per_user),
  };
}

async function ordersUsingRule(cartRuleId) {
  const data = await apiGet("order_cart_rules", { "filter[id_cart_rule]": cartRuleId, display: "full" });
  const links = data.order_cart_rules || [];
  const rows = [];
  for (const link of links) {
    const orderId = Number(link.id_order);
    const order = (await apiGet(`orders/${orderId}`, { display: "full" })).order;
    rows.push({
      idOrder: orderId,
      idCustomer: order.id_customer ? Number(order.id_customer) : null,
      currentState: Number(order.current_state),
      dateAdd: order.date_add,
    });
  }
  return rows.filter((r) => VALID_STATE_IDS.has(r.currentState));
}

async function disableFurtherUse(cartRuleId) {
  const body = { cart_rule: { id: cartRuleId, quantity: 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 cartRule = await getCartRule(CART_RULE_ID);
  const validOrders = await ordersUsingRule(CART_RULE_ID);

  const report = findVoucherOveruse(cartRule, validOrders);
  if (report === null) {
    console.log(`Cart rule ${cartRule.id} (${cartRule.code}) is within its quantity and quantity_per_user limits.`);
    return;
  }

  console.warn("Voucher overuse detected:", report);
  await disableFurtherUse(CART_RULE_ID);
  console.log("Done. 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 vouchers get reported as overused. Because find_voucher_overuse is pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_voucher_overuse.py
from audit_voucher_overuse import find_voucher_overuse

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


def order(**over):
    base = {"id_order": 1, "id_customer": 10, "current_state": 2, "date_add": "2026-07-01 10:00:00"}
    base.update(over)
    return base


def test_no_overage_when_single_use_within_quantity():
    orders = [order()]
    assert find_voucher_overuse(RULE, orders) is None


def test_overage_when_quantity_one_used_twice():
    orders = [order(id_order=1, id_customer=10), order(id_order=2, id_customer=11)]
    result = find_voucher_overuse(RULE, orders)
    assert result is not None
    assert result["overage_count"] == 1
    assert result["offending_order_ids"] == [1, 2]


def test_per_user_violation_flagged_even_under_total_quantity():
    rule = {"id": 7, "code": "VIP5", "quantity": 5, "quantity_per_user": 1}
    orders = [order(id_order=1, id_customer=10), order(id_order=2, id_customer=10)]
    result = find_voucher_overuse(rule, orders)
    assert result is not None
    assert result["per_user_violations"] == {10: 2}
    assert result["overage_count"] == 0


def test_no_flag_when_orders_empty():
    assert find_voucher_overuse(RULE, []) is None


def test_offending_order_ids_are_sorted():
    orders = [order(id_order=5, id_customer=1), order(id_order=2, id_customer=2), order(id_order=9, id_customer=3)]
    rule = {"id": 8, "code": "X", "quantity": 1, "quantity_per_user": 1}
    result = find_voucher_overuse(rule, orders)
    assert result["offending_order_ids"] == [2, 5, 9]


def test_guest_orders_without_customer_id_are_grouped_together():
    orders = [order(id_order=1, id_customer=None), order(id_order=2, id_customer=None)]
    rule = {"id": 9, "code": "GUEST1", "quantity": 5, "quantity_per_user": 1}
    result = find_voucher_overuse(rule, orders)
    assert result is not None
    assert result["per_user_violations"] == {None: 2}
voucher-overuse.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findVoucherOveruse } from "./audit-voucher-overuse.js";

const RULE = { id: 42, code: "SUMMER1", quantity: 1, quantityPerUser: 1 };

const order = (over = {}) => ({ idOrder: 1, idCustomer: 10, currentState: 2, dateAdd: "2026-07-01 10:00:00", ...over });

test("no overage when single use is within quantity", () => {
  const orders = [order()];
  assert.equal(findVoucherOveruse(RULE, orders), null);
});

test("overage when quantity one is used twice", () => {
  const orders = [order({ idOrder: 1, idCustomer: 10 }), order({ idOrder: 2, idCustomer: 11 })];
  const result = findVoucherOveruse(RULE, orders);
  assert.ok(result);
  assert.equal(result.overageCount, 1);
  assert.deepEqual(result.offendingOrderIds, [1, 2]);
});

test("per user violation flagged even under total quantity", () => {
  const rule = { id: 7, code: "VIP5", quantity: 5, quantityPerUser: 1 };
  const orders = [order({ idOrder: 1, idCustomer: 10 }), order({ idOrder: 2, idCustomer: 10 })];
  const result = findVoucherOveruse(rule, orders);
  assert.ok(result);
  assert.deepEqual(result.perUserViolations, { 10: 2 });
  assert.equal(result.overageCount, 0);
});

test("no flag when orders list is empty", () => {
  assert.equal(findVoucherOveruse(RULE, []), null);
});

test("offending order ids are sorted", () => {
  const orders = [order({ idOrder: 5, idCustomer: 1 }), order({ idOrder: 2, idCustomer: 2 }), order({ idOrder: 9, idCustomer: 3 })];
  const rule = { id: 8, code: "X", quantity: 1, quantityPerUser: 1 };
  const result = findVoucherOveruse(rule, orders);
  assert.deepEqual(result.offendingOrderIds, [2, 5, 9]);
});

test("guest orders without a customer id are grouped together", () => {
  const orders = [order({ idOrder: 1, idCustomer: null }), order({ idOrder: 2, idCustomer: null })];
  const rule = { id: 9, code: "GUEST1", quantity: 5, quantityPerUser: 1 };
  const result = findVoucherOveruse(rule, orders);
  assert.ok(result);
  assert.deepEqual(result.perUserViolations, { null: 2 });
});

Case studies

Flash sale

One influencer code, six paid orders

A skincare brand handed a single-use code to an influencer for one giveaway winner. The post went out, and within ninety seconds six different followers had it applied and paid. The cart rule's quantity was correctly set to 1 the entire time; the checkouts simply raced past the check before any of them finished validating.

The audit script pulled order_cart_rules for that cart rule id, found six valid paid orders against a quantity of one, and reported the full list sorted by date_add. The brand honored the discount for the earliest order and reached out individually to the other five with a small store credit instead of a blanket cancellation.

Guest checkout

quantity_per_user did nothing for guests

A subscription box store gave every new signup a welcome code capped at quantity_per_user 1. Logged-in accounts were blocked correctly on a second attempt, but guest checkouts kept slipping through, since the cap keyed off id_customer and guest orders were not tied to one consistently.

Running the auditor weekly grouped valid orders by id_customer, including the null-customer guest group, and surfaced a growing cluster of guest orders reusing the same welcome code well past its intended per-user limit. The store added a required account step at checkout for that promotion going forward.

What good looks like

After this runs, a single-use voucher that slipped past its own limit is never a surprise buried in a discount report. Every overused cart rule comes with the exact orders and customers involved, sorted so the earliest legitimate use is obvious, and no order that already used the code is ever cancelled or edited automatically. A human reviews the report and decides the fair outcome for the rest.

FAQ

Why did a single-use voucher get used on more than one order?

PrestaShop checks a cart rule's remaining quantity and quantity_per_user when the code is applied to the cart, and again when the order is placed, but those reads and writes are not wrapped in a locking transaction. Under concurrent checkouts, two customers can both pass the quantity remaining check before either order's validation step decrements the cart rule's used count, so both orders get created against the same single-use voucher.

Is it safe to auto cancel the extra orders that used the voucher?

No. The orders are already placed and often already paid, so cancelling or editing them automatically would mutate financial state without a human involved. The safe pattern is to report every order using the voucher beyond its quantity or quantity_per_user limit, honor the discount on the first valid order by date_add, and let a human decide on a refund, adjustment, or customer contact for the rest.

Does quantity_per_user protect guest checkouts too?

Not reliably. quantity_per_user is checked against id_customer, and guest orders are not tied to a persistent customer id the same way a logged in account is, so a guest can bypass the per-user cap even when the overall quantity has not run out. Treat guest orders as a separate group when auditing quantity_per_user violations.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #9839: Cart rules can be reused under concurrent access. github.com/PrestaShop/PrestaShop/issues/9839
  2. PrestaShop GitHub Issue #29493: Cart rule discount with quantity 1 can be used more than once. github.com/PrestaShop/PrestaShop/issues/29493
  3. PrestaShop GitHub Issue #16370: "Quantity per user" is not considered for guests orders. github.com/PrestaShop/PrestaShop/issues/16370

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 9 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 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