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.

Python and Node.js Runs on a schedule Safe by default (dry run)
Text
Photo by Tamanna Rumee on Unsplash
The short answer

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.

Renewal attempt 1 card declined Counter += 1 counted before it was paid Retry succeeds real payment happens Counter += 1 again one real payment, two counts Stored count: 2 real paid renewals: 1 ?
A declined attempt and its retry both nudge the same counter, so the stored count runs ahead of how many payments were actually discounted.

Why it happens

The counter is meant to move in lockstep with real, paid renewals, but a few common situations break that lockstep:

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 key insight

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.

Scheduled job once a day Find subscriptions carrying the coupon Recount paid renewals confirmed against Stripe Counter matches? yes, skip no, repair Write true count save counter + note
The job recounts the truth from paid, Stripe-confirmed renewal orders and only writes the counter when it disagrees with what is stored. A matching counter is left untouched.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export COUPON_CODE="vip10"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export COUPON_CODE="vip10"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

step2.py
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
step2.js
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++;
  }
}
3

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.

recount.py
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
recount.js
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;
}
4

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.

decide.py
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})")
decide.js
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})`];
}
5

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.

apply.py
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()
apply.js
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}.`,
    }),
  });
}
6

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.

Run it safe

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_limited_payment_coupons.py
"""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-limited-payment-coupons.js
/**
 * 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.

test_limited_payment_decide.py
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"
recount-limited-payment-coupons.test.js
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

Declined and retried

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.

Plan switch

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.

What good looks like

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:

  1. WooCommerce Subscriptions docs: coupons, including the "Active for x payments" field for limited-payment discounts. woocommerce.com/document/subscriptions/store-manager-guide/coupons
  2. WooCommerce Subscriptions docs: how switching a subscription rebuilds its line items and related data. woocommerce.com/document/subscriptions/upgrading-downgrading
  3. WooCommerce Subscriptions docs: renewal order behavior on failed and retried payments. woocommerce.com/document/subscriptions/renewal-process

On the solution:

  1. WooCommerce REST API: list and update subscriptions, and add subscription notes. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: read an order's coupon lines and transaction id. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes