Reconciler WooCommerce Subscriptions: switches, coupons, and data
Limited-payment coupon miscounts
A coupon is set to discount only a subscriber's first few renewal payments, say the first three, then go away. Somewhere along the way that stops being true. A subscriber gets the discount on payment five, or it disappears on payment two. The one number WooCommerce Subscriptions uses to track "how many payments has this coupon already discounted" has lost count. Here is why that counter drifts and a small job that recounts it from real paid orders and repairs the rest.
WooCommerce Subscriptions stores a per-coupon, per-subscription counter that says how many renewal payments the coupon has already discounted. Each successful renewal that carries the coupon should add one to it. A failed renewal that gets retried, a plan switch that recreates line items, or a manual order edit can add one twice or skip the step, so the counter and reality disagree. Run a small Python or Node.js job on a schedule that recounts the real number of paid renewal orders carrying the coupon (each one confirmed against Stripe by its PaymentIntent), compares that to the stored counter, and corrects it when they disagree. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions has a coupon setting called "Active for x payments." It lets a store offer a discount for a limited stretch, like fifty percent off the first three months, then full price after. To make that work, the plugin has to remember, per subscriber, how many of those discounted payments have already happened.
That memory is a single number saved on the subscription. Every time a renewal goes through with the coupon attached, the plugin is supposed to add one to that number. Once the number reaches the limit, the coupon stops applying. The trouble starts when a renewal does not go cleanly, for example the card is declined and the payment is retried, or the plan is switched and its line items are rebuilt. The counter can be nudged twice for one real payment, or never nudged at all, and from that point on it no longer matches how many payments actually happened.
Why it happens
The counter is meant to move in lockstep with real, paid renewals, but a few common situations break that lockstep:
- A renewal fails and WooCommerce Subscriptions retries it. Depending on when the counter update runs relative to the retry, the same real payment can be counted once, twice, or, in the opposite direction, not at all if the increment fires on the failed attempt and never again.
- A subscriber switches plans mid-cycle. Switching regenerates the subscription's line items, and a coupon that was meant to carry its count forward can instead reset, or the reverse, keep counting against a coupon the new plan no longer even has.
- A shop manager manually edits a renewal order, for example to fix a price, without WooCommerce Subscriptions replaying the coupon logic that would have adjusted the counter.
- A store migrates orders or subscriptions between systems and the counter meta does not travel with the rest of the subscription's history.
Whatever the cause, the result is the same: the stored counter and the real number of discounted, paid renewals disagree, and either the store keeps giving away a discount it meant to stop, or a subscriber loses a discount they are still owed.
The renewal orders are the source of truth, not the counter. A paid renewal order that carries the coupon line and whose PaymentIntent Stripe confirms as succeeded is a real discounted payment. Count those orders directly and the true number falls out on its own, no matter how the stored counter got confused along the way.
The fix, as a flow
We do not touch the live checkout or the coupon logic itself. We add a job that runs on a schedule, finds every subscription carrying the coupon, and recounts the coupon's real payment count from that subscription's own renewal order history. If the true count and the stored counter disagree, we write the true count back and leave a note, the same way a careful shop manager would after checking the orders by hand.
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 subscriptions 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 COUPON_CODE="vip10"
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_CODE="vip10"
export DRY_RUN="true" // start safe, change to false to write
Find every subscription carrying the coupon
Page through subscriptions from the WooCommerce REST API and keep the ones whose coupon lines include the code you are checking. This is the same list a shop manager would build by hand, just done for every subscriber at once.
import requests
from requests.auth import HTTPBasicAuth
WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")
def subscriptions_with_coupon(coupon_code):
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"per_page": 50, "page": page}, auth=AUTH, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
codes = {(l.get("code") or "").lower() for l in sub.get("coupon_lines") or []}
if coupon_code.lower() in codes:
yield sub
page += 1
async function* subscriptionsWithCoupon(couponCode) {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) {
const codes = new Set((sub.coupon_lines || []).map((l) => (l.code || "").toLowerCase()));
if (codes.has(couponCode.toLowerCase())) yield sub;
}
page++;
}
}
Recount the true number of discounted payments
For each subscription, load its renewal orders and confirm each one's saved PaymentIntent against Stripe. Only a renewal that is genuinely paid, carries the coupon, and Stripe confirms as succeeded counts as a real discounted payment. This keeps the count honest even if an order's status was set by hand.
PAID_ORDER_STATUSES = {"processing", "completed"}
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_applied_coupon(order, coupon_code):
for line in order.get("coupon_lines") or []:
if (line.get("code") or "").lower() == coupon_code.lower():
return True
return False
def true_payment_count(renewal_orders, coupon_code, verified_intent_ids):
count = 0
for order in renewal_orders:
if order.get("status") not in PAID_ORDER_STATUSES:
continue
if not order_applied_coupon(order, coupon_code):
continue
intent_id = intent_id_of(order)
if intent_id is not None and intent_id not in verified_intent_ids:
continue
count += 1
return count
const PAID_ORDER_STATUSES = new Set(["processing", "completed"]);
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 orderAppliedCoupon(order, couponCode) {
for (const line of order.coupon_lines || []) {
if ((line.code || "").toLowerCase() === couponCode.toLowerCase()) return true;
}
return false;
}
export function truePaymentCount(renewalOrders, couponCode, verifiedIntentIds) {
let count = 0;
for (const order of renewalOrders) {
if (!PAID_ORDER_STATUSES.has(order.status)) continue;
if (!orderAppliedCoupon(order, couponCode)) continue;
const intentId = intentIdOf(order);
if (intentId !== null && !verifiedIntentIds.has(intentId)) continue;
count++;
}
return count;
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription, the coupon code, and the recounted true value, and returns an action. The rule is simple. If there is no stored counter yet, flag it as unknown and leave it alone. If the stored counter already matches the true count, skip it. Otherwise, repair it.
COUNTER_META_PREFIX = "_coupon_number_payments_"
def stored_counter(subscription, coupon_code):
key = f"{COUNTER_META_PREFIX}{coupon_code.lower()}"
for meta in subscription.get("meta_data") or []:
if meta.get("key") == key:
try:
return int(meta["value"])
except (TypeError, ValueError):
return None
return None
def decide(subscription, coupon_code, true_count):
stored = stored_counter(subscription, coupon_code)
if stored is None:
return ("unknown", "no stored counter found for this coupon")
if stored == true_count:
return ("skip", "counter already matches the real payment count")
direction = "ahead of" if stored > true_count else "behind"
return ("repair", f"stored counter ({stored}) is {direction} the real count ({true_count})")
const COUNTER_META_PREFIX = "_coupon_number_payments_";
export function storedCounter(subscription, couponCode) {
const key = `${COUNTER_META_PREFIX}${couponCode.toLowerCase()}`;
for (const meta of subscription.meta_data || []) {
if (meta.key === key) {
const n = parseInt(meta.value, 10);
return Number.isNaN(n) ? null : n;
}
}
return null;
}
export function decide(subscription, couponCode, trueCount) {
const stored = storedCounter(subscription, couponCode);
if (stored === null) return ["unknown", "no stored counter found for this coupon"];
if (stored === trueCount) return ["skip", "counter already matches the real payment count"];
const direction = stored > trueCount ? "ahead of" : "behind";
return ["repair", `stored counter (${stored}) is ${direction} the real count (${trueCount})`];
}
Write the true count back and leave a note
When the action is repair, save the recounted true value onto the subscription's counter meta, then add a note so the shop manager can see the counter was corrected and to what value. Both go through the REST API, so High Performance Order Storage stores are handled the same as classic ones.
def repair_counter(subscription_id, coupon_code, true_count):
key = f"{COUNTER_META_PREFIX}{coupon_code.lower()}"
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"meta_data": [{"key": key, "value": str(true_count)}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Coupon '{coupon_code}' payment counter recounted from order history "
f"and corrected to {true_count}."},
auth=AUTH, timeout=30,
).raise_for_status()
async function repairCounter(subscriptionId, couponCode, trueCount) {
const key = `${COUNTER_META_PREFIX}${couponCode.toLowerCase()}`;
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key, value: String(trueCount) }] }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Coupon '${couponCode}' payment counter recounted from order history ` +
`and corrected to ${trueCount}.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports what it would repair. Read the output, trust it, then switch it off to let it write. Run it once a day with cron, or right after you notice a mismatch.
Always start with DRY_RUN=true. This job writes to a subscription's own meta, so you want to see its plan before it acts. Once the report looks right for a handful of subscriptions you check by hand, turn it off.
The full code
Here is the complete recount 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 it only ever writes the recounted true value.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Recount and repair a WooCommerce Subscriptions coupon limited to N renewal payments.
A coupon can be set to discount only a subscription's first N renewal payments
(the "Active for x payments" field WooCommerce Subscriptions adds to a coupon).
Each subscription keeps a running counter of how many payments that coupon has
already discounted, in item meta on the subscription. A failed-then-retried
renewal, or a plan switch, can make that counter skip a count or add one twice,
so the coupon keeps discounting past its real limit (a quiet revenue leak) or
stops discounting a payment early (a support ticket).
This walks subscriptions carrying the coupon, recounts the payments it should
have discounted by looking at the subscription's own paid renewal order
history (each renewal order's line item carries a coupon snapshot with the
per-payment discount total, and its PaymentIntent is confirmed against
Stripe), compares that to the stored counter, and repairs the counter when it
disagrees. Read only by default. Run on a schedule.
"""
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_limited_payment_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", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
COUNTER_META_PREFIX = "_coupon_number_payments_"
PAID_ORDER_STATUSES = {"processing", "completed"}
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 stored_counter(subscription, coupon_code):
"""The counter WooCommerce Subscriptions keeps for this coupon on this subscription."""
key = f"{COUNTER_META_PREFIX}{coupon_code.lower()}"
for meta in subscription.get("meta_data") or []:
if meta.get("key") == key:
try:
return int(meta["value"])
except (TypeError, ValueError):
return None
return None
def renewal_order_ids(subscription):
"""Renewal order ids attached to the subscription, oldest first."""
ids = subscription.get("renewal_order_ids")
if ids is None:
ids = [ro.get("id") for ro in subscription.get("_links", {}).get("renewals", [])]
return list(ids or [])
def order_applied_coupon(order, coupon_code):
"""True if this order's line items show the coupon code was applied."""
for line in order.get("coupon_lines") or []:
if (line.get("code") or "").lower() == coupon_code.lower():
return True
return False
def true_payment_count(renewal_orders, coupon_code, verified_intent_ids):
"""Recount from real orders: paid, coupon applied, and Stripe confirms the charge.
renewal_orders: list of WooCommerce order dicts for the subscription's renewals.
verified_intent_ids: set of PaymentIntent ids Stripe reports as succeeded.
"""
count = 0
for order in renewal_orders:
if order.get("status") not in PAID_ORDER_STATUSES:
continue
if not order_applied_coupon(order, coupon_code):
continue
intent_id = intent_id_of(order)
if intent_id is not None and intent_id not in verified_intent_ids:
# Stripe does not confirm this one, do not count it as a real payment.
continue
count += 1
return count
def decide(subscription, coupon_code, true_count):
"""Pure decision: compare the stored counter to the recounted truth.
Returns (action, reason) where action is one of:
"skip" stored counter already matches the true count
"repair" stored counter is wrong and should be written back
"unknown" the subscription has no stored counter for this coupon yet
"""
stored = stored_counter(subscription, coupon_code)
if stored is None:
return ("unknown", "no stored counter found for this coupon")
if stored == true_count:
return ("skip", "counter already matches the real payment count")
direction = "ahead of" if stored > true_count else "behind"
return ("repair", f"stored counter ({stored}) is {direction} the real count ({true_count})")
def woo_get(path, params=None):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def subscriptions_with_coupon(coupon_code):
page = 1
while True:
batch = woo_get("/subscriptions", params={"per_page": 50, "page": page})
if not batch:
return
for sub in batch:
codes = {(line.get("code") or "").lower() for line in sub.get("coupon_lines") or []}
if coupon_code.lower() in codes:
yield sub
page += 1
def get_renewal_orders(subscription):
return [woo_get(f"/orders/{oid}") for oid in renewal_order_ids(subscription)]
def verify_intents(order_ids_with_intents):
"""Ask Stripe which of these PaymentIntent ids are really succeeded."""
verified = set()
for intent_id in order_ids_with_intents:
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
continue
if intent.status == "succeeded":
verified.add(intent_id)
return verified
def repair_counter(subscription_id, coupon_code, true_count):
key = f"{COUNTER_META_PREFIX}{coupon_code.lower()}"
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"meta_data": [{"key": key, "value": str(true_count)}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Coupon '{coupon_code}' payment counter recounted from order history "
f"and corrected to {true_count}."},
auth=AUTH, timeout=30,
).raise_for_status()
def run(coupon_code):
repaired = 0
for subscription in subscriptions_with_coupon(coupon_code):
renewals = get_renewal_orders(subscription)
intent_ids = {intent_id_of(o) for o in renewals if intent_id_of(o)}
verified = verify_intents(intent_ids)
true_count = true_payment_count(renewals, coupon_code, verified)
action, reason = decide(subscription, coupon_code, true_count)
if action in ("skip", "unknown"):
if action == "unknown":
log.warning("Subscription %s: %s", subscription["id"], reason)
continue
log.info("Subscription %s: %s. %s", subscription["id"], reason,
"would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
repair_counter(subscription["id"], coupon_code, true_count)
repaired += 1
log.info("Done. %d subscription(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")
if __name__ == "__main__":
run(os.environ.get("COUPON_CODE", "vip10"))
/**
* Recount and repair a WooCommerce Subscriptions coupon limited to N renewal payments.
*
* A coupon can be set to discount only a subscription's first N renewal payments
* (the "Active for x payments" field WooCommerce Subscriptions adds to a coupon).
* Each subscription keeps a running counter of how many payments that coupon has
* already discounted, in item meta on the subscription. A failed-then-retried
* renewal, or a plan switch, can make that counter skip a count or add one twice,
* so the coupon keeps discounting past its real limit (a quiet revenue leak) or
* stops discounting a payment early (a support ticket).
*
* This walks subscriptions carrying the coupon, recounts the payments it should
* have discounted by looking at the subscription's own paid renewal order
* history (each renewal order's line item carries a coupon snapshot, and its
* PaymentIntent is confirmed against Stripe), compares that to the stored
* counter, and repairs the counter when it disagrees. Read only by default.
* Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/limited-payment-coupon-miscounts/
*/
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";
const COUPON_CODE = process.env.COUPON_CODE || "vip10";
const COUNTER_META_PREFIX = "_coupon_number_payments_";
const PAID_ORDER_STATUSES = new Set(["processing", "completed"]);
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 storedCounter(subscription, couponCode) {
const key = `${COUNTER_META_PREFIX}${couponCode.toLowerCase()}`;
for (const meta of subscription.meta_data || []) {
if (meta.key === key) {
const n = parseInt(meta.value, 10);
return Number.isNaN(n) ? null : n;
}
}
return null;
}
export function orderAppliedCoupon(order, couponCode) {
for (const line of order.coupon_lines || []) {
if ((line.code || "").toLowerCase() === couponCode.toLowerCase()) return true;
}
return false;
}
export function truePaymentCount(renewalOrders, couponCode, verifiedIntentIds) {
let count = 0;
for (const order of renewalOrders) {
if (!PAID_ORDER_STATUSES.has(order.status)) continue;
if (!orderAppliedCoupon(order, couponCode)) continue;
const intentId = intentIdOf(order);
if (intentId !== null && !verifiedIntentIds.has(intentId)) continue;
count++;
}
return count;
}
export function decide(subscription, couponCode, trueCount) {
const stored = storedCounter(subscription, couponCode);
if (stored === null) return ["unknown", "no stored counter found for this coupon"];
if (stored === trueCount) return ["skip", "counter already matches the real payment count"];
const direction = stored > trueCount ? "ahead of" : "behind";
return ["repair", `stored counter (${stored}) is ${direction} the real count (${trueCount})`];
}
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();
}
function renewalOrderIds(subscription) {
return subscription.renewal_order_ids || [];
}
async function* subscriptionsWithCoupon(couponCode) {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) {
const codes = new Set((sub.coupon_lines || []).map((l) => (l.code || "").toLowerCase()));
if (codes.has(couponCode.toLowerCase())) yield sub;
}
page++;
}
}
async function getRenewalOrders(subscription) {
return Promise.all(renewalOrderIds(subscription).map((id) => woo(`/orders/${id}`)));
}
async function verifyIntents(intentIds) {
const verified = new Set();
for (const intentId of intentIds) {
let intent;
try {
intent = await stripe.paymentIntents.retrieve(intentId);
} catch {
continue;
}
if (intent.status === "succeeded") verified.add(intentId);
}
return verified;
}
async function repairCounter(subscriptionId, couponCode, trueCount) {
const key = `${COUNTER_META_PREFIX}${couponCode.toLowerCase()}`;
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key, value: String(trueCount) }] }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Coupon '${couponCode}' payment counter recounted from order history ` +
`and corrected to ${trueCount}.`,
}),
});
}
export async function run(couponCode = COUPON_CODE) {
let repaired = 0;
for await (const subscription of subscriptionsWithCoupon(couponCode)) {
const renewals = await getRenewalOrders(subscription);
const intentIds = new Set(renewals.map(intentIdOf).filter(Boolean));
const verified = await verifyIntents(intentIds);
const trueCount = truePaymentCount(renewals, couponCode, verified);
const [action, reason] = decide(subscription, couponCode, trueCount);
if (action === "skip" || action === "unknown") {
if (action === "unknown") console.warn(`Subscription ${subscription.id}: ${reason}`);
continue;
}
console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await repairCounter(subscription.id, couponCode, trueCount);
repaired++;
}
console.log(`Done. ${repaired} subscription(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}
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 recount rule are the parts most worth testing, because together they decide whether a real subscriber's discount gets changed. Because both stayed pure, no network and no Stripe account are needed. The tests just feed in plain objects and a set of already-verified intent ids, and check the result.
from recount_limited_payment_coupons import (
decide,
stored_counter,
order_applied_coupon,
true_payment_count,
)
def subscription(counter=None, code="vip10"):
meta = []
if counter is not None:
meta.append({"key": f"_coupon_number_payments_{code.lower()}", "value": str(counter)})
return {"id": 42, "meta_data": meta}
def order(status="processing", code="vip10", intent_id="pi_1"):
o = {"status": status, "coupon_lines": [{"code": code}]}
if intent_id is not None:
o["meta_data"] = [{"key": "_stripe_intent_id", "value": intent_id}]
return o
def test_stored_counter_reads_the_right_meta_key():
sub = subscription(counter=3)
assert stored_counter(sub, "VIP10") == 3
def test_true_payment_count_only_counts_paid_orders_with_the_coupon():
orders = [
order(status="processing", intent_id="pi_1"),
order(status="pending", intent_id="pi_2"),
order(status="processing", code="other", intent_id="pi_3"),
]
verified = {"pi_1", "pi_2", "pi_3"}
assert true_payment_count(orders, "vip10", verified) == 1
def test_true_payment_count_skips_orders_stripe_does_not_confirm():
orders = [order(status="processing", intent_id="pi_1"), order(status="processing", intent_id="pi_2")]
verified = {"pi_1"} # pi_2 was never confirmed succeeded by Stripe
assert true_payment_count(orders, "vip10", verified) == 1
def test_decide_repair_when_counter_is_ahead():
sub = subscription(counter=5)
action, reason = decide(sub, "vip10", 2)
assert action == "repair"
assert "ahead of" in reason
def test_decide_skip_when_counter_matches():
sub = subscription(counter=2)
assert decide(sub, "vip10", 2)[0] == "skip"
def test_decide_unknown_when_no_counter_stored():
sub = subscription(counter=None)
assert decide(sub, "vip10", 2)[0] == "unknown"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, storedCounter, truePaymentCount } from "./recount-limited-payment-coupons.js";
const subscription = (counter = null, code = "vip10") => {
const meta = [];
if (counter !== null) meta.push({ key: `_coupon_number_payments_${code.toLowerCase()}`, value: String(counter) });
return { id: 42, meta_data: meta };
};
const order = (status = "processing", code = "vip10", intentId = "pi_1") => {
const o = { status, coupon_lines: [{ code }] };
if (intentId !== null) o.meta_data = [{ key: "_stripe_intent_id", value: intentId }];
return o;
};
test("storedCounter reads the right meta key", () => {
assert.equal(storedCounter(subscription(3), "VIP10"), 3);
});
test("truePaymentCount only counts paid orders with the coupon", () => {
const orders = [
order("processing", "vip10", "pi_1"),
order("pending", "vip10", "pi_2"),
order("processing", "other", "pi_3"),
];
const verified = new Set(["pi_1", "pi_2", "pi_3"]);
assert.equal(truePaymentCount(orders, "vip10", verified), 1);
});
test("truePaymentCount skips orders Stripe does not confirm", () => {
const orders = [order("processing", "vip10", "pi_1"), order("processing", "vip10", "pi_2")];
const verified = new Set(["pi_1"]);
assert.equal(truePaymentCount(orders, "vip10", verified), 1);
});
test("decide repair when counter is ahead", () => {
const [action, reason] = decide(subscription(5), "vip10", 2);
assert.equal(action, "repair");
assert.match(reason, /ahead of/);
});
test("decide skip when counter matches", () => {
assert.equal(decide(subscription(2), "vip10", 2)[0], "skip");
});
test("decide unknown when no counter stored", () => {
assert.equal(decide(subscription(null), "vip10", 2)[0], "unknown");
});
Case studies
The subscriber who kept the discount forever
A coupon was meant to discount only a subscriber's first three renewals. A card decline in month two led to a retry that both increased the counter on the failed attempt and again on the successful retry. The counter reached three after only two real discounted payments, then stalled below the limit on every later renewal because a second unrelated decline repeated the same pattern in the other direction.
The recount job read the subscriber's actual renewal orders, found only two real discounted payments confirmed by Stripe, and corrected the counter to two. The coupon then applied for one more renewal as intended and stopped cleanly.
The switch that reset a counter mid-way
A subscriber switched plans in month two of a three month "half off" coupon. The switch rebuilt the subscription's line items and the counter meta was not carried over, so it silently reset to zero. The coupon then applied for three more renewals after the switch, five discounted payments in total instead of three.
Running the job in dry run surfaced the mismatch immediately: two payments already discounted before the switch, three more counted as new. The team corrected the counter to the true total and the coupon stopped applying on the next renewal.
After this runs on a schedule, a limited-payment coupon's counter is never more than a day out of date with what actually happened. Declines, retries, and plan switches stop being able to quietly extend or cut short a discount. Keep the job running even after the root cause is patched, since retries and switches will keep happening.
FAQ
Why does my limited-payment coupon keep discounting after it should have stopped?
WooCommerce Subscriptions keeps a per-subscription counter of how many renewal payments a coupon has already discounted. A failed renewal that gets retried, or a plan switch, can make that counter skip a step, so it never reaches the real limit and keeps discounting. Recounting the real number from paid renewal orders and writing the true count back fixes it.
Is it safe to change a coupon's payment counter with a script?
Yes, when the script counts only renewal orders that are genuinely paid, carry the coupon, and are confirmed against Stripe by their PaymentIntent, then writes that real count back instead of guessing. 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, since renewals only happen a handful of times a day per subscription. It only reads orders and corrects a single counter, so running it more often is safe and cheap.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: coupons, including the "Active for x payments" field for limited-payment discounts. woocommerce.com/document/subscriptions/store-manager-guide/coupons
- WooCommerce Subscriptions docs: how switching a subscription rebuilds its line items and related data. woocommerce.com/document/subscriptions/upgrading-downgrading
- WooCommerce Subscriptions docs: renewal order behavior on failed and retried payments. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- WooCommerce REST API: list and update subscriptions, and add subscription notes. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: read an order's coupon lines and transaction id. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm its status before trusting a stored count. 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 counts?
If this saved you from a quiet revenue leak or an angry subscriber ticket, 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