Repair WooCommerce Subscriptions: schedules and dates
Active sub with a past next payment date
The subscription looks fine in the admin list. Status: Active. But open it up and the next payment date is last week, or last month, and no renewal order ever appeared. The customer is not being charged, you are not getting paid, and nothing in the UI is telling you why. Here is why the schedule falls behind while the status stays green, and a small job that finds every case and moves the date forward safely.
A subscription's status and its billing schedule are two different things in WooCommerce Subscriptions. The status can stay Active while the scheduled Action Scheduler event that should fire the renewal quietly fails to run, so next_payment never moves and falls into the past. Run a small Python or Node.js job on a schedule that lists Active subscriptions from the WooCommerce REST API, finds any whose next payment date is behind today, computes the correct next future date from the billing period and interval, and writes it back, skipping any subscription that already has a renewal order in progress. Full code, tests, and a dry run guard are below.
The problem in plain words
A WooCommerce subscription is really two records working together. There is the subscription's status, which is what you see as a colored label in the admin list: Active, On hold, Cancelled. And there is its schedule, a set of dates stored in post meta, including _schedule_next_payment, that tells Action Scheduler when to run the renewal.
Normally those two stay in sync. The renewal date arrives, Action Scheduler fires the scheduled action, WooCommerce Subscriptions creates a renewal order, charges the card, and moves the next payment date forward by one billing period. But if that one scheduled action never runs, nothing else changes. The status stays Active because nothing told it to change. The next payment date stays exactly where it was. Today keeps moving. Eventually the date you see in the admin is in the past, on a subscription that still looks perfectly healthy.
Why it happens
WooCommerce Subscriptions relies entirely on Action Scheduler to fire renewals at the right time. When that pipeline breaks anywhere along the way, the date stops moving but nothing marks the subscription as broken. Common causes:
- WP-Cron never runs because the site has no real traffic, or the host disabled it and no system cron was set up to call
wp-cron.phpin its place. - The Action Scheduler queue backs up with thousands of pending or failed actions, so a due renewal sits behind a long backlog and never gets picked up in time.
- A site migration or a staging-to-live copy brought over the old schedule dates along with the database, and the scheduled actions that should drive them were never recreated on the new environment.
- A renewal attempt failed at the payment gateway, and the retry logic that should reschedule the next attempt itself failed silently, leaving the old date in place.
WooCommerce Subscriptions' own documentation on scheduled actions and the Action Scheduler admin screen both point at the same failure mode: a subscription's status and its _schedule_next_payment meta are independent, and a stalled scheduler leaves the status untouched while the date quietly falls behind. See the citations at the end for the exact references.
The billing period and interval on the subscription are the source of truth for what the next payment date should be, not whatever value happens to be stored in _schedule_next_payment right now. A repair job is a safety net that runs on a schedule, recomputes the correct next future date from the subscription's own billing terms, and writes it back, the same way a healthy renewal would have moved it forward.
The fix, as a flow
We do not change any subscription that is not broken. We add a job that runs every hour or so, lists subscriptions with status Active, and for each one checks whether next_payment is behind today. If it is, and there is no renewal order already in progress for that date, we walk the date forward one billing period at a time, using the subscription's own period and interval, until it lands in the future, then write that date back with the WooCommerce REST API.
Build it step by step
Get access to both systems
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions, plus a Stripe secret key so the job can check whether a renewal attempt is already underway for a given order. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export GRACE_HOURS="2"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export GRACE_HOURS="2"
export DRY_RUN="true" // start safe, change to false to write
List the active subscriptions
Ask the WooCommerce REST API for subscriptions with status active, paging through all of them. A small grace period keeps the job from flagging a subscription whose renewal is due any minute now but has not technically arrived yet.
import os, 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 active_subscriptions():
page = 1
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:
yield sub
page += 1
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");
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* activeSubscriptions() {
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) yield sub;
page++;
}
}
Check whether a renewal is already in progress
Before touching the date, look at the subscription's last order. If it has a saved PaymentIntent id in _stripe_intent_id or transaction_id and Stripe shows that intent as still processing or requiring action, a renewal is genuinely underway and we should leave the schedule alone rather than race it.
import stripe
IN_PROGRESS_STATUSES = {"processing", "requires_action", "requires_capture"}
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in (order or {}).get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = (order or {}).get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def renewal_in_progress(last_order):
intent_id = intent_id_of(last_order)
if not intent_id:
return False
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return False
return intent.status in IN_PROGRESS_STATUSES
const IN_PROGRESS_STATUSES = new Set(["processing", "requires_action", "requires_capture"]);
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 async function renewalInProgress(stripe, lastOrder) {
const intentId = intentIdOf(lastOrder);
if (!intentId) return false;
try {
const intent = await stripe.paymentIntents.retrieve(intentId);
return IN_PROGRESS_STATUSES.has(intent.status);
} catch {
return false;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription, the current time, and whether a renewal is in progress, and returns an action plus the corrected date when there is one. A pure function like this is easy to read and easy to test, which we do later. The rule: skip anything not Active, skip anything already renewing, skip anything whose next payment is still in the future, and otherwise compute the next future date from the billing period and interval.
from datetime import datetime, timedelta, timezone
PERIOD_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
def advance(next_payment, period, interval, now):
"""Step next_payment forward by whole billing periods until it is in the future."""
step = timedelta(days=PERIOD_DAYS[period] * interval)
if step.total_seconds() <= 0:
return next_payment
while next_payment <= now:
next_payment += step
return next_payment
def decide(sub, now, renewal_in_progress=False):
if sub["status"] != "active":
return ("skip", "subscription not active", None)
if renewal_in_progress:
return ("skip", "a renewal is already in progress", None)
next_payment = sub["next_payment"]
if next_payment is None or next_payment > now:
return ("skip", "next payment date is not in the past", None)
period = sub.get("billing_period", "month")
interval = int(sub.get("billing_interval", 1) or 1)
if period not in PERIOD_DAYS or interval < 1:
return ("skip", "unknown billing schedule", None)
fixed = advance(next_payment, period, interval, now)
return ("reschedule", "next payment was in the past", fixed)
const PERIOD_DAYS = { day: 1, week: 7, month: 30, year: 365 };
const DAY_MS = 24 * 60 * 60 * 1000;
export function advance(nextPayment, period, interval, now) {
const stepMs = PERIOD_DAYS[period] * interval * DAY_MS;
if (stepMs <= 0) return nextPayment;
let fixed = nextPayment;
while (fixed <= now) fixed += stepMs;
return fixed;
}
export function decide(sub, now, renewalInProgress = false) {
if (sub.status !== "active") return ["skip", "subscription not active", null];
if (renewalInProgress) return ["skip", "a renewal is already in progress", null];
const nextPayment = sub.next_payment;
if (nextPayment == null || nextPayment > now) {
return ["skip", "next payment date is not in the past", null];
}
const period = sub.billing_period || "month";
const interval = Number(sub.billing_interval || 1) || 1;
if (!(period in PERIOD_DAYS) || interval < 1) {
return ["skip", "unknown billing schedule", null];
}
const fixed = advance(nextPayment, period, interval, now);
return ["reschedule", "next payment was in the past", fixed];
}
Write the corrected date back
When the action is reschedule, PUT the new date to the subscription through the REST API and add a note explaining why it moved. The subscription's own next_payment_date_gmt field is what WooCommerce Subscriptions and Action Scheduler read, so writing it there is the same repair a healthy renewal would have made on its own.
def reschedule(sub_id, fixed_date):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"next_payment_date_gmt": fixed_date.strftime("%Y-%m-%d %H:%M:%S")},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Repaired by the schedule fixer. Next payment was in the past, "
f"moved forward to {fixed_date.isoformat()}."},
auth=AUTH, timeout=30,
).raise_for_status()
async function reschedule(subId, fixedDate) {
const iso = new Date(fixedDate).toISOString().replace("T", " ").slice(0, 19);
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({ next_payment_date_gmt: iso }),
});
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Repaired by the schedule fixer. Next payment was in the past, ` +
`moved forward to ${new Date(fixedDate).toISOString()}.`,
}),
});
}
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 change. Read the output, confirm the new dates look right, then switch it off to let it write. Run it on a schedule with cron every hour.
Always start with DRY_RUN=true. A repair job writes to real subscription schedules, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.
The full code
Here is the complete repair job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a subscription whose next payment date is already in the future.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Move an active subscription's next payment date forward when it has fallen into
the past. Run on a schedule. Safe to run again and again.
"""
import os
import logging
from datetime import datetime, timedelta, timezone
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_past_next_payment")
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", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PERIOD_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
IN_PROGRESS_STATUSES = {"processing", "requires_action", "requires_capture"}
def active_subscriptions():
page = 1
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:
yield sub
page += 1
def intent_id_of(order):
for meta in (order or {}).get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = (order or {}).get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def renewal_in_progress(last_order):
intent_id = intent_id_of(last_order)
if not intent_id:
return False
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return False
return intent.status in IN_PROGRESS_STATUSES
def advance(next_payment, period, interval, now):
"""Step next_payment forward by whole billing periods until it is in the future."""
step = timedelta(days=PERIOD_DAYS[period] * interval)
if step.total_seconds() <= 0:
return next_payment
while next_payment <= now:
next_payment += step
return next_payment
def decide(sub, now, renewal_in_progress=False):
if sub["status"] != "active":
return ("skip", "subscription not active", None)
if renewal_in_progress:
return ("skip", "a renewal is already in progress", None)
next_payment = sub["next_payment"]
if next_payment is None or next_payment > now:
return ("skip", "next payment date is not in the past", None)
period = sub.get("billing_period", "month")
interval = int(sub.get("billing_interval", 1) or 1)
if period not in PERIOD_DAYS or interval < 1:
return ("skip", "unknown billing schedule", None)
fixed = advance(next_payment, period, interval, now)
return ("reschedule", "next payment was in the past", fixed)
def reschedule(sub_id, fixed_date):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"next_payment_date_gmt": fixed_date.strftime("%Y-%m-%d %H:%M:%S")},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Repaired by the schedule fixer. Next payment was in the past, "
f"moved forward to {fixed_date.isoformat()}."},
auth=AUTH, timeout=30,
).raise_for_status()
def parse_wc_date(value):
if not value:
return None
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
def run():
now = datetime.now(timezone.utc) - timedelta(hours=GRACE_HOURS)
fixed_count = 0
for sub in active_subscriptions():
sub_view = {
"status": sub["status"],
"next_payment": parse_wc_date(sub.get("next_payment_date_gmt")),
"billing_period": sub.get("billing_period"),
"billing_interval": sub.get("billing_interval"),
}
in_progress = renewal_in_progress(sub.get("last_order"))
action, reason, fixed_date = decide(sub_view, now, in_progress)
if action != "reschedule":
continue
log.info("Subscription %s: %s. New date %s. %s", sub["id"], reason,
fixed_date.isoformat(), "would fix" if DRY_RUN else "fixing")
if not DRY_RUN:
reschedule(sub["id"], fixed_date)
fixed_count += 1
log.info("Done. %d subscription(s) %s.", fixed_count, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Move an active subscription's next payment date forward when it has fallen into
* the past. Run on a schedule. Safe to run again and again.
*/
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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 || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PERIOD_DAYS = { day: 1, week: 7, month: 30, year: 365 };
const DAY_MS = 24 * 60 * 60 * 1000;
const IN_PROGRESS_STATUSES = new Set(["processing", "requires_action", "requires_capture"]);
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* activeSubscriptions() {
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) yield sub;
page++;
}
}
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;
}
async function renewalInProgress(lastOrder) {
const intentId = intentIdOf(lastOrder);
if (!intentId) return false;
try {
const intent = await stripe.paymentIntents.retrieve(intentId);
return IN_PROGRESS_STATUSES.has(intent.status);
} catch {
return false;
}
}
export function advance(nextPayment, period, interval, now) {
const stepMs = PERIOD_DAYS[period] * interval * DAY_MS;
if (stepMs <= 0) return nextPayment;
let fixed = nextPayment;
while (fixed <= now) fixed += stepMs;
return fixed;
}
export function decide(sub, now, renewalInProgressFlag = false) {
if (sub.status !== "active") return ["skip", "subscription not active", null];
if (renewalInProgressFlag) return ["skip", "a renewal is already in progress", null];
const nextPayment = sub.next_payment;
if (nextPayment == null || nextPayment > now) {
return ["skip", "next payment date is not in the past", null];
}
const period = sub.billing_period || "month";
const interval = Number(sub.billing_interval || 1) || 1;
if (!(period in PERIOD_DAYS) || interval < 1) {
return ["skip", "unknown billing schedule", null];
}
const fixed = advance(nextPayment, period, interval, now);
return ["reschedule", "next payment was in the past", fixed];
}
async function reschedule(subId, fixedDate) {
const iso = new Date(fixedDate).toISOString().replace("T", " ").slice(0, 19);
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({ next_payment_date_gmt: iso }),
});
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Repaired by the schedule fixer. Next payment was in the past, ` +
`moved forward to ${new Date(fixedDate).toISOString()}.`,
}),
});
}
function parseWcDate(value) {
if (!value) return null;
return new Date(value.endsWith("Z") ? value : value + "Z").getTime();
}
async function run() {
const now = Date.now() - GRACE_HOURS * 60 * 60 * 1000;
let fixedCount = 0;
for await (const sub of activeSubscriptions()) {
const subView = {
status: sub.status,
next_payment: parseWcDate(sub.next_payment_date_gmt),
billing_period: sub.billing_period,
billing_interval: sub.billing_interval,
};
const inProgress = await renewalInProgress(sub.last_order);
const [action, reason, fixedDate] = decide(subView, now, inProgress);
if (action !== "reschedule") continue;
console.log(`Subscription ${sub.id}: ${reason}. New date ${new Date(fixedDate).toISOString()}. ` +
`${DRY_RUN ? "would fix" : "fixing"}`);
if (!DRY_RUN) await reschedule(sub.id, fixedDate);
fixedCount++;
}
console.log(`Done. ${fixedCount} subscription(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule and the date math are the parts most worth testing, because together they decide which real subscriptions get touched and what date they end up with. Because we kept decide and advance pure, the tests need no network and no Stripe account. They just feed in plain values and check the result.
from datetime import datetime, timedelta, timezone
from fix_past_next_payment import decide, advance
NOW = datetime(2026, 7, 10, 12, 0, 0, tzinfo=timezone.utc)
def sub(**over):
base = {
"status": "active",
"next_payment": NOW - timedelta(days=10),
"billing_period": "month",
"billing_interval": 1,
}
base.update(over)
return base
def test_reschedule_when_active_and_past_due():
action, _, fixed = decide(sub(), NOW)
assert action == "reschedule"
assert fixed > NOW
def test_skip_when_not_active():
action, _, fixed = decide(sub(status="on-hold"), NOW)
assert action == "skip"
assert fixed is None
def test_skip_when_next_payment_in_future():
action, _, _ = decide(sub(next_payment=NOW + timedelta(days=5)), NOW)
assert action == "skip"
def test_skip_when_renewal_in_progress():
action, _, _ = decide(sub(), NOW, renewal_in_progress=True)
assert action == "skip"
def test_advance_steps_by_whole_periods():
old = NOW - timedelta(days=95) # about 3 monthly periods behind
fixed = advance(old, "month", 1, NOW)
assert fixed > NOW
assert (fixed - old).days % 30 == 0
def test_advance_respects_multi_month_interval():
old = NOW - timedelta(days=200)
fixed = advance(old, "month", 3, NOW)
assert fixed > NOW
assert (fixed - old).days % 90 == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, advance } from "./fix-past-next-payment.js";
const NOW = Date.parse("2026-07-10T12:00:00Z");
const DAY_MS = 24 * 60 * 60 * 1000;
const sub = (over = {}) => ({
status: "active",
next_payment: NOW - 10 * DAY_MS,
billing_period: "month",
billing_interval: 1,
...over,
});
test("reschedule when active and past due", () => {
const [action, , fixed] = decide(sub(), NOW);
assert.equal(action, "reschedule");
assert.ok(fixed > NOW);
});
test("skip when not active", () => {
const [action, , fixed] = decide(sub({ status: "on-hold" }), NOW);
assert.equal(action, "skip");
assert.equal(fixed, null);
});
test("skip when next payment in future", () => {
const [action] = decide(sub({ next_payment: NOW + 5 * DAY_MS }), NOW);
assert.equal(action, "skip");
});
test("skip when renewal in progress", () => {
const [action] = decide(sub(), NOW, true);
assert.equal(action, "skip");
});
test("advance steps by whole periods", () => {
const old = NOW - 95 * DAY_MS; // about 3 monthly periods behind
const fixed = advance(old, "month", 1, NOW);
assert.ok(fixed > NOW);
assert.equal((fixed - old) % (30 * DAY_MS), 0);
});
test("advance respects multi-month interval", () => {
const old = NOW - 200 * DAY_MS;
const fixed = advance(old, "month", 3, NOW);
assert.ok(fixed > NOW);
assert.equal((fixed - old) % (90 * DAY_MS), 0);
});
Case studies
The store on a host that disabled WP-Cron
A managed host turned off WordPress's default pseudo-cron for performance and expected the owner to set up a real system cron in its place. Nobody did. New orders kept the front end looking alive, but background jobs, including subscription renewals, silently stopped firing.
Within a month, dozens of Active subscriptions had next payment dates weeks in the past. The repair job found all of them in dry run, the site owner set up a real cron entry to fix the root cause, then ran the job for real to bring every schedule back in line.
The staging site that was pushed back to live by mistake
A developer copied the staging database over the live one during a theme update, planning to exclude the subscriptions tables but missing a few rows. The subscriptions came back Active with next payment dates from three weeks earlier, and no scheduled actions existed anymore to drive them.
The job's grace period and renewal-in-progress check meant it did not touch the handful of subscriptions that had a genuine renewal order already mid-flight, and correctly rescheduled the rest to the next sensible future date based on each one's own billing interval.
After this runs on a schedule, a stalled Action Scheduler queue is no longer an invisible revenue leak. The worst case becomes a short delay before the repair job notices the past date and moves it back onto the correct future cadence, ready for the next real renewal to run normally. Keep it running even after you fix the root cause, since scheduler backlogs and cron outages tend to come back.
FAQ
Why does my active WooCommerce subscription have a next payment date in the past?
The subscription's status and its schedule are stored separately. The status still says Active, but the scheduled Action Scheduler event that should have triggered the renewal and moved the date forward never ran, so next_payment stayed on the old date while today passed it.
Is it safe to change a subscription's next payment date with a script?
Yes, when the job only touches subscriptions that are Active, have a next payment date in the past, and have no renewal order already in progress for that date. It should compute the next date from the billing period and interval, not guess, and default to a dry run so you can review the list first.
Will rescheduling the date trigger an extra charge?
No. Moving next_payment forward only updates the schedule. It does not charge the customer by itself. The next real charge happens on the new date through the normal renewal process, the same as any other subscription.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: how scheduled actions and Action Scheduler drive renewal dates. woocommerce.com/document/subscriptions
- WooCommerce docs: troubleshooting WP-Cron and scheduled events not running. woocommerce.com/document/troubleshooting-scheduled-events-wp-cron
- Action Scheduler project: understanding the admin screen, statuses, and why a queue can back up. actionscheduler.org
On the solution:
- WooCommerce Subscriptions REST API: list and update subscriptions, including next_payment_date_gmt. woocommerce.github.io/subscriptions-rest-api-docs
- WooCommerce REST API: read order meta data and add notes. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent and read its status. docs.stripe.com/api/payment_intents/retrieve
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your stalled subscriptions?
If this saved you a pile of missed renewals or a confused customer email, 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