Reconciler Vouchers & Gift Cards
Voucher usage count double incremented on payment retries
One order, one voucher code, and yet the usage counter climbed by two. Nobody applied the code twice, the customer only checked out once, but a two-stage payment gateway made the checkout call complete twice behind the scenes, and Saleor counted both. Here is why that double count happens and a script that finds every code where it did.
Saleor increments voucher usage synchronously as part of the checkoutComplete mutation. That is fine for a single-stage payment. But with a two-stage, "action required" gateway, the same checkout is completed by calling checkoutComplete twice: once when the gateway returns confirmationNeeded or asks for additional customer action, and again after the customer confirms and the payment is actually captured. The increment was tied to the completion call itself, not to a confirmed final payment state, so both calls bump the counter for the same logical order. VoucherCode.used ends up permanently inflated relative to the orders that actually consumed the code (see saleor/saleor#8219). Run a small Python or Node.js script that reads the stored counter per code, recomputes the real usage from paid orders, and reports every code where the two disagree. Full code, tests, and a dry run guard are below.
The problem in plain words
Applying a voucher and completing a checkout feels like one action to the customer, so it is easy to assume Saleor treats it as one write. It does not, always. Voucher usage goes up inside checkoutComplete, and for most payment methods that mutation only ever gets called once per checkout: the payment settles synchronously, the checkout turns into an order, done.
Two-stage gateways break that assumption. Some payment methods cannot finish in one round trip. The gateway comes back with confirmationNeeded, or the checkout's response says additional action is required, such as a 3D Secure redirect or an app confirmation step. The storefront has to call checkoutComplete a second time once the customer has confirmed, so that Saleor can finish converting the checkout into an order. Both of those calls run through the same completion code path, and that path increments voucher usage every time it runs, not just on the call that actually results in a captured payment. The same checkout, the same voucher code, and the same order get counted twice.
Why it happens
- Voucher usage is incremented synchronously as part of completing the checkout, inside
checkoutCompleteitself, rather than being gated on a confirmed, final payment state. - Two-stage "action required" gateways cannot finish a payment in a single call. The first
checkoutCompletecall returnsconfirmationNeededor reports that additional customer action is required, such as a 3D Secure redirect. - Once the customer completes that action, the storefront calls
checkoutCompletea second time for the exact same checkout so Saleor can finish turning it into an order. - Both calls run the same completion logic, so both increment
VoucherandVoucherCode.used, even though only one order, and one real use of the code, ever exists.
Nothing about this raises an error. The order looks completely normal, correctly paid, with the voucher discount correctly applied once. The only visible symptom is a usage counter that quietly runs ahead of reality, which becomes a real problem once a voucher has a usageLimit: codes hit their limit and get rejected for new customers well before as many orders as the limit implies have actually used them. See the citations at the end for the exact GitHub thread and the relevant object docs.
The stored counter on VoucherCode.used is not the source of truth here, the orders are. You cannot tell from the counter alone which codes were double counted, but you can always recompute the truth by counting orders that actually reference a voucher code and reached a paid or confirmed state. Comparing the stored counter against that recomputed count turns an invisible drift into a simple diff: any code where stored is higher than real is a candidate for this exact bug.
The fix, as a flow
The script pulls the stored usage counter for each voucher code, then pages through orders filtered by that code, keeping only the ones that reached a real, completed state. A single pure function compares stored usage against the recomputed real usage and decides whether a correction is needed. When it is, the script only reports the discrepancy by default. It never writes the counter directly, because Saleor has no public mutation for that.
Build it step by step
Get an app token with order and discount read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and discounts. Use the resulting app token as a Bearer token, 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 the counter, only reports
// 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 the counter, only reports
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;
}
Read the stored usage counter for every voucher code
Ask voucher(id: $id) { codes(first: 100) { edges { node { id code used } } } usageLimit } to get the stored used value per code. On pre-code-split Saleor versions this lives directly on voucher.used, but the code-level counter is what this reconciler targets.
VOUCHER_QUERY = """
query($id: ID!) {
voucher(id: $id) {
id
usageLimit
codes(first: 100) {
edges { node { id code used } }
}
}
}"""
def voucher_codes(voucher_id):
data = gql(VOUCHER_QUERY, {"id": voucher_id})["voucher"]
return [edge["node"] for edge in data["codes"]["edges"]]
const VOUCHER_QUERY = `
query($id: ID!) {
voucher(id: $id) {
id
usageLimit
codes(first: 100) {
edges { node { id code used } }
}
}
}`;
async function voucherCodes(voucherId) {
const data = (await gql(VOUCHER_QUERY, { id: voucherId })).voucher;
return data.codes.edges.map((edge) => edge.node);
}
Page through the orders that qualify for each code
Ask orders(first, after, filter: { voucherCode: $code }) and read back id number created voucherCode status isPaid paymentStatus. Only orders that reached an actually completed state count toward real usage, so an abandoned or expired checkout must never be counted.
ORDERS_FOR_CODE_QUERY = """
query($code: String!, $cursor: String) {
orders(first: 100, after: $cursor, filter: { voucherCode: $code }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucherCode status isPaid paymentStatus }
}
}
}"""
def orders_for_code(code):
cursor = None
while True:
data = gql(ORDERS_FOR_CODE_QUERY, {"code": code, "cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_FOR_CODE_QUERY = `
query($code: String!, $cursor: String) {
orders(first: 100, after: $cursor, filter: { voucherCode: $code }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucherCode status isPaid paymentStatus }
}
}
}`;
async function* ordersForCode(code) {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_FOR_CODE_QUERY, { code, cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the code's stored usage and the list of qualifying orders, and returns an action. Real usage counts only orders that are paid or that reached FULFILLED, PARTIALLY_FULFILLED, or UNFULFILLED, since those are orders that actually completed. If stored usage is at or below real usage there is nothing to do. If stored usage is higher, the code was overcounted by the delta, and the corrected value is the real usage.
COMPLETED_STATUSES = {"FULFILLED", "PARTIALLY_FULFILLED", "UNFULFILLED"}
def decide_voucher_usage_correction(code, qualifying_orders):
real_usage = sum(
1 for o in qualifying_orders
if o.get("isPaid") or o.get("status") in COMPLETED_STATUSES
)
stored_used = code["storedUsed"]
if stored_used <= real_usage:
return {"action": "none", "correctedUsed": stored_used, "delta": 0}
return {
"action": "decrement",
"correctedUsed": real_usage,
"delta": stored_used - real_usage,
}
const COMPLETED_STATUSES = new Set(["FULFILLED", "PARTIALLY_FULFILLED", "UNFULFILLED"]);
export function decideVoucherUsageCorrection(code, qualifyingOrders) {
const realUsage = qualifyingOrders.filter(
(o) => o.isPaid || COMPLETED_STATUSES.has(o.status)
).length;
const storedUsed = code.storedUsed;
if (storedUsed <= realUsage) {
return { action: "none", correctedUsed: storedUsed, delta: 0 };
}
return {
action: "decrement",
correctedUsed: realUsage,
delta: storedUsed - realUsage,
};
}
Report the overcount, do not auto-write the counter
When a code needs a correction, log {code, storedUsed, realUsage, delta}. Saleor does not expose a public voucherCodeUsageSet mutation, so writing the counter directly needs a custom app with database access using the internal usage-adjustment helper, which is not part of the public GraphQL schema. The safe, documented lever for everyone else is to hand this report to staff for a manual correction in the dashboard.
This script's default behavior is report-only, and it should stay that way for almost every store. There is no safe public mutation to decrement VoucherCode.used directly, so treat the output as a staff correction list, not something to feed into an auto-writer, unless you have built and tested your own app-level correction mutation and are gating it behind an explicit --confirm flag with DRY_RUN=false.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks every voucher code, recomputes real usage from qualifying orders, and reports every code where the stored counter overcounts. The default run never writes to Saleor at all, since flagging is the safe behavior for this issue.
"""Find Saleor voucher codes whose stored usage counter was double
incremented by a two-stage payment gateway calling checkoutComplete twice
for the same checkout, once for confirmationNeeded and once after the
customer confirms (see saleor/saleor#8219, the Voucher and VoucherCode
object docs, and the orders query docs).
This script never writes the usage counter. Saleor has no public
voucherCodeUsageSet mutation, so under DRY_RUN=true (the default, and the
only mode this script supports out of the box) it logs a report entry
for every overcounted code: {code, storedUsed, realUsage, delta}. Hand
that report to staff for a manual correction in the dashboard, or wire in
your own app-level correction mutation if you have built one. Run on a
schedule. 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("reconcile_voucher_usage")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
VOUCHER_ID = os.environ.get("SALEOR_VOUCHER_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
COMPLETED_STATUSES = {"FULFILLED", "PARTIALLY_FULFILLED", "UNFULFILLED"}
VOUCHER_QUERY = """
query($id: ID!) {
voucher(id: $id) {
id
usageLimit
codes(first: 100) {
edges { node { id code used } }
}
}
}"""
ORDERS_FOR_CODE_QUERY = """
query($code: String!, $cursor: String) {
orders(first: 100, after: $cursor, filter: { voucherCode: $code }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucherCode status isPaid paymentStatus }
}
}
}"""
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 decide_voucher_usage_correction(code, qualifying_orders):
real_usage = sum(
1 for o in qualifying_orders
if o.get("isPaid") or o.get("status") in COMPLETED_STATUSES
)
stored_used = code["storedUsed"]
if stored_used <= real_usage:
return {"action": "none", "correctedUsed": stored_used, "delta": 0}
return {
"action": "decrement",
"correctedUsed": real_usage,
"delta": stored_used - real_usage,
}
def voucher_codes(voucher_id):
data = gql(VOUCHER_QUERY, {"id": voucher_id})["voucher"]
return [
{"id": node["id"], "code": node["code"], "storedUsed": node["used"]}
for node in (edge["node"] for edge in data["codes"]["edges"])
]
def orders_for_code(code):
cursor = None
while True:
data = gql(ORDERS_FOR_CODE_QUERY, {"code": code, "cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
if not VOUCHER_ID:
raise SystemExit("Set SALEOR_VOUCHER_ID to the voucher you want to reconcile.")
flagged = 0
for code in voucher_codes(VOUCHER_ID):
qualifying_orders = list(orders_for_code(code["code"]))
decision = decide_voucher_usage_correction(code, qualifying_orders)
if decision["action"] == "none":
continue
report_entry = {
"code": code["code"],
"storedUsed": code["storedUsed"],
"realUsage": decision["correctedUsed"],
"delta": decision["delta"],
}
log.warning("Overcounted voucher code found. %s %s", report_entry,
"(dry run, reporting only)" if DRY_RUN else "(reporting only, no public write mutation)")
flagged += 1
log.info("Done. %d voucher code(s) flagged for staff correction.", flagged)
if __name__ == "__main__":
run()
/**
* Find Saleor voucher codes whose stored usage counter was double
* incremented by a two-stage payment gateway calling checkoutComplete twice
* for the same checkout, once for confirmationNeeded and once after the
* customer confirms (see saleor/saleor#8219, the Voucher and VoucherCode
* object docs, and the orders query docs).
*
* This script never writes the usage counter. Saleor has no public
* voucherCodeUsageSet mutation, so under DRY_RUN=true (the default, and the
* only mode this script supports out of the box) it logs a report entry
* for every overcounted code: {code, storedUsed, realUsage, delta}. Hand
* that report to staff for a manual correction in the dashboard, or wire in
* your own app-level correction mutation if you have built one. Run on a
* schedule.
*
* Guide: https://www.allanninal.dev/saleor/voucher-usage-double-incremented/
*/
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 VOUCHER_ID = process.env.SALEOR_VOUCHER_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const COMPLETED_STATUSES = new Set(["FULFILLED", "PARTIALLY_FULFILLED", "UNFULFILLED"]);
export function decideVoucherUsageCorrection(code, qualifyingOrders) {
const realUsage = qualifyingOrders.filter(
(o) => o.isPaid || COMPLETED_STATUSES.has(o.status)
).length;
const storedUsed = code.storedUsed;
if (storedUsed <= realUsage) {
return { action: "none", correctedUsed: storedUsed, delta: 0 };
}
return {
action: "decrement",
correctedUsed: realUsage,
delta: storedUsed - realUsage,
};
}
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 VOUCHER_QUERY = `
query($id: ID!) {
voucher(id: $id) {
id
usageLimit
codes(first: 100) {
edges { node { id code used } }
}
}
}`;
const ORDERS_FOR_CODE_QUERY = `
query($code: String!, $cursor: String) {
orders(first: 100, after: $cursor, filter: { voucherCode: $code }) {
pageInfo { hasNextPage endCursor }
edges {
node { id number created voucherCode status isPaid paymentStatus }
}
}
}`;
async function voucherCodes(voucherId) {
const data = (await gql(VOUCHER_QUERY, { id: voucherId })).voucher;
return data.codes.edges.map((edge) => ({
id: edge.node.id,
code: edge.node.code,
storedUsed: edge.node.used,
}));
}
async function* ordersForCode(code) {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_FOR_CODE_QUERY, { code, cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
export async function run() {
if (!VOUCHER_ID) {
throw new Error("Set SALEOR_VOUCHER_ID to the voucher you want to reconcile.");
}
let flagged = 0;
for (const code of await voucherCodes(VOUCHER_ID)) {
const qualifyingOrders = [];
for await (const order of ordersForCode(code.code)) qualifyingOrders.push(order);
const decision = decideVoucherUsageCorrection(code, qualifyingOrders);
if (decision.action === "none") continue;
const reportEntry = {
code: code.code,
storedUsed: code.storedUsed,
realUsage: decision.correctedUsed,
delta: decision.delta,
};
console.warn(
"Overcounted voucher code found.",
reportEntry,
DRY_RUN ? "(dry run, reporting only)" : "(reporting only, no public write mutation)"
);
flagged++;
}
console.log(`Done. ${flagged} voucher code(s) flagged for staff correction.`);
}
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 codes get reported as overcounted. Because decide_voucher_usage_correction is pure, taking a code and a plain list of orders instead of hitting the API, the test needs no network and no Saleor account. It just feeds in fixture arrays and checks the answer.
from reconcile_voucher_usage import decide_voucher_usage_correction
def code(**over):
base = {"id": "Vm91Y2hlckNvZGU6MQ==", "code": "SAVE10", "storedUsed": 2}
base.update(over)
return base
def order(**over):
base = {"id": "T3JkZXI6MQ==", "status": "UNFULFILLED", "isPaid": True}
base.update(over)
return base
def test_decrement_when_double_incremented_by_retry():
result = decide_voucher_usage_correction(code(storedUsed=2), [order()])
assert result == {"action": "decrement", "correctedUsed": 1, "delta": 1}
def test_none_when_stored_matches_real_usage():
result = decide_voucher_usage_correction(code(storedUsed=1), [order()])
assert result == {"action": "none", "correctedUsed": 1, "delta": 0}
def test_none_when_stored_is_undercount():
# out of scope for this repair, do not touch an undercount
result = decide_voucher_usage_correction(code(storedUsed=1), [order(), order(id="T3JkZXI6Mg==")])
assert result == {"action": "none", "correctedUsed": 1, "delta": 0}
def test_cancelled_orders_do_not_count_toward_real_usage():
orders = [
order(),
order(id="T3JkZXI6Mg==", status="CANCELED", isPaid=False),
]
result = decide_voucher_usage_correction(code(storedUsed=2), orders)
assert result == {"action": "decrement", "correctedUsed": 1, "delta": 1}
def test_paid_but_status_not_yet_completed_still_counts():
orders = [order(status="UNCONFIRMED", isPaid=True)]
result = decide_voucher_usage_correction(code(storedUsed=2), orders)
assert result == {"action": "decrement", "correctedUsed": 1, "delta": 1}
def test_zero_qualifying_orders_flags_full_stored_amount_as_delta():
result = decide_voucher_usage_correction(code(storedUsed=3), [])
assert result == {"action": "decrement", "correctedUsed": 0, "delta": 3}
def test_partially_fulfilled_counts_as_real_usage():
orders = [order(status="PARTIALLY_FULFILLED", isPaid=False)]
result = decide_voucher_usage_correction(code(storedUsed=1), orders)
assert result == {"action": "none", "correctedUsed": 1, "delta": 0}
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideVoucherUsageCorrection } from "./reconcile-voucher-usage.js";
const code = (over = {}) => ({ id: "Vm91Y2hlckNvZGU6MQ==", code: "SAVE10", storedUsed: 2, ...over });
const order = (over = {}) => ({ id: "T3JkZXI6MQ==", status: "UNFULFILLED", isPaid: true, ...over });
test("decrement when double incremented by a payment retry", () => {
const result = decideVoucherUsageCorrection(code({ storedUsed: 2 }), [order()]);
assert.deepEqual(result, { action: "decrement", correctedUsed: 1, delta: 1 });
});
test("none when stored matches real usage", () => {
const result = decideVoucherUsageCorrection(code({ storedUsed: 1 }), [order()]);
assert.deepEqual(result, { action: "none", correctedUsed: 1, delta: 0 });
});
test("none when stored is an undercount (out of scope)", () => {
const orders = [order(), order({ id: "T3JkZXI6Mg==" })];
const result = decideVoucherUsageCorrection(code({ storedUsed: 1 }), orders);
assert.deepEqual(result, { action: "none", correctedUsed: 1, delta: 0 });
});
test("cancelled orders do not count toward real usage", () => {
const orders = [order(), order({ id: "T3JkZXI6Mg==", status: "CANCELED", isPaid: false })];
const result = decideVoucherUsageCorrection(code({ storedUsed: 2 }), orders);
assert.deepEqual(result, { action: "decrement", correctedUsed: 1, delta: 1 });
});
test("paid but not yet a completed status still counts", () => {
const orders = [order({ status: "UNCONFIRMED", isPaid: true })];
const result = decideVoucherUsageCorrection(code({ storedUsed: 2 }), orders);
assert.deepEqual(result, { action: "decrement", correctedUsed: 1, delta: 1 });
});
test("zero qualifying orders flags the full stored amount as delta", () => {
const result = decideVoucherUsageCorrection(code({ storedUsed: 3 }), []);
assert.deepEqual(result, { action: "decrement", correctedUsed: 0, delta: 3 });
});
test("partially fulfilled counts as real usage", () => {
const orders = [order({ status: "PARTIALLY_FULFILLED", isPaid: false })];
const result = decideVoucherUsageCorrection(code({ storedUsed: 1 }), orders);
assert.deepEqual(result, { action: "none", correctedUsed: 1, delta: 0 });
});
Case studies
A launch code hit its usage limit at half the real orders
A store ran a limited launch voucher with usageLimit: 200 to reward the first customers. Their checkout used a card gateway that required 3D Secure for a large share of banks, so a good portion of checkouts called checkoutComplete twice: once for confirmationNeeded, once after the redirect back. The voucher hit its limit and started rejecting new customers when barely more than a hundred real orders existed.
Running the reconciler against the voucher's codes showed the stored counter running roughly double the real order count on every code that had gone through a 3D Secure confirmation. Staff raised the usage limit temporarily and reported the mismatch, and the store's checkout team started tracking which gateway paths triggered the second completion call.
A payment app's async confirmation doubled a handful of codes
A merchant's custom payment app took a few seconds to confirm a transaction, so their storefront polled and called checkoutComplete again once the app's webhook resolved the payment. For most orders this only fired once, but on slower confirmations it fired twice, quietly inflating a small number of high-traffic discount codes.
The reconciler flagged exactly those codes, each with a small, consistent delta of one or two extra uses, which lined up perfectly with the slow-confirmation orders staff could find in their payment app's own logs. They corrected the counters by hand in the dashboard and shortened their polling window so the double call stopped happening for new orders.
After this runs on a schedule, a voucher code's usage limit means what it says. The reconciler surfaces every code where the stored counter has drifted from the real order count, with the exact delta needed to fix it, so staff correct the dashboard value instead of guessing or extending limits blindly. Nothing gets written automatically, since the only safe public lever here is a report, and deciding when to apply the correction stays with a person who can verify it against the orders themselves.
FAQ
Why does my Saleor voucher show more uses than actual orders?
checkoutComplete increments voucher usage as part of completing the checkout, but a two-stage payment gateway can require calling checkoutComplete twice for the same checkout, once when the gateway returns confirmationNeeded or requires additional customer action, and again after the customer confirms and the payment actually captures. Both calls increment the same counter, so VoucherCode.used ends up one higher than the number of orders that truly used the code.
How do I find which voucher codes were double incremented?
Read the stored used counter from voucher.codes, then separately count the orders that actually reference that voucherCode and reached a paid or confirmed state, excluding cancelled, draft, or abandoned checkouts. Compare the two. Any code where the stored counter is higher than the real order count is a candidate for the double-increment bug, and the gap tells you exactly how many extra increments to account for.
Is it safe to auto-correct the voucher usage counter?
No. Saleor does not expose a public voucherCodeUsageSet mutation, and writing the counter directly requires a custom app with database access or an internal ORM helper that is not part of the public API. The safe pattern is to run the reconciler in DRY_RUN mode, log the code, stored count, real count, and delta for every affected code, and hand that report to staff to correct in the dashboard rather than writing to the counter automatically.
Related field notes
Citations
On the problem:
- Voucher code will be used multiple times. github.com/saleor/saleor/issues/8219
- Saleor Commerce Documentation: the checkoutComplete mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-complete
- Saleor Commerce Documentation: the CheckoutComplete object. docs.saleor.io/api-reference/checkout/objects/checkout-complete
On the solution:
- Saleor Commerce Documentation: the Voucher object. docs.saleor.io/api-reference/discounts/objects/voucher
- Saleor Commerce Documentation: the VoucherCode object. docs.saleor.io/api-reference/miscellaneous/objects/voucher-code
- Saleor Commerce Documentation: the orders query. docs.saleor.io/api-reference/orders/queries/orders
Stuck on a tricky one?
If you have a problem in Saleor checkout, payments, discounts, 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 drifted voucher count for you?
If this saved you from raising a usage limit blindly or arguing with a customer over a code that "should still work," 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