Reconciler WooCommerce core: coupons

Coupon usage count undercounts

A coupon is set to a strict usage limit, but somehow it gets used more times than that limit allows. Or the opposite happens, a coupon that has clearly been used a dozen times still shows a usage count of nine. The number WooCommerce stores for a coupon's usage_count has quietly fallen behind the real number of orders that used it. Here is why that number drifts and a small job that recounts it from the orders themselves and repairs it.

Python and Node.js Runs on a schedule Safe by default (dry run)
50% discount sign
Photo by Artem Beliaikin on Unsplash
The short answer

WooCommerce keeps a coupon's usage_count as one plain number, and increases it by one each time an order applies the coupon. When two checkouts apply the same coupon at nearly the same instant, both can read the same starting number and both write back the same next number, so one use is never added. A cancelled or failed order can leave the count too high in the same way. Run a small Python or Node.js job on a schedule that counts orders that really used the coupon and were really paid (confirmed against Stripe by their PaymentIntent), compares that to the stored usage_count, and corrects it when the two disagree. Full code, tests, and a dry run guard are below.

The problem in plain words

A coupon with a usage limit works by checking one number before letting a checkout go through. If that number is at or past the limit, WooCommerce refuses the coupon. If it is below the limit, WooCommerce lets the order through and then, after the order is placed, increases the number by one.

That "increase by one" step is the weak point. It reads the current number, adds one, and saves it. If two orders do this at almost the same moment, both can read the same number before either one saves, so both save the same result. One real use of the coupon never gets counted. Over a busy sale, this can let a coupon meant for fifty uses get claimed by fifty-three people, and nobody notices until the numbers are compared by hand.

Checkout A reads usage_count = 49 Checkout B reads usage_count = 49 Both write 50 one save overwrites the other Stored: 50 real uses: 51 Limit missed
Two checkouts read the same starting number and both write back the same next number, so one real use is silently dropped from the stored count.

Why it happens

This is a plain race condition on a single shared number, made worse by a few WooCommerce specific details:

Whatever the exact cause, the result is the same: the number on the coupon and the number of orders that actually, successfully used it disagree. See the citations at the end for background on how coupon usage tracking works and where the docs call out the limitation.

The key insight

Orders are the source of truth for coupon usage, not the stored usage_count. Every order that used the coupon and was genuinely paid is one real use. A recounting job that reads orders, confirms payment against Stripe, and writes the true count back is a safety net that catches every kind of drift, no matter which of the causes above produced it.

The fix, as a flow

We do not touch checkout at all. We add a job that runs on a schedule, looks at every coupon, finds the orders that used it, and keeps only the orders that are genuinely paid, confirmed by looking up the order's Stripe PaymentIntent and checking its status is succeeded. That gives a real, trustworthy count. If the real count does not match the stored usage_count, we write the real number over it.

Scheduled job once a day List coupons find orders using each Confirm with Stripe PaymentIntent succeeded? Real count differs? yes no, skip Correct count write real number
The job counts orders that used a coupon and were genuinely paid, then corrects the stored number only when it disagrees with that real count. Coupons that already match are left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to coupons and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   // start safe, change to false to write
2

List the coupons and the orders that used each one

Page through every coupon with the WooCommerce REST API, then for each coupon code, page through orders and keep the ones whose coupon_lines mention that code. This works the same on stores with High Performance Order Storage (HPOS) turned on, because the REST API handles the storage for you.

step2.py
import requests
from requests.auth import HTTPBasicAuth

AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)

def list_coupons():
    page = 1
    while True:
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/coupons",
                          params={"per_page": 50, "page": page}, auth=AUTH, timeout=30)
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        yield from batch
        page += 1

def orders_using(coupon_code):
    page = 1
    while True:
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
                          params={"per_page": 50, "page": page, "status": "any"}, auth=AUTH, timeout=30)
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            codes = {line.get("code") for line in order.get("coupon_lines") or []}
            if coupon_code in codes:
                yield order
        page += 1
step2.js
async function* listCoupons() {
  let page = 1;
  while (true) {
    const batch = await woo(`/coupons?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const coupon of batch) yield coupon;
    page++;
  }
}

async function* ordersUsing(couponCode) {
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=any&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) {
      const codes = new Set((order.coupon_lines || []).map((line) => line.code));
      if (codes.has(couponCode)) yield order;
    }
    page++;
  }
}
3

Confirm each order was really paid, with Stripe

WooCommerce order status alone is not proof of payment, a status can be changed by hand or left behind by a broken integration. Read the order's PaymentIntent id from meta _stripe_intent_id, or fall back to transaction_id when it looks like a PaymentIntent id, then ask Stripe for that PaymentIntent and check its status.

step3.py
import stripe

def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Decide, with two pure functions

Keep the logic in small functions with no I/O, so they are easy to read and easy to test. One decides whether a single order counts as a real, kept use of the coupon. The other compares the real count to the stored number and says whether to correct it.

decide.py
CANDIDATE_STATUSES = {"processing", "completed", "on-hold"}

def order_counts_as_used(order, intent):
    if order.get("status") not in CANDIDATE_STATUSES:
        return False
    if intent is None:
        return False
    status = intent.get("status") if isinstance(intent, dict) else intent.status
    return status == "succeeded"

def decide(coupon, real_count):
    stored = int(coupon.get("usage_count", 0))
    if stored == real_count:
        return ("ok", "usage_count already matches real orders")
    if stored < real_count:
        return ("correct", f"undercounted: stored {stored}, real {real_count}")
    return ("correct", f"overcounted: stored {stored}, real {real_count}")
decide.js
const CANDIDATE_STATUSES = new Set(["processing", "completed", "on-hold"]);

export function orderCountsAsUsed(order, intent) {
  if (!CANDIDATE_STATUSES.has(order.status)) return false;
  if (!intent) return false;
  return intent.status === "succeeded";
}

export function decide(coupon, realCount) {
  const stored = Number(coupon.usage_count || 0);
  if (stored === realCount) return ["ok", "usage_count already matches real orders"];
  if (stored < realCount) return ["correct", `undercounted: stored ${stored}, real ${realCount}`];
  return ["correct", `overcounted: stored ${stored}, real ${realCount}`];
}
5

Write the corrected number back

When the action is correct, write the real count over the coupon's usage_count with a single REST API call. There is no note to add on a coupon the way there is on an order, so the log line from the job is the record of what changed and why.

apply.py
def correct_usage_count(coupon_id, real_count):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon_id}",
        json={"usage_count": real_count},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function correctUsageCount(couponId, realCount) {
  await woo(`/coupons/${couponId}`, {
    method: "PUT",
    body: JSON.stringify({ usage_count: realCount }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the job only reports which coupons it would correct and by how much. Read the output, trust it, then switch it off to let it write. Run it once a day with cron, or right after a sale with a tight usage limit.

Run it safe

Always start with DRY_RUN=true. Correcting usage_count changes what future checkouts are allowed to do, so read the plan before it writes, especially the first time you run it on a store with a long coupon history.

The full code

Here is the complete recounting job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because a coupon whose stored count already matches its real orders is simply skipped.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

recount_coupon_usage.py
"""Recount WooCommerce coupon usage from real, paid orders and repair a wrong usage_count.

WooCommerce tracks how many times a coupon was used with a single number,
usage_count, stored on the coupon itself. Two checkouts that apply the same
coupon at nearly the same moment can both read the old number and both write
back old_number + 1, so one use is lost. A cancelled, failed, or refunded
order can also fail to give its use back. Either way the stored count drifts
from reality, and a limited coupon can be used more times than the shop
owner intended, or looks used up when it still has room.

This script asks WooCommerce for orders that used the coupon, keeps only the
ones that are genuinely paid, and confirms "genuinely paid" against Stripe by
looking up the order's PaymentIntent (from order meta _stripe_intent_id, or
transaction_id when it looks like a PaymentIntent id) and checking its
status is succeeded. That real count is compared to the coupon's stored
usage_count, and the stored number is corrected when it disagrees. Read only
by default. Safe to run again and again.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("recount_coupon_usage")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Orders in these statuses are worth checking with Stripe at all. Anything
# else (cancelled, failed, refunded, pending, trash) never counts as a use.
CANDIDATE_STATUSES = {"processing", "completed", "on-hold"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_counts_as_used(order, intent):
    """Pure rule: does this order count as one real, kept use of a coupon?

    order: a dict with at least "status".
    intent: a Stripe PaymentIntent dict (or object with attribute access), or
      None when no PaymentIntent could be found or loaded for the order.

    An order counts only when it is in a candidate status AND Stripe confirms
    the matching PaymentIntent actually succeeded. No PaymentIntent, or a
    PaymentIntent that is not succeeded, means the order does not count, no
    matter what status WooCommerce shows.
    """
    if order.get("status") not in CANDIDATE_STATUSES:
        return False
    if intent is None:
        return False
    status = intent.get("status") if isinstance(intent, dict) else intent.status
    return status == "succeeded"


def decide(coupon, real_count):
    """Pure decision: compare the coupon's stored usage_count to the real
    count of orders confirmed used and paid, and say whether to correct it.

    coupon: a dict with at least "id", "code", "usage_count".
    real_count: an int, the number of confirmed-paid orders using this coupon.

    Returns a tuple of (action, reason) where action is one of:
      "ok"      the stored count already matches, nothing to do
      "correct" the stored count is wrong, write real_count over it
    """
    stored = int(coupon.get("usage_count", 0))
    if stored == real_count:
        return ("ok", "usage_count already matches real orders")
    if stored < real_count:
        return ("correct", f"undercounted: stored {stored}, real {real_count}")
    return ("correct", f"overcounted: stored {stored}, real {real_count}")


def list_coupons():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/coupons",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for coupon in batch:
            yield coupon
        page += 1


def orders_using(coupon_code):
    """Every order (any status) whose coupon_lines mention this code."""
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"per_page": 50, "page": page, "status": "any"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            codes = {line.get("code") for line in order.get("coupon_lines") or []}
            if coupon_code in codes:
                yield order
        page += 1


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def real_usage_count(coupon_code):
    count = 0
    for order in orders_using(coupon_code):
        if order.get("status") not in CANDIDATE_STATUSES:
            continue
        intent = get_intent(intent_id_of(order))
        if order_counts_as_used(order, intent):
            count += 1
    return count


def correct_usage_count(coupon_id, real_count):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon_id}",
        json={"usage_count": real_count},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    corrected = 0
    for coupon in list_coupons():
        real_count = real_usage_count(coupon["code"])
        action, reason = decide(coupon, real_count)
        if action == "ok":
            continue
        log.info(
            "Coupon %s (%s): %s. %s",
            coupon["code"], coupon["id"], reason, "would correct" if DRY_RUN else "correcting",
        )
        if not DRY_RUN:
            correct_usage_count(coupon["id"], real_count)
        corrected += 1
    log.info("Done. %d coupon(s) %s.", corrected, "to correct" if DRY_RUN else "corrected")


if __name__ == "__main__":
    run()
recount-coupon-usage.js
/**
 * Recount WooCommerce coupon usage from real, paid orders and repair a wrong usage_count.
 *
 * WooCommerce tracks how many times a coupon was used with a single number,
 * usage_count, stored on the coupon itself. Two checkouts that apply the
 * same coupon at nearly the same moment can both read the old number and
 * both write back old_number + 1, so one use is lost. A cancelled, failed,
 * or refunded order can also fail to give its use back. Either way the
 * stored count drifts from reality, and a limited coupon can be used more
 * times than the shop owner intended, or looks used up when it still has
 * room.
 *
 * This script asks WooCommerce for orders that used the coupon, keeps only
 * the ones that are genuinely paid, and confirms "genuinely paid" against
 * Stripe by looking up the order's PaymentIntent (from order meta
 * _stripe_intent_id, or transaction_id when it looks like a PaymentIntent
 * id) and checking its status is succeeded. That real count is compared to
 * the coupon's stored usage_count, and the stored number is corrected when
 * it disagrees. Read only by default. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/coupon-usage-count-undercounts/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Orders in these statuses are worth checking with Stripe at all. Anything
// else (cancelled, failed, refunded, pending, trash) never counts as a use.
const CANDIDATE_STATUSES = new Set(["processing", "completed", "on-hold"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function orderCountsAsUsed(order, intent) {
  if (!CANDIDATE_STATUSES.has(order.status)) return false;
  if (!intent) return false;
  return intent.status === "succeeded";
}

export function decide(coupon, realCount) {
  const stored = Number(coupon.usage_count || 0);
  if (stored === realCount) return ["ok", "usage_count already matches real orders"];
  if (stored < realCount) return ["correct", `undercounted: stored ${stored}, real ${realCount}`];
  return ["correct", `overcounted: stored ${stored}, real ${realCount}`];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* listCoupons() {
  let page = 1;
  while (true) {
    const batch = await woo(`/coupons?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const coupon of batch) yield coupon;
    page++;
  }
}

async function* ordersUsing(couponCode) {
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=any&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) {
      const codes = new Set((order.coupon_lines || []).map((line) => line.code));
      if (codes.has(couponCode)) yield order;
    }
    page++;
  }
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function realUsageCount(couponCode) {
  let count = 0;
  for await (const order of ordersUsing(couponCode)) {
    if (!CANDIDATE_STATUSES.has(order.status)) continue;
    const intent = await getIntent(intentIdOf(order));
    if (orderCountsAsUsed(order, intent)) count++;
  }
  return count;
}

async function correctUsageCount(couponId, realCount) {
  await woo(`/coupons/${couponId}`, {
    method: "PUT",
    body: JSON.stringify({ usage_count: realCount }),
  });
}

export async function run() {
  let corrected = 0;
  for await (const coupon of listCoupons()) {
    const realCount = await realUsageCount(coupon.code);
    const [action, reason] = decide(coupon, realCount);
    if (action === "ok") continue;
    console.log(
      `Coupon ${coupon.code} (${coupon.id}): ${reason}. ${DRY_RUN ? "would correct" : "correcting"}`
    );
    if (!DRY_RUN) await correctUsageCount(coupon.id, realCount);
    corrected++;
  }
  console.log(`Done. ${corrected} coupon(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The two decision functions are the part most worth testing, because together they decide which orders count as real uses and whether a coupon's real limit gets enforced correctly. Because both are pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.

test_coupon_recount_decide.py
from recount_coupon_usage import decide, order_counts_as_used, intent_id_of


def intent(**over):
    base = {"status": "succeeded"}
    base.update(over)
    return base


def test_ok_when_stored_matches_real():
    coupon = {"usage_count": 3}
    assert decide(coupon, 3)[0] == "ok"


def test_correct_when_stored_undercounts():
    coupon = {"usage_count": 2}
    action, reason = decide(coupon, 5)
    assert action == "correct"
    assert "undercounted" in reason


def test_correct_when_stored_overcounts():
    coupon = {"usage_count": 7}
    action, reason = decide(coupon, 4)
    assert action == "correct"
    assert "overcounted" in reason


def test_order_counts_when_processing_and_succeeded():
    order = {"status": "processing"}
    assert order_counts_as_used(order, intent()) is True


def test_order_does_not_count_when_cancelled():
    order = {"status": "cancelled"}
    assert order_counts_as_used(order, intent()) is False


def test_order_does_not_count_when_no_intent():
    order = {"status": "completed"}
    assert order_counts_as_used(order, None) is False


def test_intent_id_from_meta():
    order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
    assert intent_id_of(order) == "pi_123"
recount-coupon-usage.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, orderCountsAsUsed, intentIdOf } from "./recount-coupon-usage.js";

const intent = (over = {}) => ({ status: "succeeded", ...over });

test("ok when stored matches real", () => {
  assert.equal(decide({ usage_count: 3 }, 3)[0], "ok");
});

test("correct when stored undercounts", () => {
  const [action, reason] = decide({ usage_count: 2 }, 5);
  assert.equal(action, "correct");
  assert.match(reason, /undercounted/);
});

test("correct when stored overcounts", () => {
  const [action, reason] = decide({ usage_count: 7 }, 4);
  assert.equal(action, "correct");
  assert.match(reason, /overcounted/);
});

test("order counts when processing and succeeded", () => {
  assert.equal(orderCountsAsUsed({ status: "processing" }, intent()), true);
});

test("order does not count when cancelled", () => {
  assert.equal(orderCountsAsUsed({ status: "cancelled" }, intent()), false);
});

test("intentIdOf from meta", () => {
  assert.equal(
    intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }),
    "pi_123"
  );
});

Case studies

Flash sale

The fifty-code coupon that fifty-three people used

A store capped a launch coupon at fifty uses. During the first hour of a sale, checkouts came in close together and a handful landed within milliseconds of each other. By the time the sale ended, fifty-three orders had used the coupon, but usage_count still read fifty.

The recounting job, run right after the sale, found the true count of fifty-three confirmed-paid orders and corrected the number, so later reporting and any future limit checks were working from the truth.

Refund cleanup

The coupon that looked used up but was not

A support agent refunded and cancelled a batch of orders from a bad supplier issue, several of which had used a limited coupon. WooCommerce did not give the uses back on every one of them, and the coupon showed as fully used within a week, even though real remaining demand for it was low.

Running the job in dry run showed the coupon was overcounted by four. After a quick sanity check against the order list, the team let it write, and the coupon became available again for genuine new customers.

What good looks like

After this runs on a schedule, a coupon's usage_count is never more than a day's drift away from reality. Limited coupons stay limited, refunded and cancelled orders stop inflating the count, and nobody has to page through order exports by hand to find out what really happened during a sale.

FAQ

Why is my coupon's usage count lower than the real number of orders that used it?

WooCommerce stores usage_count as a single number on the coupon and increases it by one each time an order uses the coupon. Two checkouts that apply the coupon at nearly the same moment can both read the same starting number and both write back the same next number, so one use never gets added. A recounting job that counts real orders and corrects the stored number fixes it.

Is it safe to change a coupon's usage_count with a script?

Yes, when the script counts only orders that are genuinely paid, confirmed against Stripe, and writes the real count back rather than guessing or incrementing blindly. Start in dry run mode to review the corrected numbers before it writes.

How often should the recount job run?

Once a day is enough for most stores, or right after a sale with a tight usage limit. It only reads orders and corrects a single number, so running it more often is safe and cheap.

Related field notes

Citations

On the problem:

  1. WooCommerce developer docs: the coupon usage_count and usage_limit fields, and how they are read at checkout. woocommerce.github.io/code-reference/classes/WC-Coupon
  2. WooCommerce docs: coupon management, including usage limits and how they are meant to be enforced. woocommerce.com/document/coupon-management
  3. WordPress developer docs: race conditions on post meta and options values under concurrent requests. developer.wordpress.org/apis/options

On the solution:

  1. WooCommerce REST API: list and update coupons, including the usage_count field. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list orders and read coupon_lines to see which coupons an order used. woocommerce.github.io/woocommerce-rest-api-docs
  3. Stripe API: retrieve a PaymentIntent and check its status before trusting a payment as final. docs.stripe.com/api/payment_intents/retrieve

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 fix your coupon numbers?

If this saved you a pile of confused support tickets or an oversold sale, 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 WooCommerce and Stripe field notes