Diagnostic Customers, cards, and tokens

Card not saved for future renewals

The first order paid without a hitch. The customer's card went through, the order shows paid, everyone moved on. Months later the renewal runs and fails, because there was never a reusable card behind that first payment in the first place. Here is why that gap opens up and a small script that finds every subscription sitting in it before the next renewal date arrives.

Python and Node.js Runs on a schedule Safe by default (dry run)
Black asus laptop computer on white surface
Photo by Markus Winkler on Unsplash
The short answer

The subscription's first payment used a Stripe PaymentIntent that was never marked to save the card for later, so Stripe took the one payment and attached no reusable payment method to the customer. There is nothing to recover after the fact. Run a small Python or Node.js check on a schedule that looks at subscriptions whose renewal is due soon, reads the PaymentIntent from the parent order, and flags any subscription where the payment succeeded but no customer and payment method were ever saved. Full code, tests, and a dry run guard are below.

The problem in plain words

When a customer buys a subscription, WooCommerce Subscriptions needs a card it can charge again later without the customer sitting at the checkout page. To do that, the Stripe PaymentIntent behind that first payment has to be built with a flag called setup_future_usage. That flag tells Stripe "keep this payment method attached to the customer, we will use it again."

If that flag is missing, Stripe still runs the charge exactly the same way. The customer pays, the order is marked paid, and nothing on the screen looks wrong. But behind the scenes, no reusable payment method got attached to the Stripe customer. The subscription now has a paid first order and no way to renew it.

Customer subscribes and pays once Stripe charges status: succeeded no setup_future_usage No card saved for the customer Renewal has nothing
The money is taken at the charge step, but nothing marks the card as reusable, so the subscription has no card to fall back on when it needs to renew.

Why it happens

The official Stripe docs are clear that a PaymentIntent only attaches a reusable payment method to a customer when setup_future_usage is set on it, either on_session or off_session. Without that flag, the charge is a one-time payment by design, even if a subscription is what it was meant to pay for. A few common ways this gap opens up:

This is a known and reported class of problem. WooCommerce Subscriptions support threads describe renewals failing with a generic decline for accounts that show a perfectly successful first payment. The Stripe docs on saving cards during payment explain exactly which flag is missing when this happens. See the citations at the end for the exact references.

The key insight

A successful first payment tells you nothing about whether a renewal will work. Those are two different questions to Stripe. The first is "did this one charge succeed." The second is "is there a payment method attached to this customer that we can charge again." A diagnostic that checks the second question, ahead of the renewal date, turns a failed renewal into a friendly email asking for a card.

The fix, as a flow

We do not try to recreate a saved card from a PaymentIntent that never had one. Once a payment was taken without setup_future_usage, Stripe does not keep a chargeable payment method behind it, so there is nothing to repair. Instead we add a check that runs on a schedule, looks at subscriptions whose renewal is coming up soon, and confirms whether a reusable customer and payment method actually exist. If they do not, we flag the subscription early, while there is still time to ask the customer for a card.

Scheduled job once a day List subscriptions renewal due soon Load parent order's PaymentIntent Customer and card saved? yes, skip no Flag subscription add a note, ask for card
The check reads the truth from Stripe and only flags subscriptions that are close to renewing and genuinely have no reusable card. Everything else is left alone.

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 access to orders and subscriptions, plus write access to notes. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. WooCommerce Subscriptions must be active for the /subscriptions endpoint to exist. 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 DAYS_BEFORE_RENEWAL="3"
export REVIEW_HOLD="false"
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 DAYS_BEFORE_RENEWAL="3"
export REVIEW_HOLD="false"
export DRY_RUN="true"   // start safe, change to false to write
2

List subscriptions whose renewal is coming up soon

Ask WooCommerce for active and on-hold subscriptions. We only care about the Stripe gateway here, since other gateways save cards a different way. Filtering to subscriptions whose next_payment_date_gmt is within a few days keeps the check fast and keeps notes from piling up on subscriptions that are not close to renewing yet.

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

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

def active_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

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

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

Load the parent order and its PaymentIntent

Each subscription points at a parent order, the order that created it. Read the Stripe PaymentIntent id from that order's meta, from _stripe_intent_id first, falling back to transaction_id when it looks like a PaymentIntent id. Then ask Stripe for the intent itself, since that is the only place the truth about a saved card lives.

step3.py
import stripe

def get_meta(record, key):
    for m in (record or {}).get("meta_data", []) or []:
        if m.get("key") == key:
            return m.get("value")
    return None

def intent_id_of(order):
    value = get_meta(order, "_stripe_intent_id")
    if value:
        return 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
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

function getMeta(record, key) {
  for (const m of (record && record.meta_data) || []) {
    if (m.key === key) return m.value;
  }
  return null;
}

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

async function getOrder(orderId) {
  if (!orderId) return null;
  return woo(`/orders/${orderId}`);
}

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 the subscription, the parent order, the Stripe intent, and the current time, and returns an action. A pure function like this is easy to read and easy to test, which we do later. Skip subscriptions that are not close to renewing yet. Flag a subscription only when the parent order really paid and Stripe really has no reusable card behind it.

decide.py
ACTIVE_STATUSES = {"active", "on-hold"}
DAYS_BEFORE_RENEWAL = 3

def days_until(renewal_date_gmt, now):
    if not renewal_date_gmt:
        return None
    from datetime import datetime, timezone
    renewal = datetime.fromisoformat(renewal_date_gmt.replace("Z", "+00:00"))
    if renewal.tzinfo is None:
        renewal = renewal.replace(tzinfo=timezone.utc)
    return (renewal - now).total_seconds() / 86400

def decide(subscription, parent_order, intent, now):
    if subscription["status"] not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active or on-hold")
    if subscription.get("payment_method") != "stripe":
        return ("skip", "subscription is not on the Stripe gateway")
    remaining = days_until(subscription.get("next_payment_date_gmt"), now)
    if remaining is not None and remaining > DAYS_BEFORE_RENEWAL:
        return ("skip", "next renewal is not due soon enough to act yet")
    if parent_order is None:
        return ("skip", "no parent order to check yet")
    if intent is None:
        return ("skip", "parent order has no Stripe PaymentIntent to check")
    if intent.get("status") != "succeeded":
        return ("skip", "parent order payment was not a succeeded charge")
    if intent.get("customer") and intent.get("payment_method"):
        return ("ok", "a reusable card is already attached for renewals")
    return ("flag", "payment succeeded but no reusable card was saved for renewals")
decide.js
const ACTIVE_STATUSES = new Set(["active", "on-hold"]);
const DAYS_BEFORE_RENEWAL = 3;

export function daysUntil(renewalDateGmt, now) {
  if (!renewalDateGmt) return null;
  const renewal = new Date(renewalDateGmt.endsWith("Z") ? renewalDateGmt : `${renewalDateGmt}Z`);
  return (renewal.getTime() - now.getTime()) / 86400000;
}

export function decide(subscription, parentOrder, intent, now) {
  if (!ACTIVE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }
  if (subscription.payment_method !== "stripe") {
    return ["skip", "subscription is not on the Stripe gateway"];
  }
  const remaining = daysUntil(subscription.next_payment_date_gmt, now);
  if (remaining !== null && remaining > DAYS_BEFORE_RENEWAL) {
    return ["skip", "next renewal is not due soon enough to act yet"];
  }
  if (!parentOrder) return ["skip", "no parent order to check yet"];
  if (!intent) return ["skip", "parent order has no Stripe PaymentIntent to check"];
  if (intent.status !== "succeeded") {
    return ["skip", "parent order payment was not a succeeded charge"];
  }
  if (intent.customer && intent.payment_method) {
    return ["ok", "a reusable card is already attached for renewals"];
  }
  return ["flag", "payment succeeded but no reusable card was saved for renewals"];
}
5

Flag the subscription so a human can ask for a card

When the action is flag, add an order note explaining exactly what is missing, so the shop manager sees it without digging through Stripe. Optionally move the subscription to on-hold with REVIEW_HOLD, which stops WooCommerce Subscriptions from attempting a renewal charge that would only fail and generate a decline email to the customer.

apply.py
def flag(subscription, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}/notes",
        json={"note": f"Renewal card check failed: {reason}. The next automatic "
                      f"renewal will not have a card to charge. Please ask the "
                      f"customer to add a payment method before the renewal date."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if REVIEW_HOLD:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
            json={"status": "on-hold"}, auth=AUTH, timeout=30,
        ).raise_for_status()
apply.js
async function flag(subscription, reason) {
  await woo(`/subscriptions/${subscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal card check failed: ${reason}. The next automatic renewal ` +
            `will not have a card to charge. Please ask the customer to add a ` +
            `payment method before the renewal date.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/subscriptions/${subscription.id}`, {
      method: "PUT",
      body: JSON.stringify({ status: "on-hold" }),
    });
  }
}
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 flag. Read the output, trust it, then switch it off to let it write notes. Run it once a day with cron, since renewal dates do not move quickly.

Run it safe

Always start with DRY_RUN=true. This check writes order notes and, if REVIEW_HOLD is on, changes subscription status, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete 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 only acts on subscriptions that are genuinely missing a reusable card.

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

find_unsaved_renewal_cards.py
"""Find WooCommerce subscriptions whose first payment succeeded but never
saved a reusable card, so the next automatic renewal has nothing to charge.

The initial order can be paid in full while the Stripe PaymentIntent behind it
was created without `setup_future_usage`. That happens when a checkout plugin,
a custom "buy now" button, or an older integration builds the PaymentIntent by
hand and forgets the flag. Stripe still takes the money, WooCommerce still
marks the order paid, and nobody notices until the renewal date arrives with
no saved card to charge.

This walks active and on-hold subscriptions, reads the PaymentIntent id from
the parent order's meta (_stripe_intent_id, falling back to transaction_id),
and asks Stripe whether that PaymentIntent actually attached a reusable
PaymentMethod to a Customer. If it did not, there is nothing to recover, so the
subscription is flagged (and optionally put on-hold) well before the renewal
is due, so the shop can ask the customer for a card while there is still time.
Read only by default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

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"])
DAYS_BEFORE_RENEWAL = int(os.environ.get("DAYS_BEFORE_RENEWAL", "3"))
REVIEW_HOLD = os.environ.get("REVIEW_HOLD", "false").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ACTIVE_STATUSES = {"active", "on-hold"}


def get_meta(record, key):
    for m in (record or {}).get("meta_data", []) or []:
        if m.get("key") == key:
            return m.get("value")
    return None


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    value = get_meta(order, "_stripe_intent_id")
    if value:
        return value
    tid = (order or {}).get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def days_until(renewal_date_gmt, now):
    if not renewal_date_gmt:
        return None
    from datetime import datetime, timezone
    renewal = datetime.fromisoformat(renewal_date_gmt.replace("Z", "+00:00"))
    if renewal.tzinfo is None:
        renewal = renewal.replace(tzinfo=timezone.utc)
    return (renewal - now).total_seconds() / 86400


def decide(subscription, parent_order, intent, now):
    """Pure decision function. No I/O.

    subscription: dict with at least "id", "status", "next_payment_date_gmt".
    parent_order: the subscription's original paid order dict, or None.
    intent: the Stripe PaymentIntent dict the parent order paid with, or None
            if it could not be found on Stripe at all.
    now: a timezone-aware datetime, passed in so this stays pure.
    """
    if subscription["status"] not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active or on-hold")
    if subscription.get("payment_method") != "stripe":
        return ("skip", "subscription is not on the Stripe gateway")
    remaining = days_until(subscription.get("next_payment_date_gmt"), now)
    if remaining is not None and remaining > DAYS_BEFORE_RENEWAL:
        return ("skip", "next renewal is not due soon enough to act yet")
    if parent_order is None:
        return ("skip", "no parent order to check yet")
    if intent is None:
        return ("skip", "parent order has no Stripe PaymentIntent to check")
    if intent.get("status") != "succeeded":
        return ("skip", "parent order payment was not a succeeded charge")
    if intent.get("customer") and intent.get("payment_method"):
        return ("ok", "a reusable card is already attached for renewals")
    return ("flag", "payment succeeded but no reusable card was saved for renewals")


def active_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1


def get_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 flag(subscription, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}/notes",
        json={"note": f"Renewal card check failed: {reason}. The next automatic "
                      f"renewal will not have a card to charge. Please ask the "
                      f"customer to add a payment method before the renewal date."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if REVIEW_HOLD:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
            json={"status": "on-hold"}, auth=AUTH, timeout=30,
        ).raise_for_status()


def run():
    from datetime import datetime, timezone
    now = datetime.now(timezone.utc)
    flagged = 0
    for subscription in active_subscriptions():
        parent_order = get_order(subscription.get("parent_id"))
        intent = get_intent(intent_id_of(parent_order))
        action, reason = decide(subscription, parent_order, intent, now)
        if action != "flag":
            continue
        log.warning("Subscription %s: %s. %s", subscription["id"], reason,
                    "would flag" if DRY_RUN else "flagging")
        if not DRY_RUN:
            flag(subscription, reason)
        flagged += 1
    log.info("Done. %d subscription(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
find-unsaved-renewal-cards.js
/**
 * Find WooCommerce subscriptions whose first payment succeeded but never
 * saved a reusable card, so the next automatic renewal has nothing to charge.
 *
 * The initial order can be paid in full while the Stripe PaymentIntent behind
 * it was created without `setup_future_usage`. That happens when a checkout
 * plugin, a custom "buy now" button, or an older integration builds the
 * PaymentIntent by hand and forgets the flag. Stripe still takes the money,
 * WooCommerce still marks the order paid, and nobody notices until the
 * renewal date arrives with no saved card to charge.
 *
 * This walks active and on-hold subscriptions, reads the PaymentIntent id
 * from the parent order's meta (_stripe_intent_id, falling back to
 * transaction_id), and asks Stripe whether that PaymentIntent actually
 * attached a reusable PaymentMethod to a Customer. If it did not, there is
 * nothing to recover, so the subscription is flagged (and optionally put
 * on-hold) well before the renewal is due, so the shop can ask the customer
 * for a card while there is still time. Read only by default. Run on a
 * schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/card-not-saved-for-future-renewals/
 */
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 DAYS_BEFORE_RENEWAL = Number(process.env.DAYS_BEFORE_RENEWAL || 3);
const REVIEW_HOLD = (process.env.REVIEW_HOLD || "false").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ACTIVE_STATUSES = new Set(["active", "on-hold"]);

export function getMeta(record, key) {
  for (const m of (record && record.meta_data) || []) {
    if (m.key === key) return m.value;
  }
  return null;
}

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

export function daysUntil(renewalDateGmt, now) {
  if (!renewalDateGmt) return null;
  const renewal = new Date(renewalDateGmt.endsWith("Z") ? renewalDateGmt : `${renewalDateGmt}Z`);
  return (renewal.getTime() - now.getTime()) / 86400000;
}

/**
 * Pure decision function. No I/O.
 *
 * subscription: object with at least id, status, payment_method,
 *               next_payment_date_gmt.
 * parentOrder: the subscription's original paid order object, or null.
 * intent: the Stripe PaymentIntent object the parent order paid with, or
 *         null if it could not be found on Stripe at all.
 * now: a Date, passed in so this stays pure.
 */
export function decide(subscription, parentOrder, intent, now) {
  if (!ACTIVE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }
  if (subscription.payment_method !== "stripe") {
    return ["skip", "subscription is not on the Stripe gateway"];
  }
  const remaining = daysUntil(subscription.next_payment_date_gmt, now);
  if (remaining !== null && remaining > DAYS_BEFORE_RENEWAL) {
    return ["skip", "next renewal is not due soon enough to act yet"];
  }
  if (!parentOrder) {
    return ["skip", "no parent order to check yet"];
  }
  if (!intent) {
    return ["skip", "parent order has no Stripe PaymentIntent to check"];
  }
  if (intent.status !== "succeeded") {
    return ["skip", "parent order payment was not a succeeded charge"];
  }
  if (intent.customer && intent.payment_method) {
    return ["ok", "a reusable card is already attached for renewals"];
  }
  return ["flag", "payment succeeded but no reusable card was saved for renewals"];
}

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

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

async function getOrder(orderId) {
  if (!orderId) return null;
  return woo(`/orders/${orderId}`);
}

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

async function flag(subscription, reason) {
  await woo(`/subscriptions/${subscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Renewal card check failed: ${reason}. The next automatic renewal ` +
            `will not have a card to charge. Please ask the customer to add a ` +
            `payment method before the renewal date.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/subscriptions/${subscription.id}`, {
      method: "PUT",
      body: JSON.stringify({ status: "on-hold" }),
    });
  }
}

export async function run() {
  const now = new Date();
  let flagged = 0;
  for await (const subscription of activeSubscriptions()) {
    const parentOrder = await getOrder(subscription.parent_id);
    const intent = await getIntent(intentIdOf(parentOrder));
    const [action, reason] = decide(subscription, parentOrder, intent, now);
    if (action !== "flag") continue;
    console.warn(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
    if (!DRY_RUN) await flag(subscription, reason);
    flagged++;
  }
  console.log(`Done. ${flagged} subscription(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which subscriptions get flagged and possibly put on-hold. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and a fixed clock, then checks the action.

test_card_saved_decide.py
from datetime import datetime, timedelta, timezone

from find_unsaved_renewal_cards import decide, intent_id_of, days_until


NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)


def subscription(**over):
    base = {
        "id": 501,
        "status": "active",
        "payment_method": "stripe",
        "next_payment_date_gmt": (NOW + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S"),
    }
    base.update(over)
    return base


def order(**over):
    base = {"id": 900, "status": "processing", "meta_data": [
        {"key": "_stripe_intent_id", "value": "pi_1"}
    ]}
    base.update(over)
    return base


def intent(**over):
    base = {"status": "succeeded", "customer": "cus_1", "payment_method": "pm_1"}
    base.update(over)
    return base


def test_ok_when_reusable_card_attached():
    assert decide(subscription(), order(), intent(), NOW)[0] == "ok"


def test_flag_when_no_customer_on_intent():
    assert decide(subscription(), order(), intent(customer=None), NOW)[0] == "flag"


def test_flag_when_no_payment_method_on_intent():
    assert decide(subscription(), order(), intent(payment_method=None), NOW)[0] == "flag"


def test_skip_when_subscription_not_active():
    sub = subscription(status="cancelled")
    assert decide(sub, order(), intent(), NOW)[0] == "skip"


def test_skip_when_not_stripe_gateway():
    sub = subscription(payment_method="paypal")
    assert decide(sub, order(), intent(), NOW)[0] == "skip"


def test_skip_when_renewal_not_due_soon():
    sub = subscription(next_payment_date_gmt=(NOW + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S"))
    assert decide(sub, order(), intent(), NOW)[0] == "skip"


def test_skip_when_no_parent_order():
    assert decide(subscription(), None, intent(), NOW)[0] == "skip"


def test_skip_when_no_intent_found():
    assert decide(subscription(), order(), None, NOW)[0] == "skip"


def test_skip_when_intent_not_succeeded():
    assert decide(subscription(), order(), intent(status="requires_payment_method"), NOW)[0] == "skip"
find-unsaved-renewal-cards.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, daysUntil } from "./find-unsaved-renewal-cards.js";

const NOW = new Date("2026-07-10T00:00:00Z");

const subscription = (over = {}) => ({
  id: 501,
  status: "active",
  payment_method: "stripe",
  next_payment_date_gmt: "2026-07-11T00:00:00",
  ...over,
});

const order = (over = {}) => ({
  id: 900,
  status: "processing",
  meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }],
  ...over,
});

const intent = (over = {}) => ({
  status: "succeeded",
  customer: "cus_1",
  payment_method: "pm_1",
  ...over,
});

test("ok when reusable card attached", () => {
  assert.equal(decide(subscription(), order(), intent(), NOW)[0], "ok");
});

test("flag when no customer on intent", () => {
  assert.equal(decide(subscription(), order(), intent({ customer: null }), NOW)[0], "flag");
});

test("flag when no payment method on intent", () => {
  assert.equal(decide(subscription(), order(), intent({ payment_method: null }), NOW)[0], "flag");
});

test("skip when subscription not active", () => {
  assert.equal(decide(subscription({ status: "cancelled" }), order(), intent(), NOW)[0], "skip");
});

test("skip when not stripe gateway", () => {
  assert.equal(decide(subscription({ payment_method: "paypal" }), order(), intent(), NOW)[0], "skip");
});

test("skip when renewal not due soon", () => {
  const sub = subscription({ next_payment_date_gmt: "2026-08-10T00:00:00" });
  assert.equal(decide(sub, order(), intent(), NOW)[0], "skip");
});

test("skip when no parent order", () => {
  assert.equal(decide(subscription(), null, intent(), NOW)[0], "skip");
});

test("skip when no intent found", () => {
  assert.equal(decide(subscription(), order(), null, NOW)[0], "skip");
});

test("skip when intent not succeeded", () => {
  assert.equal(decide(subscription(), order(), intent({ status: "requires_payment_method" }), NOW)[0], "skip");
});

Case studies

Custom checkout

The landing page that skipped the flag

A store ran a limited time offer through a custom landing page that built its own Stripe PaymentIntent to keep the design simple. The first charge worked fine and converted well. Weeks later, support tickets came in from customers confused about renewal declines, because the PaymentIntent never marked the card reusable.

Running the check three days before each cohort's renewal date caught the whole batch early. The team emailed every flagged customer for a fresh card before a single renewal attempt was made, so nobody saw a decline.

Migration

The imported orders with real charges but no tokens

A store migrated from another cart and imported a batch of subscriptions with parent orders that pointed at real Stripe charges from the old system. The charges were legitimate and succeeded, but they were never built with saving a card in mind, so no payment method existed to attach.

The check flagged every migrated subscription on its first run. The store treated it as a one time cleanup task, reaching out to each customer once instead of waiting for renewal failures to trickle in over the following month.

What good looks like

After this runs on a schedule, a missing saved card stops being a surprise on renewal day. It becomes a note a few days ahead of time and a short, polite email asking the customer to add a payment method. The renewal either succeeds because the customer added a card, or it is paused cleanly instead of failing with a confusing decline.

FAQ

Why did my subscription charge once but never save the card?

The first payment used a Stripe PaymentIntent that was never told to save the card for later. Without setup_future_usage set to off_session, Stripe takes the one payment and does not attach a reusable payment method to the customer, so nothing exists for the renewal to charge.

Can I recover the saved card after the fact?

No, not from that same PaymentIntent. If the card was never marked reusable at the time of the charge, Stripe does not keep a chargeable payment method behind it. The fix is to detect the gap early and ask the customer to add a card before the renewal date, not to try to recreate it.

How soon before renewal should I run this check?

A few days is usually enough. Running it three to five days before the next renewal date gives the shop time to email the customer and still get a card on file before the charge is attempted.

Related field notes

Citations

On the problem:

  1. Stripe docs: saving cards during payment and the setup_future_usage parameter on PaymentIntents. docs.stripe.com/payments/save-during-payment
  2. WooCommerce Subscriptions docs: how renewal payments depend on a stored payment token. woocommerce.com/document/subscriptions/renewal-process
  3. Stripe docs: PaymentIntent object reference, including the customer and payment_method fields. docs.stripe.com/api/payment_intents/object

On the solution:

  1. WooCommerce Subscriptions REST API: reading and updating subscriptions. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a PaymentIntent to check its customer and payment_method. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: add an order note through the orders endpoint. 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.

Contact me on LinkedIn

Did this save you from a wave of renewal declines?

If this helped you catch missing cards before they turned into failed renewals, 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