Skip to content

Diagnostic

Duplicate invoice numbers issued across different orders

Two customers check out within a second of each other, and both end up with an invoice carrying the exact same sequential number. Your accountant notices it first, usually when trying to reconcile a VAT report, and now you have two legal documents claiming to be the same invoice. Here is why PrestaShop can hand out one number twice and a script that finds every pair this happened to so a human can decide how to fix it.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
Printed paper
Photo by Kelly Sikkema on Unsplash
The short answer

PrestaShop assigns invoice numbers in Order::setInvoice() and setLastInvoiceNumber() with a non-atomic read then write: it runs a query equivalent to SELECT MAX(number)+1 FROM ps_order_invoice and writes that computed value into the new invoice row, instead of using a real atomic counter such as an auto-increment column or a SELECT ... FOR UPDATE inside a transaction. Under concurrent checkout load, two order-validation requests can both read the same current MAX before either writes back, so both compute and save the identical number for two different orders. This has been reported against nearly every PrestaShop version and remains unresolved in core. Run a Python or Node.js script that pulls recent order_invoices, groups them by number, and flags any number linked to more than one distinct id_order. Full code, tests, and citations are below.

The problem in plain words

An invoice number is supposed to mean one thing: this is the Nth invoice this shop has ever issued, in order, with no gaps and no repeats. Accountants and tax authorities rely on that sequence being unbroken, because a break or a repeat is exactly the kind of anomaly a VAT audit looks for.

PrestaShop generates that number by looking at the highest number currently in ps_order_invoice and adding one. That works fine when checkouts happen one at a time. It falls apart the moment two checkouts finish validating at nearly the same instant, because nothing stops both of them from reading the same "current highest number" before either one has saved its own new row.

Checkout A validates calls Order::setInvoice() Checkout B validates calls Order::setInvoice() SELECT MAX(number)+1 both read the same current MAX Order A invoice #1042 written to ps_order_invoice Order B invoice #1042 same number, different order
Neither request knows about the other. Both compute the same "next" number and both save it, so two different orders now claim the same invoice number.

Why it happens

The root cause is that invoice numbering never became a real atomic counter. Documented ways it shows up:

This is not a data corruption bug you can spot from a broken page. The store keeps running, both orders look normal, and the only visible symptom is two invoices, for two different customers, both stamped with the same number. See the citations at the end for the exact issues and docs.

The key insight

A duplicate invoice number is not something a script should just renumber. Invoice numbers are fiscal and legal documents in most jurisdictions, so silently changing an already-issued number creates a compliance problem worse than the duplicate. So the safe pattern is not "renumber whichever one looks wrong." It is "flag every colliding pair for a human," an accountant or admin, who decides which order keeps the number and which one gets a corrective reissued invoice through the normal Back Office generate invoice action.

The fix, as a flow

We do not touch ps_order_invoice at all. We add a job that pulls recent invoices through the webservice, groups them by their human-facing number, and reports any number tied to more than one distinct order. Anything flagged becomes a report row for a human to review and resolve.

List recent invoices GET order_invoices, filter[date_add] Group by number find_duplicate_invoice_numbers More than one distinct id_order? same order re-fetched twice does not count Collision found? yes no, move on Report DRY_RUN No auto-renumber: invoices are fiscal documents. Manual review only.
The job only ever reads and reports. Deciding which order keeps the number and issuing a corrective reissued invoice stays a manual, back-office 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 order_invoices and orders. 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 recent invoices from the webservice

Call GET /api/order_invoices?filter[date_add]=[start,end]&display=full&output_format=JSON for the window you want to check, for example the last few days. For each invoice element, keep number, id_order, id, and date_add. number is the human-facing sequential field, which is distinct from the invoice's own internal id.

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 recent_invoices(date_start, date_end):
    data = api_get("order_invoices", params={
        "filter[date_add]": f"[{date_start},{date_end}]",
        "display": "full",
    })
    return data.get("order_invoices") 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 recentInvoices(dateStart, dateEnd) {
  const data = await apiGet("order_invoices", {
    "filter[date_add]": `[${dateStart},${dateEnd}]`,
    display: "full",
  });
  return data.order_invoices || [];
}
3

Confirm the two orders are genuinely different

A collision on number only matters if two different orders are involved. Call GET /api/orders?filter[id]=[id_order1|id_order2]&display=full&output_format=JSON for the pair and check that id_customer or reference differ, so a single order fetched twice is never mistaken for a collision.

step3.py
def orders_by_ids(id_order_a, id_order_b):
    data = api_get("orders", params={
        "filter[id]": f"[{id_order_a}|{id_order_b}]",
        "display": "full",
    })
    return data.get("orders") or []

def confirm_orders_differ(id_order_a, id_order_b):
    orders = {str(o["id"]): o for o in orders_by_ids(id_order_a, id_order_b)}
    a = orders.get(str(id_order_a))
    b = orders.get(str(id_order_b))
    if not a or not b:
        return False
    return a.get("id_customer") != b.get("id_customer") or a.get("reference") != b.get("reference")
step3.js
async function ordersByIds(idOrderA, idOrderB) {
  const data = await apiGet("orders", {
    "filter[id]": `[${idOrderA}|${idOrderB}]`,
    display: "full",
  });
  return data.orders || [];
}

async function confirmOrdersDiffer(idOrderA, idOrderB) {
  const orders = {};
  for (const o of await ordersByIds(idOrderA, idOrderB)) orders[String(o.id)] = o;
  const a = orders[String(idOrderA)];
  const b = orders[String(idOrderB)];
  if (!a || !b) return false;
  return a.id_customer !== b.id_customer || a.reference !== b.reference;
}
4

Decide, with one pure function

Keep the grouping in its own function that takes the list of invoice records and returns every collision it finds. It groups by the human-facing number, then keeps only the groups whose id_order values include more than one distinct order. A single order fetched twice through pagination or a retry must never count as a collision, since its id_order is the same both times.

decide.py
def find_duplicate_invoice_numbers(invoices):
    groups = {}
    for inv in invoices:
        groups.setdefault(inv["number"], []).append(inv)

    collisions = []
    for number, rows in groups.items():
        distinct_orders = {r["id_order"] for r in rows}
        if len(distinct_orders) > 1:
            collisions.append({
                "number": number,
                "orders": [r["id_order"] for r in rows],
                "invoice_ids": [r["id"] for r in rows],
                "timestamps": [r["date_add"] for r in rows],
            })
    return collisions
decide.js
export function findDuplicateInvoiceNumbers(invoices) {
  const groups = new Map();
  for (const inv of invoices) {
    if (!groups.has(inv.number)) groups.set(inv.number, []);
    groups.get(inv.number).push(inv);
  }

  const collisions = [];
  for (const [number, rows] of groups) {
    const distinctOrders = new Set(rows.map((r) => r.id_order));
    if (distinctOrders.size > 1) {
      collisions.push({
        number,
        orders: rows.map((r) => r.id_order),
        invoice_ids: rows.map((r) => r.id),
        timestamps: rows.map((r) => r.date_add),
      });
    }
  }
  return collisions;
}
5

Write the collision to a report, never to the resource

For every collision, confirm the orders genuinely differ, then log a report row with both id_order values, the shared number, and both date_add timestamps. True race-condition collisions cluster within seconds of each other, which is worth calling out in the report since it is the strongest evidence this is the concurrency bug and not a manual data entry mistake. Never PUT or PATCH the order_invoices resource to change number directly.

report.py
def build_report_row(collision):
    orders = collision["orders"]
    timestamps = collision["timestamps"]
    return {
        "number": collision["number"],
        "id_order_a": orders[0],
        "id_order_b": orders[1],
        "invoice_ids": collision["invoice_ids"],
        "date_add_a": timestamps[0],
        "date_add_b": timestamps[1],
    }
report.js
export function buildReportRow(collision) {
  const [orderA, orderB] = collision.orders;
  const [dateA, dateB] = collision.timestamps;
  return {
    number: collision.number,
    id_order_a: orderA,
    id_order_b: orderB,
    invoice_ids: collision.invoice_ids,
    date_add_a: dateA,
    date_add_b: dateB,
  };
}
6

Wire it together with a dry run guard

The loop ties every piece together: pull invoices for the window, group and detect collisions with find_duplicate_invoice_numbers, confirm each pair is genuinely two different orders, and log a report row for anything flagged. There is no write path at all here, since renumbering an already-issued invoice is unsafe. DRY_RUN only controls how loud the report is. Run it on a schedule that matches how often your store checks out, for example once a day, or right after a known high-traffic sale.

Run it safe

Never PUT or PATCH the order_invoices resource to change a number directly, and never auto-renumber in place. Invoice numbers are fiscal and legal documents in most jurisdictions. Treat every flagged pair as a lead for an accountant or admin to review, decide which order keeps the number, and issue a corrective reissued invoice for the other one through the normal Back Office generate invoice action.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pulls invoices for a date window, groups and reports every collision cross-checked against the two orders involved, and never attempts to write or renumber anything in order_invoices.

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.
check_duplicate_invoice_numbers.py
"""Detect PrestaShop invoice numbers issued to more than one order.

Order::setInvoice() and setLastInvoiceNumber() compute the next invoice number with a
query equivalent to SELECT MAX(number)+1 FROM ps_order_invoice, then write that value
into the new invoice row as a separate step. Nothing serializes the read and the write:
there is no auto-increment column backing number, and no SELECT ... FOR UPDATE inside a
transaction. Under concurrent checkout load, two order-validation requests can both read
the same current MAX before either has written its own row, so both persist the
identical number for two different orders. Tracked upstream in PrestaShop/PrestaShop
issues #28757, #23025, and #12660, reported against nearly every version from 1.6
through 1.7.8.x and later, and unresolved in core.

This script only reads and reports. Invoice numbers are fiscal and legal documents in
most jurisdictions, so renumbering an already-issued invoice automatically is unsafe.
Flagged pairs need a human, an accountant or admin, to decide which order keeps the
number and which one gets a corrective reissued invoice through the normal Back Office
generate invoice action. Never PUT or PATCH order_invoices to change number directly.

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("check_duplicate_invoice_numbers")

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"
DATE_START = os.environ.get("INVOICE_DATE_START", "")
DATE_END = os.environ.get("INVOICE_DATE_END", "")
AUTH = (PRESTASHOP_WS_KEY, "")


def find_duplicate_invoice_numbers(invoices):
    """Pure decision function, no I/O.

    invoices is a list of order_invoices rows already fetched, each with at least id,
    id_order, number, and date_add. Groups the rows by number and returns a collision
    dict for every number whose rows span more than one distinct id_order. A single
    order fetched twice keeps the same id_order both times, so it is never counted as a
    collision.
    """
    groups = {}
    for inv in invoices:
        groups.setdefault(inv["number"], []).append(inv)

    collisions = []
    for number, rows in groups.items():
        distinct_orders = {r["id_order"] for r in rows}
        if len(distinct_orders) > 1:
            collisions.append({
                "number": number,
                "orders": [r["id_order"] for r in rows],
                "invoice_ids": [r["id"] for r in rows],
                "timestamps": [r["date_add"] for r in rows],
            })
    return collisions


def build_report_row(collision):
    orders = collision["orders"]
    timestamps = collision["timestamps"]
    return {
        "number": collision["number"],
        "id_order_a": orders[0],
        "id_order_b": orders[1],
        "invoice_ids": collision["invoice_ids"],
        "date_add_a": timestamps[0],
        "date_add_b": timestamps[1],
    }


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 recent_invoices(date_start, date_end):
    data = api_get("order_invoices", params={
        "filter[date_add]": f"[{date_start},{date_end}]",
        "display": "full",
    })
    return data.get("order_invoices") or []


def orders_by_ids(id_order_a, id_order_b):
    data = api_get("orders", params={
        "filter[id]": f"[{id_order_a}|{id_order_b}]",
        "display": "full",
    })
    return data.get("orders") or []


def confirm_orders_differ(id_order_a, id_order_b):
    orders = {str(o["id"]): o for o in orders_by_ids(id_order_a, id_order_b)}
    a = orders.get(str(id_order_a))
    b = orders.get(str(id_order_b))
    if not a or not b:
        return False
    return a.get("id_customer") != b.get("id_customer") or a.get("reference") != b.get("reference")


def run():
    if not DATE_START or not DATE_END:
        log.error("Set INVOICE_DATE_START and INVOICE_DATE_END (YYYY-MM-DD) to the window to scan.")
        return

    invoices = recent_invoices(DATE_START, DATE_END)
    collisions = find_duplicate_invoice_numbers(invoices)

    flagged = 0
    for collision in collisions:
        id_order_a, id_order_b = collision["orders"][0], collision["orders"][1]
        if not confirm_orders_differ(id_order_a, id_order_b):
            continue
        row = build_report_row(collision)
        flagged += 1
        log.warning(
            "Duplicate invoice number found. number=%s id_order_a=%s id_order_b=%s "
            "invoice_ids=%s date_add_a=%s date_add_b=%s",
            row["number"], row["id_order_a"], row["id_order_b"],
            row["invoice_ids"], row["date_add_a"], row["date_add_b"],
        )
    log.info(
        "Done. %d duplicate invoice number(s) flagged for manual review. DRY_RUN=%s "
        "(no writes are ever performed, invoice numbers are never changed automatically).",
        flagged, DRY_RUN,
    )


if __name__ == "__main__":
    run()
check-duplicate-invoice-numbers.js
/**
 * Detect PrestaShop invoice numbers issued to more than one order.
 *
 * Order::setInvoice() and setLastInvoiceNumber() compute the next invoice number with a
 * query equivalent to SELECT MAX(number)+1 FROM ps_order_invoice, then write that value
 * into the new invoice row as a separate step. Nothing serializes the read and the
 * write: there is no auto-increment column backing number, and no
 * SELECT ... FOR UPDATE inside a transaction. Under concurrent checkout load, two
 * order-validation requests can both read the same current MAX before either has
 * written its own row, so both persist the identical number for two different orders.
 * Tracked upstream in PrestaShop/PrestaShop issues #28757, #23025, and #12660, reported
 * against nearly every version from 1.6 through 1.7.8.x and later, and unresolved in
 * core.
 *
 * This script only reads and reports. Invoice numbers are fiscal and legal documents in
 * most jurisdictions, so renumbering an already-issued invoice automatically is unsafe.
 * Flagged pairs need a human, an accountant or admin, to decide which order keeps the
 * number and which one gets a corrective reissued invoice through the normal Back
 * Office generate invoice action. Never PUT or PATCH order_invoices to change number
 * directly.
 *
 * Guide: https://www.allanninal.dev/prestashop/duplicate-invoice-numbers/
 */
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";
const DATE_START = process.env.INVOICE_DATE_START || "";
const DATE_END = process.env.INVOICE_DATE_END || "";

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

/**
 * Pure decision function, no I/O.
 *
 * invoices is an array of order_invoices rows already fetched, each with at least id,
 * id_order, number, and date_add. Groups the rows by number and returns a collision
 * object for every number whose rows span more than one distinct id_order. A single
 * order fetched twice keeps the same id_order both times, so it is never counted as a
 * collision.
 */
export function findDuplicateInvoiceNumbers(invoices) {
  const groups = new Map();
  for (const inv of invoices) {
    if (!groups.has(inv.number)) groups.set(inv.number, []);
    groups.get(inv.number).push(inv);
  }

  const collisions = [];
  for (const [number, rows] of groups) {
    const distinctOrders = new Set(rows.map((r) => r.id_order));
    if (distinctOrders.size > 1) {
      collisions.push({
        number,
        orders: rows.map((r) => r.id_order),
        invoice_ids: rows.map((r) => r.id),
        timestamps: rows.map((r) => r.date_add),
      });
    }
  }
  return collisions;
}

export function buildReportRow(collision) {
  const [orderA, orderB] = collision.orders;
  const [dateA, dateB] = collision.timestamps;
  return {
    number: collision.number,
    id_order_a: orderA,
    id_order_b: orderB,
    invoice_ids: collision.invoice_ids,
    date_add_a: dateA,
    date_add_b: dateB,
  };
}

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 recentInvoices(dateStart, dateEnd) {
  const data = await apiGet("order_invoices", {
    "filter[date_add]": `[${dateStart},${dateEnd}]`,
    display: "full",
  });
  return data.order_invoices || [];
}

async function ordersByIds(idOrderA, idOrderB) {
  const data = await apiGet("orders", {
    "filter[id]": `[${idOrderA}|${idOrderB}]`,
    display: "full",
  });
  return data.orders || [];
}

async function confirmOrdersDiffer(idOrderA, idOrderB) {
  const orders = {};
  for (const o of await ordersByIds(idOrderA, idOrderB)) orders[String(o.id)] = o;
  const a = orders[String(idOrderA)];
  const b = orders[String(idOrderB)];
  if (!a || !b) return false;
  return a.id_customer !== b.id_customer || a.reference !== b.reference;
}

export async function run() {
  if (!DATE_START || !DATE_END) {
    console.error("Set INVOICE_DATE_START and INVOICE_DATE_END (YYYY-MM-DD) to the window to scan.");
    return;
  }

  const invoices = await recentInvoices(DATE_START, DATE_END);
  const collisions = findDuplicateInvoiceNumbers(invoices);

  let flagged = 0;
  for (const collision of collisions) {
    const [idOrderA, idOrderB] = collision.orders;
    if (!(await confirmOrdersDiffer(idOrderA, idOrderB))) continue;
    const row = buildReportRow(collision);
    flagged++;
    console.warn(
      `Duplicate invoice number found. number=${row.number} id_order_a=${row.id_order_a} ` +
        `id_order_b=${row.id_order_b} invoice_ids=${JSON.stringify(row.invoice_ids)} ` +
        `date_add_a=${row.date_add_a} date_add_b=${row.date_add_b}`
    );
  }
  console.log(
    `Done. ${flagged} duplicate invoice number(s) flagged for manual review. DRY_RUN=${DRY_RUN} ` +
      `(no writes are ever performed, invoice numbers are never changed automatically).`
  );
}

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

Add a test

The grouping rule is the part most worth testing, because it decides which numbers get reported as collisions versus left alone as a single order seen twice. Because we kept find_duplicate_invoice_numbers pure, the test needs no network and no PrestaShop store. It just feeds in plain invoice records and checks the answer.

test_duplicate_invoice_numbers.py
from check_duplicate_invoice_numbers import find_duplicate_invoice_numbers


def invoice(**over):
    base = {"id": 1, "id_order": 100, "number": 1042, "date_add": "2026-07-10 10:00:00"}
    base.update(over)
    return base


def test_no_collisions():
    rows = [
        invoice(id=1, id_order=100, number=1042),
        invoice(id=2, id_order=101, number=1043),
    ]
    assert find_duplicate_invoice_numbers(rows) == []


def test_one_collision_pair():
    rows = [
        invoice(id=1, id_order=100, number=1042, date_add="2026-07-10 10:00:00"),
        invoice(id=2, id_order=101, number=1042, date_add="2026-07-10 10:00:02"),
    ]
    collisions = find_duplicate_invoice_numbers(rows)
    assert len(collisions) == 1
    assert collisions[0]["number"] == 1042
    assert set(collisions[0]["orders"]) == {100, 101}
    assert set(collisions[0]["invoice_ids"]) == {1, 2}


def test_same_order_refetched_twice_is_not_a_collision():
    rows = [
        invoice(id=1, id_order=100, number=1042, date_add="2026-07-10 10:00:00"),
        invoice(id=1, id_order=100, number=1042, date_add="2026-07-10 10:00:00"),
    ]
    assert find_duplicate_invoice_numbers(rows) == []


def test_three_way_collision():
    rows = [
        invoice(id=1, id_order=100, number=1042),
        invoice(id=2, id_order=101, number=1042),
        invoice(id=3, id_order=102, number=1042),
    ]
    collisions = find_duplicate_invoice_numbers(rows)
    assert len(collisions) == 1
    assert set(collisions[0]["orders"]) == {100, 101, 102}
    assert len(collisions[0]["invoice_ids"]) == 3


def test_no_invoices_no_collisions():
    assert find_duplicate_invoice_numbers([]) == []


def test_multiple_independent_collisions_are_both_reported():
    rows = [
        invoice(id=1, id_order=100, number=1042),
        invoice(id=2, id_order=101, number=1042),
        invoice(id=3, id_order=200, number=2001),
        invoice(id=4, id_order=201, number=2001),
    ]
    collisions = find_duplicate_invoice_numbers(rows)
    assert len(collisions) == 2
    numbers = {c["number"] for c in collisions}
    assert numbers == {1042, 2001}
duplicate-invoice-numbers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateInvoiceNumbers } from "./check-duplicate-invoice-numbers.js";

const invoice = (over = {}) => ({
  id: 1,
  id_order: 100,
  number: 1042,
  date_add: "2026-07-10 10:00:00",
  ...over,
});

test("no collisions", () => {
  const rows = [
    invoice({ id: 1, id_order: 100, number: 1042 }),
    invoice({ id: 2, id_order: 101, number: 1043 }),
  ];
  assert.deepEqual(findDuplicateInvoiceNumbers(rows), []);
});

test("one collision pair", () => {
  const rows = [
    invoice({ id: 1, id_order: 100, number: 1042, date_add: "2026-07-10 10:00:00" }),
    invoice({ id: 2, id_order: 101, number: 1042, date_add: "2026-07-10 10:00:02" }),
  ];
  const collisions = findDuplicateInvoiceNumbers(rows);
  assert.equal(collisions.length, 1);
  assert.equal(collisions[0].number, 1042);
  assert.deepEqual(new Set(collisions[0].orders), new Set([100, 101]));
  assert.deepEqual(new Set(collisions[0].invoice_ids), new Set([1, 2]));
});

test("same order refetched twice is not a collision", () => {
  const rows = [
    invoice({ id: 1, id_order: 100, number: 1042, date_add: "2026-07-10 10:00:00" }),
    invoice({ id: 1, id_order: 100, number: 1042, date_add: "2026-07-10 10:00:00" }),
  ];
  assert.deepEqual(findDuplicateInvoiceNumbers(rows), []);
});

test("three way collision", () => {
  const rows = [
    invoice({ id: 1, id_order: 100, number: 1042 }),
    invoice({ id: 2, id_order: 101, number: 1042 }),
    invoice({ id: 3, id_order: 102, number: 1042 }),
  ];
  const collisions = findDuplicateInvoiceNumbers(rows);
  assert.equal(collisions.length, 1);
  assert.deepEqual(new Set(collisions[0].orders), new Set([100, 101, 102]));
  assert.equal(collisions[0].invoice_ids.length, 3);
});

test("no invoices no collisions", () => {
  assert.deepEqual(findDuplicateInvoiceNumbers([]), []);
});

test("multiple independent collisions are both reported", () => {
  const rows = [
    invoice({ id: 1, id_order: 100, number: 1042 }),
    invoice({ id: 2, id_order: 101, number: 1042 }),
    invoice({ id: 3, id_order: 200, number: 2001 }),
    invoice({ id: 4, id_order: 201, number: 2001 }),
  ];
  const collisions = findDuplicateInvoiceNumbers(rows);
  assert.equal(collisions.length, 2);
  const numbers = new Set(collisions.map((c) => c.number));
  assert.deepEqual(numbers, new Set([1042, 2001]));
});

Case studies

Flash sale

The store that found out during a VAT audit

An electronics reseller ran a midnight flash sale and saw checkout traffic spike for about twenty minutes. Months later, during a routine VAT audit, the accountant flagged two invoices with the identical sequential number, one for a customer in the sale window and one for an unrelated order placed the same night.

Running the diagnostic against that whole month's order_invoices turned up three more colliding pairs, all clustered within seconds of a checkout, all from that same flash sale night. The accountant reviewed each pair, kept the earlier order's number, and issued corrective reissued invoices for the others through Back Office generate invoice, closing the audit finding cleanly.

Marketplace sync

The multi-channel seller whose orders landed in bursts

A seller syncing orders from a marketplace integration had orders arrive in tight bursts whenever the marketplace's webhook queue caught up after downtime, sometimes ten or more validating within the same second. Customer support started getting occasional emails asking why an invoice PDF had someone else's order number on it.

The team scheduled the script to run nightly against the previous day's invoices. It surfaced every collision from the burst pattern well before a customer noticed on their own, and the finance team resolved each one the same day it was flagged instead of discovering it weeks later during reconciliation.

What good looks like

After this runs on a schedule, a duplicated invoice number surfaces as a clear, dated report line naming both orders involved, instead of a surprise an accountant stumbles on during an audit. Nothing gets renumbered automatically, since that would create a bigger compliance problem than the one it solves. A human reviews each flagged pair, keeps the correct order on the original number, and issues a proper corrective invoice for the other, so the books stay clean and defensible.

FAQ

Why does PrestaShop issue the same invoice number to two orders?

Order::setInvoice() and setLastInvoiceNumber() compute the next invoice number with a query equivalent to SELECT MAX(number)+1 FROM ps_order_invoice, then write that value back into the new invoice row. That is a read then write with no atomic counter or locking, so under concurrent checkout load two order-validation requests can both read the same current MAX before either has written its own row, and both save the identical number for two different orders.

Is it safe to renumber a duplicated invoice automatically?

No. Invoice numbers are fiscal and legal documents in most jurisdictions, so silently changing a number that has already been issued to a customer creates a compliance problem that is worse than the duplicate itself. The safe action is to detect and report every colliding pair for a human, an accountant or admin, to decide which order keeps the number and which one gets a corrective reissued invoice through the normal Back Office generate invoice action.

How do I detect duplicate invoice numbers through the webservice API?

Pull recent invoices with GET order_invoices filtered by date_add and display=full, then group them client side by the human-facing number field, which is distinct from each invoice's own internal id. Any group whose number is linked to more than one distinct id_order is a collision. Confirm the two orders are genuinely different by fetching GET orders filtered by both ids and comparing id_customer and reference, then record both id_order values, the shared number, and the two date_add timestamps, since true race condition collisions cluster within seconds of each other.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Duplicated Invoice Number. Issue #28757. github.com/PrestaShop/PrestaShop/issues/28757
  2. PrestaShop GitHub: Two different orders with the same invoice ID. Issue #23025. github.com/PrestaShop/PrestaShop/issues/23025
  3. PrestaShop GitHub: [PS 1.7.3.3] duplicate invoice number. Issue #12660. github.com/PrestaShop/PrestaShop/issues/12660

On the solution:

  1. PrestaShop Developer Documentation: Order invoices webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_invoices/
  2. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
  3. PrestaShop Developer Documentation: Additional list parameters, filters and date ranges. devdocs.prestashop-project.org/8/webservice/tutorials/advanced-use/additional-list-parameters/

Stuck on a tricky one?

If you have a problem in PrestaShop orders, invoices, financial reconciliation, 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 untangle your invoice numbers?

If this saved you an awkward audit finding or a confused customer email, 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