Repair WooCommerce core: coupons

WooCommerce coupon usage counted twice via the REST API

A shopper uses a coupon once, but the coupon's usage_count climbs by two. Do that enough times and a coupon with a usage_limit of one hundred looks used up at fifty real redemptions, so it locks out shoppers who never even tried it. This is a REST API order handling quirk, not a coupon problem, and it has a small, testable fix.

Python and Node.js Runs on a schedule or by hand Safe by default (dry run)
Person holding a discount card
Photo by Erik Mclean on Unsplash
The short answer

An order created through POST /wp-json/wc/v3/orders with a coupon already attached, then updated again through the REST API, can make WooCommerce increase the coupon's usage_count more than once for that one order. Run a small Python or Node.js script that treats Stripe as the source of truth, counts each order as one real redemption at most, and lowers any coupon's usage_count back to the correct number. Full code, tests, and a dry run guard are below.

The problem in plain words

Every WooCommerce coupon keeps a running total called usage_count. Each time an order finishes with that coupon attached, the count goes up by one. Set a usage_limit on the coupon, and once usage_count reaches it, nobody else can use the code.

That total is supposed to move in lockstep with real orders. One paid order, one increase. But when an order is created through the REST API with coupon_lines already on the payload, and then the same order is touched again afterward (a retry from your checkout app, a fulfillment step, a sync job that both creates and later updates the order), WooCommerce can end up recording the usage twice for a single sale. The shopper only used the code once. The coupon thinks two people did.

POST /orders coupon_lines attached usage_count +1 first increase same order id PUT /orders/{id} retry or status update usage_count +1 second increase, same order One sale, count of 2 limit reached too soon
The same order is counted on create and again on a later update, so one real redemption inflates the coupon's usage_count by two.

Why it happens

The WooCommerce REST API documents coupon_lines as part of the order create and update payload, and WooCommerce's own coupon usage tracking is tied to order status changes and to saving coupon lines on the order, not to a single unambiguous "this coupon was just redeemed" event. A few concrete ways the double count shows up:

The WooCommerce coupon usage_count and usage_limit behavior is documented as tied to order processing, and community reports describe usage_count climbing faster than real orders when orders are created or edited outside the normal checkout form, which is exactly what integrations using the REST API do. See the citations at the end for the relevant references.

The key insight

Stripe knows how many times a given order was actually paid, once. If you can match an order back to a succeeded PaymentIntent and count that order only once no matter how many REST calls touched it, you have a trustworthy number to compare against the coupon's stored usage_count. When the stored number is higher than the trustworthy one, it is safe to bring it back down.

The fix, as a flow

We do not touch checkout or the coupon logic in WooCommerce core. We add a script that, for one or more coupon codes, lists every order carrying that coupon, confirms with Stripe that each distinct order was genuinely paid once, and compares that verified count against the coupon's usage_count. If usage_count is higher than the verified count, we correct it. If it is lower or equal, we leave it alone, since a low count points to a different bug entirely.

List orders using this coupon code Confirm with Stripe PaymentIntent succeeded Count distinct verified order ids usage_count too high? yes no, skip Correct usage_count set to verified total
The script only lowers a coupon's usage_count when it is higher than the number of distinct orders that Stripe confirms were actually paid.

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 orders and coupons. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. List the coupon codes you want to check. 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 COUPON_CODES="SAVE10,WELCOME20"
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 COUPON_CODES="SAVE10,WELCOME20"
export DRY_RUN="true"   // start safe, change to false to write
2

List every order that used the coupon

Page through the WooCommerce REST API orders endpoint and keep the ones whose coupon_lines include the code you are checking. Going through the REST API means this works the same whether the store has High Performance Order Storage turned on or not, since WooCommerce handles the storage for you.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

def list_orders_using_coupon(code):
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"page": page, "per_page": 100},
            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", [])}
            if code in codes:
                yield order
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

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

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

Confirm each order was actually paid, once

Read the saved Stripe PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id when it already looks like a PaymentIntent id. Retrieve it from Stripe and treat the order as a genuine, single redemption only when the order is in a valid paid status, the intent succeeded, and the amount matches. Keep money math in minor units (cents) to avoid float drift.

verify.py
import stripe

VALID_ORDER_STATUSES = {"processing", "completed", "on-hold"}

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 order_amount_minor(order):
    return round(float(order["total"]) * 100)

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 counts_as_one_real_use(order, intent):
    if order["status"] not in VALID_ORDER_STATUSES:
        return False
    if intent is None:
        return False
    if intent.get("status") != "succeeded":
        return False
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return False
    return True
verify.js
const VALID_ORDER_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 orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

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

export function countsAsOneRealUse(order, intent) {
  if (!VALID_ORDER_STATUSES.has(order.status)) return false;
  if (!intent) return false;
  if (intent.status !== "succeeded") return false;
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) return false;
  return true;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the coupon and the verified use count and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If usage_count is already lower than or equal to the verified count, skip it, since that is either correct or a different bug. Only fix it when usage_count is too high.

decide.py
def decide(coupon, verified_use_count):
    usage_count = coupon.get("usage_count", 0)
    if usage_count < 0:
        return ("skip", "usage_count is already negative, needs manual review", usage_count)
    if verified_use_count > usage_count:
        # Recorded count is lower than the verified real usage. That is a
        # different bug (undercounting), not the one this script repairs.
        return ("skip", "usage_count is not inflated for this order set", usage_count)
    if verified_use_count == usage_count:
        return ("skip", "usage_count matches the verified orders that used it", usage_count)
    return (
        "fix",
        f"usage_count {usage_count} is higher than the {verified_use_count} verified order(s) that used it",
        verified_use_count,
    )
decide.js
export function decide(coupon, verifiedUseCount) {
  const usageCount = coupon.usage_count || 0;
  if (usageCount < 0) {
    return ["skip", "usage_count is already negative, needs manual review", usageCount];
  }
  if (verifiedUseCount > usageCount) {
    return ["skip", "usage_count is not inflated for this order set", usageCount];
  }
  if (verifiedUseCount === usageCount) {
    return ["skip", "usage_count matches the verified orders that used it", usageCount];
  }
  return [
    "fix",
    `usage_count ${usageCount} is higher than the ${verifiedUseCount} verified order(s) that used it`,
    verifiedUseCount,
  ];
}
5

Correct the coupon the way a single redemption should have left it

When the action is fix, write the corrected number straight to the coupon's usage_count through the REST API. This is the same field WooCommerce itself updates when a coupon is redeemed, so nothing else about the coupon changes, only the count goes back to what it should be.

apply.py
def apply_fix(coupon, corrected_count):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon['id']}",
        json={"usage_count": corrected_count},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function applyFix(coupon, correctedCount) {
  await woo(`/coupons/${coupon.id}`, {
    method: "PUT",
    body: JSON.stringify({ usage_count: correctedCount }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together, checking each coupon code you list. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would set usage_count to. Read the output, trust it, then switch it off to let it write. This is not something to run every minute, since coupon totals do not need second by second precision, but it is safe to run as often as you like.

Run it safe

Always start with DRY_RUN=true. Changing usage_count is a real write to a coupon that shoppers may be using right now, so read the planned change first. Once the report looks right, turn it off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never touches a coupon whose usage_count is not actually too high.

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

coupon_usage_dedupe.py
"""Find and repair WooCommerce coupons whose usage_count was incremented
twice for the same paid order created through the REST API.

When an order is created through POST /wp-json/wc/v3/orders with
coupon_lines already attached, and the same order is then updated again
through the REST API (a retry, a fulfillment step, or an integration that
both creates and later PUTs the order to a paid status), WooCommerce can run
its usage-count hook more than once for that one order. Each run increases
the coupon's usage_count, so a coupon a single buyer redeemed once ends up
counted twice, or more, and can hit its usage_limit long before it should.

This script treats Stripe as the source of truth for "was this order paid
exactly once." For each order that carries a coupon, it reads the saved
PaymentIntent id from order meta _stripe_intent_id (falling back to
transaction_id), confirms with Stripe that the PaymentIntent succeeded, and
counts the order only once no matter how many times WooCommerce re-saved it.
It compares that trustworthy count against each coupon's usage_count and,
when usage_count is inflated, lowers it back to the correct number.

Read only by default. Run on a schedule or by hand after a spike in
"coupon usage limit reached" reports.
"""
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("coupon_usage_dedupe")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
COUPON_CODES = [c.strip() for c in os.environ.get("COUPON_CODES", "").split(",") if c.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

VALID_ORDER_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_amount_minor(order):
    # Keep money math in minor units (cents) to avoid float drift.
    return round(float(order["total"]) * 100)


def counts_as_one_real_use(order, intent):
    """Pure. Decide whether a single order should count as exactly one
    coupon redemption. An order only counts when it is in a valid status and
    Stripe confirms a succeeded PaymentIntent for the order's own amount.
    """
    if order["status"] not in VALID_ORDER_STATUSES:
        return False
    if intent is None:
        return False
    if intent.get("status") != "succeeded":
        return False
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return False
    return True


def decide(coupon, verified_use_count):
    """Pure decision function. No I/O.

    coupon: a dict shaped like the WooCommerce REST coupon resource, with at
      least "id", "code", and "usage_count".
    verified_use_count: the number of distinct orders that counts_as_one_real_use
      confirmed as genuine, single-counted redemptions of this coupon.

    Returns a tuple of (action, reason, corrected_count).
    """
    usage_count = coupon.get("usage_count", 0)
    if usage_count < 0:
        return ("skip", "usage_count is already negative, needs manual review", usage_count)
    if verified_use_count > usage_count:
        # Recorded count is lower than the verified real usage. That is a
        # different bug (undercounting), not the one this script repairs.
        return ("skip", "usage_count is not inflated for this order set", usage_count)
    if verified_use_count == usage_count:
        return ("skip", "usage_count matches the verified orders that used it", usage_count)
    return (
        "fix",
        f"usage_count {usage_count} is higher than the {verified_use_count} verified order(s) that used it",
        verified_use_count,
    )


def list_orders_using_coupon(code):
    """Yield every order (any page) that has this coupon code on it."""
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"page": page, "per_page": 100},
            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", [])}
            if 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 verified_use_count_for_coupon(code):
    """Count distinct orders that genuinely redeemed this coupon once,
    confirmed against Stripe. Each qualifying order id counts once, no
    matter how many times a buggy integration re-saved it.
    """
    seen_order_ids = set()
    for order in list_orders_using_coupon(code):
        if order["id"] in seen_order_ids:
            continue
        intent = get_intent(intent_id_of(order))
        if counts_as_one_real_use(order, intent):
            seen_order_ids.add(order["id"])
    return len(seen_order_ids)


def get_coupon(code):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/coupons",
        params={"code": code},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    matches = r.json()
    return matches[0] if matches else None


def apply_fix(coupon, corrected_count):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon['id']}",
        json={"usage_count": corrected_count},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    codes = COUPON_CODES
    if not codes:
        log.warning("No COUPON_CODES set. Nothing to check.")
        return
    fixed = 0
    for code in codes:
        coupon = get_coupon(code)
        if coupon is None:
            log.warning("Coupon %s not found", code)
            continue
        verified_count = verified_use_count_for_coupon(code)
        action, reason, corrected_count = decide(coupon, verified_count)
        if action == "skip":
            log.info("Coupon %s: %s", code, reason)
            continue
        log.info(
            "Coupon %s: %s. %s",
            code, reason, ("would set usage_count to " + str(corrected_count)) if DRY_RUN else "fixing",
        )
        if not DRY_RUN:
            apply_fix(coupon, corrected_count)
        fixed += 1
    log.info("Done. %d coupon(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
coupon-usage-dedupe.js
/**
 * Find and repair WooCommerce coupons whose usage_count was incremented
 * twice for the same paid order created through the REST API.
 *
 * When an order is created through POST /wp-json/wc/v3/orders with
 * coupon_lines already attached, and the same order is then updated again
 * through the REST API (a retry, a fulfillment step, or an integration that
 * both creates and later PUTs the order to a paid status), WooCommerce can
 * run its usage-count hook more than once for that one order. Each run
 * increases the coupon's usage_count, so a coupon a single buyer redeemed
 * once ends up counted twice, or more, and can hit its usage_limit long
 * before it should.
 *
 * This script treats Stripe as the source of truth for "was this order paid
 * exactly once." For each order that carries a coupon, it reads the saved
 * PaymentIntent id from order meta _stripe_intent_id (falling back to
 * transaction_id), confirms with Stripe that the PaymentIntent succeeded,
 * and counts the order only once no matter how many times WooCommerce
 * re-saved it. It compares that trustworthy count against each coupon's
 * usage_count and, when usage_count is inflated, lowers it back to the
 * correct number.
 *
 * Read only by default. Run on a schedule or by hand.
 *
 * Guide: https://www.allanninal.dev/woocommerce/coupon-usage-counted-twice-via-rest/
 */
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 COUPON_CODES = (process.env.COUPON_CODES || "").split(",").map((c) => c.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const VALID_ORDER_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 orderAmountMinor(order) {
  // Keep money math in minor units (cents) to avoid float drift.
  return Math.round(parseFloat(order.total) * 100);
}

export function countsAsOneRealUse(order, intent) {
  if (!VALID_ORDER_STATUSES.has(order.status)) return false;
  if (!intent) return false;
  if (intent.status !== "succeeded") return false;
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) return false;
  return true;
}

export function decide(coupon, verifiedUseCount) {
  const usageCount = coupon.usage_count || 0;
  if (usageCount < 0) {
    return ["skip", "usage_count is already negative, needs manual review", usageCount];
  }
  if (verifiedUseCount > usageCount) {
    return ["skip", "usage_count is not inflated for this order set", usageCount];
  }
  if (verifiedUseCount === usageCount) {
    return ["skip", "usage_count matches the verified orders that used it", usageCount];
  }
  return [
    "fix",
    `usage_count ${usageCount} is higher than the ${verifiedUseCount} verified order(s) that used it`,
    verifiedUseCount,
  ];
}

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* listOrdersUsingCoupon(code) {
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?page=${page}&per_page=100`);
    if (!batch.length) return;
    for (const order of batch) {
      const codes = new Set((order.coupon_lines || []).map((line) => line.code));
      if (codes.has(code)) yield order;
    }
    page++;
  }
}

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

async function verifiedUseCountForCoupon(code) {
  const seenOrderIds = new Set();
  for await (const order of listOrdersUsingCoupon(code)) {
    if (seenOrderIds.has(order.id)) continue;
    const intent = await getIntent(intentIdOf(order));
    if (countsAsOneRealUse(order, intent)) seenOrderIds.add(order.id);
  }
  return seenOrderIds.size;
}

async function getCoupon(code) {
  const matches = await woo(`/coupons?code=${encodeURIComponent(code)}`);
  return matches[0] || null;
}

async function applyFix(coupon, correctedCount) {
  await woo(`/coupons/${coupon.id}`, {
    method: "PUT",
    body: JSON.stringify({ usage_count: correctedCount }),
  });
}

export async function run() {
  if (!COUPON_CODES.length) {
    console.warn("No COUPON_CODES set. Nothing to check.");
    return;
  }
  let fixed = 0;
  for (const code of COUPON_CODES) {
    const coupon = await getCoupon(code);
    if (!coupon) {
      console.warn(`Coupon ${code} not found`);
      continue;
    }
    const verifiedCount = await verifiedUseCountForCoupon(code);
    const [action, reason, correctedCount] = decide(coupon, verifiedCount);
    if (action === "skip") {
      console.log(`Coupon ${code}: ${reason}`);
      continue;
    }
    console.log(`Coupon ${code}: ${reason}. ${DRY_RUN ? "would set usage_count to " + correctedCount : "fixing"}`);
    if (!DRY_RUN) await applyFix(coupon, correctedCount);
    fixed++;
  }
  console.log(`Done. ${fixed} coupon(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

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

Add a test

The decision rule and the "does this order really count once" rule are the parts most worth testing, because together they decide whether a coupon that shoppers are actively using gets changed. Because we kept both functions pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.

test_coupon_dedupe_decide.py
from coupon_usage_dedupe import decide, counts_as_one_real_use, intent_id_of


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


def coupon(**over):
    base = {"id": 42, "code": "SAVE10", "usage_count": 2}
    base.update(over)
    return base


def order(**over):
    base = {"id": 900, "status": "processing", "total": "50.00"}
    base.update(over)
    return base


def test_fix_when_usage_count_is_inflated():
    action, reason, corrected = decide(coupon(usage_count=2), 1)
    assert action == "fix"
    assert corrected == 1


def test_skip_when_usage_count_matches_verified_orders():
    action, reason, corrected = decide(coupon(usage_count=1), 1)
    assert action == "skip"


def test_skip_when_verified_count_exceeds_usage_count():
    action, reason, corrected = decide(coupon(usage_count=1), 2)
    assert action == "skip"


def test_counts_when_status_valid_and_stripe_confirms_paid():
    assert counts_as_one_real_use(order(), intent()) is True


def test_does_not_count_when_intent_not_succeeded():
    bad_intent = intent(status="requires_payment_method")
    assert counts_as_one_real_use(order(), bad_intent) is False


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

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
const coupon = (over = {}) => ({ id: 42, code: "SAVE10", usage_count: 2, ...over });
const order = (over = {}) => ({ id: 900, status: "processing", total: "50.00", ...over });

test("fix when usage_count is inflated", () => {
  const [action, , corrected] = decide(coupon({ usage_count: 2 }), 1);
  assert.equal(action, "fix");
  assert.equal(corrected, 1);
});

test("skip when usage_count matches verified orders", () => {
  const [action] = decide(coupon({ usage_count: 1 }), 1);
  assert.equal(action, "skip");
});

test("skip when verified count exceeds usage_count (different bug)", () => {
  const [action] = decide(coupon({ usage_count: 1 }), 2);
  assert.equal(action, "skip");
});

test("counts when status valid and stripe confirms paid", () => {
  assert.equal(countsAsOneRealUse(order(), intent()), true);
});

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

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

Case studies

Headless checkout

The storefront that created, then finished, the same order twice over

A headless storefront created the order through the REST API with the welcome coupon already attached, then called update on the same order a moment later to attach the payment result. Both calls ran through code paths that recalculated the coupon usage, so every single signup coupon redemption counted as two against a hundred use limit.

The store noticed when new signups started reporting "this code has reached its limit" at only fifty real redemptions. The script found the coupon's usage_count sitting at exactly double the verified order count and brought it back down in one dry run followed by one real run.

Retry storm

The integration that retried a slow order create

A fulfillment integration had a five second timeout on its call to create the order. Under load, some create calls took longer than that to respond even though they had already succeeded, so the integration retried and created what it thought was a missing order, actually recounting the coupon on the same one.

Running the script across the three coupons used during that promotion showed the exact number of duplicate counts per code. The team fixed the integration's timeout and idempotency handling, then ran the script once more to confirm every coupon's usage_count matched Stripe.

What good looks like

After this runs, a coupon's usage_count reflects real, Stripe-confirmed redemptions, not however many times an integration happened to touch the order. Keep the script handy for the next promotion, and treat a coupon that keeps drifting as a sign to look at whatever is creating or updating your orders, since the real fix is stopping the duplicate save, not just correcting the number after it happens.

FAQ

Why does my coupon's usage_count go up by two for one order?

It usually happens when an order is created through the WooCommerce REST API with the coupon already attached, and the same order is updated again afterward, for example by a retry or a second call that moves it to a paid status. WooCommerce can run its usage counting hook on both the create and the later update, so one real redemption gets recorded twice.

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

Yes, when the script first confirms with Stripe that each order it is counting was actually paid, and it counts every order only once no matter how many times it was saved. It should also refuse to touch a coupon whose usage_count looks too low, since that is a different problem. Start in dry run mode to see the planned change before it writes.

Will this also stop the double count from happening again?

No, this script repairs the number after the fact. Stopping it for good means finding and fixing the integration or automation that saves the same order more than once. The script is meant to clean up the coupon while that root cause gets fixed.

Related field notes

Citations

On the problem:

  1. WooCommerce REST API docs: the order coupon_lines fields, and how coupons are attached on order create and update. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce docs: how coupon usage_limit and usage_count work together. woocommerce.com/document/coupon-management
  3. WooCommerce developer docs: the coupon usage count and restriction hooks fired during order processing. developer.woocommerce.com/docs/coupons

On the solution:

  1. Stripe API: retrieve a PaymentIntent and read its status and amount_received. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: list and update coupons, including the usage_count field. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce REST API: list orders and filter by page, so large stores can page through every order safely. woocommerce.github.io/woocommerce-rest-api-docs

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 counts?

If this saved a promotion from locking out real shoppers early, 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