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.
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.
Why it happens
This is a plain race condition on a single shared number, made worse by a few WooCommerce specific details:
- The read-then-write step for usage_count is not locked against another checkout doing the same thing at the same time, so a busy sale is exactly when this is most likely to happen.
- An order can be placed, count the coupon as used, then later be cancelled, fail, or get refunded. WooCommerce is meant to give the use back in that case, but if that step is skipped or errors out, the count stays too high forever.
- Manual edits in the admin, a migration, or a plugin that also touches coupons can change usage_count directly without going through the normal order flow, so it stops matching reality.
- Multi-site or multi-server setups with database replication lag can let two servers each believe they hold the latest number for a brief moment.
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.
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.
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 coupons and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. 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 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 DRY_RUN="true" // start safe, change to false to write
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.
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
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++;
}
}
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.
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
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;
}
}
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.
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}")
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}`];
}
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.
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()
async function correctUsageCount(couponId, realCount) {
await woo(`/coupons/${couponId}`, {
method: "PUT",
body: JSON.stringify({ usage_count: realCount }),
});
}
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.
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 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 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.
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"
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
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.
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.
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:
- 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
- WooCommerce docs: coupon management, including usage limits and how they are meant to be enforced. woocommerce.com/document/coupon-management
- WordPress developer docs: race conditions on post meta and options values under concurrent requests. developer.wordpress.org/apis/options
On the solution:
- 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 read coupon_lines to see which coupons an order used. woocommerce.github.io/woocommerce-rest-api-docs
- 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.
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