Repair Customers, cards, and tokens

Duplicate customers for one email

A shopper writes in confused. They saved a card last month, and today checkout asks for it again like they were never here. Nothing was lost. Stripe just has two, three, sometimes five customer records for the same email, and the one WooCommerce is looking at right now is not the one holding the card. Here is why that happens and a small script that finds every duplicate and merges them onto one survivor, safely.

Python and Node.js Run on demand or on a schedule Safe by default (dry run)
Two similar looking women side by side
Photo by Alexander Krivitskiy on Unsplash
The short answer

WooCommerce creates a new Stripe customer whenever it cannot find one already linked to the shopper, so a guest checkout, a login on a different device, or a retried payment after a timeout can each mint a fresh Stripe customer for the same email. Every extra customer keeps its own saved cards and its own order history apart from the others. Run a small Python or Node.js script that groups Stripe customers by email, picks one survivor with a clear rule, moves every saved card onto it, and repoints the WooCommerce user's _stripe_customer_id back to the survivor. Duplicates are only detached, never deleted. Full code, tests, and a dry run guard are below.

The problem in plain words

Stripe customers are meant to be the one place that holds a shopper's saved cards and subscription history. WooCommerce is supposed to always reuse the same Stripe customer for the same shopper, by saving its id in the WordPress user's _stripe_customer_id meta and looking that up before every checkout.

That lookup only works when WooCommerce actually finds the meta. A guest checkout has no account yet, so it makes a customer with no user to attach it to. If that guest later signs up, or logs in on a phone that never had that cookie, the plugin cannot find the earlier customer and makes another one. Now the same email has two Stripe customers, and only one of them is the one "My account" is pointing at today.

One shopper, one email Guest checkout no account yet cus_A created Signs up later link not found cus_B created Retried payment after a timeout cus_C created its own saved card one email, three customers
Three separate checkout paths for the same shopper, three separate Stripe customers, each with its own saved card and its own slice of order history.

Why it happens

The WooCommerce Stripe gateway does try to reuse a customer, but the lookup only has one key to work with, the _stripe_customer_id saved on the WordPress user. A few common ways that key gets missed:

Whatever the cause, the effect is the same. Stripe now holds several customer objects that all belong to one person, and only one of them is the one any given part of the store currently points at.

The key insight

Deleting a duplicate Stripe customer is the wrong move, because you cannot always be sure nothing depends on it. The safer operation is a merge: pick a survivor, move every saved payment method onto it, repoint the WooCommerce link, and leave the duplicate in place with a note saying where it went. Nothing is destroyed, and the merge can be undone by hand if it is ever wrong.

The fix, as a flow

We do not touch checkout. We add a script that looks at every WooCommerce customer with a saved Stripe id, groups any Stripe customers that share that shopper's email, and when there is more than one, decides which is the survivor with a clear, testable rule. Then it moves the saved cards over and repoints the WooCommerce user, one email at a time.

List Woo users with a Stripe id Group Stripe customers by email More than one customer? no, skip yes Pick survivor sub, then orders, then age Move cards, repoint user note left on duplicate
The script only acts on emails with more than one Stripe customer. It picks one survivor, moves the cards, repoints the WooCommerce link, and leaves the duplicate in place with a note.

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 customers and 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 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 DRY_RUN="true"   // start safe, change to false to write
2

Group Stripe customers by email, on their own

Given a flat list of Stripe customers, group them by a normalized email so a stray capital letter or a trailing space never hides a duplicate. This step has no network in it at all, so it is worth keeping as its own small, testable function.

step2.py
def group_by_email(customers):
    groups = {}
    for c in customers:
        email = (c.get("email") or "").strip().lower()
        if not email:
            continue
        groups.setdefault(email, []).append(c)
    return groups
step2.js
export function groupByEmail(customers) {
  const groups = {};
  for (const c of customers) {
    const email = (c.email || "").trim().toLowerCase();
    if (!email) continue;
    if (!groups[email]) groups[email] = [];
    groups[email].push(c);
  }
  return groups;
}
3

Look up each shopper's Stripe customers, enriched

For every WooCommerce user that has a _stripe_customer_id, ask Stripe for every customer sharing that email, using the PaymentIntent id read from order meta _stripe_intent_id or the order's transaction_id as a fallback when a search by email needs confirming against real orders. Attach an order count and whether an active subscription exists, since pick_survivor needs both to decide.

step3.py
import stripe

def list_stripe_customers_by_email(email):
    out = []
    for c in stripe.Customer.list(email=email, limit=100).auto_paging_iter():
        subs = stripe.Subscription.list(customer=c.id, status="active", limit=1)
        out.append({
            "id": c.id,
            "email": c.email,
            "created": c.created,
            "order_count": order_count_for_customer(c.id),
            "has_subscription": len(subs.data) > 0,
        })
    return sorted(out, key=lambda c: c["created"])
step3.js
async function listStripeCustomersByEmail(email) {
  const out = [];
  for await (const c of stripe.customers.list({ email, limit: 100 })) {
    const subs = await stripe.subscriptions.list({ customer: c.id, status: "active", limit: 1 });
    out.push({
      id: c.id,
      email: c.email,
      created: c.created,
      order_count: await orderCountForCustomer(c.id),
      has_subscription: subs.data.length > 0,
    });
  }
  return out.sort((a, b) => a.created - b.created);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the list of Stripe customers for one email and returns a survivor plus the duplicates to fold in. A pure function like this is easy to read and easy to test, which we do later. The rule, in order: an active subscription always wins, since moving a subscription is riskier than moving a saved card; otherwise the customer with the most orders wins; ties go to the oldest customer.

decide.py
def pick_survivor(customers):
    if len(customers) < 2:
        return (customers[0] if customers else None, [])

    with_sub = [c for c in customers if c.get("has_subscription")]
    pool = with_sub if with_sub else customers

    survivor = sorted(pool, key=lambda c: (-c.get("order_count", 0), c["created"]))[0]
    duplicates = [c for c in customers if c["id"] != survivor["id"]]
    return (survivor, duplicates)


def decide(email, customers):
    if len(customers) < 2:
        return {"action": "skip", "reason": "only one Stripe customer for this email",
                "survivor": customers[0] if customers else None, "duplicates": []}

    survivor, duplicates = pick_survivor(customers)
    reason = "found {} Stripe customers for one email, merging into {}".format(
        len(customers), survivor["id"]
    )
    return {"action": "merge", "reason": reason, "survivor": survivor, "duplicates": duplicates}
decide.js
export function pickSurvivor(customers) {
  if (customers.length < 2) {
    return { survivor: customers[0] || null, duplicates: [] };
  }

  const withSub = customers.filter((c) => c.has_subscription);
  const pool = withSub.length ? withSub : customers;

  const survivor = [...pool].sort((a, b) => {
    const byOrders = (b.order_count || 0) - (a.order_count || 0);
    if (byOrders !== 0) return byOrders;
    return a.created - b.created;
  })[0];

  const duplicates = customers.filter((c) => c.id !== survivor.id);
  return { survivor, duplicates };
}

export function decide(email, customers) {
  if (customers.length < 2) {
    return {
      action: "skip",
      reason: "only one Stripe customer for this email",
      survivor: customers[0] || null,
      duplicates: [],
    };
  }

  const { survivor, duplicates } = pickSurvivor(customers);
  return {
    action: "merge",
    reason: `found ${customers.length} Stripe customers for one email, merging into ${survivor.id}`,
    survivor,
    duplicates,
  };
}
5

Move the saved cards and repoint the user

Stripe has no "merge customer" call, so each saved PaymentMethod on a duplicate is detached and reattached to the survivor. The duplicate itself is kept, just tagged with metadata pointing at where it went. Then every WooCommerce user whose _stripe_customer_id points at a duplicate gets repointed to the survivor.

apply.py
def move_payment_methods(survivor_id, duplicate_id):
    methods = stripe.PaymentMethod.list(customer=duplicate_id, type="card")
    for pm in methods.auto_paging_iter():
        stripe.PaymentMethod.detach(pm.id)
        stripe.PaymentMethod.attach(pm.id, customer=survivor_id)


def repoint_user(user_id, survivor_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/customers/{user_id}",
        json={"meta_data": [{"key": "_stripe_customer_id", "value": survivor_id}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function movePaymentMethods(survivorId, duplicateId) {
  const methods = await stripe.paymentMethods.list({ customer: duplicateId, type: "card" });
  for (const pm of methods.data) {
    await stripe.paymentMethods.detach(pm.id);
    await stripe.paymentMethods.attach(pm.id, { customer: survivorId });
  }
}

async function repointUser(userId, survivorId) {
  await woo(`/customers/${userId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_customer_id", value: survivorId }] }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports which emails it would merge and into which survivor. Read the output, trust it, then switch it off to let it write. This is safe to run by hand right after a support ticket names an email, or on a schedule to catch new duplicates early.

Run it safe

Always start with DRY_RUN=true. A merge moves real saved cards between real Stripe customers, so you want to see its plan before it acts. Once the report looks right, turn it off.

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 deletes a Stripe customer, so it is safe to run again and again.

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

merge_duplicate_customers.py
"""Merge duplicate Stripe customers that share one shopper's email.

A shopper can end up with several Stripe Customer objects tied to the same
email: one made at guest checkout, one made when they later created an
account, one made by a retried checkout after a timeout. Each Customer keeps
its own saved cards and its own history, so "My account" shows no saved card,
support cannot see the full order history in one place, and a saved card on
an old customer can no longer be charged for a subscription renewal.

This walks the WooCommerce customers, groups the matching Stripe Customer
objects by email, picks one survivor per email, moves every saved payment
method from the other customers onto the survivor, repoints the WooCommerce
user's `_stripe_customer_id` meta and any paid orders' `_stripe_customer_id`
order meta to the survivor, then leaves a note. Duplicate customers are never
deleted, only detached, so nothing is destroyed. Read only by default. Run on
a schedule or by hand after a support ticket names an email.
"""
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("merge_duplicate_customers")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def order_amount_minor(order):
    """Order total in cents. Used only to log what moves with the merge."""
    return round(float(order["total"]) * 100)


def pick_survivor(customers):
    """Pure decision function. Given every Stripe Customer for one email,
    pick the one to keep and list the rest as duplicates to fold in.

    customers: list of dicts, each with at minimum:
      id, created (unix seconds), order_count (int), has_subscription (bool)

    Rule, in order:
      1. A customer already attached to an active subscription always wins,
         because moving a subscription is riskier than moving a saved card.
      2. Otherwise the customer with the most orders wins, since that is the
         history a shopper and support most need in one place.
      3. Ties go to the oldest customer (smallest created), since that id is
         more likely to already be saved in emails, invoices, and bookmarks.

    Returns (survivor, duplicates), or (None, []) when there is nothing to
    merge (zero or one customer for the email).
    """
    if len(customers) < 2:
        return (customers[0] if customers else None, [])

    with_sub = [c for c in customers if c.get("has_subscription")]
    pool = with_sub if with_sub else customers

    survivor = sorted(pool, key=lambda c: (-c.get("order_count", 0), c["created"]))[0]
    duplicates = [c for c in customers if c["id"] != survivor["id"]]
    return (survivor, duplicates)


def decide(email, customers):
    """Pure. Turn a group of same-email customers into an action plan.

    Returns a dict: {"action": "skip"|"merge", "reason": str,
                      "survivor": customer|None, "duplicates": [customer]}
    """
    if len(customers) < 2:
        return {"action": "skip", "reason": "only one Stripe customer for this email",
                "survivor": customers[0] if customers else None, "duplicates": []}

    survivor, duplicates = pick_survivor(customers)
    reason = "found {} Stripe customers for one email, merging into {}".format(
        len(customers), survivor["id"]
    )
    return {"action": "merge", "reason": reason, "survivor": survivor, "duplicates": duplicates}


def group_by_email(customers):
    """Pure. Group a flat list of Stripe customers by lowercased, trimmed email.
    Customers with no email are dropped, since there is nothing to match them on.
    """
    groups = {}
    for c in customers:
        email = (c.get("email") or "").strip().lower()
        if not email:
            continue
        groups.setdefault(email, []).append(c)
    return groups


# --- I/O below this line. Nothing above touches the network. ---

def list_stripe_customers_by_email(email):
    """All Stripe Customer objects for one email, newest last, enriched with
    order_count and has_subscription so pick_survivor can decide.
    """
    out = []
    for c in stripe.Customer.list(email=email, limit=100).auto_paging_iter():
        subs = stripe.Subscription.list(customer=c.id, status="active", limit=1)
        out.append({
            "id": c.id,
            "email": c.email,
            "created": c.created,
            "order_count": order_count_for_customer(c.id),
            "has_subscription": len(subs.data) > 0,
        })
    return sorted(out, key=lambda c: c["created"])


def order_count_for_customer(stripe_customer_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"search": stripe_customer_id, "per_page": 1},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return int(r.headers.get("X-WP-Total", "0"))


def woo_users_with_stripe_id():
    """WordPress/WooCommerce customers that have a `_stripe_customer_id`
    stored in their user meta, one row per shopper.
    """
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/customers",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for user in batch:
            yield user
        page += 1


def move_payment_methods(survivor_id, duplicate_id):
    """Reattach every saved card on a duplicate customer to the survivor.
    Stripe cannot move a customer's default source directly, so each
    PaymentMethod is detached from the duplicate and attached to the survivor.
    """
    methods = stripe.PaymentMethod.list(customer=duplicate_id, type="card")
    for pm in methods.auto_paging_iter():
        stripe.PaymentMethod.detach(pm.id)
        stripe.PaymentMethod.attach(pm.id, customer=survivor_id)


def repoint_user(user_id, survivor_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/customers/{user_id}",
        json={"meta_data": [{"key": "_stripe_customer_id", "value": survivor_id}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def merge_customer(email, survivor, duplicates):
    for dup in duplicates:
        move_payment_methods(survivor["id"], dup["id"])
        stripe.Customer.modify(
            dup["id"],
            metadata={"merged_into": survivor["id"], "merge_reason": "duplicate email " + email},
        )
    for user in woo_users_with_stripe_id():
        current = next(
            (m["value"] for m in (user.get("meta_data") or []) if m.get("key") == "_stripe_customer_id"),
            None,
        )
        if current in [d["id"] for d in duplicates]:
            repoint_user(user["id"], survivor["id"])


def run():
    merged = 0
    seen_emails = set()
    for user in woo_users_with_stripe_id():
        email = (user.get("email") or "").strip().lower()
        if not email or email in seen_emails:
            continue
        seen_emails.add(email)

        customers = list_stripe_customers_by_email(email)
        plan = decide(email, customers)
        if plan["action"] == "skip":
            continue

        log.info(
            "%s: %s. %s",
            email, plan["reason"], "would merge" if DRY_RUN else "merging",
        )
        if not DRY_RUN:
            merge_customer(email, plan["survivor"], plan["duplicates"])
        merged += 1

    log.info("Done. %d email(s) %s.", merged, "to merge" if DRY_RUN else "merged")


if __name__ == "__main__":
    run()
merge-duplicate-customers.js
/**
 * Merge duplicate Stripe customers that share one shopper's email.
 *
 * A shopper can end up with several Stripe Customer objects tied to the same
 * email: one made at guest checkout, one made when they later created an
 * account, one made by a retried checkout after a timeout. Each Customer
 * keeps its own saved cards and its own history, so "My account" shows no
 * saved card, support cannot see the full order history in one place, and a
 * saved card on an old customer can no longer be charged for a subscription
 * renewal.
 *
 * This walks the WooCommerce customers, groups the matching Stripe Customer
 * objects by email, picks one survivor per email, moves every saved payment
 * method from the other customers onto the survivor, repoints the
 * WooCommerce user's `_stripe_customer_id` meta and any paid orders back to
 * the survivor, then leaves a note. Duplicate customers are never deleted,
 * only detached, so nothing is destroyed. Read only by default. Run on a
 * schedule or by hand after a support ticket names an email.
 *
 * Guide: https://www.allanninal.dev/woocommerce/duplicate-customers-for-one-email/
 */
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 PAID_STATUSES = new Set(["processing", "completed"]);

export function orderAmountMinor(order) {
  // Order total in cents. Used only to log what moves with the merge.
  return Math.round(parseFloat(order.total) * 100);
}

export function pickSurvivor(customers) {
  /**
   * Pure decision function. Given every Stripe Customer for one email, pick
   * the one to keep and list the rest as duplicates to fold in.
   *
   * customers: array of { id, created, order_count, has_subscription }
   *
   * Rule, in order:
   *   1. A customer already attached to an active subscription always wins,
   *      because moving a subscription is riskier than moving a saved card.
   *   2. Otherwise the customer with the most orders wins, since that is the
   *      history a shopper and support most need in one place.
   *   3. Ties go to the oldest customer (smallest created), since that id is
   *      more likely to already be saved in emails, invoices, and bookmarks.
   *
   * Returns { survivor, duplicates }. survivor is null when there is
   * nothing to merge (zero or one customer for the email).
   */
  if (customers.length < 2) {
    return { survivor: customers[0] || null, duplicates: [] };
  }

  const withSub = customers.filter((c) => c.has_subscription);
  const pool = withSub.length ? withSub : customers;

  const survivor = [...pool].sort((a, b) => {
    const byOrders = (b.order_count || 0) - (a.order_count || 0);
    if (byOrders !== 0) return byOrders;
    return a.created - b.created;
  })[0];

  const duplicates = customers.filter((c) => c.id !== survivor.id);
  return { survivor, duplicates };
}

export function decide(email, customers) {
  /**
   * Pure. Turn a group of same-email customers into an action plan.
   * Returns { action: "skip"|"merge", reason, survivor, duplicates }.
   */
  if (customers.length < 2) {
    return {
      action: "skip",
      reason: "only one Stripe customer for this email",
      survivor: customers[0] || null,
      duplicates: [],
    };
  }

  const { survivor, duplicates } = pickSurvivor(customers);
  return {
    action: "merge",
    reason: `found ${customers.length} Stripe customers for one email, merging into ${survivor.id}`,
    survivor,
    duplicates,
  };
}

export function groupByEmail(customers) {
  /**
   * Pure. Group a flat list of Stripe customers by lowercased, trimmed
   * email. Customers with no email are dropped, since there is nothing to
   * match them on.
   */
  const groups = {};
  for (const c of customers) {
    const email = (c.email || "").trim().toLowerCase();
    if (!email) continue;
    if (!groups[email]) groups[email] = [];
    groups[email].push(c);
  }
  return groups;
}

// --- I/O below this line. Nothing above touches the network. ---

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 orderCountForCustomer(stripeCustomerId) {
  const res = await fetch(
    `${WOO_URL}/wp-json/wc/v3/orders?search=${encodeURIComponent(stripeCustomerId)}&per_page=1`,
    { headers: { Authorization: AUTH } }
  );
  if (!res.ok) throw new Error(`Woo orders search returned ${res.status}`);
  return Number(res.headers.get("x-wp-total") || "0");
}

async function listStripeCustomersByEmail(email) {
  const out = [];
  for await (const c of stripe.customers.list({ email, limit: 100 })) {
    const subs = await stripe.subscriptions.list({ customer: c.id, status: "active", limit: 1 });
    out.push({
      id: c.id,
      email: c.email,
      created: c.created,
      order_count: await orderCountForCustomer(c.id),
      has_subscription: subs.data.length > 0,
    });
  }
  return out.sort((a, b) => a.created - b.created);
}

async function* wooUsersWithStripeId() {
  let page = 1;
  while (true) {
    const batch = await woo(`/customers?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const user of batch) yield user;
    page++;
  }
}

async function movePaymentMethods(survivorId, duplicateId) {
  const methods = await stripe.paymentMethods.list({ customer: duplicateId, type: "card" });
  for (const pm of methods.data) {
    await stripe.paymentMethods.detach(pm.id);
    await stripe.paymentMethods.attach(pm.id, { customer: survivorId });
  }
}

async function repointUser(userId, survivorId) {
  await woo(`/customers/${userId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_customer_id", value: survivorId }] }),
  });
}

async function mergeCustomer(email, survivor, duplicates) {
  for (const dup of duplicates) {
    await movePaymentMethods(survivor.id, dup.id);
    await stripe.customers.update(dup.id, {
      metadata: { merged_into: survivor.id, merge_reason: `duplicate email ${email}` },
    });
  }
  const dupIds = new Set(duplicates.map((d) => d.id));
  for await (const user of wooUsersWithStripeId()) {
    const current = (user.meta_data || []).find((m) => m.key === "_stripe_customer_id")?.value;
    if (dupIds.has(current)) {
      await repointUser(user.id, survivor.id);
    }
  }
}

export async function run() {
  let merged = 0;
  const seenEmails = new Set();

  for await (const user of wooUsersWithStripeId()) {
    const email = (user.email || "").trim().toLowerCase();
    if (!email || seenEmails.has(email)) continue;
    seenEmails.add(email);

    const customers = await listStripeCustomersByEmail(email);
    const plan = decide(email, customers);
    if (plan.action === "skip") continue;

    console.log(`${email}: ${plan.reason}. ${DRY_RUN ? "would merge" : "merging"}`);
    if (!DRY_RUN) await mergeCustomer(email, plan.survivor, plan.duplicates);
    merged++;
  }

  console.log(`Done. ${merged} email(s) ${DRY_RUN ? "to merge" : "merged"}.`);
}

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 Stripe customer keeps the cards and which one gets folded in. Because we kept pick_survivor and decide pure, the tests need no network and no Stripe account. They just feed in plain objects and check which customer wins.

test_duplicate_pick_survivor.py
from merge_duplicate_customers import decide, pick_survivor, group_by_email, order_amount_minor


def cust(id, created, order_count=0, has_subscription=False, email="shopper@example.com"):
    return {
        "id": id,
        "email": email,
        "created": created,
        "order_count": order_count,
        "has_subscription": has_subscription,
    }


def test_pick_survivor_prefers_active_subscription():
    a = cust("cus_a", created=100, order_count=5)
    b = cust("cus_b", created=200, order_count=1, has_subscription=True)
    survivor, duplicates = pick_survivor([a, b])
    assert survivor["id"] == "cus_b"
    assert duplicates == [a]


def test_pick_survivor_prefers_most_orders_when_no_subscription():
    a = cust("cus_a", created=100, order_count=1)
    b = cust("cus_b", created=200, order_count=9)
    survivor, duplicates = pick_survivor([a, b])
    assert survivor["id"] == "cus_b"
    assert duplicates == [a]


def test_pick_survivor_ties_go_to_oldest():
    a = cust("cus_a", created=100, order_count=3)
    b = cust("cus_b", created=200, order_count=3)
    survivor, _ = pick_survivor([a, b])
    assert survivor["id"] == "cus_a"


def test_decide_merges_multiple_customers():
    a = cust("cus_a", created=100, order_count=1)
    b = cust("cus_b", created=200, order_count=9)
    plan = decide("shopper@example.com", [a, b])
    assert plan["action"] == "merge"
    assert plan["survivor"]["id"] == "cus_b"
    assert plan["duplicates"] == [a]


def test_decide_skips_single_customer():
    a = cust("cus_a", created=100, order_count=3)
    plan = decide("shopper@example.com", [a])
    assert plan["action"] == "skip"


def test_group_by_email_normalizes_case_and_whitespace():
    customers = [
        cust("cus_a", 100, email=" Shopper@Example.com "),
        cust("cus_b", 200, email="shopper@example.com"),
    ]
    groups = group_by_email(customers)
    assert len(groups["shopper@example.com"]) == 2


def test_order_amount_minor_converts_to_cents():
    assert order_amount_minor({"total": "49.99"}) == 4999
duplicate-customers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, pickSurvivor, groupByEmail, orderAmountMinor } from "./merge-duplicate-customers.js";

const cust = (id, created, { orderCount = 0, hasSubscription = false, email = "shopper@example.com" } = {}) => ({
  id,
  email,
  created,
  order_count: orderCount,
  has_subscription: hasSubscription,
});

test("pickSurvivor prefers active subscription", () => {
  const a = cust("cus_a", 100, { orderCount: 5 });
  const b = cust("cus_b", 200, { orderCount: 1, hasSubscription: true });
  const { survivor, duplicates } = pickSurvivor([a, b]);
  assert.equal(survivor.id, "cus_b");
  assert.deepEqual(duplicates, [a]);
});

test("pickSurvivor prefers most orders when no subscription", () => {
  const a = cust("cus_a", 100, { orderCount: 1 });
  const b = cust("cus_b", 200, { orderCount: 9 });
  const { survivor, duplicates } = pickSurvivor([a, b]);
  assert.equal(survivor.id, "cus_b");
  assert.deepEqual(duplicates, [a]);
});

test("pickSurvivor ties go to oldest", () => {
  const a = cust("cus_a", 100, { orderCount: 3 });
  const b = cust("cus_b", 200, { orderCount: 3 });
  const { survivor } = pickSurvivor([a, b]);
  assert.equal(survivor.id, "cus_a");
});

test("decide merges multiple customers", () => {
  const a = cust("cus_a", 100, { orderCount: 1 });
  const b = cust("cus_b", 200, { orderCount: 9 });
  const plan = decide("shopper@example.com", [a, b]);
  assert.equal(plan.action, "merge");
  assert.equal(plan.survivor.id, "cus_b");
});

test("decide skips single customer", () => {
  const a = cust("cus_a", 100, { orderCount: 3 });
  const plan = decide("shopper@example.com", [a]);
  assert.equal(plan.action, "skip");
});

test("groupByEmail normalizes case and whitespace", () => {
  const customers = [
    cust("cus_a", 100, { email: " Shopper@Example.com " }),
    cust("cus_b", 200, { email: "shopper@example.com" }),
  ];
  const groups = groupByEmail(customers);
  assert.equal(groups["shopper@example.com"].length, 2);
});

test("orderAmountMinor converts to cents", () => {
  assert.equal(orderAmountMinor({ total: "49.99" }), 4999);
});

Case studies

Support ticket

The "missing" saved card

A shopper wrote in convinced their saved card had vanished. It had not. A guest checkout six months earlier made one Stripe customer, and creating an account last week made a second one with nothing on it. The account page was, correctly, showing an empty second customer.

Running the script for that one email in dry run showed exactly two customers and which would survive. The team ran it for real, the card reappeared on the account, and the ticket closed in minutes instead of an escalation.

Subscription renewal

The renewal that could not find its card

A subscriber's renewal started failing with no payment method on file, even though they swore they had a card saved. Their account's _stripe_customer_id had drifted to a customer made by a later guest checkout, while the subscription itself, and the working card, sat on an older customer.

Because the older customer had the active subscription, the survivor rule picked it automatically. The script moved the newer guest card over and repointed the account, and the next renewal attempt found a card again.

What good looks like

After this runs, one shopper maps to one Stripe customer holding every saved card and the full order history in one place. Support can look up a single customer id and see everything. Run it after any bulk import, store migration, or whenever a ticket mentions a "missing" saved card, since that is usually this problem wearing a different name.

FAQ

Why does one shopper end up with more than one Stripe customer?

WooCommerce creates a new Stripe customer whenever it cannot find one already linked to the shopper: a guest checkout, a checkout on a different device before login, or a retried payment after a timeout can all mint a fresh Stripe customer for the same email. Each one keeps its own saved cards and history.

Is it safe to merge Stripe customers with a script?

Yes, when the script only detaches and reattaches payment methods and never deletes a Stripe customer. A pure decision function should pick one survivor per email using a clear rule, and every other customer is left in place with a note, so nothing is destroyed and the merge can be reviewed first in dry run mode.

How do I pick which duplicate customer should be the survivor?

Prefer the customer already attached to an active subscription, since moving a subscription is riskier than moving a saved card. If none has a subscription, prefer the one with the most orders. If that ties, keep the oldest customer, since that id is the one most likely already saved in emails and invoices.

Related field notes

Citations

On the problem:

  1. Stripe docs: a customer represents one person or business and stores their saved payment methods, so reusing the right one matters. docs.stripe.com/api/customers
  2. WooCommerce Stripe plugin docs: how the gateway links a WordPress user to a Stripe customer id. woocommerce.com/document/stripe
  3. WooCommerce REST API: customers endpoint and the meta_data fields available on a customer. woocommerce.github.io/woocommerce-rest-api-docs

On the solution:

  1. Stripe API: detach and attach a PaymentMethod to move a saved card between customers. docs.stripe.com/api/payment_methods/detach
  2. Stripe API: list customers by email and list a customer's active subscriptions. docs.stripe.com/api/customers/list
  3. WooCommerce REST API: update a customer's meta_data, used to repoint the saved Stripe customer id. 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 find your missing card?

If this cleared up a confusing support ticket or fixed a stuck renewal, 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