Skip to content

Reconciler

Guest order becomes untrackable after registering an account with the same email

Someone checks out as a guest, gets their confirmation email, then a week later decides to create a real account using that exact same email address. They expect their old order to be sitting right there in their order history. It is not. Nothing crashes, nothing warns anyone, the order is simply gone from the account's point of view, even though the email matches perfectly. Here is why PrestaShop leaves that order behind and a script that finds every one of these orphaned orders so a human can decide what to do with them.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
A support agent with a headset
Photo by Vagaro on Unsplash
The short answer

A guest checkout creates a customers row with is_guest=1, and the resulting order stores that row's id as a fixed id_customer. When the same person registers a full account later with the identical email, PrestaShop does not always detect the existing guest record and convert it in place. It can instead create a second, separate customers row with is_guest=0, leaving the old order's id_customer still pointing at the original guest id. The order never shows up in the new account's history because nothing ever moved it. Run a Python or Node.js script that pulls guest customers and registered customers, matches them by normalized email, and lists every guest order that got left behind. Full code, tests, and citations are below.

The problem in plain words

Guest checkout in PrestaShop exists so a shopper can buy something without committing to an account. Under the hood it still needs a customer record to hang the order on, so PrestaShop creates one anyway, just marked is_guest=1, and the order's id_customer column points straight at that row's id. That link never changes on its own.

The trouble starts when the same person comes back and registers a proper account with that same email, either by clicking "create an account" in the guest order confirmation or tracking email, or by registering fresh at checkout later. The intent is obvious: this should become their account, and their past guest order should become visible in it. But the guest-to-account logic in AuthController and the order confirmation flow does not reliably look for an existing customer by email before or while creating the new account. So instead of converting the guest row in place, PrestaShop can leave the original guest row alone and add a brand new, separate customers row with is_guest=0. The order's id_customer still points at the old guest id, which now has no working login attached to it in any way the shopper can reach, so the order is orphaned in every sense that matters to the customer.

Guest checkout jane@example.com, is_guest=1 Order, id_customer=101 points at guest row 101 Registers real account same email, no re-check by AuthController New row, id 205 is_guest=0, same email still id_customer=101 order history is empty
The order never moved. It still points at the original guest customer id, which the shopper can no longer see through their new, separate account.

Why it happens

The root cause is that PrestaShop's order model stores a fixed foreign key, and the guest-to-account transform path does not always re-check for an existing customer by email before or while creating the new account row. Documented ways it shows up:

None of this needs anything unusual to happen. Guest checkout is a normal path precisely because plenty of shoppers do not want to commit to an account up front, and plenty of those same shoppers change their mind later once they realize they will be back. See the citations at the end for the exact issues and docs.

The key insight

Relinking an order to a different id_customer is not something a script should do blindly. It touches financial and order records directly, and PrestaShop's own core still has open bugs in this exact area, so an automated fix could easily point an order at the wrong account or miss a duplicated address. The safe pattern is not "relink every orphaned order automatically." It is "detect and report every orphaned order for a human," and only apply the actual relink as a DRY_RUN guarded, human-approved batch write. The guest customer row itself is never deleted or merged automatically, since that decision has GDPR implications a script has no business making on its own.

The fix, as a flow

We do not touch orders or customers directly by default. We add a job that pulls every guest customer and every registered customer, matches them by normalized email, and for each match lists the guest orders that are still sitting on the old guest id while confirming the real account genuinely has none for that period. Everything becomes a report row for a human to review, and the optional relink write only runs when a human has approved it and DRY_RUN is explicitly turned off.

List guest and real customers filter[is_guest]=1 and =0 Group by normalized email lowercase, trim, done in client find_orphaned_guest_orders match guest id and real id per email Guest orders found? yes no, move on Report DRY_RUN No auto-relink: only a human-approved, DRY_RUN guarded PUT ever changes id_customer.
The job only ever reads and reports by default. Changing which customer id an order belongs to stays a guarded, human-approved step.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with read access to customers and orders, plus write access to orders only if you plan to apply the guarded relink later. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   # start safe, only reports by default
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, only reports by default
2

Pull the guest customers and the real customers

Call GET /api/customers?filter[is_guest]=1&display=full&output_format=JSON to list every guest row, then call the same endpoint with filter[is_guest]=0 to list every registered row. Basic auth uses the webservice key as the username with a blank password.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()

def guest_customers():
    data = api_get("customers", params={"filter[is_guest]": "1", "display": "full"})
    return data.get("customers") or []

def real_customers():
    data = api_get("customers", params={"filter[is_guest]": "0", "display": "full"})
    return data.get("customers") or []
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function guestCustomers() {
  const data = await apiGet("customers", { "filter[is_guest]": "1", display: "full" });
  return data.customers || [];
}

async function realCustomers() {
  const data = await apiGet("customers", { "filter[is_guest]": "0", display: "full" });
  return data.customers || [];
}
3

Pull the orders for each candidate guest id

Once you know which emails exist on both a guest row and a real row, call GET /api/orders?filter[id_customer]={guest_id}&display=full&output_format=JSON for each guest id in that set, and separately confirm the matching real account genuinely has no orders for that period with the same call against the real id. That second check keeps the report honest, since it means the order is truly missing rather than merely also listed elsewhere.

step3.py
def orders_for_customer(id_customer):
    data = api_get("orders", params={"filter[id_customer]": id_customer, "display": "full"})
    return data.get("orders") or []
step3.js
async function ordersForCustomer(idCustomer) {
  const data = await apiGet("orders", { "filter[id_customer]": idCustomer, display: "full" });
  return data.orders || [];
}
4

Decide, with one pure function

Keep the decision in its own function that takes the already-fetched guest customers, real customers, and orders, and returns a relink plan with no network calls of its own. It groups both customer lists by lowercased and trimmed email, matches any email present in both groups, and for each match filters the orders list down to the ones whose id_customer equals the guest id. Every match becomes one plan entry ready to hand to the guarded repair step.

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

def find_orphaned_guest_orders(guest_customers, real_customers, orders):
    guest_by_email = {}
    for c in guest_customers:
        guest_by_email.setdefault(normalize_email(c.get("email")), []).append(c)

    real_by_email = {}
    for c in real_customers:
        real_by_email.setdefault(normalize_email(c.get("email")), []).append(c)

    plan = []
    for email, guest_rows in guest_by_email.items():
        real_rows = real_by_email.get(email)
        if not real_rows:
            continue
        guest_id = guest_rows[0].get("id")
        real_id = real_rows[0].get("id")
        for order in orders:
            if str(order.get("id_customer")) == str(guest_id):
                plan.append({
                    "id_order": order.get("id"),
                    "current_id_customer": guest_id,
                    "target_id_customer": real_id,
                    "email": email,
                })
    return plan
decide.js
export function normalizeEmail(email) {
  return String(email || "").trim().toLowerCase();
}

export function findOrphanedGuestOrders(guestCustomers, realCustomers, orders) {
  const guestByEmail = new Map();
  for (const c of guestCustomers) {
    const key = normalizeEmail(c.email);
    if (!guestByEmail.has(key)) guestByEmail.set(key, []);
    guestByEmail.get(key).push(c);
  }

  const realByEmail = new Map();
  for (const c of realCustomers) {
    const key = normalizeEmail(c.email);
    if (!realByEmail.has(key)) realByEmail.set(key, []);
    realByEmail.get(key).push(c);
  }

  const plan = [];
  for (const [email, guestRows] of guestByEmail) {
    const realRows = realByEmail.get(email);
    if (!realRows || realRows.length === 0) continue;
    const guestId = guestRows[0].id;
    const realId = realRows[0].id;
    for (const order of orders) {
      if (String(order.id_customer) === String(guestId)) {
        plan.push({
          id_order: order.id,
          current_id_customer: guestId,
          target_id_customer: realId,
          email,
        });
      }
    }
  }
  return plan;
}
5

Only ever relink as a guarded, human-approved write

If a write is ever wanted, fetch the order's full body with GET /api/orders/{id_order}?output_format=JSON, change only its id_customer from the guest id to the matching real id, adjust id_address_delivery or id_address_invoice if those addresses were duplicated under the guest id, and send the full body back with PUT /api/orders/{id_order}. Never delete or merge the guest customers row itself. That decision has GDPR implications and stays with a human.

relink.py
def relink_order(id_order, target_id_customer):
    data = api_get(f"orders/{id_order}", params={"display": "full"})
    order = data["order"]
    order["id_customer"] = target_id_customer
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{id_order}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
relink.js
async function relinkOrder(idOrder, targetIdCustomer) {
  const data = await apiGet(`orders/${idOrder}`, { display: "full" });
  const order = data.order;
  order.id_customer = targetIdCustomer;
  const res = await fetch(`${PRESTASHOP_URL}/api/orders/${idOrder}?output_format=JSON`, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together: pull guest customers, real customers, and every order tied to a candidate guest id, run find_orphaned_guest_orders, and log a plan row for every orphaned order. With DRY_RUN true (the default), it only logs the planned {id_order, from_id_customer, to_id_customer} change and never sends the PUT. With DRY_RUN false, it additionally relinks each order in the plan, once a human has reviewed the batch. Run it on a schedule that matches how often guests register accounts, for example once a day.

Run it safe

Never relink an order without a human reviewing the batch first, and never delete or merge the orphaned guest customers row automatically. Relinking touches financial and order records directly, PrestaShop's own core still has open bugs in this exact area, and there is no supported "merge customer" endpoint. Report by default, and only flip DRY_RUN off once someone has actually looked at the plan.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pulls guest and real customers plus their orders, reports every orphaned order it finds, and only ever sends the relink PUT when DRY_RUN is explicitly false.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
find_orphaned_orders.py
"""Detect PrestaShop guest orders orphaned after the same email registers a real account.

A guest checkout creates a customers row with is_guest=1, and the resulting order stores
that row's id as a fixed id_customer foreign key. When the same person later registers a
full account with the identical email, either through the "create an account" link in the
order confirmation or guest-tracking email, or by registering fresh at checkout, PrestaShop
does not always detect the existing guest record and transform it in place. It can instead
create a second, separate customers row with is_guest=0 and a new id. The old guest order's
id_customer keeps pointing at the original guest customer id, so the order never appears in
the new logged-in account's order history even though the emails match exactly.

This script only reads and reports by default. Relinking an order to a different id_customer
touches financial and order records directly, and PrestaShop's own core has open bugs in this
exact area, so it is unsafe for an unattended script to do automatically. The only write this
script ever performs is changing id_customer on an order already confirmed orphaned, and only
when DRY_RUN is explicitly set to false. The guest customers row itself is never deleted or
merged, since that decision has GDPR implications and stays with a human.

Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


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


def find_orphaned_guest_orders(guest_customers, real_customers, orders):
    """Pure decision function, no I/O.

    guest_customers is a list of customer dicts with is_guest=1, real_customers is a list
    of customer dicts with is_guest=0, and orders is a list of already-fetched order dicts.
    Groups both customer lists by lowercased and trimmed email. For each email present in
    both groups, takes the guest's id_customer and the real account's id_customer, filters
    orders where order['id_customer'] equals the guest id, and returns a list of
    {id_order, current_id_customer, target_id_customer, email} dicts, one per orphaned
    order, ready to hand to the guarded repair step.
    """
    guest_by_email = {}
    for c in guest_customers:
        guest_by_email.setdefault(normalize_email(c.get("email")), []).append(c)

    real_by_email = {}
    for c in real_customers:
        real_by_email.setdefault(normalize_email(c.get("email")), []).append(c)

    plan = []
    for email, guest_rows in guest_by_email.items():
        real_rows = real_by_email.get(email)
        if not real_rows:
            continue
        guest_id = guest_rows[0].get("id")
        real_id = real_rows[0].get("id")
        for order in orders:
            if str(order.get("id_customer")) == str(guest_id):
                plan.append({
                    "id_order": order.get("id"),
                    "current_id_customer": guest_id,
                    "target_id_customer": real_id,
                    "email": email,
                })
    return plan


def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def guest_customers():
    data = api_get("customers", params={"filter[is_guest]": "1", "display": "full"})
    return data.get("customers") or []


def real_customers():
    data = api_get("customers", params={"filter[is_guest]": "0", "display": "full"})
    return data.get("customers") or []


def orders_for_customer(id_customer):
    data = api_get("orders", params={"filter[id_customer]": id_customer, "display": "full"})
    return data.get("orders") or []


def relink_order(id_order, target_id_customer):
    data = api_get(f"orders/{id_order}", params={"display": "full"})
    order = data["order"]
    order["id_customer"] = target_id_customer
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{id_order}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    guests = guest_customers()
    reals = real_customers()

    real_emails = {normalize_email(c.get("email")) for c in reals}
    candidate_guest_ids = [
        c.get("id") for c in guests if normalize_email(c.get("email")) in real_emails
    ]

    orders = []
    for guest_id in candidate_guest_ids:
        orders.extend(orders_for_customer(guest_id))

    plan = find_orphaned_guest_orders(guests, reals, orders)

    for entry in plan:
        log.warning(
            "Orphaned guest order found. id_order=%s from_id_customer=%s to_id_customer=%s email=%s %s",
            entry["id_order"], entry["current_id_customer"], entry["target_id_customer"],
            entry["email"], "(dry run, not applied)" if DRY_RUN else "(relinking)",
        )
        if not DRY_RUN:
            relink_order(entry["id_order"], entry["target_id_customer"])

    log.info(
        "Done. %d orphaned order(s) found. DRY_RUN=%s (relink only applied when explicitly "
        "false, and never merges or deletes the guest customer row).",
        len(plan), DRY_RUN,
    )


if __name__ == "__main__":
    run()
find-orphaned-orders.js
/**
 * Detect PrestaShop guest orders orphaned after the same email registers a real account.
 *
 * A guest checkout creates a customers row with is_guest=1, and the resulting order stores
 * that row's id as a fixed id_customer foreign key. When the same person later registers a
 * full account with the identical email, either through the "create an account" link in the
 * order confirmation or guest-tracking email, or by registering fresh at checkout, PrestaShop
 * does not always detect the existing guest record and transform it in place. It can instead
 * create a second, separate customers row with is_guest=0 and a new id. The old guest order's
 * id_customer keeps pointing at the original guest customer id, so the order never appears in
 * the new logged-in account's order history even though the emails match exactly.
 *
 * This script only reads and reports by default. Relinking an order to a different id_customer
 * touches financial and order records directly, and PrestaShop's own core has open bugs in this
 * exact area, so it is unsafe for an unattended script to do automatically. The only write this
 * script ever performs is changing id_customer on an order already confirmed orphaned, and only
 * when DRY_RUN is explicitly set to false. The guest customers row itself is never deleted or
 * merged, since that decision has GDPR implications and stays with a human.
 *
 * Guide: https://www.allanninal.dev/prestashop/guest-order-untrackable-after-registration/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

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

/**
 * Pure decision function, no I/O.
 *
 * guestCustomers is an array of customer objects with is_guest=1, realCustomers is an array
 * of customer objects with is_guest=0, and orders is an array of already-fetched order
 * objects. Groups both customer lists by lowercased and trimmed email. For each email
 * present in both groups, takes the guest's id_customer and the real account's id_customer,
 * filters orders where order.id_customer equals the guest id, and returns an array of
 * {id_order, current_id_customer, target_id_customer, email} objects, one per orphaned
 * order, ready to hand to the guarded repair step.
 */
export function findOrphanedGuestOrders(guestCustomers, realCustomers, orders) {
  const guestByEmail = new Map();
  for (const c of guestCustomers) {
    const key = normalizeEmail(c.email);
    if (!guestByEmail.has(key)) guestByEmail.set(key, []);
    guestByEmail.get(key).push(c);
  }

  const realByEmail = new Map();
  for (const c of realCustomers) {
    const key = normalizeEmail(c.email);
    if (!realByEmail.has(key)) realByEmail.set(key, []);
    realByEmail.get(key).push(c);
  }

  const plan = [];
  for (const [email, guestRows] of guestByEmail) {
    const realRows = realByEmail.get(email);
    if (!realRows || realRows.length === 0) continue;
    const guestId = guestRows[0].id;
    const realId = realRows[0].id;
    for (const order of orders) {
      if (String(order.id_customer) === String(guestId)) {
        plan.push({
          id_order: order.id,
          current_id_customer: guestId,
          target_id_customer: realId,
          email,
        });
      }
    }
  }
  return plan;
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function guestCustomers() {
  const data = await apiGet("customers", { "filter[is_guest]": "1", display: "full" });
  return data.customers || [];
}

async function realCustomers() {
  const data = await apiGet("customers", { "filter[is_guest]": "0", display: "full" });
  return data.customers || [];
}

async function ordersForCustomer(idCustomer) {
  const data = await apiGet("orders", { "filter[id_customer]": idCustomer, display: "full" });
  return data.orders || [];
}

async function relinkOrder(idOrder, targetIdCustomer) {
  const data = await apiGet(`orders/${idOrder}`, { display: "full" });
  const order = data.order;
  order.id_customer = targetIdCustomer;
  const res = await fetch(`${PRESTASHOP_URL}/api/orders/${idOrder}?output_format=JSON`, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
  return res.json();
}

export async function run() {
  const guests = await guestCustomers();
  const reals = await realCustomers();

  const realEmails = new Set(reals.map((c) => normalizeEmail(c.email)));
  const candidateGuestIds = guests
    .filter((c) => realEmails.has(normalizeEmail(c.email)))
    .map((c) => c.id);

  const orders = [];
  for (const guestId of candidateGuestIds) {
    orders.push(...(await ordersForCustomer(guestId)));
  }

  const plan = findOrphanedGuestOrders(guests, reals, orders);

  for (const entry of plan) {
    console.warn(
      `Orphaned guest order found. id_order=${entry.id_order} ` +
        `from_id_customer=${entry.current_id_customer} to_id_customer=${entry.target_id_customer} ` +
        `email=${entry.email} ${DRY_RUN ? "(dry run, not applied)" : "(relinking)"}`
    );
    if (!DRY_RUN) await relinkOrder(entry.id_order, entry.target_id_customer);
  }

  console.log(
    `Done. ${plan.length} orphaned order(s) found. DRY_RUN=${DRY_RUN} (relink only applied ` +
      `when explicitly false, and never merges or deletes the guest customer row).`
  );
}

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

Add a test

The matching rule is the part most worth testing, because it decides which orders get reported as orphaned and, eventually, which orders a human might approve for relinking. Because we kept find_orphaned_guest_orders pure, the test needs no network and no PrestaShop store. It just feeds in plain customer and order lists and checks the answer.

test_guest_orphaned_orders.py
from find_orphaned_orders import find_orphaned_guest_orders, normalize_email


def guest(**over):
    base = {"id": 101, "email": "jane@example.com", "is_guest": "1"}
    base.update(over)
    return base


def real(**over):
    base = {"id": 205, "email": "jane@example.com", "is_guest": "0"}
    base.update(over)
    return base


def order(**over):
    base = {"id": 900, "id_customer": 101, "reference": "ABCDE", "total_paid": "49.90"}
    base.update(over)
    return base


def test_finds_orphaned_order_when_email_matches_both_groups():
    plan = find_orphaned_guest_orders([guest()], [real()], [order()])
    assert plan == [{
        "id_order": 900,
        "current_id_customer": 101,
        "target_id_customer": 205,
        "email": "jane@example.com",
    }]


def test_no_plan_when_email_only_a_guest():
    plan = find_orphaned_guest_orders([guest()], [], [order()])
    assert plan == []


def test_no_plan_when_order_belongs_to_a_different_customer():
    plan = find_orphaned_guest_orders([guest()], [real()], [order(id_customer=999)])
    assert plan == []


def test_ignores_orders_already_on_the_real_account():
    orders = [order(id=900, id_customer=101), order(id=901, id_customer=205)]
    plan = find_orphaned_guest_orders([guest()], [real()], orders)
    assert [p["id_order"] for p in plan] == [900]


def test_multiple_orphaned_orders_for_the_same_guest():
    orders = [order(id=900), order(id=901)]
    plan = find_orphaned_guest_orders([guest()], [real()], orders)
    assert [p["id_order"] for p in plan] == [900, 901]


def test_email_matching_is_case_and_space_insensitive():
    g = guest(email="  Jane@Example.COM ")
    plan = find_orphaned_guest_orders([g], [real()], [order()])
    assert len(plan) == 1
    assert plan[0]["email"] == "jane@example.com"


def test_unrelated_email_pairs_are_ignored():
    other_guest = guest(id=111, email="bob@example.com")
    plan = find_orphaned_guest_orders([other_guest], [real()], [order(id_customer=111)])
    assert plan == []


def test_normalize_email_lowers_and_trims():
    assert normalize_email("  Jane@Example.COM ") == "jane@example.com"


def test_normalize_email_handles_none():
    assert normalize_email(None) == ""
orphaned-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedGuestOrders, normalizeEmail } from "./find-orphaned-orders.js";

const guest = (over = {}) => ({ id: 101, email: "jane@example.com", is_guest: "1", ...over });
const real = (over = {}) => ({ id: 205, email: "jane@example.com", is_guest: "0", ...over });
const order = (over = {}) => ({ id: 900, id_customer: 101, reference: "ABCDE", total_paid: "49.90", ...over });

test("finds orphaned order when email matches both groups", () => {
  const plan = findOrphanedGuestOrders([guest()], [real()], [order()]);
  assert.deepEqual(plan, [{
    id_order: 900,
    current_id_customer: 101,
    target_id_customer: 205,
    email: "jane@example.com",
  }]);
});

test("no plan when email only a guest", () => {
  const plan = findOrphanedGuestOrders([guest()], [], [order()]);
  assert.deepEqual(plan, []);
});

test("no plan when order belongs to a different customer", () => {
  const plan = findOrphanedGuestOrders([guest()], [real()], [order({ id_customer: 999 })]);
  assert.deepEqual(plan, []);
});

test("ignores orders already on the real account", () => {
  const orders = [order({ id: 900, id_customer: 101 }), order({ id: 901, id_customer: 205 })];
  const plan = findOrphanedGuestOrders([guest()], [real()], orders);
  assert.deepEqual(plan.map((p) => p.id_order), [900]);
});

test("multiple orphaned orders for the same guest", () => {
  const orders = [order({ id: 900 }), order({ id: 901 })];
  const plan = findOrphanedGuestOrders([guest()], [real()], orders);
  assert.deepEqual(plan.map((p) => p.id_order), [900, 901]);
});

test("email matching is case and space insensitive", () => {
  const g = guest({ email: "  Jane@Example.COM " });
  const plan = findOrphanedGuestOrders([g], [real()], [order()]);
  assert.equal(plan.length, 1);
  assert.equal(plan[0].email, "jane@example.com");
});

test("unrelated email pairs are ignored", () => {
  const otherGuest = guest({ id: 111, email: "bob@example.com" });
  const plan = findOrphanedGuestOrders([otherGuest], [real()], [order({ id_customer: 111 })]);
  assert.deepEqual(plan, []);
});

test("normalizeEmail lowers and trims", () => {
  assert.equal(normalizeEmail("  Jane@Example.COM "), "jane@example.com");
});

test("normalizeEmail handles missing input", () => {
  assert.equal(normalizeEmail(undefined), "");
});

Case studies

Order confirmation link

The boutique where customers swore their order vanished

A small boutique let most shoppers check out as guests, and its order confirmation email included a friendly "create an account to track this order" link. Several customers clicked it days later, set a password, and then emailed support insisting their order had disappeared, even though they used the exact same email address every time.

Running the scan across guest and registered customers turned up the pattern immediately: each confused customer had a guest row and a separate registered row sharing one email, with the order still pointing at the old guest id. Support could now explain exactly what happened and manually point the customer at their real order instead of guessing.

Repeat purchase, fresh signup

The store where a second purchase triggered a fresh registration

A shopper bought once as a guest, then came back weeks later and registered a full account at checkout for the second purchase, using the same email both times out of habit. The first order stayed invisible in the new account, and the shopper assumed the store had lost it entirely.

The detection script flagged the split identity on the very first scheduled run, confirming the registered account genuinely had zero orders for that period before listing the guest order as orphaned. A support agent reviewed the single-row report and manually relinked the order after checking it was safe, rather than trusting an automated fix to guess correctly.

What good looks like

After this runs on a schedule, every guest order stranded behind a fresh registration surfaces as a clear report line naming the order, the guest id it is stuck on, and the real account it should belong to, instead of a support ticket about a vanished order. Nothing gets relinked automatically, since PrestaShop's own core has open bugs in this exact area and guessing wrong would touch a financial record. A human reviews the short list, approves the batch, and only then does the guarded write flip id_customer on the orders that were actually confirmed orphaned.

FAQ

Why does my order disappear after I create an account with the same email?

Your guest order is stored with a fixed id_customer pointing at the guest customer row that was created for that checkout. When you register a full account afterward, PrestaShop does not always find and convert that guest row in place. It can create a second customer row instead, and the old order keeps pointing at the original guest id, so it never appears in your new logged in account even though the email matches exactly.

Is it safe to automatically relink these orphaned orders?

No, not by default. Relinking an order means changing its id_customer and possibly its address ids, which touches financial and order records directly, and PrestaShop's own core still has open bugs in this area. The safe pattern is to detect and report the orphaned orders for a human to confirm, and only apply the relink as a DRY_RUN guarded write once someone has approved the batch.

How do I find guest orders that got separated from a real account?

Pull every guest customer with GET /api/customers?filter[is_guest]=1 and every registered customer with filter[is_guest]=0, then group both sets by lowercased and trimmed email in your own code. Any email that appears on both a guest row and a registered row is a split identity candidate, and fetching orders by filter[id_customer] for the guest id lists the orders that are stuck and invisible in the real account.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Order as a guest, register with same email, order lost and cannot be tracked. Issue #15605. github.com/PrestaShop/PrestaShop/issues/15605
  2. PrestaShop GitHub: It's possible to create two customers with the same email address while transforming from guest. Issue #36421. github.com/PrestaShop/PrestaShop/issues/36421
  3. PrestaShop GitHub Discussion: Guest Orders and New Customer Accounts, automatically merge? Discussion #34904. github.com/PrestaShop/PrestaShop/discussions/34904

On the solution:

  1. PrestaShop Developer Documentation: Customers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/customers/
  2. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
  3. PrestaShop Developer Documentation: The PrestaShop webservice API. devdocs.prestashop-project.org/9/webservice/

Stuck on a tricky one?

If you have a problem in PrestaShop customers, orders, or the webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this find a lost order?

If this saved you a confusing support ticket about an order that seemed to vanish, 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 PrestaShop field notes