Reconciler WooCommerce core: customers and linking

Duplicate customer accounts at checkout

A shopper emails support confused: they have two accounts, and only one of them shows the order they just placed. Nothing looks broken from the outside, the order went through fine, but WooCommerce quietly created a second customer account for the same email during checkout. Here is why a checkout race causes this and a small script that finds every duplicate pair and reports a safe plan to merge them.

Python and Node.js Runs on a schedule Safe by default (dry run)
A group of people sitting on a bench near trees
Photo by Naassom Azevedo on Unsplash
The short answer

A checkout race, usually a double click, a retried request, or two open tabs, can trigger WooCommerce's account creation step twice before the first one finishes, so two WordPress user accounts get made for one email. Run a small Python or Node.js job on a schedule that groups customers by email, checks the Stripe PaymentIntent behind each account's orders to confirm they are the same payer, and reports which duplicate is safe to merge into which survivor. Full code, tests, and a dry run guard are below.

The problem in plain words

When a new shopper checks out and asks WooCommerce to create an account, the store has to do two things quickly: check that the email is not already registered, and then create the WordPress user. Between those two steps there is a small gap of time.

If the shopper's browser sends that request twice, a double click on the place order button, a slow network that triggers a silent retry, or the same checkout open in two tabs, both requests can pass the "email is free" check before either one finishes creating the user. The result is two separate WordPress user accounts tied to the same email, one holding the order that was placed and one sitting empty. WooCommerce never notices, because from its point of view two valid requests came in and two accounts were made.

Double click or retry same checkout, twice Request A checks email is free Request B checks email is free both pass before either finishes Account 1 created has the placed order Account 2 created empty, same email One shopper two confusing logins
Both requests check the email before either one finishes, so WooCommerce ends up with two accounts for one shopper.

Why it happens

This is a classic race condition in the account creation step, and it shows up more often than most store owners expect. A few common triggers:

WordPress usernames and emails are meant to be unique, but the uniqueness check and the insert are not always wrapped in a single safe operation on every path through WooCommerce's checkout, especially under load or with custom registration hooks involved. Once two accounts exist, WooCommerce has no built in job that notices and cleans it up. It just keeps working with both.

The key insight

Two accounts sharing one email is not proof they should be merged blindly. The safe signal is the money: if both accounts' orders were paid through the same Stripe customer, it is almost certainly one person. If the orders paid through different Stripe customers, treat it as a coincidence or a shared inbox and leave it for a human to check.

The fix, as a flow

We do not touch checkout at all, this is a cleanup job that runs on a schedule. It pulls customers from the WooCommerce REST API, groups them by a normalized email, and for any email with more than one account, picks a survivor (the account with the order history) and checks whether each other account's orders were paid by the same Stripe customer. Only when that is confirmed does it report the pair as safe to merge, repointing the duplicate's orders onto the survivor.

Scheduled job daily or weekly Group customers by normalized email Pick survivor most orders, oldest wins ties Same Stripe customer? yes no, flag for review Report merge repoint orders + note
The job only reports a merge when it can confirm both accounts were paid by the same Stripe customer. Anything less certain is left for a human.

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

Group customers by a normalized email

Pull every customer from the WooCommerce REST API and group them by email, lowercased and trimmed, so Person@Shop.com and person@shop.com land in the same group. Any group with more than one account is a duplicate to look at.

step2.py
def normalize_email(email):
    return (email or "").strip().lower()

def group_by_email(customers):
    groups = {}
    for customer in customers:
        key = normalize_email(customer.get("email"))
        if not key:
            continue
        groups.setdefault(key, []).append(customer)
    return {email: group for email, group in groups.items() if len(group) > 1}
step2.js
export function normalizeEmail(email) {
  return (email || "").trim().toLowerCase();
}

export function groupByEmail(customers) {
  const groups = new Map();
  for (const customer of customers) {
    const key = normalizeEmail(customer.email);
    if (!key) continue;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(customer);
  }
  const result = {};
  for (const [email, group] of groups) {
    if (group.length > 1) result[email] = group;
  }
  return result;
}
3

Pick the survivor and load each account's orders

The survivor is the account with the most orders, since that is the identity the shopper actually uses. If two accounts somehow have the same order count, the one created first wins. Then fetch each account's orders through the REST API so we have something to compare.

step3.py
import requests
from requests.auth import HTTPBasicAuth

AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)

def pick_survivor(customers):
    return sorted(
        customers,
        key=lambda c: (-c.get("orders_count", 0), c.get("date_created") or ""),
    )[0]

def orders_for_customer(customer_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"customer": customer_id, "per_page": 50},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()
step3.js
export function pickSurvivor(customers) {
  return [...customers].sort((a, b) => {
    const byOrders = (b.orders_count || 0) - (a.orders_count || 0);
    if (byOrders !== 0) return byOrders;
    return (a.date_created || "").localeCompare(b.date_created || "");
  })[0];
}

async function ordersForCustomer(customerId) {
  return woo(`/orders?customer=${customerId}&per_page=50`);
}
4

Confirm the duplicate with Stripe, then decide

Read the Stripe PaymentIntent id saved on each order, from meta _stripe_intent_id or from transaction_id when it starts with pi_, then look up the PaymentIntent in Stripe and read its customer field. If the duplicate's orders share a Stripe customer with the survivor's orders, it is the same payer and safe to merge. If they point to different Stripe customers, flag it for a human instead of guessing. Keeping this in one pure function, with no network calls inside it, makes it easy to test.

decide.py
def intent_id_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 if tid and tid.startswith("pi_") else None

def stripe_customer_of(order, get_intent):
    intent = get_intent(intent_id_of(order))
    if intent is None:
        return None
    customer = intent.get("customer")
    return customer if isinstance(customer, str) else None

def decide(email, customers, orders_by_customer, get_intent):
    if len(customers) < 2:
        return ("skip", "not a duplicate", None, [])

    survivor = pick_survivor(customers)
    duplicates = [c for c in customers if c["id"] != survivor["id"]]

    survivor_stripe_ids = {
        stripe_customer_of(o, get_intent)
        for o in orders_by_customer.get(survivor["id"], [])
    }
    survivor_stripe_ids.discard(None)

    for dup in duplicates:
        dup_orders = orders_by_customer.get(dup["id"], [])
        if not dup_orders:
            continue
        dup_stripe_ids = {stripe_customer_of(o, get_intent) for o in dup_orders}
        dup_stripe_ids.discard(None)
        if dup_stripe_ids and survivor_stripe_ids and dup_stripe_ids.isdisjoint(survivor_stripe_ids):
            return ("review", f"duplicate account {dup['id']} paid through a different Stripe customer, needs a human", survivor, duplicates)

    return ("merge", "same email, same payer, safe to merge", survivor, duplicates)
decide.js
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;
}

function stripeCustomerOf(order, getIntent) {
  const intent = getIntent(intentIdOf(order));
  if (!intent) return null;
  return typeof intent.customer === "string" ? intent.customer : null;
}

export function decide(email, customers, ordersByCustomer, getIntent) {
  if (customers.length < 2) {
    return { action: "skip", reason: "not a duplicate", survivor: null, duplicates: [] };
  }

  const survivor = pickSurvivor(customers);
  const duplicates = customers.filter((c) => c.id !== survivor.id);

  const survivorStripeIds = new Set(
    (ordersByCustomer[survivor.id] || [])
      .map((o) => stripeCustomerOf(o, getIntent))
      .filter(Boolean)
  );

  for (const dup of duplicates) {
    const dupOrders = ordersByCustomer[dup.id] || [];
    if (dupOrders.length === 0) continue;
    const dupStripeIds = new Set(dupOrders.map((o) => stripeCustomerOf(o, getIntent)).filter(Boolean));
    const overlaps = [...dupStripeIds].some((id) => survivorStripeIds.has(id));
    if (dupStripeIds.size > 0 && survivorStripeIds.size > 0 && !overlaps) {
      return {
        action: "review",
        reason: `duplicate account ${dup.id} paid through a different Stripe customer, needs a human`,
        survivor,
        duplicates,
      };
    }
  }

  return { action: "merge", reason: "same email, same payer, safe to merge", survivor, duplicates };
}
5

Repoint the duplicate's orders onto the survivor

When the action is merge, update each of the duplicate's orders through the REST API so their customer_id points at the survivor, and add a note on the survivor recording which duplicate was merged into it. The duplicate account itself is left in place, empty of orders, ready for you to delete once you have checked the report.

apply.py
def repoint_orders(duplicate_id, survivor_id, orders):
    for order in orders:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"customer_id": survivor_id},
            auth=AUTH, timeout=30,
        ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/customers/{survivor_id}",
        json={"meta_data": [{"key": "_merged_duplicate_account", "value": str(duplicate_id)}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function repointOrders(duplicateId, survivorId, orders) {
  for (const order of orders) {
    await woo(`/orders/${order.id}`, {
      method: "PUT",
      body: JSON.stringify({ customer_id: survivorId }),
    });
  }
  await woo(`/customers/${survivorId}`, {
    method: "POST",
    body: JSON.stringify({
      meta_data: [{ key: "_merged_duplicate_account", value: String(duplicateId) }],
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports the survivor and duplicates for each email, plus any pairs it wants a human to review. Read the report, trust it, then switch it off to let it repoint orders. Run it on a schedule with cron, once a day or once a week is plenty since this is a slow leak, not an emergency.

Run it safe

Always start with DRY_RUN=true. This job changes which account owns an order history, so you want to read its report before it writes anything. Once the report looks right for a few runs, turn it off.

The full code

Here is the complete job in one file for each language. It reads settings from the environment, logs what it plans to do, respects the dry run flag, and never merges a pair unless the Stripe evidence backs it up.

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

find_duplicate_accounts.py
"""Find WordPress or WooCommerce customer accounts that got duplicated for one
email address during checkout.

A checkout race (a double click, a slow network retry, or two tabs) can call
"create account" twice before the first request finishes, so WooCommerce ends up
with two separate customer accounts for one shopper: one with the order history,
one empty. This walks recent customers, groups them by a normalized email, and
for each pair reads the saved Stripe PaymentIntent on their orders to confirm
both accounts really were paid by the same person before it reports a merge
plan. Read only by default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_duplicate_accounts")

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


def normalize_email(email):
    """Lowercase and trim, so Person@Shop.com and person@shop.com group together."""
    return (email or "").strip().lower()


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 stripe_customer_of(order, get_intent):
    """The Stripe Customer id behind an order's payment, if we can find one."""
    intent = get_intent(intent_id_of(order))
    if intent is None:
        return None
    customer = intent.get("customer")
    return customer if isinstance(customer, str) else None


def group_by_email(customers):
    """Group a flat list of WooCommerce customers by normalized email."""
    groups = {}
    for customer in customers:
        key = normalize_email(customer.get("email"))
        if not key:
            continue
        groups.setdefault(key, []).append(customer)
    return {email: group for email, group in groups.items() if len(group) > 1}


def pick_survivor(customers):
    """The account to keep: most orders first, then the account created first."""
    return sorted(
        customers,
        key=lambda c: (-c.get("orders_count", 0), c.get("date_created") or ""),
    )[0]


def decide(email, customers, orders_by_customer, get_intent):
    """Pure decision for one email's group of duplicate customer accounts.

    Returns (action, reason, survivor, duplicates):
      - "merge": duplicates have no orders, or their orders trace to the same
        Stripe customer as the survivor's orders. Safe to repoint and remove.
      - "review": a duplicate has orders that trace to a *different* Stripe
        customer than the survivor. Do not auto merge, a human should look.
      - "skip": fewer than two accounts share this email.
    """
    if len(customers) < 2:
        return ("skip", "not a duplicate", None, [])

    survivor = pick_survivor(customers)
    duplicates = [c for c in customers if c["id"] != survivor["id"]]

    survivor_stripe_ids = {
        stripe_customer_of(o, get_intent)
        for o in orders_by_customer.get(survivor["id"], [])
    }
    survivor_stripe_ids.discard(None)

    for dup in duplicates:
        dup_orders = orders_by_customer.get(dup["id"], [])
        if not dup_orders:
            continue
        dup_stripe_ids = {
            stripe_customer_of(o, get_intent) for o in dup_orders
        }
        dup_stripe_ids.discard(None)
        if dup_stripe_ids and survivor_stripe_ids and dup_stripe_ids.isdisjoint(survivor_stripe_ids):
            return (
                "review",
                f"duplicate account {dup['id']} paid through a different Stripe customer, needs a human",
                survivor,
                duplicates,
            )

    return ("merge", "same email, same payer, safe to merge", survivor, duplicates)


def list_customers():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/customers",
            params={"per_page": 100, "page": page, "orderby": "registered_date", "order": "desc"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for customer in batch:
            yield customer
        page += 1


def orders_for_customer(customer_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"customer": customer_id, "per_page": 50},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def repoint_orders(duplicate_id, survivor_id, orders):
    for order in orders:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"customer_id": survivor_id},
            auth=AUTH, timeout=30,
        ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/customers/{survivor_id}",
        json={"meta_data": [{"key": "_merged_duplicate_account", "value": str(duplicate_id)}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    reported = 0
    orders_by_customer = {}
    all_customers = list(list_customers())
    for group in group_by_email(all_customers).values():
        for customer in group:
            orders_by_customer[customer["id"]] = orders_for_customer(customer["id"])

    for email, group in group_by_email(all_customers).items():
        action, reason, survivor, duplicates = decide(email, group, orders_by_customer, get_intent)
        if action == "skip":
            continue
        if action == "review":
            log.warning("Email %s: %s", email, reason)
            reported += 1
            continue
        log.info(
            "Email %s: %s. Survivor %s, merge %s.",
            email, reason, survivor["id"], [d["id"] for d in duplicates],
        )
        if not DRY_RUN:
            for dup in duplicates:
                repoint_orders(dup["id"], survivor["id"], orders_by_customer.get(dup["id"], []))
        reported += 1
    log.info("Done. %d duplicate email group(s) %s.", reported, "to merge" if DRY_RUN else "processed")


if __name__ == "__main__":
    run()
find-duplicate-accounts.js
/**
 * Find WordPress or WooCommerce customer accounts that got duplicated for one
 * email address during checkout.
 *
 * A checkout race (a double click, a slow network retry, or two tabs) can call
 * "create account" twice before the first request finishes, so WooCommerce ends
 * up with two separate customer accounts for one shopper: one with the order
 * history, one empty. This walks recent customers, groups them by a normalized
 * email, and for each pair reads the saved Stripe PaymentIntent on their orders
 * to confirm both accounts really were paid by the same person before it
 * reports a merge plan. Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/duplicate-customer-accounts-at-checkout/
 */
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 || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function normalizeEmail(email) {
  return (email || "").trim().toLowerCase();
}

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;
}

function stripeCustomerOf(order, getIntent) {
  const intent = getIntent(intentIdOf(order));
  if (!intent) return null;
  return typeof intent.customer === "string" ? intent.customer : null;
}

export function groupByEmail(customers) {
  const groups = new Map();
  for (const customer of customers) {
    const key = normalizeEmail(customer.email);
    if (!key) continue;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(customer);
  }
  const result = {};
  for (const [email, group] of groups) {
    if (group.length > 1) result[email] = group;
  }
  return result;
}

export function pickSurvivor(customers) {
  return [...customers].sort((a, b) => {
    const byOrders = (b.orders_count || 0) - (a.orders_count || 0);
    if (byOrders !== 0) return byOrders;
    return (a.date_created || "").localeCompare(b.date_created || "");
  })[0];
}

/**
 * Pure decision for one email's group of duplicate customer accounts.
 *
 * Returns { action, reason, survivor, duplicates }:
 *   - "merge": duplicates have no orders, or their orders trace to the same
 *     Stripe customer as the survivor's orders. Safe to repoint and remove.
 *   - "review": a duplicate has orders that trace to a *different* Stripe
 *     customer than the survivor. Do not auto merge, a human should look.
 *   - "skip": fewer than two accounts share this email.
 */
export function decide(email, customers, ordersByCustomer, getIntent) {
  if (customers.length < 2) {
    return { action: "skip", reason: "not a duplicate", survivor: null, duplicates: [] };
  }

  const survivor = pickSurvivor(customers);
  const duplicates = customers.filter((c) => c.id !== survivor.id);

  const survivorStripeIds = new Set(
    (ordersByCustomer[survivor.id] || [])
      .map((o) => stripeCustomerOf(o, getIntent))
      .filter(Boolean)
  );

  for (const dup of duplicates) {
    const dupOrders = ordersByCustomer[dup.id] || [];
    if (dupOrders.length === 0) continue;
    const dupStripeIds = new Set(dupOrders.map((o) => stripeCustomerOf(o, getIntent)).filter(Boolean));
    const overlaps = [...dupStripeIds].some((id) => survivorStripeIds.has(id));
    if (dupStripeIds.size > 0 && survivorStripeIds.size > 0 && !overlaps) {
      return {
        action: "review",
        reason: `duplicate account ${dup.id} paid through a different Stripe customer, needs a human`,
        survivor,
        duplicates,
      };
    }
  }

  return { action: "merge", reason: "same email, same payer, safe to merge", survivor, duplicates };
}

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* listCustomers() {
  let page = 1;
  while (true) {
    const batch = await woo(`/customers?per_page=100&page=${page}&orderby=registered_date&order=desc`);
    if (!batch.length) return;
    for (const customer of batch) yield customer;
    page++;
  }
}

async function ordersForCustomer(customerId) {
  return woo(`/orders?customer=${customerId}&per_page=50`);
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function repointOrders(duplicateId, survivorId, orders) {
  for (const order of orders) {
    await woo(`/orders/${order.id}`, {
      method: "PUT",
      body: JSON.stringify({ customer_id: survivorId }),
    });
  }
  await woo(`/customers/${survivorId}`, {
    method: "POST",
    body: JSON.stringify({
      meta_data: [{ key: "_merged_duplicate_account", value: String(duplicateId) }],
    }),
  });
}

export async function run() {
  let reported = 0;
  const allCustomers = [];
  for await (const customer of listCustomers()) allCustomers.push(customer);

  const groups = groupByEmail(allCustomers);
  const ordersByCustomer = {};
  for (const group of Object.values(groups)) {
    for (const customer of group) {
      ordersByCustomer[customer.id] = await ordersForCustomer(customer.id);
    }
  }

  for (const [email, group] of Object.entries(groups)) {
    const { action, reason, survivor, duplicates } = decide(email, group, ordersByCustomer, getIntent);
    if (action === "skip") continue;
    if (action === "review") {
      console.warn(`Email ${email}: ${reason}`);
      reported++;
      continue;
    }
    console.log(
      `Email ${email}: ${reason}. Survivor ${survivor.id}, merge ${duplicates.map((d) => d.id)}.`
    );
    if (!DRY_RUN) {
      for (const dup of duplicates) {
        await repointOrders(dup.id, survivor.id, ordersByCustomer[dup.id] || []);
      }
    }
    reported++;
  }
  console.log(`Done. ${reported} duplicate email group(s) ${DRY_RUN ? "to merge" : "processed"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether two accounts get merged. Because we kept decide pure, and gave it a get_intent function to call instead of reaching for Stripe itself, the test needs no network and no Stripe account. It just feeds in plain objects and a stand in for Stripe, then checks the action.

test_duplicate_checkout_decide.py
from find_duplicate_accounts import decide, pick_survivor


def customer(id, orders_count=0, date_created="2026-01-01T00:00:00"):
    return {"id": id, "email": "shopper@example.com", "orders_count": orders_count, "date_created": date_created}


def order(id, intent_id="pi_1"):
    return {"id": id, "meta_data": [{"key": "_stripe_intent_id", "value": intent_id}], "transaction_id": ""}


def make_get_intent(customer_by_intent):
    def get_intent(intent_id):
        if intent_id is None or intent_id not in customer_by_intent:
            return None
        return {"id": intent_id, "customer": customer_by_intent[intent_id]}
    return get_intent


def test_merge_when_duplicate_has_no_orders():
    a, b = customer(1, orders_count=3), customer(2, orders_count=0)
    action, reason, survivor, duplicates = decide(
        "a@example.com", [a, b], {1: [order(101)], 2: []}, make_get_intent({"pi_1": "cus_survivor"})
    )
    assert action == "merge"
    assert survivor["id"] == 1


def test_merge_when_both_trace_to_same_stripe_customer():
    a, b = customer(1, orders_count=2), customer(2, orders_count=1)
    orders_by_customer = {1: [order(101, "pi_1")], 2: [order(102, "pi_2")]}
    get_intent = make_get_intent({"pi_1": "cus_same", "pi_2": "cus_same"})
    action, reason, survivor, duplicates = decide("a@example.com", [a, b], orders_by_customer, get_intent)
    assert action == "merge"


def test_review_when_stripe_customers_differ():
    a, b = customer(1, orders_count=2), customer(2, orders_count=1)
    orders_by_customer = {1: [order(101, "pi_1")], 2: [order(102, "pi_2")]}
    get_intent = make_get_intent({"pi_1": "cus_aaa", "pi_2": "cus_bbb"})
    action, reason, survivor, duplicates = decide("a@example.com", [a, b], orders_by_customer, get_intent)
    assert action == "review"


def test_pick_survivor_prefers_most_orders():
    a = customer(1, orders_count=1, date_created="2026-01-01T00:00:00")
    b = customer(2, orders_count=5, date_created="2026-02-01T00:00:00")
    assert pick_survivor([a, b])["id"] == 2
find-duplicate-accounts.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, pickSurvivor } from "./find-duplicate-accounts.js";

const customer = (id, over = {}) => ({
  id, email: "shopper@example.com", orders_count: 0, date_created: "2026-01-01T00:00:00", ...over,
});
const order = (id, intentId = "pi_1") => ({
  id, meta_data: [{ key: "_stripe_intent_id", value: intentId }], transaction_id: "",
});
function makeGetIntent(customerByIntent) {
  return (intentId) => (intentId in customerByIntent ? { id: intentId, customer: customerByIntent[intentId] } : null);
}

test("merge when duplicate has no orders", () => {
  const a = customer(1, { orders_count: 3 });
  const b = customer(2, { orders_count: 0 });
  const result = decide("a@example.com", [a, b], { 1: [order(101)], 2: [] }, makeGetIntent({ pi_1: "cus_survivor" }));
  assert.equal(result.action, "merge");
  assert.equal(result.survivor.id, 1);
});

test("merge when both trace to the same Stripe customer", () => {
  const a = customer(1, { orders_count: 2 });
  const b = customer(2, { orders_count: 1 });
  const ordersByCustomer = { 1: [order(101, "pi_1")], 2: [order(102, "pi_2")] };
  const result = decide("a@example.com", [a, b], ordersByCustomer, makeGetIntent({ pi_1: "cus_same", pi_2: "cus_same" }));
  assert.equal(result.action, "merge");
});

test("review when Stripe customers differ", () => {
  const a = customer(1, { orders_count: 2 });
  const b = customer(2, { orders_count: 1 });
  const ordersByCustomer = { 1: [order(101, "pi_1")], 2: [order(102, "pi_2")] };
  const result = decide("a@example.com", [a, b], ordersByCustomer, makeGetIntent({ pi_1: "cus_aaa", pi_2: "cus_bbb" }));
  assert.equal(result.action, "review");
});

test("pickSurvivor prefers most orders", () => {
  const a = customer(1, { orders_count: 1, date_created: "2026-01-01T00:00:00" });
  const b = customer(2, { orders_count: 5, date_created: "2026-02-01T00:00:00" });
  assert.equal(pickSurvivor([a, b]).id, 2);
});

Case studies

Mobile checkout

The double tap on a slow train wifi connection

A shopper checked out on a train, tapped "Place order" once, saw nothing happen for a few seconds, and tapped again. Both requests went through. She got one order confirmation email, but support later found two customer accounts under her email, one with the order and one empty, and she could not log in with the password she remembered because it was tied to the empty account.

The job found the pair on its next scheduled run, confirmed both would have been paid by the same Stripe customer had the empty one ever placed an order, and reported it for a one line merge. Support reset her password on the survivor account and closed the ticket in minutes.

Custom checkout field

A loyalty plugin that raced WooCommerce's own check

A store added a loyalty points plugin that hooked into checkout and ran its own quick lookup before letting WooCommerce continue. Under normal traffic it was invisible, but during a busy weekend the extra round trip was just long enough to open a window where two checkouts for one email both passed as new.

Running the job weekly caught nine duplicate pairs over a month. All nine were confirmed same payer through Stripe and merged. The loyalty plugin was later updated to reuse WooCommerce's own account check instead of doing its own.

What good looks like

After this runs on a schedule, a checkout race turns into a quiet one line report instead of a confused support ticket weeks later. Shoppers keep one account with their full order history, and any pair the job cannot confirm with confidence is left for a human, so nothing gets merged on a guess.

FAQ

Why did WooCommerce create two accounts for the same email?

A checkout race, usually a double click on the place order button, a slow connection that gets retried, or two open tabs, can send the account creation step twice before the first one finishes. WordPress allows a short window where both requests check that the email is free and both pass, so two user accounts get created for one person.

Is it safe to merge the duplicate accounts automatically?

Only when it is clearly the same person. The script keeps the account with the order history as the survivor and only auto merges a duplicate when it has no orders, or when its orders were paid through the same Stripe customer as the survivor. If the orders trace to two different Stripe customers, it is flagged for a human to check instead of merged.

What happens to the duplicate account's orders when accounts merge?

Each order on the duplicate account is repointed to the survivor account through the WooCommerce REST API, so the full order history ends up in one place. The survivor account gets a note recording which duplicate was merged into it, and the empty duplicate is left ready to delete.

Related field notes

Citations

On the problem:

  1. WordPress core: how wp_insert_user and the email uniqueness check work, and where the timing gap sits. developer.wordpress.org/reference/functions/wp_insert_user
  2. WooCommerce docs: customer accounts created at checkout and how registration during checkout is handled. woocommerce.com/document/managing-customer-accounts-and-orders
  3. WooCommerce REST API: the Customers endpoint, including email and orders_count fields. woocommerce.github.io/woocommerce-rest-api-docs

On the solution:

  1. Stripe API: retrieve a PaymentIntent and read its customer field to identify the payer. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe docs: the Customer object and how one Stripe customer can be linked back to a store's own user records. docs.stripe.com/api/customers/object
  3. WooCommerce REST API: update an order's customer_id and add a customer 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 untangle your duplicate accounts?

If this saved you a confused support ticket or a lost order history, 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