Repair WooCommerce Subscriptions: schedules and dates

Early renewal shifts the billing cadence

A customer clicked "Renew now" to pay a subscription early, or a store manager pushed a manual renewal through the REST API. The payment went through fine. But the subscription's next payment date never moved, so it is still anchored to the old cadence. A few days later the customer is charged again, and now every renewal after that is off by the same amount. Here is why the schedule drifts and a small script that realigns it to the date that was actually paid.

Python and Node.js Runs on a schedule Safe by default (dry run)
Black scissors on a gift box
Photo by Marissa Grootes on Unsplash
The short answer

WooCommerce Subscriptions is supposed to set next_payment to one full billing period after whichever renewal was actually paid, even an early one. When a custom "renew now" flow or a manual REST renewal pays the order but skips the date recalculation, next_payment stays where the original cadence put it, so the next charge lands too soon. Run a small Python or Node.js script on a schedule that reads recent renewal orders, confirms the payment with Stripe, works out the correct next payment date from the paid date plus one billing period, and corrects the subscription when it has drifted. Full code, tests, and a dry run guard are below.

The problem in plain words

Every WooCommerce subscription keeps a schedule in its own metadata: a billing period, a billing interval, and a next_payment date that tells the scheduler when to charge next. Under normal renewals, WooCommerce Subscriptions updates that date itself once the automatic renewal payment succeeds.

Early renewals are a different path. A "Renew now" button, a "pay it forward" feature, or a support agent triggering a manual renewal through the REST API all create and pay a renewal order outside the usual scheduled run. If that path pays the order but never calls the step that recalculates the schedule, the subscription ends up with a renewal that was paid today and a next_payment date that still assumes the old, unshifted cadence. The customer paid early, but WooCommerce still expects to charge them on the original day, so the gap between renewals shrinks to almost nothing.

Customer clicks Renew now, early Renewal order paid Stripe: succeeded schedule not moved next_payment still on old date Charged again days later
The renewal is paid in full, but nothing moves the schedule forward. The next charge lands only days after the last one instead of a full billing period out.

Why it happens

WooCommerce Subscriptions' own documentation on manual renewals notes that the normal date update runs as part of the standard renewal payment process. A few common ways an early renewal skips that step:

None of these are rare edge cases. Any store that offers customers an early renewal option, or that uses internal tools to push through manual renewals for support cases, can hit this. It is reported often enough that "renew now" and "pay it forward" plugins carry warnings in their own documentation about keeping the schedule in sync.

The key insight

The paid renewal order is the source of truth for when the customer actually paid. If a subscription's next_payment does not sit exactly one billing period after the date its most recent renewal was paid, the schedule is wrong, not the payment. A small script that recalculates the correct date from the paid renewal and compares it to what is stored catches the drift before the next charge fires on the wrong day.

The fix, as a flow

We do not touch checkout or the renewal payment itself. We add a job that runs once a day, looks at recent renewal orders, confirms each one with Stripe, and works out what the subscription's next payment date should be: the confirmed paid date plus one full billing period. If the subscription's stored date disagrees with that by more than an hour, we write the corrected date and leave a note explaining why.

Scheduled job once a day List renewal orders from the last few days Confirm with Stripe amount and status Date already correct? yes, skip no Realign schedule write date + add note
The script recalculates the correct next payment date from the confirmed paid renewal and only writes a change when the stored date has actually drifted.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and subscriptions. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="3"
export TOLERANCE_SECONDS="3600"
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 LOOKBACK_DAYS="3"
export TOLERANCE_SECONDS="3600"
export DRY_RUN="true"   // start safe, change to false to write
2

List recent renewal orders

Ask the WooCommerce REST API for orders created in the last few days, then keep only the ones that carry _subscription_renewal in their meta data. That meta key points at the parent subscription, and it is how WooCommerce Subscriptions marks any order as a renewal rather than an original purchase.

step2.py
import os, requests
from datetime import datetime, timedelta, timezone
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 meta_value(item, key):
    for meta in item.get("meta_data") or []:
        if meta.get("key") == key:
            return meta.get("value")
    return None

def recent_renewal_orders(lookback_days):
    since = (datetime.now(timezone.utc) - timedelta(days=lookback_days)).strftime("%Y-%m-%dT%H:%M:%S")
    page = 1
    while True:
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
                          params={"after": since, "per_page": 50, "page": page},
                          auth=AUTH, timeout=30)
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            if meta_value(order, "_subscription_renewal"):
                yield order
        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");

function metaValue(item, key) {
  for (const meta of item.meta_data || []) {
    if (meta.key === key) return meta.value;
  }
  return null;
}

async function* recentRenewalOrders(lookbackDays) {
  const since = new Date(Date.now() - lookbackDays * 86400000).toISOString().slice(0, 19);
  let page = 1;
  while (true) {
    const res = await fetch(`${WOO_URL}/wp-json/wc/v3/orders?after=${since}&per_page=50&page=${page}`,
      { headers: { Authorization: AUTH } });
    const batch = await res.json();
    if (!batch.length) return;
    for (const order of batch) {
      if (metaValue(order, "_subscription_renewal")) yield order;
    }
    page++;
  }
}
3

Load the parent subscription and confirm the payment

Each renewal order's _subscription_renewal meta value is the parent subscription's ID. Load it through the WooCommerce Subscriptions REST endpoint. Then read the PaymentIntent ID from the renewal order's _stripe_intent_id meta, falling back to transaction_id if it looks like a PaymentIntent, and retrieve it from Stripe to confirm it actually succeeded.

step3.py
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

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

def intent_id_of(order):
    stored = meta_value(order, "_stripe_intent_id")
    if stored:
        return stored
    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
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function getSubscription(subscriptionId) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3/subscriptions/${subscriptionId}`,
    { headers: { Authorization: AUTH } });
  if (res.status === 404) return null;
  return res.json();
}

function intentIdOf(order) {
  const stored = metaValue(order, "_stripe_intent_id");
  if (stored) return stored;
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

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

Decide, with one pure function

Keep the decision in its own function that takes the subscription, the renewal order, and the Stripe intent, and returns an action. The rule: if Stripe has not confirmed the payment or the amount is off, hold rather than guess. If the confirmed paid date plus one billing period matches the stored next_payment within a small tolerance, skip. Otherwise, fix it.

decide.py
from datetime import timedelta

PERIOD_SECONDS = {"day": 86400, "week": 7 * 86400, "month": 30 * 86400, "year": 365 * 86400}

def order_amount_minor(order):
    # Works for two decimal currencies. Zero decimal currencies have their own guide.
    return round(float(order["total"]) * 100)

def expected_next_payment(paid_at, billing_interval, billing_period):
    seconds = PERIOD_SECONDS[billing_period] * billing_interval
    return paid_at + timedelta(seconds=seconds)

def decide(subscription, renewal_order, intent, tolerance_seconds=3600):
    if intent is None:
        return ("hold", "no Stripe PaymentIntent found for the renewal order")
    if intent.get("status") != "succeeded":
        return ("skip", "renewal payment not succeeded on Stripe")
    if abs(order_amount_minor(renewal_order) - intent.get("amount_received", 0)) > 1:
        return ("hold", "renewal amount does not match the Stripe charge")

    paid_at = parse_wc_date(renewal_order.get("date_paid_gmt") or renewal_order.get("date_created_gmt"))
    current_next_payment = parse_wc_date(meta_value(subscription, "_schedule_next_payment"))
    billing_interval = int(subscription.get("billing_interval") or 1)
    billing_period = subscription.get("billing_period")

    if paid_at is None or billing_period not in PERIOD_SECONDS:
        return ("hold", "missing paid date or unknown billing period")
    if current_next_payment is None:
        return ("hold", "subscription has no next payment date scheduled")

    correct_next_payment = expected_next_payment(paid_at, billing_interval, billing_period)
    drift = abs((current_next_payment - correct_next_payment).total_seconds())

    if drift <= tolerance_seconds:
        return ("skip", "next payment date already matches the paid renewal")
    return ("fix", f"next payment is off by {int(drift)}s from the corrected cadence")
decide.js
const PERIOD_SECONDS = { day: 86400, week: 7 * 86400, month: 30 * 86400, year: 365 * 86400 };

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function expectedNextPayment(paidAt, billingInterval, billingPeriod) {
  const seconds = PERIOD_SECONDS[billingPeriod] * billingInterval;
  return new Date(paidAt.getTime() + seconds * 1000);
}

export function decide(subscription, renewalOrder, intent, toleranceSeconds = 3600) {
  if (!intent) return ["hold", "no Stripe PaymentIntent found for the renewal order"];
  if (intent.status !== "succeeded") return ["skip", "renewal payment not succeeded on Stripe"];
  if (Math.abs(orderAmountMinor(renewalOrder) - (intent.amount_received || 0)) > 1) {
    return ["hold", "renewal amount does not match the Stripe charge"];
  }

  const paidAt = parseWcDate(renewalOrder.date_paid_gmt || renewalOrder.date_created_gmt);
  const currentNextPayment = parseWcDate(metaValue(subscription, "_schedule_next_payment"));
  const billingInterval = Number(subscription.billing_interval || 1);
  const billingPeriod = subscription.billing_period;

  if (!paidAt || !PERIOD_SECONDS[billingPeriod]) {
    return ["hold", "missing paid date or unknown billing period"];
  }
  if (!currentNextPayment) {
    return ["hold", "subscription has no next payment date scheduled"];
  }

  const correctNextPayment = expectedNextPayment(paidAt, billingInterval, billingPeriod);
  const drift = Math.abs((currentNextPayment.getTime() - correctNextPayment.getTime()) / 1000);

  if (drift <= toleranceSeconds) return ["skip", "next payment date already matches the paid renewal"];
  return ["fix", `next payment is off by ${Math.round(drift)}s from the corrected cadence`];
}
5

Write the corrected schedule

When the action is fix, write the corrected date onto the subscription's _schedule_next_payment meta through the REST API, then add a subscription note so the shop manager can see the schedule was realigned and why. Nothing about the renewal order itself changes, only the forward looking schedule.

apply.py
def format_wc_date(dt):
    return dt.strftime("%Y-%m-%dT%H:%M:%S")

def apply_fix(subscription_id, correct_next_payment):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"meta_data": [{"key": "_schedule_next_payment",
                              "value": format_wc_date(correct_next_payment)}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": "Realigned the next payment date after an early renewal. "
                      f"New next payment: {format_wc_date(correct_next_payment)} UTC."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
function formatWcDate(date) {
  return date.toISOString().slice(0, 19);
}

async function applyFix(subscriptionId, correctNextPayment) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [{ key: "_schedule_next_payment", value: formatWcDate(correctNextPayment) }],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Realigned the next payment date after an early renewal. " +
            `New next payment: ${formatWcDate(correctNextPayment)} UTC.`,
    }),
  });
}
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 which subscriptions it would realign. Read the output, trust it, then switch it off to let it write. Run it once a day, since early renewals are not a high frequency event.

Run it safe

Always start with DRY_RUN=true. This script writes to a live billing schedule, so you want to see its plan, and confirm the dates make sense, before it acts.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever recalculates the schedule from a confirmed, matching payment.

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

realign_next_payment.py
"""Realign a WooCommerce Subscription's next payment date after an early renewal.

When a customer or store manager pays a renewal early ("Renew now" / pay it
forward), WooCommerce Subscriptions is supposed to push next_payment out to one
full billing period from that new paid date. A common bug in custom "renew
now" buttons and some REST driven manual renewals pays the order but never
calls the date update, so next_payment is left pointing at the old cadence.
The next charge then fires just days later instead of a full period out, and
every early renewal after that compounds the drift.

This reads recent renewal orders and their parent subscriptions from the
WooCommerce REST API, works out what next_payment should be from the last
paid renewal date plus the billing interval, and corrects the subscription's
schedule when it has drifted. It also cross checks the paid amount against
the Stripe PaymentIntent (read from order meta _stripe_intent_id, or
transaction_id) so we only trust a renewal that Stripe actually confirms.

Safe by default. Read only unless DRY_RUN is set to false. 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("realign_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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "3"))
TOLERANCE_SECONDS = int(os.environ.get("TOLERANCE_SECONDS", "3600"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# One billing period in seconds, for the periods WooCommerce Subscriptions supports.
PERIOD_SECONDS = {
    "day": 86400,
    "week": 7 * 86400,
    "month": 30 * 86400,
    "year": 365 * 86400,
}

WC_DATE_FMT = "%Y-%m-%dT%H:%M:%S"


def parse_wc_date(value):
    """Parse a WooCommerce GMT date string into an aware UTC datetime, or None."""
    if not value:
        return None
    return datetime.strptime(value, WC_DATE_FMT).replace(tzinfo=timezone.utc)


def format_wc_date(dt):
    return dt.strftime(WC_DATE_FMT)


def meta_value(item, key):
    for meta in item.get("meta_data") or []:
        if meta.get("key") == key:
            return meta.get("value")
    return None


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    stored = meta_value(order, "_stripe_intent_id")
    if stored:
        return stored
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_amount_minor(order):
    """Order total in minor units (cents). Two decimal currencies only."""
    return round(float(order["total"]) * 100)


def expected_next_payment(last_paid_at, billing_interval, billing_period):
    """Pure. What next_payment should be: one full billing period after the
    renewal that was actually paid, regardless of when the old cadence
    said the charge was "due"."""
    seconds = PERIOD_SECONDS[billing_period] * billing_interval
    return last_paid_at + timedelta(seconds=seconds)


def decide(subscription, renewal_order, intent):
    """Pure decision function. No I/O. Returns (action, reason).

    Actions:
      skip  - nothing to do, schedule already correct or renewal not confirmed
      hold  - cannot safely decide, missing data
      fix   - next_payment has drifted from where the early renewal should
              place it, and it needs to move to the corrected date
    """
    if intent is None:
        return ("hold", "no Stripe PaymentIntent found for the renewal order")
    if intent.get("status") != "succeeded":
        return ("skip", "renewal payment not succeeded on Stripe")
    if abs(order_amount_minor(renewal_order) - intent.get("amount_received", 0)) > 1:
        return ("hold", "renewal amount does not match the Stripe charge")

    paid_at = parse_wc_date(renewal_order.get("date_paid_gmt") or renewal_order.get("date_created_gmt"))
    current_next_payment = parse_wc_date(meta_value(subscription, "_schedule_next_payment"))
    billing_interval = int(subscription.get("billing_interval") or 1)
    billing_period = subscription.get("billing_period")

    if paid_at is None or billing_period not in PERIOD_SECONDS:
        return ("hold", "missing paid date or unknown billing period")
    if current_next_payment is None:
        return ("hold", "subscription has no next payment date scheduled")

    correct_next_payment = expected_next_payment(paid_at, billing_interval, billing_period)
    drift = abs((current_next_payment - correct_next_payment).total_seconds())

    if drift <= TOLERANCE_SECONDS:
        return ("skip", "next payment date already matches the paid renewal")

    return ("fix", f"next payment is off by {int(drift)}s from the corrected cadence")


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 recent_renewal_orders():
    """Renewal orders (marked with _subscription_renewal meta) created in the
    lookback window, paged through the REST API."""
    since = (datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS)).strftime(WC_DATE_FMT)
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"after": since, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            if meta_value(order, "_subscription_renewal"):
                yield order
        page += 1


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


def apply_fix(subscription_id, correct_next_payment):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"meta_data": [{"key": "_schedule_next_payment", "value": format_wc_date(correct_next_payment)}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": "Realigned the next payment date after an early renewal. "
                      f"New next payment: {format_wc_date(correct_next_payment)} UTC."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for renewal_order in recent_renewal_orders():
        subscription_id = meta_value(renewal_order, "_subscription_renewal")
        subscription = get_subscription(subscription_id)
        if subscription is None:
            log.warning("Renewal order %s points to missing subscription %s", renewal_order["id"], subscription_id)
            continue

        intent = get_intent(intent_id_of(renewal_order))
        action, reason = decide(subscription, renewal_order, intent)

        if action == "hold":
            log.warning("Subscription %s: %s", subscription_id, reason)
            continue
        if action == "skip":
            continue

        paid_at = parse_wc_date(renewal_order.get("date_paid_gmt") or renewal_order.get("date_created_gmt"))
        billing_interval = int(subscription.get("billing_interval") or 1)
        billing_period = subscription.get("billing_period")
        correct_next_payment = expected_next_payment(paid_at, billing_interval, billing_period)

        log.info("Subscription %s: %s. %s", subscription_id, reason, "would fix" if DRY_RUN else "fixing")
        if not DRY_RUN:
            apply_fix(subscription_id, correct_next_payment)
        fixed += 1

    log.info("Done. %d subscription(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
realign-next-payment.js
/**
 * Realign a WooCommerce Subscription's next payment date after an early renewal.
 *
 * When a customer or store manager pays a renewal early ("Renew now" / pay it
 * forward), WooCommerce Subscriptions is supposed to push next_payment out to
 * one full billing period from that new paid date. A common bug in custom
 * "renew now" buttons and some REST driven manual renewals pays the order but
 * never calls the date update, so next_payment is left pointing at the old
 * cadence. The next charge then fires just days later instead of a full
 * period out, and every early renewal after that compounds the drift.
 *
 * This reads recent renewal orders and their parent subscriptions from the
 * WooCommerce REST API, works out what next_payment should be from the last
 * paid renewal date plus the billing interval, and corrects the
 * subscription's schedule when it has drifted. It also cross checks the paid
 * amount against the Stripe PaymentIntent (read from order meta
 * _stripe_intent_id, or transaction_id) so we only trust a renewal that
 * Stripe actually confirms.
 *
 * Safe by default. Read only unless DRY_RUN is set to false. Run on a schedule.
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 3);
const TOLERANCE_SECONDS = Number(process.env.TOLERANCE_SECONDS || 3600);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PERIOD_SECONDS = {
  day: 86400,
  week: 7 * 86400,
  month: 30 * 86400,
  year: 365 * 86400,
};

export function parseWcDate(value) {
  if (!value) return null;
  return new Date(`${value}Z`);
}

export function formatWcDate(date) {
  return date.toISOString().slice(0, 19);
}

export function metaValue(item, key) {
  for (const meta of item.meta_data || []) {
    if (meta.key === key) return meta.value;
  }
  return null;
}

export function intentIdOf(order) {
  const stored = metaValue(order, "_stripe_intent_id");
  if (stored) return stored;
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function expectedNextPayment(paidAt, billingInterval, billingPeriod) {
  const seconds = PERIOD_SECONDS[billingPeriod] * billingInterval;
  return new Date(paidAt.getTime() + seconds * 1000);
}

export function decide(subscription, renewalOrder, intent) {
  if (!intent) return ["hold", "no Stripe PaymentIntent found for the renewal order"];
  if (intent.status !== "succeeded") return ["skip", "renewal payment not succeeded on Stripe"];
  if (Math.abs(orderAmountMinor(renewalOrder) - (intent.amount_received || 0)) > 1) {
    return ["hold", "renewal amount does not match the Stripe charge"];
  }

  const paidAt = parseWcDate(renewalOrder.date_paid_gmt || renewalOrder.date_created_gmt);
  const currentNextPayment = parseWcDate(metaValue(subscription, "_schedule_next_payment"));
  const billingInterval = Number(subscription.billing_interval || 1);
  const billingPeriod = subscription.billing_period;

  if (!paidAt || !PERIOD_SECONDS[billingPeriod]) {
    return ["hold", "missing paid date or unknown billing period"];
  }
  if (!currentNextPayment) {
    return ["hold", "subscription has no next payment date scheduled"];
  }

  const correctNextPayment = expectedNextPayment(paidAt, billingInterval, billingPeriod);
  const drift = Math.abs((currentNextPayment.getTime() - correctNextPayment.getTime()) / 1000);

  if (drift <= TOLERANCE_SECONDS) return ["skip", "next payment date already matches the paid renewal"];

  return ["fix", `next payment is off by ${Math.round(drift)}s from the corrected cadence`];
}

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

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

async function* recentRenewalOrders() {
  const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString().slice(0, 19);
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?after=${since}&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const order of batch) {
      if (metaValue(order, "_subscription_renewal")) yield order;
    }
    page++;
  }
}

async function getSubscription(subscriptionId) {
  return woo(`/subscriptions/${subscriptionId}`);
}

async function applyFix(subscriptionId, correctNextPayment) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [{ key: "_schedule_next_payment", value: formatWcDate(correctNextPayment) }],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Realigned the next payment date after an early renewal. " +
            `New next payment: ${formatWcDate(correctNextPayment)} UTC.`,
    }),
  });
}

export async function run() {
  let fixed = 0;
  for await (const renewalOrder of recentRenewalOrders()) {
    const subscriptionId = metaValue(renewalOrder, "_subscription_renewal");
    const subscription = await getSubscription(subscriptionId);
    if (!subscription) {
      console.warn(`Renewal order ${renewalOrder.id} points to missing subscription ${subscriptionId}`);
      continue;
    }

    const intent = await getIntent(intentIdOf(renewalOrder));
    const [action, reason] = decide(subscription, renewalOrder, intent);

    if (action === "hold") { console.warn(`Subscription ${subscriptionId}: ${reason}`); continue; }
    if (action === "skip") continue;

    const paidAt = parseWcDate(renewalOrder.date_paid_gmt || renewalOrder.date_created_gmt);
    const billingInterval = Number(subscription.billing_interval || 1);
    const billingPeriod = subscription.billing_period;
    const correctNextPayment = expectedNextPayment(paidAt, billingInterval, billingPeriod);

    console.log(`Subscription ${subscriptionId}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
    if (!DRY_RUN) await applyFix(subscriptionId, correctNextPayment);
    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 decision rule is the part most worth testing, because it decides whether a live billing schedule gets rewritten. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_cadence_realign_decide.py
from realign_next_payment import decide, expected_next_payment


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000}
    base.update(over)
    return base


def subscription(**over):
    base = {
        "billing_interval": 1,
        "billing_period": "month",
        "meta_data": [{"key": "_schedule_next_payment", "value": "2026-07-15T00:00:00"}],
    }
    base.update(over)
    return base


def renewal_order(**over):
    base = {
        "total": "50.00",
        "date_paid_gmt": "2026-06-10T00:00:00",
        "date_created_gmt": "2026-06-10T00:00:00",
    }
    base.update(over)
    return base


def test_fix_when_next_payment_left_on_old_cadence():
    # Paid early on June 10. Correct next payment is July 10, but the
    # subscription still shows July 15, the old cadence, so it should fix.
    sub = subscription(meta_data=[{"key": "_schedule_next_payment", "value": "2026-07-15T00:00:00"}])
    assert decide(sub, renewal_order(), intent())[0] == "fix"


def test_skip_when_next_payment_already_correct():
    sub = subscription(meta_data=[{"key": "_schedule_next_payment", "value": "2026-07-10T00:00:00"}])
    assert decide(sub, renewal_order(), intent())[0] == "skip"


def test_hold_when_no_intent():
    assert decide(subscription(), renewal_order(), None)[0] == "hold"


def test_hold_when_amount_mismatch():
    order = renewal_order(total="80.00")
    assert decide(subscription(), order, intent())[0] == "hold"
realign-next-payment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, expectedNextPayment, parseWcDate } from "./realign-next-payment.js";

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });

const subscription = (over = {}) => ({
  billing_interval: 1,
  billing_period: "month",
  meta_data: [{ key: "_schedule_next_payment", value: "2026-07-15T00:00:00" }],
  ...over,
});

const renewalOrder = (over = {}) => ({
  total: "50.00",
  date_paid_gmt: "2026-06-10T00:00:00",
  date_created_gmt: "2026-06-10T00:00:00",
  ...over,
});

test("fix when next payment left on old cadence", () => {
  const sub = subscription({ meta_data: [{ key: "_schedule_next_payment", value: "2026-07-15T00:00:00" }] });
  assert.equal(decide(sub, renewalOrder(), intent())[0], "fix");
});

test("skip when next payment already correct", () => {
  const sub = subscription({ meta_data: [{ key: "_schedule_next_payment", value: "2026-07-10T00:00:00" }] });
  assert.equal(decide(sub, renewalOrder(), intent())[0], "skip");
});

test("hold when no intent", () => {
  assert.equal(decide(subscription(), renewalOrder(), null)[0], "hold");
});

test("hold when amount mismatch", () => {
  assert.equal(decide(subscription(), renewalOrder({ total: "80.00" }), intent())[0], "hold");
});

Case studies

Renew now button

The plugin that paid but never rescheduled

A store added a third party "renew now" button so customers could top up a subscription before a trip. The button paid the renewal order directly through the payment gateway, but it had no idea it also needed to update _schedule_next_payment. Every customer who used it got billed again about a week later, right on the old schedule.

The script, run once a day, caught the drifted subscriptions the morning after launch, realigned all of them to the correct one month cadence, and left a clear note on each one explaining why the date moved.

Support tool

The internal tool that skipped the schedule step

A support agent used an internal script to push through a manual renewal for a customer who asked to be charged early ahead of a price change. The script created and paid the renewal order through the REST API, but only the REST API, so the subscription's own renewal completion logic never ran.

Dry run output showed the one affected subscription with the exact drift in seconds. The team confirmed it, ran the fix for real, and added a step to the internal tool so it would not happen again.

What good looks like

After this runs on a schedule, an early renewal is no longer a scheduling accident waiting to double bill a customer. The worst case becomes a short delay of a day before the script realigns the cadence. Keep it running even after the root cause is fixed, since an early renewal will always be possible through support tools and customer facing shortcuts alike.

FAQ

Why did an early renewal mess up my subscription's billing date?

WooCommerce Subscriptions is supposed to move next_payment to one full billing period after a renewal is paid, even if that renewal happened early. A bug in a custom renew now button, or a manual renewal made through the REST API, can pay the order without recalculating the schedule, so next_payment stays on the original cadence and the next charge fires too soon.

Is it safe to change a subscription's next payment date with a script?

Yes, when the script only acts after confirming Stripe shows the renewal as succeeded and the amount matches the order total, and it recalculates the date from the confirmed paid date plus one full billing period rather than guessing. Start in dry run mode to review the plan before it writes.

How often should this script run?

Once a day is enough for most stores, since early renewals are not a high frequency event. Running it more often is harmless, since it only touches subscriptions whose schedule has actually drifted from a confirmed paid renewal.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: renewal orders and how the automatic renewal process updates the subscription's schedule. woocommerce.com/document/subscriptions/renewal-process
  2. WooCommerce Subscriptions docs: manual renewals and how they can bypass the standard automatic payment flow. woocommerce.com/document/subscriptions/renewal-process
  3. WooCommerce Subscriptions developer docs: the subscription schedule and its next payment date meta. github.com/woocommerce/woocommerce-subscriptions-core

On the solution:

  1. Stripe API: retrieve a PaymentIntent to confirm status and amount before trusting a payment. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: list orders with date filters and read order meta data. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce Subscriptions REST API: read and update a subscription's fields and meta data. woocommerce.com/document/subscriptions/develop/rest-api

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 dates?

If this saved you a pile of confused customers or an early double charge, 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