Skip to content

Reconciler Customer & Auth

Orders stay linked to the guest record after registration

A shopper checks out as a guest, then comes back later and registers with the exact same email so they can track their order properly. They expect to see it waiting for them. Instead their account is empty. The order is still there in the database, still paid, still real, but it is foreign-keyed to the old guest customer row, and the new registered account has no way to see it. Here is why Medusa v2 leaves it that way on purpose and a small script that finds every stuck order so you can drive Medusa's own consent-based transfer flow instead of guessing.

Python and Node.js Medusa Admin API Safe by default (dry run)
A person smiling at work
Photo by LumenSoft Technologies on Unsplash
The short answer

In Medusa v2's Customer module, a customer row is uniquely keyed by email and has_account together, not by email alone. A guest checkout creates a Customer with has_account: false, and the order's customer_id points at that guest row. When the same person later registers with the same email, Medusa creates a separate Customer row with has_account: true. It does not retroactively update the existing order's customer_id, so the order stays linked to the old guest record and is invisible to the new authenticated account. This is deliberate: blindly re-linking by email would let anyone claim another person's guest orders just by signing up with that email. Run a read-only script that pages through customers, groups them by email, flags every orphaned guest-plus-registered pair, and lists the stuck order ids, then drive Medusa's supported Order Transfer workflow, which asks the original order owner to confirm by email before anything moves. Full code and tests are below.

The problem in plain words

When a shopper checks out without an account, Medusa still needs a place to keep their name, email, and shipping details, so it creates a Customer row for them. That row has has_account: false, since no password was ever set. The order they just placed points its customer_id straight at that row's cus_ id.

Weeks later the same person registers, using the same email, hoping to see their order history. Medusa's Customer module treats a row keyed by (email, has_account: false) as a different record from one keyed by (email, has_account: true), so registration creates a brand new Customer row rather than promoting the guest one in place. The order never moves. It is still sitting on customer_id pointing at the guest row, and the shopper's new, authenticated account has no foreign key back to it. To them, the order simply vanished.

Guest checkout has_account: false Order created customer_id = guest cus_ weeks later Register same email new Customer row created has_account: true customer_id never updated Order stays on guest row customer_id still = guest cus_ Invisible to account new registered cus_ id has no orders of its own
The order's customer_id was set once, at checkout, and nothing revisits it. Registering later creates a second row, not an update to the first.

Why it happens

Medusa's Customer module treats (email, has_account) as the effective identity of a row, not email on its own. A few things line up to produce this exact symptom:

This is documented, intentional behavior, not a bug waiting for a silent fix. It shows up across GitHub issues #11827 and #9999, and in the community discussion asking for guests to find their orders after registering. Medusa's answer was to ship an explicit, consent-based Order Transfer feature instead of automatic re-linking. See the citations at the end for the exact threads and docs.

The key insight

Do not rewrite order.customer_id yourself, even after you have found the stuck order. Medusa's own privacy model forbids blind email-based re-linking, because it would hand out someone's order history to whoever registers with their email next. The correct action is to detect the orphaned pairs, then drive Medusa's supported Order Transfer workflow, which notifies the original guest order's owner and only completes once they accept. Detection can run unattended. The transfer request itself should always pass through a human review step before it fires.

The fix, as a flow

We never touch order.customer_id directly. The script authenticates against the Admin API, pages through every customer, groups them by lowercased email, and runs a pure function that flags the exact guest-plus-registered pattern. For every flagged pair it lists the orders still hanging off the guest id, cross-checks that those same orders are absent from the registered customer's own order list, and then, only with human approval and DRY_RUN off, calls Medusa's transfer endpoint to request the move. The transfer itself is not final until the original guest order owner accepts it by email.

List customers id, email, has_account Group by email lowercased Pure decision fn findOrphanedGuestOrders Orders on guest id? yes no, skip pair Not reported left alone Report, then request transfer DRY_RUN by default
Detection is read-only. Only a human-approved, DRY_RUN-off run ever calls the transfer endpoint, and Medusa still requires the original owner to accept before anything moves.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, change to false only after a human approves the batch
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, change to false only after a human approves the batch
2

Page through every customer

Ask for id, email, has_account, and created_at on every customer, paginated with limit and offset. This is read-only. Nothing is written in this step.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_all_customers(token):
    customers = []
    offset = 0
    limit = 200
    fields = "id,email,has_account,created_at"
    while True:
        data = admin_get(token, "/admin/customers", {
            "fields": fields,
            "limit": limit,
            "offset": offset,
        })
        customers.extend(data["customers"])
        offset += limit
        if offset >= data["count"]:
            return customers
step2.js
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function listAllCustomers(token) {
  const customers = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/customers", {
      fields: "id,email,has_account,created_at",
      limit,
      offset,
    });
    customers.push(...data.customers);
    offset += limit;
    if (offset >= data.count) return customers;
  }
}
3

Decide, with one pure function

Keep the grouping and the decision in a function that takes only customers and orders, never touches the network, and returns plain data. Group customers by lowercased email, keep only the groups with exactly one has_account: false row and one has_account: true row, then attach the order ids still pointing at the guest id. That is the specific orphaned-guest-order pattern this note is about.

decide.py
def find_orphaned_guest_orders(customers, orders):
    """Pure decision function. No I/O.

    customers: [{"id": str, "email": str, "has_account": bool}, ...]
    orders: [{"id": str, "customer_id": str, "email": str}, ...]

    Groups customers by lowercased email, keeps only email groups containing
    exactly one has_account False row and one has_account True row, then
    filters orders whose customer_id matches the guest row's id.

    Returns one record per such email group:
    {"guestCustomerId", "registeredCustomerId", "orderIds"}
    orderIds is an empty list when no orders reference the guest id.
    """
    groups = {}
    for customer in customers:
        email = (customer.get("email") or "").strip().lower()
        groups.setdefault(email, []).append(customer)

    results = []
    for rows in groups.values():
        guest_rows = [c for c in rows if c.get("has_account") is False]
        registered_rows = [c for c in rows if c.get("has_account") is True]
        if len(guest_rows) != 1 or len(registered_rows) != 1:
            continue
        guest_id = guest_rows[0]["id"]
        order_ids = [o["id"] for o in orders if o.get("customer_id") == guest_id]
        results.append({
            "guestCustomerId": guest_id,
            "registeredCustomerId": registered_rows[0]["id"],
            "orderIds": order_ids,
        })
    return results
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {Array<{id: string, email: string, has_account: boolean}>} customers
 * @param {Array<{id: string, customer_id: string, email: string}>} orders
 * @returns {Array<{guestCustomerId: string, registeredCustomerId: string, orderIds: string[]}>}
 */
export function findOrphanedGuestOrders(customers, orders) {
  const groups = new Map();
  for (const customer of customers) {
    const email = (customer.email || "").trim().toLowerCase();
    if (!groups.has(email)) groups.set(email, []);
    groups.get(email).push(customer);
  }

  const results = [];
  for (const rows of groups.values()) {
    const guestRows = rows.filter((c) => c.has_account === false);
    const registeredRows = rows.filter((c) => c.has_account === true);
    if (guestRows.length !== 1 || registeredRows.length !== 1) continue;
    const guestId = guestRows[0].id;
    const orderIds = orders.filter((o) => o.customer_id === guestId).map((o) => o.id);
    results.push({
      guestCustomerId: guestId,
      registeredCustomerId: registeredRows[0].id,
      orderIds,
    });
  }
  return results;
}
4

List the orders on the guest id and confirm the registered account has none

For every flagged pair, list the orders still on the guest cus_ id, then cross-check the registered customer's own orders to confirm those same orders are absent there. That absence is the actual symptom being reconciled, not just a guess from the customer rows alone.

step4.py
def orders_for_customer(token, customer_id):
    data = admin_get(token, "/admin/orders", {
        "customer_id": customer_id,
        "fields": "id,display_id,email,customer_id,created_at",
        "limit": 100,
    })
    return data["orders"]
step4.js
async function ordersForCustomer(token, customerId) {
  const data = await adminGet(token, "/admin/orders", {
    customer_id: customerId,
    fields: "id,display_id,email,customer_id,created_at",
    limit: 100,
  });
  return data.orders;
}
5

Request an Order Transfer instead of rewriting customer_id

Never call a write endpoint that changes order.customer_id directly. Instead call POST /admin/orders/{id}/transfer naming the registered customer as the recipient. Medusa notifies the original guest order owner by email, and the transfer only completes once they accept it through the store customer-facing accept or decline endpoints. This step only runs when DRY_RUN is false and a human has approved the batch.

apply.py
def request_order_transfer(token, order_id, registered_customer_id):
    r = requests.post(
        f"{BACKEND_URL}/admin/orders/{order_id}/transfer",
        json={"customer_id": registered_customer_id},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function requestOrderTransfer(token, orderId, registeredCustomerId) {
  const res = await fetch(`${BACKEND_URL}/admin/orders/${orderId}/transfer`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ customer_id: registeredCustomerId }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status} on POST /admin/orders/${orderId}/transfer`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together. With DRY_RUN on, which is the default, it only prints the planned transfer requests as order_id -> target customer_id and never calls the transfer endpoint. Read the printed plan, get a human to approve it, then set DRY_RUN=false to actually send the requests. Even then, each transfer still waits on the original guest order owner accepting it by email before anything changes.

Run it safe

Detection can run unattended on a schedule. Sending transfer requests cannot. Always start with DRY_RUN=true, review the printed plan, and only flip DRY_RUN=false once a human has approved the specific batch of orders. Even then, Medusa still requires the original order owner to accept the transfer by email, so nothing you run here can silently move an order on its own.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through every customer, flags orphaned guest-plus-registered pairs with a pure function, lists the stuck orders per pair, and either prints the planned transfers or requests them, depending on the dry run flag. It is safe to run again and again because detection never writes, and the transfer step only ever asks Medusa to start a consent-based transfer, never to force one.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
reconcile_guest_orders.py
"""Find Medusa orders still linked to an orphaned guest customer record.

Medusa v2 keys a Customer row by (email, has_account), not by email alone. A
guest checkout creates a Customer with has_account false, and the order's
customer_id points at that row. When the same person later registers with the
same email, Medusa creates a separate Customer row with has_account true. It
does not retroactively update the existing order's customer_id, so the order
stays linked to the old guest record, invisible to the new authenticated
account.

Medusa deliberately does not auto-merge these on registration, since blindly
re-linking by email would let anyone claim another person's guest orders just
by signing up with that email. Instead Medusa ships a consent-based Order
Transfer workflow: an admin-initiated request that notifies the original
guest order owner by email, and only completes once they accept it.

This script only reads by default. It pages through every customer, flags the
orphaned guest-plus-registered pattern, lists the stuck orders per pair, and
prints the planned transfer requests as order_id -> target customer_id.
Nothing is sent to Medusa unless DRY_RUN is false and a human has approved
the batch.
Run on a schedule for detection. Only run with DRY_RUN=false after review.
"""
import os
import logging
import requests

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

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CUSTOMER_FIELDS = "id,email,has_account,created_at"
ORDER_FIELDS = "id,display_id,email,customer_id,created_at"


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def find_orphaned_guest_orders(customers, orders):
    """Pure decision function. No I/O.

    customers: [{"id": str, "email": str, "has_account": bool}, ...]
    orders: [{"id": str, "customer_id": str, "email": str}, ...]

    Groups customers by lowercased email, keeps only email groups containing
    exactly one has_account False row and one has_account True row, then
    filters orders whose customer_id matches the guest row's id.

    Returns one record per such email group:
    {"guestCustomerId", "registeredCustomerId", "orderIds"}
    orderIds is an empty list when no orders reference the guest id.
    """
    groups = {}
    for customer in customers:
        email = (customer.get("email") or "").strip().lower()
        groups.setdefault(email, []).append(customer)

    results = []
    for rows in groups.values():
        guest_rows = [c for c in rows if c.get("has_account") is False]
        registered_rows = [c for c in rows if c.get("has_account") is True]
        if len(guest_rows) != 1 or len(registered_rows) != 1:
            continue
        guest_id = guest_rows[0]["id"]
        order_ids = [o["id"] for o in orders if o.get("customer_id") == guest_id]
        results.append({
            "guestCustomerId": guest_id,
            "registeredCustomerId": registered_rows[0]["id"],
            "orderIds": order_ids,
        })
    return results


def list_all_customers(token):
    customers = []
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/customers", {
            "fields": CUSTOMER_FIELDS,
            "limit": limit,
            "offset": offset,
        })
        customers.extend(data["customers"])
        offset += limit
        if offset >= data["count"]:
            return customers


def orders_for_customer(token, customer_id):
    data = admin_get(token, "/admin/orders", {
        "customer_id": customer_id,
        "fields": ORDER_FIELDS,
        "limit": 100,
    })
    return data["orders"]


def request_order_transfer(token, order_id, registered_customer_id):
    r = requests.post(
        f"{BACKEND_URL}/admin/orders/{order_id}/transfer",
        json={"customer_id": registered_customer_id},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_admin_token()
    customers = list_all_customers(token)

    all_orders = []
    for customer in customers:
        if customer.get("has_account") is False:
            all_orders.extend(orders_for_customer(token, customer["id"]))

    pairs = find_orphaned_guest_orders(customers, all_orders)

    planned = 0
    for pair in pairs:
        if not pair["orderIds"]:
            continue
        for order_id in pair["orderIds"]:
            planned += 1
            log.warning(
                "Planned transfer: order %s -> customer %s. %s",
                order_id, pair["registeredCustomerId"],
                "dry run, not sent" if DRY_RUN else "requesting transfer",
            )
            if not DRY_RUN:
                request_order_transfer(token, order_id, pair["registeredCustomerId"])

    log.info("Done. %d planned transfer(s) across %d orphaned pair(s).", planned, len(pairs))
    return pairs


if __name__ == "__main__":
    run()
reconcile-guest-orders.js
/**
 * Find Medusa orders still linked to an orphaned guest customer record.
 *
 * Medusa v2 keys a Customer row by (email, has_account), not by email alone. A
 * guest checkout creates a Customer with has_account false, and the order's
 * customer_id points at that row. When the same person later registers with
 * the same email, Medusa creates a separate Customer row with has_account
 * true. It does not retroactively update the existing order's customer_id, so
 * the order stays linked to the old guest record, invisible to the new
 * authenticated account.
 *
 * Medusa deliberately does not auto-merge these on registration, since
 * blindly re-linking by email would let anyone claim another person's guest
 * orders just by signing up with that email. Instead Medusa ships a
 * consent-based Order Transfer workflow: an admin-initiated request that
 * notifies the original guest order owner by email, and only completes once
 * they accept it.
 *
 * This script only reads by default. It pages through every customer, flags
 * the orphaned guest-plus-registered pattern, lists the stuck orders per
 * pair, and prints the planned transfer requests as order_id -> target
 * customer_id. Nothing is sent to Medusa unless DRY_RUN is false and a human
 * has approved the batch.
 * Run on a schedule for detection. Only run with DRY_RUN=false after review.
 *
 * Guide: https://www.allanninal.dev/medusa/orders-stuck-on-guest-customer/
 */
import { pathToFileURL } from "node:url";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CUSTOMER_FIELDS = "id,email,has_account,created_at";
const ORDER_FIELDS = "id,display_id,email,customer_id,created_at";

/**
 * Pure decision function. No I/O.
 *
 * @param {Array<{id: string, email: string, has_account: boolean}>} customers
 * @param {Array<{id: string, customer_id: string, email: string}>} orders
 * @returns {Array<{guestCustomerId: string, registeredCustomerId: string, orderIds: string[]}>}
 */
export function findOrphanedGuestOrders(customers, orders) {
  const groups = new Map();
  for (const customer of customers) {
    const email = (customer.email || "").trim().toLowerCase();
    if (!groups.has(email)) groups.set(email, []);
    groups.get(email).push(customer);
  }

  const results = [];
  for (const rows of groups.values()) {
    const guestRows = rows.filter((c) => c.has_account === false);
    const registeredRows = rows.filter((c) => c.has_account === true);
    if (guestRows.length !== 1 || registeredRows.length !== 1) continue;
    const guestId = guestRows[0].id;
    const orderIds = orders.filter((o) => o.customer_id === guestId).map((o) => o.id);
    results.push({
      guestCustomerId: guestId,
      registeredCustomerId: registeredRows[0].id,
      orderIds,
    });
  }
  return results;
}

async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function listAllCustomers(token) {
  const customers = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/customers", {
      fields: CUSTOMER_FIELDS,
      limit,
      offset,
    });
    customers.push(...data.customers);
    offset += limit;
    if (offset >= data.count) return customers;
  }
}

async function ordersForCustomer(token, customerId) {
  const data = await adminGet(token, "/admin/orders", {
    customer_id: customerId,
    fields: ORDER_FIELDS,
    limit: 100,
  });
  return data.orders;
}

async function requestOrderTransfer(token, orderId, registeredCustomerId) {
  const res = await fetch(`${BACKEND_URL}/admin/orders/${orderId}/transfer`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ customer_id: registeredCustomerId }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status} on POST /admin/orders/${orderId}/transfer`);
  return res.json();
}

export async function run() {
  const token = await getAdminToken();
  const customers = await listAllCustomers(token);

  const allOrders = [];
  for (const customer of customers) {
    if (customer.has_account === false) {
      allOrders.push(...(await ordersForCustomer(token, customer.id)));
    }
  }

  const pairs = findOrphanedGuestOrders(customers, allOrders);

  let planned = 0;
  for (const pair of pairs) {
    if (!pair.orderIds.length) continue;
    for (const orderId of pair.orderIds) {
      planned++;
      console.warn(
        `Planned transfer: order ${orderId} -> customer ${pair.registeredCustomerId}. ${DRY_RUN ? "dry run, not sent" : "requesting transfer"}`
      );
      if (!DRY_RUN) await requestOrderTransfer(token, orderId, pair.registeredCustomerId);
    }
  }

  console.log(`Done. ${planned} planned transfer(s) across ${pairs.length} orphaned pair(s).`);
  return pairs;
}

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

Add a test

find_orphaned_guest_orders is the part most worth testing, because it decides which email groups are the exact orphaned guest pattern versus a single guest, a single registered account, or two registered rows sharing an email. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture rows and checks the answer.

test_guest_orphaned_orders.py
from reconcile_guest_orders import find_orphaned_guest_orders


def customer(cid, email, has_account):
    return {"id": cid, "email": email, "has_account": has_account}


def order(oid, customer_id, email):
    return {"id": oid, "customer_id": customer_id, "email": email}


def test_single_guest_with_no_registered_row_is_not_flagged():
    customers = [customer("cus_1", "a@example.com", False)]
    orders = [order("order_1", "cus_1", "a@example.com")]
    assert find_orphaned_guest_orders(customers, orders) == []


def test_single_registered_row_is_not_flagged():
    customers = [customer("cus_1", "a@example.com", True)]
    assert find_orphaned_guest_orders(customers, []) == []


def test_guest_plus_registered_pair_is_flagged_with_its_orders():
    customers = [
        customer("cus_guest", "a@example.com", False),
        customer("cus_reg", "a@example.com", True),
    ]
    orders = [
        order("order_1", "cus_guest", "a@example.com"),
        order("order_2", "cus_guest", "a@example.com"),
        order("order_3", "cus_reg", "a@example.com"),
    ]
    result = find_orphaned_guest_orders(customers, orders)
    assert len(result) == 1
    assert result[0]["guestCustomerId"] == "cus_guest"
    assert result[0]["registeredCustomerId"] == "cus_reg"
    assert sorted(result[0]["orderIds"]) == ["order_1", "order_2"]


def test_pair_with_no_orders_on_guest_id_returns_empty_order_list():
    customers = [
        customer("cus_guest", "a@example.com", False),
        customer("cus_reg", "a@example.com", True),
    ]
    result = find_orphaned_guest_orders(customers, [])
    assert result[0]["orderIds"] == []


def test_email_is_normalized_before_grouping():
    customers = [
        customer("cus_guest", "  A@Example.com ", False),
        customer("cus_reg", "a@example.com", True),
    ]
    orders = [order("order_1", "cus_guest", "a@example.com")]
    result = find_orphaned_guest_orders(customers, orders)
    assert len(result) == 1
    assert result[0]["orderIds"] == ["order_1"]


def test_two_registered_rows_sharing_email_is_not_this_pattern():
    customers = [
        customer("cus_reg1", "a@example.com", True),
        customer("cus_reg2", "a@example.com", True),
    ]
    assert find_orphaned_guest_orders(customers, []) == []


def test_two_guest_rows_sharing_email_is_not_this_pattern():
    customers = [
        customer("cus_g1", "a@example.com", False),
        customer("cus_g2", "a@example.com", False),
    ]
    assert find_orphaned_guest_orders(customers, []) == []


def test_different_emails_are_separate_groups():
    customers = [
        customer("cus_1", "a@example.com", False),
        customer("cus_2", "b@example.com", True),
    ]
    assert find_orphaned_guest_orders(customers, []) == []


def test_empty_input_returns_empty_list():
    assert find_orphaned_guest_orders([], []) == []
orphaned-guest-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedGuestOrders } from "./reconcile-guest-orders.js";

const customer = (id, email, hasAccount) => ({ id, email, has_account: hasAccount });
const order = (id, customerId, email) => ({ id, customer_id: customerId, email });

test("single guest with no registered row is not flagged", () => {
  const customers = [customer("cus_1", "a@example.com", false)];
  const orders = [order("order_1", "cus_1", "a@example.com")];
  assert.deepEqual(findOrphanedGuestOrders(customers, orders), []);
});

test("single registered row is not flagged", () => {
  const customers = [customer("cus_1", "a@example.com", true)];
  assert.deepEqual(findOrphanedGuestOrders(customers, []), []);
});

test("guest plus registered pair is flagged with its orders", () => {
  const customers = [
    customer("cus_guest", "a@example.com", false),
    customer("cus_reg", "a@example.com", true),
  ];
  const orders = [
    order("order_1", "cus_guest", "a@example.com"),
    order("order_2", "cus_guest", "a@example.com"),
    order("order_3", "cus_reg", "a@example.com"),
  ];
  const result = findOrphanedGuestOrders(customers, orders);
  assert.equal(result.length, 1);
  assert.equal(result[0].guestCustomerId, "cus_guest");
  assert.equal(result[0].registeredCustomerId, "cus_reg");
  assert.deepEqual(result[0].orderIds.sort(), ["order_1", "order_2"]);
});

test("pair with no orders on guest id returns empty order list", () => {
  const customers = [
    customer("cus_guest", "a@example.com", false),
    customer("cus_reg", "a@example.com", true),
  ];
  const result = findOrphanedGuestOrders(customers, []);
  assert.deepEqual(result[0].orderIds, []);
});

test("email is normalized before grouping", () => {
  const customers = [
    customer("cus_guest", "  A@Example.com ", false),
    customer("cus_reg", "a@example.com", true),
  ];
  const orders = [order("order_1", "cus_guest", "a@example.com")];
  const result = findOrphanedGuestOrders(customers, orders);
  assert.equal(result.length, 1);
  assert.deepEqual(result[0].orderIds, ["order_1"]);
});

test("two registered rows sharing email is not this pattern", () => {
  const customers = [
    customer("cus_reg1", "a@example.com", true),
    customer("cus_reg2", "a@example.com", true),
  ];
  assert.deepEqual(findOrphanedGuestOrders(customers, []), []);
});

test("two guest rows sharing email is not this pattern", () => {
  const customers = [
    customer("cus_g1", "a@example.com", false),
    customer("cus_g2", "a@example.com", false),
  ];
  assert.deepEqual(findOrphanedGuestOrders(customers, []), []);
});

test("different emails are separate groups", () => {
  const customers = [
    customer("cus_1", "a@example.com", false),
    customer("cus_2", "b@example.com", true),
  ];
  assert.deepEqual(findOrphanedGuestOrders(customers, []), []);
});

test("empty input returns empty list", () => {
  assert.deepEqual(findOrphanedGuestOrders([], []), []);
});

Case studies

Support escalation

The repeat buyer who registered to track a delivery

A furniture brand let people check out as a guest, then encouraged them to register once the order shipped so they could track it. A customer registered the day after her order left the warehouse, opened her new account to check the tracking number, and found nothing there. Support had to search orders by email manually, every time, because the account itself showed zero orders.

Running the detection script in dry run surfaced the exact pair, her guest customer id, her registered customer id, and the order id still stuck on the guest row. Support requested an order transfer through the Admin, she received the confirmation email, accepted it, and the order appeared in her account within minutes, without anyone touching the database directly.

Data audit

A subscription store finding out how widespread the gap was

A subscription box brand suspected this was happening but had no idea how often, since nobody files a ticket unless they specifically go looking for an old order. Before investing in an in-app "claim your order" flow, they wanted real numbers on how many guest-then-registered pairs existed and how many orders were affected.

The script's report, built entirely from the pure decision function, gave them a clean count of orphaned pairs and orders per pair, all without a single write to Medusa. That count justified building the claim flow, and the report itself became the initial backlog of transfer requests to send once it shipped.

What good looks like

After this runs on a schedule, every orphaned guest order is visible in one report instead of surfacing only when a customer complains that their history is empty. Nothing is rewritten silently. Every reconnection goes through Medusa's own Order Transfer workflow, so the original guest order owner is the one who confirms it, and your audit trail shows a deliberate, consented transfer rather than a script quietly editing a foreign key. Detection can run unattended. Sending the actual transfer requests should always wait on a human looking at the printed plan first.

FAQ

Why does my order stay linked to a guest customer after I register?

Medusa v2 keys a Customer row by email and has_account together, not by email alone. Your guest checkout created a Customer with has_account false, and the order's customer_id points at that row. Registering with the same email creates a separate Customer row with has_account true. Medusa does not retroactively repoint the order, so it stays on the old guest row and is invisible to your new account.

Why does not Medusa just re-link the order automatically when I register?

Because email alone is not proof of identity. If Medusa silently re-linked every order sharing an email address, anyone could claim someone else's guest orders just by registering with that email, which is a data-privacy hole. Instead Medusa ships a consent-based Order Transfer workflow that requires the original order owner to confirm the transfer by email before it completes.

How do you find orders stuck on an orphaned guest customer record?

Page through GET /admin/customers with fields for id, email, and has_account, group the rows by lowercased email, and keep only groups with exactly one has_account false row and one has_account true row. For each guest id found, list its orders with GET /admin/orders?customer_id={guest_cus_id} to see which orders are still stuck there, and confirm they are absent from the registered customer's own order list.

Related field notes

Citations

On the problem:

  1. Bug: Orders incorrectly associated with guest accounts instead of registered customers sharing the same email. Medusa GitHub Issue #11827. github.com/medusajs/medusa/issues/11827
  2. Bug: Duplicate Customer Records Affecting Order Visibility for Registered Users. Medusa GitHub Issue #9999. github.com/medusajs/medusa/issues/9999
  3. Discussion: List all orders by email so guest can find their orders once registered. Medusa GitHub Discussion #4437. github.com/medusajs/medusa/discussions/4437

On the solution:

  1. Medusa Documentation: Customer Accounts, has_account and guest versus registered customer uniqueness per email. docs.medusajs.com/resources/commerce-modules/customer/customer-accounts
  2. Medusa.js Blog: Announcing privacy-safe Order Transfers. medusajs.com/blog/announcing-order-transfers
  3. Medusa V2 Admin API Reference: admin/customers and admin/orders endpoints. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows 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 a stuck order?

If this saved you a support escalation or a confusing "my order disappeared" ticket, 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 Medusa field notes