Reconciler WooCommerce Subscriptions: schedules and dates
HPOS schedule divergence
A subscription's next payment date should be one fact. On a store with High Performance Order Storage turned on, it quietly becomes two facts: the value in the HPOS order tables, and an older copy in postmeta kept around for backward compatibility. When something writes to only one of them, the two disagree, and whichever system trusts the stale copy renews, reports, or emails on the wrong day. Here is why it happens and a small script that finds every subscription where the two have drifted apart and brings them back to one source of truth.
WooCommerce Subscriptions keeps schedule dates in two places on an HPOS store: the HPOS order tables, which the REST API reads from, and a legacy postmeta copy kept for older code. When a direct meta update, a sync miss during migration, or a third-party plugin touches only one copy, they drift apart. Run a small Python or Node.js job on a schedule that reads each active subscription, compares the HPOS date against the postmeta copy and against the timestamp of the last succeeded Stripe charge, then copies the HPOS value onto postmeta whenever they disagree. Full code, tests, and a dry run guard are below.
The problem in plain words
Before High Performance Order Storage, every subscription date lived in one place: postmeta on the subscription's post row. Reports queried it directly, plugins hooked into it directly, everyone agreed because there was only one copy.
HPOS moves that data into dedicated order tables and exposes it through the REST API and WooCommerce's own data store. To avoid breaking every plugin that still queries postmeta by hand, WooCommerce keeps a compatibility copy there too. Most of the time WooCommerce keeps both copies in sync for you. But a direct SQL update, a custom cron job written before HPOS existed, a plugin that calls update_post_meta() instead of the subscription's own setter, or a sync that was interrupted mid-migration can update one copy and leave the other behind. Now the subscription has two different opinions about when it renews next, and nothing tells you they disagree until a renewal fires on the wrong day.
Why it happens
The WooCommerce HPOS documentation is explicit that it ships with a data sync layer so both storage systems stay consistent while stores migrate, but that layer depends on writes going through WooCommerce's own CRUD objects. A few common ways a write skips that path:
- Custom code written before HPOS existed still calls
update_post_meta( $subscription_id, '_schedule_next_payment', $date )directly instead ofWC_Subscription::update_dates(), so only postmeta changes. - A third-party plugin or an old export or import tool bulk updates dates with raw SQL against the postmeta table, bypassing WooCommerce entirely.
- The store is mid-migration with sync mode set to run in the background, and a subscription is read or acted on before its background sync has caught up.
- A support agent uses a database tool to fix one subscription by hand and only updates the table they happened to open.
This is a known category of report against the HPOS compatibility layer: schedule and date meta getting out of sync after custom code, imports, or partial migrations touch one storage location and not the other. See the citations at the end for the exact references.
HPOS is the source of truth once it is enabled. It is what WooCommerce itself reads from and what the REST API returns. Postmeta is a compatibility copy that should always mirror HPOS, never the other way around. A reconciler that runs on a schedule, reads the HPOS value, and copies it onto postmeta whenever they disagree removes the guesswork about which date is real.
The fix, as a flow
We do not touch checkout or the renewal cron. We add a job that runs on a schedule, reads every active subscription from the REST API (which reflects HPOS), compares the schedule date against the legacy postmeta copy included in the same response, and also checks it against the timestamp of the last succeeded Stripe PaymentIntent tied to the subscription's most recent order. If HPOS and postmeta disagree, we copy the HPOS value onto postmeta. If both agree with each other but the date looks wrong against Stripe, we flag it for a human instead of guessing.
Build it step by step
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 subscriptions and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRIFT_TOLERANCE_SECONDS="3600"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRIFT_TOLERANCE_SECONDS="3600"
export DRY_RUN="true" // start safe, change to false to write
Read each active subscription with both dates
The WooCommerce Subscriptions REST API returns schedule_next_payment from HPOS and includes the subscription's full meta_data array, which still contains the legacy _schedule_next_payment postmeta key on stores that have not fully cleaned it up. We read both from the same response, so there is no chance of comparing stale data against fresh data by accident.
import requests
from requests.auth import HTTPBasicAuth
AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)
def get_subscriptions(woo_url):
page = 1
while True:
r = requests.get(
f"{woo_url}/wp-json/wc/v3/subscriptions",
params={"status": "active,pending-cancel", "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
async function* getSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,pending-cancel&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
Read the last real charge from Stripe
To tell a genuine divergence from a subscription that just paused for a legitimate reason, we need a third fact that neither WooCommerce copy can lie about: when the last renewal actually got paid. We read the PaymentIntent id from the order's _stripe_intent_id meta, falling back to transaction_id when that is what got saved, and ask Stripe for the charge time.
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_last_charge_ts(order):
if order is None:
return None
intent_id = intent_id_of(order)
if not intent_id:
return None
intent = stripe.PaymentIntent.retrieve(intent_id)
if intent.get("status") != "succeeded":
return None
return intent.get("created")
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 getLastChargeTs(order) {
if (!order) return null;
const intentId = intentIdOf(order);
if (!intentId) return null;
const intent = await stripe.paymentIntents.retrieve(intentId);
if (intent.status !== "succeeded") return null;
return intent.created;
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription and the last charge timestamp and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. Skip inactive subscriptions. Repair postmeta whenever it disagrees with HPOS by more than a small tolerance. Otherwise, flag anything whose schedule date is not after the last real charge, since that means the next renewal is at risk no matter what postmeta says.
ACTIVE_STATUSES = {"active", "pending-cancel"}
DRIFT_TOLERANCE_SECONDS = 3600
def decide(subscription, last_charge_ts):
if subscription.get("status") not in ACTIVE_STATUSES:
return ("skip", "subscription is not active")
hpos_ts = hpos_next_payment_ts(subscription)
if hpos_ts is None:
return ("skip", "no HPOS schedule date to compare")
meta_ts = meta_next_payment_ts(subscription)
if meta_ts is not None and abs(hpos_ts - meta_ts) > DRIFT_TOLERANCE_SECONDS:
return ("diverged", "HPOS schedule date and postmeta copy disagree")
if last_charge_ts is not None and hpos_ts <= last_charge_ts:
return ("stale", "next payment date is not after the last succeeded Stripe charge")
return ("ok", "HPOS and postmeta agree, and the schedule is ahead of the last charge")
const ACTIVE_STATUSES = new Set(["active", "pending-cancel"]);
const DRIFT_TOLERANCE_SECONDS = 3600;
export function decide(subscription, lastChargeTs) {
if (!ACTIVE_STATUSES.has(subscription.status)) {
return ["skip", "subscription is not active"];
}
const hposTs = hposNextPaymentTs(subscription);
if (hposTs === null) {
return ["skip", "no HPOS schedule date to compare"];
}
const metaTs = metaNextPaymentTs(subscription);
if (metaTs !== null && Math.abs(hposTs - metaTs) > DRIFT_TOLERANCE_SECONDS) {
return ["diverged", "HPOS schedule date and postmeta copy disagree"];
}
if (lastChargeTs != null && hposTs <= lastChargeTs) {
return ["stale", "next payment date is not after the last succeeded Stripe charge"];
}
return ["ok", "HPOS and postmeta agree, and the schedule is ahead of the last charge"];
}
Repair postmeta from HPOS, or flag for review
When the action is diverged, write the HPOS date onto the legacy postmeta key through the REST API, the direction that always makes postmeta agree with the source of truth. When the action is stale, do not guess. Add an order note so a human decides whether the subscription needs its dates recalculated.
def repair_postmeta_from_hpos(subscription_id, hpos_ts):
iso = datetime.datetime.utcfromtimestamp(hpos_ts).strftime("%Y-%m-%dT%H:%M:%S")
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"meta_data": [{"key": "_schedule_next_payment", "value": iso}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": "Reconciled schedule date: postmeta was out of sync with HPOS. "
"The HPOS value was copied onto the legacy postmeta key."},
auth=AUTH, timeout=30,
).raise_for_status()
async function repairPostmetaFromHpos(subscriptionId, hposTs) {
const iso = new Date(hposTs * 1000).toISOString().replace(/\.\d+Z$/, "");
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: "_schedule_next_payment", value: iso }] }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Reconciled schedule date: postmeta was out of sync with HPOS. " +
"The HPOS value was copied onto the legacy postmeta key.",
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once an hour.
Always start with DRY_RUN=true. This job writes schedule dates on real subscriptions, 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 reconciler 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 repairs postmeta to match HPOS.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Reconcile WooCommerce Subscriptions schedule dates that have drifted between
HPOS (the authoritative wc_orders / wc_orders_meta tables) and the legacy postmeta
copy that some reports, exports, and older custom code still read directly.
When the two disagree, this trusts the HPOS value from the REST API as the
source of truth, then cross-checks it against Stripe: it reads the linked
renewal order's PaymentIntent (from order meta _stripe_intent_id, falling back
to transaction_id) and uses the charge time on that succeeded PaymentIntent to
confirm the next payment date is actually in the future relative to the last
real charge. Read only by default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_schedule_dates")
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"),
)
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
# How much a schedule date is allowed to drift, in seconds, before we call it wrong.
DRIFT_TOLERANCE_SECONDS = int(os.environ.get("DRIFT_TOLERANCE_SECONDS", "3600"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ACTIVE_STATUSES = {"active", "pending-cancel"}
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 hpos_next_payment_ts(subscription):
"""The next payment date as WooCommerce (HPOS) reports it, epoch seconds or None."""
return _parse_woo_datetime(subscription.get("schedule_next_payment"))
def meta_next_payment_ts(subscription):
"""The next payment date as it sits in legacy postmeta, epoch seconds or None."""
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_schedule_next_payment" and meta.get("value"):
return _parse_woo_datetime(meta["value"])
return None
def _parse_woo_datetime(value):
import datetime
if isinstance(value, dict):
value = value.get("date")
if not value:
return None
text = value.split(".")[0]
try:
dt = datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%S")
except ValueError:
dt = datetime.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
return int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())
def decide(subscription, last_charge_ts):
"""Pure decision function. No I/O. Returns (action, reason).
subscription is the WooCommerce Subscriptions REST resource (HPOS backed),
with its meta_data array included so we can see the legacy postmeta copy.
last_charge_ts is the Stripe PaymentIntent charge time (epoch seconds, or
None) for the most recent renewal order tied to this subscription.
Actions:
skip - subscription is not active, nothing to reconcile
ok - HPOS and postmeta agree, and the schedule is after the last charge
diverged - HPOS and postmeta disagree with each other, repair postmeta from HPOS
stale - HPOS agrees with postmeta but the next payment date is not after
the last real Stripe charge, flag for manual review
"""
if subscription.get("status") not in ACTIVE_STATUSES:
return ("skip", "subscription is not active")
hpos_ts = hpos_next_payment_ts(subscription)
if hpos_ts is None:
return ("skip", "no HPOS schedule date to compare")
meta_ts = meta_next_payment_ts(subscription)
if meta_ts is not None and abs(hpos_ts - meta_ts) > DRIFT_TOLERANCE_SECONDS:
return ("diverged", "HPOS schedule date and postmeta copy disagree")
if last_charge_ts is not None and hpos_ts <= last_charge_ts:
return ("stale", "next payment date is not after the last succeeded Stripe charge")
return ("ok", "HPOS and postmeta agree, and the schedule is ahead of the last charge")
def get_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,pending-cancel", "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_renewal_order(subscription):
"""The most recent order in this subscription's order list, or None."""
related = subscription.get("related_orders") or []
order_id = (related[-1] if related else None) or subscription.get("parent_id")
if not order_id:
return None
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def get_last_charge_ts(order):
"""The Stripe charge time for a succeeded PaymentIntent on this order, or None."""
if order is None:
return None
intent_id = intent_id_of(order)
if not intent_id:
return None
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
if intent.get("status") != "succeeded":
return None
charge_id = intent.get("latest_charge")
if charge_id:
try:
charge = stripe.Charge.retrieve(charge_id)
return charge.get("created")
except stripe.error.InvalidRequestError:
pass
return intent.get("created")
def repair_postmeta_from_hpos(subscription_id, hpos_ts):
"""Write the HPOS schedule date back onto the legacy postmeta key so anything
still reading postmeta directly sees the same value the REST API reports.
"""
import datetime
iso = datetime.datetime.utcfromtimestamp(hpos_ts).strftime("%Y-%m-%dT%H:%M:%S")
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"meta_data": [{"key": "_schedule_next_payment", "value": iso}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": "Reconciled schedule date: postmeta was out of sync with HPOS. "
"The HPOS value was copied onto the legacy postmeta key."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag_for_review(subscription_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Schedule check failed: {reason}. HPOS and postmeta agree with "
f"each other but the date looks wrong against Stripe. Please review."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
reconciled = 0
flagged = 0
for subscription in get_subscriptions():
last_order = get_last_renewal_order(subscription)
last_charge_ts = get_last_charge_ts(last_order)
action, reason = decide(subscription, last_charge_ts)
if action in ("skip", "ok"):
continue
sub_id = subscription["id"]
if action == "diverged":
hpos_ts = hpos_next_payment_ts(subscription)
log.info("Subscription %s: %s. %s", sub_id, reason,
"would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
repair_postmeta_from_hpos(sub_id, hpos_ts)
reconciled += 1
elif action == "stale":
log.warning("Subscription %s: %s. %s", sub_id, reason,
"would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
flag_for_review(sub_id, reason)
flagged += 1
log.info(
"Done. %d subscription(s) %s, %d flagged for review.",
reconciled, "to repair" if DRY_RUN else "repaired", flagged,
)
if __name__ == "__main__":
run()
/**
* Reconcile WooCommerce Subscriptions schedule dates that have drifted between
* HPOS (the authoritative wc_orders / wc_orders_meta tables) and the legacy
* postmeta copy that some reports, exports, and older custom code still read
* directly.
*
* When the two disagree, this trusts the HPOS value from the REST API as the
* source of truth, then cross-checks it against Stripe: it reads the linked
* renewal order's PaymentIntent (from order meta _stripe_intent_id, falling
* back to transaction_id) and uses the charge time on that succeeded
* PaymentIntent to confirm the next payment date is actually in the future
* relative to the last real charge. Read only by default. 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 || 30);
// How much a schedule date is allowed to drift, in seconds, before we call it wrong.
const DRIFT_TOLERANCE_SECONDS = Number(process.env.DRIFT_TOLERANCE_SECONDS || 3600);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_STATUSES = new Set(["active", "pending-cancel"]);
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;
}
function parseWooDatetime(value) {
if (value && typeof value === "object") value = value.date;
if (!value) return null;
const text = value.split(".")[0];
const iso = text.includes("T") ? text : text.replace(" ", "T");
const ms = Date.parse(iso.endsWith("Z") ? iso : iso + "Z");
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
}
export function hposNextPaymentTs(subscription) {
return parseWooDatetime(subscription.schedule_next_payment);
}
export function metaNextPaymentTs(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_schedule_next_payment" && meta.value) return parseWooDatetime(meta.value);
}
return null;
}
/**
* Pure decision function. No I/O. Returns [action, reason].
*
* subscription is the WooCommerce Subscriptions REST resource (HPOS backed),
* with its meta_data array included so we can see the legacy postmeta copy.
* lastChargeTs is the Stripe PaymentIntent charge time (epoch seconds, or
* null) for the most recent renewal order tied to this subscription.
*
* Actions:
* skip - subscription is not active, nothing to reconcile
* ok - HPOS and postmeta agree, and the schedule is after the last charge
* diverged - HPOS and postmeta disagree with each other, repair postmeta from HPOS
* stale - HPOS agrees with postmeta but the next payment date is not after
* the last real Stripe charge, flag for manual review
*/
export function decide(subscription, lastChargeTs) {
if (!ACTIVE_STATUSES.has(subscription.status)) {
return ["skip", "subscription is not active"];
}
const hposTs = hposNextPaymentTs(subscription);
if (hposTs === null) {
return ["skip", "no HPOS schedule date to compare"];
}
const metaTs = metaNextPaymentTs(subscription);
if (metaTs !== null && Math.abs(hposTs - metaTs) > DRIFT_TOLERANCE_SECONDS) {
return ["diverged", "HPOS schedule date and postmeta copy disagree"];
}
if (lastChargeTs !== null && lastChargeTs !== undefined && hposTs <= lastChargeTs) {
return ["stale", "next payment date is not after the last succeeded Stripe charge"];
}
return ["ok", "HPOS and postmeta agree, and the schedule is ahead of the last charge"];
}
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* getSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,pending-cancel&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
async function getLastRenewalOrder(subscription) {
const related = subscription.related_orders || [];
const orderId = (related.length ? related[related.length - 1] : null) || subscription.parent_id;
if (!orderId) return null;
return woo(`/orders/${orderId}`);
}
async function getLastChargeTs(order) {
if (!order) return null;
const intentId = intentIdOf(order);
if (!intentId) return null;
let intent;
try {
intent = await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
if (intent.status !== "succeeded") return null;
if (intent.latest_charge) {
try {
const charge = await stripe.charges.retrieve(intent.latest_charge);
return charge.created;
} catch {
// fall through to intent.created
}
}
return intent.created;
}
async function repairPostmetaFromHpos(subscriptionId, hposTs) {
const iso = new Date(hposTs * 1000).toISOString().replace(/\.\d+Z$/, "");
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: "_schedule_next_payment", value: iso }] }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Reconciled schedule date: postmeta was out of sync with HPOS. " +
"The HPOS value was copied onto the legacy postmeta key.",
}),
});
}
async function flagForReview(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Schedule check failed: ${reason}. HPOS and postmeta agree with each ` +
`other but the date looks wrong against Stripe. Please review.`,
}),
});
}
export async function run() {
let reconciled = 0;
let flagged = 0;
for await (const subscription of getSubscriptions()) {
const lastOrder = await getLastRenewalOrder(subscription);
const lastChargeTs = await getLastChargeTs(lastOrder);
const [action, reason] = decide(subscription, lastChargeTs);
if (action === "skip" || action === "ok") continue;
const subId = subscription.id;
if (action === "diverged") {
const hposTs = hposNextPaymentTs(subscription);
console.log(`Subscription ${subId}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await repairPostmetaFromHpos(subId, hposTs);
reconciled++;
} else if (action === "stale") {
console.warn(`Subscription ${subId}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) await flagForReview(subId, reason);
flagged++;
}
}
console.log(
`Done. ${reconciled} 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 which subscriptions get rewritten. Because we kept decide pure, the test needs no network, no Stripe account, and no live store. It just feeds in plain objects and checks the action.
from reconcile_schedule_dates import decide, hpos_next_payment_ts
def subscription(**over):
base = {
"status": "active",
"schedule_next_payment": "2026-08-10T00:00:00",
"meta_data": [{"key": "_schedule_next_payment", "value": "2026-08-10T00:00:00"}],
}
base.update(over)
return base
def test_ok_when_hpos_and_meta_agree_and_schedule_is_ahead():
sub = subscription()
last_charge_ts = hpos_next_payment_ts(sub) - 30 * 86400
assert decide(sub, last_charge_ts)[0] == "ok"
def test_diverged_when_hpos_and_meta_disagree():
sub = subscription(
schedule_next_payment="2026-08-10T00:00:00",
meta_data=[{"key": "_schedule_next_payment", "value": "2026-07-01T00:00:00"}],
)
assert decide(sub, None)[0] == "diverged"
def test_stale_when_schedule_is_not_after_last_charge():
sub = subscription()
last_charge_ts = hpos_next_payment_ts(sub) + 3600
assert decide(sub, last_charge_ts)[0] == "stale"
def test_skip_when_subscription_not_active():
sub = subscription(status="cancelled")
assert decide(sub, None)[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, hposNextPaymentTs } from "./reconcile-schedule-dates.js";
const subscription = (over = {}) => ({
status: "active",
schedule_next_payment: "2026-08-10T00:00:00",
meta_data: [{ key: "_schedule_next_payment", value: "2026-08-10T00:00:00" }],
...over,
});
test("ok when hpos and meta agree and schedule is ahead", () => {
const sub = subscription();
const lastChargeTs = hposNextPaymentTs(sub) - 30 * 86400;
assert.equal(decide(sub, lastChargeTs)[0], "ok");
});
test("diverged when hpos and meta disagree", () => {
const sub = subscription({
schedule_next_payment: "2026-08-10T00:00:00",
meta_data: [{ key: "_schedule_next_payment", value: "2026-07-01T00:00:00" }],
});
assert.equal(decide(sub, null)[0], "diverged");
});
test("stale when schedule is not after last charge", () => {
const sub = subscription();
const lastChargeTs = hposNextPaymentTs(sub) + 3600;
assert.equal(decide(sub, lastChargeTs)[0], "stale");
});
test("skip when subscription not active", () => {
const sub = subscription({ status: "cancelled" });
assert.equal(decide(sub, null)[0], "skip");
});
Case studies
The custom script nobody remembered
A store had a homegrown script from before HPOS that nudged trial end dates for a promo, writing straight to postmeta. After the store enabled HPOS, that script kept running unchanged, quietly drifting postmeta away from what HPOS and the REST API reported for a slice of subscriptions.
The reconciler caught the drift on its first scheduled run, listed every affected subscription with both dates side by side, and once turned off dry run, brought postmeta back in line so the store's own reporting plugin, which still reads postmeta, matched the real schedule again.
The HPOS sync that stalled halfway
During a background HPOS migration, a server restart interrupted the sync queue partway through. A batch of subscriptions ended up with fresh HPOS rows but stale postmeta left over from before the migration began, with no error anywhere to point at it.
Running the job with a wide lookback window found every subscription the interrupted sync had missed, all showing the same divergence pattern, and repaired them in one pass without touching subscriptions the migration had already finished correctly.
After this runs on a schedule, HPOS and postmeta are never allowed to drift apart for long. A stray direct write becomes a small, quiet correction within the hour instead of a mystery renewal weeks later. Keep it running even after you find and fix whatever wrote to postmeta directly, since old code and old habits have a way of coming back.
FAQ
Why does a subscription's next payment date look different in two places?
WooCommerce Subscriptions stores schedule dates in the HPOS order tables, but keeps a legacy postmeta copy for backward compatibility with older code and plugins that read postmeta directly. When something updates one copy without the other, the two disagree, and whichever code path reads the stale copy renews or reports at the wrong time.
Which value should I trust, HPOS or postmeta?
Trust HPOS. It is the source WooCommerce itself reads from when High Performance Order Storage is enabled, and it is what the REST API returns. Treat postmeta as a copy that should always match HPOS, and repair it in that direction, never the reverse.
How do I know a divergence actually caused a bad renewal?
Compare the subscription's next payment date against the timestamp of the last succeeded Stripe PaymentIntent on its most recent order. If the schedule date is not after that charge, the schedule is stale and the next renewal is at risk of firing at the wrong time or not at all.
Related field notes
Citations
On the problem:
- WooCommerce developer docs: High Performance Order Storage overview and the compatibility data sync between HPOS and postmeta. developer.woocommerce.com/docs/high-performance-order-storage-hpos
- WooCommerce Subscriptions developer docs: how subscription dates are stored and updated. woocommerce.com/document/subscriptions/develop/functions
- WooCommerce developer blog: keeping custom code compatible with HPOS instead of querying postmeta directly. developer.woocommerce.com/2022/09/15/high-performance-order-storage-recipe-book
On the solution:
- WooCommerce REST API: retrieve and update a subscription, including its meta_data array. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent and read its charge and creation time. docs.stripe.com/api/payment_intents/retrieve
- Stripe API: retrieve a Charge object for the exact settlement timestamp. docs.stripe.com/api/charges/retrieve
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your schedule drift?
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