Diagnostic WooCommerce Subscriptions: manual renewal and dunning
Trial-end action false-positive failure
Action Scheduler shows the trial end action as failed, red text and all. You brace for an angry customer with a broken subscription. But when you look, the subscription is active, the first renewal order exists, and Stripe shows the charge succeeded. Nothing is actually wrong. Here is why that failed entry shows up anyway, and a small script that checks the real state and clears the false alarm without touching anything that might really be broken.
The trial end action can run twice for the same subscription when a request is slow or two workers pick it up at once. The run that finishes first completes the transition and the renewal. The run that loses the race hits state that has already changed, throws, and Action Scheduler logs that one as failed, even though the subscription is fine. Run a small Python or Node.js check on a schedule that reads the real subscription status and the renewal order's Stripe PaymentIntent, and clears the alarm with a note only when both confirm the trial actually ended cleanly. Full code, tests, and a dry run guard are below.
The problem in plain words
When a free trial ends, WooCommerce Subscriptions schedules one Action Scheduler action to fire on that date. That action moves the subscription out of the trial and, if the plan is not free, kicks off the first real renewal order and charge. Almost all of the time this runs once, quietly, and nobody notices.
Sometimes the same action gets triggered twice for one subscription. Maybe the site had two cron workers running at once, maybe a page load that also processes due actions was slow and overlapped with the real scheduled run, or maybe a host's job runner retried after a timeout that was not actually a failure. The first run does its job and finishes. The second run arrives a moment later, sees a subscription that is no longer on trial, tries to apply the same transition again, and throws an error because the state does not match what it expected. Action Scheduler faithfully records that second, doomed run as failed. The subscription itself never had a problem.
Why it happens
Action Scheduler is built to be safe against this in most cases, but a few conditions still let a double run slip through and leave a false alarm on the log:
- Two cron paths are active at once, for example a real system cron hitting
wp-cron.phpdirectly while a page load also triggers WordPress's built-in pseudo-cron, so both try to claim the same due action. - A slow request holds an action claim past its lock window, so a second worker picks up what it thinks is an abandoned action and runs it again while the first one is still finishing.
- A host or monitoring job retries a request that timed out on the client side but had actually already completed on the server, triggering the hook a second time.
- The action's own claim lock is shorter than how long the trial-end transition and its renewal actually take on a slow site, so the lock expires mid-run and lets another worker in.
This is a known shape of problem with Action Scheduler style job queues in general, not something unique to one store. WooCommerce Subscriptions' own documentation on scheduled actions notes that a failed status only means the last attempt threw an error, not that the underlying subscription state is wrong. See the citations at the end for more detail on how Action Scheduler claims and locks actions.
The Action Scheduler failed status describes one code execution, not the subscription. The subscription's real status and Stripe's record of the charge are the actual source of truth. A check that reads both before touching the alarm can tell a genuine billing failure from a harmless duplicate run, and only clears the ones it can actually confirm.
The fix, as a flow
We never re-run the trial-end action itself. Re-running it is exactly the kind of duplicate execution that caused this in the first place, and it risks a second renewal order or a second charge. Instead we add a job that runs once a day, finds subscriptions with a trial-end action logged as failed, and checks the subscription's current status plus the Stripe PaymentIntent behind its renewal order, if there is one. Only when both confirm the trial genuinely finished and any charge succeeded do we add a note clearing the alarm. Anything we cannot confirm is left for a human, and anything that looks like a real failure is left alone.
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 access to subscriptions and orders, plus write access for notes. 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="7"
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="7"
export DRY_RUN="true" // start safe, change to false to write
Find subscriptions with a failed trial-end action
Many stores mirror the Action Scheduler failure into a subscription meta field so it can be queried through the REST API without a direct database read. We page through subscriptions filtered to that meta key. If your store does not mirror this yet, swap this step for a direct Action Scheduler query and keep the rest of the flow the same.
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 flagged_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "any", "per_page": 50, "page": page,
"meta_key": "_trial_end_action_status", "meta_value": "failed"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
yield sub
page += 1
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* flaggedSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(
`/subscriptions?status=any&per_page=50&page=${page}` +
`&meta_key=_trial_end_action_status&meta_value=failed`
);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
Load the renewal order and its Stripe PaymentIntent
Read the subscription's most recent renewal order ID from its meta or its _links, then pull that order from the WooCommerce REST API. Read the PaymentIntent ID from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent ID, then confirm it on Stripe. A missing PaymentIntent is not automatically a failure, it might mean Stripe just has not been checked yet, which the decision function treats as unclear rather than broken.
import stripe
def intent_id_of(order):
for meta in (order or {}).get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = (order or {}).get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def get_order(order_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_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
export function intentIdOf(order) {
for (const meta of (order || {}).meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = (order || {}).transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription, its renewal order, and the Stripe intent, and returns one of three outcomes: clear when the trial genuinely ended and any charge succeeded, leave when the failure looks real, and unclear when there is not enough evidence yet. A pure function like this is easy to read and easy to test, which we do later.
POST_TRIAL_STATUSES = {"active", "on-hold", "pending-cancel", "cancelled"}
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(subscription, renewal_order, intent):
if subscription.get("status") not in POST_TRIAL_STATUSES:
return ("leave", "subscription is still on trial or has no post-trial status")
if subscription.get("status") == "active" and renewal_order is None:
if subscription.get("trial_total_minor", 0) == 0:
return ("clear", "subscription is active and the trial had no charge due")
return ("unclear", "active with no renewal order and a nonzero trial amount")
if renewal_order is None:
return ("unclear", "no renewal order found to check against Stripe")
if renewal_order.get("status") in {"cancelled", "failed"}:
return ("leave", "the renewal order itself failed or was cancelled")
if intent is None:
return ("unclear", "renewal order has no matching Stripe PaymentIntent yet")
if intent.get("status") != "succeeded":
return ("leave", "Stripe shows the renewal payment did not succeed")
if abs(order_amount_minor(renewal_order) - intent.get("amount_received", 0)) > 1:
return ("unclear", "renewal order amount does not match the Stripe charge")
return ("clear", "subscription moved past trial and the renewal charge succeeded on Stripe")
const POST_TRIAL_STATUSES = new Set(["active", "on-hold", "pending-cancel", "cancelled"]);
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
export function decide(subscription, renewalOrder, intent) {
if (!POST_TRIAL_STATUSES.has(subscription.status)) {
return ["leave", "subscription is still on trial or has no post-trial status"];
}
if (subscription.status === "active" && !renewalOrder) {
if ((subscription.trial_total_minor || 0) === 0) {
return ["clear", "subscription is active and the trial had no charge due"];
}
return ["unclear", "active with no renewal order and a nonzero trial amount"];
}
if (!renewalOrder) {
return ["unclear", "no renewal order found to check against Stripe"];
}
if (renewalOrder.status === "cancelled" || renewalOrder.status === "failed") {
return ["leave", "the renewal order itself failed or was cancelled"];
}
if (!intent) {
return ["unclear", "renewal order has no matching Stripe PaymentIntent yet"];
}
if (intent.status !== "succeeded") {
return ["leave", "Stripe shows the renewal payment did not succeed"];
}
if (Math.abs(orderAmountMinor(renewalOrder) - (intent.amount_received || 0)) > 1) {
return ["unclear", "renewal order amount does not match the Stripe charge"];
}
return ["clear", "subscription moved past trial and the renewal charge succeeded on Stripe"];
}
Clear the alarm with a note, not a retry
When the action is clear, add a subscription note explaining exactly why the failed entry is a false alarm, so the shop manager sees the reasoning instead of a blank retry. We never change the subscription status and never touch Action Scheduler's own retry logic here, since the goal is only to explain the failed entry, not to act on the subscription again.
def clear_alarm(subscription, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}/notes",
json={"note": f"Trial end action false alarm cleared: {reason}. "
f"The subscription and its renewal charge are confirmed correct, "
f"so the failed Action Scheduler entry can be ignored."},
auth=AUTH, timeout=30,
).raise_for_status()
async function clearAlarm(subscription, reason) {
await woo(`/subscriptions/${subscription.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Trial end action false alarm cleared: ${reason}. ` +
`The subscription and its renewal charge are confirmed correct, ` +
`so the failed Action Scheduler entry can be ignored.`,
}),
});
}
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 clear. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day, since trial end events are not high frequency.
Always start with DRY_RUN=true. This check only adds a note, but you still want to see its plan before it acts, and you never want a version of this script that re-runs the trial-end transition itself.
The full code
Here is the complete check in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never re-runs the trial-end action and never writes anything beyond a note.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Detect and clear a trial end action that Action Scheduler logged as failed
by mistake, even though the subscription already moved out of the trial.
WooCommerce Subscriptions runs woocommerce_scheduled_subscription_trial_end on
the trial end date. When a slow request, a second worker, or a timeout makes
that hook run twice, the loser of the race throws and Action Scheduler marks
the action failed, but the subscription already has the correct status and
the first renewal order already exists. The failed log entry is then a false
alarm, not a real billing problem.
This script pulls subscriptions that still show a trial-end action as failed,
checks the subscription status and its renewal order (and, when a renewal
order exists, its Stripe PaymentIntent) against the real state, and adds a
note that clears the alarm when everything actually succeeded. It never
re-runs the trial-end transition itself, since that is what caused the
duplicate-run risk in the first place. Read only unless DRY_RUN is off.
"""
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("clear_trial_end_false_positive")
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", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Statuses that mean the subscription is no longer sitting in a trial.
POST_TRIAL_STATUSES = {"active", "on-hold", "pending-cancel", "cancelled"}
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in (order or {}).get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = (order or {}).get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(subscription, renewal_order, intent):
"""Pure decision function. No I/O. Returns (action, reason).
action is one of:
"leave" - the trial-end action failure looks real, do nothing
"clear" - the failure was a false positive, clear the alarm
"unclear" - not enough evidence either way, needs a human to look
"""
if subscription.get("status") not in POST_TRIAL_STATUSES:
# The trial genuinely never finished transitioning. The logged
# failure is probably real, so leave it for a human to chase.
return ("leave", "subscription is still on trial or has no post-trial status")
if subscription.get("status") == "active" and renewal_order is None:
# Active with no renewal order at all is fine only when the plan
# has a $0 signup and the first paid renewal has not been billed
# yet. Anything else is unclear, since we cannot confirm billing.
if subscription.get("trial_total_minor", 0) == 0:
return ("clear", "subscription is active and the trial had no charge due")
return ("unclear", "active with no renewal order and a nonzero trial amount")
if renewal_order is None:
return ("unclear", "no renewal order found to check against Stripe")
if renewal_order.get("status") in {"cancelled", "failed"}:
return ("leave", "the renewal order itself failed or was cancelled")
if intent is None:
return ("unclear", "renewal order has no matching Stripe PaymentIntent yet")
if intent.get("status") != "succeeded":
return ("leave", "Stripe shows the renewal payment did not succeed")
if abs(order_amount_minor(renewal_order) - intent.get("amount_received", 0)) > 1:
return ("unclear", "renewal order amount does not match the Stripe charge")
return ("clear", "subscription moved past trial and the renewal charge succeeded on Stripe")
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_order(order_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_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def flagged_subscriptions():
"""Subscriptions whose most recent trial-end action Action Scheduler
reports as failed. This walks the custom meta a store typically sets
(or mirrors) from the Action Scheduler failure log, filtered to the
lookback window. Stores without that mirror can swap this for a direct
Action Scheduler REST or database query.
"""
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={
"status": "any",
"per_page": 50,
"page": page,
"meta_key": "_trial_end_action_status",
"meta_value": "failed",
},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
yield sub
page += 1
def latest_renewal_order_id(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_last_renewal_order_id" and meta.get("value"):
return meta["value"]
related = subscription.get("_links", {}).get("renewal_order") or []
return related[0]["href"].rstrip("/").rsplit("/", 1)[-1] if related else None
def clear_alarm(subscription, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}/notes",
json={"note": f"Trial end action false alarm cleared: {reason}. "
f"The subscription and its renewal charge are confirmed correct, "
f"so the failed Action Scheduler entry can be ignored."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
cleared = 0
for subscription in flagged_subscriptions():
order_id = latest_renewal_order_id(subscription)
renewal_order = get_order(order_id)
intent = get_intent(intent_id_of(renewal_order))
action, reason = decide(subscription, renewal_order, intent)
if action != "clear":
if action == "unclear":
log.warning("Subscription %s: %s. Needs a human look.", subscription["id"], reason)
continue
log.info("Subscription %s: %s. %s", subscription["id"], reason, "would clear" if DRY_RUN else "clearing")
if not DRY_RUN:
clear_alarm(subscription, reason)
cleared += 1
log.info("Done. %d subscription(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")
if __name__ == "__main__":
run()
/**
* Detect and clear a trial end action that Action Scheduler logged as
* failed by mistake, even though the subscription already moved out of
* the trial.
*
* WooCommerce Subscriptions runs woocommerce_scheduled_subscription_trial_end
* on the trial end date. When a slow request, a second worker, or a timeout
* makes that hook run twice, the loser of the race throws and Action
* Scheduler marks the action failed, but the subscription already has the
* correct status and the first renewal order already exists. The failed log
* entry is then a false alarm, not a real billing problem.
*
* This script pulls subscriptions that still show a trial-end action as
* failed, checks the subscription status and its renewal order (and, when a
* renewal order exists, its Stripe PaymentIntent) against the real state,
* and adds a note that clears the alarm when everything actually succeeded.
* It never re-runs the trial-end transition itself, since that is what
* caused the duplicate-run risk in the first place. Read only unless
* DRY_RUN is off.
*
* Guide: https://www.allanninal.dev/woocommerce/trial-end-action-false-positive-failure/
*/
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 || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Statuses that mean the subscription is no longer sitting in a trial.
const POST_TRIAL_STATUSES = new Set(["active", "on-hold", "pending-cancel", "cancelled"]);
export function intentIdOf(order) {
for (const meta of (order || {}).meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = (order || {}).transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
/**
* Pure decision function. No I/O. Returns [action, reason].
*
* action is one of:
* "leave" - the trial-end action failure looks real, do nothing
* "clear" - the failure was a false positive, clear the alarm
* "unclear" - not enough evidence either way, needs a human to look
*/
export function decide(subscription, renewalOrder, intent) {
if (!POST_TRIAL_STATUSES.has(subscription.status)) {
return ["leave", "subscription is still on trial or has no post-trial status"];
}
if (subscription.status === "active" && !renewalOrder) {
if ((subscription.trial_total_minor || 0) === 0) {
return ["clear", "subscription is active and the trial had no charge due"];
}
return ["unclear", "active with no renewal order and a nonzero trial amount"];
}
if (!renewalOrder) {
return ["unclear", "no renewal order found to check against Stripe"];
}
if (renewalOrder.status === "cancelled" || renewalOrder.status === "failed") {
return ["leave", "the renewal order itself failed or was cancelled"];
}
if (!intent) {
return ["unclear", "renewal order has no matching Stripe PaymentIntent yet"];
}
if (intent.status !== "succeeded") {
return ["leave", "Stripe shows the renewal payment did not succeed"];
}
if (Math.abs(orderAmountMinor(renewalOrder) - (intent.amount_received || 0)) > 1) {
return ["unclear", "renewal order amount does not match the Stripe charge"];
}
return ["clear", "subscription moved past trial and the renewal charge succeeded on Stripe"];
}
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;
}
}
function latestRenewalOrderId(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_last_renewal_order_id" && meta.value) return meta.value;
}
const related = (subscription._links || {}).renewal_order || [];
if (!related.length) return null;
const href = related[0].href.replace(/\/$/, "");
return href.split("/").pop();
}
async function* flaggedSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(
`/subscriptions?status=any&per_page=50&page=${page}` +
`&meta_key=_trial_end_action_status&meta_value=failed`
);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
async function clearAlarm(subscription, reason) {
await woo(`/subscriptions/${subscription.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Trial end action false alarm cleared: ${reason}. ` +
`The subscription and its renewal charge are confirmed correct, ` +
`so the failed Action Scheduler entry can be ignored.`,
}),
});
}
export async function run() {
let cleared = 0;
for await (const subscription of flaggedSubscriptions()) {
const orderId = latestRenewalOrderId(subscription);
const renewalOrder = orderId ? await woo(`/orders/${orderId}`) : null;
const intent = await getIntent(intentIdOf(renewalOrder));
const [action, reason] = decide(subscription, renewalOrder, intent);
if (action !== "clear") {
if (action === "unclear") {
console.warn(`Subscription ${subscription.id}: ${reason}. Needs a human look.`);
}
continue;
}
console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would clear" : "clearing"}`);
if (!DRY_RUN) await clearAlarm(subscription, reason);
cleared++;
}
console.log(`Done. ${cleared} subscription(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real failure gets buried under a cleared alarm. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from clear_trial_end_false_positive import decide, intent_id_of, order_amount_minor
def sub(**over):
base = {"status": "active", "trial_total_minor": 0}
base.update(over)
return base
def order(**over):
base = {"status": "processing", "total": "50.00"}
base.update(over)
return base
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000}
base.update(over)
return base
def test_clear_when_active_no_renewal_needed_and_no_charge_due():
assert decide(sub(status="active", trial_total_minor=0), None, None)[0] == "clear"
def test_unclear_when_active_no_renewal_but_trial_had_a_charge():
assert decide(sub(status="active", trial_total_minor=500), None, None)[0] == "unclear"
def test_leave_when_still_on_trial():
assert decide(sub(status="trial"), None, None)[0] == "leave"
def test_leave_when_renewal_order_failed():
assert decide(sub(), order(status="failed"), None)[0] == "leave"
def test_unclear_when_no_intent_yet():
assert decide(sub(), order(), None)[0] == "unclear"
def test_leave_when_intent_not_succeeded():
assert decide(sub(), order(), intent(status="requires_payment_method"))[0] == "leave"
def test_unclear_when_amount_mismatch():
assert decide(sub(), order(total="80.00"), intent())[0] == "unclear"
def test_clear_when_everything_checks_out():
assert decide(sub(), order(), intent())[0] == "clear"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, orderAmountMinor } from "./clear-trial-end-false-positive.js";
const sub = (over = {}) => ({ status: "active", trial_total_minor: 0, ...over });
const order = (over = {}) => ({ status: "processing", total: "50.00", ...over });
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
test("clear when active, no renewal needed, and no charge due", () => {
assert.equal(decide(sub({ status: "active", trial_total_minor: 0 }), null, null)[0], "clear");
});
test("unclear when active with no renewal but trial had a charge", () => {
assert.equal(decide(sub({ status: "active", trial_total_minor: 500 }), null, null)[0], "unclear");
});
test("leave when still on trial", () => {
assert.equal(decide(sub({ status: "trial" }), null, null)[0], "leave");
});
test("leave when renewal order failed", () => {
assert.equal(decide(sub(), order({ status: "failed" }), null)[0], "leave");
});
test("unclear when no intent yet", () => {
assert.equal(decide(sub(), order(), null)[0], "unclear");
});
test("leave when intent not succeeded", () => {
assert.equal(decide(sub(), order(), intent({ status: "requires_payment_method" }))[0], "leave");
});
test("unclear when amount mismatch", () => {
assert.equal(decide(sub(), order({ total: "80.00" }), intent())[0], "unclear");
});
test("clear when everything checks out", () => {
assert.equal(decide(sub(), order(), intent())[0], "clear");
});
Case studies
The migration that left two crons running
A store moved hosts and the old host's system cron was never disabled, so both the old and new host were hitting wp-cron.php every minute. Every trial ending that week logged a failed trial-end action, one for every subscription, even though each one had renewed correctly.
The check ran once, confirmed every flagged subscription was active with a succeeded renewal charge, and cleared all of them with a note. The team then disabled the old host's cron so the double runs stopped for good.
The one that was not a false alarm
Among a batch of twenty flagged trial-end actions, nineteen were the usual duplicate-run noise. The twentieth had a renewal order marked failed, because the card had actually been declined at the trial's end.
The decision function returned leave for that one specific case since the renewal order status was failed, so it was never touched, while the other nineteen were cleared. The real failure stayed visible for the team to chase through their normal dunning flow.
After this runs on a schedule, the failed actions list in Action Scheduler stops being something to fear. A daily sweep clears the false alarms with a clear explanation and leaves the real ones untouched and visible, so nobody wastes an afternoon chasing a subscription that was never actually broken.
FAQ
Why does Action Scheduler show the trial end action as failed when the subscription looks fine?
A slow request, a second cron worker, or a timeout can make woocommerce_scheduled_subscription_trial_end run twice for the same subscription. The first run finishes the transition and the renewal, then the second run collides with the already-changed state and throws, so Action Scheduler logs that second run as failed. The subscription and the charge are correct, only the log entry is wrong.
Is it safe to just delete or retry the failed action?
Retrying is not safe, since the transition already happened and running it again risks a second renewal order or a duplicate charge. Deleting the log entry without checking anything hides real failures too. Check the subscription status and the Stripe PaymentIntent first, and only clear the alarm once both confirm the trial actually ended cleanly.
How often should this check run?
Once a day is enough for most stores, since trial end actions are not high frequency events. Running it daily on a schedule keeps the failed actions list clean without adding any real load to the site.
Related field notes
Citations
On the problem:
- Action Scheduler documentation: how actions are claimed and locked, and what a failed status means. actionscheduler.org
- WooCommerce Subscriptions developer docs: the scheduled hooks that manage trial and renewal dates. woocommerce.com/document/subscriptions/develop/scheduled-actions
- WordPress cron behavior and why multiple triggers can overlap on busy or migrated sites. developer.wordpress.org/plugins/cron
On the solution:
- Stripe API: retrieve a PaymentIntent to confirm its final status and amount received. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: read subscriptions and orders, and add notes to either. woocommerce.github.io/subscriptions-rest-api-docs
- WooCommerce REST API: orders endpoint reference for reading order meta and status. woocommerce.github.io/woocommerce-rest-api-docs
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 clear up a false alarm for you?
If this saved you from chasing a subscription that was never actually broken, 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