Diagnostic WooCommerce Subscriptions: switches, coupons, and data
Orphaned subscriptions with no customer
Somewhere in your subscriptions list there are rows still marked Active, still renewing, still taking payment from a saved card, that belong to no one. The customer account behind them is gone, or was never really there, and customer_id just reads 0. Stripe does not care and keeps billing the card every cycle. Nobody in your account area can see the order, and no email reaches a real inbox when something goes wrong. Here is why a subscription loses its owner and a small script that finds every orphan, reattaches the ones it safely can, and flags the rest for a human.
A WooCommerce Subscription keeps its own customer_id. When that value is 0, or points at a WordPress user that was deleted, the subscription has no owner even though Stripe is still charging the saved card behind it every cycle. Run a small Python or Node.js script that walks active-like subscriptions, checks whether customer_id still resolves to a real user, and either reattaches the subscription to the WooCommerce user named in the Stripe PaymentIntent's metadata, or flags it for a human when Stripe has no usable owner either. Full code, tests, and a dry run guard are below.
The problem in plain words
Every WooCommerce Subscription points at a WordPress user through a field called customer_id. That one number is how the subscription shows up in someone's My Account page, how renewal emails get addressed, and how a shop manager finds "all of this person's subscriptions" in one place.
Delete that user, and the subscription itself does not go anywhere. It keeps its status, its next payment date, and its saved Stripe PaymentIntent. It just stops belonging to anyone. Stripe has no idea the WooCommerce side lost track of the owner, so the card on file keeps getting charged on schedule, renewal after renewal, with the money landing in an order that nobody's account page will ever show.
Why it happens
The link between a subscription and its owner is a single field, so a handful of everyday events can quietly clear it:
- A WordPress user is deleted, whether by a shop manager clearing spam accounts, a GDPR erasure request, or a plugin that removes inactive accounts, and the subscriptions that user owned are never reassigned or cancelled to match.
- The checkout flow creates the subscription before the account step finishes, and a timeout, a plugin conflict, or a browser tab closed mid-signup leaves the subscription committed with
customer_idstill at 0. - A staging to production sync, or a restore from an older backup, brings back subscription post types and their meta but the user table does not match, so
customer_idnow points at a user id that belongs to someone else or nobody at all. - Two customer accounts get merged during support cleanup, and the merge tool moves the orders but misses the subscriptions, leaving them attached to the account that is about to be deleted.
None of this stops the renewal. WooCommerce Subscriptions schedules the next payment from data stored on the subscription itself, and the WooCommerce Stripe gateway charges the saved PaymentIntent or off-session payment method the same way regardless of whether a human owns the record. The only sign something is wrong is a subscription that shows up in an admin export with an empty customer column, or a support message from someone who says they were charged but see nothing in their account.
Stripe's PaymentIntent metadata is often the last reliable record of who this subscription was really for, even after the WooCommerce side has forgotten. If Stripe's metadata.woo_customer_id still names a WooCommerce user that exists, that is a safe reattachment. If it does not, guessing is worse than leaving the subscription flagged for a person to look at.
The fix, as a flow
We do not touch the renewal schedule or the checkout flow. We add a job that walks recent subscriptions, and for any one that is active-like and whose customer_id does not resolve to a real WooCommerce user, checks the saved Stripe PaymentIntent for an owner. If Stripe still names a real user, we reattach the subscription to that user. If Stripe has nothing usable either, we flag the subscription with a note so a shop manager can decide what to do by hand.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions and customers. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DRY_RUN="true" // start safe, change to false to write
List subscriptions from the lookback window
Ask the WooCommerce REST API for subscriptions created within your lookback window, and page through all of them. There is no need to scan the whole history every run, since an orphan from years ago can wait one more pass while you clear the recent backlog first.
import os, datetime, 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 list_subscriptions(lookback_days):
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for subscription in batch:
yield subscription
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* listSubscriptions(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?after=${after}&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
Check whether the customer still exists, and ask Stripe who else might
Read the saved Stripe PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent id. If the current customer_id does not resolve to a real WooCommerce user, retrieve that PaymentIntent from Stripe and read metadata.woo_customer_id, the WooCommerce user id the gateway wrote down when the charge was first made.
import stripe
def intent_id_of(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = subscription.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def woo_user_exists(customer_id):
if not customer_id:
return False
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return False
r.raise_for_status()
return True
def stripe_owner_of(subscription):
intent_id = intent_id_of(subscription)
if not intent_id:
return None
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
return (intent.get("metadata") or {}).get("woo_customer_id") or None
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
function intentIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = subscription.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function wooUserExists(customerId) {
if (!customerId) return false;
const user = await woo(`/customers/${customerId}`);
return Boolean(user);
}
async function stripeOwnerOf(subscription) {
const intentId = intentIdOf(subscription);
if (!intentId) return null;
let intent;
try {
intent = await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
return (intent.metadata || {}).woo_customer_id || null;
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription, whether its current customer_id resolves to a real user, and the owner id Stripe metadata names, if any. A pure function like this is easy to read and easy to test. Cancelled and pending subscriptions are skipped entirely, since a missing owner there is not worth acting on. A working customer_id means nothing to do. Otherwise, reattach when Stripe names a real user, or flag when it does not.
ACTIVE_LIKE_STATUSES = {"active", "on-hold", "pending-cancel"}
def decide(subscription, woo_user_exists, stripe_owner_id):
status = subscription.get("status")
if status not in ACTIVE_LIKE_STATUSES:
return ("skip", "subscription is not in an active-like status")
customer_id = subscription.get("customer_id") or 0
if customer_id and woo_user_exists:
return ("ok", "subscription has a real WooCommerce customer")
if stripe_owner_id:
return ("reattach", "Stripe metadata names a WooCommerce user that still exists")
return ("orphan", "no WooCommerce customer, and Stripe has no owner to reattach to")
const ACTIVE_LIKE_STATUSES = new Set(["active", "on-hold", "pending-cancel"]);
export function decide(subscription, wooUserExists, stripeOwnerId) {
const status = subscription.status;
if (!ACTIVE_LIKE_STATUSES.has(status)) {
return ["skip", "subscription is not in an active-like status"];
}
const customerId = subscription.customer_id || 0;
if (customerId && wooUserExists) {
return ["ok", "subscription has a real WooCommerce customer"];
}
if (stripeOwnerId) {
return ["reattach", "Stripe metadata names a WooCommerce user that still exists"];
}
return ["orphan", "no WooCommerce customer, and Stripe has no owner to reattach to"];
}
Reattach the owner, or flag it for a human
When the action is reattach, set customer_id to the id Stripe named and add a subscription note explaining why. When the action is orphan, add a note asking a shop manager to review it by hand rather than guessing at an owner. Both go through the REST API, so the change shows up in the admin the same as any manual edit would.
def reattach(subscription_id, customer_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"customer_id": int(customer_id)},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Reattached to WooCommerce customer {customer_id} using the owner "
f"named in Stripe PaymentIntent metadata. Fixed by the orphan reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag(subscription_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Orphan check failed: {reason}. This subscription has no WooCommerce "
f"customer attached and Stripe has no owner to reattach it to. Please review."},
auth=AUTH, timeout=30,
).raise_for_status()
async function reattach(subscriptionId, customerId) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ customer_id: Number(customerId) }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reattached to WooCommerce customer ${customerId} using the owner named in ` +
`Stripe PaymentIntent metadata. Fixed by the orphan reconciler.`,
}),
});
}
async function flag(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Orphan check failed: ${reason}. This subscription has no WooCommerce customer ` +
`attached and Stripe has no owner to reattach it to. Please review.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Leave DRY_RUN on for the first few runs so the script only reports what it would reattach or flag. Once the report looks right for a few days, turn it off. Run it daily or weekly with cron, since this problem builds up slowly rather than in a burst.
Always start with DRY_RUN=true. Reattaching an owner is easy to reverse, but it still changes who can see and manage a subscription. Read a few runs of the report before you let the script write anything.
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 never guesses an owner that Stripe's own metadata does not name.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find WooCommerce Subscriptions with no customer attached, and flag or
repair the ones that are genuinely orphaned.
A subscription is supposed to belong to a WordPress user, stored as
`customer_id` on the subscription. A deleted account, a GDPR erasure
request, a failed account step during signup, or a bad import can leave a
subscription with `customer_id` set to 0 while Stripe is still billing the
saved card behind it every cycle. Nobody notices, because the renewal still
succeeds. This walks recent subscriptions, decides what is wrong with a
pure function, and either reports it (dry run) or repairs it: reattach the
subscription to the WooCommerce user Stripe metadata already names, or flag
it for a human when no such user can be found. Safe 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_orphaned_subscriptions")
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ACTIVE_LIKE_STATUSES = {"active", "on-hold", "pending-cancel"}
def intent_id_of(subscription):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or
transaction_id. Either can be used to look up the Stripe side and find
who Stripe thinks this billing relationship belongs to, via
metadata.woo_customer_id on the PaymentIntent.
"""
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = subscription.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def decide(subscription, woo_user_exists, stripe_owner_id):
"""Pure decision function. No I/O, no Stripe or WooCommerce calls inside.
subscription: a dict shaped like a WooCommerce Subscription record.
woo_user_exists: True if subscription["customer_id"] still points at a
real WooCommerce user, False otherwise.
stripe_owner_id: the WooCommerce user id named in the Stripe
PaymentIntent metadata.woo_customer_id, or None if
Stripe has no such metadata or the PaymentIntent
could not be found.
Returns a (action, reason) tuple. Actions:
"ok" customer_id is set and that user still exists. Nothing to do.
"reattach" customer_id is 0 or points at a deleted user, but Stripe
metadata names a WooCommerce user that still exists.
Point the subscription at that user.
"orphan" customer_id is 0 or points at a deleted user, and Stripe
has no usable owner to reattach to. Flag for a human.
"skip" the subscription is not in a state worth checking, for
example it is already cancelled or a draft.
"""
status = subscription.get("status")
if status not in ACTIVE_LIKE_STATUSES:
return ("skip", "subscription is not in an active-like status")
customer_id = subscription.get("customer_id") or 0
if customer_id and woo_user_exists:
return ("ok", "subscription has a real WooCommerce customer")
if stripe_owner_id:
return ("reattach", "Stripe metadata names a WooCommerce user that still exists")
return ("orphan", "no WooCommerce customer, and Stripe has no owner to reattach to")
def list_subscriptions(lookback_days):
"""WooCommerce Subscriptions created in the lookback window, paged."""
import datetime
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for subscription in batch:
yield subscription
page += 1
def woo_user_exists(customer_id):
"""True if this WooCommerce customer id still resolves to a real user."""
if not customer_id:
return False
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return False
r.raise_for_status()
return True
def stripe_owner_of(subscription):
"""The WooCommerce user id Stripe metadata names for this subscription's
PaymentIntent, or None if there is nothing usable to reattach to.
"""
intent_id = intent_id_of(subscription)
if not intent_id:
return None
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
owner_id = (intent.get("metadata") or {}).get("woo_customer_id")
return owner_id if owner_id else None
def reattach(subscription_id, customer_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"customer_id": int(customer_id)},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Reattached to WooCommerce customer {customer_id} using the owner "
f"named in Stripe PaymentIntent metadata. Fixed by the orphan reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag(subscription_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Orphan check failed: {reason}. This subscription has no WooCommerce "
f"customer attached and Stripe has no owner to reattach it to. Please review."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
reattached = 0
flagged = 0
for subscription in list_subscriptions(LOOKBACK_DAYS):
customer_id = subscription.get("customer_id") or 0
exists = woo_user_exists(customer_id)
owner_id = None if (customer_id and exists) else stripe_owner_of(subscription)
action, reason = decide(subscription, exists, owner_id)
if action in ("ok", "skip"):
continue
sub_id = subscription["id"]
if action == "reattach":
log.info("Subscription %s: %s. %s", sub_id, reason, "would reattach" if DRY_RUN else "reattaching")
if not DRY_RUN:
reattach(sub_id, owner_id)
reattached += 1
continue
log.warning("Subscription %s: %s. %s", sub_id, reason, "would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
flag(sub_id, reason)
flagged += 1
log.info("Done. %d reattached, %d flagged for review.", reattached, flagged)
if __name__ == "__main__":
run()
/**
* Find WooCommerce Subscriptions with no customer attached, and flag or
* repair the ones that are genuinely orphaned.
*
* A subscription is supposed to belong to a WordPress user, stored as
* `customer_id` on the subscription. A deleted account, a GDPR erasure
* request, a failed account step during signup, or a bad import can leave a
* subscription with `customer_id` set to 0 while Stripe is still billing
* the saved card behind it every cycle. Nobody notices, because the
* renewal still succeeds. This walks recent subscriptions, decides what is
* wrong with a pure function, and either reports it (dry run) or repairs
* it: reattach the subscription to the WooCommerce user Stripe metadata
* already names, or flag it for a human when no such user can be found.
* Safe by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/orphaned-subscriptions-with-no-customer/
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_LIKE_STATUSES = new Set(["active", "on-hold", "pending-cancel"]);
export function intentIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = subscription.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function decide(subscription, wooUserExists, stripeOwnerId) {
const status = subscription.status;
if (!ACTIVE_LIKE_STATUSES.has(status)) {
return ["skip", "subscription is not in an active-like status"];
}
const customerId = subscription.customer_id || 0;
if (customerId && wooUserExists) {
return ["ok", "subscription has a real WooCommerce customer"];
}
if (stripeOwnerId) {
return ["reattach", "Stripe metadata names a WooCommerce user that still exists"];
}
return ["orphan", "no WooCommerce customer, and Stripe has no owner to reattach to"];
}
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* listSubscriptions(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?after=${after}&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
async function wooUserExists(customerId) {
if (!customerId) return false;
const user = await woo(`/customers/${customerId}`);
return Boolean(user);
}
async function stripeOwnerOf(subscription) {
const intentId = intentIdOf(subscription);
if (!intentId) return null;
let intent;
try {
intent = await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
const ownerId = (intent.metadata || {}).woo_customer_id;
return ownerId || null;
}
async function reattach(subscriptionId, customerId) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ customer_id: Number(customerId) }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reattached to WooCommerce customer ${customerId} using the owner named in ` +
`Stripe PaymentIntent metadata. Fixed by the orphan reconciler.`,
}),
});
}
async function flag(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Orphan check failed: ${reason}. This subscription has no WooCommerce customer ` +
`attached and Stripe has no owner to reattach it to. Please review.`,
}),
});
}
export async function run() {
let reattached = 0;
let flagged = 0;
for await (const subscription of listSubscriptions(LOOKBACK_DAYS)) {
const customerId = subscription.customer_id || 0;
const exists = await wooUserExists(customerId);
const ownerId = customerId && exists ? null : await stripeOwnerOf(subscription);
const [action, reason] = decide(subscription, exists, ownerId);
if (action === "ok" || action === "skip") continue;
const subId = subscription.id;
if (action === "reattach") {
console.log(`Subscription ${subId}: ${reason}. ${DRY_RUN ? "would reattach" : "reattaching"}`);
if (!DRY_RUN) await reattach(subId, ownerId);
reattached++;
continue;
}
console.warn(`Subscription ${subId}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) await flag(subId, reason);
flagged++;
}
console.log(`Done. ${reattached} reattached, ${flagged} flagged for review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a subscription's ownership gets changed automatically. 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 find_orphaned_subscriptions import decide
def subscription(**over):
base = {"id": 501, "status": "active", "customer_id": 42}
base.update(over)
return base
def test_ok_when_customer_id_set_and_user_exists():
action, _ = decide(subscription(), True, None)
assert action == "ok"
def test_reattach_when_customer_id_zero_but_stripe_names_owner():
action, _ = decide(subscription(customer_id=0), False, 77)
assert action == "reattach"
def test_reattach_when_customer_id_points_at_deleted_user():
action, _ = decide(subscription(customer_id=42), False, 77)
assert action == "reattach"
def test_orphan_when_no_customer_and_no_stripe_owner():
action, _ = decide(subscription(customer_id=0), False, None)
assert action == "orphan"
def test_skip_when_status_is_cancelled():
action, _ = decide(subscription(status="cancelled", customer_id=0), False, None)
assert action == "skip"
def test_skip_when_status_is_pending():
action, _ = decide(subscription(status="pending", customer_id=0), False, None)
assert action == "skip"
def test_ok_takes_priority_even_with_a_stripe_owner_present():
# A healthy subscription should never be touched, even if a stray
# metadata value happens to be present.
action, _ = decide(subscription(), True, 99)
assert action == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./find-orphaned-subscriptions.js";
const subscription = (over = {}) => ({ id: 501, status: "active", customer_id: 42, ...over });
test("ok when customer_id set and user exists", () => {
assert.equal(decide(subscription(), true, null)[0], "ok");
});
test("reattach when customer_id is zero but stripe names an owner", () => {
assert.equal(decide(subscription({ customer_id: 0 }), false, 77)[0], "reattach");
});
test("reattach when customer_id points at a deleted user", () => {
assert.equal(decide(subscription({ customer_id: 42 }), false, 77)[0], "reattach");
});
test("orphan when no customer and no stripe owner", () => {
assert.equal(decide(subscription({ customer_id: 0 }), false, null)[0], "orphan");
});
test("skip when status is cancelled", () => {
assert.equal(decide(subscription({ status: "cancelled", customer_id: 0 }), false, null)[0], "skip");
});
test("skip when status is pending", () => {
assert.equal(decide(subscription({ status: "pending", customer_id: 0 }), false, null)[0], "skip");
});
test("ok takes priority even with a stray stripe owner present", () => {
assert.equal(decide(subscription(), true, 99)[0], "ok");
});
test("intentIdOf from meta", () => {
assert.equal(
intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }),
"pi_123"
);
});
test("intentIdOf falls back to transaction_id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});
test("intentIdOf null when transaction is a charge", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "ch_789" }), null);
});
Case studies
The erasure request that forgot the subscription
A customer asked to have their account erased under GDPR. The plugin handling the request deleted the WordPress user cleanly, but the active subscription that user owned was never cancelled or reassigned, and it kept renewing against the saved card on file with customer_id now at 0.
The script found the orphan on its next scheduled run. Stripe's PaymentIntent metadata no longer named a usable owner either, since the erasure had scrubbed it, so the subscription was flagged and a shop manager manually cancelled it and refunded the one renewal that had gone through unseen.
The restore that shuffled the user ids
After restoring a staging copy from a backup taken weeks earlier, a store noticed several subscriptions pointing at customer_id values that now belonged to entirely different people, since the user table had been recreated with new ids in a different order.
Running the script in dry run mode showed exactly which subscriptions had a mismatched owner. Since Stripe's own metadata still named the correct WooCommerce user for each one by the id set at checkout time, turning off dry run reattached every one automatically, and the mismatched links were gone within a single run.
After this runs on a schedule, a subscription that loses its owner gets caught within a day or a week instead of surfacing months later as an unexplained charge on someone's statement. Genuinely unclaimed subscriptions stop quietly renewing in the dark, and nothing gets reattached unless Stripe's own metadata backs it up.
FAQ
How does a WooCommerce subscription end up with no customer attached?
The subscription's customer_id is set to 0 or points at a WordPress user that no longer exists. This happens after an account is deleted, a GDPR erasure request is processed, a signup flow fails partway through, or a database import drops the link. Stripe keeps billing the saved card either way, since the renewal does not depend on the WooCommerce user existing.
Is it safe to reattach a subscription to a different customer automatically?
Yes, when the script only reattaches to a WooCommerce user that Stripe's own PaymentIntent metadata already names, and that user still exists. If no such owner can be found, the script flags the subscription for a human instead of guessing.
How often should I check for orphaned subscriptions?
Daily or weekly is enough for most stores, since this problem builds up slowly from account deletions and imports rather than in a sudden burst. It only ever touches subscriptions with a broken owner, so running it often is safe and cheap.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions developer docs: how a subscription stores its customer_id and related order data. woocommerce.com/document/subscriptions/develop/functions
- WordPress developer docs: deleting a user does not delete data your plugins stored elsewhere, such as a subscription post. developer.wordpress.org/reference/functions/wp_delete_user
- WooCommerce Subscriptions REST API reference: the subscription resource and its customer_id field. woocommerce.github.io/subscriptions-rest-api-docs
On the solution:
- Stripe API: retrieve a PaymentIntent and read the metadata your integration wrote onto it. docs.stripe.com/api/payment_intents/retrieve
- Stripe docs: best practices for storing your own reference ids in metadata. docs.stripe.com/metadata
- WooCommerce REST API: update a subscription and add a subscription note. woocommerce.github.io/woocommerce-rest-api-docs
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch some orphans of your own?
If this found a subscription billing away with nobody attached to it, 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