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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A january wall calendar
Photo by Maddi Bazzocco on Unsplash
The short answer

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.

Subscription status: active Renewal date arrives on schedule scheduled action never runs No renewal order no charge attempted Still active date: in the past
The status label never changes because nothing tells it to. Only the schedule falls behind, quietly, until the next payment date is well behind today.

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:

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

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.

Scheduled job every hour List active subscriptions Read next_payment and billing terms Past date and no renewal in progress? yes no, skip Write new date next future date + note
The repair job only touches subscriptions that are past due and not already mid-renewal. Everything else is left alone.

Build it step by step

1

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.

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

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.

step2.py
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
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");

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++;
  }
}
3

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.

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

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.

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

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.

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

Run it safe

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.

fix_past_next_payment.py
"""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()
fix-past-next-payment.js
/**
 * 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.

test_decide.py
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
decide.test.js
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

Cron never ran

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.

Staging copy

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.

What good looks like

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:

  1. WooCommerce Subscriptions docs: how scheduled actions and Action Scheduler drive renewal dates. woocommerce.com/document/subscriptions
  2. WooCommerce docs: troubleshooting WP-Cron and scheduled events not running. woocommerce.com/document/troubleshooting-scheduled-events-wp-cron
  3. Action Scheduler project: understanding the admin screen, statuses, and why a queue can back up. actionscheduler.org

On the solution:

  1. WooCommerce Subscriptions REST API: list and update subscriptions, including next_payment_date_gmt. woocommerce.github.io/subscriptions-rest-api-docs
  2. WooCommerce REST API: read order meta data and add notes. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes