Diagnostic
Order payment row duplicated for certain order state configurations
A customer pays by bankwire, staff move the order to Payment accepted, and the order detail page suddenly shows two payment lines for the exact same amount, seconds apart. The order is not double charged, but the record now looks like it is, and total_paid_real can be thrown off in reports. Here is why an order state can quietly write its own payment twice and a script that finds every order this happened to so a human can review it.
When an order state has both Consider the associated order as validated and Set the order as paid enabled together, like a typical bankwire or cheque Payment accepted status, calling Order::validateOrder() with that state fires two independent code paths that each write a payment row for the same amount. PaymentModule::validateOrder() calls Order::addOrderPayment() directly, and separately the invoice logic in OrderInvoice still counts the order as unpaid on a dummy invoice (invoice number 0) and lets the state change insert a second payment. Both land in order_payment with the same id_order and amount. Run a Python or Node.js script that lists orders in a paid-and-validated state, pulls each one's order_payments by order_reference, and flags any pair with matching amounts and near-identical date_add timestamps. Full code, tests, and citations are below.
The problem in plain words
PrestaShop lets you build custom order states in Back Office > Order Settings > Statuses, and each state carries a set of checkboxes: whether it counts as a valid order, whether it counts as paid, whether it triggers stock movement, and so on. A common setup for a bankwire or cheque payment method is a single state called something like Payment accepted that has both Consider the associated order as validated and Set the order as paid switched on, so one click on that state both confirms the order and marks it paid.
That convenience is exactly the problem. Order::validateOrder() was written assuming those two effects, validated and paid, mostly happen through separate, sequenced writes. When a single state change asks for both at once, the payment-writing code in PaymentModule runs, and the invoice code in OrderInvoice also runs, and each of them independently decides the order still needs a payment recorded for the full amount. Neither one knows the other already wrote it.
Why it happens
The root cause sits in how PrestaShop separates "is this order validated" from "is this order paid" as two flags on the same state, and in how the invoice system tracks what is still owed. Documented ways it shows up:
- An order state, most often a bankwire or cheque Payment accepted status, has both Consider the associated order as validated and Set the order as paid enabled, so a single history change requests both effects at once.
PaymentModule::validateOrder()explicitly callsOrder::addOrderPayment()for the paid amount as part of validating the order, which is the expected, intended payment write.- Separately, the invoice-generation path reads
OrderInvoice::getRestPaid()andgetTotalPaid(), and for an invoice whose number has not been assigned yet (invoice number still 0, a "dummy" invoice), it treats the order as still owing the full amount and lets the state-change logic re-trigger a second payment insert. - Both writes land in
order_paymentwith the identicalid_orderand amount, tracked upstream as PrestaShop/PrestaShop issue #12588, and a related report, issue #22414, shows the same duplication surfacing when "Set the order as paid" is turned on just to activate RMA on a state.
This was only fully patched in pull request #19260, merged for PrestaShop 1.7.8.0, by making OrderInvoice::getRestPaid() return 0 for any invoice whose number is still 0, so a not-yet-assigned dummy invoice stops being double-counted as unpaid. Stores on earlier versions, or on a custom state combining both flags in a way the patch does not fully cover, can still see it. See the citations at the end for the exact issues and docs.
A duplicate order_payment row is not something a script should just delete. The webservice does not expose a DELETE route for order_payments at all, and removing the wrong row by hand can corrupt total_paid_real and break accounting reconciliation. So the safe pattern is not "clean up every duplicate automatically." It is "flag every duplicate for a human," using the pattern that actually distinguishes a duplicate from a legitimate split payment: matching amount and a date_add only seconds apart, not a real second installment paid days later.
The fix, as a flow
We do not touch order_payment at all. We add a job that lists orders sitting in a state that is both paid and logable, reads each order's payment rows by order_reference, and runs them through one pure check that clusters near-identical payments. Anything flagged becomes a report row for a store admin to review and remove by hand.
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 orders, order_payments, and order_states. 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
List orders in a paid-and-validated state
Call GET /api/order_states/{id}?display=full&output_format=JSON for the states you use to confirm which ones actually carry both the paid and logable flags. Then call GET /api/orders?filter[current_state]=<id_state>&display=full&output_format=JSON to list candidate orders sitting in that state.
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 is_paid_and_logable_state(id_state):
data = api_get(f"order_states/{id_state}", params={"display": "full"})
state = data.get("order_state") or {}
return str(state.get("paid")) == "1" and str(state.get("logable")) == "1"
def orders_in_state(id_state):
data = api_get("orders", params={"filter[current_state]": id_state, "display": "full"})
return data.get("orders") 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 isPaidAndLogableState(idState) {
const data = await apiGet(`order_states/${idState}`, { display: "full" });
const state = data.order_state || {};
return String(state.paid) === "1" && String(state.logable) === "1";
}
async function ordersInState(idState) {
const data = await apiGet("orders", { "filter[current_state]": idState, display: "full" });
return data.orders || [];
}
Read each order's payment rows by order_reference
order_payments has no direct id_order filter, it is matched by order_reference instead, so call GET /api/order_payments?filter[order_reference]=<order.reference>&display=full&output_format=JSON for every candidate order. Keep the fields the decision needs: id, order_reference, amount, and date_add.
def order_payments_for(order_reference):
data = api_get("order_payments", params={
"filter[order_reference]": order_reference,
"display": "full",
})
return data.get("order_payments") or []
def total_paid_real(id_order):
data = api_get(f"orders/{id_order}", params={"display": "full"})
order = data.get("order") or {}
return float(order.get("total_paid_real", 0))
async function orderPaymentsFor(orderReference) {
const data = await apiGet("order_payments", {
"filter[order_reference]": orderReference,
display: "full",
});
return data.order_payments || [];
}
async function totalPaidReal(idOrder) {
const data = await apiGet(`orders/${idOrder}`, { display: "full" });
const order = data.order || {};
return Number(order.total_paid_real || 0);
}
Decide, with one pure function
Keep the duplicate check in its own function that takes a list of payment rows for one order and returns any clusters it finds. It sorts by date_add first, then walks adjacent pairs and groups any pair whose amounts are equal within a cent and whose date_add values are within about a minute of each other. That is the pattern the research shows distinguishes a genuine duplicate write from a legitimate partial or split payment, which is usually the same amount days apart, or a different amount entirely.
import datetime
def _to_epoch(date_add):
return datetime.datetime.fromisoformat(str(date_add).replace(" ", "T")).timestamp()
def find_duplicate_payments(payments, amount_tolerance=0.01, time_tolerance_seconds=60):
rows = sorted(payments, key=lambda p: _to_epoch(p["date_add"]))
clusters = []
used = set()
for i in range(len(rows) - 1):
a, b = rows[i], rows[i + 1]
if id(a) in used and id(b) in used:
continue
amount_a, amount_b = float(a["amount"]), float(b["amount"])
delta_seconds = abs(_to_epoch(b["date_add"]) - _to_epoch(a["date_add"]))
if abs(amount_a - amount_b) <= amount_tolerance and delta_seconds <= time_tolerance_seconds:
clusters.append({
"order_reference": a["order_reference"],
"duplicate_payment_ids": [a.get("id"), b.get("id")],
"amount": amount_a,
"count": 2,
})
used.add(id(a))
used.add(id(b))
return clusters
function toEpoch(dateAdd) {
return Date.parse(String(dateAdd).replace(" ", "T")) / 1000;
}
export function findDuplicatePayments(payments, amountTolerance = 0.01, timeToleranceSeconds = 60) {
const rows = [...payments].sort((a, b) => toEpoch(a.date_add) - toEpoch(b.date_add));
const clusters = [];
const used = new Set();
for (let i = 0; i < rows.length - 1; i++) {
const a = rows[i];
const b = rows[i + 1];
if (used.has(a) && used.has(b)) continue;
const amountA = Number(a.amount);
const amountB = Number(b.amount);
const deltaSeconds = Math.abs(toEpoch(b.date_add) - toEpoch(a.date_add));
if (Math.abs(amountA - amountB) <= amountTolerance && deltaSeconds <= timeToleranceSeconds) {
clusters.push({
order_reference: a.order_reference,
duplicate_payment_ids: [a.id, b.id],
amount: amountA,
count: 2,
});
used.add(a);
used.add(b);
}
}
return clusters;
}
Cross-check total_paid_real before reporting
Before writing a report row, pull GET /api/orders/{id}?display=full and compare total_paid_real to what the summed order_payments rows would suggest. If total_paid_real reflects only one payment's worth while order_payments shows two, that confirms the duplicate is a record-keeping artifact, not money that actually moved twice.
def build_report_row(id_order, order_reference, cluster, paid_real):
summed = cluster["amount"] * cluster["count"]
return {
"id_order": id_order,
"order_reference": order_reference,
"duplicate_payment_ids": cluster["duplicate_payment_ids"],
"amount": cluster["amount"],
"summed_order_payments": round(summed, 2),
"total_paid_real": paid_real,
"inflated": round(summed, 2) != round(paid_real, 2),
}
export function buildReportRow(idOrder, orderReference, cluster, paidReal) {
const summed = cluster.amount * cluster.count;
return {
id_order: idOrder,
order_reference: orderReference,
duplicate_payment_ids: cluster.duplicate_payment_ids,
amount: cluster.amount,
summed_order_payments: Math.round(summed * 100) / 100,
total_paid_real: paidReal,
inflated: Math.round(summed * 100) / 100 !== Math.round(paidReal * 100) / 100,
};
}
Wire it together with a dry run guard
The loop ties every piece together: list every candidate order in a paid-and-validated state, pull its order_payments, run them through find_duplicate_payments, and log a report row for anything flagged, cross-checked against total_paid_real. There is no write path at all here, since the webservice does not expose one for order_payments. DRY_RUN only controls how loud the report is. Run it on a schedule that matches how often bankwire or cheque orders get confirmed, for example once a day.
Never attempt to delete an order_payment row through the webservice, there is no DELETE route for it in core PrestaShop. Treat every flagged order as a lead for a store admin to confirm in Back Office > Orders, or through a backed up direct database delete plus a recalculation of total_paid_real. The longer term fix is upgrading to a version with pull request #19260 (PrestaShop 1.7.8.0 or later), or splitting the paid and validated flags across two separate order states so one status change cannot fire both payment-writing paths at once.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks every candidate order and its payments, reports every duplicate cluster cross-checked against total_paid_real, and never attempts to write or delete anything in order_payment.
"""Detect PrestaShop orders with a duplicated order_payment row.
When an order state has both Consider the associated order as validated and Set the
order as paid enabled together, such as a typical bankwire or cheque Payment accepted
status, Order::validateOrder() with that state triggers two independent code paths that
each write a payment for the same amount. PaymentModule::validateOrder() calls
Order::addOrderPayment() directly, while the invoice-generation path in OrderInvoice
(getRestPaid() / getTotalPaid()) still treats the order as owing money on a dummy
invoice (invoice number 0) and lets the state-change logic re-trigger a second payment
insert. Both writes land in order_payment with the identical id_order and amount.
Tracked upstream as PrestaShop/PrestaShop issue #12588 and only fully patched in pull
request #19260 (PrestaShop 1.7.8.0) by making OrderInvoice::getRestPaid() return 0 for
invoices whose number is still 0.
This script only reads and reports. The order_payment resource has no DELETE route in
the core webservice, and removing the wrong row by hand risks corrupting
total_paid_real, so it never writes or deletes anything. Flagged orders need a store
admin to review and remove the extra row in Back Office > Orders, or via a backed up
direct database delete plus a recalculation of total_paid_real.
Run on a schedule. Safe to run again and again.
"""
import os
import datetime
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_duplicate_payments")
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"
PAID_AND_LOGABLE_STATE_ID = os.environ.get("PAID_AND_LOGABLE_STATE_ID", "")
AUTH = (PRESTASHOP_WS_KEY, "")
def _to_epoch(date_add):
return datetime.datetime.fromisoformat(str(date_add).replace(" ", "T")).timestamp()
def find_duplicate_payments(payments, amount_tolerance=0.01, time_tolerance_seconds=60):
"""Pure decision function, no I/O.
payments is a list of order_payments rows already fetched for one order, each with
at least order_reference, amount, and date_add. Sorts by date_add, then scans
adjacent pairs, grouping any pair whose amounts match within amount_tolerance and
whose date_add values are within time_tolerance_seconds of each other. Returns a
list of cluster dicts for clusters of size 2 or more.
"""
rows = sorted(payments, key=lambda p: _to_epoch(p["date_add"]))
clusters = []
used = set()
for i in range(len(rows) - 1):
a, b = rows[i], rows[i + 1]
if id(a) in used and id(b) in used:
continue
amount_a, amount_b = float(a["amount"]), float(b["amount"])
delta_seconds = abs(_to_epoch(b["date_add"]) - _to_epoch(a["date_add"]))
if abs(amount_a - amount_b) <= amount_tolerance and delta_seconds <= time_tolerance_seconds:
clusters.append({
"order_reference": a["order_reference"],
"duplicate_payment_ids": [a.get("id"), b.get("id")],
"amount": amount_a,
"count": 2,
})
used.add(id(a))
used.add(id(b))
return clusters
def build_report_row(id_order, order_reference, cluster, paid_real):
summed = cluster["amount"] * cluster["count"]
return {
"id_order": id_order,
"order_reference": order_reference,
"duplicate_payment_ids": cluster["duplicate_payment_ids"],
"amount": cluster["amount"],
"summed_order_payments": round(summed, 2),
"total_paid_real": paid_real,
"inflated": round(summed, 2) != round(paid_real, 2),
}
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 is_paid_and_logable_state(id_state):
data = api_get(f"order_states/{id_state}", params={"display": "full"})
state = data.get("order_state") or {}
return str(state.get("paid")) == "1" and str(state.get("logable")) == "1"
def orders_in_state(id_state):
data = api_get("orders", params={"filter[current_state]": id_state, "display": "full"})
return data.get("orders") or []
def order_payments_for(order_reference):
data = api_get("order_payments", params={
"filter[order_reference]": order_reference,
"display": "full",
})
return data.get("order_payments") or []
def total_paid_real(id_order):
data = api_get(f"orders/{id_order}", params={"display": "full"})
order = data.get("order") or {}
return float(order.get("total_paid_real", 0))
def run():
if not PAID_AND_LOGABLE_STATE_ID:
log.error("Set PAID_AND_LOGABLE_STATE_ID to the id_order_state to scan.")
return
if not is_paid_and_logable_state(PAID_AND_LOGABLE_STATE_ID):
log.warning("State %s is not both paid and logable, scanning anyway.", PAID_AND_LOGABLE_STATE_ID)
flagged = 0
for order in orders_in_state(PAID_AND_LOGABLE_STATE_ID):
id_order = order["id"]
reference = order.get("reference")
payments = order_payments_for(reference)
clusters = find_duplicate_payments(payments)
if not clusters:
continue
paid_real = total_paid_real(id_order)
for cluster in clusters:
row = build_report_row(id_order, reference, cluster, paid_real)
flagged += 1
log.warning(
"Duplicate order_payment found. id_order=%s reference=%s payment_ids=%s "
"amount=%.2f summed=%.2f total_paid_real=%.2f inflated=%s",
row["id_order"], row["order_reference"], row["duplicate_payment_ids"],
row["amount"], row["summed_order_payments"], row["total_paid_real"], row["inflated"],
)
log.info(
"Done. %d duplicate payment cluster(s) flagged for manual review. DRY_RUN=%s "
"(no writes are ever performed, order_payment has no DELETE route).",
flagged, DRY_RUN,
)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop orders with a duplicated order_payment row.
*
* When an order state has both Consider the associated order as validated and Set the
* order as paid enabled together, such as a typical bankwire or cheque Payment accepted
* status, Order::validateOrder() with that state triggers two independent code paths
* that each write a payment for the same amount. PaymentModule::validateOrder() calls
* Order::addOrderPayment() directly, while the invoice-generation path in OrderInvoice
* (getRestPaid() / getTotalPaid()) still treats the order as owing money on a dummy
* invoice (invoice number 0) and lets the state-change logic re-trigger a second
* payment insert. Both writes land in order_payment with the identical id_order and
* amount. Tracked upstream as PrestaShop/PrestaShop issue #12588 and only fully patched
* in pull request #19260 (PrestaShop 1.7.8.0) by making OrderInvoice::getRestPaid()
* return 0 for invoices whose number is still 0.
*
* This script only reads and reports. The order_payment resource has no DELETE route in
* the core webservice, and removing the wrong row by hand risks corrupting
* total_paid_real, so it never writes or deletes anything. Flagged orders need a store
* admin to review and remove the extra row in Back Office > Orders, or via a backed up
* direct database delete plus a recalculation of total_paid_real.
*
* Guide: https://www.allanninal.dev/prestashop/duplicate-order-payment-row/
*/
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 PAID_AND_LOGABLE_STATE_ID = process.env.PAID_AND_LOGABLE_STATE_ID || "";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
function toEpoch(dateAdd) {
return Date.parse(String(dateAdd).replace(" ", "T")) / 1000;
}
/**
* Pure decision function, no I/O.
*
* payments is an array of order_payments rows already fetched for one order, each with
* at least order_reference, amount, and date_add. Sorts by date_add, then scans
* adjacent pairs, grouping any pair whose amounts match within amountTolerance and
* whose date_add values are within timeToleranceSeconds of each other. Returns an
* array of cluster objects for clusters of size 2 or more.
*/
export function findDuplicatePayments(payments, amountTolerance = 0.01, timeToleranceSeconds = 60) {
const rows = [...payments].sort((a, b) => toEpoch(a.date_add) - toEpoch(b.date_add));
const clusters = [];
const used = new Set();
for (let i = 0; i < rows.length - 1; i++) {
const a = rows[i];
const b = rows[i + 1];
if (used.has(a) && used.has(b)) continue;
const amountA = Number(a.amount);
const amountB = Number(b.amount);
const deltaSeconds = Math.abs(toEpoch(b.date_add) - toEpoch(a.date_add));
if (Math.abs(amountA - amountB) <= amountTolerance && deltaSeconds <= timeToleranceSeconds) {
clusters.push({
order_reference: a.order_reference,
duplicate_payment_ids: [a.id, b.id],
amount: amountA,
count: 2,
});
used.add(a);
used.add(b);
}
}
return clusters;
}
export function buildReportRow(idOrder, orderReference, cluster, paidReal) {
const summed = cluster.amount * cluster.count;
return {
id_order: idOrder,
order_reference: orderReference,
duplicate_payment_ids: cluster.duplicate_payment_ids,
amount: cluster.amount,
summed_order_payments: Math.round(summed * 100) / 100,
total_paid_real: paidReal,
inflated: Math.round(summed * 100) / 100 !== Math.round(paidReal * 100) / 100,
};
}
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 isPaidAndLogableState(idState) {
const data = await apiGet(`order_states/${idState}`, { display: "full" });
const state = data.order_state || {};
return String(state.paid) === "1" && String(state.logable) === "1";
}
async function ordersInState(idState) {
const data = await apiGet("orders", { "filter[current_state]": idState, display: "full" });
return data.orders || [];
}
async function orderPaymentsFor(orderReference) {
const data = await apiGet("order_payments", {
"filter[order_reference]": orderReference,
display: "full",
});
return data.order_payments || [];
}
async function totalPaidReal(idOrder) {
const data = await apiGet(`orders/${idOrder}`, { display: "full" });
const order = data.order || {};
return Number(order.total_paid_real || 0);
}
export async function run() {
if (!PAID_AND_LOGABLE_STATE_ID) {
console.error("Set PAID_AND_LOGABLE_STATE_ID to the id_order_state to scan.");
return;
}
if (!(await isPaidAndLogableState(PAID_AND_LOGABLE_STATE_ID))) {
console.warn(`State ${PAID_AND_LOGABLE_STATE_ID} is not both paid and logable, scanning anyway.`);
}
let flagged = 0;
for (const order of await ordersInState(PAID_AND_LOGABLE_STATE_ID)) {
const idOrder = order.id;
const reference = order.reference;
const payments = await orderPaymentsFor(reference);
const clusters = findDuplicatePayments(payments);
if (!clusters.length) continue;
const paidReal = await totalPaidReal(idOrder);
for (const cluster of clusters) {
const row = buildReportRow(idOrder, reference, cluster, paidReal);
flagged++;
console.warn(
`Duplicate order_payment found. id_order=${row.id_order} reference=${row.order_reference} ` +
`payment_ids=${JSON.stringify(row.duplicate_payment_ids)} amount=${row.amount.toFixed(2)} ` +
`summed=${row.summed_order_payments.toFixed(2)} total_paid_real=${row.total_paid_real.toFixed(2)} ` +
`inflated=${row.inflated}`
);
}
}
console.log(
`Done. ${flagged} duplicate payment cluster(s) flagged for manual review. DRY_RUN=${DRY_RUN} ` +
`(no writes are ever performed, order_payment has no DELETE route).`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The clustering rule is the part most worth testing, because it decides which orders get reported as duplicates versus left alone as a legitimate split payment. Because we kept find_duplicate_payments pure, the test needs no network and no PrestaShop store. It just feeds in plain payment rows and checks the answer.
from check_duplicate_payments import find_duplicate_payments
def payment(**over):
base = {"id": 1, "order_reference": "ABC123", "amount": "49.99", "date_add": "2026-07-10 10:00:00"}
base.update(over)
return base
def test_two_payments_seconds_apart_is_flagged():
rows = [
payment(id=1, date_add="2026-07-10 10:00:00"),
payment(id=2, date_add="2026-07-10 10:00:20"),
]
clusters = find_duplicate_payments(rows)
assert len(clusters) == 1
assert clusters[0]["order_reference"] == "ABC123"
assert clusters[0]["amount"] == 49.99
assert clusters[0]["count"] == 2
assert set(clusters[0]["duplicate_payment_ids"]) == {1, 2}
def test_different_amounts_not_flagged():
rows = [
payment(id=1, amount="49.99", date_add="2026-07-10 10:00:00"),
payment(id=2, amount="25.00", date_add="2026-07-10 10:00:20"),
]
assert find_duplicate_payments(rows) == []
def test_same_amount_days_apart_not_flagged():
rows = [
payment(id=1, amount="49.99", date_add="2026-07-10 10:00:00"),
payment(id=2, amount="49.99", date_add="2026-07-13 10:00:00"),
]
assert find_duplicate_payments(rows) == []
def test_single_payment_not_flagged():
assert find_duplicate_payments([payment()]) == []
def test_no_payments_not_flagged():
assert find_duplicate_payments([]) == []
def test_amount_within_cent_tolerance_is_flagged():
rows = [
payment(id=1, amount="49.990", date_add="2026-07-10 10:00:00"),
payment(id=2, amount="49.995", date_add="2026-07-10 10:00:05"),
]
assert len(find_duplicate_payments(rows)) == 1
def test_unsorted_input_still_detected():
rows = [
payment(id=2, date_add="2026-07-10 10:00:20"),
payment(id=1, date_add="2026-07-10 10:00:00"),
]
clusters = find_duplicate_payments(rows)
assert len(clusters) == 1
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicatePayments } from "./check-duplicate-payments.js";
const payment = (over = {}) => ({
id: 1,
order_reference: "ABC123",
amount: "49.99",
date_add: "2026-07-10 10:00:00",
...over,
});
test("two payments seconds apart is flagged", () => {
const rows = [
payment({ id: 1, date_add: "2026-07-10 10:00:00" }),
payment({ id: 2, date_add: "2026-07-10 10:00:20" }),
];
const clusters = findDuplicatePayments(rows);
assert.equal(clusters.length, 1);
assert.equal(clusters[0].order_reference, "ABC123");
assert.equal(clusters[0].amount, 49.99);
assert.equal(clusters[0].count, 2);
assert.deepEqual(new Set(clusters[0].duplicate_payment_ids), new Set([1, 2]));
});
test("different amounts not flagged", () => {
const rows = [
payment({ id: 1, amount: "49.99", date_add: "2026-07-10 10:00:00" }),
payment({ id: 2, amount: "25.00", date_add: "2026-07-10 10:00:20" }),
];
assert.deepEqual(findDuplicatePayments(rows), []);
});
test("same amount days apart not flagged", () => {
const rows = [
payment({ id: 1, amount: "49.99", date_add: "2026-07-10 10:00:00" }),
payment({ id: 2, amount: "49.99", date_add: "2026-07-13 10:00:00" }),
];
assert.deepEqual(findDuplicatePayments(rows), []);
});
test("single payment not flagged", () => {
assert.deepEqual(findDuplicatePayments([payment()]), []);
});
test("no payments not flagged", () => {
assert.deepEqual(findDuplicatePayments([]), []);
});
test("amount within cent tolerance is flagged", () => {
const rows = [
payment({ id: 1, amount: "49.990", date_add: "2026-07-10 10:00:00" }),
payment({ id: 2, amount: "49.995", date_add: "2026-07-10 10:00:05" }),
];
assert.equal(findDuplicatePayments(rows).length, 1);
});
test("unsorted input still detected", () => {
const rows = [
payment({ id: 2, date_add: "2026-07-10 10:00:20" }),
payment({ id: 1, date_add: "2026-07-10 10:00:00" }),
];
const clusters = findDuplicatePayments(rows);
assert.equal(clusters.length, 1);
});
Case studies
The wholesaler whose bank reports never matched Prestashop
A furniture wholesaler took bankwire payments and used a single Payment accepted state with both the validated and paid boxes checked, because it saved staff a click. Every month, finance flagged a gap between what the bank actually received and what PrestaShop reported as paid, and nobody could explain the difference without opening orders one by one.
Running the diagnostic across every order in that state turned up a steady trickle of duplicate order_payment rows, all with the telltale pattern of matching amounts seconds apart. Staff reviewed the list, removed the extra rows through Back Office > Orders, and split the state's flags across two statuses so new orders stopped generating the duplicate.
The store that only noticed after a refund request
A boutique accepting cheque payments had a custom Payment received state that also marked as paid, set up years earlier by a developer who is long gone. A customer requested a partial refund, and the order detail page showed two payment entries for the full amount, which confused everyone trying to work out how much had actually been collected.
The script's cross-check against total_paid_real made the pattern obvious immediately, since the real total was half of what the summed order_payments rows implied. That confirmed it was a duplicate write, not a double charge, and the team upgraded PrestaShop to pick up the fix in pull request #19260 rather than patch it order by order.
After this runs on a schedule, every duplicate order_payment row surfaces as a clear, dated report line instead of a confusing extra entry someone stumbles on during a refund or a bank reconciliation. Nothing gets deleted automatically, since the webservice cannot do that safely anyway. Staff get an accurate list to clean up by hand, and the underlying state configuration or PrestaShop version gets fixed so new orders stop generating the duplicate at all.
FAQ
Why does PrestaShop write two order_payment rows for one order?
It happens when an order state has both Consider the associated order as validated and Set the order as paid enabled at the same time, such as a bankwire or cheque Payment accepted status. Order::validateOrder() with that state triggers two independent paths that each write a payment: PaymentModule::validateOrder() calls Order::addOrderPayment() directly, while the invoice logic in OrderInvoice still treats the order as owing money on a dummy invoice with number 0 and lets the state change trigger a second payment insert.
Is it safe to delete the duplicate order_payment row through the webservice?
No. The order_payment resource has no DELETE route in the core PrestaShop webservice, so it cannot be removed through the API, and removing the wrong row by hand risks corrupting total_paid_real and accounting reconciliation. The safe action is to detect and report the duplicates for a store admin to review and remove in Back Office > Orders, or through a backed up direct database delete plus a recalculation of total_paid_real.
How do I detect duplicate order_payment rows through the API?
List orders in a paid and validated state, then for each order call GET order_payments filtered by order_reference, since order_payments has no direct id_order filter. Group the rows by order_reference and flag any order with more than one payment row where the amounts are equal within a cent and the date_add timestamps are within about a minute of each other, since that pattern points to a duplicate write rather than a legitimate separate payment.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Order payment duplicated depending on order state configuration. Issue #12588. github.com/PrestaShop/PrestaShop/issues/12588
- PrestaShop GitHub: Fix order payment duplication depending on order state configuration. Pull Request #19260. github.com/PrestaShop/PrestaShop/pull/19260
- PrestaShop GitHub: If "Set the order as paid" is necessary to activate RMA, payments appear duplicated on BO order detail. Issue #22414. github.com/PrestaShop/PrestaShop/issues/22414
On the solution:
- PrestaShop Developer Documentation: Order payments webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_payments/
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/8/webservice/resources/orders/
- 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 orders, payments, order states, 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 payment records?
If this saved you a confusing reconciliation or a support ticket about a phantom double charge, 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