Reconciler Vouchers & Gift Cards
Voucher usable past its usage limit under concurrency
A voucher has a clear usageLimit, and Saleor's own used counter agrees with it right up until launch day. Then two customers check out within the same second, both orders complete, and the voucher that was supposed to cap out at fifty redemptions quietly clears fifty two. Nobody wrote a bad rule. The count and the check that guards it are just not protected from each other. Here is why that race exists and a script that finds every voucher it has already hit and reports exactly which orders are involved.
Saleor increments a voucher's used counter only after a checkout or order actually completes, not when the code is first validated or applied. The sequence of read used, compare it against usageLimit, then complete the order and write the new used, is not wrapped in an atomic guard against a second checkout doing the same read at nearly the same instant. Under concurrent completion, two checkouts can both see the voucher as still under the limit before either write lands, so both pass validation and both get counted, and used ends up above usageLimit. This is a documented race, saleor/saleor#544, with a related variant in saleor/saleor#8219 where a retried checkoutComplete across a 3DS payment confirmation double counts one redemption. You cannot safely undo a paid order to claw back a voucher use, so the fix here is a small Python or Node.js reconciler that pages every voucher with a limit, cross-checks it against real orders, and reports the true overage and the exact order ids for a human to review. Full code and tests are below.
The problem in plain words
A usage-limited voucher works on a simple promise: once used reaches usageLimit, the code stops working. Saleor checks that promise when a checkout tries to apply the voucher, and it is supposed to update the counter once the order behind that checkout actually completes.
The catch is timing. The check happens first, the increment happens later, and nothing stops a second checkout from running its own check in the gap between those two steps. If two customers complete checkout close enough together, both read the voucher while used still shows room under the limit. Both are allowed through. Both then get counted. The voucher that was only supposed to be used fifty times now shows fifty two, and there was never a moment where Saleor's own logic thought it was doing anything wrong.
Why it happens
- Saleor validates a voucher against
usageLimitwhen a checkout is completed, but it only writes the newusedvalue once that completion actually finishes, so there is a window between the read and the write on every single completion. - The read-then-write sequence is not guarded by row-level locking or an atomic increment across concurrent requests, so two completions racing in that same window can both read the pre-increment value and both proceed, a race documented in saleor/saleor#544.
- A related but distinct cause shows up in saleor/saleor#8219: a 2-stage payment confirmation such as 3DS can cause the client or gateway to retry
checkoutComplete, and if the retry is treated as a fresh completion, the same real redemption gets counted againstusedtwice. - High-traffic moments make this far more likely than it sounds. A limited-time flash sale voucher advertised widely is exactly the shape of load that produces two completions close enough together to both land inside the same race window.
Nothing here throws an error a store owner would notice. The orders complete normally, the customers get their discount, and the only visible symptom is a voucher whose used count no longer agrees with its own usageLimit, discovered later when someone asks why a fifty-redemption code apparently redeemed fifty two times.
Saleor's own used field can already be the inflated, racy number, so you cannot trust it alone as ground truth. The only reliable check is to count real orders that actually reference the voucher, non-draft and non-canceled, and compare that count against usageLimit directly. When the true order count exceeds the limit, or when Saleor's used disagrees with the limit it is supposed to enforce, that is the signal to flag, not to silently trust whichever number is highest.
The fix, as a flow
The script never rolls back a completed order on its own, since deciding which of two conflicting orders to cancel or refund is a business call only a human should make. It pages every voucher that has a usageLimit, pages the orders that reference each one, counts the real non-canceled redemptions, and compares that against the limit and against Saleor's own used. Anything over the limit becomes a report with the voucher, both counts, and every affected order id. Only when a human explicitly turns off dry run does it take the one safe automated action available, stopping further redemptions by moving the voucher's endDate to now, and it still leaves the affected orders for manual review.
Build it step by step
Get an app token with read access to vouchers and orders
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read discounts and orders, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" # start safe, this script never writes without it off
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
import os, requests
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
Page every voucher that has a usage limit
Ask for vouchers with a cursor and read back the fields the decision needs: the id, code, usageLimit, and Saleor's own used count. Skip anything without a usageLimit, since there is no cap to violate.
VOUCHERS_QUERY = """
query($cursor: String) {
vouchers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node { id code usageLimit used singleUse applyOncePerCustomer }
}
}
}"""
def limited_vouchers():
cursor = None
while True:
data = gql(VOUCHERS_QUERY, {"cursor": cursor})["vouchers"]
for edge in data["edges"]:
node = edge["node"]
if node.get("usageLimit") is not None:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const VOUCHERS_QUERY = `
query($cursor: String) {
vouchers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node { id code usageLimit used singleUse applyOncePerCustomer }
}
}
}`;
async function* limitedVouchers() {
let cursor = null;
while (true) {
const data = (await gql(VOUCHERS_QUERY, { cursor })).vouchers;
for (const edge of data.edges) {
if (edge.node.usageLimit !== null && edge.node.usageLimit !== undefined) {
yield edge.node;
}
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Page the real orders that reference the voucher
For each flagged voucher, page orders filtered to that voucher's code and read back the id and status. This is the ground truth, independent of whatever used claims, because it counts orders that genuinely exist rather than trusting a counter that can already be racy.
ORDERS_BY_VOUCHER_QUERY = """
query($voucherCode: String!, $cursor: String) {
orders(first: 50, after: $cursor, filter: { voucherCode: $voucherCode }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucher { id } voucherCode status }
}
}
}"""
def orders_for_voucher(voucher_code):
cursor = None
while True:
data = gql(ORDERS_BY_VOUCHER_QUERY, {"voucherCode": voucher_code, "cursor": cursor})["orders"]
for edge in data["edges"]:
node = edge["node"]
yield {
"id": node["id"],
"voucherId": (node.get("voucher") or {}).get("id"),
"status": node["status"],
}
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_BY_VOUCHER_QUERY = `
query($voucherCode: String!, $cursor: String) {
orders(first: 50, after: $cursor, filter: { voucherCode: $voucherCode }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucher { id } voucherCode status }
}
}
}`;
async function* ordersForVoucher(voucherCode) {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_BY_VOUCHER_QUERY, { voucherCode, cursor })).orders;
for (const edge of data.edges) {
const node = edge.node;
yield {
id: node.id,
voucherId: node.voucher?.id ?? null,
status: node.status,
};
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the voucher and the orders that reference it, filters out drafts and cancellations, and returns an overage report only when the true redemption count actually exceeds usageLimit, or when used already disagrees with the limit. No I/O, so it is easy to test with plain objects and arrays.
EXCLUDED_STATUSES = {"DRAFT", "CANCELED"}
def detect_voucher_overage(voucher, orders):
usage_limit = voucher.get("usageLimit")
if usage_limit is None:
return None
counted = [
o for o in orders
if o.get("voucherId") == voucher.get("id") and o.get("status") not in EXCLUDED_STATUSES
]
actual_redemptions = len(counted)
overage_count = max(0, actual_redemptions - usage_limit)
used = voucher.get("used", 0)
if overage_count == 0 and used <= usage_limit:
return None
return {
"voucherId": voucher["id"],
"overageCount": overage_count,
"actualRedemptions": actual_redemptions,
"affectedOrderIds": [o["id"] for o in counted],
}
const EXCLUDED_STATUSES = new Set(["DRAFT", "CANCELED"]);
export function detectVoucherOverage(voucher, orders) {
const usageLimit = voucher.usageLimit;
if (usageLimit === null || usageLimit === undefined) return null;
const counted = orders.filter(
(o) => o.voucherId === voucher.id && !EXCLUDED_STATUSES.has(o.status)
);
const actualRedemptions = counted.length;
const overageCount = Math.max(0, actualRedemptions - usageLimit);
const used = voucher.used ?? 0;
if (overageCount === 0 && used <= usageLimit) return null;
return {
voucherId: voucher.id,
overageCount,
actualRedemptions,
affectedOrderIds: counted.map((o) => o.id),
};
}
Report, and only stop new redemptions when dry run is off
Under DRY_RUN=true, the default, the script only logs each overage: the voucher id, code, usageLimit, used, the true actualRedemptions, and every affected order id. It never cancels or refunds an order itself, since that is a business decision. When DRY_RUN=false, the one safe automated repair is to stop the voucher from being redeemed again by setting its endDate to now with voucherUpdate, and the affected order ids still go into the report for staff to resolve by hand.
Never let a script cancel, refund, or re-charge a completed order to "fix" a voucher count. That is exactly the kind of decision that needs a human looking at both orders. Report the overage, stop the voucher from accepting new redemptions if authorized, and leave the order-level call to staff.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages vouchers and their real orders, applies the pure decision function, logs every overage it finds, and only stops further redemptions on a voucher when a human turns off dry run.
"""Find Saleor vouchers whose used count climbed past their usageLimit
under concurrent checkout completion.
Saleor increments a voucher's used counter only after checkout or order
completion, and the check that used is still below usageLimit is not
atomically guarded against a second completion doing the same read at
nearly the same instant. Under concurrent completion, two checkouts can
both pass the check before either write lands, pushing used above
usageLimit (saleor/saleor#544). A retried checkoutComplete across a 3DS
payment confirmation can also double count one redemption
(saleor/saleor#8219).
Rolling back a completed, paid order to unwind an over-redeemed voucher is
a business decision, so this script never cancels or refunds an order on
its own. Under DRY_RUN=true (the default) it only reports every overage:
the voucher, both counts, and the affected order ids. When DRY_RUN=false
the only automated repair is to stop new redemptions by setting the
voucher's endDate to now. Safe to run again and again.
"""
import os
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_voucher_overage")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EXCLUDED_STATUSES = {"DRAFT", "CANCELED"}
VOUCHERS_QUERY = """
query($cursor: String) {
vouchers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node { id code usageLimit used singleUse applyOncePerCustomer }
}
}
}"""
ORDERS_BY_VOUCHER_QUERY = """
query($voucherCode: String!, $cursor: String) {
orders(first: 50, after: $cursor, filter: { voucherCode: $voucherCode }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucher { id } voucherCode status }
}
}
}"""
STOP_VOUCHER_MUTATION = """
mutation($id: ID!, $endDate: DateTime!) {
voucherUpdate(id: $id, input: { endDate: $endDate }) {
voucher { id endDate }
errors { field message code }
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def detect_voucher_overage(voucher, orders):
usage_limit = voucher.get("usageLimit")
if usage_limit is None:
return None
counted = [
o for o in orders
if o.get("voucherId") == voucher.get("id") and o.get("status") not in EXCLUDED_STATUSES
]
actual_redemptions = len(counted)
overage_count = max(0, actual_redemptions - usage_limit)
used = voucher.get("used", 0)
if overage_count == 0 and used <= usage_limit:
return None
return {
"voucherId": voucher["id"],
"overageCount": overage_count,
"actualRedemptions": actual_redemptions,
"affectedOrderIds": [o["id"] for o in counted],
}
def limited_vouchers():
cursor = None
while True:
data = gql(VOUCHERS_QUERY, {"cursor": cursor})["vouchers"]
for edge in data["edges"]:
node = edge["node"]
if node.get("usageLimit") is not None:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def orders_for_voucher(voucher_code):
cursor = None
while True:
data = gql(ORDERS_BY_VOUCHER_QUERY, {"voucherCode": voucher_code, "cursor": cursor})["orders"]
for edge in data["edges"]:
node = edge["node"]
yield {
"id": node["id"],
"voucherId": (node.get("voucher") or {}).get("id"),
"status": node["status"],
}
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def stop_further_redemptions(voucher_id):
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
result = gql(STOP_VOUCHER_MUTATION, {"id": voucher_id, "endDate": now_iso})["voucherUpdate"]
if result["errors"]:
raise RuntimeError(result["errors"])
def run():
reports = []
for voucher in limited_vouchers():
orders = list(orders_for_voucher(voucher["code"]))
report = detect_voucher_overage(voucher, orders)
if report is None:
continue
log.warning(
"Overage: voucher=%s code=%s usageLimit=%s used=%s actualRedemptions=%s overageCount=%s orders=%s",
report["voucherId"], voucher["code"], voucher["usageLimit"], voucher["used"],
report["actualRedemptions"], report["overageCount"], report["affectedOrderIds"],
)
reports.append(report)
if not DRY_RUN:
log.info("Stopping further redemptions on voucher %s (%s).", report["voucherId"], voucher["code"])
stop_further_redemptions(report["voucherId"])
log.info(
"Done. %d voucher(s) over their usage limit%s.",
len(reports), "" if DRY_RUN else ", further redemptions stopped",
)
return reports
if __name__ == "__main__":
run()
/**
* Find Saleor vouchers whose used count climbed past their usageLimit
* under concurrent checkout completion.
*
* Saleor increments a voucher's used counter only after checkout or order
* completion, and the check that used is still below usageLimit is not
* atomically guarded against a second completion doing the same read at
* nearly the same instant. Under concurrent completion, two checkouts can
* both pass the check before either write lands, pushing used above
* usageLimit (saleor/saleor#544). A retried checkoutComplete across a 3DS
* payment confirmation can also double count one redemption
* (saleor/saleor#8219).
*
* Rolling back a completed, paid order to unwind an over-redeemed voucher
* is a business decision, so this script never cancels or refunds an
* order on its own. Under DRY_RUN=true (the default) it only reports every
* overage: the voucher, both counts, and the affected order ids. When
* DRY_RUN=false the only automated repair is to stop new redemptions by
* setting the voucher's endDate to now. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/saleor/voucher-usable-past-usage-limit/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EXCLUDED_STATUSES = new Set(["DRAFT", "CANCELED"]);
export function detectVoucherOverage(voucher, orders) {
const usageLimit = voucher.usageLimit;
if (usageLimit === null || usageLimit === undefined) return null;
const counted = orders.filter(
(o) => o.voucherId === voucher.id && !EXCLUDED_STATUSES.has(o.status)
);
const actualRedemptions = counted.length;
const overageCount = Math.max(0, actualRedemptions - usageLimit);
const used = voucher.used ?? 0;
if (overageCount === 0 && used <= usageLimit) return null;
return {
voucherId: voucher.id,
overageCount,
actualRedemptions,
affectedOrderIds: counted.map((o) => o.id),
};
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const VOUCHERS_QUERY = `
query($cursor: String) {
vouchers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node { id code usageLimit used singleUse applyOncePerCustomer }
}
}
}`;
const ORDERS_BY_VOUCHER_QUERY = `
query($voucherCode: String!, $cursor: String) {
orders(first: 50, after: $cursor, filter: { voucherCode: $voucherCode }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucher { id } voucherCode status }
}
}
}`;
const STOP_VOUCHER_MUTATION = `
mutation($id: ID!, $endDate: DateTime!) {
voucherUpdate(id: $id, input: { endDate: $endDate }) {
voucher { id endDate }
errors { field message code }
}
}`;
async function* limitedVouchers() {
let cursor = null;
while (true) {
const data = (await gql(VOUCHERS_QUERY, { cursor })).vouchers;
for (const edge of data.edges) {
if (edge.node.usageLimit !== null && edge.node.usageLimit !== undefined) {
yield edge.node;
}
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function* ordersForVoucher(voucherCode) {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_BY_VOUCHER_QUERY, { voucherCode, cursor })).orders;
for (const edge of data.edges) {
const node = edge.node;
yield {
id: node.id,
voucherId: node.voucher?.id ?? null,
status: node.status,
};
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function stopFurtherRedemptions(voucherId) {
const nowIso = new Date().toISOString();
const result = (await gql(STOP_VOUCHER_MUTATION, { id: voucherId, endDate: nowIso })).voucherUpdate;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}
export async function run() {
const reports = [];
for await (const voucher of limitedVouchers()) {
const orders = [];
for await (const order of ordersForVoucher(voucher.code)) orders.push(order);
const report = detectVoucherOverage(voucher, orders);
if (!report) continue;
console.warn(
`Overage: voucher=${report.voucherId} code=${voucher.code} usageLimit=${voucher.usageLimit} used=${voucher.used} actualRedemptions=${report.actualRedemptions} overageCount=${report.overageCount} orders=${JSON.stringify(report.affectedOrderIds)}`
);
reports.push(report);
if (!DRY_RUN) {
console.log(`Stopping further redemptions on voucher ${report.voucherId} (${voucher.code}).`);
await stopFurtherRedemptions(report.voucherId);
}
}
console.log(
`Done. ${reports.length} voucher(s) over their usage limit${DRY_RUN ? "" : ", further redemptions stopped"}.`
);
return reports;
}
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 and which order ids go to staff. Because detect_voucher_overage is pure, the test needs no network and no Saleor account. It just feeds in plain objects and checks the answer.
from detect_voucher_overage import detect_voucher_overage
V1 = "gid://saleor/Voucher/1"
def voucher(**over):
base = {"id": V1, "code": "SALE50", "usageLimit": 50, "used": 50}
base.update(over)
return base
def order(order_id, voucher_id=V1, status="FULFILLED"):
return {"id": order_id, "voucherId": voucher_id, "status": status}
def test_no_limit_returns_none():
v = voucher(usageLimit=None)
assert detect_voucher_overage(v, []) is None
def test_under_limit_returns_none():
orders = [order(f"o{i}") for i in range(10)]
v = voucher(usageLimit=50, used=10)
assert detect_voucher_overage(v, orders) is None
def test_exactly_at_limit_returns_none():
orders = [order(f"o{i}") for i in range(50)]
v = voucher(usageLimit=50, used=50)
assert detect_voucher_overage(v, orders) is None
def test_one_order_over_limit_is_flagged():
orders = [order(f"o{i}") for i in range(51)]
v = voucher(usageLimit=50, used=51)
result = detect_voucher_overage(v, orders)
assert result["overageCount"] == 1
assert result["actualRedemptions"] == 51
assert len(result["affectedOrderIds"]) == 51
def test_retried_payment_double_count_flagged_even_if_orders_match_limit():
# used is already inflated by a retried checkoutComplete, but the real
# order count is still at the limit. Flag on Saleor's own used disagreeing.
orders = [order(f"o{i}") for i in range(50)]
v = voucher(usageLimit=50, used=52)
result = detect_voucher_overage(v, orders)
assert result is not None
assert result["overageCount"] == 0
assert result["actualRedemptions"] == 50
def test_canceled_orders_excluded_from_count():
orders = [order(f"o{i}") for i in range(50)] + [
order("o-canceled-1", status="CANCELED"),
order("o-canceled-2", status="CANCELED"),
order("o-draft-1", status="DRAFT"),
]
v = voucher(usageLimit=50, used=50)
assert detect_voucher_overage(v, orders) is None
def test_orders_for_other_vouchers_are_ignored():
orders = [order(f"o{i}") for i in range(30)] + [
order("other-1", voucher_id="gid://saleor/Voucher/999"),
order("other-2", voucher_id="gid://saleor/Voucher/999"),
]
v = voucher(usageLimit=50, used=30)
assert detect_voucher_overage(v, orders) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectVoucherOverage } from "./detect-voucher-overage.js";
const V1 = "gid://saleor/Voucher/1";
const voucher = (over = {}) => ({ id: V1, code: "SALE50", usageLimit: 50, used: 50, ...over });
const order = (id, over = {}) => ({ id, voucherId: V1, status: "FULFILLED", ...over });
test("no limit returns null", () => {
assert.equal(detectVoucherOverage(voucher({ usageLimit: null }), []), null);
});
test("under limit returns null", () => {
const orders = Array.from({ length: 10 }, (_, i) => order(`o${i}`));
assert.equal(detectVoucherOverage(voucher({ usageLimit: 50, used: 10 }), orders), null);
});
test("exactly at limit returns null", () => {
const orders = Array.from({ length: 50 }, (_, i) => order(`o${i}`));
assert.equal(detectVoucherOverage(voucher({ usageLimit: 50, used: 50 }), orders), null);
});
test("one order over limit is flagged", () => {
const orders = Array.from({ length: 51 }, (_, i) => order(`o${i}`));
const result = detectVoucherOverage(voucher({ usageLimit: 50, used: 51 }), orders);
assert.equal(result.overageCount, 1);
assert.equal(result.actualRedemptions, 51);
assert.equal(result.affectedOrderIds.length, 51);
});
test("retried payment double count flagged even if orders match limit", () => {
const orders = Array.from({ length: 50 }, (_, i) => order(`o${i}`));
const result = detectVoucherOverage(voucher({ usageLimit: 50, used: 52 }), orders);
assert.notEqual(result, null);
assert.equal(result.overageCount, 0);
assert.equal(result.actualRedemptions, 50);
});
test("canceled orders excluded from count", () => {
const orders = [
...Array.from({ length: 50 }, (_, i) => order(`o${i}`)),
order("o-canceled-1", { status: "CANCELED" }),
order("o-canceled-2", { status: "CANCELED" }),
order("o-draft-1", { status: "DRAFT" }),
];
assert.equal(detectVoucherOverage(voucher({ usageLimit: 50, used: 50 }), orders), null);
});
test("orders for other vouchers are ignored", () => {
const orders = [
...Array.from({ length: 30 }, (_, i) => order(`o${i}`)),
order("other-1", { voucherId: "gid://saleor/Voucher/999" }),
order("other-2", { voucherId: "gid://saleor/Voucher/999" }),
];
assert.equal(detectVoucherOverage(voucher({ usageLimit: 50, used: 30 }), orders), null);
});
Case studies
A fifty-code voucher cleared fifty two
A skincare brand advertised a code good for the first fifty checkouts of a flash sale, with usageLimit set to 50. Traffic spiked the moment the email went out, and dozens of customers completed checkout within seconds of each other. When the sale ended, used read 52, and finance wanted to know which two orders should not have gotten the discount.
The reconciler paged the voucher, pulled every order that actually referenced it, and confirmed 52 real, non-canceled orders against a limit of 50. It reported the two extra order ids so support could decide, order by order, whether to honor the discount as a goodwill cost or reach out to the customers, rather than guessing from the counter alone.
One customer, one order, used counted twice
A single customer's card required a 3DS confirmation step. Their bank's app was slow, the checkout flow retried checkoutComplete once the confirmation finally landed, and the voucher's used counter went up by two for what was, in the end, one completed order.
Real order data showed only one non-canceled order referencing the voucher, so actualRedemptions stayed at the true count while used disagreed with it. The script flagged the mismatch between Saleor's own counter and the limit, which let the team catch the double count without ever touching the customer's single, legitimate order.
After this runs on a schedule, a voucher that slipped past its usage limit gets caught with the real numbers next to it: the limit, Saleor's own used, and the true count from actual orders. Staff get the exact order ids to review instead of a vague "something is off," and the voucher itself stops accepting new redemptions the moment it is authorized, without a script ever deciding on its own which paid order should be undone.
FAQ
Why did a Saleor voucher get used more times than its usageLimit?
Saleor only bumps a voucher's used counter after checkout or order completion, and the check that used is still below usageLimit happens before that write lands. When two checkouts complete at nearly the same moment, both can read the voucher as still under the limit before either increment is saved, so both pass and both get counted, pushing used above usageLimit.
Can a retried checkoutComplete call during 3DS double count a voucher?
Yes. A two-stage payment confirmation can cause checkoutComplete to be called again after the first attempt already incremented the voucher, which counts one real redemption twice against usageLimit. This inflates used without a second genuine order in some flows, and it is a separate but related cause from two different checkouts racing each other.
Is it safe to automatically cancel an order to fix a voucher overage?
No. Cancelling or refunding a completed, paid order to unwind an over-redeemed voucher is a business decision, not something a script should decide on its own. The safe automated step is to report the overage and stop further redemptions, for example by moving the voucher's endDate to now, then hand the affected order ids to staff for manual resolution.
Related field notes
Citations
On the problem:
- Vouchers can be used past their usage limit under concurrent access. github.com/saleor/saleor/issues/544
- Voucher code will be used multiple times. github.com/saleor/saleor/issues/8219
- Saleor Commerce Documentation: Vouchers. docs.saleor.io/developer/discounts/vouchers
On the solution:
- Saleor Commerce Documentation: Voucher Object, including usageLimit, used, code, and singleUse. docs.saleor.io/api-reference/discounts/objects/voucher
- Saleor Commerce Documentation: checkoutComplete Mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-complete
- Saleor Commerce Documentation: vouchers Query. docs.saleor.io/api-reference/discounts/queries/vouchers
Stuck on a tricky one?
If you have a problem in Saleor checkout, discounts, orders, or fulfillment 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 overage for you?
If this saved you from a confusing revenue report or a vague voucher complaint, 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