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.
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.
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:
- A shopper double clicks "Place order" on a slow connection, and the browser sends the checkout request twice before the page can respond and disable the button.
- A flaky mobile network drops the response to the first request, so the browser or a service worker retries it, and both the original and the retry reach the server.
- The same cart is open in two browser tabs, and the shopper completes checkout in both without realizing it.
- A plugin or custom checkout field hooks into
woocommerce_created_customeror the registration flow and does its own email lookup slightly out of step with WooCommerce's own check, opening a second small window for the race.
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.
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.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to customers and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true" // start safe, change to false to write
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.
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}
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;
}
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.
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()
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`);
}
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.
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)
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 };
}
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.
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()
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) }],
}),
});
}
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.
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 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 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.
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
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
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.
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.
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:
- WordPress core: how
wp_insert_userand the email uniqueness check work, and where the timing gap sits. developer.wordpress.org/reference/functions/wp_insert_user - WooCommerce docs: customer accounts created at checkout and how registration during checkout is handled. woocommerce.com/document/managing-customer-accounts-and-orders
- WooCommerce REST API: the Customers endpoint, including
emailandorders_countfields. woocommerce.github.io/woocommerce-rest-api-docs
On the solution:
- Stripe API: retrieve a PaymentIntent and read its
customerfield to identify the payer. docs.stripe.com/api/payment_intents/retrieve - 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
- WooCommerce REST API: update an order's
customer_idand 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.
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