Skip to content

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.

Python and Node.js Saleor GraphQL API Report only, staff decides the repair
Mannequins in a shop window
Photo by Artem Beliaikin on Unsplash
The short answer

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.

Checkout A completes reads used = 49 of 50 Checkout B completes also reads used = 49 of 50 Both pass check 49 < 50, both allowed Both pass check no lock in between Both increment used, used writes land used = 51 over limit
Neither checkout did anything wrong on its own. The check and the increment are not atomic together, so a second completion can slip through the same gap the first one used.

Why it happens

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.

The key insight

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.

Page vouchers with a usageLimit Page real orders that reference each voucher Count non-canceled, non-draft redemptions Over the limit? yes, report no, skip Report + stop new redemptions orders left for staff
The script always reports the voucher and every affected order id first. It only ever stops further redemptions automatically, and only when dry run is off. The affected orders are always left for staff to resolve.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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"]
step3.js
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;
  }
}
4

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.

step4.py
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"]
step4.js
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;
  }
}
5

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.

decide.py
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],
    }
decide.js
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),
  };
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
detect_voucher_overage.py
"""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()
detect-voucher-overage.js
/**
 * 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.

test_voucher_overage.py
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
overage.test.js
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

Flash sale

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.

3DS retry

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.

What good looks like

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:

  1. Vouchers can be used past their usage limit under concurrent access. github.com/saleor/saleor/issues/544
  2. Voucher code will be used multiple times. github.com/saleor/saleor/issues/8219
  3. Saleor Commerce Documentation: Vouchers. docs.saleor.io/developer/discounts/vouchers

On the solution:

  1. Saleor Commerce Documentation: Voucher Object, including usageLimit, used, code, and singleUse. docs.saleor.io/api-reference/discounts/objects/voucher
  2. Saleor Commerce Documentation: checkoutComplete Mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-complete
  3. 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.

Contact me on LinkedIn

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

Back to all Saleor field notes