Repair Account and store migration

Recreate WooCommerce Subscriptions after a Stripe account move

The store moved to a new Stripe account, and now renewals are failing with errors like "No such customer" or "No such payment_method." Nothing is wrong with the subscriptions themselves, they are just pointing at customer and card ids that only ever existed on the old account. Here is why that happens and a small script that finds every affected subscription and recreates it on the new account, one chargeable token at a time.

Python and Node.js Run once per migration Safe by default (dry run)
A wooden block spelling subscribe on a table
Photo by Markus Winkler on Unsplash
The short answer

A Stripe customer id and payment method id are only valid on the Stripe account that created them. Moving to a new Stripe account does not carry saved cards along, so every subscription still pointing at the old ids will fail its next renewal. Run a small Python or Node.js script that checks, for each active or on-hold subscription, whether its customer already has a valid, chargeable payment method on the new account, and if so, re-points the subscription at it. Full code, tests, and a dry run guard are below.

The problem in plain words

A WooCommerce subscription does not store a credit card. It stores two Stripe ids, a customer id and a payment method id (or the older "source" id), and asks Stripe to charge that pair on each renewal date. Those ids are just pointers. The actual card details live inside Stripe, tied to one specific Stripe account.

When a store moves to a new Stripe account, whether from a business merge, a change of payment provider tier, or a new Connect account, the new account starts with no customers and no cards. The old ids saved on every subscription are meaningless there. The next renewal tries to charge a customer id the new account has never heard of, Stripe returns an error, and the subscription either retries and fails again or falls into "on-hold."

Store moves to a new Stripe account Subscription still has old cus_ / pm_ ids unknown on new account Renewal fails No such customer Subscription on-hold
The move happens on the Stripe side. WooCommerce never finds out, so every subscription keeps asking the new account to charge ids it has never seen.

Why it happens

Stripe scopes customers, payment methods, and saved cards to a single account by design. That is a security boundary, not an oversight, so there is no direct way to carry a token from one account to another without the cardholder taking an action or the merchant using Stripe's dedicated migration tooling.

WooCommerce Subscriptions itself has no idea any of this happened. It just keeps firing scheduled renewal orders and asking the gateway to charge the token on file. The gateway is the one reporting the ids do not exist.

The key insight

A subscription is not "broken," it is stale. The fix is never to guess at a new card, it is to check whether the customer already has a valid, chargeable payment method sitting on the new Stripe account, usually because they added a card during a recent manual purchase or a "update your payment method" email, and if so, re-point the subscription at that token. If no token exists yet, the honest answer is that the customer still needs to add a card, and the script should say so rather than pretend to fix it.

The fix, as a flow

We do not touch billing directly and we never invent a card. We walk every active or on-hold subscription, look up the billing email on the new Stripe account, and check for an existing chargeable payment method. When one exists and the subscription is not already pointing at it, we update the subscription's saved Stripe customer and payment method ids and leave a note. When no token exists yet, we flag it so the store can follow up with the customer instead of letting it fail silently on the next renewal.

List active and on-hold subscriptions Look up customer on the new Stripe account Find newest card payment method Chargeable token found? yes no, flag it Needs a new card ask the customer
Only subscriptions whose customer already has a valid, chargeable token on the new account get recreated. Everything else is flagged for a human to follow up on, never guessed at.

Build it step by step

1

Get access to both systems

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

setup (shell)
pip install stripe requests

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

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

List the subscriptions that still bill a customer

Ask the WooCommerce REST API for subscriptions with status active or on-hold. Cancelled or expired subscriptions do not need a token at all, so we leave them alone. We page through results the same way you would page through orders.

step2.py
import requests
from requests.auth import HTTPBasicAuth

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

Look up a chargeable token on the new account

Search the new Stripe account by the customer's billing email. If a matching customer exists there and has at least one card payment method attached, that is the candidate token. A card that failed its CVC check on save is worth treating as not chargeable, since it will likely just fail again.

step3.py
import stripe

def find_new_token(email):
    customers = stripe.Customer.list(email=email, limit=1).data
    if not customers:
        return None
    customer = customers[0]
    methods = stripe.PaymentMethod.list(customer=customer.id, type="card", limit=1).data
    if not methods:
        return None
    pm = methods[0]
    chargeable = pm.card.get("checks", {}).get("cvc_check") != "fail"
    return {"customer_id": customer.id, "payment_method_id": pm.id, "chargeable": chargeable}
step3.js
async function findNewToken(email) {
  const customers = await stripe.customers.list({ email, limit: 1 });
  if (!customers.data.length) return null;
  const customer = customers.data[0];
  const methods = await stripe.paymentMethods.list({ customer: customer.id, type: "card", limit: 1 });
  if (!methods.data.length) return null;
  const pm = methods.data[0];
  const chargeable = pm.card?.checks?.cvc_check !== "fail";
  return { customerId: customer.id, paymentMethodId: pm.id, chargeable };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the subscription's saved ids and the candidate new token, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule: skip anything not active or on-hold, skip anything with no old customer to migrate, flag anything the customer has no usable token for yet, and only recreate when a chargeable token exists and it is not already the one saved.

decide.py
ACTIVE_SUB_STATUSES = {"active", "on-hold"}

def decide(subscription, new_token):
    if subscription["status"] not in ACTIVE_SUB_STATUSES:
        return ("skip", "subscription is not active or on-hold")

    old_customer = subscription.get("_stripe_customer_id")
    old_source = subscription.get("_stripe_source_id")

    if not old_customer:
        return ("skip", "no old Stripe customer recorded, nothing to migrate")

    if new_token is None:
        return ("missing", "no valid payment method on the new Stripe account yet")

    if not new_token.get("chargeable", False):
        return ("missing", "customer has a payment method on the new account but it is not chargeable")

    if new_token.get("customer_id") == old_customer and new_token.get("payment_method_id") == old_source:
        return ("skip", "subscription already points at the current token")

    return ("recreate", "old token is gone, pointing subscription at the new customer and payment method")
decide.js
const ACTIVE_SUB_STATUSES = new Set(["active", "on-hold"]);

export function decide(subscription, newToken) {
  if (!ACTIVE_SUB_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }

  const oldCustomer = subscription._stripe_customer_id;
  const oldSource = subscription._stripe_source_id;

  if (!oldCustomer) {
    return ["skip", "no old Stripe customer recorded, nothing to migrate"];
  }

  if (!newToken) {
    return ["missing", "no valid payment method on the new Stripe account yet"];
  }

  if (!newToken.chargeable) {
    return ["missing", "customer has a payment method on the new account but it is not chargeable"];
  }

  if (newToken.customerId === oldCustomer && newToken.paymentMethodId === oldSource) {
    return ["skip", "subscription already points at the current token"];
  }

  return ["recreate", "old token is gone, pointing subscription at the new customer and payment method"];
}
5

Recreate the subscription with the new token

When the action is recreate, write the new Stripe customer and payment method ids onto the subscription's meta data, the same fields the WooCommerce Stripe gateway reads on renewal, and add a note so the shop manager can see when and why it changed. This never issues a charge itself. The next scheduled renewal is what actually bills the customer, using the token we just attached.

apply.py
def apply_new_token(sub_id, new_token):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
        json={
            "meta_data": [
                {"key": "_stripe_customer_id", "value": new_token["customer_id"]},
                {"key": "_stripe_source_id", "value": new_token["payment_method_id"]},
            ]
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
        json={"note": f"Recreated after the Stripe account move. Now billing "
                      f"{new_token['customer_id']} / {new_token['payment_method_id']} "
                      f"on the new account."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function applyNewToken(subId, newToken) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_stripe_customer_id", value: newToken.customerId },
        { key: "_stripe_source_id", value: newToken.paymentMethodId },
      ],
    }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Recreated after the Stripe account move. Now billing ` +
            `${newToken.customerId} / ${newToken.paymentMethodId} on the new account.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together and separates the subscriptions we can fix from the ones that still need the customer to add a card. Leave DRY_RUN on for the first few runs so the script only reports its plan. Read the output, trust it, then switch it off to let it write. This is a migration job, so run it once by hand right after the Stripe move, then again a few days later to catch anyone who has since added a new card.

Run it safe

Always start with DRY_RUN=true. This script rewrites which Stripe customer and card a subscription bills, so you want to see the exact list before it acts. Once the report looks right, turn it off and run it for real.

The full code

Here is the complete migration script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it skips any subscription already pointing at its current token.

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

recreate_subs.py
"""Recreate WooCommerce Subscriptions renewals after a Stripe account move.

When a store moves to a new Stripe account (a merge, a platform migration, or a
new Connect account), every saved card token that lived on the old account stops
working. WooCommerce Subscriptions still points renewal orders at the old
Stripe customer and payment method id, so the next scheduled renewal fails with
a Stripe error like "No such customer" or "No such payment_method". This script
finds subscriptions still tied to the old Stripe account, and for any customer
who already has a valid, chargeable payment method on the new account, it
re-points the subscription at the new Stripe customer and payment method so the
next renewal can actually be charged. Read-only planning by default. Run once
per migration, or on a schedule until the backlog clears.
"""
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("recreate_subs")

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

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


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def decide(subscription, new_token):
    """Pure decision: what should we do with this subscription's Stripe link.

    subscription: dict with at least "status", "_stripe_customer_id",
        "_stripe_source_id" (the ids saved on the subscription today).
    new_token: dict or None. When present, it is the customer's newest valid,
        chargeable payment method on the NEW Stripe account, shaped like
        {"customer_id": "cus_new...", "payment_method_id": "pm_new...", "chargeable": bool}.

    Returns a (action, reason) tuple. action is one of:
      "skip"     - nothing to do, leave the subscription alone
      "missing"  - subscription is active but the customer has no usable token yet
      "recreate" - re-point the subscription at the new customer/payment method
    """
    if subscription["status"] not in ACTIVE_SUB_STATUSES:
        return ("skip", "subscription is not active or on-hold")

    old_customer = subscription.get("_stripe_customer_id")
    old_source = subscription.get("_stripe_source_id")

    if not old_customer:
        return ("skip", "no old Stripe customer recorded, nothing to migrate")

    if new_token is None:
        return ("missing", "no valid payment method on the new Stripe account yet")

    if not new_token.get("chargeable", False):
        return ("missing", "customer has a payment method on the new account but it is not chargeable")

    if new_token.get("customer_id") == old_customer and new_token.get("payment_method_id") == old_source:
        return ("skip", "subscription already points at the current token")

    return ("recreate", "old token is gone, pointing subscription at the new customer and payment method")


def find_new_token(email):
    """Search the (new, current) Stripe account for a customer by email and
    return their most recently attached chargeable card payment method, or None.
    """
    customers = stripe.Customer.list(email=email, limit=1).data
    if not customers:
        return None
    customer = customers[0]
    methods = stripe.PaymentMethod.list(customer=customer.id, type="card", limit=1).data
    if not methods:
        return None
    pm = methods[0]
    chargeable = pm.card.get("checks", {}).get("cvc_check") != "fail"
    return {"customer_id": customer.id, "payment_method_id": pm.id, "chargeable": chargeable}


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 apply_new_token(sub_id, new_token):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
        json={
            "meta_data": [
                {"key": "_stripe_customer_id", "value": new_token["customer_id"]},
                {"key": "_stripe_source_id", "value": new_token["payment_method_id"]},
            ]
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
        json={"note": f"Recreated after the Stripe account move. Now billing "
                      f"{new_token['customer_id']} / {new_token['payment_method_id']} "
                      f"on the new account."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    recreated = 0
    missing = 0
    for sub in active_subscriptions():
        meta = {m["key"]: m.get("value") for m in sub.get("meta_data") or []}
        subscription = {
            "status": sub["status"],
            "_stripe_customer_id": meta.get("_stripe_customer_id"),
            "_stripe_source_id": meta.get("_stripe_source_id"),
        }
        email = (sub.get("billing") or {}).get("email")
        new_token = find_new_token(email) if email else None
        action, reason = decide(subscription, new_token)

        if action == "skip":
            continue
        if action == "missing":
            log.warning("Subscription %s: %s", sub["id"], reason)
            missing += 1
            continue

        log.info("Subscription %s: %s. %s", sub["id"], reason, "would recreate" if DRY_RUN else "recreating")
        if not DRY_RUN:
            apply_new_token(sub["id"], new_token)
        recreated += 1

    log.info(
        "Done. %d subscription(s) %s. %d still need a new card from the customer.",
        recreated, "to recreate" if DRY_RUN else "recreated", missing,
    )


if __name__ == "__main__":
    run()
recreate-subs.js
/**
 * Recreate WooCommerce Subscriptions renewals after a Stripe account move.
 *
 * When a store moves to a new Stripe account (a merge, a platform migration, or
 * a new Connect account), every saved card token that lived on the old account
 * stops working. WooCommerce Subscriptions still points renewal orders at the
 * old Stripe customer and payment method id, so the next scheduled renewal
 * fails with a Stripe error like "No such customer" or "No such payment_method".
 * This script finds subscriptions still tied to the old Stripe account, and for
 * any customer who already has a valid, chargeable payment method on the new
 * account, it re-points the subscription at the new Stripe customer and payment
 * method so the next renewal can actually be charged. Read-only planning by
 * default. Run once per migration, or on a schedule until the backlog clears.
 *
 * Guide: https://www.allanninal.dev/woocommerce/recreate-subs-after-account-move/
 */
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"]);

/**
 * Pure decision: what should we do with this subscription's Stripe link.
 *
 * subscription: { status, _stripe_customer_id, _stripe_source_id } the ids
 *   saved on the subscription today.
 * newToken: object or null. When present, it is the customer's newest valid,
 *   chargeable payment method on the NEW Stripe account, shaped like
 *   { customerId: "cus_new...", paymentMethodId: "pm_new...", chargeable: bool }.
 *
 * Returns [action, reason]. action is one of "skip", "missing", "recreate".
 */
export function decide(subscription, newToken) {
  if (!ACTIVE_SUB_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }

  const oldCustomer = subscription._stripe_customer_id;
  const oldSource = subscription._stripe_source_id;

  if (!oldCustomer) {
    return ["skip", "no old Stripe customer recorded, nothing to migrate"];
  }

  if (!newToken) {
    return ["missing", "no valid payment method on the new Stripe account yet"];
  }

  if (!newToken.chargeable) {
    return ["missing", "customer has a payment method on the new account but it is not chargeable"];
  }

  if (newToken.customerId === oldCustomer && newToken.paymentMethodId === oldSource) {
    return ["skip", "subscription already points at the current token"];
  }

  return ["recreate", "old token is gone, pointing subscription at the new customer and payment method"];
}

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

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 findNewToken(email) {
  const customers = await stripe.customers.list({ email, limit: 1 });
  if (!customers.data.length) return null;
  const customer = customers.data[0];
  const methods = await stripe.paymentMethods.list({ customer: customer.id, type: "card", limit: 1 });
  if (!methods.data.length) return null;
  const pm = methods.data[0];
  const chargeable = pm.card?.checks?.cvc_check !== "fail";
  return { customerId: customer.id, paymentMethodId: pm.id, chargeable };
}

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

async function applyNewToken(subId, newToken) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_stripe_customer_id", value: newToken.customerId },
        { key: "_stripe_source_id", value: newToken.paymentMethodId },
      ],
    }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Recreated after the Stripe account move. Now billing ` +
            `${newToken.customerId} / ${newToken.paymentMethodId} on the new account.`,
    }),
  });
}

export async function run() {
  let recreated = 0;
  let missing = 0;

  for await (const sub of activeSubscriptions()) {
    const meta = Object.fromEntries((sub.meta_data || []).map((m) => [m.key, m.value]));
    const subscription = {
      status: sub.status,
      _stripe_customer_id: meta._stripe_customer_id,
      _stripe_source_id: meta._stripe_source_id,
    };
    const email = sub.billing?.email;
    const newToken = email ? await findNewToken(email) : null;
    const [action, reason] = decide(subscription, newToken);

    if (action === "skip") continue;
    if (action === "missing") {
      console.warn(`Subscription ${sub.id}: ${reason}`);
      missing++;
      continue;
    }

    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would recreate" : "recreating"}`);
    if (!DRY_RUN) await applyNewToken(sub.id, newToken);
    recreated++;
  }

  console.log(
    `Done. ${recreated} subscription(s) ${DRY_RUN ? "to recreate" : "recreated"}. ` +
    `${missing} still need a new card from the customer.`
  );
}

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 rewritten to bill a different Stripe customer. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_recreate_decide.py
from recreate_subs import decide


def sub(**over):
    base = {"status": "active", "_stripe_customer_id": "cus_old1", "_stripe_source_id": "pm_old1"}
    base.update(over)
    return base


def token(**over):
    base = {"customer_id": "cus_new1", "payment_method_id": "pm_new1", "chargeable": True}
    base.update(over)
    return base


def test_recreate_when_new_token_available():
    assert decide(sub(), token())[0] == "recreate"


def test_skip_when_subscription_not_active():
    assert decide(sub(status="cancelled"), token())[0] == "skip"


def test_skip_when_on_hold_is_still_considered():
    assert decide(sub(status="on-hold"), token())[0] == "recreate"


def test_missing_when_no_new_token_yet():
    assert decide(sub(), None)[0] == "missing"


def test_missing_when_token_not_chargeable():
    assert decide(sub(), token(chargeable=False))[0] == "missing"


def test_skip_when_already_pointing_at_current_token():
    current = sub(_stripe_customer_id="cus_new1", _stripe_source_id="pm_new1")
    assert decide(current, token())[0] == "skip"


def test_skip_when_no_old_customer_recorded():
    assert decide(sub(_stripe_customer_id=None), token())[0] == "skip"
recreate-subs.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./recreate-subs.js";

const sub = (over = {}) => ({
  status: "active",
  _stripe_customer_id: "cus_old1",
  _stripe_source_id: "pm_old1",
  ...over,
});

const token = (over = {}) => ({
  customerId: "cus_new1",
  paymentMethodId: "pm_new1",
  chargeable: true,
  ...over,
});

test("recreate when new token available", () => {
  assert.equal(decide(sub(), token())[0], "recreate");
});

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

test("recreate still considered when on-hold", () => {
  assert.equal(decide(sub({ status: "on-hold" }), token())[0], "recreate");
});

test("missing when no new token yet", () => {
  assert.equal(decide(sub(), null)[0], "missing");
});

test("missing when token not chargeable", () => {
  assert.equal(decide(sub(), token({ chargeable: false }))[0], "missing");
});

test("skip when already pointing at current token", () => {
  const current = sub({ _stripe_customer_id: "cus_new1", _stripe_source_id: "pm_new1" });
  assert.equal(decide(current, token())[0], "skip");
});

test("skip when no old customer recorded", () => {
  assert.equal(decide(sub({ _stripe_customer_id: null }), token())[0], "skip");
});

Case studies

Business merge

Two stores, one new Stripe account

A store merged with a sister brand and both moved onto a single new Stripe account for cleaner reporting. Around six hundred active subscriptions kept their old customer and card ids from two different legacy accounts, and every renewal for the next few days failed with "No such customer."

Running the script in dry run showed that roughly a third of customers had already added a card on the new account during a recent one-time purchase. Those were recreated immediately. The rest were flagged, and the store sent a single "update your payment method" email to that smaller list instead of guessing at all six hundred.

Platform switch

Leaving a marketplace Connect account

A store outgrew a marketplace platform and moved its payments off the platform's Stripe Connect account onto its own standalone Stripe account. The subscriptions plugin had no setting for this, so every renewal after the switch kept hitting the old, now inaccessible Connect account.

The team ran the recreate script daily for two weeks as customers gradually added new cards in response to renewal reminder emails, watching the "still needs a new card" count shrink each day until only a handful of genuinely lapsed customers were left.

What good looks like

After running this, every subscription either bills the correct, chargeable token on the new Stripe account, or it is on a clearly flagged list of customers who still need to add a card. Nobody is left silently retrying a renewal against an account that no longer exists, and nobody gets charged on a guess.

FAQ

Why do subscription renewals fail after a Stripe account move?

A saved card token only exists on the Stripe account it was created on. When a store moves to a new Stripe account, the customer and payment method ids saved on each subscription still point at the old account, so the new account has never seen them. The next renewal charge fails with an error like No such customer or No such payment_method.

Can I just copy the old customer and card ids into the new Stripe account?

No. Stripe customer and payment method ids are only valid on the account that created them, and raw card numbers are never available to copy. The customer has to add a card on the new account, or you use Stripe's own account migration tooling, before a subscription can be recreated against it.

Will this script charge customers automatically?

No. It only re-points a subscription at a payment method that is already saved and chargeable on the new Stripe account. It never creates a charge itself. The next scheduled renewal is what actually bills the customer, using the token this script attached.

Related field notes

Citations

On the problem:

  1. Stripe docs: customers, payment methods, and cards are scoped to a single Stripe account. docs.stripe.com/api/customers
  2. WooCommerce Subscriptions docs: how renewal payments are processed against the saved payment token. woocommerce.com/document/subscriptions/renewal-process
  3. Stripe error reference: No such customer and No such payment_method, and what causes them. docs.stripe.com/error-codes

On the solution:

  1. Stripe docs: migrating cardholders between Stripe accounts and options for account level migration. docs.stripe.com/get-started/data-migrations/account-migrations
  2. Stripe API: list payment methods for a customer and read card verification checks. docs.stripe.com/api/payment_methods/list
  3. WooCommerce REST API: update a subscription and add an order 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.

Contact me on LinkedIn

Did this fix your subscriptions?

If this saved you from a pile of failed renewals or a wave of angry cancellation emails, 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