Repair Bulk subscription operations
Bulk swap a reissued card range across WooCommerce Subscriptions
A bank or card network reissues a whole range of numbers after a breach, and every subscription still charging one of the old cards is about to fail its next renewal. That can be dozens or thousands of subscriptions at once, all breaking for the same reason on the same week. Here is why it happens and a small script that finds every affected subscription and moves it onto a clean card in a safe way.
A reissue notice names a batch of old Stripe payment method ids that no longer work. Run a small Python or Node.js job that walks active, on-hold, and pending WooCommerce Subscriptions, finds the ones still storing one of those old ids, and swaps each one onto the customer's current default payment method on Stripe, but only when that replacement is real, different, and not itself on the reissued range. Anything without a safe replacement gets flagged for a human instead of guessed at. Full code, tests, and a dry run guard are below.
The problem in plain words
Every card has a number, and every number eventually needs to change. A data breach at a merchant, a lost card, a bank switching processors, any of these can cause an issuer to reissue a whole range of cards at once. The cardholder gets a new card in the mail. The old number stops working everywhere, all on the same day.
WooCommerce Subscriptions does not know any of this happened. Each subscription keeps charging the token it was given when the customer first checked out. If that token points at a card from the reissued range, the next renewal attempt is declined, and it is declined for every single subscription tied to that range, not just one unlucky customer. Support sees a wall of failed renewals that all started on the same day for no visible reason, until someone traces it back to one issuer notice.
Why it happens
WooCommerce Subscriptions and the Stripe gateway are both working as designed here. The problem is a mismatch between what the bank knows and what the store's saved tokens still say. A few things make the cluster worse:
- The reissue is a bank side event. Stripe only learns the old card is dead when a charge against it is attempted and declined, so nothing warns the store in advance.
- Card updater services can refresh some saved cards automatically, but coverage is not universal, and a batch reissue from a breach often lands faster than the updater cycle.
- WooCommerce Subscriptions retries a failed renewal a few times over several days, so the same broken card can generate multiple decline emails and note entries per subscription before anyone notices the pattern.
- Support usually finds out one ticket at a time, long after the first wave of renewals has already failed, because nothing groups the declines by the card range that caused them.
By the time someone connects the dots, dozens or hundreds of subscriptions can be sitting on hold for the exact same reason. Fixing them one by one in the WooCommerce admin does not scale to a breach sized event.
The Stripe Customer object, not the subscription, usually already has the answer. When a customer updates their card after a reissue notice, most checkout flows save the new card and set it as the customer's default payment method on Stripe. The subscription just never got told about it. A bulk swap job is really a job that copies the customer's current default onto every subscription that is still stuck on the old one.
The fix, as a flow
We do not touch checkout or the renewal logic. We add a one time job, triggered by a reissue notice, that lists the affected old payment method ids, walks every active subscription, and checks whether its stored token is in that list. When it is, we look up the customer's current default payment method on Stripe. If that default is a real card and it is not itself on the reissued range, we update the subscription's stored token and leave a note. If there is no safe replacement, we flag the subscription instead of guessing.
Build it step by step
Get access to both systems and the reissue list
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API, and make sure WooCommerce Subscriptions is active so the /subscriptions endpoint exists. The reissue notice from your processor or bank gives you the old, affected Stripe payment_method ids, one per card. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export AFFECTED_PAYMENT_METHOD_IDS="pm_old_1,pm_old_2,pm_old_3"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export AFFECTED_PAYMENT_METHOD_IDS="pm_old_1,pm_old_2,pm_old_3"
export DRY_RUN="true" // start safe, change to false to write
Walk every active subscription and read its stored token
List subscriptions with a status of active, on-hold, or pending through the WooCommerce REST API. WooCommerce stores the current payment method token for a subscription in its meta, typically _stripe_source_id. We also read _stripe_customer_id so we know which Stripe Customer to check for a replacement card.
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 get_meta(obj, key):
for meta in obj.get("meta_data") or []:
if meta.get("key") == key:
return meta.get("value")
return None
def current_card_token(sub):
return get_meta(sub, "_stripe_source_id")
def customer_id_of(sub):
return get_meta(sub, "_stripe_customer_id")
def get_subscriptions_on_hold_and_active():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold,pending", "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
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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
function getMeta(obj, key) {
for (const meta of obj.meta_data || []) {
if (meta.key === key) return meta.value;
}
return null;
}
function currentCardToken(sub) {
return getMeta(sub, "_stripe_source_id");
}
function customerIdOf(sub) {
return getMeta(sub, "_stripe_customer_id");
}
async function* activeSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold,pending&per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
Look up the customer's current default card on Stripe
Retrieve the Stripe Customer and read invoice_settings.default_payment_method, falling back to the legacy default_source field for older integrations. Fetch the full PaymentMethod object so we can compare its id against the reissued range, not just trust a string.
import stripe
def get_customer_default_payment_method(customer_id):
if not customer_id:
return None
try:
customer = stripe.Customer.retrieve(customer_id)
except stripe.error.InvalidRequestError:
return None
default_id = (customer.get("invoice_settings") or {}).get("default_payment_method")
if not default_id:
default_id = customer.get("default_source")
if not default_id:
return None
try:
return stripe.PaymentMethod.retrieve(default_id)
except stripe.error.InvalidRequestError:
return None
async function getCustomerDefaultPaymentMethod(customerId) {
if (!customerId) return null;
let customer;
try {
customer = await stripe.customers.retrieve(customerId);
} catch {
return null;
}
const defaultId =
(customer.invoice_settings && customer.invoice_settings.default_payment_method) ||
customer.default_source;
if (!defaultId) return null;
try {
return await stripe.paymentMethods.retrieve(defaultId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription, the set of affected token ids, and the customer's resolved default payment method, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule stays narrow on purpose. Skip anything not active, anything with no stored token, and anything not on the reissued range. Only swap when the customer's default is a real, different card that is not itself on the reissued range. Anything else needing a human gets flagged instead of guessed at.
ACTIVE_SUB_STATUSES = {"active", "on-hold", "pending"}
def decide(sub, affected_token_ids, default_payment_method):
if sub["status"] not in ACTIVE_SUB_STATUSES:
return ("skip", "subscription not in an active state")
token = current_card_token(sub)
if not token:
return ("skip", "no stored payment token on this subscription")
if token not in affected_token_ids:
return ("skip", "not on the reissued card range")
if default_payment_method is None:
return ("needs-attention", "no replacement card on file for this customer")
new_token = default_payment_method.get("id")
if not new_token or new_token in affected_token_ids:
return ("needs-attention", "customer default is missing or also on the reissued range")
if new_token == token:
return ("skip", "already on the new token")
return ("swap", "reissued card on file, a clean replacement is ready")
const ACTIVE_SUB_STATUSES = new Set(["active", "on-hold", "pending"]);
export function decide(sub, affectedTokenIds, defaultPaymentMethod) {
if (!ACTIVE_SUB_STATUSES.has(sub.status)) {
return ["skip", "subscription not in an active state"];
}
const token = currentCardToken(sub);
if (!token) return ["skip", "no stored payment token on this subscription"];
if (!affectedTokenIds.has(token)) return ["skip", "not on the reissued card range"];
if (!defaultPaymentMethod) {
return ["needs-attention", "no replacement card on file for this customer"];
}
const newToken = defaultPaymentMethod.id;
if (!newToken || affectedTokenIds.has(newToken)) {
return ["needs-attention", "customer default is missing or also on the reissued range"];
}
if (newToken === token) return ["skip", "already on the new token"];
return ["swap", "reissued card on file, a clean replacement is ready"];
}
Apply the swap or flag for follow up
When the action is swap, write the new token onto the subscription's _stripe_source_id meta and add a note recording the old and new ids. When the action is needs-attention, leave the subscription untouched and add a note asking a human to reach out to the customer for a new card before the next renewal.
def apply_swap(sub_id, new_token, old_token):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"meta_data": [{"key": "_stripe_source_id", "value": new_token}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Reissued card {old_token} swapped for {new_token} by the bulk "
f"card range reconciler. Next renewal will charge the new card."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag_needs_attention(sub_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Card range reissue: {reason}. This subscription is on an old, "
f"reissued card and has no safe replacement on file. Please contact "
f"the customer for a new card before the next renewal."},
auth=AUTH, timeout=30,
).raise_for_status()
async function applySwap(subId, newToken, oldToken) {
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: "_stripe_source_id", value: newToken }] }),
});
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reissued card ${oldToken} swapped for ${newToken} by the bulk card range ` +
`reconciler. Next renewal will charge the new card.`,
}),
});
}
async function flagNeedsAttention(subId, reason) {
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Card range reissue: ${reason}. This subscription is on an old, reissued ` +
`card and has no safe replacement on file. Please contact the customer for ` +
`a new card before the next renewal.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would do and how many subscriptions need a human. Read the output, trust it, then switch it off to let it write. This is a one time job you run per reissue event, not a recurring schedule.
Always start with DRY_RUN=true. A bulk swap writes to real subscriptions across your whole customer base at once, so you want to see the exact plan, and the exact flagged list, before it acts.
The full code
Here is the complete job in one file for each language. It reads the affected token list and settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever touches a subscription still stuck on the reissued range.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Bulk swap subscriptions off a reissued Stripe card range onto each customer's
current default payment method.
An issuer notice or a Stripe card updater event names a batch of old payment_method
ids (or fingerprints) that no longer work. Any active subscription still storing one
of those old ids as its payment token will decline on its next renewal. This walks the
affected subscriptions, reads the matching Stripe Customer, and swaps the subscription
onto the customer's current default payment method, but only when that default is a
real, different, non-affected card. Safe to run again and again. Dry run by default.
"""
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("swap_reissued_card")
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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ACTIVE_SUB_STATUSES = {"active", "on-hold", "pending"}
def get_meta(obj, key):
for meta in obj.get("meta_data") or []:
if meta.get("key") == key:
return meta.get("value")
return None
def current_card_token(sub):
"""The payment method id a subscription currently charges, read from meta
_stripe_source_id, falling back to a saved _stripe_intent_id or transaction_id
(a pi_ id, whose payment_method we resolve before deciding)."""
return get_meta(sub, "_stripe_source_id")
def customer_id_of(sub):
return get_meta(sub, "_stripe_customer_id")
def decide(sub, affected_token_ids, default_payment_method):
"""Pure decision function. No I/O. Returns (action, reason).
sub: a dict with at least "status" and meta_data carrying _stripe_source_id.
affected_token_ids: a set of old payment_method ids from the reissued range.
default_payment_method: the customer's current default PaymentMethod dict
(or None), already resolved by the caller.
"""
if sub["status"] not in ACTIVE_SUB_STATUSES:
return ("skip", "subscription not in an active state")
token = current_card_token(sub)
if not token:
return ("skip", "no stored payment token on this subscription")
if token not in affected_token_ids:
return ("skip", "not on the reissued card range")
if default_payment_method is None:
return ("needs-attention", "no replacement card on file for this customer")
new_token = default_payment_method.get("id")
if not new_token or new_token in affected_token_ids:
return ("needs-attention", "customer default is missing or also on the reissued range")
if new_token == token:
return ("skip", "already on the new token")
return ("swap", "reissued card on file, a clean replacement is ready")
def get_subscriptions_on_hold_and_active():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold,pending", "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_customer_default_payment_method(customer_id):
if not customer_id:
return None
try:
customer = stripe.Customer.retrieve(customer_id)
except stripe.error.InvalidRequestError:
return None
default_id = (customer.get("invoice_settings") or {}).get("default_payment_method")
if not default_id:
default_id = customer.get("default_source")
if not default_id:
return None
try:
return stripe.PaymentMethod.retrieve(default_id)
except stripe.error.InvalidRequestError:
return None
def apply_swap(sub_id, new_token, old_token):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"meta_data": [{"key": "_stripe_source_id", "value": new_token}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Reissued card {old_token} swapped for {new_token} by the bulk "
f"card range reconciler. Next renewal will charge the new card."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag_needs_attention(sub_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Card range reissue: {reason}. This subscription is on an old, "
f"reissued card and has no safe replacement on file. Please contact "
f"the customer for a new card before the next renewal."},
auth=AUTH, timeout=30,
).raise_for_status()
def load_affected_token_ids():
"""Read the comma separated list of old payment_method ids from the environment.
In practice this comes from the issuer's reissue notice or a Stripe card updater
export, one payment_method id per affected card."""
raw = os.environ.get("AFFECTED_PAYMENT_METHOD_IDS", "")
return {pm.strip() for pm in raw.split(",") if pm.strip()}
def run():
affected = load_affected_token_ids()
if not affected:
log.warning("AFFECTED_PAYMENT_METHOD_IDS is empty, nothing to do.")
return
swapped = 0
flagged = 0
for sub in get_subscriptions_on_hold_and_active():
token = current_card_token(sub)
if token not in affected:
continue
default_pm = get_customer_default_payment_method(customer_id_of(sub))
action, reason = decide(sub, affected, default_pm)
if action == "skip":
continue
if action == "needs-attention":
log.warning("Subscription %s: %s", sub["id"], reason)
if not DRY_RUN:
flag_needs_attention(sub["id"], reason)
flagged += 1
continue
new_token = default_pm["id"]
log.info("Subscription %s: %s. %s", sub["id"], reason, "would swap" if DRY_RUN else "swapping")
if not DRY_RUN:
apply_swap(sub["id"], new_token, token)
swapped += 1
log.info(
"Done. %d subscription(s) %s, %d flagged for manual follow up.",
swapped, "to swap" if DRY_RUN else "swapped", flagged,
)
if __name__ == "__main__":
run()
/**
* Bulk swap subscriptions off a reissued Stripe card range onto each customer's
* current default payment method.
*
* An issuer notice or a Stripe card updater event names a batch of old payment_method
* ids (or fingerprints) that no longer work. Any active subscription still storing one
* of those old ids as its payment token will decline on its next renewal. This walks
* the affected subscriptions, reads the matching Stripe Customer, and swaps the
* subscription onto the customer's current default payment method, but only when that
* default is a real, different, non-affected card. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/bulk-swap-a-reissued-card-range/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_SUB_STATUSES = new Set(["active", "on-hold", "pending"]);
export function getMeta(obj, key) {
for (const meta of obj.meta_data || []) {
if (meta.key === key) return meta.value;
}
return null;
}
export function currentCardToken(sub) {
return getMeta(sub, "_stripe_source_id");
}
export function customerIdOf(sub) {
return getMeta(sub, "_stripe_customer_id");
}
/**
* Pure decision function. No I/O. Returns [action, reason].
*
* sub: an object with at least "status" and meta_data carrying _stripe_source_id.
* affectedTokenIds: a Set of old payment_method ids from the reissued range.
* defaultPaymentMethod: the customer's current default PaymentMethod object
* (or null), already resolved by the caller.
*/
export function decide(sub, affectedTokenIds, defaultPaymentMethod) {
if (!ACTIVE_SUB_STATUSES.has(sub.status)) {
return ["skip", "subscription not in an active state"];
}
const token = currentCardToken(sub);
if (!token) return ["skip", "no stored payment token on this subscription"];
if (!affectedTokenIds.has(token)) return ["skip", "not on the reissued card range"];
if (!defaultPaymentMethod) {
return ["needs-attention", "no replacement card on file for this customer"];
}
const newToken = defaultPaymentMethod.id;
if (!newToken || affectedTokenIds.has(newToken)) {
return ["needs-attention", "customer default is missing or also on the reissued range"];
}
if (newToken === token) return ["skip", "already on the new token"];
return ["swap", "reissued card on file, a clean replacement is ready"];
}
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* activeSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold,pending&per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
async function getCustomerDefaultPaymentMethod(customerId) {
if (!customerId) return null;
let customer;
try {
customer = await stripe.customers.retrieve(customerId);
} catch {
return null;
}
const defaultId =
(customer.invoice_settings && customer.invoice_settings.default_payment_method) ||
customer.default_source;
if (!defaultId) return null;
try {
return await stripe.paymentMethods.retrieve(defaultId);
} catch {
return null;
}
}
async function applySwap(subId, newToken, oldToken) {
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: "_stripe_source_id", value: newToken }] }),
});
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reissued card ${oldToken} swapped for ${newToken} by the bulk card range ` +
`reconciler. Next renewal will charge the new card.`,
}),
});
}
async function flagNeedsAttention(subId, reason) {
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Card range reissue: ${reason}. This subscription is on an old, reissued ` +
`card and has no safe replacement on file. Please contact the customer for ` +
`a new card before the next renewal.`,
}),
});
}
function loadAffectedTokenIds() {
const raw = process.env.AFFECTED_PAYMENT_METHOD_IDS || "";
return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
}
export async function run() {
const affected = loadAffectedTokenIds();
if (affected.size === 0) {
console.warn("AFFECTED_PAYMENT_METHOD_IDS is empty, nothing to do.");
return;
}
let swapped = 0;
let flagged = 0;
for await (const sub of activeSubscriptions()) {
const token = currentCardToken(sub);
if (!affected.has(token)) continue;
const defaultPm = await getCustomerDefaultPaymentMethod(customerIdOf(sub));
const [action, reason] = decide(sub, affected, defaultPm);
if (action === "skip") continue;
if (action === "needs-attention") {
console.warn(`Subscription ${sub.id}: ${reason}`);
if (!DRY_RUN) await flagNeedsAttention(sub.id, reason);
flagged++;
continue;
}
const newToken = defaultPm.id;
console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would swap" : "swapping"}`);
if (!DRY_RUN) await applySwap(sub.id, newToken, token);
swapped++;
}
console.log(
`Done. ${swapped} subscription(s) ${DRY_RUN ? "to swap" : "swapped"}, ` +
`${flagged} flagged for manual follow up.`
);
}
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 subscription gets swapped onto a different card. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from swap_reissued_card import decide, current_card_token, customer_id_of
AFFECTED = {"pm_old_1", "pm_old_2"}
def sub(status="active", token="pm_old_1", customer="cus_1"):
meta = []
if token:
meta.append({"key": "_stripe_source_id", "value": token})
if customer:
meta.append({"key": "_stripe_customer_id", "value": customer})
return {"id": 42, "status": status, "meta_data": meta}
def pm(pm_id="pm_new_1"):
return {"id": pm_id}
def test_swap_when_on_reissued_range_and_clean_replacement_ready():
assert decide(sub(), AFFECTED, pm("pm_new_1"))[0] == "swap"
def test_skip_when_subscription_not_active():
assert decide(sub(status="cancelled"), AFFECTED, pm("pm_new_1"))[0] == "skip"
def test_skip_when_no_stored_token():
assert decide(sub(token=None), AFFECTED, pm("pm_new_1"))[0] == "skip"
def test_skip_when_token_not_in_affected_range():
assert decide(sub(token="pm_fine_1"), AFFECTED, pm("pm_new_1"))[0] == "skip"
def test_needs_attention_when_no_replacement_on_file():
assert decide(sub(), AFFECTED, None)[0] == "needs-attention"
def test_needs_attention_when_default_is_also_affected():
assert decide(sub(), AFFECTED, pm("pm_old_2"))[0] == "needs-attention"
def test_skip_when_already_on_the_new_token():
assert decide(sub(token="pm_new_1"), AFFECTED, pm("pm_new_1"))[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, currentCardToken, customerIdOf } from "./swap-reissued-card.js";
const AFFECTED = new Set(["pm_old_1", "pm_old_2"]);
function sub({ status = "active", token = "pm_old_1", customer = "cus_1" } = {}) {
const meta = [];
if (token) meta.push({ key: "_stripe_source_id", value: token });
if (customer) meta.push({ key: "_stripe_customer_id", value: customer });
return { id: 42, status, meta_data: meta };
}
const pm = (id = "pm_new_1") => ({ id });
test("swap when on reissued range and clean replacement ready", () => {
assert.equal(decide(sub(), AFFECTED, pm("pm_new_1"))[0], "swap");
});
test("skip when subscription not active", () => {
assert.equal(decide(sub({ status: "cancelled" }), AFFECTED, pm("pm_new_1"))[0], "skip");
});
test("needs-attention when no replacement on file", () => {
assert.equal(decide(sub(), AFFECTED, null)[0], "needs-attention");
});
test("needs-attention when default is also affected", () => {
assert.equal(decide(sub(), AFFECTED, pm("pm_old_2"))[0], "needs-attention");
});
Case studies
The processor breach that reissued four thousand cards
A payment processor disclosed a breach and the issuing banks reissued every card in the affected range the same week. A subscription store found around four hundred active subscriptions on one of those old cards, all set to renew within the next ten days.
Support pulled the reissue notice's list of old payment method ids, ran the job in dry run, and saw that most customers already had a new default card on file from updating it at checkout on another purchase. The real run swapped over three hundred subscriptions in minutes and flagged the rest for outreach before a single renewal failed.
The support team that stopped guessing at declines
A smaller store noticed a cluster of ten renewal declines in one afternoon, all with the same decline code, and started manually calling each customer before anyone realized it was one reissue event rather than ten unrelated problems.
Once they had the old payment method ids from the decline messages, the job found six more subscriptions on the same range that had not failed yet, swapped four of them onto a clean card automatically, and flagged two where the customer had never returned to update their card.
After this runs against a reissue notice, a card range breach becomes a short list of swaps and a short list of flagged follow ups, not a slow trickle of angry tickets over the following month. Keep the reissue list on hand and rerun the job whenever a new notice comes in, since it never touches a subscription that is not on the range you give it.
FAQ
Why does one card breach affect so many WooCommerce subscriptions at once?
A single issuer notice can name thousands of card numbers at once, and every subscription still storing one of those old Stripe payment method ids will decline on its next renewal. Because the cards share one reissue event, the failures land in a cluster instead of trickling in one at a time.
Is it safe to swap a subscription onto a different card automatically?
Yes, when the script only swaps to the customer's current default payment method on Stripe, and that replacement is confirmed to be different from the old card and not itself part of the reissued range. Anything without a clean replacement gets flagged for manual follow up instead of a guess. Start in dry run mode to review the plan first.
What happens to subscriptions where the customer has no new card yet?
The script leaves those subscriptions untouched and adds a note asking for manual follow up. It never invents a payment method or leaves a subscription silently unprotected, so support can reach out to the customer before the next renewal runs.
Related field notes
Citations
On the problem:
- Stripe docs: card issuers can reissue cards after a compromise, and old numbers stop working. docs.stripe.com/issuing/purchases/physical-and-digital-cards
- Stripe docs: automatic card updates through networks and card updater services, and their coverage limits. docs.stripe.com/saved-cards/maintaining-card-freshness
- WooCommerce Subscriptions docs: renewal payments and how retry schedules work after a failed charge. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- Stripe API: retrieve a Customer object and read invoice_settings.default_payment_method. docs.stripe.com/api/customers/retrieve
- Stripe API: retrieve a PaymentMethod by id to confirm what it points to. docs.stripe.com/api/payment_methods/retrieve
- WooCommerce Subscriptions REST API: list and update subscriptions and add subscription notes. woocommerce.github.io/subscriptions-rest-api-docs
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this save your renewals?
If this saved you a pile of support tickets after a card reissue, 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