Repair WooCommerce Subscriptions: schedules and dates

Next payment date drifts after a late renewal

A renewal payment failed, got retried a few days late, or ran through a delayed cron job, and now the subscription's next payment date is not where it should be. It is not a huge jump, usually a day or two, but it never corrects itself. Every late renewal after that nudges the date a little further off schedule, until a customer notices they were billed on a different day than they signed up for. Here is why the schedule drifts and a small script that recomputes the correct date and repairs it.

Python and Node.js Runs on a schedule Safe by default (dry run)
An orange wrapped gift box with a bow
Photo by Ilie Barna on Unsplash
The short answer

The next payment date is supposed to stay anchored to the subscription's original billing schedule, not to whenever a renewal happens to complete. When a renewal runs late, either from a failed payment retry or a delayed scheduled action, some setups recompute the next date from that late completion time instead of the original anchor, so the schedule creeps forward with every late run. Run a small Python or Node.js repair on a schedule that reads each active subscription, recomputes the correct next payment date from the billing interval and period anchored to the start date, and corrects the stored date whenever it disagrees by more than a small tolerance. Full code, tests, and a dry run guard are below.

The problem in plain words

A WooCommerce subscription bills on an interval, every month, every two weeks, whatever the customer chose at checkout. WooCommerce Subscriptions tracks this with a stored field, _schedule_next_payment, that says exactly when the next charge should run. As long as every renewal happens on time, that date stays perfectly in step with the original schedule.

The trouble starts when a renewal does not happen on time. A card gets declined and the retry succeeds two days later. Action Scheduler falls behind under load and a renewal that should have run at 3am runs at 3pm. A store manager manually retries a failed renewal from the admin screen a week after it was due. In each of these cases, the important question is what the next payment date gets set to once that late renewal finishes. If the next date is calculated from the moment the late renewal actually completed, rather than from where the subscription was always supposed to land, the whole schedule has just shifted. It will never shift back on its own, and the next late renewal shifts it again.

Renewal due scheduled for Jun 1 Card declined retry succeeds Jun 4 renewal runs 3 days late Next date set from Jun 4, not Jun 1 Schedule shifted 3 days, for good
A late renewal completes, and the next payment date is anchored to that late moment instead of the original schedule. The gap does not close on its own.

Why it happens

WooCommerce Subscriptions and the underlying Action Scheduler queue are both designed around scheduled actions that fire at, or soon after, a target time. Most of the time "soon after" means a few seconds. Under real world conditions, it can mean hours or days, and that is where dates start to drift:

None of these throw an error. The renewal completes, the customer is charged, and the order looks completely normal. The only sign anything is wrong is a next payment date that has quietly walked away from the date it should have landed on, which most stores only notice when a customer asks why their bill moved.

The key insight

The correct next payment date is not "whenever the last renewal happened plus one interval." It is the subscription's original schedule, advanced by whole billing periods from its start date, until you land on the next date that is still in the future. Anchoring the calculation to the schedule instead of to the last event is what keeps a late renewal from ever compounding into a permanent shift.

The fix, as a flow

We add a job that runs on a schedule, walks active subscriptions, and for each one recomputes what the next payment date should be, using only the billing interval, the billing period, and the subscription's start date. It then compares that computed date to the date actually stored on the subscription. If the two disagree by more than a small tolerance, in either direction, it corrects the stored date to match the schedule.

Scheduled job once a day Read active subs status: active Recompute date from interval + start Stored date matches? yes, leave alone no Correct the date write + add a note
The repair recomputes the schedule from its true anchor and only writes when the stored date has actually drifted. A subscription that is still on schedule is left untouched.

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, which the WooCommerce Subscriptions extension exposes through the same /wp-json/wc/v3/ namespace as orders. You also need a Stripe secret key if you want to cross check the last renewal's PaymentIntent, read from order meta _stripe_intent_id or transaction_id, though the date repair itself does not require it. 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 DRIFT_TOLERANCE_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 DRIFT_TOLERANCE_HOURS="6"
export DRY_RUN="true"   // start safe, change to false to write
2

Walk active subscriptions

Page through subscriptions with status active. There is no point recomputing a schedule for a subscription that is on-hold, cancelled, or pending cancellation, since none of those are actively billing on a schedule right now.

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

Read the schedule anchor and the last successful renewal's intent

Every subscription carries its own schedule fields: billing_period, billing_interval, start_date_gmt, and the currently stored next_payment_date_gmt. The PaymentIntent id on the most recent renewal order, read from meta _stripe_intent_id or transaction_id, is useful context for a note explaining what caused the drift, but the recalculation itself never depends on Stripe. It only depends on the subscription's own schedule fields.

step3.py
def intent_id_of(order):
    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 get_intent(intent_id):
    import stripe
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return 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;
}

async function getIntent(stripe, intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Recompute the schedule, with one pure function

Given the billing period, the interval, and the start date, step forward in whole intervals from the start date until you reach the first date that is still in the future. That is the correct next payment date, no matter how many renewals, late or on time, have happened in between. Add months for a monthly plan using calendar month arithmetic, not a fixed number of days, since months are not all the same length. Keep this function pure so it takes plain values and a reference "now" and returns a date, with no network calls inside it.

schedule.py
from datetime import datetime, timedelta, timezone

def add_interval(dt, period, interval):
    if period == "day":
        return dt + timedelta(days=interval)
    if period == "week":
        return dt + timedelta(weeks=interval)
    if period in ("month", "year"):
        months_to_add = interval * (12 if period == "year" else 1)
        month_index = dt.month - 1 + months_to_add
        year = dt.year + month_index // 12
        month = month_index % 12 + 1
        day = min(dt.day, _days_in_month(year, month))
        return dt.replace(year=year, month=month, day=day)
    raise ValueError(f"unknown billing period: {period}")

def _days_in_month(year, month):
    if month == 12:
        return 31
    next_month_first = datetime(year, month + 1, 1)
    return (next_month_first - timedelta(days=1)).day

def correct_next_payment(start_date, period, interval, now):
    """Pure: step forward in whole billing intervals from start_date until the
    result is strictly after now. No I/O, so this is fully unit testable."""
    if interval <= 0:
        raise ValueError("interval must be positive")
    next_date = add_interval(start_date, period, interval)
    guard = 0
    while next_date <= now:
        next_date = add_interval(next_date, period, interval)
        guard += 1
        if guard > 10000:
            raise RuntimeError("schedule did not converge, check inputs")
    return next_date
schedule.js
export function daysInMonth(year, monthIndex0) {
  return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate();
}

export function addInterval(date, period, interval) {
  const d = new Date(date.getTime());
  if (period === "day") {
    d.setUTCDate(d.getUTCDate() + interval);
    return d;
  }
  if (period === "week") {
    d.setUTCDate(d.getUTCDate() + interval * 7);
    return d;
  }
  if (period === "month" || period === "year") {
    const monthsToAdd = interval * (period === "year" ? 12 : 1);
    const monthIndex = d.getUTCMonth() + monthsToAdd;
    const year = d.getUTCFullYear() + Math.floor(monthIndex / 12);
    const month = ((monthIndex % 12) + 12) % 12;
    const day = Math.min(d.getUTCDate(), daysInMonth(year, month));
    return new Date(Date.UTC(year, month, day, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
  }
  throw new Error(`unknown billing period: ${period}`);
}

export function correctNextPayment(startDate, period, interval, now) {
  // Pure: step forward in whole billing intervals from startDate until the
  // result is strictly after now. No I/O, so this is fully unit testable.
  if (interval <= 0) throw new Error("interval must be positive");
  let nextDate = addInterval(startDate, period, interval);
  let guard = 0;
  while (nextDate.getTime() <= now.getTime()) {
    nextDate = addInterval(nextDate, period, interval);
    guard += 1;
    if (guard > 10000) throw new Error("schedule did not converge, check inputs");
  }
  return nextDate;
}
5

Decide whether the stored date has drifted, with a second pure function

Compare the stored next_payment_date_gmt to the value correct_next_payment computes. Allow a small tolerance, a handful of hours, since the exact minute a cron job fires will always vary slightly and that is not real drift. Anything beyond the tolerance, in either direction, is a subscription that needs its schedule corrected.

decide.py
DRIFT_TOLERANCE_HOURS = 6

def decide(subscription, now, tolerance_hours=DRIFT_TOLERANCE_HOURS):
    if subscription.get("status") != "active":
        return ("skip", "subscription not active")
    start = subscription["start_date_gmt"]
    period = subscription["billing_period"]
    interval = int(subscription["billing_interval"])
    stored = subscription.get("next_payment_date_gmt")
    if not stored:
        return ("skip", "no next payment date stored yet")
    correct = correct_next_payment(start, period, interval, now)
    drift_hours = (stored - correct).total_seconds() / 3600
    if abs(drift_hours) <= tolerance_hours:
        return ("ok", "next payment date matches the schedule")
    direction = "ahead of" if drift_hours > 0 else "behind"
    return ("fix", f"stored date is {abs(drift_hours):.1f}h {direction} schedule")
decide.js
const DRIFT_TOLERANCE_HOURS = 6;

export function decide(subscription, now, toleranceHours = DRIFT_TOLERANCE_HOURS) {
  if (subscription.status !== "active") return ["skip", "subscription not active"];
  const { start_date_gmt: start, billing_period: period, billing_interval: interval, next_payment_date_gmt: stored } = subscription;
  if (!stored) return ["skip", "no next payment date stored yet"];
  const correct = correctNextPayment(start, period, Number(interval), now);
  const driftHours = (stored.getTime() - correct.getTime()) / 3600000;
  if (Math.abs(driftHours) <= toleranceHours) return ["ok", "next payment date matches the schedule"];
  const direction = driftHours > 0 ? "ahead of" : "behind";
  return ["fix", `stored date is ${Math.abs(driftHours).toFixed(1)}h ${direction} schedule`];
}
6

Write the corrected date and wire it together with a dry run guard

When the action is fix, write the recomputed date back through the REST API and add a subscription note explaining the correction and the size of the drift, so a shop manager can see exactly what changed and why. Leave DRY_RUN on for the first few runs so the script only reports what it would correct. Read the output, trust it, then switch it off. Run it on a schedule with cron once a day, since schedule drift is never urgent to the minute.

Run it safe

Always start with DRY_RUN=true. This job changes a subscription's billing schedule, which affects when a customer is charged next, 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 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 only touches a subscription whose stored date has actually drifted from its own schedule.

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

fix_next_payment_drift.py
"""Correct a WooCommerce Subscriptions next payment date that drifted after a late renewal.

When a renewal runs late, from a failed payment retry, a delayed Action Scheduler
run, or a manual retry from wp-admin, some paths recompute the next payment date
from the moment the late renewal completed instead of from the subscription's
original billing schedule. Each late renewal after that nudges the date a little
further off. This walks active subscriptions, recomputes the correct next payment
date from the billing interval and period anchored to the start date, and corrects
the stored date whenever it disagrees by more than a small tolerance, adding a
subscription note either way. Safe to run again and again. Run on a schedule.
"""
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_next_payment_drift")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
DRIFT_TOLERANCE_HOURS = float(os.environ.get("DRIFT_TOLERANCE_HOURS", "6"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


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 get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def _days_in_month(year, month):
    if month == 12:
        return 31
    next_month_first = datetime(year, month + 1, 1)
    return (next_month_first - timedelta(days=1)).day


def add_interval(dt, period, interval):
    """Step a datetime forward by one or more whole billing periods."""
    if period == "day":
        return dt + timedelta(days=interval)
    if period == "week":
        return dt + timedelta(weeks=interval)
    if period in ("month", "year"):
        months_to_add = interval * (12 if period == "year" else 1)
        month_index = dt.month - 1 + months_to_add
        year = dt.year + month_index // 12
        month = month_index % 12 + 1
        day = min(dt.day, _days_in_month(year, month))
        return dt.replace(year=year, month=month, day=day)
    raise ValueError(f"unknown billing period: {period}")


def correct_next_payment(start_date, period, interval, now):
    """Pure: step forward in whole billing intervals from start_date until the
    result is strictly after now. No I/O, so this is fully unit testable."""
    if interval <= 0:
        raise ValueError("interval must be positive")
    next_date = add_interval(start_date, period, interval)
    guard = 0
    while next_date <= now:
        next_date = add_interval(next_date, period, interval)
        guard += 1
        if guard > 10000:
            raise RuntimeError("schedule did not converge, check inputs")
    return next_date


def decide(subscription, now, tolerance_hours=DRIFT_TOLERANCE_HOURS):
    """Pure decision: given a subscription and the current time, decide whether its
    stored next payment date has drifted from the true schedule. No I/O here, so
    this is fully unit testable."""
    if subscription.get("status") != "active":
        return ("skip", "subscription not active")
    start = subscription["start_date_gmt"]
    period = subscription["billing_period"]
    interval = int(subscription["billing_interval"])
    stored = subscription.get("next_payment_date_gmt")
    if not stored:
        return ("skip", "no next payment date stored yet")
    correct = correct_next_payment(start, period, interval, now)
    drift_hours = (stored - correct).total_seconds() / 3600
    if abs(drift_hours) <= tolerance_hours:
        return ("ok", "next payment date matches the schedule")
    direction = "ahead of" if drift_hours > 0 else "behind"
    return ("fix", f"stored date is {abs(drift_hours):.1f}h {direction} schedule")


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 parse_gmt(value):
    if not value:
        return None
    return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)


def correct_schedule(subscription_id, correct_date, reason):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"next_payment_date_gmt": correct_date.strftime("%Y-%m-%dT%H:%M:%S")},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Next payment date corrected: {reason}. Recomputed from the "
                      f"billing schedule and reset to {correct_date.isoformat()}."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    now = datetime.now(timezone.utc)
    for subscription in active_subscriptions():
        parsed = dict(subscription)
        parsed["start_date_gmt"] = parse_gmt(subscription.get("start_date_gmt"))
        parsed["next_payment_date_gmt"] = parse_gmt(subscription.get("next_payment_date_gmt"))
        action, reason = decide(parsed, now)
        if action != "fix":
            continue
        correct_date = correct_next_payment(
            parsed["start_date_gmt"], parsed["billing_period"], int(parsed["billing_interval"]), now
        )
        log.warning(
            "Subscription %s: %s. %s",
            subscription["id"], reason, "would fix" if DRY_RUN else "fixing",
        )
        if not DRY_RUN:
            correct_schedule(subscription["id"], correct_date, reason)
        fixed += 1
    log.info("Done. %d subscription(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
fix-next-payment-drift.js
/**
 * Correct a WooCommerce Subscriptions next payment date that drifted after a late renewal.
 *
 * When a renewal runs late, from a failed payment retry, a delayed Action Scheduler
 * run, or a manual retry from wp-admin, some paths recompute the next payment date
 * from the moment the late renewal completed instead of from the subscription's
 * original billing schedule. Each late renewal after that nudges the date a little
 * further off. This walks active subscriptions, recomputes the correct next payment
 * date from the billing interval and period anchored to the start date, and corrects
 * the stored date whenever it disagrees by more than a small tolerance, adding a
 * subscription note either way. Safe to run again and again. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/next-payment-date-drifts-after-a-late-renewal/
 */
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 DRIFT_TOLERANCE_HOURS = Number(process.env.DRIFT_TOLERANCE_HOURS || 6);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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 daysInMonth(year, monthIndex0) {
  return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate();
}

export function addInterval(date, period, interval) {
  const d = new Date(date.getTime());
  if (period === "day") {
    d.setUTCDate(d.getUTCDate() + interval);
    return d;
  }
  if (period === "week") {
    d.setUTCDate(d.getUTCDate() + interval * 7);
    return d;
  }
  if (period === "month" || period === "year") {
    const monthsToAdd = interval * (period === "year" ? 12 : 1);
    const monthIndex = d.getUTCMonth() + monthsToAdd;
    const year = d.getUTCFullYear() + Math.floor(monthIndex / 12);
    const month = ((monthIndex % 12) + 12) % 12;
    const day = Math.min(d.getUTCDate(), daysInMonth(year, month));
    return new Date(Date.UTC(year, month, day, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
  }
  throw new Error(`unknown billing period: ${period}`);
}

export function correctNextPayment(startDate, period, interval, now) {
  // Pure: step forward in whole billing intervals from startDate until the
  // result is strictly after now. No I/O, so this is fully unit testable.
  if (interval <= 0) throw new Error("interval must be positive");
  let nextDate = addInterval(startDate, period, interval);
  let guard = 0;
  while (nextDate.getTime() <= now.getTime()) {
    nextDate = addInterval(nextDate, period, interval);
    guard += 1;
    if (guard > 10000) throw new Error("schedule did not converge, check inputs");
  }
  return nextDate;
}

export function decide(subscription, now, toleranceHours = DRIFT_TOLERANCE_HOURS) {
  // Pure decision: given a subscription and the current time, decide whether its
  // stored next payment date has drifted from the true schedule. No I/O here, so
  // this is fully unit testable.
  if (subscription.status !== "active") return ["skip", "subscription not active"];
  const { start_date_gmt: start, billing_period: period, billing_interval: interval, next_payment_date_gmt: stored } = subscription;
  if (!stored) return ["skip", "no next payment date stored yet"];
  const correct = correctNextPayment(start, period, Number(interval), now);
  const driftHours = (stored.getTime() - correct.getTime()) / 3600000;
  if (Math.abs(driftHours) <= toleranceHours) return ["ok", "next payment date matches the schedule"];
  const direction = driftHours > 0 ? "ahead of" : "behind";
  return ["fix", `stored date is ${Math.abs(driftHours).toFixed(1)}h ${direction} schedule`];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* 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++;
  }
}

function parseGmt(value) {
  if (!value) return null;
  return new Date(value.endsWith("Z") ? value : `${value}Z`);
}

async function correctSchedule(subscriptionId, correctDate, reason) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ next_payment_date_gmt: correctDate.toISOString().slice(0, 19) }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Next payment date corrected: ${reason}. Recomputed from the billing ` +
            `schedule and reset to ${correctDate.toISOString()}.`,
    }),
  });
}

export async function run() {
  let fixed = 0;
  const now = new Date();
  for await (const subscription of activeSubscriptions()) {
    const parsed = {
      ...subscription,
      start_date_gmt: parseGmt(subscription.start_date_gmt),
      next_payment_date_gmt: parseGmt(subscription.next_payment_date_gmt),
    };
    const [action, reason] = decide(parsed, now);
    if (action !== "fix") continue;
    const correctDate = correctNextPayment(
      parsed.start_date_gmt, parsed.billing_period, Number(parsed.billing_interval), now
    );
    console.warn(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
    if (!DRY_RUN) await correctSchedule(subscription.id, correctDate, reason);
    fixed++;
  }
  console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

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

Add a test

The schedule math and the drift decision are the parts most worth testing, because together they decide which real subscriptions get their billing date rewritten. Because both correct_next_payment and decide are pure, the tests need no network and no WooCommerce site. They just feed in plain dates and check the result.

test_drift_decide.py
from datetime import datetime, timezone
from fix_next_payment_drift import correct_next_payment, decide


def dt(y, m, d, h=0):
    return datetime(y, m, d, h, tzinfo=timezone.utc)


def test_correct_next_payment_steps_one_month_forward():
    start = dt(2026, 1, 15)
    now = dt(2026, 3, 20)
    result = correct_next_payment(start, "month", 1, now)
    assert result == dt(2026, 4, 15)


def test_correct_next_payment_handles_month_length_change():
    start = dt(2026, 1, 31)
    now = dt(2026, 1, 31, 1)
    result = correct_next_payment(start, "month", 1, now)
    assert result == dt(2026, 2, 28)


def test_ok_when_stored_date_matches_schedule():
    sub = {
        "status": "active", "billing_period": "month", "billing_interval": 1,
        "start_date_gmt": dt(2026, 1, 15), "next_payment_date_gmt": dt(2026, 4, 15),
    }
    assert decide(sub, dt(2026, 3, 20))[0] == "ok"


def test_fix_when_stored_date_drifted_ahead():
    sub = {
        "status": "active", "billing_period": "month", "billing_interval": 1,
        "start_date_gmt": dt(2026, 1, 15), "next_payment_date_gmt": dt(2026, 4, 18),
    }
    action, reason = decide(sub, dt(2026, 3, 20))
    assert action == "fix"
    assert "ahead" in reason


def test_skip_when_subscription_not_active():
    sub = {
        "status": "on-hold", "billing_period": "month", "billing_interval": 1,
        "start_date_gmt": dt(2026, 1, 15), "next_payment_date_gmt": dt(2026, 4, 15),
    }
    assert decide(sub, dt(2026, 3, 20))[0] == "skip"


def test_tolerance_allows_a_small_gap():
    sub = {
        "status": "active", "billing_period": "week", "billing_interval": 2,
        "start_date_gmt": dt(2026, 1, 1), "next_payment_date_gmt": dt(2026, 3, 26, 2),
    }
    assert decide(sub, dt(2026, 3, 20), tolerance_hours=6)[0] == "ok"
fix-next-payment-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { correctNextPayment, decide } from "./fix-next-payment-drift.js";

const dt = (y, m, d, h = 0) => new Date(Date.UTC(y, m - 1, d, h));

test("correctNextPayment steps one month forward", () => {
  const result = correctNextPayment(dt(2026, 1, 15), "month", 1, dt(2026, 3, 20));
  assert.equal(result.getTime(), dt(2026, 4, 15).getTime());
});

test("correctNextPayment handles month length change", () => {
  const result = correctNextPayment(dt(2026, 1, 31), "month", 1, dt(2026, 1, 31, 1));
  assert.equal(result.getTime(), dt(2026, 2, 28).getTime());
});

test("ok when stored date matches schedule", () => {
  const sub = {
    status: "active", billing_period: "month", billing_interval: 1,
    start_date_gmt: dt(2026, 1, 15), next_payment_date_gmt: dt(2026, 4, 15),
  };
  assert.equal(decide(sub, dt(2026, 3, 20))[0], "ok");
});

test("fix when stored date drifted ahead", () => {
  const sub = {
    status: "active", billing_period: "month", billing_interval: 1,
    start_date_gmt: dt(2026, 1, 15), next_payment_date_gmt: dt(2026, 4, 18),
  };
  const [action, reason] = decide(sub, dt(2026, 3, 20));
  assert.equal(action, "fix");
  assert.match(reason, /ahead/);
});

test("skip when subscription not active", () => {
  const sub = {
    status: "on-hold", billing_period: "month", billing_interval: 1,
    start_date_gmt: dt(2026, 1, 15), next_payment_date_gmt: dt(2026, 4, 15),
  };
  assert.equal(decide(sub, dt(2026, 3, 20))[0], "skip");
});

test("tolerance allows a small gap", () => {
  const sub = {
    status: "active", billing_period: "week", billing_interval: 2,
    start_date_gmt: dt(2026, 1, 1), next_payment_date_gmt: dt(2026, 3, 26, 2),
  };
  assert.equal(decide(sub, dt(2026, 3, 20), 6)[0], "ok");
});

Case studies

Declined card, late retry

The subscription that gained two days every failed card

A customer's card kept expiring right before renewal. Each time, the automatic retry succeeded two or three days after the original due date, and the next payment date came out calculated from that late retry instead of the original schedule. Over five months the subscription had drifted almost two weeks from where the customer thought their monthly billing date was.

The repair ran once and reset the schedule back to the correct date, anchored to the original start date. Support could point to a single note explaining exactly how much it had drifted and why, instead of guessing at the history.

Action Scheduler backlog

The store where a busy queue pushed every renewal a day late

During a traffic spike, Action Scheduler fell badly behind and a batch of renewals that were due overnight did not actually run until the following afternoon. None of the payments failed, but the next payment dates on all of those subscriptions were calculated from the late run time.

Running the repair in dry run first showed 63 subscriptions with schedules that had shifted by roughly 14 to 18 hours, comfortably outside the tolerance. The team reviewed the list, then ran it for real, and every subscription's next payment date snapped back to its original day.

What good looks like

After this runs on a schedule, a late renewal stops being a permanent shift in a customer's billing date. The worst case becomes a same day correction the next time the repair runs, with a clear note on the subscription explaining what happened. Keep it running even after you track down why renewals are running late, because a slow retry or a busy queue will happen again eventually.

FAQ

Why did my WooCommerce subscription's next payment date move after a late renewal?

When a renewal runs late, some code paths recalculate the next payment date from the moment the renewal actually completed instead of from the subscription's original billing anchor. Each late renewal nudges the schedule a little further from where it should be, and the drift compounds over time.

Is it safe to recompute and overwrite the next payment date with a script?

Yes, when the script recomputes the date the same way WooCommerce Subscriptions does, from the billing interval and period anchored to the subscription's start date, and only changes subscriptions that are active and where the stored date disagrees with the computed one by more than a small tolerance.

How do I stop the next payment date from drifting again after I fix it?

You cannot fully prevent a late renewal, but you can run this repair on a schedule so any drift it causes is corrected the same day. Anchoring the recalculation to the original start date, not the last renewal's actual completion time, is what keeps the schedule stable.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how renewal payments, retries, and scheduled dates are handled. woocommerce.com/document/subscriptions/renewal-process
  2. WooCommerce docs: Action Scheduler and how delayed background jobs affect scheduled actions. actionscheduler.org
  3. WooCommerce Subscriptions docs: manual renewal payments and retry behavior. woocommerce.com/document/subscriptions/manual-renewal-payments

On the solution:

  1. WooCommerce Subscriptions REST API: the subscription object, including schedule fields. woocommerce.github.io/subscriptions-rest-api-docs
  2. WooCommerce REST API: update a resource and add a note through the same orders and subscriptions namespace. woocommerce.github.io/woocommerce-rest-api-docs
  3. Stripe docs: retrieve a PaymentIntent to see when a renewal charge actually completed. 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 billing schedule?

If this saved you a confused customer or a billing date that would never have corrected itself, 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