Diagnostic
Refunded quantity exceeds the originally ordered quantity
Someone opens an order to check its refund history, and one line shows five units refunded on an order that only ever had three. Nothing on the order screen stops this from happening, and nothing warns you when it has. Here is why PrestaShop lets a line's refunded quantity climb past what was actually ordered, and a script that finds every line where the two disagree so a human can reconcile it against the real credit slips.
PrestaShop stores product_quantity and product_quantity_refunded as independent unsigned columns on order_detail. Standard and partial refunds, issued through IssueStandardRefundCommand or IssuePartialRefundCommand, increment product_quantity_refunded without ever touching product_quantity, and nothing in the back office validates that the refunded count stays under the ordered count. If a line's quantity is later edited down by hand, or repeated partial refunds keep stacking on the same line outside the normal flow, you end up with product_quantity_refunded > product_quantity, which is silently wrong at best and, per a reported PrestaShop core issue, can throw SQLSTATE[22003]: 1690 BIGINT UNSIGNED value is out of range in 'product_quantity - product_quantity_refunded' when core code later computes that subtraction. Run a Python or Node.js script that pulls every order's lines with GET /api/order_details, flags any row where refunded exceeds ordered, and cross-checks against GET /api/order_slips to see whether real credit slips back up the number. Full code, tests, and citations are below.
The problem in plain words
Every line on a PrestaShop order, an order_detail row, carries two separate quantity counters: product_quantity, how many units were ordered, and product_quantity_refunded, how many units have been refunded so far. In the ordinary case, the second number only ever grows up to the first, since you cannot refund more units than someone bought.
The trouble is that PrestaShop's refund commands only ever move one of those two counters. When a standard or partial refund runs, through IssueStandardRefundCommand or IssuePartialRefundCommand, it increments product_quantity_refunded on the line. It never touches product_quantity, and it never checks what product_quantity_refunded already is before adding to it. If an employee later opens the order and manually edits the line's product_quantity down, or if credit slips get created outside the normal refund flow and keep stacking refunds against the same line, nothing stops the refunded count from climbing past the ordered count. The result is a line where product_quantity_refunded is bigger than product_quantity, and the back office never says a word about it.
Why it happens
PrestaShop treats product_quantity and product_quantity_refunded as two independent unsigned columns on order_detail, not as a pair that is validated together. A few common ways a store ends up with refunded exceeding ordered:
- An employee manually edits a line's
product_quantitydown after refunds have already been recorded against it, so the old refunded count is now bigger than the new ordered count. - Repeated partial refunds keep stacking against the same
order_detailrow, for example through credit slips created outside the normalIssuePartialRefundCommandflow, and nothing caps the running total at the original quantity. IssueStandardRefundCommandandIssuePartialRefundCommandboth incrementproduct_quantity_refundedwithout reading or checkingproduct_quantityfirst.- Once the gap exists, core code that later computes
product_quantity - product_quantity_refunded, for stock or shippable-quantity checks, can hit an unsigned integer underflow at the database level.
This is reported in PrestaShop/PrestaShop#39391, where the underflow surfaces as SQLSTATE[22003]: 1690 BIGINT UNSIGNED value is out of range in 'product_quantity - product_quantity_refunded'. The forums and a related issue on partial refunds generating a wrong order slip show the same class of drift between what was ordered and what the system thinks was refunded. See the citations at the end for the exact reports.
A line where refunded exceeds ordered is not safe to auto-fix. You cannot tell from the data alone whether product_quantity was wrongly lowered after the fact, or whether product_quantity_refunded was wrongly inflated by a stray refund, and reversing either one risks real accounting or stock harm. So the safe pattern is not "clamp every mismatched line automatically." It is "flag every mismatch for a human to reconcile against the order's actual credit slips," and only clamp product_quantity_refunded down to product_quantity under an explicit, operator-confirmed override.
The fix, as a flow
We do not touch product_quantity_refunded by default. We add a job that pulls each order's lines, flags any row where the refunded quantity is out of range, cross-checks the order's credit slips to see whether the refund history actually supports the number, and reports everything it finds. A confirmed repair only happens under an explicit DRY_RUN=false override with an operator-supplied order id list.
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_details, and order_slips, plus write access to order_details if you plan to run confirmed repairs. 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 orders, then their lines
Call GET /api/orders?filter[date_add]=[2026-06-01,2026-07-11]&display=full&output_format=JSON to get candidate id_order values, or iterate all orders if a full audit is needed. For each order, call GET /api/order_details?filter[id_order]={id_order}&display=full&output_format=JSON to read id, product_id, product_quantity, product_quantity_refunded, product_quantity_return, and product_quantity_reinjected per line.
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_order_ids(date_from, date_to):
data = api_get("orders", params={"filter[date_add]": f"[{date_from},{date_to}]", "display": "full"})
return [int(o["id"]) for o in (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 recentOrderIds(dateFrom, dateTo) {
const data = await apiGet("orders", { "filter[date_add]": `[${dateFrom},${dateTo}]`, display: "full" });
return (data.orders || []).map((o) => Number(o.id));
}
Fetch each order's lines
Loop over the candidate order ids and pull the full order_detail rows for each one. This gives the raw dicts the decision function needs, with no interpretation done yet.
def order_lines(id_order):
data = api_get("order_details", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_details") or []
async function orderLines(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
Decide, with one pure function
Keep the comparison in its own function that takes a list of already-fetched order_detail dicts and returns findings, nothing else. For each line it computes delta = product_quantity_refunded - product_quantity, and if that delta is positive it is a finding. It also flags, separately tagged, lines where product_quantity_return exceeds product_quantity or product_quantity_reinjected exceeds product_quantity_refunded, the same manual-edit-after-refund pattern showing up on a sibling column. No network calls happen inside it, which is what makes it easy to test on its own.
def find_refund_overage(order_lines):
findings = []
for line in order_lines:
ordered = int(line["product_quantity"])
refunded = int(line["product_quantity_refunded"])
returned = int(line.get("product_quantity_return", 0))
reinjected = int(line.get("product_quantity_reinjected", 0))
delta = refunded - ordered
if delta > 0:
findings.append({
"id_order": line["id_order"],
"id": line["id"],
"product_id": line["product_id"],
"ordered": ordered,
"refunded": refunded,
"overage": delta,
"reason": "refunded_exceeds_ordered",
})
if returned > ordered:
findings.append({
"id_order": line["id_order"],
"id": line["id"],
"product_id": line["product_id"],
"ordered": ordered,
"refunded": returned,
"overage": returned - ordered,
"reason": "returned_exceeds_ordered",
})
if reinjected > refunded:
findings.append({
"id_order": line["id_order"],
"id": line["id"],
"product_id": line["product_id"],
"ordered": refunded,
"refunded": reinjected,
"overage": reinjected - refunded,
"reason": "reinjected_exceeds_refunded",
})
return sorted(findings, key=lambda f: f["overage"], reverse=True)
export function findRefundOverage(orderLines) {
const findings = [];
for (const line of orderLines) {
const ordered = Number(line.product_quantity);
const refunded = Number(line.product_quantity_refunded);
const returned = Number(line.product_quantity_return || 0);
const reinjected = Number(line.product_quantity_reinjected || 0);
const delta = refunded - ordered;
if (delta > 0) {
findings.push({
id_order: line.id_order,
id: line.id,
product_id: line.product_id,
ordered,
refunded,
overage: delta,
reason: "refunded_exceeds_ordered",
});
}
if (returned > ordered) {
findings.push({
id_order: line.id_order,
id: line.id,
product_id: line.product_id,
ordered,
refunded: returned,
overage: returned - ordered,
reason: "returned_exceeds_ordered",
});
}
if (reinjected > refunded) {
findings.push({
id_order: line.id_order,
id: line.id,
product_id: line.product_id,
ordered: refunded,
refunded: reinjected,
overage: reinjected - refunded,
reason: "reinjected_exceeds_refunded",
});
}
}
return findings.sort((a, b) => b.overage - a.overage);
}
Cross-check credit slips, then report or clamp
For each finding, call GET /api/order_slips?filter[id_order]={id_order}&display=full&output_format=JSON to see whether real credit slips corroborate the refunded quantity, which distinguishes a legitimate high-refund history from raw data corruption. The script always logs a report row with the order id, product id, ordered quantity, refunded quantity, and the overage. It never writes by default. Only when DRY_RUN=false, and only for order ids the operator has explicitly confirmed, does it attempt the conservative repair, clamping product_quantity_refunded down to product_quantity with a full re-sent order_detail body on PUT, since PrestaShop's webservice requires the whole resource on a write, not a partial patch.
def order_slips_for(id_order):
data = api_get("order_slips", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_slips") or []
def clamp_refunded_to_ordered(order_detail_id):
full = api_get(f"order_details/{order_detail_id}")["order_detail"]
full["product_quantity_refunded"] = full["product_quantity"]
r = requests.put(
f"{PRESTASHOP_URL}/api/order_details/{order_detail_id}",
params={"output_format": "JSON"},
json={"order_detail": full},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
async function orderSlipsFor(idOrder) {
const data = await apiGet("order_slips", { "filter[id_order]": idOrder, display: "full" });
return data.order_slips || [];
}
async function clampRefundedToOrdered(orderDetailId) {
const full = (await apiGet(`order_details/${orderDetailId}`)).order_detail;
full.product_quantity_refunded = full.product_quantity;
const url = new URL(`${PRESTASHOP_URL}/api/order_details/${orderDetailId}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ order_detail: full }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT order_details/${orderDetailId}`);
return res.json();
}
Wire it together with a dry run guard
The loop ties every piece together: pull candidate orders, pull their lines, run find_refund_overage, and log a report row for every finding. DRY_RUN defaults to true, so the script only ever reports unless you flip it off and also pass an explicit, operator-confirmed list of order ids to repair through CONFIRM_ORDER_IDS. Run it on a schedule that matches how often refunds happen in the back office, for example once a day.
Always start with DRY_RUN=true. You cannot tell from the data alone whether the ordered quantity was wrongly lowered or the refunded quantity was wrongly inflated, so treat every report row as a lead for staff to check against the order's Credit Slips and Refunds screen, not a queue to auto-repair. Only clamp a specific line once a human has confirmed, order by order, that the ordered quantity is the correct number to keep.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks orders, pulls their lines, flags every overage, cross-checks credit slips, respects the dry run flag, and only ever writes a clamp when explicitly told to for a confirmed order id.
"""Detect PrestaShop order_detail rows where the refunded quantity exceeds the
ordered quantity.
PrestaShop stores product_quantity and product_quantity_refunded as independent
unsigned columns on order_detail. Standard and partial refunds, issued through
IssueStandardRefundCommand or IssuePartialRefundCommand, increment
product_quantity_refunded without ever adjusting product_quantity, and nothing in
the back office validates that the refunded count stays under the ordered count.
If a line's quantity is later edited down by hand, or repeated partial refunds
keep stacking against the same line outside the normal flow, product_quantity_refunded
can end up bigger than product_quantity. Per PrestaShop/PrestaShop#39391 this can
later throw SQLSTATE[22003]: 1690 BIGINT UNSIGNED value is out of range in
'product_quantity - product_quantity_refunded' when core code computes that
subtraction for stock or shippable-quantity checks.
This script flags affected lines by default. It never overwrites
product_quantity_refunded unless DRY_RUN is explicitly false and the order id is
in an operator-confirmed CONFIRM_ORDER_IDS list, and even then it only clamps
product_quantity_refunded down to product_quantity, re-sending the full
order_detail resource body as PrestaShop's webservice requires on a PUT.
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_refund_overage")
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_FROM = os.environ.get("DATE_FROM", "2026-06-01")
DATE_TO = os.environ.get("DATE_TO", "2026-07-11")
CONFIRM_ORDER_IDS = {
int(x) for x in os.environ.get("CONFIRM_ORDER_IDS", "").split(",") if x.strip()
}
AUTH = (PRESTASHOP_WS_KEY, "")
def find_refund_overage(order_lines):
"""Pure decision logic, no I/O.
Input: a list of order_detail dicts already fetched from the API. For each
line, computes delta = product_quantity_refunded - product_quantity; a
positive delta is a finding tagged refunded_exceeds_ordered. Also flags,
separately tagged, lines where product_quantity_return exceeds
product_quantity (returned_exceeds_ordered) or product_quantity_reinjected
exceeds product_quantity_refunded (reinjected_exceeds_refunded), the same
manual-edit-after-refund pattern on a sibling column. Returns findings
sorted by overage descending.
"""
findings = []
for line in order_lines:
ordered = int(line["product_quantity"])
refunded = int(line["product_quantity_refunded"])
returned = int(line.get("product_quantity_return", 0))
reinjected = int(line.get("product_quantity_reinjected", 0))
delta = refunded - ordered
if delta > 0:
findings.append({
"id_order": line["id_order"],
"id": line["id"],
"product_id": line["product_id"],
"ordered": ordered,
"refunded": refunded,
"overage": delta,
"reason": "refunded_exceeds_ordered",
})
if returned > ordered:
findings.append({
"id_order": line["id_order"],
"id": line["id"],
"product_id": line["product_id"],
"ordered": ordered,
"refunded": returned,
"overage": returned - ordered,
"reason": "returned_exceeds_ordered",
})
if reinjected > refunded:
findings.append({
"id_order": line["id_order"],
"id": line["id"],
"product_id": line["product_id"],
"ordered": refunded,
"refunded": reinjected,
"overage": reinjected - refunded,
"reason": "reinjected_exceeds_refunded",
})
return sorted(findings, key=lambda f: f["overage"], reverse=True)
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_order_ids(date_from, date_to):
data = api_get("orders", params={"filter[date_add]": f"[{date_from},{date_to}]", "display": "full"})
return [int(o["id"]) for o in (data.get("orders") or [])]
def order_lines(id_order):
data = api_get("order_details", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_details") or []
def order_slips_for(id_order):
data = api_get("order_slips", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_slips") or []
def clamp_refunded_to_ordered(order_detail_id):
full = api_get(f"order_details/{order_detail_id}")["order_detail"]
full["product_quantity_refunded"] = full["product_quantity"]
r = requests.put(
f"{PRESTASHOP_URL}/api/order_details/{order_detail_id}",
params={"output_format": "JSON"},
json={"order_detail": full},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
flagged = 0
repaired = 0
for id_order in recent_order_ids(DATE_FROM, DATE_TO):
lines = order_lines(id_order)
findings = find_refund_overage(lines)
if not findings:
continue
slips = order_slips_for(id_order)
for finding in findings:
flagged += 1
log.warning(
"Refund overage. id_order=%s id=%s product_id=%s ordered=%s "
"refunded=%s overage=%s reason=%s credit_slips=%d",
finding["id_order"], finding["id"], finding["product_id"],
finding["ordered"], finding["refunded"], finding["overage"],
finding["reason"], len(slips),
)
if not DRY_RUN and finding["reason"] == "refunded_exceeds_ordered" and id_order in CONFIRM_ORDER_IDS:
clamp_refunded_to_ordered(finding["id"])
repaired += 1
log.info("Clamped order_detail id=%s refunded down to ordered=%s.", finding["id"], finding["ordered"])
log.info("Done. %d line(s) flagged for review, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop order_detail rows where the refunded quantity exceeds the
* ordered quantity.
*
* PrestaShop stores product_quantity and product_quantity_refunded as independent
* unsigned columns on order_detail. Standard and partial refunds, issued through
* IssueStandardRefundCommand or IssuePartialRefundCommand, increment
* product_quantity_refunded without ever adjusting product_quantity, and nothing
* in the back office validates that the refunded count stays under the ordered
* count. If a line's quantity is later edited down by hand, or repeated partial
* refunds keep stacking against the same line outside the normal flow,
* product_quantity_refunded can end up bigger than product_quantity. Per
* PrestaShop/PrestaShop#39391 this can later throw SQLSTATE[22003]: 1690 BIGINT
* UNSIGNED value is out of range in 'product_quantity - product_quantity_refunded'
* when core code computes that subtraction for stock or shippable-quantity checks.
*
* This script flags affected lines by default. It never overwrites
* product_quantity_refunded unless DRY_RUN is explicitly false and the order id
* is in an operator-confirmed CONFIRM_ORDER_IDS list, and even then it only
* clamps product_quantity_refunded down to product_quantity, re-sending the full
* order_detail resource body as PrestaShop's webservice requires on a PUT.
*
* Guide: https://www.allanninal.dev/prestashop/refunded-quantity-exceeds-ordered/
*/
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_FROM = process.env.DATE_FROM || "2026-06-01";
const DATE_TO = process.env.DATE_TO || "2026-07-11";
const CONFIRM_ORDER_IDS = new Set(
(process.env.CONFIRM_ORDER_IDS || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.map(Number)
);
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision logic, no I/O.
*
* Input: a list of order_detail objects already fetched from the API. For each
* line, computes delta = product_quantity_refunded - product_quantity; a
* positive delta is a finding tagged refunded_exceeds_ordered. Also flags,
* separately tagged, lines where product_quantity_return exceeds
* product_quantity (returned_exceeds_ordered) or product_quantity_reinjected
* exceeds product_quantity_refunded (reinjected_exceeds_refunded). Returns
* findings sorted by overage descending.
*/
export function findRefundOverage(orderLines) {
const findings = [];
for (const line of orderLines) {
const ordered = Number(line.product_quantity);
const refunded = Number(line.product_quantity_refunded);
const returned = Number(line.product_quantity_return || 0);
const reinjected = Number(line.product_quantity_reinjected || 0);
const delta = refunded - ordered;
if (delta > 0) {
findings.push({
id_order: line.id_order,
id: line.id,
product_id: line.product_id,
ordered,
refunded,
overage: delta,
reason: "refunded_exceeds_ordered",
});
}
if (returned > ordered) {
findings.push({
id_order: line.id_order,
id: line.id,
product_id: line.product_id,
ordered,
refunded: returned,
overage: returned - ordered,
reason: "returned_exceeds_ordered",
});
}
if (reinjected > refunded) {
findings.push({
id_order: line.id_order,
id: line.id,
product_id: line.product_id,
ordered: refunded,
refunded: reinjected,
overage: reinjected - refunded,
reason: "reinjected_exceeds_refunded",
});
}
}
return findings.sort((a, b) => b.overage - a.overage);
}
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 recentOrderIds(dateFrom, dateTo) {
const data = await apiGet("orders", { "filter[date_add]": `[${dateFrom},${dateTo}]`, display: "full" });
return (data.orders || []).map((o) => Number(o.id));
}
async function orderLines(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
async function orderSlipsFor(idOrder) {
const data = await apiGet("order_slips", { "filter[id_order]": idOrder, display: "full" });
return data.order_slips || [];
}
async function clampRefundedToOrdered(orderDetailId) {
const full = (await apiGet(`order_details/${orderDetailId}`)).order_detail;
full.product_quantity_refunded = full.product_quantity;
const url = new URL(`${PRESTASHOP_URL}/api/order_details/${orderDetailId}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ order_detail: full }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT order_details/${orderDetailId}`);
return res.json();
}
export async function run() {
let flagged = 0;
let repaired = 0;
for (const idOrder of await recentOrderIds(DATE_FROM, DATE_TO)) {
const lines = await orderLines(idOrder);
const findings = findRefundOverage(lines);
if (!findings.length) continue;
const slips = await orderSlipsFor(idOrder);
for (const finding of findings) {
flagged++;
console.warn(
`Refund overage. id_order=${finding.id_order} id=${finding.id} ` +
`product_id=${finding.product_id} ordered=${finding.ordered} ` +
`refunded=${finding.refunded} overage=${finding.overage} ` +
`reason=${finding.reason} credit_slips=${slips.length}`
);
if (!DRY_RUN && finding.reason === "refunded_exceeds_ordered" && CONFIRM_ORDER_IDS.has(idOrder)) {
await clampRefundedToOrdered(finding.id);
repaired++;
console.log(`Clamped order_detail id=${finding.id} refunded down to ordered=${finding.ordered}.`);
}
}
}
console.log(`Done. ${flagged} line(s) flagged for review, ${repaired} repaired. DRY_RUN=${DRY_RUN}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which lines get flagged for review. Because we kept find_refund_overage pure, the test needs no network and no PrestaShop store. It just feeds in plain dicts and checks the answer.
from check_refund_overage import find_refund_overage
def line(**over):
base = {
"id": 1,
"id_order": 100,
"product_id": 55,
"product_quantity": 3,
"product_quantity_refunded": 2,
"product_quantity_return": 0,
"product_quantity_reinjected": 0,
}
base.update(over)
return base
def test_no_finding_when_refunded_within_ordered():
assert find_refund_overage([line()]) == []
def test_flags_refunded_exceeding_ordered():
findings = find_refund_overage([line(product_quantity=3, product_quantity_refunded=5)])
assert len(findings) == 1
finding = findings[0]
assert finding["reason"] == "refunded_exceeds_ordered"
assert finding["ordered"] == 3
assert finding["refunded"] == 5
assert finding["overage"] == 2
def test_flags_returned_exceeding_ordered():
findings = find_refund_overage([line(product_quantity=2, product_quantity_return=4)])
reasons = [f["reason"] for f in findings]
assert "returned_exceeds_ordered" in reasons
def test_flags_reinjected_exceeding_refunded():
findings = find_refund_overage([line(product_quantity_refunded=2, product_quantity_reinjected=3)])
reasons = [f["reason"] for f in findings]
assert "reinjected_exceeds_refunded" in reasons
def test_sorted_by_overage_descending():
lines = [
line(id=1, product_quantity=10, product_quantity_refunded=11),
line(id=2, product_quantity=3, product_quantity_refunded=8),
]
findings = find_refund_overage(lines)
assert [f["id"] for f in findings] == [2, 1]
def test_equal_refunded_and_ordered_is_not_flagged():
assert find_refund_overage([line(product_quantity=3, product_quantity_refunded=3)]) == []
def test_multiple_lines_only_flags_the_bad_one():
lines = [line(id=1), line(id=2, product_quantity=1, product_quantity_refunded=4)]
findings = find_refund_overage(lines)
assert len(findings) == 1
assert findings[0]["id"] == 2
import { test } from "node:test";
import assert from "node:assert/strict";
import { findRefundOverage } from "./check-refund-overage.js";
const line = (over = {}) => ({
id: 1,
id_order: 100,
product_id: 55,
product_quantity: 3,
product_quantity_refunded: 2,
product_quantity_return: 0,
product_quantity_reinjected: 0,
...over,
});
test("no finding when refunded within ordered", () => {
assert.deepEqual(findRefundOverage([line()]), []);
});
test("flags refunded exceeding ordered", () => {
const findings = findRefundOverage([line({ product_quantity: 3, product_quantity_refunded: 5 })]);
assert.equal(findings.length, 1);
assert.equal(findings[0].reason, "refunded_exceeds_ordered");
assert.equal(findings[0].ordered, 3);
assert.equal(findings[0].refunded, 5);
assert.equal(findings[0].overage, 2);
});
test("flags returned exceeding ordered", () => {
const findings = findRefundOverage([line({ product_quantity: 2, product_quantity_return: 4 })]);
assert.ok(findings.some((f) => f.reason === "returned_exceeds_ordered"));
});
test("flags reinjected exceeding refunded", () => {
const findings = findRefundOverage([line({ product_quantity_refunded: 2, product_quantity_reinjected: 3 })]);
assert.ok(findings.some((f) => f.reason === "reinjected_exceeds_refunded"));
});
test("sorted by overage descending", () => {
const lines = [
line({ id: 1, product_quantity: 10, product_quantity_refunded: 11 }),
line({ id: 2, product_quantity: 3, product_quantity_refunded: 8 }),
];
const findings = findRefundOverage(lines);
assert.deepEqual(findings.map((f) => f.id), [2, 1]);
});
test("equal refunded and ordered is not flagged", () => {
assert.deepEqual(findRefundOverage([line({ product_quantity: 3, product_quantity_refunded: 3 })]), []);
});
test("multiple lines only flags the bad one", () => {
const lines = [line({ id: 1 }), line({ id: 2, product_quantity: 1, product_quantity_refunded: 4 })];
const findings = findRefundOverage(lines);
assert.equal(findings.length, 1);
assert.equal(findings[0].id, 2);
});
Case studies
The support agent who lowered the quantity after the refund
A customer had already received a partial refund for two of three units on a line, so product_quantity_refunded read 2. A support agent later corrected the order to reflect what actually shipped and dropped product_quantity to 1, not realizing the refund history was recorded against the original quantity. The line quietly ended up with two units refunded on an order that now only showed one unit bought.
Running the diagnostic across recent orders surfaced the exact line, with ordered=1, refunded=2, and an overage of 1, so the finance team could reconcile it against the original credit slip before it ever hit a stock or shippable-quantity calculation.
The store that issued two partial refunds on the same line
A store handling a return dispute issued a partial refund for two units, then a second partial refund was raised against the same order line for three more units after a miscommunication between two support agents, pushing product_quantity_refunded to 5 on a line that only ever had 3 units ordered.
The diagnostic flagged the line with an overage of 2, and cross-checking order_slips showed two separate credit slips referencing the same line, confirming this was a real double-refund, not a rounding artifact. Staff reversed the extra credit slip through the normal accounting process and then confirmed the order id for the diagnostic's clamp step, since clawing back the quantity was safe once the accounting side was fixed first.
After this runs on a schedule, no order line silently shows more refunded than was ever ordered. Instead you get a clear, dated report showing the ordered quantity, the refunded quantity, the exact overage, and how many credit slips exist for the order, so staff can check the Credit Slips and Refunds screen and decide whether to reverse a stray refund, fix a manual edit, or leave a case that is genuinely explained by the order's history. product_quantity_refunded is never overwritten except through an explicit, confirmed override.
FAQ
Why can a PrestaShop order line show more refunded quantity than was ordered?
PrestaShop stores product_quantity and product_quantity_refunded as independent columns on order_detail. Standard and partial refunds increment product_quantity_refunded without ever adjusting product_quantity, and nothing in the back office checks that the refunded amount stays under the ordered amount. If an employee later lowers product_quantity by hand, or repeated partial refunds keep stacking against the same line, the refunded count can end up higher than what was ordered.
Is it safe to automatically fix a refunded quantity that exceeds the ordered quantity?
Not automatically. You cannot safely tell from the data alone whether product_quantity was wrongly lowered after the fact or product_quantity_refunded was wrongly inflated, and reversing either one risks real accounting or stock harm. The safe pattern is to flag every affected line for a human to review against its credit slips, and only clamp product_quantity_refunded down to product_quantity under an explicit operator-confirmed override.
How do I detect order lines where the refunded quantity exceeds the ordered quantity?
Pull each order's lines with GET order_details filtered by id_order and compare product_quantity_refunded against product_quantity on every row. Any row where product_quantity_refunded is greater than product_quantity is a finding, and product_quantity_return or product_quantity_reinjected out of range are secondary symptoms of the same pattern. Cross-check against GET order_slips for the same order to see whether real credit slips back up the refunded count.
Related field notes
Citations
On the problem:
- PrestaShop/PrestaShop GitHub issue #39391: Inconsistencies in product_quantity and product_quantity_refunded during partial refunds and subsequent manual edits of product quantities. github.com/PrestaShop/PrestaShop/issues/39391
- PrestaShop Forums: product_quantity_refunded not updated for refunded orders. forum.prestashop.com/topic/1031643-product_quantity_refunded-not-updated-for-refunded-orders
- PrestaShop/PrestaShop GitHub issue #32762: [BO] Partial refund generates wrong Orderslip. github.com/PrestaShop/PrestaShop/issues/32762
On the solution:
- PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
- PrestaShop Developer Documentation: Refunds page reference for the back office order view. devdocs.prestashop-project.org/8/development/page-reference/back-office/order/view-order/refunds/
- PrestaShop Developer Documentation: Additional list parameters, filter and display, for the webservice. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/additional-list-parameters/
Stuck on a tricky one?
If you have a problem in PrestaShop orders, refunds, stock, 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 catch a bad refund line?
If this saved you a wrong stock count or a support ticket you could not explain, 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