Repair WooCommerce Subscriptions: status and renewals

Duplicate renewal orders in one cycle

A subscription is due to renew, and instead of one renewal order, it gets two. Same subscription, same billing period, two order numbers. One of them usually charged the card, the other sat there unpaid, confusing the customer and cluttering the order list. Here is why WooCommerce Subscriptions occasionally fires the renewal twice, and a small script that finds every extra order and cancels it, without ever touching one that was actually paid.

Python and Node.js Runs on a schedule Safe by default (dry run)
A receipt on a wooden table
Photo by Annie Spratt on Unsplash
The short answer

Two renewal orders for one cycle usually means the renewal action fired twice, once from the normal cron schedule and once from a retry or a manual click, before either run knew about the other. Run a small Python or Node.js script on a schedule that groups renewal orders by subscription id and renewal date, keeps the one that is paid (or the oldest if neither is), and cancels the extra order only if it was never charged. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions renews a subscription by running a scheduled action at the renewal date. That action builds a new order from the subscription, the same items, the same totals, and tries to charge the saved payment method. Normally this happens exactly once per cycle.

Sometimes it happens twice. Action Scheduler can retry a run that looked like it failed or timed out, even though the first run was still quietly finishing. Or a shop manager, worried a renewal is stuck, clicks "Process renewal" by hand while the scheduled run from cron is still in progress. Either way, two separate renewal orders get created for the same subscription and the same period, each one pointing at the same next payment date. One of them often gets charged. The other sits on Pending or Failed, doing nothing useful except making the order list look wrong and confusing anyone who opens the account page.

Renewal date arrives Scheduled cron run slow, still finishing Retry or manual click does not see the first run Renewal order A gets charged Renewal order B stays Pending Same cycle two orders
The renewal action runs twice before either run knows about the other, and the subscription ends up with two orders for one billing period.

Why it happens

The WooCommerce Subscriptions docs describe the renewal action as a background job scheduled through Action Scheduler, run by WP-Cron or a real system cron. A few things push it into running twice for the same cycle:

WooCommerce Subscriptions is generally careful about this, it does check whether a renewal order already exists for the current period before making a new one. But that check reads the database at one moment, and if two runs read it before either one has written its new order, both runs see "no renewal order yet" and both create one. This is reported in the plugin's support channels as a rare but real race, more common on stores with slow order creation, a lot of line items, or heavy load right at the renewal hour.

The key insight

Once it happens, you do not need to guess who was right. Both renewal orders carry the same _subscription_renewal meta pointing at the parent subscription, and the same _subscription_renewal_date. Any two renewal orders that share both values are the same billing cycle, full stop. Whichever one actually has Stripe's money is the real one, the other is the duplicate.

The fix, as a flow

We do not change how renewals fire, this is a cleanup pass. A job runs on a schedule, reads recent renewal orders from the WooCommerce REST API, and groups them by subscription id and renewal date. Any group with more than one order is a duplicate. We keep the order that is genuinely paid, cancel the other one if it was never charged, and if both somehow look paid, we leave both alone and flag it, because a real double charge needs a person, not a script.

Scheduled job every few hours List renewal orders last few days Group by subscription and renewal date More than one order? yes no, skip Keep the paid one cancel the unpaid extra
The cleanup only ever acts on groups of two or more renewal orders that share a subscription and renewal date, and it only cancels the order that was never charged.

Build it step by step

1

Get access to both systems

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

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="3"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="3"
export DRY_RUN="true"   // start safe, change to false to write
2

Load recent renewal orders and group them

Page through orders created in the last few days and keep only the ones that carry _subscription_renewal in their meta data, that meta key marks an order as a renewal rather than an original purchase. Group those orders by the pair of subscription id and _subscription_renewal_date. Any group is one billing cycle. A group with more than one order is a duplicate.

step2.py
from collections import defaultdict

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

def renewal_key(order):
    sub_id = meta_value(order, "_subscription_renewal")
    renewal_date = meta_value(order, "_subscription_renewal_date")
    if not sub_id or not renewal_date:
        return None
    return (str(sub_id), str(renewal_date))

def group_renewals(orders):
    groups = defaultdict(list)
    for order in orders:
        key = renewal_key(order)
        if key is not None:
            groups[key].append(order)
    return groups
step2.js
export function metaValue(order, key) {
  for (const meta of order.meta_data || []) {
    if (meta.key === key) return meta.value;
  }
  return undefined;
}

export function renewalKey(order) {
  const subId = metaValue(order, "_subscription_renewal");
  const renewalDate = metaValue(order, "_subscription_renewal_date");
  if (!subId || !renewalDate) return null;
  return `${subId}::${renewalDate}`;
}

export function groupRenewals(orders) {
  const groups = new Map();
  for (const order of orders) {
    const key = renewalKey(order);
    if (key === null) continue;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(order);
  }
  return groups;
}
3

Confirm the payment with Stripe

For any order in a duplicate group that WooCommerce shows as paid, read its PaymentIntent id from meta _stripe_intent_id, falling back to transaction_id if it looks like a PaymentIntent, and retrieve it from Stripe. This is what tells a genuinely paid order apart from one that was only marked paid by mistake.

step3.py
import stripe

def intent_id_of(order):
    value = meta_value(order, "_stripe_intent_id")
    if value:
        return value
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
export function intentIdOf(order) {
  const value = metaValue(order, "_stripe_intent_id");
  if (value) return 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;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a group of orders (and, optionally, the Stripe PaymentIntents already fetched for the paid ones) and returns an action per order. It picks a keeper, the paid order if one exists, otherwise the oldest by id, then cancels any other unpaid order in the group. If more than one order in the group looks paid, it never guesses, it flags the extras for a human instead.

decide.py
PAID_STATUSES = {"processing", "completed"}
UNPAID_STATUSES = {"pending", "on-hold", "failed"}

def choose_keeper(group):
    paid = [o for o in group if o["status"] in PAID_STATUSES]
    pool = paid if paid else group
    return min(pool, key=lambda o: o["id"])

def decide(group, intents_by_order_id=None):
    intents_by_order_id = intents_by_order_id or {}
    if len(group) < 2:
        return [(group[0], "skip", "only one renewal order for this cycle")] if group else []

    keeper = choose_keeper(group)
    results = []
    for order in group:
        if order["id"] == keeper["id"]:
            results.append((order, "keep", "kept as the order for this billing cycle"))
            continue
        if order["status"] in PAID_STATUSES:
            intent = intents_by_order_id.get(order["id"])
            if intent is not None and intent.get("status") == "succeeded":
                results.append((order, "flag", "both orders appear paid, needs manual review"))
                continue
            results.append((order, "flag", "marked paid but not confirmed by Stripe, needs manual review"))
            continue
        if order["status"] not in UNPAID_STATUSES:
            results.append((order, "skip", f"status {order['status']} is not safe to cancel automatically"))
            continue
        results.append((order, "cancel", "duplicate renewal order, never paid"))
    return results
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);
const UNPAID_STATUSES = new Set(["pending", "on-hold", "failed"]);

export function chooseKeeper(group) {
  const paid = group.filter((o) => PAID_STATUSES.has(o.status));
  const pool = paid.length ? paid : group;
  return pool.reduce((min, o) => (o.id < min.id ? o : min), pool[0]);
}

export function decide(group, intentsByOrderId = new Map()) {
  if (group.length < 2) {
    return group.length === 1
      ? [{ order: group[0], action: "skip", reason: "only one renewal order for this cycle" }]
      : [];
  }

  const keeper = chooseKeeper(group);
  const results = [];
  for (const order of group) {
    if (order.id === keeper.id) {
      results.push({ order, action: "keep", reason: "kept as the order for this billing cycle" });
      continue;
    }
    if (PAID_STATUSES.has(order.status)) {
      const intent = intentsByOrderId.get(order.id);
      if (intent && intent.status === "succeeded") {
        results.push({ order, action: "flag", reason: "both orders appear paid, needs manual review" });
        continue;
      }
      results.push({ order, action: "flag", reason: "marked paid but not confirmed by Stripe, needs manual review" });
      continue;
    }
    if (!UNPAID_STATUSES.has(order.status)) {
      results.push({ order, action: "skip", reason: `status ${order.status} is not safe to cancel automatically` });
      continue;
    }
    results.push({ order, action: "cancel", reason: "duplicate renewal order, never paid" });
  }
  return results;
}
5

Cancel the extra order and leave a note

When the action is cancel, set that order's status to Cancelled through the REST API and add an order note explaining why, so nobody reopens it later wondering what happened. Never touch the keeper. Never touch an order that is flagged, that one needs eyes on it.

apply.py
def cancel_order(order, reason):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": "cancelled"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Cancelled by the duplicate renewal cleanup: {reason}. "
                      f"This subscription already has another renewal order for the same cycle."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function cancelOrder(order, reason) {
  await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "cancelled" }) });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Cancelled by the duplicate renewal cleanup: ${reason}. ` +
            `This subscription already has another renewal order for the same cycle.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop pulls recent renewal orders, groups them, fetches Stripe PaymentIntents only for orders that WooCommerce shows as paid, then runs each group through decide. Leave DRY_RUN on for the first few runs so it only reports its plan. Read the output, trust it, then switch it off. Run it on a schedule, every few hours is plenty since duplicates are rare.

Run it safe

Always start with DRY_RUN=true. This script cancels real orders, so you want to see exactly which ones it plans to touch before it writes anything. Anything it is unsure about, it flags instead of acting, but you should still check its dry run report before turning it loose.

The full code

Here is the complete cleanup script in one file for each language. It reads settings from the environment, respects the dry run flag, never cancels an order that Stripe confirms was charged, and is safe to run again and again because a group with one order left in it is simply skipped.

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

cancel_duplicate_renewals.py
"""Find duplicate renewal orders made for the same subscription in one billing
cycle, and cancel the extra one.

WooCommerce Subscriptions can create two renewal orders for a single period when
the scheduled renewal action fires twice, for example after Action Scheduler
retries a slow run, or a shop manager clicks "Process renewal" while the cron
copy is still mid flight. Both orders carry the same subscription id in their
_subscription_renewal meta and the same _subscription_renewal_date. This walks
recent renewal orders, groups them by (subscription id, renewal date), and for
every group bigger than one, keeps exactly one order and cancels the rest, but
only when the extra order was never actually paid. A renewal that Stripe really
charged is never touched here, that is a different problem (a real double
charge) with its own guide. Read only by default. Run on a schedule.
"""
import os
import logging
from collections import defaultdict

import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cancel_duplicate_renewals")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "3"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}
UNPAID_STATUSES = {"pending", "on-hold", "failed"}


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


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    value = meta_value(order, "_stripe_intent_id")
    if value:
        return value
    tid = order.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 renewal_key(order):
    """Group key for one billing cycle: the subscription plus its renewal date."""
    sub_id = meta_value(order, "_subscription_renewal")
    renewal_date = meta_value(order, "_subscription_renewal_date")
    if not sub_id or not renewal_date:
        return None
    return (str(sub_id), str(renewal_date))


def group_renewals(orders):
    """Group renewal orders by (subscription id, renewal date)."""
    groups = defaultdict(list)
    for order in orders:
        key = renewal_key(order)
        if key is not None:
            groups[key].append(order)
    return groups


def choose_keeper(group):
    """Pick the order to keep out of a duplicate group: a paid one if any exists,
    otherwise the oldest by id. Ties among paid orders also fall back to oldest id.
    """
    paid = [o for o in group if o["status"] in PAID_STATUSES]
    pool = paid if paid else group
    return min(pool, key=lambda o: o["id"])


def decide(group, intents_by_order_id=None):
    """Pure decision function: given one group of orders that share a subscription
    id and renewal date, return a list of (order, action, reason) tuples.

    intents_by_order_id is an optional dict mapping order id to a Stripe
    PaymentIntent dict (or None), used to double check an order marked paid
    really was charged before it is ever left alone as a "keeper" on that basis
    alone versus flagged as a mismatch. It defaults to an empty dict, in which
    case the decision relies only on WooCommerce order status.
    """
    intents_by_order_id = intents_by_order_id or {}
    if len(group) < 2:
        return [(group[0], "skip", "only one renewal order for this cycle")] if group else []

    keeper = choose_keeper(group)
    results = []
    for order in group:
        if order["id"] == keeper["id"]:
            results.append((order, "keep", "kept as the order for this billing cycle"))
            continue
        if order["status"] in PAID_STATUSES:
            intent = intents_by_order_id.get(order["id"])
            if intent is not None and intent.get("status") == "succeeded":
                # Two orders in the same cycle both look genuinely charged.
                # That is a real double charge, not a duplicate order to
                # cancel automatically. Flag it for a human instead.
                results.append((order, "flag", "both orders appear paid, needs manual review"))
                continue
            results.append((order, "flag", "marked paid but not confirmed by Stripe, needs manual review"))
            continue
        if order["status"] not in UNPAID_STATUSES:
            results.append((order, "skip", f"status {order['status']} is not safe to cancel automatically"))
            continue
        results.append((order, "cancel", "duplicate renewal order, never paid"))
    return results


def recent_renewal_orders(lookback_days):
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=lookback_days)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"after": after, "per_page": 100, "page": page, "orderby": "id", "order": "asc"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            if meta_value(order, "_subscription_renewal"):
                yield order
        page += 1


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def cancel_order(order, reason):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": "cancelled"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Cancelled by the duplicate renewal cleanup: {reason}. "
                      f"This subscription already has another renewal order for the same cycle."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    groups = group_renewals(recent_renewal_orders(LOOKBACK_DAYS))
    cancelled = 0
    flagged = 0
    for key, group in groups.items():
        if len(group) < 2:
            continue
        sub_id, renewal_date = key
        intents_by_order_id = {
            order["id"]: get_intent(intent_id_of(order))
            for order in group
            if order["status"] in PAID_STATUSES
        }
        for order, action, reason in decide(group, intents_by_order_id):
            if action == "keep" or action == "skip":
                continue
            if action == "flag":
                log.warning("Subscription %s, order %s: %s", sub_id, order["id"], reason)
                flagged += 1
                continue
            log.info(
                "Subscription %s, renewal %s, order %s: %s. %s",
                sub_id, renewal_date, order["id"], reason, "would cancel" if DRY_RUN else "cancelling",
            )
            if not DRY_RUN:
                cancel_order(order, reason)
            cancelled += 1
    log.info(
        "Done. %d order(s) %s, %d flagged for manual review.",
        cancelled, "to cancel" if DRY_RUN else "cancelled", flagged,
    )


if __name__ == "__main__":
    run()
cancel-duplicate-renewals.js
/**
 * Find duplicate renewal orders made for the same subscription in one billing
 * cycle, and cancel the extra one.
 *
 * WooCommerce Subscriptions can create two renewal orders for a single period
 * when the scheduled renewal action fires twice, for example after Action
 * Scheduler retries a slow run, or a shop manager clicks "Process renewal"
 * while the cron copy is still mid flight. Both orders carry the same
 * subscription id in their _subscription_renewal meta and the same
 * _subscription_renewal_date. This walks recent renewal orders, groups them by
 * (subscription id, renewal date), and for every group bigger than one, keeps
 * exactly one order and cancels the rest, but only when the extra order was
 * never actually paid. A renewal that Stripe really charged is never touched
 * here, that is a different problem (a real double charge) with its own
 * guide. Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/duplicate-renewal-orders-in-one-cycle/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 3);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);
const UNPAID_STATUSES = new Set(["pending", "on-hold", "failed"]);

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

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

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

export function renewalKey(order) {
  const subId = metaValue(order, "_subscription_renewal");
  const renewalDate = metaValue(order, "_subscription_renewal_date");
  if (!subId || !renewalDate) return null;
  return `${subId}::${renewalDate}`;
}

export function groupRenewals(orders) {
  const groups = new Map();
  for (const order of orders) {
    const key = renewalKey(order);
    if (key === null) continue;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(order);
  }
  return groups;
}

export function chooseKeeper(group) {
  const paid = group.filter((o) => PAID_STATUSES.has(o.status));
  const pool = paid.length ? paid : group;
  return pool.reduce((min, o) => (o.id < min.id ? o : min), pool[0]);
}

/**
 * Pure decision function: given one group of orders that share a subscription
 * id and renewal date, return a list of { order, action, reason }.
 *
 * intentsByOrderId is an optional map of order id to a Stripe PaymentIntent
 * object (or null), used to double check an order marked paid really was
 * charged before it is ever left alone as a "keeper" on that basis alone
 * versus flagged as a mismatch. Defaults to an empty map, in which case the
 * decision relies only on the WooCommerce order status.
 */
export function decide(group, intentsByOrderId = new Map()) {
  if (group.length < 2) {
    return group.length === 1
      ? [{ order: group[0], action: "skip", reason: "only one renewal order for this cycle" }]
      : [];
  }

  const keeper = chooseKeeper(group);
  const results = [];
  for (const order of group) {
    if (order.id === keeper.id) {
      results.push({ order, action: "keep", reason: "kept as the order for this billing cycle" });
      continue;
    }
    if (PAID_STATUSES.has(order.status)) {
      const intent = intentsByOrderId.get(order.id);
      if (intent && intent.status === "succeeded") {
        // Two orders in the same cycle both look genuinely charged. That is
        // a real double charge, not a duplicate order to cancel
        // automatically. Flag it for a human instead.
        results.push({ order, action: "flag", reason: "both orders appear paid, needs manual review" });
        continue;
      }
      results.push({ order, action: "flag", reason: "marked paid but not confirmed by Stripe, needs manual review" });
      continue;
    }
    if (!UNPAID_STATUSES.has(order.status)) {
      results.push({ order, action: "skip", reason: `status ${order.status} is not safe to cancel automatically` });
      continue;
    }
    results.push({ order, action: "cancel", reason: "duplicate renewal order, never paid" });
  }
  return results;
}

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

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

async function* recentRenewalOrders(lookbackDays) {
  const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?after=${after}&per_page=100&page=${page}&orderby=id&order=asc`);
    if (!batch.length) return;
    for (const order of batch) {
      if (metaValue(order, "_subscription_renewal")) yield order;
    }
    page++;
  }
}

async function cancelOrder(order, reason) {
  await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "cancelled" }) });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Cancelled by the duplicate renewal cleanup: ${reason}. ` +
            `This subscription already has another renewal order for the same cycle.`,
    }),
  });
}

export async function run() {
  const orders = [];
  for await (const order of recentRenewalOrders(LOOKBACK_DAYS)) orders.push(order);
  const groups = groupRenewals(orders);

  let cancelled = 0;
  let flagged = 0;
  for (const [key, group] of groups) {
    if (group.length < 2) continue;
    const [subId, renewalDate] = key.split("::");

    const intentsByOrderId = new Map();
    for (const order of group) {
      if (PAID_STATUSES.has(order.status)) {
        intentsByOrderId.set(order.id, await getIntent(intentIdOf(order)));
      }
    }

    for (const { order, action, reason } of decide(group, intentsByOrderId)) {
      if (action === "keep" || action === "skip") continue;
      if (action === "flag") {
        console.warn(`Subscription ${subId}, order ${order.id}: ${reason}`);
        flagged++;
        continue;
      }
      console.log(
        `Subscription ${subId}, renewal ${renewalDate}, order ${order.id}: ${reason}. ` +
        `${DRY_RUN ? "would cancel" : "cancelling"}`
      );
      if (!DRY_RUN) await cancelOrder(order, reason);
      cancelled++;
    }
  }
  console.log(`Done. ${cancelled} order(s) ${DRY_RUN ? "to cancel" : "cancelled"}, ${flagged} flagged for manual 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 orders get cancelled. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain order objects and an optional map of intents, and checks the action per order.

test_duplicate_renewal_decide.py
from cancel_duplicate_renewals import decide, choose_keeper, renewal_key, group_renewals


def order(id, status, total="20.00", sub_id="55", renewal_date="2026-07-01 00:00:00"):
    meta = []
    if sub_id is not None:
        meta.append({"key": "_subscription_renewal", "value": sub_id})
    if renewal_date is not None:
        meta.append({"key": "_subscription_renewal_date", "value": renewal_date})
    return {"id": id, "status": status, "total": total, "meta_data": meta}


def test_single_order_is_left_alone():
    group = [order(1, "processing")]
    results = decide(group)
    assert results[0][1] == "skip"


def test_keeps_paid_cancels_unpaid_duplicate():
    paid = order(1, "processing")
    unpaid = order(2, "pending")
    results = {o["id"]: (action, reason) for o, action, reason in decide([paid, unpaid])}
    assert results[1][0] == "keep"
    assert results[2][0] == "cancel"


def test_flags_two_paid_orders_instead_of_cancelling():
    a = order(3, "processing")
    b = order(4, "completed")
    results = {o["id"]: action for o, action, _ in decide([a, b])}
    actions = set(results.values())
    assert "cancel" not in actions
    assert "flag" in actions
    assert "keep" in actions


def test_keeps_oldest_when_none_are_paid():
    a = order(5, "pending")
    b = order(9, "pending")
    results = {o["id"]: action for o, action, _ in decide([a, b])}
    assert results[5] == "keep"
    assert results[9] == "cancel"
cancel-duplicate-renewals.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, chooseKeeper, renewalKey, groupRenewals } from "./cancel-duplicate-renewals.js";

function makeOrder(id, status, { total = "20.00", subId = "55", renewalDate = "2026-07-01 00:00:00" } = {}) {
  const meta = [];
  if (subId !== null) meta.push({ key: "_subscription_renewal", value: subId });
  if (renewalDate !== null) meta.push({ key: "_subscription_renewal_date", value: renewalDate });
  return { id, status, total, meta_data: meta };
}

test("single order is left alone", () => {
  const results = decide([makeOrder(1, "processing")]);
  assert.equal(results[0].action, "skip");
});

test("keeps paid, cancels unpaid duplicate", () => {
  const paid = makeOrder(1, "processing");
  const unpaid = makeOrder(2, "pending");
  const byId = Object.fromEntries(decide([paid, unpaid]).map((r) => [r.order.id, r.action]));
  assert.equal(byId[1], "keep");
  assert.equal(byId[2], "cancel");
});

test("flags two paid orders instead of cancelling", () => {
  const a = makeOrder(3, "processing");
  const b = makeOrder(4, "completed");
  const actions = new Set(decide([a, b]).map((r) => r.action));
  assert.equal(actions.has("cancel"), false);
  assert.equal(actions.has("flag"), true);
  assert.equal(actions.has("keep"), true);
});

test("keeps oldest when none are paid", () => {
  const a = makeOrder(5, "pending");
  const b = makeOrder(9, "pending");
  const byId = Object.fromEntries(decide([a, b]).map((r) => [r.order.id, r.action]));
  assert.equal(byId[5], "keep");
  assert.equal(byId[9], "cancel");
});

Case studies

Action Scheduler retry

The store where every renewal ran twice for a week

A store moved to a new host with a slower disk. Renewal order creation, which used to take under a second, started taking closer to ten. Action Scheduler's own timeout treated some of those runs as failed and queued a retry, while the original run was still writing the order and its line items.

For a week, a chunk of renewals got two orders each, one paid, one stuck on Pending. The cleanup script found forty two duplicate groups on its first run, cancelled the forty unpaid extras, and flagged two where both orders looked paid for the store owner to check by hand.

Manual click

The support agent trying to help

A customer emailed asking why their subscription still said "Active" a day after the expected renewal date, worried it had failed silently. A support agent, trying to be helpful, opened the subscription and clicked "Process renewal" right as the scheduled cron run for that same subscription was still finishing in the background.

Both created a renewal order. The Stripe charge landed on the cron created order. The manually created one sat on Pending until the next scheduled run of the cleanup script cancelled it with a clear note explaining why.

What good looks like

After this runs on a schedule, a rare double renewal stops being a mystery in the order list. The unpaid duplicate gets cancelled with a note explaining exactly why, the paid order is left completely alone, and anything the script cannot tell apart on its own gets flagged for a person instead of guessed at. Keep it running even after the root cause is fixed, since retries and manual clicks will always happen once in a while.

FAQ

Why did my subscription get two renewal orders for the same cycle?

WooCommerce Subscriptions renews on a scheduled action. If that action runs twice, for example because Action Scheduler retries a slow run or a shop manager clicks Process renewal while the cron copy is still running, each run creates its own renewal order for the same subscription and the same period.

Is it safe to cancel one of the duplicate orders with a script?

Yes, when the script keeps whichever order was actually paid and only cancels the other one, and it never cancels an order that Stripe confirms was charged. If both orders look paid, that is a real double charge, not a duplicate order, and it should be flagged for a person instead.

Will cancelling the extra order affect the customer or the subscription schedule?

No. Cancelling the unpaid duplicate only removes an order that was never charged. The subscription keeps the renewal date and totals from the order that was kept, so the customer sees one clean renewal, not two.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how scheduled renewals work through Action Scheduler. woocommerce.com/document/subscriptions/renewal-process
  2. Action Scheduler docs: how scheduled actions, retries, and claims work. actionscheduler.org
  3. WooCommerce Subscriptions docs: renewal order meta data, including _subscription_renewal. woocommerce.com/document/subscriptions/develop/functions

On the solution:

  1. WooCommerce REST API: list, update, and add notes to orders. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a PaymentIntent to confirm its status. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce docs: order statuses and when it is safe to cancel an order. woocommerce.com/document/managing-orders

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 clean up your duplicate renewals?

If this saved you from a pile of confused customer emails or a messy order list, 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