Repair Sources to PaymentMethods and SCA

Move sources to payment methods

A shopper saved their card years ago, and Stripe stored it as an old style Source token that starts with src_. That was fine back then. It is not fine now. Stripe is retiring Sources for saved cards, and a Source was never built to carry a shopper through Strong Customer Authentication (SCA) on a later off-session charge. Here is why that quietly breaks renewals and repeat purchases, and a small script that finds every saved Source and moves it to a PaymentMethod in bulk, safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
A close up of a circuit board
Photo by Anne Nygard on Unsplash
The short answer

A saved Stripe Source (a token like src_1AbCdEfGh) is not the same thing as a PaymentMethod (a token like pm_1XyZ), and only PaymentMethods reliably support Strong Customer Authentication for off-session charges. Run a small Python or Node.js script on a schedule that reads the saved token from WooCommerce order meta, checks whether the underlying Stripe Source is still a chargeable card, and if so wraps it in a new PaymentMethod, attaches it to the Stripe Customer, and re-links the order. Anything it is not sure about gets flagged instead of guessed at. Full code, tests, and a dry run guard are below.

The problem in plain words

Years ago, Stripe saved a card as a Source object. That token sat quietly in WooCommerce order and customer meta, doing nothing until the next renewal or repeat purchase tried to charge it off-session. Sources were built for a simpler era of card payments, before European banks required Strong Customer Authentication on almost everything.

Stripe has been moving the whole platform toward PaymentMethods, which carry more of the metadata a bank needs to approve an off-session charge without asking the shopper to step through a browser challenge. A lingering Source token does not carry that metadata the same way, so charges against it become more likely to need extra authentication, or to be declined outright, exactly when nobody is watching, at 3 a.m. on a subscription renewal.

Card saved in 2019 as src_... Renewal tries to charge off-session SCA cannot complete Charge declined authentication_required Renewal fails shopper notified late
The Source token was fine at checkout in 2019. It becomes a problem the moment a later off-session charge needs to prove itself to a bank.

Why it happens

Stripe's own migration guidance is direct about this: stores still relying on Sources for saved, reusable cards need to move those tokens to PaymentMethods, because Sources are being wound down for that use case. A few reasons this keeps surfacing on older WooCommerce stores:

None of this is a WooCommerce bug on its own. It is a slow moving deprecation that has been documented by Stripe for a while, and it quietly bites the stores that have the oldest customer base and the most saved cards.

The key insight

You do not need the shopper in the room to fix this. A legacy card Source can be wrapped in a brand new PaymentMethod using the same underlying card, with no new card entry required, as long as Stripe still reports the Source as chargeable. That one API call is the whole migration for the common case. Anything Stripe cannot vouch for gets flagged for a real re-entry instead of a silent guess.

The fix, as a flow

We do not touch checkout. We add a job that runs on a schedule, walks recent orders, and reads the saved token from meta. If the token is already a PaymentMethod, it is left alone. If it is a legacy Source, we ask Stripe whether that Source is still a chargeable card. When it is, we wrap it as a PaymentMethod, attach it to the customer, and re-link the order to the new token. When it is not, we leave a note asking the shopper to re-enter their card.

Scheduled job reads saved token Is it a legacy card Source (src_)? Ask Stripe if it is still chargeable Chargeable card? yes no, flag it Wrap as PaymentMethod attach + re-link order
Only legacy card Sources that Stripe still reports as chargeable get migrated automatically. Everything else is flagged for the shopper to review.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. 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_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="60"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

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

Read the saved token off the order

The WooCommerce Stripe plugin writes the saved payment token into order meta under _stripe_intent_id, and older orders sometimes only have it in transaction_id. Either one might hold a Source id (src_...) or a PaymentMethod id (pm_...). We only care about the Source case.

step2.py
LEGACY_SOURCE_PREFIX = "src_"
PAYMENT_METHOD_PREFIX = "pm_"

def token_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid or None

def is_legacy_source(token):
    return bool(token) and token.startswith(LEGACY_SOURCE_PREFIX)

def is_already_payment_method(token):
    return bool(token) and token.startswith(PAYMENT_METHOD_PREFIX)
step2.js
const LEGACY_SOURCE_PREFIX = "src_";
const PAYMENT_METHOD_PREFIX = "pm_";

export function tokenOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  return order.transaction_id || null;
}

export function isLegacySource(token) {
  return Boolean(token) && token.startsWith(LEGACY_SOURCE_PREFIX);
}

export function isAlreadyPaymentMethod(token) {
  return Boolean(token) && token.startsWith(PAYMENT_METHOD_PREFIX);
}
3

Ask Stripe what state the Source is in

A Source object reports a type (we only auto-migrate card) and a status. A healthy saved card Source is usually chargeable or has moved to consumed after being used once, both of which are still fine to wrap. Anything else, canceled, failed, or a type we cannot safely reinterpret, needs a human.

step3.py
import stripe

def get_source(source_id):
    if not source_id:
        return None
    try:
        return stripe.Source.retrieve(source_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
async function getSource(sourceId) {
  if (!sourceId) return null;
  try {
    return await stripe.sources.retrieve(sourceId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order, its saved token, and the Stripe Source (or null) and returns an action. This is the part that decides whether a real saved card gets touched, so it is worth testing on its own with no network involved.

decide.py
RELEVANT_STATUSES = {"pending", "on-hold", "processing", "completed", "failed"}
OK_SOURCE_STATUSES = {"chargeable", "consumed"}

def decide(order, token, source):
    if order["status"] not in RELEVANT_STATUSES:
        return ("skip", "order status is not one we track saved cards for")
    if is_already_payment_method(token):
        return ("skip", "already a PaymentMethod")
    if not is_legacy_source(token):
        return ("skip", "no legacy Source saved on this order")
    if source is None:
        return ("flag", "Source could not be retrieved from Stripe")
    if source.get("type") != "card":
        return ("flag", "Source is not a card, cannot auto-migrate this type")
    if source.get("status") not in OK_SOURCE_STATUSES:
        return ("flag", "Source is no longer chargeable, shopper must re-enter their card")
    return ("migrate", "legacy card Source in good standing, safe to wrap as a PaymentMethod")
decide.js
const RELEVANT_STATUSES = new Set(["pending", "on-hold", "processing", "completed", "failed"]);
const OK_SOURCE_STATUSES = new Set(["chargeable", "consumed"]);

export function decide(order, token, source) {
  if (!RELEVANT_STATUSES.has(order.status)) {
    return ["skip", "order status is not one we track saved cards for"];
  }
  if (isAlreadyPaymentMethod(token)) return ["skip", "already a PaymentMethod"];
  if (!isLegacySource(token)) return ["skip", "no legacy Source saved on this order"];
  if (!source) return ["flag", "Source could not be retrieved from Stripe"];
  if (source.type !== "card") return ["flag", "Source is not a card, cannot auto-migrate this type"];
  if (!OK_SOURCE_STATUSES.has(source.status)) {
    return ["flag", "Source is no longer chargeable, shopper must re-enter their card"];
  }
  return ["migrate", "legacy card Source in good standing, safe to wrap as a PaymentMethod"];
}
5

Wrap the Source as a PaymentMethod and re-link the order

When the action is migrate, create a new PaymentMethod from the Source's own token, attach it to the Stripe Customer, then write the new pm_... id back onto the order's meta and drop a note explaining what happened. No new card entry from the shopper is needed for this path.

apply.py
def create_payment_method_from_source(source_id, customer_id):
    payment_method = stripe.PaymentMethod.create(type="card", card={"token": source_id})
    if customer_id:
        stripe.PaymentMethod.attach(payment_method.id, customer=customer_id)
    return payment_method.id

def migrate(order, new_pm_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"meta_data": [
            {"key": "_stripe_intent_id", "value": new_pm_id},
            {"key": "_stripe_source_id", "value": new_pm_id},
        ]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Migrated the saved Stripe Source to PaymentMethod {new_pm_id}. "
                      f"Future off-session charges can now go through SCA."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function createPaymentMethodFromSource(sourceId, customerId) {
  const paymentMethod = await stripe.paymentMethods.create({ type: "card", card: { token: sourceId } });
  if (customerId) {
    await stripe.paymentMethods.attach(paymentMethod.id, { customer: customerId });
  }
  return paymentMethod.id;
}

async function migrate(order, newPmId) {
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_stripe_intent_id", value: newPmId },
        { key: "_stripe_source_id", value: newPmId },
      ],
    }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Migrated the saved Stripe Source to PaymentMethod ${newPmId}. ` +
            `Future off-session charges can now go through SCA.`,
    }),
  });
}
6

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 its plan. Read the output, trust it, then switch it off to let it write. Run it on a schedule, once a day is plenty, since these tokens do not change quickly.

Run it safe

Always start with DRY_RUN=true. This script creates new PaymentMethods and rewrites saved order meta, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete 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 order whose token is already a PaymentMethod.

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

migrate_sources_to_pm.py
"""Move legacy Stripe card Sources saved on WooCommerce customers to reusable
PaymentMethods, so future off-session charges can go through Strong Customer
Authentication (SCA) instead of being declined.

Stripe is retiring the old Sources API for saved cards. A `src_...` token that
was fine for a one-off checkout years ago cannot carry a customer through 3D
Secure on a later off-session renewal or repeat purchase. This walks recent
orders, reads the saved token from order meta `_stripe_intent_id` (falling
back to `transaction_id`), and for any legacy card Source still in good
standing, wraps it in a new PaymentMethod, attaches it to the Stripe Customer,
and re-links the order (and the customer's default token) to the new
`pm_...` id. Orders whose Source cannot be migrated (wrong type, or no longer
chargeable) are flagged instead so the shopper can re-enter their card. Safe
by default (DRY_RUN=true). 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("migrate_sources_to_pm")

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

RELEVANT_STATUSES = {"pending", "on-hold", "processing", "completed", "failed"}
LEGACY_SOURCE_PREFIX = "src_"
PAYMENT_METHOD_PREFIX = "pm_"
OK_SOURCE_STATUSES = {"chargeable", "consumed"}


def token_of(order):
    """The saved Stripe token for this order, 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 or None


def is_legacy_source(token):
    return bool(token) and token.startswith(LEGACY_SOURCE_PREFIX)


def is_already_payment_method(token):
    return bool(token) and token.startswith(PAYMENT_METHOD_PREFIX)


def decide(order, token, source):
    """Pure decision: what should we do about this order's saved payment token.

    order: dict with at least "status" and "id".
    token: the saved Stripe token string, or None.
    source: a dict-like Stripe Source object (with "type" and "status"), or None
            when the token is not a legacy Source (already a PaymentMethod, or missing).

    Returns a tuple (action, reason) where action is one of:
      "skip"    - nothing to do (already a PaymentMethod, no token, or order not relevant)
      "migrate" - a legacy card Source in good standing, wrap it as a PaymentMethod
      "flag"    - a legacy Source we cannot safely auto-migrate
    """
    if order["status"] not in RELEVANT_STATUSES:
        return ("skip", "order status is not one we track saved cards for")
    if is_already_payment_method(token):
        return ("skip", "already a PaymentMethod")
    if not is_legacy_source(token):
        return ("skip", "no legacy Source saved on this order")
    if source is None:
        return ("flag", "Source could not be retrieved from Stripe")
    if source.get("type") != "card":
        return ("flag", "Source is not a card, cannot auto-migrate this type")
    if source.get("status") not in OK_SOURCE_STATUSES:
        return ("flag", "Source is no longer chargeable, shopper must re-enter their card")
    return ("migrate", "legacy card Source in good standing, safe to wrap as a PaymentMethod")


def get_source(source_id):
    if not source_id:
        return None
    try:
        return stripe.Source.retrieve(source_id)
    except stripe.error.InvalidRequestError:
        return None


def customer_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id" and meta.get("value"):
            return meta["value"]
    return None


def tracked_orders():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={
                "status": "pending,on-hold,processing,completed,failed",
                "after": after,
                "per_page": 50,
                "page": page,
            },
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def create_payment_method_from_source(source_id, customer_id):
    """Wrap a legacy card Source token in a reusable PaymentMethod and attach it."""
    payment_method = stripe.PaymentMethod.create(type="card", card={"token": source_id})
    if customer_id:
        stripe.PaymentMethod.attach(payment_method.id, customer=customer_id)
    return payment_method.id


def migrate(order, new_pm_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={
            "meta_data": [
                {"key": "_stripe_intent_id", "value": new_pm_id},
                {"key": "_stripe_source_id", "value": new_pm_id},
            ]
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={
            "note": (
                f"Migrated the saved Stripe Source to PaymentMethod {new_pm_id}. "
                "Future off-session charges on this order's saved card can now "
                "go through Strong Customer Authentication (SCA)."
            )
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()


def flag(order, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={
            "note": (
                f"Stripe Source migration check failed: {reason}. This order's saved "
                "card is a legacy Stripe Source that could not be automatically moved "
                "to a PaymentMethod. The shopper should re-enter their card on the "
                "account or my account page before the next charge."
            )
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    migrated = 0
    flagged = 0
    for order in tracked_orders():
        token = token_of(order)
        source = get_source(token) if is_legacy_source(token) else None
        action, reason = decide(order, token, source)
        if action == "skip":
            continue
        log.info(
            "Order %s: %s. %s",
            order["id"], reason,
            "would " + action if DRY_RUN else action + "ing",
        )
        if action == "migrate":
            if not DRY_RUN:
                customer_id = customer_id_of(order)
                new_pm_id = create_payment_method_from_source(token, customer_id)
                migrate(order, new_pm_id)
            migrated += 1
        elif action == "flag":
            if not DRY_RUN:
                flag(order, reason)
            flagged += 1
    log.info(
        "Done. %d order(s) %s, %d order(s) %s.",
        migrated, "to migrate" if DRY_RUN else "migrated",
        flagged, "to flag" if DRY_RUN else "flagged",
    )


if __name__ == "__main__":
    run()
migrate-sources-to-pm.js
/**
 * Move legacy Stripe card Sources saved on WooCommerce customers to reusable
 * PaymentMethods, so future off-session charges can go through Strong Customer
 * Authentication (SCA) instead of being declined.
 *
 * Stripe is retiring the old Sources API for saved cards. A `src_...` token
 * that was fine for a one-off checkout years ago cannot carry a customer
 * through 3D Secure on a later off-session renewal or repeat purchase. This
 * walks recent orders, reads the saved token from order meta
 * `_stripe_intent_id` (falling back to `transaction_id`), and for any legacy
 * card Source still in good standing, wraps it in a new PaymentMethod,
 * attaches it to the Stripe Customer, and re-links the order (and the
 * customer's default token) to the new `pm_...` id. Orders whose Source
 * cannot be migrated are flagged instead. Safe by default (DRY_RUN=true).
 *
 * Guide: https://www.allanninal.dev/woocommerce/move-sources-to-payment-methods/
 */
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 || 60);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const RELEVANT_STATUSES = new Set(["pending", "on-hold", "processing", "completed", "failed"]);
const LEGACY_SOURCE_PREFIX = "src_";
const PAYMENT_METHOD_PREFIX = "pm_";
const OK_SOURCE_STATUSES = new Set(["chargeable", "consumed"]);

export function tokenOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  return order.transaction_id || null;
}

export function isLegacySource(token) {
  return Boolean(token) && token.startsWith(LEGACY_SOURCE_PREFIX);
}

export function isAlreadyPaymentMethod(token) {
  return Boolean(token) && token.startsWith(PAYMENT_METHOD_PREFIX);
}

/**
 * Pure decision: what should we do about this order's saved payment token.
 *
 * order: object with at least { status, id }.
 * token: the saved Stripe token string, or null.
 * source: a Stripe Source-shaped object ({ type, status }), or null when the
 *         token is not a legacy Source (already a PaymentMethod, or missing).
 *
 * Returns [action, reason] where action is one of "skip", "migrate", "flag".
 */
export function decide(order, token, source) {
  if (!RELEVANT_STATUSES.has(order.status)) {
    return ["skip", "order status is not one we track saved cards for"];
  }
  if (isAlreadyPaymentMethod(token)) {
    return ["skip", "already a PaymentMethod"];
  }
  if (!isLegacySource(token)) {
    return ["skip", "no legacy Source saved on this order"];
  }
  if (!source) {
    return ["flag", "Source could not be retrieved from Stripe"];
  }
  if (source.type !== "card") {
    return ["flag", "Source is not a card, cannot auto-migrate this type"];
  }
  if (!OK_SOURCE_STATUSES.has(source.status)) {
    return ["flag", "Source is no longer chargeable, shopper must re-enter their card"];
  }
  return ["migrate", "legacy card Source in good standing, safe to wrap as a PaymentMethod"];
}

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 getSource(sourceId) {
  if (!sourceId) return null;
  try {
    return await stripe.sources.retrieve(sourceId);
  } catch {
    return null;
  }
}

function customerIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
  }
  return null;
}

async function* trackedOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(
      `/orders?status=pending,on-hold,processing,completed,failed&after=${after}&per_page=50&page=${page}`
    );
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function createPaymentMethodFromSource(sourceId, customerId) {
  const paymentMethod = await stripe.paymentMethods.create({ type: "card", card: { token: sourceId } });
  if (customerId) {
    await stripe.paymentMethods.attach(paymentMethod.id, { customer: customerId });
  }
  return paymentMethod.id;
}

async function migrate(order, newPmId) {
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_stripe_intent_id", value: newPmId },
        { key: "_stripe_source_id", value: newPmId },
      ],
    }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Migrated the saved Stripe Source to PaymentMethod ${newPmId}. ` +
            `Future off-session charges on this order's saved card can now go through ` +
            `Strong Customer Authentication (SCA).`,
    }),
  });
}

async function flag(order, reason) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stripe Source migration check failed: ${reason}. This order's saved card is ` +
            `a legacy Stripe Source that could not be automatically moved to a PaymentMethod. ` +
            `The shopper should re-enter their card on the my account page before the next charge.`,
    }),
  });
}

export async function run() {
  let migrated = 0;
  let flagged = 0;
  for await (const order of trackedOrders()) {
    const token = tokenOf(order);
    const source = isLegacySource(token) ? await getSource(token) : null;
    const [action, reason] = decide(order, token, source);
    if (action === "skip") continue;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would " + action : action + "ing"}`);
    if (action === "migrate") {
      if (!DRY_RUN) {
        const customerId = customerIdOf(order);
        const newPmId = await createPaymentMethodFromSource(token, customerId);
        await migrate(order, newPmId);
      }
      migrated++;
    } else if (action === "flag") {
      if (!DRY_RUN) await flag(order, reason);
      flagged++;
    }
  }
  console.log(
    `Done. ${migrated} order(s) ${DRY_RUN ? "to migrate" : "migrated"}, ` +
    `${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`
  );
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether a real saved card gets rewritten. 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_move_decide.py
from migrate_sources_to_pm import decide, is_legacy_source, is_already_payment_method, token_of


def order(**over):
    base = {"id": 701, "status": "pending"}
    base.update(over)
    return base


def source(**over):
    base = {"type": "card", "status": "chargeable"}
    base.update(over)
    return base


def test_migrate_when_legacy_card_source_is_chargeable():
    assert decide(order(), "src_1AbCdEfGhIjKlMnO", source())[0] == "migrate"


def test_flag_when_source_missing_from_stripe():
    assert decide(order(), "src_1AbCdEfGhIjKlMnO", None)[0] == "flag"


def test_flag_when_source_not_a_card():
    assert decide(order(), "src_1AbCdEfGhIjKlMnO", source(type="sepa_debit"))[0] == "flag"


def test_flag_when_source_no_longer_chargeable():
    assert decide(order(), "src_1AbCdEfGhIjKlMnO", source(status="failed"))[0] == "flag"


def test_skip_when_already_a_payment_method():
    assert decide(order(), "pm_1XyZ", None)[0] == "skip"


def test_skip_when_order_status_not_tracked():
    assert decide(order(status="cancelled"), "src_1AbCdEfGhIjKlMnO", source())[0] == "skip"
migrate-sources-to-pm.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./migrate-sources-to-pm.js";

const order = (over = {}) => ({ id: 701, status: "pending", ...over });
const source = (over = {}) => ({ type: "card", status: "chargeable", ...over });

test("migrate when legacy card source is chargeable", () => {
  assert.equal(decide(order(), "src_1AbCdEfGhIjKlMnO", source())[0], "migrate");
});

test("flag when source missing from stripe", () => {
  assert.equal(decide(order(), "src_1AbCdEfGhIjKlMnO", null)[0], "flag");
});

test("flag when source not a card", () => {
  assert.equal(decide(order(), "src_1AbCdEfGhIjKlMnO", source({ type: "sepa_debit" }))[0], "flag");
});

test("flag when source no longer chargeable", () => {
  assert.equal(decide(order(), "src_1AbCdEfGhIjKlMnO", source({ status: "failed" }))[0], "flag");
});

test("skip when already a payment method", () => {
  assert.equal(decide(order(), "pm_1XyZ", null)[0], "skip");
});

test("skip when order status not tracked", () => {
  assert.equal(decide(order({ status: "cancelled" }), "src_1AbCdEfGhIjKlMnO", source())[0], "skip");
});

Case studies

Long-running subscription store

The tokens nobody had looked at since 2019

A subscription box store had been live for six years. About four percent of its active subscriber base still carried a saved card as a raw Stripe Source from before the store switched to the current checkout. Renewals against those tokens were starting to need extra authentication that nothing in the flow could complete automatically, so they simply failed.

Running the migration script in dry run surfaced the exact list of legacy Sources. Ninety one percent of them were still chargeable and migrated cleanly on the first real run, with no shopper action needed.

Imported customer list

A migration that carried over the wrong kind of token

A store moved platforms and its import script copied Stripe token ids straight from the export file without checking what kind of object they pointed to. A batch of "saved cards" turned out to be old Source ids, some of which Stripe no longer considered chargeable at all.

The script's flag path caught every one of those instead of trying to force a migration, and the store emailed just that group asking them to re-enter their card, rather than guessing on their behalf.

What good looks like

After this runs on a schedule, a legacy Source token stops being a ticking clock. Chargeable cards move themselves to PaymentMethods without bothering the shopper, and the rest get a clear note asking for a real re-entry instead of a renewal that fails with no explanation. Keep the job running even after the backlog clears, since a fresh Source can still slip in from an older code path or an import.

FAQ

Why do old Stripe Sources tokens stop working on WooCommerce?

Stripe is retiring the Sources API for saved cards. A legacy src_ token was never built to carry a shopper through Strong Customer Authentication, so an off-session charge against one of these tokens can be declined even though the saved card is still valid. Wrapping the Source in a PaymentMethod fixes it.

Is it safe to migrate a saved card token with a script?

Yes, when the script only migrates Source tokens that Stripe still reports as chargeable or consumed, and it flags anything it is not sure about instead of guessing. Start in dry run mode to review the list before it writes.

What happens to orders whose Source cannot be migrated?

They get flagged with an order note instead of being changed. That covers a Source that is not a card, or one Stripe no longer reports as chargeable. The shopper needs to re-enter their card on the account page in those cases.

Related field notes

Citations

On the problem:

  1. Stripe docs: Sources API is not recommended for new integrations and is being phased out for saved, reusable payment methods. docs.stripe.com/sources
  2. Stripe docs: Strong Customer Authentication and what it means for saved cards and off-session charges. docs.stripe.com/strong-customer-authentication
  3. WooCommerce docs: how the Stripe gateway stores and reads saved payment tokens on orders and customers. woocommerce.com/document/stripe

On the solution:

  1. Stripe docs: migrating saved cards from Sources to PaymentMethods. docs.stripe.com/payments/sources-card-migration
  2. Stripe API: create a PaymentMethod from an existing token, and attach a PaymentMethod to a Customer. docs.stripe.com/api/payment_methods/create
  3. WooCommerce REST API: update an order's meta data 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 save a batch of saved cards?

If this helped you get ahead of declined renewals or a support queue full of "why did my card fail," 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