Reconciler WooCommerce Subscriptions: manual renewal and dunning

Renewal actions stall, no renewals made

Subscriptions still show Active. Customers still expect to be billed. But nothing is happening. No renewal orders are appearing, no charges are going out, and the next payment date on every affected subscription just sits there in the past. The scheduled renewal actions have stalled entirely. Here is why that happens and a small script that finds every subscription stuck this way and triggers the renewal it was owed.

Python and Node.js Runs on a schedule Safe by default (dry run)
A wrapped gift box
Photo by Ekaterina Shevchenko on Unsplash
The short answer

WooCommerce Subscriptions renews a subscription by scheduling a woocommerce_scheduled_subscription_payment action in Action Scheduler for the next payment date. When the Action Scheduler queue runner stalls, that action never runs, so no renewal order is created and no charge is attempted. Run a small Python or Node.js script on a schedule that finds active subscriptions whose next payment date has passed with no matching renewal order, then charges the saved payment method with Stripe and creates the renewal order over the WooCommerce REST API, the same way the scheduled action would have. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions does not bill a customer the moment their subscription is due. It schedules a background job ahead of time, and that job is what actually creates the renewal order and asks the payment gateway to charge the card. The job runner behind all of this is Action Scheduler, a queue built into WooCommerce that most stores never think about until it stops moving.

When that queue stalls, every action waiting in it just sits there. Renewal actions pile up as pending, past their scheduled time, and nothing runs them. From the outside, the subscription still says Active and the next payment date is still whatever it was last set to. Nobody gets an error. Nobody gets an email. The store simply stops billing, quietly, for every subscription due after the queue stopped.

Next payment date arrives Renewal action scheduled, status: pending queue stalled Action never runs still shows Active No order No charge
The renewal action is scheduled right on time. It just never runs, because the queue that was supposed to process it has stalled.

Why it happens

Action Scheduler relies on WP-Cron by default, which only fires when a visitor loads a page on the site. A few common ways the queue stops moving:

This has been reported against Action Scheduler directly, where actions can become stuck claimed and in-progress indefinitely after an interrupted run, and it is also documented as a known WooCommerce Subscriptions support scenario when the scheduled payment hook simply stops firing. See the citations at the end for both threads.

The key insight

The subscription's own record, not the stalled queue, is the source of truth for whether a renewal is owed. If a subscription is active and its next payment date has passed with no renewal order created for that period, the renewal is late, whether or not Action Scheduler ever gets unstuck. A script that reads that state directly and triggers the missing renewal is a safety net underneath the queue, not a replacement for fixing it.

The fix, as a flow

We do not touch the scheduled action or try to repair Action Scheduler's queue from the outside. We add a job that runs on its own schedule, looks at every active subscription whose next payment date is overdue, and checks whether a renewal order already exists for that period. If nothing was created and there is a saved payment method on file, we charge it directly with Stripe and create the renewal order the same way the scheduled action would have.

Scheduled job every few hours List active subs past next payment date Check renewal order and saved payment method Due and payable? yes no, skip or flag Charge and create renewal order
The script reads the subscription's own state and only acts on the ones that are truly overdue, unpaid for the period, and have a saved payment method. Everything else is left alone or flagged.

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 GRACE_HOURS="6"
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 GRACE_HOURS="6"
export DRY_RUN="true"   // start safe, change to false to write
2

List active subscriptions that are overdue

Ask the WooCommerce REST API for subscriptions with status active, then keep only the ones whose next_payment_date_gmt is further in the past than a small grace window. The grace window matters, it keeps the script from racing a queue that is only running a little behind, not actually stalled.

step2.py
import os, time, 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"])
GRACE_HOURS = int(os.environ.get("GRACE_HOURS", "6"))

def stalled_subscriptions():
    page = 1
    cutoff = int(time.time()) - GRACE_HOURS * 3600
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            next_ts = _to_epoch(sub.get("next_payment_date_gmt"))
            if next_ts is not None and next_ts < cutoff:
                yield sub
        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");
const GRACE_HOURS = Number(process.env.GRACE_HOURS || 6);

async function* stalledSubscriptions() {
  const cutoff = Math.floor(Date.now() / 1000) - GRACE_HOURS * 3600;
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) {
      const nextTs = toEpoch(sub.next_payment_date_gmt);
      if (nextTs !== null && nextTs < cutoff) yield sub;
    }
    page++;
  }
}
3

Check whether the period was already renewed

Read the subscription's own list of renewal order ids and its saved payment method. If a renewal order already exists, the period is covered, maybe a delayed run finally went through, and the script should leave it alone. If there is no saved payment method, charging blind is not an option, that subscription needs a human, not a script.

step3.py
def last_renewal_order(subscription):
    ids = subscription.get("renewal_order_ids") or []
    return ids[-1] if ids else None

def payment_method_token_of(subscription):
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_payment_method" and meta.get("value"):
            return meta["value"]
    return None
step3.js
export function lastRenewalOrder(subscription) {
  const ids = subscription.renewal_order_ids || [];
  return ids.length ? ids[ids.length - 1] : null;
}

export function paymentMethodTokenOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_payment_method" && meta.value) return meta.value;
  }
  return null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the subscription plus what we found and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. Skip anything not active or already renewed. Flag anything with no saved payment method. Otherwise, trigger it.

decide.py
ACTIVE_STATUSES = {"active"}

def decide(subscription, has_recent_renewal_order, payment_method_token):
    if subscription.get("status") not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active")
    if has_recent_renewal_order:
        return ("skip", "a renewal order already exists for this period")
    if not payment_method_token:
        return ("manual", "no saved payment method, needs the customer or manual dunning")
    if float(subscription.get("total", "0")) <= 0:
        return ("skip", "zero cost renewal, no charge needed")
    return ("trigger", "next payment date passed with no renewal order or charge")
decide.js
const ACTIVE_STATUSES = new Set(["active"]);

export function decide(subscription, hasRecentRenewalOrder, paymentMethodToken) {
  if (!ACTIVE_STATUSES.has(subscription.status)) return ["skip", "subscription is not active"];
  if (hasRecentRenewalOrder) return ["skip", "a renewal order already exists for this period"];
  if (!paymentMethodToken) return ["manual", "no saved payment method, needs the customer or manual dunning"];
  if (parseFloat(subscription.total || "0") <= 0) return ["skip", "zero cost renewal, no charge needed"];
  return ["trigger", "next payment date passed with no renewal order or charge"];
}
5

Charge off session and create the renewal order

When the action is trigger, charge the saved Stripe payment method off session for the subscription's total, then create the renewal order over the WooCommerce REST API and mark it processing with the charge as the transaction ID. Add an order note so the shop manager can see it was created by the recovery script and why.

apply.py
def charge_off_session(customer_id, payment_method_token, amount_minor, currency, subscription_id):
    return stripe.PaymentIntent.create(
        amount=amount_minor,
        currency=currency,
        customer=customer_id,
        payment_method=payment_method_token,
        off_session=True,
        confirm=True,
        metadata={"subscription_id": str(subscription_id), "reason": "stalled_renewal_trigger"},
    )

def create_renewal_order(subscription, intent):
    charge_id = intent.get("latest_charge") or intent["id"]
    r = requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        json={
            "status": "processing",
            "customer_id": subscription["customer_id"],
            "payment_method": subscription.get("payment_method", "stripe"),
            "transaction_id": charge_id,
            "line_items": subscription.get("line_items", []),
            "meta_data": [{"key": "_subscription_renewal", "value": str(subscription["id"])}],
        },
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    order = r.json()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Renewal triggered manually after the scheduled Action Scheduler "
                      f"action stalled. Charged Stripe PaymentIntent {intent['id']}."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    return order
apply.js
async function chargeOffSession(customerId, paymentMethodToken, amountMinor, currency, subscriptionId) {
  return stripe.paymentIntents.create({
    amount: amountMinor,
    currency,
    customer: customerId,
    payment_method: paymentMethodToken,
    off_session: true,
    confirm: true,
    metadata: { subscription_id: String(subscriptionId), reason: "stalled_renewal_trigger" },
  });
}

async function createRenewalOrder(subscription, intent) {
  const chargeId = intent.latest_charge || intent.id;
  const order = await woo("/orders", {
    method: "POST",
    body: JSON.stringify({
      status: "processing",
      customer_id: subscription.customer_id,
      payment_method: subscription.payment_method || "stripe",
      transaction_id: chargeId,
      line_items: subscription.line_items || [],
      meta_data: [{ key: "_subscription_renewal", value: String(subscription.id) }],
    }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal triggered manually after the scheduled Action Scheduler action ` +
            `stalled. Charged Stripe PaymentIntent ${intent.id}.`,
    }),
  });
  return order;
}
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 trigger. Read the output, trust it, then switch it off to let it charge. Run it on a schedule with cron every few hours, alongside fixing the actual Action Scheduler stall.

Run it safe

Always start with DRY_RUN=true. This script charges real cards, so you want to see its plan before it acts. Once the report looks right, turn it off, and keep it running only until the underlying Action Scheduler stall is actually fixed.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never charges a subscription that already has a renewal order or has no saved payment method.

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

trigger_stalled_renewals.py
"""Detect and trigger WooCommerce Subscriptions renewals whose scheduled Action
Scheduler action stalled and never ran.

WooCommerce Subscriptions renews a subscription by scheduling a
"woocommerce_scheduled_subscription_payment" action in Action Scheduler for the
subscription's next payment date. If the Action Scheduler queue runner stalls
(WP-Cron disabled, a stuck "in-progress" claim, PHP timing out mid batch), that
action never fires. The subscription stays active, its next payment date drifts
into the past, and no renewal order and no charge are ever created.

This script finds active subscriptions whose next payment date has passed with no
matching renewal order, and for each one, charges the customer's saved payment
method off session with Stripe and creates the renewal order over the WooCommerce
REST API, the same way the scheduled action would have. Read only by default.
"""
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("trigger_stalled_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", "6"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ACTIVE_STATUSES = {"active"}


def stalled_subscriptions():
    """Yield active subscriptions whose next payment date is in the past
    by more than GRACE_HOURS, from the WooCommerce Subscriptions REST API."""
    page = 1
    cutoff = int(time.time()) - GRACE_HOURS * 3600
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            next_ts = _to_epoch(sub.get("next_payment_date_gmt"))
            if next_ts is not None and next_ts < cutoff:
                yield sub
        page += 1


def _to_epoch(gmt_string):
    if not gmt_string:
        return None
    try:
        return int(time.mktime(time.strptime(gmt_string, "%Y-%m-%dT%H:%M:%S")))
    except ValueError:
        return None


def last_renewal_order(subscription):
    """The most recent renewal order id linked to this subscription, or None."""
    ids = subscription.get("renewal_order_ids") or []
    return ids[-1] if ids else None


def payment_method_token_of(subscription):
    """The saved Stripe payment method id, from meta _stripe_payment_method.
    Returns None when nothing is saved."""
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_payment_method" and meta.get("value"):
            return meta["value"]
    return None


def subscription_amount_minor(subscription):
    # 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(subscription["total"]) * 100)


def decide(subscription, has_recent_renewal_order, payment_method_token):
    """Pure decision: what should happen to one stalled subscription.

    Returns a tuple of (action, reason). No I/O happens here, which is what
    makes it safe and fast to unit test.
    """
    if subscription.get("status") not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active")
    if has_recent_renewal_order:
        return ("skip", "a renewal order already exists for this period")
    if not payment_method_token:
        return ("manual", "no saved payment method, needs the customer or manual dunning")
    if float(subscription.get("total", "0")) <= 0:
        return ("skip", "zero cost renewal, no charge needed")
    return ("trigger", "next payment date passed with no renewal order or charge")


def charge_off_session(customer_id, payment_method_token, amount_minor, currency, subscription_id):
    intent = stripe.PaymentIntent.create(
        amount=amount_minor,
        currency=currency,
        customer=customer_id,
        payment_method=payment_method_token,
        off_session=True,
        confirm=True,
        metadata={"subscription_id": str(subscription_id), "reason": "stalled_renewal_trigger"},
    )
    return intent


def create_renewal_order(subscription, intent):
    charge_id = intent.get("latest_charge") or intent["id"]
    r = requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        json={
            "status": "processing",
            "customer_id": subscription["customer_id"],
            "payment_method": subscription.get("payment_method", "stripe"),
            "transaction_id": charge_id,
            "line_items": subscription.get("line_items", []),
            "meta_data": [{"key": "_subscription_renewal", "value": str(subscription["id"])}],
        },
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    order = r.json()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Renewal triggered manually after the scheduled Action Scheduler "
                      f"action stalled. Charged Stripe PaymentIntent {intent['id']}."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    return order


def run():
    triggered = 0
    for subscription in stalled_subscriptions():
        renewal_order_id = last_renewal_order(subscription)
        payment_method_token = payment_method_token_of(subscription)
        action, reason = decide(subscription, renewal_order_id is not None, payment_method_token)
        if action == "skip":
            continue
        if action == "manual":
            log.warning("Subscription %s: %s", subscription["id"], reason)
            continue
        log.info("Subscription %s: %s. %s", subscription["id"], reason, "would trigger" if DRY_RUN else "triggering")
        if not DRY_RUN:
            intent = charge_off_session(
                subscription["customer_id"],
                payment_method_token,
                subscription_amount_minor(subscription),
                subscription.get("currency", "usd").lower(),
                subscription["id"],
            )
            create_renewal_order(subscription, intent)
        triggered += 1
    log.info("Done. %d subscription(s) %s.", triggered, "to trigger" if DRY_RUN else "triggered")


if __name__ == "__main__":
    run()
trigger-stalled-renewals.js
/**
 * Detect and trigger WooCommerce Subscriptions renewals whose scheduled Action
 * Scheduler action stalled and never ran.
 *
 * WooCommerce Subscriptions renews a subscription by scheduling a
 * "woocommerce_scheduled_subscription_payment" action in Action Scheduler for the
 * subscription's next payment date. If the Action Scheduler queue runner stalls
 * (WP-Cron disabled, a stuck "in-progress" claim, PHP timing out mid batch), that
 * action never fires. The subscription stays active, its next payment date drifts
 * into the past, and no renewal order and no charge are ever created.
 *
 * This script finds active subscriptions whose next payment date has passed with
 * no matching renewal order, and for each one, charges the customer's saved
 * payment method off session with Stripe and creates the renewal order over the
 * WooCommerce REST API, the same way the scheduled action would have. Read only
 * by default.
 *
 * Guide: https://www.allanninal.dev/woocommerce/renewal-actions-stall-no-renewals-made/
 */
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 || 6);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ACTIVE_STATUSES = new Set(["active"]);

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 toEpoch(gmtString) {
  if (!gmtString) return null;
  const ms = Date.parse(gmtString.endsWith("Z") ? gmtString : `${gmtString}Z`);
  return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
}

async function* stalledSubscriptions() {
  const cutoff = Math.floor(Date.now() / 1000) - GRACE_HOURS * 3600;
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) {
      const nextTs = toEpoch(sub.next_payment_date_gmt);
      if (nextTs !== null && nextTs < cutoff) yield sub;
    }
    page++;
  }
}

export function lastRenewalOrder(subscription) {
  const ids = subscription.renewal_order_ids || [];
  return ids.length ? ids[ids.length - 1] : null;
}

export function paymentMethodTokenOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_payment_method" && meta.value) return meta.value;
  }
  return null;
}

export function subscriptionAmountMinor(subscription) {
  // 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(subscription.total) * 100);
}

/**
 * Pure decision: what should happen to one stalled subscription.
 * Returns [action, reason]. No I/O happens here, which is what makes it safe
 * and fast to unit test.
 */
export function decide(subscription, hasRecentRenewalOrder, paymentMethodToken) {
  if (!ACTIVE_STATUSES.has(subscription.status)) return ["skip", "subscription is not active"];
  if (hasRecentRenewalOrder) return ["skip", "a renewal order already exists for this period"];
  if (!paymentMethodToken) return ["manual", "no saved payment method, needs the customer or manual dunning"];
  if (parseFloat(subscription.total || "0") <= 0) return ["skip", "zero cost renewal, no charge needed"];
  return ["trigger", "next payment date passed with no renewal order or charge"];
}

async function chargeOffSession(customerId, paymentMethodToken, amountMinor, currency, subscriptionId) {
  return stripe.paymentIntents.create({
    amount: amountMinor,
    currency,
    customer: customerId,
    payment_method: paymentMethodToken,
    off_session: true,
    confirm: true,
    metadata: { subscription_id: String(subscriptionId), reason: "stalled_renewal_trigger" },
  });
}

async function createRenewalOrder(subscription, intent) {
  const chargeId = intent.latest_charge || intent.id;
  const order = await woo("/orders", {
    method: "POST",
    body: JSON.stringify({
      status: "processing",
      customer_id: subscription.customer_id,
      payment_method: subscription.payment_method || "stripe",
      transaction_id: chargeId,
      line_items: subscription.line_items || [],
      meta_data: [{ key: "_subscription_renewal", value: String(subscription.id) }],
    }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal triggered manually after the scheduled Action Scheduler action ` +
            `stalled. Charged Stripe PaymentIntent ${intent.id}.`,
    }),
  });
  return order;
}

export async function run() {
  let triggered = 0;
  for await (const subscription of stalledSubscriptions()) {
    const renewalOrderId = lastRenewalOrder(subscription);
    const paymentMethodToken = paymentMethodTokenOf(subscription);
    const [action, reason] = decide(subscription, renewalOrderId !== null, paymentMethodToken);
    if (action === "skip") continue;
    if (action === "manual") {
      console.warn(`Subscription ${subscription.id}: ${reason}`);
      continue;
    }
    console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would trigger" : "triggering"}`);
    if (!DRY_RUN) {
      const intent = await chargeOffSession(
        subscription.customer_id,
        paymentMethodToken,
        subscriptionAmountMinor(subscription),
        (subscription.currency || "usd").toLowerCase(),
        subscription.id
      );
      await createRenewalOrder(subscription, intent);
    }
    triggered++;
  }
  console.log(`Done. ${triggered} subscription(s) ${DRY_RUN ? "to trigger" : "triggered"}.`);
}

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 whether real cards get charged. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_stalled_renewal_decide.py
from trigger_stalled_renewals import decide


def sub(**over):
    base = {"status": "active", "total": "29.00"}
    base.update(over)
    return base


def test_trigger_when_due_and_no_renewal_order():
    assert decide(sub(), False, "pm_1")[0] == "trigger"


def test_skip_when_renewal_order_already_exists():
    assert decide(sub(), True, "pm_1")[0] == "skip"


def test_skip_when_subscription_not_active():
    assert decide(sub(status="on-hold"), False, "pm_1")[0] == "skip"


def test_manual_when_no_payment_method_saved():
    assert decide(sub(), False, None)[0] == "manual"


def test_skip_when_zero_cost_renewal():
    assert decide(sub(total="0.00"), False, "pm_1")[0] == "skip"
trigger-stalled-renewals.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, lastRenewalOrder, paymentMethodTokenOf, subscriptionAmountMinor } from "./trigger-stalled-renewals.js";

const sub = (over = {}) => ({ status: "active", total: "29.00", ...over });

test("trigger when due and no renewal order", () => {
  assert.equal(decide(sub(), false, "pm_1")[0], "trigger");
});

test("skip when renewal order already exists", () => {
  assert.equal(decide(sub(), true, "pm_1")[0], "skip");
});

test("skip when subscription not active", () => {
  assert.equal(decide(sub({ status: "on-hold" }), false, "pm_1")[0], "skip");
});

test("manual when no payment method saved", () => {
  assert.equal(decide(sub(), false, null)[0], "manual");
});

test("skip when zero cost renewal", () => {
  assert.equal(decide(sub({ total: "0.00" }), false, "pm_1")[0], "skip");
});

Case studies

Disabled WP-Cron

The host that turned off WP-Cron for speed

A managed host disabled WP-Cron and set up a real system cron job to call wp-cron.php, but the job pointed at the staging URL by mistake after a migration. On production, nothing was ever calling into WordPress to run scheduled actions, so weeks of renewals piled up silently while every subscription still showed Active.

The script found over three hundred overdue subscriptions on its first dry run. The team fixed the cron job, then ran the script for real in small batches to catch up the backlog without a spike of chargebacks from stale cards.

Stuck claim

The batch that never let go

A plugin conflict caused a PHP fatal error mid way through an Action Scheduler batch. The batch stayed claimed and in-progress, and because Action Scheduler will not reclaim a batch that is still marked as running, every renewal action queued after that point simply waited forever.

The recovery script kept the store billing correctly while the team traced the fatal error to the conflicting plugin, deactivated it, and cleared the stuck batch by hand.

What good looks like

After this runs on a schedule, a stalled queue stops meaning lost revenue. The worst case becomes a delay of a few hours before the script catches an overdue subscription and charges it the same way the scheduled action would have. Keep it running as backup coverage even after Action Scheduler is healthy again, and treat any subscription it flags as manual as a real support task.

FAQ

Why did my WooCommerce Subscriptions renewals stop running?

WooCommerce Subscriptions schedules each renewal as a woocommerce_scheduled_subscription_payment action in Action Scheduler. If the queue runner stalls, because WP-Cron is disabled, a batch got stuck in-progress, or a fatal error broke a run, those actions never fire. A script that finds active subscriptions whose next payment date has passed with no matching renewal order, then triggers the charge directly, fixes it.

Is it safe to charge a customer with a script instead of letting WooCommerce do it?

Yes, when the script only acts on subscriptions that are active, past their next payment date, have no renewal order for the current period yet, and have a saved payment method on file. It charges off session with Stripe the same way the scheduled action would have. Start in dry run mode to review the list before it charges anyone.

What happens to subscriptions with no saved payment method?

The script never guesses. When there is no saved payment method it flags the subscription for manual follow up instead of attempting a charge, so a missing card becomes a support task rather than a silent failure.

Related field notes

Citations

On the problem:

  1. Action Scheduler issue: actions can be left claimed and in-progress indefinitely after an interrupted run. github.com/woocommerce/action-scheduler/issues/653
  2. WooCommerce docs: how Action Scheduler processes scheduled tasks and depends on WP-Cron or a real server cron. actionscheduler.org/faq
  3. WooCommerce Subscriptions docs: renewal processing and the scheduled payment hook. woocommerce.com/document/subscriptions/renewal-process

On the solution:

  1. Stripe docs: creating and confirming off session PaymentIntents for saved payment methods. docs.stripe.com/payments/save-and-reuse
  2. WooCommerce REST API: list and read subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
  3. WooCommerce REST API: create an order 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 get your renewals moving again?

If this saved you a pile of missed renewals or an awkward billing gap conversation, 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