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.
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.
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:
- Two customers race to apply the same promo code within the same window, for example during a flash sale or an influencer post that drives a burst of traffic to one code at once.
CartRule::checkValidity()reads the rule'squantityand the customer's prior use count, but nothing locks that row until the order that used it is fully written, so a second checkout in flight reads the same "still available" state.- Guest checkouts are a second gap on top of the first.
quantity_per_useris enforced againstid_customer, so a guest without a persistent customer id tied the same way can reuse a code that a logged-in account would have been blocked from, compounding the overuse. - Abandoned cart recovery, multiple browser tabs, or a customer refreshing checkout can also create more simultaneous "in-flight" applications of the same code than the store ever expected.
See the citations at the end for the exact issue threads this behavior is reported and reproduced in.
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.
Build it step by step
Enable the Webservice API and get a key
In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the cart_rules, order_cart_rules, and orders resources. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export CART_RULE_ID="42"
export DRY_RUN="true" # start safe, logs the intended PUT instead of sending it
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export CART_RULE_ID="42"
export DRY_RUN="true" // start safe, logs the intended PUT instead of sending it
Talk to the Webservice API
Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and PUT and raises on a bad status.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, body):
r = requests.put(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiPut(path, body) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
Read 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.
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]
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));
}
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.
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,
}
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,
};
}
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.
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.
"""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()
/**
* 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.
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}
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
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.
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.
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:
- PrestaShop GitHub Issue #9839: Cart rules can be reused under concurrent access. github.com/PrestaShop/PrestaShop/issues/9839
- PrestaShop GitHub Issue #29493: Cart rule discount with quantity 1 can be used more than once. github.com/PrestaShop/PrestaShop/issues/29493
- PrestaShop GitHub Issue #16370: "Quantity per user" is not considered for guests orders. github.com/PrestaShop/PrestaShop/issues/16370
On the solution:
- PrestaShop Developer Documentation: the cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources cart_rules
- PrestaShop Developer Documentation: the order_cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources order_cart_rules
- 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.
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