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.
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.
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:
- A checkout integration creates the order with the coupon already applied, then immediately calls update on the same order to set a paid status, and both requests trigger the usage counting logic.
- A retry after a timeout resends the same create or update request. The order already exists, but the retried call still walks the coupon lines and increases usage again.
- A custom plugin or automation calls
WC_Coupon::increase_usage_count()directly in a hook that already fires as part of the normal REST order flow, so the increment happens once from WooCommerce core and once from the custom code. - An order is edited through the REST API after the fact to add a coupon line that was missing, and the store also has an automation that recalculates coupon usage from order coupon lines on save, counting the same order twice.
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.
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.
Build it step by step
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.
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
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
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.
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
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++;
}
}
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.
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
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;
}
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.
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,
)
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,
];
}
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.
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()
async function applyFix(coupon, correctedCount) {
await woo(`/coupons/${coupon.id}`, {
method: "PUT",
body: JSON.stringify({ usage_count: correctedCount }),
});
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- 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
- WooCommerce docs: how coupon usage_limit and usage_count work together. woocommerce.com/document/coupon-management
- WooCommerce developer docs: the coupon usage count and restriction hooks fired during order processing. developer.woocommerce.com/docs/coupons
On the solution:
- Stripe API: retrieve a PaymentIntent and read its status and amount_received. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: list and update coupons, including the usage_count field. woocommerce.github.io/woocommerce-rest-api-docs
- 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.
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