Repair WooCommerce core: coupons
Failed orders inflate and lock coupons
A customer types in a coupon code, the checkout fails, and they try again a minute later, but WooCommerce now says the coupon has already reached its usage limit. Nobody actually used it. The card was declined, or the customer abandoned the Stripe payment page, but WooCommerce had already counted the coupon as spent the moment the order was created. Here is why that count never gets released and a small script that finds every failed order still holding a coupon slot and gives it back.
WooCommerce increases a coupon's usage_count, and adds the buyer's email to used_by, as soon as an order is placed, before the payment is confirmed. If that order later ends up Failed or Cancelled, the usage is supposed to be released, but a lot of failure paths skip that step, so the count stays inflated and a real customer can get locked out by usage_limit_per_user. Run a small Python or Node.js script on a schedule that reads recent failed and cancelled orders through the WooCommerce REST API, confirms with Stripe that no payment actually went through, and removes that order's entry from the coupon so the usage slot is free again. Full code, tests, and a dry run guard are below.
The problem in plain words
A WooCommerce coupon can be limited to a set number of total uses, or to one use per customer, through usage_limit and usage_limit_per_user. WooCommerce checks these limits by looking at two numbers stored on the coupon itself, a running usage_count and a list called used_by that records who has already used it.
The trouble is when those numbers get updated. WooCommerce increases them at the point an order is created with the coupon attached, while the order is still Pending, long before anyone knows whether the payment will actually succeed. If the card is declined, the customer closes the tab on the Stripe payment page, or the gateway returns an error, the order moves to Failed, or sometimes Cancelled. WooCommerce does have logic to release the usage when that happens, but it depends on the order passing through a specific status transition, and a surprising number of real failure paths route around it, direct database writes from an import tool, a payment plugin that sets the status without firing the normal WordPress hooks, or a High Performance Order Storage (HPOS) setup where a caching or sync plugin intercepts the change. The result is a coupon that looks fully used, or a customer who looks like they already redeemed it, even though no money ever moved.
Why it happens
Coupon usage tracking was built around the idea that an order failing would cleanly transition through a status change that triggers a release. In practice that assumption breaks in a few common ways:
- The order jumps straight from Pending to Failed without ever passing through Cancelled, and some release logic only listens for the cancelled transition.
- The customer abandons the page after Stripe declines the card or after starting a redirect based payment method, and the order sits on Pending or Failed with no further action ever recorded against it.
- A plugin, an import tool, or a direct database update changes the order status without going through the normal WordPress
woocommerce_order_status_changedhook, so nothing downstream ever runs. - The coupon has
usage_limit_per_userset to one, so a single stuck usage entry is enough to lock out that exact customer from ever trying the code again, even on a fresh order.
None of this shows up as an error anywhere. The store owner only finds out when a shopper says the discount code they were emailed will not apply, or when a coupon that should still have plenty of uses left reports it is exhausted.
Stripe is the source of truth for whether money moved. If an order is Failed or Cancelled and Stripe has no matching succeeded PaymentIntent for it, that order never earned its coupon usage. Any usage slot it is still holding is safe to give back, because the customer never actually redeemed the discount.
The fix, as a flow
We do not touch active orders, and we do not touch coupons in any way that affects real, successful redemptions. We add a job that runs on a schedule, looks at orders that failed or were cancelled, checks the coupon each one used, and confirms with Stripe that no payment succeeded for it. If the order still shows up in that coupon's used_by list, we remove the entry and decrease usage_count by one, the same repair a correct release would have made.
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. 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 LOOKBACK_DAYS="14"
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 LOOKBACK_DAYS="14"
export DRY_RUN="true" // start safe, change to false to write
List recent failed and cancelled orders that used a coupon
Ask the WooCommerce REST API for orders in the Failed and Cancelled statuses within your lookback window, and keep only the ones that have at least one coupon_lines entry. Orders without a coupon are not our concern here.
import 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 failed_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "failed,cancelled", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
if order.get("coupon_lines"):
yield order
page += 1
async function* failedOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=failed,cancelled&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) {
if (order.coupon_lines && order.coupon_lines.length) yield order;
}
page++;
}
}
Look up the Stripe payment and the coupon itself
Read the saved PaymentIntent ID from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent id. Then look up the coupon by its code through the WooCommerce REST API to get its current used_by list and usage_count.
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
def get_coupon_by_code(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
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;
}
}
async function getCouponByCode(code) {
const matches = await woo(`/coupons?code=${encodeURIComponent(code)}`);
return matches[0] || null;
}
Decide, with one pure function
Keep the decision in its own function that takes the order, the Stripe intent, and the coupon, 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. Skip anything that is not actually failed or cancelled. Skip anything where Stripe shows a real success, since that is a different bug, not this one. Skip anything already released. Otherwise, release it.
RELEASABLE_STATUSES = {"failed", "cancelled"}
def order_customer_key(order):
email = (order.get("billing") or {}).get("email")
if email:
return email
customer_id = order.get("customer_id")
return str(customer_id) if customer_id else None
def decide(order, intent, coupon):
if order.get("status") not in RELEASABLE_STATUSES:
return ("skip", "order did not fail, usage is legitimate")
if intent is not None and intent.get("status") == "succeeded":
return ("skip", "Stripe shows the payment succeeded, order status is wrong")
key = order_customer_key(order)
if not key:
return ("skip", "no billing email or customer id to match against used_by")
used_by = coupon.get("used_by") or []
if key not in used_by:
return ("skip", "coupon usage already released for this order")
return ("release", "failed order still holding a coupon usage slot")
const RELEASABLE_STATUSES = new Set(["failed", "cancelled"]);
export function orderCustomerKey(order) {
const email = order.billing && order.billing.email;
if (email) return email;
return order.customer_id ? String(order.customer_id) : null;
}
export function decide(order, intent, coupon) {
if (!RELEASABLE_STATUSES.has(order.status)) {
return ["skip", "order did not fail, usage is legitimate"];
}
if (intent && intent.status === "succeeded") {
return ["skip", "Stripe shows the payment succeeded, order status is wrong"];
}
const key = orderCustomerKey(order);
if (!key) return ["skip", "no billing email or customer id to match against used_by"];
const usedBy = coupon.used_by || [];
if (!usedBy.includes(key)) return ["skip", "coupon usage already released for this order"];
return ["release", "failed order still holding a coupon usage slot"];
}
Release the usage slot
When the action is release, remove the order's identity from the coupon's used_by list and lower usage_count by one, but never below zero. Both fields are writable through the same coupon update endpoint used to manage coupons from the admin screen.
def release_usage(coupon, key):
used_by = list(coupon.get("used_by") or [])
used_by.remove(key)
new_count = max(0, int(coupon.get("usage_count", 0)) - 1)
requests.put(
f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon['id']}",
json={"used_by": used_by, "usage_count": new_count},
auth=AUTH, timeout=30,
).raise_for_status()
async function releaseUsage(coupon, key) {
const usedBy = (coupon.used_by || []).filter((entry) => entry !== key);
const newCount = Math.max(0, Number(coupon.usage_count || 0) - 1);
await woo(`/coupons/${coupon.id}`, {
method: "PUT",
body: JSON.stringify({ used_by: usedBy, usage_count: newCount }),
});
}
Wire it together with a dry run guard
The loop ties every piece together, one coupon line at a time, since a single order can carry more than one coupon. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would release. Read the output, trust it, then switch it off to let it write. Run it once a day, or right after a sale where a lot of payments failed.
Always start with DRY_RUN=true. This script writes to real coupons, so you want to see its plan before it acts. Once the report looks right for a day, 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 is safe to run again and again because it never releases the same usage entry twice.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Release coupon usage that failed WooCommerce orders should not be holding.
WooCommerce increases a coupon's usage_count, and records the billing email under
_used_by, the moment an order is placed with that coupon attached, before payment is
confirmed. When the order later fails (a declined card, an abandoned Stripe
PaymentIntent, a gateway error), WooCommerce is supposed to release that usage back.
In practice a lot of failure paths never call it: the order goes straight from
pending to failed without passing through the cancelled transition, the store uses
High Performance Order Storage (HPOS) with a plugin that intercepts the status
change, or the failure happens on a redirect and the customer never returns to
trigger it. The coupon then looks used up, or a single customer looks like they hit
usage_limit_per_user, when the truth is Stripe never took a payment. This walks
recent failed orders, checks the Stripe PaymentIntent tied to the order (if any), and
for every failed order whose coupon usage was never released, removes that order's
email from the coupon's used_by list and decrements usage_count by one. Safe to run
again and again, since it never touches a coupon usage entry more than once. Read
only until DRY_RUN is turned off.
"""
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("release_failed_coupons")
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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RELEASABLE_STATUSES = {"failed", "cancelled"}
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_customer_key(order):
email = (order.get("billing") or {}).get("email")
if email:
return email
customer_id = order.get("customer_id")
return str(customer_id) if customer_id else None
def decide(order, intent, coupon):
if order.get("status") not in RELEASABLE_STATUSES:
return ("skip", "order did not fail, usage is legitimate")
if intent is not None and intent.get("status") == "succeeded":
return ("skip", "Stripe shows the payment succeeded, order status is wrong")
key = order_customer_key(order)
if not key:
return ("skip", "no billing email or customer id to match against used_by")
used_by = coupon.get("used_by") or []
if key not in used_by:
return ("skip", "coupon usage already released for this order")
return ("release", "failed order still holding a coupon usage slot")
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 failed_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "failed,cancelled", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
if order.get("coupon_lines"):
yield order
page += 1
def get_coupon_by_code(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 release_usage(coupon, key):
used_by = list(coupon.get("used_by") or [])
used_by.remove(key)
new_count = max(0, int(coupon.get("usage_count", 0)) - 1)
requests.put(
f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon['id']}",
json={"used_by": used_by, "usage_count": new_count},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
released = 0
for order in failed_orders():
intent = get_intent(intent_id_of(order))
for line in order["coupon_lines"]:
coupon = get_coupon_by_code(line["code"])
if coupon is None:
log.warning("Order %s used coupon %s which no longer exists", order["id"], line["code"])
continue
action, reason = decide(order, intent, coupon)
if action == "skip":
continue
key = order_customer_key(order)
log.info(
"Order %s / coupon %s: %s. %s",
order["id"], line["code"], reason, "would release" if DRY_RUN else "releasing",
)
if not DRY_RUN:
release_usage(coupon, key)
released += 1
log.info("Done. %d coupon usage slot(s) %s.", released, "to release" if DRY_RUN else "released")
if __name__ == "__main__":
run()
/**
* Release coupon usage that failed WooCommerce orders should not be holding.
* Safe to run again and again. Read only until DRY_RUN is turned off.
*/
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RELEASABLE_STATUSES = new Set(["failed", "cancelled"]);
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 orderCustomerKey(order) {
const email = order.billing && order.billing.email;
if (email) return email;
return order.customer_id ? String(order.customer_id) : null;
}
export function decide(order, intent, coupon) {
if (!RELEASABLE_STATUSES.has(order.status)) {
return ["skip", "order did not fail, usage is legitimate"];
}
if (intent && intent.status === "succeeded") {
return ["skip", "Stripe shows the payment succeeded, order status is wrong"];
}
const key = orderCustomerKey(order);
if (!key) return ["skip", "no billing email or customer id to match against used_by"];
const usedBy = coupon.used_by || [];
if (!usedBy.includes(key)) return ["skip", "coupon usage already released for this order"];
return ["release", "failed order still holding a coupon usage slot"];
}
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 getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function* failedOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=failed,cancelled&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) {
if (order.coupon_lines && order.coupon_lines.length) yield order;
}
page++;
}
}
async function getCouponByCode(code) {
const matches = await woo(`/coupons?code=${encodeURIComponent(code)}`);
return matches[0] || null;
}
async function releaseUsage(coupon, key) {
const usedBy = (coupon.used_by || []).filter((entry) => entry !== key);
const newCount = Math.max(0, Number(coupon.usage_count || 0) - 1);
await woo(`/coupons/${coupon.id}`, {
method: "PUT",
body: JSON.stringify({ used_by: usedBy, usage_count: newCount }),
});
}
export async function run() {
let released = 0;
for await (const order of failedOrders()) {
const intent = await getIntent(intentIdOf(order));
for (const line of order.coupon_lines) {
const coupon = await getCouponByCode(line.code);
if (!coupon) {
console.warn(`Order ${order.id} used coupon ${line.code} which no longer exists`);
continue;
}
const [action, reason] = decide(order, intent, coupon);
if (action === "skip") continue;
const key = orderCustomerKey(order);
console.log(`Order ${order.id} / coupon ${line.code}: ${reason}. ${DRY_RUN ? "would release" : "releasing"}`);
if (!DRY_RUN) await releaseUsage(coupon, key);
released++;
}
}
console.log(`Done. ${released} coupon usage slot(s) ${DRY_RUN ? "to release" : "released"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which coupon usage entries get removed. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from release_failed_coupons import decide, intent_id_of, order_customer_key
def intent(**over):
base = {"status": "requires_payment_method"}
base.update(over)
return base
def coupon(**over):
base = {"used_by": ["shopper@example.com"], "usage_count": 1}
base.update(over)
return base
def order(**over):
base = {"status": "failed", "billing": {"email": "shopper@example.com"}, "customer_id": 0}
base.update(over)
return base
def test_release_when_failed_and_intent_not_succeeded():
assert decide(order(), intent(), coupon())[0] == "release"
def test_release_when_no_intent_at_all():
assert decide(order(), None, coupon())[0] == "release"
def test_skip_when_order_not_failed_or_cancelled():
assert decide(order(status="processing"), intent(), coupon())[0] == "skip"
def test_skip_when_stripe_actually_succeeded():
assert decide(order(), intent(status="succeeded"), coupon())[0] == "skip"
def test_skip_when_already_released():
c = coupon(used_by=[])
assert decide(order(), intent(), c)[0] == "skip"
def test_cancelled_order_also_eligible():
assert decide(order(status="cancelled"), intent(), coupon())[0] == "release"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, orderCustomerKey } from "./release-failed-coupons.js";
const intent = (over = {}) => ({ status: "requires_payment_method", ...over });
const coupon = (over = {}) => ({ used_by: ["shopper@example.com"], usage_count: 1, ...over });
const order = (over = {}) => ({
status: "failed",
billing: { email: "shopper@example.com" },
customer_id: 0,
...over,
});
test("release when failed and intent not succeeded", () => {
assert.equal(decide(order(), intent(), coupon())[0], "release");
});
test("release when no intent at all", () => {
assert.equal(decide(order(), null, coupon())[0], "release");
});
test("skip when order not failed or cancelled", () => {
assert.equal(decide(order({ status: "processing" }), intent(), coupon())[0], "skip");
});
test("skip when Stripe actually succeeded", () => {
assert.equal(decide(order(), intent({ status: "succeeded" }), coupon())[0], "skip");
});
test("skip when already released", () => {
assert.equal(decide(order(), intent(), coupon({ used_by: [] }))[0], "skip");
});
test("cancelled order also eligible", () => {
assert.equal(decide(order({ status: "cancelled" }), intent(), coupon())[0], "release");
});
Case studies
The customer locked out of their own welcome code
A new subscriber's welcome coupon had usage_limit_per_user set to one. Her first card was declined for insufficient funds, the order went to Failed, and the usage was never released. When she tried again with a different card ten minutes later, WooCommerce told her the coupon had already been used.
Running the script in dry run mode showed her failed order still listed under the coupon's used_by. One release call later, her retry went through with the discount applied, no support ticket needed.
The storewide code that ran out early
A storewide 20 percent off code had a total usage_limit of 500. Partway through the weekend it reported as exhausted, but the store had only shipped about 340 real orders with it. A payment gateway hiccup during a traffic spike had failed a batch of checkouts, and each one had already counted against the limit.
The team ran the script in dry run first, confirmed the 140 or so failed orders it flagged had no matching Stripe charge, then let it write for real. The coupon's usage count dropped back in line with actual sales and kept working for the rest of the weekend.
After this runs on a schedule, a declined card or an abandoned checkout is no longer a permanent tax on your coupon's usage limit. Real customers stop getting turned away from codes they never actually redeemed, and total usage limits stay an accurate reflection of real, paid orders. Keep it running even after your gateway is stable, because payments will always fail once in a while.
FAQ
Why does a WooCommerce coupon say it is used up when nobody actually paid?
WooCommerce counts a coupon as used the moment an order is placed with it, before the payment is confirmed. If that order later fails, a declined card, an abandoned checkout, a gateway error, WooCommerce is supposed to release the usage back, but many failure paths never trigger that release, so the usage count and the customer's used_by entry stay stuck.
Is it safe to edit a coupon's usage count with a script?
Yes, when the script only releases usage for orders that are failed or cancelled, confirms with Stripe that no successful payment exists for that order, and only removes the exact identity that is still listed on the coupon. Start in dry run mode to see the full list before it writes anything.
How often should I run the coupon release script?
Once a day, or right after a sale with a lot of failed payments, is enough for most stores. It only touches orders that already failed or were cancelled, so running it often carries very little risk.
Related field notes
Citations
On the problem:
- WooCommerce docs: how coupon usage limits, usage_count, and used_by work. woocommerce.com/document/coupon-management
- WooCommerce core source: coupon usage is recorded when the order is created, and released on specific status changes. github.com/woocommerce/woocommerce class-wc-coupon.php
- WooCommerce core issue: coupon usage count not decreased when an order fails. github.com/woocommerce/woocommerce/issues/21455
On the solution:
- WooCommerce REST API: retrieve and update coupons, including used_by and usage_count. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: list and filter orders by status. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm its final status. 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 free up your coupon?
If this saved a customer's discount or fixed a coupon that looked exhausted too soon, 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