Diagnostic WooCommerce Subscriptions: status and renewals

A subscription renewal is marked paid, but no payment ever happened

The renewal order says Processing. The subscription's next payment date rolled forward. The customer still has access. Everything in WooCommerce looks correct. Except Stripe never took a charge for it. No PaymentIntent, no invoice, no money. This is quiet, because nothing errors and no one complains, until the payout report comes up short. Here is why it happens and a small script that finds every renewal it happened to and flags it for review.

Python and Node.js Runs on a schedule Safe by default (dry run)
A brown gift box with a pink ribbon
Photo by Jess Bailey on Unsplash
The short answer

A caching bug or a race between two renewal attempts can let the renewal handler complete an order and extend a subscription without Stripe ever showing a succeeded charge. Run a small Python or Node.js script on a schedule that reads recent renewal orders from the WooCommerce REST API, looks up the saved Stripe PaymentIntent for each one, and flags any renewal marked paid whose PaymentIntent is missing, not succeeded, or the wrong amount. It is read only by default, with an optional on-hold flag for a human to review. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions creates a renewal order for every billing cycle and hands it to the payment gateway. The gateway is supposed to charge the card, then only mark the order paid once Stripe confirms the charge succeeded. That is the entire contract: no confirmed charge, no paid order.

A caching layer sitting in front of that check, or two renewal attempts firing close together, can break the contract. One path reads a stale "already handled" flag from cache and takes the success branch without ever calling Stripe. Or two processes both start a renewal, one succeeds, and the other completes the order a second time based on old state instead of asking Stripe again. Either way, the order is marked Processing, the subscription's next payment date moves forward, the customer keeps their access, and no PaymentIntent for that amount exists on the Stripe side at all.

Renewal due two attempts fire Attempt A charges Stripe, succeeds Attempt B reads stale cache no charge made Success path runs order marked paid Sub extended no money moved
The order and the subscription both look correct. Only Stripe knows no charge was ever made for this renewal.

Why it happens

WooCommerce Subscriptions and the payment gateway are supposed to only mark a renewal paid after a confirmed charge, but a few real conditions let that check get skipped:

None of these throw an error. The order looks finished, the subscription looks active, and the first sign of trouble is usually a payout total that does not match the number of active subscribers, weeks later.

The key insight

For a renewal order, being "paid" in WooCommerce and being "paid" in Stripe must always be checked against each other, not assumed from the order status alone. If the order is Processing or Completed but Stripe has no matching succeeded PaymentIntent for the right amount, the order is wrong, not Stripe. A read-only diagnostic that checks every recent renewal catches this even when nothing else in the store is complaining.

The fix, as a flow

We do not touch the renewal logic itself. We add a job that runs on a schedule, pulls recent renewal orders, and for each one that is marked paid, reads the saved Stripe PaymentIntent id and checks it against Stripe. If Stripe agrees the charge succeeded and the amount matches, we leave it alone. If it does not, we add an order note explaining exactly what mismatched, and, only if you turn it on, put the order on hold so a person reviews it before anything else happens.

Scheduled job once a day List renewal orders marked paid, recent Read PaymentIntent id from order meta Stripe agrees, amount ok? yes, leave alone no Flag and hold note + review status
The diagnostic only ever adds a note by default. The on-hold step is opt in, and cancelling the subscription is always left to a person.

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 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 LOOKBACK_DAYS="7"
export REVIEW_HOLD="false"   # true also moves flagged renewals to on-hold
export DRY_RUN="true"        # start safe, change to false to write notes
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 LOOKBACK_DAYS="7"
export REVIEW_HOLD="false"   // true also moves flagged renewals to on-hold
export DRY_RUN="true"        // start safe, change to false to write notes
2

Pull recent renewal orders that are marked paid

WooCommerce Subscriptions tags every renewal order with the meta key _subscription_renewal, pointing at the parent subscription id. Ask the WooCommerce REST API for recent orders in a paid status and keep only the ones carrying that meta key, so we never touch a regular one-time order.

step2.py
import os, datetime, 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 is_renewal(order):
    return any(m.get("key") == "_subscription_renewal" for m in order.get("meta_data") or [])

def paid_renewal_orders(lookback_days):
    after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "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 is_renewal(order):
                yield order
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

function isRenewal(order) {
  return (order.meta_data || []).some((m) => m.key === "_subscription_renewal");
}

async function* paidRenewalOrders(lookbackDays) {
  const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const res = await fetch(
      `${WOO_URL}/wp-json/wc/v3/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`,
      { headers: { Authorization: AUTH } }
    );
    if (!res.ok) throw new Error(`Woo orders returned ${res.status}`);
    const batch = await res.json();
    if (!batch.length) return;
    for (const order of batch) {
      if (isRenewal(order)) yield order;
    }
    page++;
  }
}
3

Read the saved PaymentIntent id

The WooCommerce Stripe gateway saves the PaymentIntent id on the order as meta key _stripe_intent_id. Some older orders only have it in transaction_id. Check both, and only trust a transaction_id that looks like a PaymentIntent id, since that field sometimes holds a charge id instead.

intent_id.py
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
intent-id.js
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;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a renewal order and its Stripe PaymentIntent (or None if there is not one) and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule: if the order is not in a paid state, skip it, that is not this bug. If it is paid but Stripe has no matching succeeded PaymentIntent for the right amount, flag it.

decide.py
PAID_STATUSES = {"processing", "completed"}

def order_amount_minor(order):
    # Works for two decimal currencies. Zero decimal currencies (JPY and friends)
    # have their own guide, since 50.00 is wrong for those.
    return round(float(order["total"]) * 100)

def decide(order, intent):
    if order["status"] not in PAID_STATUSES:
        return ("skip", "renewal not in a paid state")
    if intent is None:
        return ("flag", "no Stripe charge found for a paid renewal")
    if intent.get("status") != "succeeded":
        return ("flag", "Stripe shows the payment not succeeded")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("flag", "amount does not match the Stripe charge")
    return ("ok", "matches a succeeded Stripe charge")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);

export function orderAmountMinor(order) {
  // Works for two decimal currencies. Zero decimal currencies (JPY and friends)
  // have their own guide, since 50.00 is wrong for those.
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "renewal not in a paid state"];
  if (!intent) return ["flag", "no Stripe charge found for a paid renewal"];
  if (intent.status !== "succeeded") return ["flag", "Stripe shows the payment not succeeded"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["flag", "amount does not match the Stripe charge"];
  }
  return ["ok", "matches a succeeded Stripe charge"];
}
5

Flag it, and only hold it if you ask for that

When the action is flag, add an order note explaining exactly what did not match, so the shop manager sees it on the order screen. Only move the renewal to on-hold if REVIEW_HOLD is turned on. Never cancel the subscription automatically. A false positive that cancels a paying customer is worse than a missed charge that a person reviews the next morning.

flag.py
def flag(order, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Renewal payment check failed: {reason}. This renewal is marked paid "
                      f"but Stripe does not confirm a matching succeeded charge. Please review."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if REVIEW_HOLD:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"status": "on-hold"}, auth=AUTH, timeout=30,
        ).raise_for_status()
flag.js
async function flag(order, reason) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal payment check failed: ${reason}. This renewal is marked paid but Stripe ` +
            `does not confirm a matching succeeded charge. Please review.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}
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 flag. Read the output, confirm each one really has no matching charge in the Stripe dashboard, then switch it off to let it write notes. Run it once a day with cron, since this bug is rare and does not need minute-by-minute checking.

Run it safe

Always start with DRY_RUN=true. This script touches subscription renewals, which are tied to a customer's ongoing access, so you want its report reviewed by a person before it writes a note, and reviewed again before REVIEW_HOLD ever holds an order.

The full code

Here is the complete diagnostic in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever adds a note or an on-hold status. It never touches the subscription record itself.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

flag_fake_paid_renewals.py
"""Flag WooCommerce Subscriptions renewal orders marked paid with no matching
Stripe charge behind them.

A caching bug or a race between two renewal attempts can let the renewal
handler take its success path, marking the order paid and extending the
subscription, without a succeeded PaymentIntent ever existing in Stripe. This
walks recent renewal orders, looks up the saved PaymentIntent, and flags any
renewal whose payment is missing, not succeeded, or the wrong amount, by
adding an order note (and optionally moving it to on-hold for review). Read
only by default. Run on a schedule.
"""
import os
import datetime
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("flag_fake_paid_renewals")

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", "7"))
REVIEW_HOLD = os.environ.get("REVIEW_HOLD", "false").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def is_renewal(order):
    """True when the order carries the WooCommerce Subscriptions renewal meta key."""
    return any(m.get("key") == "_subscription_renewal" for m in order.get("meta_data") or [])


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_amount_minor(order):
    return round(float(order["total"]) * 100)


def decide(order, intent):
    if order["status"] not in PAID_STATUSES:
        return ("skip", "renewal not in a paid state")
    if intent is None:
        return ("flag", "no Stripe charge found for a paid renewal")
    if intent.get("status") != "succeeded":
        return ("flag", "Stripe shows the payment not succeeded")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("flag", "amount does not match the Stripe charge")
    return ("ok", "matches a succeeded Stripe charge")


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 paid_renewal_orders():
    after = (datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)).isoformat() + "T00:00:00"
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "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 is_renewal(order):
                yield order
        page += 1


def flag(order, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Renewal payment check failed: {reason}. This renewal is marked paid "
                      f"but Stripe does not confirm a matching succeeded charge. Please review."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if REVIEW_HOLD:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"status": "on-hold"}, auth=AUTH, timeout=30,
        ).raise_for_status()


def run():
    flagged = 0
    for order in paid_renewal_orders():
        intent = get_intent(intent_id_of(order))
        action, reason = decide(order, intent)
        if action != "flag":
            continue
        log.warning("Renewal %s: %s. %s", order["id"], reason, "would flag" if DRY_RUN else "flagging")
        if not DRY_RUN:
            flag(order, reason)
        flagged += 1
    log.info("Done. %d renewal(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
flag-fake-paid-renewals.js
/**
 * Flag WooCommerce Subscriptions renewal orders marked paid with no matching
 * Stripe charge behind them.
 *
 * A caching bug or a race between two renewal attempts can let the renewal
 * handler take its success path, marking the order paid and extending the
 * subscription, without a succeeded PaymentIntent ever existing in Stripe.
 * This walks recent renewal orders, looks up the saved PaymentIntent, and
 * flags any renewal whose payment is missing, not succeeded, or the wrong
 * amount, by adding an order note (and optionally moving it to on-hold).
 * Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/renewal-marked-paid-with-no-payment/
 */
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 || 7);
const REVIEW_HOLD = (process.env.REVIEW_HOLD || "false").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function isRenewal(order) {
  return (order.meta_data || []).some((m) => m.key === "_subscription_renewal");
}

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "renewal not in a paid state"];
  if (!intent) return ["flag", "no Stripe charge found for a paid renewal"];
  if (intent.status !== "succeeded") return ["flag", "Stripe shows the payment not succeeded"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["flag", "amount does not match the Stripe charge"];
  }
  return ["ok", "matches a succeeded Stripe charge"];
}

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* paidRenewalOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) {
      if (isRenewal(order)) yield order;
    }
    page++;
  }
}

async function flag(order, reason) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal payment check failed: ${reason}. This renewal is marked paid but Stripe ` +
            `does not confirm a matching succeeded charge. Please review.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}

export async function run() {
  let flagged = 0;
  for await (const order of paidRenewalOrders()) {
    const intent = await getIntent(intentIdOf(order));
    const [action, reason] = decide(order, intent);
    if (action !== "flag") continue;
    console.warn(`Renewal ${order.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
    if (!DRY_RUN) await flag(order, reason);
    flagged++;
  }
  console.log(`Done. ${flagged} renewal(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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 renewals get flagged for review. Because we kept decide and intentIdOf pure, the tests need no network and no Stripe account. They just feed in plain objects and check the action.

test_renewal_decide.py
from flag_fake_paid_renewals import decide, intent_id_of, is_renewal


def intent(**over):
    base = {"status": "succeeded", "amount_received": 2900}
    base.update(over)
    return base


def test_ok_when_renewal_paid_and_charge_matches():
    order = {"status": "processing", "total": "29.00"}
    assert decide(order, intent())[0] == "ok"


def test_flag_when_no_intent():
    order = {"status": "completed", "total": "29.00"}
    assert decide(order, None)[0] == "flag"


def test_flag_when_intent_not_succeeded():
    order = {"status": "processing", "total": "29.00"}
    assert decide(order, intent(status="requires_payment_method"))[0] == "flag"


def test_flag_when_amount_mismatch():
    order = {"status": "processing", "total": "49.00"}
    assert decide(order, intent())[0] == "flag"


def test_skip_when_renewal_not_paid():
    order = {"status": "pending", "total": "29.00"}
    assert decide(order, None)[0] == "skip"


def test_intent_id_from_meta():
    order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
    assert intent_id_of(order) == "pi_123"


def test_intent_id_falls_back_to_transaction_id():
    order = {"meta_data": [], "transaction_id": "pi_456"}
    assert intent_id_of(order) == "pi_456"


def test_intent_id_none_when_transaction_is_a_charge():
    order = {"meta_data": [], "transaction_id": "ch_789"}
    assert intent_id_of(order) is None


def test_is_renewal_true_with_meta_key():
    order = {"meta_data": [{"key": "_subscription_renewal", "value": "12"}]}
    assert is_renewal(order) is True


def test_is_renewal_false_without_meta_key():
    order = {"meta_data": [{"key": "_some_other_key", "value": "x"}]}
    assert is_renewal(order) is False
flag-fake-paid-renewals.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, isRenewal } from "./flag-fake-paid-renewals.js";

const intent = (over = {}) => ({ status: "succeeded", amount_received: 2900, ...over });

test("ok when renewal paid and charge matches", () => {
  assert.equal(decide({ status: "processing", total: "29.00" }, intent())[0], "ok");
});

test("flag when no intent", () => {
  assert.equal(decide({ status: "completed", total: "29.00" }, null)[0], "flag");
});

test("flag when intent not succeeded", () => {
  assert.equal(decide({ status: "processing", total: "29.00" }, intent({ status: "requires_payment_method" }))[0], "flag");
});

test("flag when amount mismatch", () => {
  assert.equal(decide({ status: "processing", total: "49.00" }, intent())[0], "flag");
});

test("skip when renewal not paid", () => {
  assert.equal(decide({ status: "pending", total: "29.00" }, null)[0], "skip");
});

test("intentIdOf from meta", () => {
  assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});

test("intentIdOf falls back to transaction_id", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});

test("intentIdOf null when transaction is a charge", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "ch_789" }), null);
});

test("isRenewal true with meta key", () => {
  assert.equal(isRenewal({ meta_data: [{ key: "_subscription_renewal", value: "12" }] }), true);
});

test("isRenewal false without meta key", () => {
  assert.equal(isRenewal({ meta_data: [{ key: "_some_other_key", value: "x" }] }), false);
});

Case studies

Object cache

The Redis flag that never expired

A store used an object cache to remember which renewal orders had already been processed, keyed by order id. A deploy changed the renewal window slightly, and a handful of orders were reused across two billing periods with the same cache key still set to "done" from months earlier. Each of those renewals completed on schedule with the subscription extended, but no PaymentIntent was ever created for the new period.

The diagnostic run in dry mode caught eleven renewals over a quarter, all missing a PaymentIntent entirely. The team fixed the cache key to include the billing period and manually re-billed the eleven customers.

Retry race

The retry that fired twice

A flaky connection to the payment gateway caused a renewal action to time out and get retried by the scheduler while the first attempt was still finishing. The first attempt charged the card and completed the order. The retry, running a moment later, found the order already marked paid in its local state and short-circuited straight to "success" without checking Stripe, then reran the completion logic a second time and rewrote the order note.

The order looked untouched from the outside, but the diagnostic flagged three renewals where the PaymentIntent amount was for the previous cycle, not the current one, exposing the stale short-circuit.

What good looks like

After this runs on a schedule, a renewal that slipped past the real charge check no longer sits silently for months. It shows up as a note on the order and, if you turn on the hold, waits for a person before anything else happens. Keep it running even after you find and fix the caching or race condition, since it costs nothing to check and catches the next unrelated bug that does the same thing.

FAQ

Why does a WooCommerce Subscriptions renewal show as paid when Stripe never charged it?

A caching layer or a race between two renewal attempts can let the renewal handler run its success path, which marks the order paid and extends the subscription, without a matching succeeded PaymentIntent ever being created in Stripe. The order and the subscription both look fine while no money moved.

Is it safe to run a script that scans every renewal order?

Yes, when it only reads from Stripe and WooCommerce and writes an order note by default. Start with DRY_RUN=true so it only reports what it found, and only turn on the optional hold so a human reviews each flagged renewal before anything else changes.

Should the script cancel the subscription automatically when it finds one of these renewals?

No. Cancelling access automatically risks locking out a paying customer over a false positive. Flag the renewal, put it on hold for a person to check, and let the shop manager decide whether to re-bill, cancel, or let it go.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how a renewal order is created and marked paid, and the meta keys it relies on. woocommerce.com/document/subscriptions/renewal-process
  2. WooCommerce Stripe plugin: an open issue describing a renewal completed without a corresponding charge. github.com/woocommerce/woocommerce-gateway-stripe/issues/3154
  3. WooCommerce developer docs: caching guidance and the risks of caching order or payment state. developer.woocommerce.com/docs

On the solution:

  1. Stripe API: retrieve a PaymentIntent to confirm its status and amount received. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe docs: reconciling orders against the PaymentIntent as the source of truth for money. docs.stripe.com/webhooks/process-undelivered-events
  3. WooCommerce REST API: read orders, filter by status, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a fake-paid renewal?

If this saved you a quiet revenue leak or a confusing payout mismatch, 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