Repair Catalog, metadata, and scheduling

WooCommerce Subscriptions renewals never run because Action Scheduler stalled

Subscriptions sit there marked Active. The renewal date came and went. No card was charged, no order was created, and no email went out. Nothing looks broken in wp-admin, but the money stopped moving days ago. This is what happens when the Action Scheduler queue that drives every renewal quietly stalls. Here is why it happens and a small job that finds the backlog and charges the renewals that are actually due.

Python and Node.js Runs on a schedule Safe by default (dry run)
A lot of magazines
Photo by Mauricio Santos on Unsplash
The short answer

WooCommerce Subscriptions does not bill on its own clock. It schedules a scheduled-subscription-payment action in Action Scheduler for each renewal date, and a background worker processes that queue. When the queue stalls, from one action throwing a fatal error, a worker limit being maxed out, or WP-Cron never firing, every renewal behind it just waits forever. Run a small Python or Node.js job on a schedule that lists active and on-hold subscriptions, works out which ones are past due and past a grace window, and charges the saved Stripe payment method directly for the ones the scheduler missed. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions never charges a card the moment a renewal is "due." Instead, when a subscription is created, it tells Action Scheduler, a WordPress plugin bundled inside WooCommerce, "run this scheduled-subscription-payment action at this timestamp." Action Scheduler stores that as a pending row in its own table and a background runner picks it up when the time comes, usually triggered by WP-Cron or an async request on page load.

That background runner is a single moving part with no user watching it. If one action in the queue throws a fatal PHP error, some setups let it jam everything behind it. If the site has almost no traffic, WP-Cron, which normally fires on page loads, may not fire at all. If a host or security plugin blocks the async runner, the queue just accumulates. Either way, the action stays "pending" long after its scheduled date, Stripe is never asked for the money, and the subscription keeps showing Active because nothing ever told it otherwise.

Renewal date is reached Action queued scheduled-subscription-payment runner stalled Action stays pending, past due No charge Still Active Every renewal scheduled behind the stall waits the same way, quietly, with no error anywhere in sight.
The subscription reaches its renewal date and an action is queued, but if the runner behind it is stalled the action never fires and no charge is ever attempted.

Why it happens

Action Scheduler is solid, but it depends on something triggering it, and that trigger has more than one way to go quiet:

This is a well documented failure mode. WooCommerce's own documentation on Action Scheduler troubleshooting describes exactly this "actions never process" pattern, and the WooCommerce Subscriptions renewal docs are direct about the fact that a stalled queue means stalled billing, with no other symptom in the admin screens.

The key insight

A subscription's status field only tells you what the last successful event was, not whether the next one is overdue. "Active" just means nothing has failed yet. The real signal for a stalled queue is the gap between a subscription's next payment date and the current time. A repair job reads that gap directly and treats anything past a grace window as due, regardless of what Action Scheduler thinks it is doing.

The fix, as a flow

We do not touch Action Scheduler's internals or try to unstick its queue from the outside. We add a separate job that runs on its own schedule, looks at every active or on-hold subscription, and works out independently which ones are actually overdue. If a renewal is past its grace window, has no successful order yet, and has a saved payment method, we charge it directly through the Stripe API and update the order the same way a normal renewal would.

Scheduled job every hour List active and on-hold subscriptions Compute hours past next payment Past grace and has a card? yes no, wait or flag Charge renewal mark order processing
The job computes overdue time itself instead of trusting the queue, and only charges subscriptions that are past the grace window, unpaid, and have a saved payment method on file.

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 and subscriptions. 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 GRACE_HOURS="3"
export STALE_DAYS="14"
export DRY_RUN="true"   # start safe, change to false to charge
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 GRACE_HOURS="3"
export STALE_DAYS="14"
export DRY_RUN="true"   // start safe, change to false to charge
2

List subscriptions that could be overdue

Ask the WooCommerce REST API for subscriptions with status active or on-hold. Those are the only two states where a renewal charge is expected. Anything cancelled, expired, or already pending cancellation should never be touched by this job.

step2.py
import requests
from requests.auth import HTTPBasicAuth

AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)

def due_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1
step2.js
async function* dueSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}
3

Read the saved PaymentIntent and payment method

Each subscription's last renewal order carries the Stripe reference we need. Read it from order meta _stripe_intent_id first, and fall back to transaction_id when it looks like a PaymentIntent id. This is the same field the WooCommerce Stripe gateway writes on every successful charge, so it works whether or not the queue ever ran.

step3.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
step3.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 plain record and the current time and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule: skip anything not active or on-hold, skip anything already paid, wait if it is inside a short grace window (the scheduler might still catch it), flag as stale anything overdue for a long time so a human looks at it, and only charge what is past due, past grace, not stale, and has a saved payment method.

decide.py
RENEWABLE_STATUSES = {"active", "on-hold"}

def hours_overdue(scheduled_ts, now_ts):
    return (now_ts - scheduled_ts) / 3600

def decide(subscription, now_ts, grace_hours=3, stale_days=14):
    if subscription["status"] not in RENEWABLE_STATUSES:
        return ("skip", "subscription is not active or on-hold")
    if subscription.get("next_payment_ts") is None:
        return ("skip", "no renewal scheduled")
    if subscription["last_order_status"] in ("processing", "completed"):
        return ("skip", "renewal already paid")

    overdue_hours = hours_overdue(subscription["next_payment_ts"], now_ts)
    if overdue_hours < 0:
        return ("skip", "renewal is not due yet")
    if overdue_hours < grace_hours:
        return ("wait", "inside the grace window, scheduler may still catch it")
    if overdue_hours >= stale_days * 24:
        return ("stale", "overdue longer than the stale window, needs a human look")
    if not subscription.get("payment_method_token"):
        return ("blocked", "no saved payment method to charge")
    return ("charge", "past due and past grace, safe to charge now")
decide.js
const RENEWABLE_STATUSES = new Set(["active", "on-hold"]);

function hoursOverdue(scheduledTs, nowTs) {
  return (nowTs - scheduledTs) / 3600;
}

export function decide(subscription, nowTs, graceHours = 3, staleDays = 14) {
  if (!RENEWABLE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }
  if (subscription.nextPaymentTs === null || subscription.nextPaymentTs === undefined) {
    return ["skip", "no renewal scheduled"];
  }
  if (["processing", "completed"].includes(subscription.lastOrderStatus)) {
    return ["skip", "renewal already paid"];
  }

  const overdueHours = hoursOverdue(subscription.nextPaymentTs, nowTs);
  if (overdueHours < 0) return ["skip", "renewal is not due yet"];
  if (overdueHours < graceHours) return ["wait", "inside the grace window, scheduler may still catch it"];
  if (overdueHours >= staleDays * 24) return ["stale", "overdue longer than the stale window, needs a human look"];
  if (!subscription.paymentMethodToken) return ["blocked", "no saved payment method to charge"];
  return ["charge", "past due and past grace, safe to charge now"];
}
5

Charge the renewal the way the scheduler would have

When the action is charge, create an off-session PaymentIntent for the renewal amount using the subscription's saved Stripe customer and payment method, confirm it immediately, then write the result back onto the order the way the normal renewal flow does. Keep the money math in cents, since Stripe amounts are always minor units.

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

def charge_renewal(subscription, order):
    amount = order_amount_minor(order)
    intent = stripe.PaymentIntent.create(
        amount=amount,
        currency=order.get("currency", "usd").lower(),
        customer=subscription["customer_stripe_id"],
        payment_method=subscription["payment_method_token"],
        off_session=True,
        confirm=True,
        metadata={"order_id": str(order["id"]), "subscription_id": str(subscription["id"])},
    )
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": "processing", "transaction_id": intent.id},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Renewal charged manually after Action Scheduler stalled. "
                      f"Stripe PaymentIntent {intent.id}, status {intent.status}."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    return intent
charge.js
export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

async function chargeRenewal(subscription, order) {
  const amount = orderAmountMinor(order);
  const intent = await stripe.paymentIntents.create({
    amount,
    currency: (order.currency || "usd").toLowerCase(),
    customer: subscription.customer_stripe_id,
    payment_method: subscription.payment_method_token,
    off_session: true,
    confirm: true,
    metadata: { order_id: String(order.id), subscription_id: String(subscription.id) },
  });
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ status: "processing", transaction_id: intent.id }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal charged manually after Action Scheduler stalled. ` +
            `Stripe PaymentIntent ${intent.id}, status ${intent.status}.`,
    }),
  });
  return intent;
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would charge. Read the output, check the amounts against your own records, then switch it off to let it write. Run it hourly with cron as a safety net, separate from whatever fixed the queue.

Run it safe

Always start with DRY_RUN=true. This job charges real cards off-session, so you want to see its exact plan, subscription by subscription, before it moves any money. Once the report matches what you expect for a day, turn it off.

The full code

Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never touches a subscription that is already paid, not active, or still inside its grace window.

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

run_due_renewals.py
"""Trigger WooCommerce Subscriptions renewals that Action Scheduler stopped running.

When the Action Scheduler queue stalls (a fatal error in one action, a maxed out
worker, a cron that stopped firing) the scheduled-subscription-payment actions pile
up "pending" long past their scheduled_date. WooCommerce never asked Stripe for the
money, so the subscription just sits there looking active while nothing is billed.

This walks orders that look like stuck renewals, reads the saved Stripe PaymentIntent
(or the customer's saved payment method) and charges the renewal amount directly
through the Stripe API, then reports the result back onto the order. Safe to run
again and again. Read only in DRY_RUN mode.
"""
import os
import time
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("run_due_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"])
GRACE_HOURS = int(os.environ.get("GRACE_HOURS", "3"))
STALE_DAYS = int(os.environ.get("STALE_DAYS", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

RENEWABLE_STATUSES = {"active", "on-hold"}


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


def order_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 hours_overdue(scheduled_ts, now_ts):
    return (now_ts - scheduled_ts) / 3600


def decide(subscription, now_ts, grace_hours=GRACE_HOURS, stale_days=STALE_DAYS):
    """Pure decision: what to do about one subscription's due renewal.

    subscription is a plain dict with:
      status: the subscription status string
      next_payment_ts: unix timestamp the renewal was scheduled for, or None
      last_order_status: status of the most recent renewal order, or None
      payment_method_token: a saved Stripe payment method id, or None

    Returns (action, reason) where action is one of:
      "skip"    - nothing due, or already handled
      "wait"    - due, but still inside the grace window, leave it to the scheduler
      "charge"  - due, past grace, and we have what we need to charge it
      "blocked" - due, past grace, but there is no saved payment method to charge
      "stale"   - overdue so long it needs a human, not an auto charge
    """
    if subscription["status"] not in RENEWABLE_STATUSES:
        return ("skip", "subscription is not active or on-hold")
    if subscription.get("next_payment_ts") is None:
        return ("skip", "no renewal scheduled")
    if subscription["last_order_status"] in ("processing", "completed"):
        return ("skip", "renewal already paid")

    overdue_hours = hours_overdue(subscription["next_payment_ts"], now_ts)
    if overdue_hours < 0:
        return ("skip", "renewal is not due yet")
    if overdue_hours < grace_hours:
        return ("wait", "inside the grace window, scheduler may still catch it")
    if overdue_hours >= stale_days * 24:
        return ("stale", "overdue longer than the stale window, needs a human look")
    if not subscription.get("payment_method_token"):
        return ("blocked", "no saved payment method to charge")
    return ("charge", "past due and past grace, safe to charge now")


def get_subscription(subscription_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        auth=AUTH, timeout=30,
    )
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def due_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1


def charge_renewal(subscription, order):
    """Charge the saved payment method off-session for the renewal amount."""
    amount = order_amount_minor(order)
    intent = stripe.PaymentIntent.create(
        amount=amount,
        currency=order.get("currency", "usd").lower(),
        customer=subscription["customer_stripe_id"],
        payment_method=subscription["payment_method_token"],
        off_session=True,
        confirm=True,
        metadata={"order_id": str(order["id"]), "subscription_id": str(subscription["id"])},
    )
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": "processing", "transaction_id": intent.id},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Renewal charged manually after Action Scheduler stalled. "
                      f"Stripe PaymentIntent {intent.id}, status {intent.status}."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    return intent


def run():
    charged = 0
    now_ts = int(time.time())
    for sub in due_subscriptions():
        order = sub.get("last_order") or {}
        record = {
            "status": sub["status"],
            "next_payment_ts": sub.get("next_payment_ts"),
            "last_order_status": order.get("status"),
            "payment_method_token": sub.get("payment_method_token"),
        }
        action, reason = decide(record, now_ts)
        if action in ("skip", "wait"):
            continue
        if action in ("blocked", "stale"):
            log.warning("Subscription %s: %s. %s", sub["id"], action, reason)
            continue
        log.info("Subscription %s: %s. %s", sub["id"], reason, "would charge" if DRY_RUN else "charging")
        if not DRY_RUN:
            charge_renewal(sub, order)
        charged += 1
    log.info("Done. %d renewal(s) %s.", charged, "to charge" if DRY_RUN else "charged")


if __name__ == "__main__":
    run()
run-due-renewals.js
/**
 * Trigger WooCommerce Subscriptions renewals that Action Scheduler stopped running.
 *
 * When the Action Scheduler queue stalls (a fatal error in one action, a maxed out
 * worker, a cron that stopped firing) the scheduled-subscription-payment actions
 * pile up "pending" long past their scheduled_date. WooCommerce never asked Stripe
 * for the money, so the subscription just sits there looking active while nothing
 * is billed.
 *
 * This walks subscriptions that look like stuck renewals, reads the saved Stripe
 * payment method, and charges the renewal amount directly through the Stripe API,
 * then reports the result back onto the order. Safe to run again and again.
 * Read only in DRY_RUN mode.
 *
 * Guide: https://www.allanninal.dev/woocommerce/renewals-never-run/
 */
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 GRACE_HOURS = Number(process.env.GRACE_HOURS || 3);
const STALE_DAYS = Number(process.env.STALE_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const RENEWABLE_STATUSES = new Set(["active", "on-hold"]);

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

export function 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);
}

function hoursOverdue(scheduledTs, nowTs) {
  return (nowTs - scheduledTs) / 3600;
}

/**
 * Pure decision: what to do about one subscription's due renewal.
 *
 * subscription is a plain object with:
 *   status: the subscription status string
 *   nextPaymentTs: unix timestamp (seconds) the renewal was scheduled for, or null
 *   lastOrderStatus: status of the most recent renewal order, or null
 *   paymentMethodToken: a saved Stripe payment method id, or null
 *
 * Returns [action, reason] where action is one of:
 *   "skip"    - nothing due, or already handled
 *   "wait"    - due, but still inside the grace window, leave it to the scheduler
 *   "charge"  - due, past grace, and we have what we need to charge it
 *   "blocked" - due, past grace, but there is no saved payment method to charge
 *   "stale"   - overdue so long it needs a human, not an auto charge
 */
export function decide(subscription, nowTs, graceHours = GRACE_HOURS, staleDays = STALE_DAYS) {
  if (!RENEWABLE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }
  if (subscription.nextPaymentTs === null || subscription.nextPaymentTs === undefined) {
    return ["skip", "no renewal scheduled"];
  }
  if (["processing", "completed"].includes(subscription.lastOrderStatus)) {
    return ["skip", "renewal already paid"];
  }

  const overdueHours = hoursOverdue(subscription.nextPaymentTs, nowTs);
  if (overdueHours < 0) return ["skip", "renewal is not due yet"];
  if (overdueHours < graceHours) return ["wait", "inside the grace window, scheduler may still catch it"];
  if (overdueHours >= staleDays * 24) return ["stale", "overdue longer than the stale window, needs a human look"];
  if (!subscription.paymentMethodToken) return ["blocked", "no saved payment method to charge"];
  return ["charge", "past due and past grace, safe to charge now"];
}

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.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* dueSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

async function chargeRenewal(subscription, order) {
  const amount = orderAmountMinor(order);
  const intent = await stripe.paymentIntents.create({
    amount,
    currency: (order.currency || "usd").toLowerCase(),
    customer: subscription.customer_stripe_id,
    payment_method: subscription.payment_method_token,
    off_session: true,
    confirm: true,
    metadata: { order_id: String(order.id), subscription_id: String(subscription.id) },
  });
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ status: "processing", transaction_id: intent.id }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal charged manually after Action Scheduler stalled. ` +
            `Stripe PaymentIntent ${intent.id}, status ${intent.status}.`,
    }),
  });
  return intent;
}

export async function run() {
  let charged = 0;
  const nowTs = Math.floor(Date.now() / 1000);
  for await (const sub of dueSubscriptions()) {
    const order = sub.last_order || {};
    const record = {
      status: sub.status,
      nextPaymentTs: sub.next_payment_ts ?? null,
      lastOrderStatus: order.status ?? null,
      paymentMethodToken: sub.payment_method_token ?? null,
    };
    const [action, reason] = decide(record, nowTs);
    if (action === "skip" || action === "wait") continue;
    if (action === "blocked" || action === "stale") {
      console.warn(`Subscription ${sub.id}: ${action}. ${reason}`);
      continue;
    }
    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would charge" : "charging"}`);
    if (!DRY_RUN) await chargeRenewal(sub, order);
    charged++;
  }
  console.log(`Done. ${charged} renewal(s) ${DRY_RUN ? "to charge" : "charged"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((e) => { console.error(e); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a card gets charged off-session. Because we kept decide pure, with a fixed "now" passed in instead of read from the clock, the test needs no network and no Stripe account. It just feeds in plain records and checks the action.

test_renewals_decide.py
from run_due_renewals import decide

NOW = 1_800_000_000  # a fixed "now" for deterministic tests
HOUR = 3600
DAY = 24 * HOUR


def sub(**over):
    base = {
        "status": "active",
        "next_payment_ts": NOW - 5 * HOUR,
        "last_order_status": "pending",
        "payment_method_token": "pm_123",
    }
    base.update(over)
    return base


def test_charge_when_past_due_and_past_grace():
    assert decide(sub(), NOW)[0] == "charge"


def test_wait_when_inside_grace_window():
    s = sub(next_payment_ts=NOW - 1 * HOUR)
    assert decide(s, NOW)[0] == "wait"


def test_skip_when_not_due_yet():
    s = sub(next_payment_ts=NOW + 1 * HOUR)
    assert decide(s, NOW)[0] == "skip"


def test_blocked_when_no_payment_method():
    s = sub(payment_method_token=None)
    assert decide(s, NOW)[0] == "blocked"


def test_stale_when_overdue_past_stale_window():
    s = sub(next_payment_ts=NOW - 20 * DAY)
    assert decide(s, NOW)[0] == "stale"
run-due-renewals.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./run-due-renewals.js";

const NOW = 1_800_000_000; // a fixed "now" for deterministic tests
const HOUR = 3600;
const DAY = 24 * HOUR;

const sub = (over = {}) => ({
  status: "active",
  nextPaymentTs: NOW - 5 * HOUR,
  lastOrderStatus: "pending",
  paymentMethodToken: "pm_123",
  ...over,
});

test("charge when past due and past grace", () => {
  assert.equal(decide(sub(), NOW)[0], "charge");
});

test("wait when inside grace window", () => {
  assert.equal(decide(sub({ nextPaymentTs: NOW - 1 * HOUR }), NOW)[0], "wait");
});

test("skip when not due yet", () => {
  assert.equal(decide(sub({ nextPaymentTs: NOW + 1 * HOUR }), NOW)[0], "skip");
});

test("blocked when no payment method", () => {
  assert.equal(decide(sub({ paymentMethodToken: null }), NOW)[0], "blocked");
});

test("stale when overdue past stale window", () => {
  assert.equal(decide(sub({ nextPaymentTs: NOW - 20 * DAY }), NOW)[0], "stale");
});

Case studies

Fatal error in the queue

The plugin update that jammed everything behind it

A store updated a loyalty points plugin that hooked into an unrelated Action Scheduler action and threw a fatal error every time that action ran. Because that one action kept getting retried, the whole worker spent its time budget on it and never reached the renewal actions queued behind it. Four days of renewals sat pending with nothing charged.

The repair job, run in dry run first, showed the exact 61 subscriptions overdue past the grace window. Once the loyalty plugin was rolled back, the team ran the job for real and the backlog cleared in one pass.

WP-Cron never fired

The staging clone that quietly went live

A store's staging copy was promoted to production, but the migration left a maintenance flag that suppressed WP-Cron for "performance." Traffic was steady, but no page load ever triggered a cron run, so nothing behind it processed for over a week, including every subscription renewal due in that window.

The overdue count made the stale window kick in for the oldest ones, which the job correctly left for a human to check for a lapsed card, while the rest were charged automatically once the flag was removed.

What good looks like

After this runs on a schedule, a stalled Action Scheduler queue costs you an hour of delay instead of a week of missed billing. Keep the job running even after you find and fix the root cause, since queues can stall again for reasons entirely outside your control, and the stale window keeps it from blindly charging a card that has been sitting for weeks.

FAQ

Why did my WooCommerce Subscriptions renewals just stop billing?

WooCommerce Subscriptions does not charge cards on its own timer. It schedules a scheduled-subscription-payment action in Action Scheduler for each renewal date, and a background worker runs that queue. If the queue stalls, from a fatal error in one action, a maxed out worker, or WP-Cron never firing, the actions sit as pending and the renewal never happens.

Is it safe to charge renewals with a script instead of waiting for Action Scheduler?

Yes, when the script only charges subscriptions that are active or on-hold, past a grace window, not already paid, and have a saved payment method, and it leaves anything overdue for a long time to a human to review instead of auto-charging it. Start in dry run mode to see the exact list before it charges a single card.

How do I stop this from happening again?

Fix the underlying cause, usually a fatal error in one action that is blocking the whole queue, or WP-Cron not firing because of low traffic or a caching plugin. Then keep a small job running on a schedule as a safety net, since a queue can stall again for reasons outside your control.

Related field notes

Citations

On the problem:

  1. WooCommerce developer docs: Action Scheduler, how the queue and background runner work. developer.woocommerce.com/docs/how-action-scheduler-processes-jobs
  2. WooCommerce docs: troubleshooting Action Scheduler actions stuck as pending or in-progress. woocommerce.com/document/managing-action-scheduler
  3. WooCommerce Subscriptions docs: how renewal orders and payment retries are scheduled. woocommerce.com/document/subscriptions/renewal-process

On the solution:

  1. Stripe docs: creating off-session PaymentIntents for saved payment methods. docs.stripe.com/payments/save-and-reuse
  2. Stripe API: the PaymentIntents create endpoint and its off_session and confirm parameters. docs.stripe.com/api/payment_intents/create
  3. WooCommerce REST API: subscriptions and orders endpoints for reading status and adding notes. 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 fix your stalled renewals?

If this saved you a pile of missed billing or a scramble to figure out why revenue stopped, 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