Diagnostic Cart Price Rules and Coupons
Coupon usage limit not enforced, allowing unlimited reuse
You set Uses per Coupon to one, or Uses per Customer to one, and the storefront still accepts the same code on the tenth order. Nothing looks wrong in the rule configuration. But the counters Magento uses to enforce that limit are updated after the fact, on a queue, and when that queue lags or the consumer is not running, the limit simply stops being checked. Here is why that happens and a small script that finds every coupon being reused past its real limit without touching a single order.
Since Magento 2.4.3, coupon and customer usage bookkeeping, salesrule_coupon.times_used, salesrule_customer.times_used, and the salesrule_coupon_usage rows, is incremented asynchronously by the sales.rule.update.coupon.usage message queue consumer instead of synchronously at order placement. If the consumer is not running, lags under load, or the order crashes after the coupon is applied but before the queue message is consumed, times_used never moves even though the coupon was used on a real order, so uses_per_coupon and uses_per_customer silently stop being enforced. A script can pull each rule's configured limits over GET /rest/V1/salesRules, the API's reported times_used over GET /rest/V1/coupons, and the real, authoritative usage from GET /rest/V1/orders filtered by coupon_code, then flag any coupon where the real order count disagrees with the configured limit or the reported counter. It should not cancel or refund those orders on its own, since that is a business decision. Full code, tests, and a dry run guard are below.
The problem in plain words
A cart price rule with a coupon code carries two limits merchants rely on: uses_per_coupon, how many times the code overall can be redeemed, and uses_per_customer, how many times one customer can redeem it. Both are meant to be checked and incremented the moment an order is placed with that coupon attached, the same way inventory is reserved the moment a cart becomes an order.
That used to be true. But since Magento 2.4.3, the increment side of that bookkeeping moved off the request thread and onto a message queue. Magento\SalesRule\Model\Plugin\Quote and the CouponUsageProcessor now publish a message to sales.rule.update.coupon.usage when an order is placed, and a separate consumer process is responsible for actually writing the updated times_used values back to salesrule_coupon, salesrule_customer, and salesrule_coupon_usage. The order itself succeeds and ships regardless of whether that consumer ever runs. If it is not running, falls behind under load, or the request crashes after the coupon was applied to the quote but before the queue message is consumed, the counters simply never catch up, and the coupon keeps validating as if it had never been used.
Why it happens
- The
sales.rule.update.coupon.usageconsumer is not started at all, common right after a fresh deployment or a server migration wherebin/magento queue:consumers:startwas never added to the process manager. - The consumer is running but falls behind during a sale or a flash promotion, so a burst of orders using the same coupon all place successfully before any of their usage messages are processed.
- The order placement request crashes, times out, or is killed after the coupon has been applied to the quote and the order has been created, but before the queue message for that order is published or consumed.
- A related variant leaves the opposite symptom: a cancelled or failed order's usage row in
salesrule_coupon_usageis never decremented, sotimes_usedstays artificially high and blocks a valid customer from reusing a coupon they never successfully redeemed. Both symptoms trace back to the same root cause, usage tracking that is decoupled from the authoritative order record.
This is a documented, recurring gap reported directly against Magento 2.4.3 and later, both in the core issue tracker and in Adobe's own knowledge base. See the citations at the end for the exact threads and articles.
salesrule_coupon.times_used is not the source of truth, it is a cache that is supposed to track the source of truth. The actual record of whether a coupon was used is the order itself, specifically its coupon_code field and its state. So a detection script should never trust times_used on its own. It should count real, non-cancelled orders carrying that coupon code and compare that count against the configured limit, then treat any gap between the real count and the reported counter as direct evidence the queue consumer fell behind.
The fix, as a flow
We do not touch the live checkout or the queue consumer's code. We add a job that pulls every active coupon rule and its configured limits, pulls the coupon codes under each rule, independently counts real usage from actual orders, and reports any coupon where the real count breaks the configured limit or disagrees with the reported counter. Nothing about placing, cancelling, or refunding an order is touched by this job.
Build it step by step
Get an admin bearer token
Authenticate the way any Magento REST client does. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" # start safe, change to false only with --apply to recompute counters
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" // start safe, change to false only with --apply to recompute counters
Talk to the Magento REST API
Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
List active coupon rules and their coupons
Search /salesRules for rules that are active and carry a coupon type of specific or auto-generated, and read back rule_id, uses_per_coupon, and uses_per_customer. For each rule, search /coupons filtered by rule_id to get every coupon's coupon_id, code, and the API's reported times_used.
def active_coupon_rules(page_size=100):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_type",
"searchCriteria[filterGroups][0][filters][0][value]": "2,3",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[filterGroups][1][filters][0][field]": "is_active",
"searchCriteria[filterGroups][1][filters][0][value]": 1,
"searchCriteria[pageSize]": page_size,
}
return magento_get("/salesRules", params)["items"]
def coupons_for_rule(rule_id):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "rule_id",
"searchCriteria[filterGroups][0][filters][0][value]": rule_id,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/coupons", params)["items"]
async function activeCouponRules(pageSize = 100) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_type",
"searchCriteria[filterGroups][0][filters][0][value]": "2,3",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[filterGroups][1][filters][0][field]": "is_active",
"searchCriteria[filterGroups][1][filters][0][value]": 1,
"searchCriteria[pageSize]": pageSize,
};
const data = await magentoGet("/salesRules", params);
return data.items;
}
async function couponsForRule(ruleId) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "rule_id",
"searchCriteria[filterGroups][0][filters][0][value]": ruleId,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/coupons", params);
return data.items;
}
Count the real usage from orders
For each coupon code, search /orders filtered by coupon_code equal to that code, excluding cancelled orders. Read entity_id, increment_id, customer_id, and state, since orders, not the cached counter, are the authoritative record of whether a coupon was really used. Page through with currentPage so a heavily used coupon does not get truncated.
def orders_for_coupon(code, page_size=100):
page = 1
while True:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
"searchCriteria[filterGroups][0][filters][0][value]": code,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
items = magento_get("/orders", params)["items"]
if not items:
return
for item in items:
yield item
if len(items) < page_size:
return
page += 1
async function* ordersForCoupon(code, pageSize = 100) {
let page = 1;
while (true) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
"searchCriteria[filterGroups][0][filters][0][value]": code,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const data = await magentoGet("/orders", params);
const items = data.items || [];
if (items.length === 0) return;
for (const item of items) yield item;
if (items.length < pageSize) return;
page++;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the rule's configured limits, the coupon record, and the real orders, and returns a plain verdict. A pure function like this is easy to read and easy to test, which we do later. It filters out cancelled orders, counts the real total and the count per customer, and flags a violation when the real total exceeds uses_per_coupon, any customer's real count exceeds uses_per_customer, or the reported times_used is lower than the real total, which is direct evidence the queue consumer under-counted.
def evaluate_coupon_usage(rule, coupon_record, real_orders):
active = [o for o in real_orders if o.get("state") != "canceled"]
real_total_count = len(active)
per_customer_counts = {}
for o in active:
key = str(o.get("customerId")) if o.get("customerId") is not None else "guest"
per_customer_counts[key] = per_customer_counts.get(key, 0) + 1
uses_per_coupon = rule.get("usesPerCoupon")
uses_per_customer = rule.get("usesPerCustomer")
reported_times_used = coupon_record.get("reportedTimesUsed", 0)
reason = None
if uses_per_coupon and real_total_count > uses_per_coupon:
reason = "per_coupon_exceeded"
elif uses_per_customer and any(c > uses_per_customer for c in per_customer_counts.values()):
reason = "per_customer_exceeded"
elif reported_times_used < real_total_count:
reason = "times_used_drift"
allowed = 0
if reason == "per_coupon_exceeded":
allowed = uses_per_coupon
offending = [o["incrementId"] for o in active[allowed:]] if reason == "per_coupon_exceeded" else (
[o["incrementId"] for o in active] if reason else []
)
return {
"isViolation": reason is not None,
"reason": reason,
"realTotalCount": real_total_count,
"perCustomerCounts": per_customer_counts,
"offendingOrderIncrementIds": offending,
}
export function evaluateCouponUsage(rule, couponRecord, realOrders) {
const active = realOrders.filter((o) => o.state !== "canceled");
const realTotalCount = active.length;
const perCustomerCounts = {};
for (const o of active) {
const key = o.customerId != null ? String(o.customerId) : "guest";
perCustomerCounts[key] = (perCustomerCounts[key] || 0) + 1;
}
const { usesPerCoupon, usesPerCustomer } = rule;
const reportedTimesUsed = couponRecord.reportedTimesUsed || 0;
let reason = null;
if (usesPerCoupon && realTotalCount > usesPerCoupon) {
reason = "per_coupon_exceeded";
} else if (usesPerCustomer && Object.values(perCustomerCounts).some((c) => c > usesPerCustomer)) {
reason = "per_customer_exceeded";
} else if (reportedTimesUsed < realTotalCount) {
reason = "times_used_drift";
}
const offendingOrderIncrementIds =
reason === "per_coupon_exceeded"
? active.slice(usesPerCoupon).map((o) => o.incrementId)
: reason
? active.map((o) => o.incrementId)
: [];
return {
isViolation: reason !== null,
reason,
realTotalCount,
perCustomerCounts,
offendingOrderIncrementIds,
};
}
Report by default, repair only when gated
The default output is a structured record per flagged coupon: its rule_id, coupon_id and code, the configured uses_per_coupon and uses_per_customer, the real order count, the reported times_used, and the offending order increment ids, for an operator to review. Placing holds, cancelling, or refunding those orders is never something this script does, since orders that already shipped carry business and legal consequences no script should decide unilaterally. Only when DRY_RUN is false and the script is run with an explicit --apply flag does it recompute and correct the times_used counters to match the true order count, and it prints a reminder to run bin/magento queue:consumers:start sales.rule.update.coupon.usage or otherwise verify the consumer is running.
Always start with DRY_RUN=true and without --apply. Even the one safe corrective action, recomputing times_used, should be reviewed against the printed order list first, and the offending orders themselves are never touched by this script.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, lists active coupon rules and their coupons, counts real usage from orders, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Detect Magento 2 and Adobe Commerce coupons that are being reused past their
configured limit, safely.
Since Magento 2.4.3, coupon usage bookkeeping (salesrule_coupon.times_used,
salesrule_customer.times_used, and salesrule_coupon_usage rows) is
incremented asynchronously by the sales.rule.update.coupon.usage message
queue consumer instead of during order placement. If that consumer is not
running, lags under load, or the order crashes after the coupon is applied
but before the message is consumed, times_used never increments even though
the coupon was used on a real order, so uses_per_coupon and
uses_per_customer silently stop being enforced.
This reports every discrepancy by default. It never cancels, refunds, or
holds an order. The only gated corrective action, behind DRY_RUN=false and
--apply, recomputes times_used to match the real order count. Run on a
schedule. Safe to run again and again.
"""
import os
import sys
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("evaluate_coupon_usage")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
APPLY = "--apply" in sys.argv
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_put_coupon_times_used(coupon_id, times_used):
r = requests.put(
f"{MAGENTO_URL}/rest/V1/coupons",
json={"entity": {"coupon_id": coupon_id, "times_used": times_used}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def active_coupon_rules(page_size=100):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_type",
"searchCriteria[filterGroups][0][filters][0][value]": "2,3",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[filterGroups][1][filters][0][field]": "is_active",
"searchCriteria[filterGroups][1][filters][0][value]": 1,
"searchCriteria[pageSize]": page_size,
}
return magento_get("/salesRules", params)["items"]
def coupons_for_rule(rule_id):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "rule_id",
"searchCriteria[filterGroups][0][filters][0][value]": rule_id,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/coupons", params)["items"]
def orders_for_coupon(code, page_size=100):
page = 1
while True:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
"searchCriteria[filterGroups][0][filters][0][value]": code,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
items = magento_get("/orders", params)["items"]
if not items:
return
for item in items:
yield item
if len(items) < page_size:
return
page += 1
def evaluate_coupon_usage(rule, coupon_record, real_orders):
active = [o for o in real_orders if o.get("state") != "canceled"]
real_total_count = len(active)
per_customer_counts = {}
for o in active:
key = str(o.get("customerId")) if o.get("customerId") is not None else "guest"
per_customer_counts[key] = per_customer_counts.get(key, 0) + 1
uses_per_coupon = rule.get("usesPerCoupon")
uses_per_customer = rule.get("usesPerCustomer")
reported_times_used = coupon_record.get("reportedTimesUsed", 0)
reason = None
if uses_per_coupon and real_total_count > uses_per_coupon:
reason = "per_coupon_exceeded"
elif uses_per_customer and any(c > uses_per_customer for c in per_customer_counts.values()):
reason = "per_customer_exceeded"
elif reported_times_used < real_total_count:
reason = "times_used_drift"
allowed = uses_per_coupon if reason == "per_coupon_exceeded" else 0
offending = [o["incrementId"] for o in active[allowed:]] if reason == "per_coupon_exceeded" else (
[o["incrementId"] for o in active] if reason else []
)
return {
"isViolation": reason is not None,
"reason": reason,
"realTotalCount": real_total_count,
"perCustomerCounts": per_customer_counts,
"offendingOrderIncrementIds": offending,
}
def to_plain_rule(raw):
return {
"ruleId": raw["rule_id"],
"usesPerCoupon": raw.get("uses_per_coupon") or None,
"usesPerCustomer": raw.get("uses_per_customer") or None,
}
def to_plain_coupon(raw):
return {
"couponId": raw["coupon_id"],
"code": raw["code"],
"reportedTimesUsed": raw.get("times_used") or 0,
}
def to_plain_orders(raw_items):
return [
{
"orderId": str(item["entity_id"]),
"incrementId": item.get("increment_id", ""),
"customerId": item.get("customer_id"),
"state": item.get("state", ""),
}
for item in raw_items
]
def run():
flagged = 0
for raw_rule in active_coupon_rules():
rule = to_plain_rule(raw_rule)
for raw_coupon in coupons_for_rule(rule["ruleId"]):
coupon_record = to_plain_coupon(raw_coupon)
real_orders = to_plain_orders(list(orders_for_coupon(coupon_record["code"])))
result = evaluate_coupon_usage(rule, coupon_record, real_orders)
if not result["isViolation"]:
continue
flagged += 1
log.warning(
"Rule %s coupon %s (%s): reason=%s real_count=%s reported_times_used=%s "
"uses_per_coupon=%s uses_per_customer=%s offending_orders=%s",
rule["ruleId"], coupon_record["couponId"], coupon_record["code"],
result["reason"], result["realTotalCount"], coupon_record["reportedTimesUsed"],
rule["usesPerCoupon"], rule["usesPerCustomer"], result["offendingOrderIncrementIds"],
)
if not DRY_RUN and APPLY:
log.warning(
"DRY_RUN is false and --apply is set: recomputing times_used for coupon %s "
"from %s to %s. Confirm sales.rule.update.coupon.usage is running so this "
"does not drift again.",
coupon_record["code"], coupon_record["reportedTimesUsed"], result["realTotalCount"],
)
magento_put_coupon_times_used(coupon_record["couponId"], result["realTotalCount"])
log.info("Done. %d coupon(s) flagged.", flagged)
if __name__ == "__main__":
run()
/**
* Detect Magento 2 and Adobe Commerce coupons that are being reused past
* their configured limit, safely.
*
* Since Magento 2.4.3, coupon usage bookkeeping (salesrule_coupon.times_used,
* salesrule_customer.times_used, and salesrule_coupon_usage rows) is
* incremented asynchronously by the sales.rule.update.coupon.usage message
* queue consumer instead of during order placement. If that consumer is not
* running, lags under load, or the order crashes after the coupon is
* applied but before the message is consumed, times_used never increments
* even though the coupon was used on a real order, so uses_per_coupon and
* uses_per_customer silently stop being enforced.
*
* This reports every discrepancy by default. It never cancels, refunds, or
* holds an order. The only gated corrective action, behind DRY_RUN=false
* and --apply, recomputes times_used to match the real order count. Run on
* a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/coupon-usage-limit-not-enforced/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const APPLY = process.argv.includes("--apply");
export function evaluateCouponUsage(rule, couponRecord, realOrders) {
const active = realOrders.filter((o) => o.state !== "canceled");
const realTotalCount = active.length;
const perCustomerCounts = {};
for (const o of active) {
const key = o.customerId != null ? String(o.customerId) : "guest";
perCustomerCounts[key] = (perCustomerCounts[key] || 0) + 1;
}
const { usesPerCoupon, usesPerCustomer } = rule;
const reportedTimesUsed = couponRecord.reportedTimesUsed || 0;
let reason = null;
if (usesPerCoupon && realTotalCount > usesPerCoupon) {
reason = "per_coupon_exceeded";
} else if (usesPerCustomer && Object.values(perCustomerCounts).some((c) => c > usesPerCustomer)) {
reason = "per_customer_exceeded";
} else if (reportedTimesUsed < realTotalCount) {
reason = "times_used_drift";
}
const offendingOrderIncrementIds =
reason === "per_coupon_exceeded"
? active.slice(usesPerCoupon).map((o) => o.incrementId)
: reason
? active.map((o) => o.incrementId)
: [];
return {
isViolation: reason !== null,
reason,
realTotalCount,
perCustomerCounts,
offendingOrderIncrementIds,
};
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPutCouponTimesUsed(couponId, timesUsed) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/coupons`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ entity: { coupon_id: couponId, times_used: timesUsed } }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function activeCouponRules(pageSize = 100) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_type",
"searchCriteria[filterGroups][0][filters][0][value]": "2,3",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[filterGroups][1][filters][0][field]": "is_active",
"searchCriteria[filterGroups][1][filters][0][value]": 1,
"searchCriteria[pageSize]": pageSize,
};
const data = await magentoGet("/salesRules", params);
return data.items;
}
async function couponsForRule(ruleId) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "rule_id",
"searchCriteria[filterGroups][0][filters][0][value]": ruleId,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/coupons", params);
return data.items;
}
async function* ordersForCoupon(code, pageSize = 100) {
let page = 1;
while (true) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
"searchCriteria[filterGroups][0][filters][0][value]": code,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const data = await magentoGet("/orders", params);
const items = data.items || [];
if (items.length === 0) return;
for (const item of items) yield item;
if (items.length < pageSize) return;
page++;
}
}
function toPlainRule(raw) {
return {
ruleId: raw.rule_id,
usesPerCoupon: raw.uses_per_coupon || null,
usesPerCustomer: raw.uses_per_customer || null,
};
}
function toPlainCoupon(raw) {
return {
couponId: raw.coupon_id,
code: raw.code,
reportedTimesUsed: raw.times_used || 0,
};
}
function toPlainOrder(item) {
return {
orderId: String(item.entity_id),
incrementId: item.increment_id || "",
customerId: item.customer_id ?? null,
state: item.state || "",
};
}
export async function run() {
let flagged = 0;
const rawRules = await activeCouponRules();
for (const rawRule of rawRules) {
const rule = toPlainRule(rawRule);
const rawCoupons = await couponsForRule(rule.ruleId);
for (const rawCoupon of rawCoupons) {
const couponRecord = toPlainCoupon(rawCoupon);
const realOrders = [];
for await (const item of ordersForCoupon(couponRecord.code)) {
realOrders.push(toPlainOrder(item));
}
const result = evaluateCouponUsage(rule, couponRecord, realOrders);
if (!result.isViolation) continue;
flagged++;
console.warn(
`Rule ${rule.ruleId} coupon ${couponRecord.couponId} (${couponRecord.code}): ` +
`reason=${result.reason} real_count=${result.realTotalCount} reported_times_used=${couponRecord.reportedTimesUsed} ` +
`uses_per_coupon=${rule.usesPerCoupon} uses_per_customer=${rule.usesPerCustomer} ` +
`offending_orders=${JSON.stringify(result.offendingOrderIncrementIds)}`
);
if (!DRY_RUN && APPLY) {
console.warn(
`DRY_RUN is false and --apply is set: recomputing times_used for coupon ${couponRecord.code} ` +
`from ${couponRecord.reportedTimesUsed} to ${result.realTotalCount}. Confirm ` +
`sales.rule.update.coupon.usage is running so this does not drift again.`
);
await magentoPutCouponTimesUsed(couponRecord.couponId, result.realTotalCount);
}
}
}
console.log(`Done. ${flagged} coupon(s) flagged.`);
}
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 whether a coupon gets flagged as reused past its limit. Because we kept evaluate_coupon_usage pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture objects and checks the answer.
from evaluate_coupon_usage import evaluate_coupon_usage
def rule(**over):
base = {"ruleId": 12, "usesPerCoupon": 1, "usesPerCustomer": 1}
base.update(over)
return base
def coupon(**over):
base = {"couponId": 55, "code": "SAVE10", "reportedTimesUsed": 1}
base.update(over)
return base
def order(**over):
base = {"orderId": "1", "incrementId": "000000001", "customerId": 7, "state": "complete"}
base.update(over)
return base
def test_no_violation_when_within_limits_and_counter_matches():
result = evaluate_coupon_usage(rule(), coupon(), [order()])
assert result["isViolation"] is False
assert result["realTotalCount"] == 1
def test_per_coupon_exceeded_when_real_count_over_limit():
orders = [order(orderId="1", incrementId="000000001"), order(orderId="2", incrementId="000000002", customerId=9)]
result = evaluate_coupon_usage(rule(usesPerCoupon=1, usesPerCustomer=None), coupon(reportedTimesUsed=2), orders)
assert result["isViolation"] is True
assert result["reason"] == "per_coupon_exceeded"
assert result["offendingOrderIncrementIds"] == ["000000002"]
def test_per_customer_exceeded_when_same_customer_reuses_coupon():
orders = [order(orderId="1", incrementId="000000001"), order(orderId="2", incrementId="000000002")]
result = evaluate_coupon_usage(rule(usesPerCoupon=None, usesPerCustomer=1), coupon(reportedTimesUsed=2), orders)
assert result["isViolation"] is True
assert result["reason"] == "per_customer_exceeded"
assert result["perCustomerCounts"]["7"] == 2
def test_times_used_drift_when_counter_lags_real_orders():
orders = [order()]
result = evaluate_coupon_usage(rule(usesPerCoupon=None, usesPerCustomer=None), coupon(reportedTimesUsed=0), orders)
assert result["isViolation"] is True
assert result["reason"] == "times_used_drift"
def test_cancelled_orders_are_excluded_from_the_real_count():
orders = [order(), order(orderId="2", incrementId="000000002", state="canceled")]
result = evaluate_coupon_usage(rule(usesPerCoupon=1), coupon(reportedTimesUsed=1), orders)
assert result["isViolation"] is False
assert result["realTotalCount"] == 1
def test_guest_orders_are_grouped_under_guest_key():
orders = [order(customerId=None)]
result = evaluate_coupon_usage(rule(usesPerCoupon=None, usesPerCustomer=None), coupon(reportedTimesUsed=1), orders)
assert result["perCustomerCounts"]["guest"] == 1
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateCouponUsage } from "./evaluate-coupon-usage.js";
const rule = (over = {}) => ({ ruleId: 12, usesPerCoupon: 1, usesPerCustomer: 1, ...over });
const coupon = (over = {}) => ({ couponId: 55, code: "SAVE10", reportedTimesUsed: 1, ...over });
const order = (over = {}) => ({ orderId: "1", incrementId: "000000001", customerId: 7, state: "complete", ...over });
test("no violation when within limits and counter matches", () => {
const result = evaluateCouponUsage(rule(), coupon(), [order()]);
assert.equal(result.isViolation, false);
assert.equal(result.realTotalCount, 1);
});
test("per_coupon_exceeded when real count over limit", () => {
const orders = [order({ orderId: "1", incrementId: "000000001" }), order({ orderId: "2", incrementId: "000000002", customerId: 9 })];
const result = evaluateCouponUsage(rule({ usesPerCoupon: 1, usesPerCustomer: null }), coupon({ reportedTimesUsed: 2 }), orders);
assert.equal(result.isViolation, true);
assert.equal(result.reason, "per_coupon_exceeded");
assert.deepEqual(result.offendingOrderIncrementIds, ["000000002"]);
});
test("per_customer_exceeded when same customer reuses coupon", () => {
const orders = [order({ orderId: "1", incrementId: "000000001" }), order({ orderId: "2", incrementId: "000000002" })];
const result = evaluateCouponUsage(rule({ usesPerCoupon: null, usesPerCustomer: 1 }), coupon({ reportedTimesUsed: 2 }), orders);
assert.equal(result.isViolation, true);
assert.equal(result.reason, "per_customer_exceeded");
assert.equal(result.perCustomerCounts["7"], 2);
});
test("times_used_drift when counter lags real orders", () => {
const result = evaluateCouponUsage(rule({ usesPerCoupon: null, usesPerCustomer: null }), coupon({ reportedTimesUsed: 0 }), [order()]);
assert.equal(result.isViolation, true);
assert.equal(result.reason, "times_used_drift");
});
test("cancelled orders are excluded from the real count", () => {
const orders = [order(), order({ orderId: "2", incrementId: "000000002", state: "canceled" })];
const result = evaluateCouponUsage(rule({ usesPerCoupon: 1 }), coupon({ reportedTimesUsed: 1 }), orders);
assert.equal(result.isViolation, false);
assert.equal(result.realTotalCount, 1);
});
test("guest orders are grouped under guest key", () => {
const orders = [order({ customerId: null })];
const result = evaluateCouponUsage(rule({ usesPerCoupon: null, usesPerCustomer: null }), coupon({ reportedTimesUsed: 1 }), orders);
assert.equal(result.perCustomerCounts.guest, 1);
});
Case studies
The one-time code that redeemed forty times
A DTC brand launched a first-order discount capped at uses_per_customer equal to one, expecting it as a one-shot welcome offer. During a traffic spike from a newsletter blast, the same code was reused by several repeat customers dozens of times over a weekend. The rule configuration in the admin looked completely normal.
The detection job counted real orders per coupon_code and per customer_id straight from the orders API and found the queue consumer had been quietly falling behind since the traffic spike started. The report gave finance the exact list of offending increment ids to review, and the operations team restarted and monitored the sales.rule.update.coupon.usage consumer so it stopped happening.
The loyal customer who got blocked by their own old order
A subscription add-on store had a repeat customer complain that a single-use coupon they had never successfully redeemed refused to apply. Support assumed it was user error until someone pulled the coupon's usage rows and found a cancelled order still counted toward times_used, since the decrement never ran when that earlier order was cancelled.
Running the script surfaced the mismatch immediately: the real, non-cancelled order count for that customer was zero, but the reported counter said one. The team corrected the counter through the gated --apply path after confirming the cancelled order in the admin, and the customer could use the code as intended.
After this runs on a schedule, a coupon quietly bypassing its own limit gets caught within one detection cycle instead of surviving for weeks in a discount report nobody double-checks. The report carries the rule's configured limits, the real order count, the reported counter, and the exact offending increment ids, so finance and support can decide what to do about the extra orders with full information. Keep the actual counter correction gated behind --apply and a human review, since that is what keeps the script from making a business call it has no business making.
FAQ
Why does my Magento coupon keep working after it should have hit its usage limit?
Since Magento 2.4.3, the counters that enforce uses_per_coupon and uses_per_customer, such as salesrule_coupon.times_used, salesrule_customer.times_used, and the salesrule_coupon_usage rows, are updated asynchronously by the sales.rule.update.coupon.usage message queue consumer instead of during order placement. If that consumer is not running, falls behind under load, or the order placement fails after the coupon is applied but before the queue message is processed, times_used never increments even though the coupon was used on a real order, so the limit silently stops being enforced.
How do I detect coupons that are being reused past their limit?
Pull active coupon rules through GET /rest/V1/salesRules filtered on coupon_type and is_active, then list each rule's coupons through GET /rest/V1/coupons to read the API's reported times_used. Independently count the real usage from GET /rest/V1/orders filtered by coupon_code and a non-cancelled state, grouped by coupon_code and by customer_id. A coupon is flagged when the real order count exceeds uses_per_coupon, any customer's real count exceeds uses_per_customer, or the reported times_used is lower than the real count, which is the direct evidence the async consumer under-counted.
Is it safe for a script to automatically cancel or block the extra orders?
No. Those orders already shipped or are being processed, so cancelling or refunding them is a business and legal decision, not something a script should decide on its own. The script only reports the discrepancy, the rule, the coupon, and the offending order increment ids for a human to review. The one safe corrective action, gated behind an explicit --apply flag, is recomputing and correcting the times_used counters to match the true order count and confirming the sales.rule.update.coupon.usage consumer is actually running.
Related field notes
Citations
On the problem:
- GitHub Issue: Cart Price Rules, Uses per Coupon and Uses per Customer not working. github.com/magento/magento2/issues/34065
- GitHub Issue: Magento CE 2.4.3, a coupon set to Uses per Customer = 1 can be used in multiple orders by the same customer. github.com/magento/magento2/issues/35077
- Adobe Commerce Knowledge Base: coupon code tracking discrepancy in Adobe Commerce. experienceleague.adobe.com coupon code tracking discrepancy
On the solution:
- Adobe Commerce: How many coupons can a customer use in Adobe Commerce. experienceleague.adobe.com how many coupons can a customer use
- Adobe Commerce: Coupon codes, cart price rules documentation. experienceleague.adobe.com price rules cart coupon
- Adobe Commerce: ACSD-54966, fix for limited-use coupon code after failed orders. experienceleague.adobe.com ACSD-54966 patch notes
Stuck on a tricky one?
If you have a problem in Magento 2 or Adobe Commerce orders, payments, catalog data, or inventory 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 coupon slipping past its limit?
If this saved you from an unlimited discount code or a customer wrongly blocked by a stale usage row, 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