Repair WooCommerce Subscriptions: status and renewals

Zero cost renewal orphaned by block checkout

A subscriber's renewal came out to $0.00, a coupon covered it, a switch left a credit, a trial converted with a balance still applied, and nothing needed to be charged. But the renewal order never finished. It sits on Pending or On hold, no receipt went out, and the subscription's next payment date never moved. This is a quiet bug in how the new block checkout handles a free renewal, and a small script that finds every order it left behind and completes it in a safe way.

Python and Node.js Runs on a schedule Safe by default (dry run)
Us dollar bills
Photo by Jason Leung on Unsplash
The short answer

Block checkout skips the step that finishes an order when its total is $0.00, the step the classic checkout still runs. That step is what would normally mark the order paid and let WooCommerce Subscriptions record the renewal. Without it, a genuinely free renewal is created and then just sits unpaid forever. Run a small Python or Node.js script on a schedule that finds subscription renewal orders that are still pending or on-hold, totals $0.00, and has no Stripe PaymentIntent attached, and completes it the way the checkout should have. Full code, tests, and a dry run guard are below.

The problem in plain words

Most renewals cost something, so Stripe is charged and the order finishes when the charge succeeds. But sometimes a renewal legitimately costs nothing: a 100% off coupon applied at signup and carried forward, a subscription switch that left a credit larger than the new price, or a free trial that converts with enough store credit to cover the first cycle. WooCommerce is built to handle this. When there is nothing to charge, it is supposed to mark the order paid on its own and let the subscription continue as normal.

The new block based checkout handles that "nothing to charge" case differently than the classic shortcode checkout. The classic flow still calls the WooCommerce function that finishes an order regardless of the total. The block flow's equivalent step assumes a zero total order needs no further processing and moves on, so the order is created but never marked paid, never gets a renewal note, and the subscription's own bookkeeping (next payment date, active status) never gets the update it expects. The buyer sees nothing wrong since no card was charged, but their account starts drifting out of sync with what they actually purchased.

Renewal due total is $0.00 Block checkout builds the order no completion step Order orphaned Pending / On hold No note Dates drift
The renewal never touches Stripe because there is nothing to charge. The order is only finished if something calls the completion step for it, and block checkout does not.

Why it happens

WooCommerce Subscriptions renewals go through the normal order pipeline, and a $0.00 order is meant to be finished automatically since there is no payment to wait for. A few things line up to cause the miss:

This has been reported by store owners who moved from the shortcode checkout to the block checkout and started seeing free renewals stall, while paid renewals on the same subscriptions kept working normally. See the citations at the end for the related reports.

The key insight

A $0.00 order has no gateway to wait on. If a subscription renewal order totals $0.00 and is still Pending or On hold with no Stripe PaymentIntent on it, there is nothing left for Stripe to tell you, the order simply needs to be marked paid. A small script that finds exactly these orders and finishes them is a safety net for the one case block checkout does not handle on its own.

The fix, as a flow

We do not change the checkout. We add a job that runs every so often, looks at recent renewal orders that are still unpaid, and checks each one. If the order is a subscription renewal, its total is $0.00 within a cent, and it has no Stripe PaymentIntent attached, we mark it paid and processing and add a note, the same completion the classic checkout would have triggered. Anything with real money involved is left alone.

Scheduled job every few hours List pending and on hold orders Is it a renewal, $0.00, no intent? All three true? yes no, skip Mark processing set paid + note
The script only completes orders that are a renewal, total $0.00, and have no Stripe PaymentIntent. Any order with real money or a payment reference is left for a different fix.

Build it step by step

1

Get access to WooCommerce

This fix never touches Stripe, since a $0.00 renewal has nothing to charge. You only need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create it under WooCommerce, Settings, Advanced, REST API, and keep both values in environment variables, never in the file.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// no npm install needed, this fix only calls the WooCommerce REST API

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

List renewal orders that are still unpaid

Ask the WooCommerce REST API for orders in the Pending and On hold statuses created within your lookback window. We page through all of them. WooCommerce Subscriptions writes a _subscription_renewal meta key onto every renewal order, so that field is how we tell a renewal apart from a first order.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))

def renewal_orders():
    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={"status": "pending,on-hold", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);

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* renewalOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=pending,on-hold&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
3

Read the meta that tells us the order's shape

Two pieces of order meta matter here. _subscription_renewal marks the order as a renewal rather than a first purchase. The PaymentIntent id, read from _stripe_intent_id or from transaction_id when it looks like a PaymentIntent, tells us whether Stripe was ever involved. If either one is missing in the way we expect, the order is not our case.

meta.py
def intent_id_of(order):
    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 is_renewal_order(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_subscription_renewal" and meta.get("value"):
            return True
    return False
meta.js
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 isRenewalOrder(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_subscription_renewal" && meta.value) return true;
  }
  return false;
}
4

Decide, with one pure function

Keep the decision in its own function that takes only the order and returns an action. Money math stays in minor units (cents), so a $0.00 total becomes 0 and a one cent tolerance is a plain integer comparison. The rule: skip anything that is not a renewal, not unpaid, has a real total, or already has a PaymentIntent. Only a true zero cost orphan gets completed.

decide.py
UNPAID_STATUSES = {"pending", "on-hold"}
ZERO_COST_TOLERANCE_MINOR = 1  # a cent of rounding slack

def order_total_minor(order):
    # Keep money math in minor units (cents).
    return round(float(order["total"]) * 100)

def decide(order):
    if not is_renewal_order(order):
        return ("skip", "not a subscription renewal order")
    if order["status"] not in UNPAID_STATUSES:
        return ("skip", "order is not pending or on-hold")
    if order_total_minor(order) > ZERO_COST_TOLERANCE_MINOR:
        return ("skip", "order total is not zero cost")
    if intent_id_of(order) is not None:
        return ("skip", "a Stripe PaymentIntent is attached, not a zero cost orphan")
    return ("complete", "zero cost renewal with no PaymentIntent, safe to complete")
decide.js
const UNPAID_STATUSES = new Set(["pending", "on-hold"]);
const ZERO_COST_TOLERANCE_MINOR = 1; // a cent of rounding slack

export function orderTotalMinor(order) {
  // Keep money math in minor units (cents).
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order) {
  if (!isRenewalOrder(order)) return ["skip", "not a subscription renewal order"];
  if (!UNPAID_STATUSES.has(order.status)) return ["skip", "order is not pending or on-hold"];
  if (orderTotalMinor(order) > ZERO_COST_TOLERANCE_MINOR) return ["skip", "order total is not zero cost"];
  if (intentIdOf(order) !== null) {
    return ["skip", "a Stripe PaymentIntent is attached, not a zero cost orphan"];
  }
  return ["complete", "zero cost renewal with no PaymentIntent, safe to complete"];
}
5

Complete the order the way payment_complete() would

When the action is complete, set the order to Processing and mark it paid through the REST API's set_paid flag. Then add an order note so the shop manager can see the renewal was repaired and why. Both calls go through the REST API, so High Performance Order Storage (HPOS) is handled for you.

apply.py
def complete_renewal(order):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": "processing", "set_paid": True},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": "Completed by the zero cost renewal script. This renewal totaled "
                      "$0.00 and had no Stripe PaymentIntent, so it was never finished by "
                      "the block checkout flow. Marked processing and paid."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function completeRenewal(order) {
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ status: "processing", set_paid: true }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Completed by the zero cost renewal script. This renewal totaled $0.00 " +
            "and had no Stripe PaymentIntent, so it was never finished by the block " +
            "checkout flow. Marked processing and paid.",
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron every few hours, since a stalled free renewal is not as time sensitive as a stuck payment.

Run it safe

Always start with DRY_RUN=true. This script writes to real orders, so you want to see its plan before it acts. Once the report looks right for a few runs, turn it off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches an order that already has a Stripe PaymentIntent or a non-zero total.

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

complete_zero_cost_renewal.py
"""Finish zero cost WooCommerce Subscriptions renewal orders orphaned by block checkout.

When a renewal nets to $0.00 (a 100% off coupon, a switch credit, a free trial that
converted with a balance still applied) WooCommerce skips Stripe entirely, since
there is nothing to charge. The classic checkout flow still calls
payment_complete() on the order for a $0 total. The block checkout flow does not
run that step for zero cost renewals, so the renewal order is created and then just
sits on Pending or On hold, no Stripe PaymentIntent is ever attached, no renewal
note is added, and the subscription's next payment date is never advanced.

This script finds renewal orders that are genuinely zero cost, still unpaid, and
have no Stripe PaymentIntent on them (because none was ever needed), and completes
them the way payment_complete() would have. It never touches an order that has a
real PaymentIntent attached or a non-zero total, those belong to a different fix.
Read the order list from the WooCommerce REST API. Safe to run again and again.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth

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

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", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

UNPAID_STATUSES = {"pending", "on-hold"}
ZERO_COST_TOLERANCE_MINOR = 1  # a cent of rounding slack, same idea as the other guides


def order_total_minor(order):
    """Order total in minor units (cents). Keep money math in integers."""
    return round(float(order["total"]) * 100)


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 is_renewal_order(order):
    """Renewal orders carry the subscription renewal meta WooCommerce Subscriptions writes."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_subscription_renewal" and meta.get("value"):
            return True
    return False


def created_via(order):
    return (order.get("created_via") or "").lower()


def decide(order):
    """Pure decision: should this renewal order be completed as a zero cost renewal?

    Returns a tuple of (action, reason). No I/O, no Stripe or Woo calls, just the
    order dict already on hand, so this is trivial to unit test.
    """
    if not is_renewal_order(order):
        return ("skip", "not a subscription renewal order")
    if order["status"] not in UNPAID_STATUSES:
        return ("skip", "order is not pending or on-hold")
    if order_total_minor(order) > ZERO_COST_TOLERANCE_MINOR:
        return ("skip", "order total is not zero cost")
    if intent_id_of(order) is not None:
        # A PaymentIntent exists, so this is a stuck payment case, not an orphaned
        # zero cost renewal. That belongs to the "paid orders stuck on pending" fix.
        return ("skip", "a Stripe PaymentIntent is attached, not a zero cost orphan")
    if created_via(order) not in ("checkout", "subscription", ""):
        # Unexpected origin, safer to leave it for a human to check.
        return ("review", "unexpected created_via, check manually")
    return ("complete", "zero cost renewal with no PaymentIntent, safe to complete")


def renewal_orders():
    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={"status": "pending,on-hold", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def complete_renewal(order):
    """Finish the order the way payment_complete() would for a $0 renewal."""
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": "processing", "set_paid": True},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": "Completed by the zero cost renewal script. This renewal totaled "
                      "$0.00 and had no Stripe PaymentIntent, so it was never finished by "
                      "the block checkout flow. Marked processing and paid."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for order in renewal_orders():
        action, reason = decide(order)
        if action == "review":
            log.warning("Order %s: %s", order["id"], reason)
            continue
        if action != "complete":
            continue
        log.info("Order %s: %s. %s", order["id"], reason, "would complete" if DRY_RUN else "completing")
        if not DRY_RUN:
            complete_renewal(order)
        fixed += 1
    log.info("Done. %d order(s) %s.", fixed, "to complete" if DRY_RUN else "completed")


if __name__ == "__main__":
    run()
complete-zero-cost-renewal.js
/**
 * Finish zero cost WooCommerce Subscriptions renewal orders orphaned by block checkout.
 *
 * When a renewal nets to $0.00 (a 100% off coupon, a switch credit, a free trial
 * that converted with a balance still applied) WooCommerce skips Stripe entirely,
 * since there is nothing to charge. The classic checkout flow still calls
 * payment_complete() on the order for a $0 total. The block checkout flow does
 * not run that step for zero cost renewals, so the renewal order is created and
 * then just sits on Pending or On hold, no Stripe PaymentIntent is ever attached,
 * no renewal note is added, and the subscription's next payment date is never
 * advanced.
 *
 * This script finds renewal orders that are genuinely zero cost, still unpaid,
 * and have no Stripe PaymentIntent on them (because none was ever needed), and
 * completes them the way payment_complete() would have. It never touches an
 * order that has a real PaymentIntent attached or a non-zero total, those belong
 * to a different fix. Read only in dry run. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/zero-cost-renewal-orphaned-by-block-checkout/
 */
import { pathToFileURL } from "node:url";

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

const UNPAID_STATUSES = new Set(["pending", "on-hold"]);
const ZERO_COST_TOLERANCE_MINOR = 1; // a cent of rounding slack

export function orderTotalMinor(order) {
  // Keep money math in minor units (cents).
  return Math.round(parseFloat(order.total) * 100);
}

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 isRenewalOrder(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_subscription_renewal" && meta.value) return true;
  }
  return false;
}

function createdVia(order) {
  return (order.created_via || "").toLowerCase();
}

export function decide(order) {
  /**
   * Pure decision: should this renewal order be completed as a zero cost renewal?
   * Returns a tuple of [action, reason]. No I/O, just the order object already on
   * hand, so this is trivial to unit test.
   */
  if (!isRenewalOrder(order)) return ["skip", "not a subscription renewal order"];
  if (!UNPAID_STATUSES.has(order.status)) return ["skip", "order is not pending or on-hold"];
  if (orderTotalMinor(order) > ZERO_COST_TOLERANCE_MINOR) return ["skip", "order total is not zero cost"];
  if (intentIdOf(order) !== null) {
    // A PaymentIntent exists, so this is a stuck payment case, not an orphaned
    // zero cost renewal. That belongs to the "paid orders stuck on pending" fix.
    return ["skip", "a Stripe PaymentIntent is attached, not a zero cost orphan"];
  }
  const via = createdVia(order);
  if (via !== "checkout" && via !== "subscription" && via !== "") {
    return ["review", "unexpected created_via, check manually"];
  }
  return ["complete", "zero cost renewal with no PaymentIntent, safe to complete"];
}

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* renewalOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=pending,on-hold&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function completeRenewal(order) {
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ status: "processing", set_paid: true }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Completed by the zero cost renewal script. This renewal totaled $0.00 " +
            "and had no Stripe PaymentIntent, so it was never finished by the block " +
            "checkout flow. Marked processing and paid.",
    }),
  });
}

export async function run() {
  let fixed = 0;
  for await (const order of renewalOrders()) {
    const [action, reason] = decide(order);
    if (action === "review") { console.warn(`Order ${order.id}: ${reason}`); continue; }
    if (action !== "complete") continue;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would complete" : "completing"}`);
    if (!DRY_RUN) await completeRenewal(order);
    fixed++;
  }
  console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to complete" : "completed"}.`);
}

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 marked paid. Because we kept decide pure, the test needs no network, no WooCommerce site, and no Stripe account. It just feeds in plain order objects and checks the action.

test_orphaned_zero_cost_decide.py
from complete_zero_cost_renewal import decide, intent_id_of, is_renewal_order, order_total_minor


def renewal_order(**over):
    base = {
        "status": "pending",
        "total": "0.00",
        "created_via": "subscription",
        "meta_data": [{"key": "_subscription_renewal", "value": "123"}],
    }
    base.update(over)
    return base


def test_complete_when_zero_cost_renewal_with_no_intent():
    order = renewal_order()
    assert decide(order)[0] == "complete"


def test_skip_when_not_a_renewal_order():
    order = renewal_order(meta_data=[])
    assert decide(order)[0] == "skip"


def test_skip_when_order_already_paid():
    order = renewal_order(status="processing")
    assert decide(order)[0] == "skip"


def test_skip_when_total_is_not_zero_cost():
    order = renewal_order(total="19.99")
    assert decide(order)[0] == "skip"


def test_skip_when_a_payment_intent_is_attached():
    order = renewal_order(meta_data=[
        {"key": "_subscription_renewal", "value": "123"},
        {"key": "_stripe_intent_id", "value": "pi_abc"},
    ])
    assert decide(order)[0] == "skip"
complete-zero-cost-renewal.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, isRenewalOrder, orderTotalMinor } from "./complete-zero-cost-renewal.js";

const renewalOrder = (over = {}) => ({
  status: "pending",
  total: "0.00",
  created_via: "subscription",
  meta_data: [{ key: "_subscription_renewal", value: "123" }],
  ...over,
});

test("complete when zero cost renewal with no intent", () => {
  assert.equal(decide(renewalOrder())[0], "complete");
});

test("skip when not a renewal order", () => {
  assert.equal(decide(renewalOrder({ meta_data: [] }))[0], "skip");
});

test("skip when order already paid", () => {
  assert.equal(decide(renewalOrder({ status: "processing" }))[0], "skip");
});

test("skip when total is not zero cost", () => {
  assert.equal(decide(renewalOrder({ total: "19.99" }))[0], "skip");
});

test("skip when a payment intent is attached", () => {
  const order = renewalOrder({
    meta_data: [
      { key: "_subscription_renewal", value: "123" },
      { key: "_stripe_intent_id", value: "pi_abc" },
    ],
  });
  assert.equal(decide(order)[0], "skip");
});

Case studies

100% off coupon

The lifetime discount that stopped renewing quietly

A creator offered a "first hundred subscribers get it free forever" coupon that carried forward on every renewal. After the store switched its checkout to blocks, those subscribers' renewal orders started piling up on On hold, invisible to everyone since no card was ever charged and no one complained.

The script found eleven months of orphaned renewals across eighteen subscribers in dry run, all correctly totaling $0.00 with no PaymentIntent, and completed them in one real run. The next payment dates caught up immediately.

Subscription switch

The upgrade credit that covered the whole next cycle

A customer downgraded a plan mid cycle, leaving a large proration credit on their subscription. The next renewal came out to exactly $0.00 after the credit was applied, and block checkout left it unpaid instead of finishing it the way a paid renewal would have been.

Support only noticed when the customer asked why their account still showed a past due renewal despite never being charged. The script cleared the single order and confirmed no other subscriptions were affected.

What good looks like

After this runs on a schedule, a genuinely free renewal is no longer a silent gap in your subscription records. The worst case becomes a short delay before the script catches up and marks it paid. Keep it running even after you notice the pattern, since coupons, switches, and store credit will keep producing $0.00 renewals on their own.

FAQ

Why is a $0.00 renewal order stuck on Pending or On hold?

Block checkout does not call payment_complete() for renewal orders that total $0.00, because it assumes there is nothing to process when no gateway charge is needed. The classic checkout flow calls it anyway for a zero total, so the order finishes there but not in block checkout. The order is created and then just sits unpaid.

Is it safe to mark a $0.00 order as paid with a script?

Yes, when the script confirms the order is a subscription renewal, totals $0.00 within a cent, and has no Stripe PaymentIntent attached. An order with a PaymentIntent is a different, stuck payment problem and should not be touched by this script.

Will this script ever mark a real, paid order as complete by mistake?

No. The decision function skips any order that already has a Stripe PaymentIntent id, any order that is not a subscription renewal, and any order whose total is above a one cent tolerance. Only true zero cost renewals with no payment reference are completed.

Related field notes

Citations

On the problem:

  1. WooCommerce Blocks issue tracker: order processing behavior differences between the block checkout and the classic checkout for zero total carts. github.com/woocommerce/woocommerce/issues
  2. WooCommerce Subscriptions docs: how renewal orders are created and how a $0 renewal is expected to be marked paid automatically. woocommerce.com/document/subscriptions/renewal-process
  3. WooCommerce docs: Cart and Checkout Blocks, how checkout processing differs from the shortcode checkout. woocommerce.com/document/cart-checkout-blocks-support-status

On the solution:

  1. WooCommerce code reference: wc_get_order and payment_complete(), what finishing an order is supposed to do. woocommerce.github.io/code-reference/classes/WC-Order
  2. WooCommerce REST API: update an order, including set_paid, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. Stripe docs: PaymentIntent object and status values, used here only to confirm none exists on an orphaned order. docs.stripe.com/api/payment_intents/object

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your orphaned renewals?

If this saved you a pile of support tickets or cleared up a batch of stalled subscribers, 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