Diagnostic WooCommerce Subscriptions: schedules and dates

Timezone-corrupted next payment dates

A subscription renews hours before it should, or twice on the same day, or looks overdue when the customer was charged just fine last week. The subscription itself is not broken. The next payment date saved against it is. Somewhere along the way, a local time value was written into a field that WooCommerce Subscriptions and Action Scheduler both expect to hold UTC, and now the schedule is running on a corrupted clock. Here is why that happens and a small script that finds every affected subscription and repairs the ones it can safely fix.

Python and Node.js Runs on a schedule Safe by default (dry run)
A monthly calendar
Photo by Behnam Norouzi on Unsplash
The short answer

WooCommerce Subscriptions stores next_payment_date_gmt and its sibling schedule fields in UTC, with no offset attached. A timezone plugin, a server timezone change, or a hand edit can write the site's local wall clock time into that same field instead. The saved date then drifts from the true UTC due date by whatever the site's UTC offset happens to be, and Action Scheduler faithfully fires the renewal at the wrong moment. Run a small Python or Node.js script on a schedule that works out the correct next payment date from the last paid renewal and the billing interval, compares it to the saved value, and repairs any subscription whose drift matches a clean multiple of the site's UTC offset. Anything that does not line up with a clean offset is flagged for a person instead of guessed at. Full code, tests, and a dry run guard are below.

The problem in plain words

Every WooCommerce Subscription carries a small set of schedule dates: when the trial ends, when the next payment is due, when the subscription ends. All of them are meant to be stored the same way, as UTC, with no timezone attached, exactly like every other date column in WordPress. Action Scheduler reads that UTC value directly and fires the renewal action when the clock reaches it. Nothing in that chain expects a timezone conversion, because there is not supposed to be one left to do.

The corruption happens when something writes the site's local time into that UTC field anyway. A well-meaning timezone plugin converts a date for display and saves the converted value back. A store changes its WordPress timezone setting and a bulk update or migration script runs the new offset against dates that were already correct. A support agent edits a subscription's next payment date by hand in a tool that shows local time, not realizing the field underneath expects UTC. Whatever the cause, the saved date is now off by the site's UTC offset, sometimes by a clean multiple of it if the bug ran more than once.

True due date correct, in UTC Local time written into the UTC field offset baked in Date corrupted next_payment_date_gmt Early or double fire
The date starts out correct in UTC. A conversion meant for display gets saved back into the same field, and Action Scheduler now fires on a corrupted clock.

Why it happens

WooCommerce's own documentation on dates and timezones is explicit that internal date fields are stored in UTC (also called GMT) while the store's front end display converts to the site's local timezone only for showing dates to people. That conversion is meant to be one way, from storage to screen, never written back. A few common ways it leaks back into storage anyway:

This is a known category of bug in the wider WordPress and WooCommerce ecosystem: any code path that treats a UTC storage field as if it were a display field for even one write introduces an offset that Action Scheduler will faithfully act on. See the citations at the end for background on how WooCommerce and Action Scheduler expect these dates to be stored.

The key insight

The billing history is the source of truth for what the schedule should be, not the saved date field. If you know the last paid renewal and the subscription's billing interval, you can compute what next_payment_date_gmt should say. A script that does that math independently, then compares it to the saved value, can tell a genuine timezone offset (a clean number of hours) apart from a date that is wrong for some other reason.

The fix, as a flow

We do not touch the live checkout or the renewal logic. We add a job that runs once a day, looks at every active or on-hold subscription, and works out what its next payment date should be from the most recent paid renewal order and the billing period. If the saved date and the expected date disagree by a clean multiple of the site's known UTC offset, within a small tolerance, we correct the saved date and leave a note. If the disagreement does not match a clean offset, we flag it instead of guessing, because a wrong repair can misfire a real charge.

Scheduled job once a day Compute expected from last paid renewal Compare to saved next_payment_date_gmt Clean offset match? yes no, flag Repair date save UTC + add note
The job trusts the billing history, not the saved field, to know what the date should be. Only a clean offset match gets repaired automatically. Everything else is flagged for a person.

Build it step by step

1

Get access and know your site's UTC offset

You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions and orders. Create it under WooCommerce, Settings, Advanced, REST API. You also need to know the site's UTC offset in hours, which you can read from Settings, General, since that is the offset a corrupted date is most likely to be off by.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export SITE_UTC_OFFSET_HOURS="8"   # your site's UTC offset
export MAX_OFFSET_MULTIPLE="2"
export TOLERANCE_MINUTES="5"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// no extra packages needed, fetch is built in

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export SITE_UTC_OFFSET_HOURS="8"   // your site's UTC offset
export MAX_OFFSET_MULTIPLE="2"
export TOLERANCE_MINUTES="5"
export DRY_RUN="true"   // start safe, change to false to write
2

List active subscriptions and find the last paid renewal

Read every subscription that is active or on-hold through the WooCommerce REST API, then for each one look up its most recent renewal order that is processing or completed. That order's date_paid_gmt is the anchor we trust, since it reflects when a real payment actually happened, not a date field that might already be corrupted.

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,on-hold", "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 get_last_paid_renewal(sub_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"subscription": sub_id, "status": "processing,completed",
                 "per_page": 1, "orderby": "date", "order": "desc"},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    batch = r.json()
    return batch[0] if batch else None
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.status === 404) return null;
  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,on-hold&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

async function getLastPaidRenewal(subId) {
  const batch = await woo(
    `/orders?subscription=${subId}&status=processing,completed&per_page=1&orderby=date&order=desc`
  );
  return batch && batch.length ? batch[0] : null;
}
3

Work out the expected next payment date

From the last paid renewal's date and the subscription's billing interval and period, compute what the next payment date should be. This is the same math WooCommerce Subscriptions itself uses to schedule a renewal, kept independent from whatever value happens to already be saved.

expected.py
from datetime import datetime, timedelta, timezone

BILLING_PERIOD_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
WC_DATE_FMT = "%Y-%m-%dT%H:%M:%S"

def parse_woo_date(value):
    if not value:
        return None
    return datetime.strptime(value, WC_DATE_FMT).replace(tzinfo=timezone.utc)

def expected_next_payment(last_paid_at, billing_interval, billing_period):
    days = BILLING_PERIOD_DAYS.get(billing_period, 30) * max(billing_interval, 1)
    return last_paid_at + timedelta(days=days)
expected.js
const BILLING_PERIOD_DAYS = { day: 1, week: 7, month: 30, year: 365 };

export function parseWooDate(value) {
  if (!value) return null;
  return Date.parse(`${value}Z`);
}

export function expectedNextPayment(lastPaidAtMs, billingInterval, billingPeriod) {
  const days = (BILLING_PERIOD_DAYS[billingPeriod] || 30) * Math.max(billingInterval, 1);
  return lastPaidAtMs + days * 86400000;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the subscription and the expected date and returns an action. It skips subscriptions that are not active, skips anything already matching within a small tolerance, repairs a drift that lines up cleanly with a multiple of the site's UTC offset, and flags anything else for a person. This is the part we unit test, since it decides whether a real schedule gets rewritten.

decide.py
def hours_offset(expected, actual):
    return (actual - expected).total_seconds() / 3600.0

def decide(subscription, expected_next_payment_at, tolerance_minutes=5,
           site_utc_offset_hours=0, max_offset_multiple=2):
    if subscription.get("status") not in ("active", "on-hold"):
        return ("skip", "subscription is not active", None)

    saved = parse_woo_date(subscription.get("next_payment_date_gmt"))
    if saved is None:
        return ("skip", "no next payment date saved", None)
    if expected_next_payment_at is None:
        return ("skip", "no expected date to compare against", None)

    tolerance_hours = tolerance_minutes / 60.0
    offset = hours_offset(expected_next_payment_at, saved)

    if abs(offset) <= tolerance_hours:
        return ("ok", "matches the expected date", None)

    if site_utc_offset_hours:
        for multiple in range(1, max_offset_multiple + 1):
            step = site_utc_offset_hours * multiple
            if abs(abs(offset) - abs(step)) <= tolerance_hours:
                corrected = expected_next_payment_at.strftime(WC_DATE_FMT)
                return ("repair", f"off by {multiple}x the site UTC offset "
                        f"({offset:+.1f}h), repairing to {corrected}", corrected)

    return ("flag", f"off by {offset:+.1f}h, does not match a clean site offset multiple", None)
decide.js
export function hoursOffset(expectedMs, actualMs) {
  return (actualMs - expectedMs) / 3600000;
}

export function decide(subscription, expectedNextPaymentMs, {
  toleranceMinutes = 5, siteUtcOffsetHours = 0, maxOffsetMultiple = 2,
} = {}) {
  if (!["active", "on-hold"].includes(subscription.status)) {
    return ["skip", "subscription is not active", null];
  }
  const saved = parseWooDate(subscription.next_payment_date_gmt);
  if (saved === null || Number.isNaN(saved)) return ["skip", "no next payment date saved", null];
  if (expectedNextPaymentMs == null) return ["skip", "no expected date to compare against", null];

  const toleranceHours = toleranceMinutes / 60;
  const offset = hoursOffset(expectedNextPaymentMs, saved);

  if (Math.abs(offset) <= toleranceHours) return ["ok", "matches the expected date", null];

  if (siteUtcOffsetHours) {
    for (let multiple = 1; multiple <= maxOffsetMultiple; multiple++) {
      const step = siteUtcOffsetHours * multiple;
      if (Math.abs(Math.abs(offset) - Math.abs(step)) <= toleranceHours) {
        const corrected = new Date(expectedNextPaymentMs).toISOString().replace(/\.\d{3}Z$/, "");
        return ["repair", `off by ${multiple}x the site UTC offset (${offset.toFixed(1)}h), repairing to ${corrected}`, corrected];
      }
    }
  }
  return ["flag", `off by ${offset.toFixed(1)}h, does not match a clean site offset multiple`, null];
}
5

Write the corrected date back the same way WooCommerce would

When the action is repair, update the subscription's next_payment_date_gmt through the REST API, then add a note explaining what changed and why. Writing through the REST API keeps the change compatible with High Performance Order Storage (HPOS), since WooCommerce handles the underlying storage either way.

apply.py
def repair_next_payment_date(sub_id, corrected_iso):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
        json={"next_payment_date_gmt": corrected_iso},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
        json={"note": f"Timezone repair: next payment date corrected to {corrected_iso} UTC. "
                      f"The saved date was off by a clean multiple of the site's UTC offset."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function repairNextPaymentDate(subId, correctedIso) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({ next_payment_date_gmt: correctedIso }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Timezone repair: next payment date corrected to ${correctedIso} UTC. ` +
            `The saved date was off by a clean multiple of the site's UTC offset.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together: list active subscriptions, find each one's last paid renewal, compute the expected date, decide, then repair or flag. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it once a day, ideally a few hours before your renewal window, with cron.

Run it safe

Always start with DRY_RUN=true. This script rewrites the date a real renewal charge fires on, so you want to see its plan before it acts. Once the report looks right for a few days, turn it off.

The full code

Here is the complete detect and repair script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only repairs a subscription when the drift lines up cleanly with a known offset. Anything murkier is flagged instead of guessed at.

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

fix_next_payment_timezone.py
"""Detect and repair WooCommerce Subscriptions next payment dates that were saved in
the site's local time instead of UTC. Run on a schedule. Safe to run again and again.
"""
import os
import logging
from datetime import datetime, timedelta, timezone

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_timezone")

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"),
)
SITE_UTC_OFFSET_HOURS = float(os.environ.get("SITE_UTC_OFFSET_HOURS", "0"))
MAX_OFFSET_MULTIPLE = int(os.environ.get("MAX_OFFSET_MULTIPLE", "2"))
TOLERANCE_MINUTES = int(os.environ.get("TOLERANCE_MINUTES", "5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

BILLING_PERIOD_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
WC_DATE_FMT = "%Y-%m-%dT%H:%M:%S"


def parse_woo_date(value):
    if not value:
        return None
    return datetime.strptime(value, WC_DATE_FMT).replace(tzinfo=timezone.utc)


def expected_next_payment(last_paid_at, billing_interval, billing_period):
    days = BILLING_PERIOD_DAYS.get(billing_period, 30) * max(billing_interval, 1)
    return last_paid_at + timedelta(days=days)


def hours_offset(expected, actual):
    return (actual - expected).total_seconds() / 3600.0


def decide(subscription, expected_next_payment_at, tolerance_minutes=TOLERANCE_MINUTES,
           site_utc_offset_hours=SITE_UTC_OFFSET_HOURS, max_offset_multiple=MAX_OFFSET_MULTIPLE):
    if subscription.get("status") not in ("active", "on-hold"):
        return ("skip", "subscription is not active", None)

    saved = parse_woo_date(subscription.get("next_payment_date_gmt"))
    if saved is None:
        return ("skip", "no next payment date saved", None)
    if expected_next_payment_at is None:
        return ("skip", "no expected date to compare against", None)

    tolerance_hours = tolerance_minutes / 60.0
    offset = hours_offset(expected_next_payment_at, saved)

    if abs(offset) <= tolerance_hours:
        return ("ok", "matches the expected date", None)

    if site_utc_offset_hours:
        for multiple in range(1, max_offset_multiple + 1):
            step = site_utc_offset_hours * multiple
            if abs(abs(offset) - abs(step)) <= tolerance_hours:
                corrected = expected_next_payment_at.strftime(WC_DATE_FMT)
                return (
                    "repair",
                    f"off by {multiple}x the site UTC offset ({offset:+.1f}h), repairing to {corrected}",
                    corrected,
                )

    return ("flag", f"off by {offset:+.1f}h, does not match a clean site offset multiple", None)


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


def get_last_paid_renewal(sub_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"subscription": sub_id, "status": "processing,completed",
                 "per_page": 1, "orderby": "date", "order": "desc"},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    batch = r.json()
    return batch[0] if batch else None


def active_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold", "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 repair_next_payment_date(sub_id, corrected_iso):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
        json={"next_payment_date_gmt": corrected_iso},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
        json={"note": f"Timezone repair: next payment date corrected to {corrected_iso} UTC. "
                      f"The saved date was off by a clean multiple of the site's UTC offset."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    repaired = 0
    flagged = 0
    for sub in active_subscriptions():
        renewal = get_last_paid_renewal(sub["id"])
        if renewal is None:
            log.info("Subscription %s: no paid renewal yet, skipping", sub["id"])
            continue

        last_paid_at = parse_woo_date(renewal.get("date_paid_gmt") or renewal.get("date_created_gmt"))
        billing_interval = int(sub.get("billing_interval", 1) or 1)
        billing_period = sub.get("billing_period", "month")
        expected = expected_next_payment(last_paid_at, billing_interval, billing_period) if last_paid_at else None

        action, reason, corrected_iso = decide(sub, expected)

        if action in ("skip", "ok"):
            continue

        if action == "flag":
            log.warning("Subscription %s: %s", sub["id"], reason)
            flagged += 1
            continue

        log.info("Subscription %s: %s. %s", sub["id"], reason, "would repair" if DRY_RUN else "repairing")
        if not DRY_RUN:
            repair_next_payment_date(sub["id"], corrected_iso)
        repaired += 1

    log.info(
        "Done. %d subscription(s) %s, %d flagged for review.",
        repaired, "to repair" if DRY_RUN else "repaired", flagged,
    )


if __name__ == "__main__":
    run()
fix-next-payment-timezone.js
/**
 * Detect and repair WooCommerce Subscriptions next payment dates that were saved in
 * the site's local time instead of UTC. Run on a schedule. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

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 SITE_UTC_OFFSET_HOURS = Number(process.env.SITE_UTC_OFFSET_HOURS || 0);
const MAX_OFFSET_MULTIPLE = Number(process.env.MAX_OFFSET_MULTIPLE || 2);
const TOLERANCE_MINUTES = Number(process.env.TOLERANCE_MINUTES || 5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const BILLING_PERIOD_DAYS = { day: 1, week: 7, month: 30, year: 365 };

export function parseWooDate(value) {
  if (!value) return null;
  return Date.parse(`${value}Z`);
}

export function expectedNextPayment(lastPaidAtMs, billingInterval, billingPeriod) {
  const days = (BILLING_PERIOD_DAYS[billingPeriod] || 30) * Math.max(billingInterval, 1);
  return lastPaidAtMs + days * 86400000;
}

export function hoursOffset(expectedMs, actualMs) {
  return (actualMs - expectedMs) / 3600000;
}

function toWooDateString(ms) {
  return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "");
}

export function decide(
  subscription,
  expectedNextPaymentMs,
  {
    toleranceMinutes = TOLERANCE_MINUTES,
    siteUtcOffsetHours = SITE_UTC_OFFSET_HOURS,
    maxOffsetMultiple = MAX_OFFSET_MULTIPLE,
  } = {}
) {
  if (!["active", "on-hold"].includes(subscription.status)) {
    return ["skip", "subscription is not active", null];
  }

  const saved = parseWooDate(subscription.next_payment_date_gmt);
  if (saved === null || Number.isNaN(saved)) {
    return ["skip", "no next payment date saved", null];
  }

  if (expectedNextPaymentMs === null || expectedNextPaymentMs === undefined) {
    return ["skip", "no expected date to compare against", null];
  }

  const toleranceHours = toleranceMinutes / 60;
  const offset = hoursOffset(expectedNextPaymentMs, saved);

  if (Math.abs(offset) <= toleranceHours) {
    return ["ok", "matches the expected date", null];
  }

  if (siteUtcOffsetHours) {
    for (let multiple = 1; multiple <= maxOffsetMultiple; multiple++) {
      const step = siteUtcOffsetHours * multiple;
      if (Math.abs(Math.abs(offset) - Math.abs(step)) <= toleranceHours) {
        const corrected = toWooDateString(expectedNextPaymentMs);
        return [
          "repair",
          `off by ${multiple}x the site UTC offset (${offset >= 0 ? "+" : ""}${offset.toFixed(1)}h), repairing to ${corrected}`,
          corrected,
        ];
      }
    }
  }

  return ["flag", `off by ${offset >= 0 ? "+" : ""}${offset.toFixed(1)}h, does not match a clean site offset multiple`, null];
}

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 getLastPaidRenewal(subId) {
  const batch = await woo(
    `/orders?subscription=${subId}&status=processing,completed&per_page=1&orderby=date&order=desc`
  );
  return batch && batch.length ? batch[0] : null;
}

async function* activeSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

async function repairNextPaymentDate(subId, correctedIso) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({ next_payment_date_gmt: correctedIso }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Timezone repair: next payment date corrected to ${correctedIso} UTC. ` +
            `The saved date was off by a clean multiple of the site's UTC offset.`,
    }),
  });
}

export async function run() {
  let repaired = 0;
  let flagged = 0;
  for await (const sub of activeSubscriptions()) {
    const renewal = await getLastPaidRenewal(sub.id);
    if (!renewal) {
      console.log(`Subscription ${sub.id}: no paid renewal yet, skipping`);
      continue;
    }

    const lastPaidAtMs = parseWooDate(renewal.date_paid_gmt || renewal.date_created_gmt);
    const billingInterval = Number(sub.billing_interval || 1) || 1;
    const billingPeriod = sub.billing_period || "month";
    const expectedMs = lastPaidAtMs
      ? expectedNextPayment(lastPaidAtMs, billingInterval, billingPeriod)
      : null;

    const [action, reason, correctedIso] = decide(sub, expectedMs);

    if (action === "skip" || action === "ok") continue;

    if (action === "flag") {
      console.warn(`Subscription ${sub.id}: ${reason}`);
      flagged++;
      continue;
    }

    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
    if (!DRY_RUN) await repairNextPaymentDate(sub.id, correctedIso);
    repaired++;
  }
  console.log(`Done. ${repaired} subscription(s) ${DRY_RUN ? "to repair" : "repaired"}, ${flagged} flagged for review.`);
}

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 subscription's live schedule gets rewritten. Because we kept decide pure, no network and no live store are needed. It just feeds in plain objects and timestamps and checks the action.

test_timezone_offset_decide.py
from datetime import datetime, timezone
from fix_next_payment_timezone import decide


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


def subscription(**over):
    base = {"id": 501, "status": "active", "next_payment_date_gmt": "2026-08-10T00:00:00"}
    base.update(over)
    return base


def test_ok_when_saved_matches_expected():
    expected = dt(2026, 8, 10, 0, 0)
    sub = subscription(next_payment_date_gmt="2026-08-10T00:00:00")
    assert decide(sub, expected)[0] == "ok"


def test_repair_when_off_by_one_site_offset():
    # Site is UTC+8. The saved date was written 8 hours ahead of the true UTC value.
    expected = dt(2026, 8, 10, 0, 0)
    sub = subscription(next_payment_date_gmt="2026-08-10T08:00:00")
    action, reason, corrected = decide(sub, expected, site_utc_offset_hours=8)
    assert action == "repair"
    assert corrected == "2026-08-10T00:00:00"


def test_flag_when_offset_does_not_match_a_clean_multiple():
    expected = dt(2026, 8, 10, 0, 0)
    sub = subscription(next_payment_date_gmt="2026-08-10T03:00:00")
    assert decide(sub, expected, site_utc_offset_hours=8)[0] == "flag"


def test_skip_when_subscription_not_active():
    expected = dt(2026, 8, 10, 0, 0)
    sub = subscription(status="cancelled")
    assert decide(sub, expected)[0] == "skip"
fix-next-payment-timezone.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./fix-next-payment-timezone.js";

const ms = (y, m, d, h = 0, mi = 0) => Date.UTC(y, m - 1, d, h, mi);
const subscription = (over = {}) => ({
  id: 501, status: "active", next_payment_date_gmt: "2026-08-10T00:00:00", ...over,
});

test("ok when saved matches expected", () => {
  const expected = ms(2026, 8, 10, 0, 0);
  assert.equal(decide(subscription(), expected)[0], "ok");
});

test("repair when off by one site offset", () => {
  // Site is UTC+8. The saved date was written 8 hours ahead of the true UTC value.
  const expected = ms(2026, 8, 10, 0, 0);
  const sub = subscription({ next_payment_date_gmt: "2026-08-10T08:00:00" });
  const [action, , corrected] = decide(sub, expected, { siteUtcOffsetHours: 8 });
  assert.equal(action, "repair");
  assert.equal(corrected, "2026-08-10T00:00:00");
});

test("flag when offset does not match a clean multiple", () => {
  const expected = ms(2026, 8, 10, 0, 0);
  const sub = subscription({ next_payment_date_gmt: "2026-08-10T03:00:00" });
  assert.equal(decide(sub, expected, { siteUtcOffsetHours: 8 })[0], "flag");
});

test("skip when subscription not active", () => {
  const expected = ms(2026, 8, 10, 0, 0);
  const sub = subscription({ status: "cancelled" });
  assert.equal(decide(sub, expected)[0], "skip");
});

Case studies

Server migration

The migration that moved the server's clock, not just the files

A store moved hosts during a migration, and the new server's default timezone was different from the old one. A caching or import step re-saved every subscription's schedule dates using the new server timezone as if it had always been the site's setting, shifting every next payment date by a clean number of hours.

The script found forty two subscriptions off by exactly the new server's offset, all in the same direction. Every one matched a clean multiple, so the dry run report and the real run agreed, and all forty two were corrected without a single flagged case.

Daylight saving

The subscriptions that renewed twice around the clock change

A store in a region observing daylight saving had a handful of subscriptions whose next payment date had been hand corrected months earlier by a support agent using a local time display, before the clocks changed for the season. What was a clean offset in one season became a slightly different one after the transition.

The script flagged these instead of guessing, since the drift no longer matched a clean multiple of the current offset. A quick look confirmed the history and the dates were corrected by hand with confidence, instead of a script assuming which season's offset applied.

What good looks like

After this runs on a schedule, a timezone bug in the schedule dates stops being a mystery support ticket about a subscription renewing at 2am or twice in a day. The script catches the clean cases automatically and puts a clear, evidence backed flag in front of a person for the rest. Keep it running even after you find the root cause, since any future plugin, migration, or hand edit can reopen the same door.

FAQ

Why did a WooCommerce subscription renew hours early or twice in one day?

WooCommerce Subscriptions expects every schedule date, including next_payment_date_gmt, to be stored in UTC. If a plugin, a server timezone change, or a hand edit wrote the site's local wall clock time into that field instead, the saved date sits hours away from the true UTC due date. Action Scheduler runs on the saved value, so it fires the renewal at the wrong moment, sometimes twice around a daylight saving change.

Is it safe to auto-repair a corrupted next payment date?

It is safe only when the drift lines up cleanly with a known offset, such as the site's UTC offset or a small multiple of it, calculated from the last paid renewal and the billing schedule. A drift that does not match a clean offset should be flagged for a person to review instead of guessed at, since writing the wrong date can misfire a real charge.

How often should the timezone check run?

Once a day is enough for most stores, ideally a few hours before your renewal window. It only compares and repairs subscriptions that are active or on-hold, so running it daily carries little risk and catches drift before Action Scheduler acts on a bad date.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: subscription dates and how the schedule is stored and displayed. woocommerce.com/document/subscriptions
  2. WordPress developer docs: dates and times, and why internal storage is UTC while display converts to the site timezone. developer.wordpress.org/apis/handbook/dates-and-times
  3. Action Scheduler documentation: how scheduled actions are stored and run against a UTC timestamp. actionscheduler.org/api

On the solution:

  1. WooCommerce REST API: retrieve and update a subscription, including its schedule dates. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list orders filtered by subscription, status, and date, used to find the last paid renewal. woocommerce.github.io/woocommerce-rest-api-docs
  3. Python docs: datetime and timezone-aware arithmetic used to compute expected schedule dates safely. docs.python.org/3/library/datetime.html

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

If this saved you a pile of confused renewal tickets, 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