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.
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.
Why it happens
The root cause is that invoice numbering never became a real atomic counter. Documented ways it shows up:
Order::setInvoice()andsetLastInvoiceNumber()run a query equivalent toSELECT MAX(number)+1 FROM ps_order_invoiceto decide the next number, then write that computed value into the new invoice row as a separate step.- Nothing serializes the two steps. There is no auto-increment column backing
number, and noSELECT ... FOR UPDATEinside a transaction to stop a second request from reading the sameMAXbefore the first request's write lands. - Under concurrent checkout load, such as a flash sale, two carts finishing validation within the same fraction of a second, or two payment webhooks landing back to back, both read the same current
MAX, both compute the same "next" value, and both persist it tops_order_invoice.numberfor two different orders. - This race has been reported against nearly every PrestaShop version, from 1.6 through 1.7.8.x and later, tracked upstream in issues #28757, #23025, and #12660. It remains unresolved in core, because fixing it properly means serializing invoice number allocation, which core has not implemented.
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.
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.
Build it step by step
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.
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
// 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
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.
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 []
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 || [];
}
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.
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")
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;
}
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.
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
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;
}
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.
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],
}
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,
};
}
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.
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.
"""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()
/**
* 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.
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}
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
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.
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.
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:
- PrestaShop GitHub: Duplicated Invoice Number. Issue #28757. github.com/PrestaShop/PrestaShop/issues/28757
- PrestaShop GitHub: Two different orders with the same invoice ID. Issue #23025. github.com/PrestaShop/PrestaShop/issues/23025
- PrestaShop GitHub: [PS 1.7.3.3] duplicate invoice number. Issue #12660. github.com/PrestaShop/PrestaShop/issues/12660
On the solution:
- PrestaShop Developer Documentation: Order invoices webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_invoices/
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
- 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.
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