Skip to content

Reconciler

Partial refund accepted for more than the original line amount

Someone processes a partial refund in the back office, or a script calls the webservice directly, and PrestaShop accepts it without a fight, even though the amount is bigger than what the line was ever worth. Nothing on the order screen stops it. Here is why PrestaShop lets a refund overshoot its own order line, and a script that finds every order where that already happened so a human can look before the books close.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
Holding smartphones
Photo by Clay Banks on Unsplash
The short answer

PrestaShop's partial-refund flow, an OrderSlip created through the back office Order Refund form, the actionOrderSlipAdd hook, or a direct write through the webservice, computes the refunded amount from whatever the operator or API caller submits. There is no consistent server-side cap comparing that number against the order line's own product_quantity and total_price_tax_incl, and the check that does exist has lived only in front-end JavaScript or in per-version controller code that is not consistent across releases. Run a Python or Node.js script that pulls each order's lines with GET /api/order_details, cross-references the credit notes with GET /api/order_slip, and flags any row where product_quantity_refunded exceeds product_quantity or the refunded amount exceeds total_price_tax_incl. Full code, tests, and citations are below.

The problem in plain words

Every order line in PrestaShop lives on an order_detail row with its own product_quantity and its own total_price_tax_incl. When someone issues a partial refund, whether through the Order page's Refund panel, a module hooking actionOrderSlipAdd, or a client writing straight to the webservice, PrestaShop records that refund by creating an OrderSlip, essentially a credit note, and by updating product_quantity_refunded on the corresponding line.

The trouble is that nothing on the server side reliably stops the requested refund from being bigger than the line ever was. Validation for this has historically lived only in the back office's own JavaScript form, or in controller checks that differ from one PrestaShop release to the next, fixed in some 1.7.x builds and missing in others. A client that skips that form entirely, including a script calling the webservice directly, can post a refund quantity or amount larger than the line's product_quantity or total_price_tax_incl and PrestaShop will accept it. The confirmed side effect is that product_quantity_refunded ends up bigger than product_quantity, and PrestaShop does not recompute or cap product_quantity against the refunds already issued, an inconsistency still open as of PrestaShop 8.2.2 and 9.0.

Refund request back office or API No server-side cap vs. line qty and total never rejected OrderSlip created qty_refunded > qty Totals now inconsistent
The refund request goes straight to an OrderSlip. Nothing along the way compares it against what the line was actually worth, so the credit note and the line's refunded quantity can both end up bigger than the original.

Why it happens

PrestaShop's refund flow trusts the number it is handed. A few common ways an order ends up with an over-refunded line:

Any of these leaves a credit note, and an order, that says more was refunded than the customer ever paid for that line, which is a source of wrong accounting, gateway reconciliation failures, and refunds that quietly outrun the original sale. See the citations at the end for the exact reports and docs.

The key insight

A refund that exceeds its line total is not safe to auto-fix. It is already a financial transaction, reflected in a credit note and possibly reconciled with a payment gateway, so rewriting it after the fact risks contradicting money that has already moved. The safe pattern is not "correct every over-refund automatically." It is "flag every order and line where this happened," and reserve any corrective code for a preventive check inside the script that creates future refunds, rejecting an over-large request before it is ever sent, never mutating a refund that already exists.

The fix, as a flow

We do not touch any existing OrderSlip or order_detail row. We add a job that pulls each order's lines and its issued credit notes, computes how much has actually been refunded against each line, and reports anything where that refunded amount or quantity is bigger than the line ever was. The only place this script is allowed to prevent anything is a guard you can drop into the code that creates new refunds, so the next one never repeats the mistake.

Pull lines + credit notes order_details, order_slip Sum refunded per line qty and amount Compare to product_quantity, total_price_tax_incl is_refund_overage(...) Overage? yes no, move on Report for staff Never mutates a past refund. Guard is only for the NEXT refund request.
The job only ever reads and reports by default. The same pure decision function can guard a future refund request before it is sent, but it never edits a credit note that already exists.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with read access to orders, order_details, and order_slip. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

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

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

Pull an order's lines

Call GET /api/order_details?filter[id_order]={id}&display=full&output_format=JSON to get every line on the order, with its id, product_quantity, product_quantity_refunded, unit_price_tax_incl, and total_price_tax_incl. You can also read an order's own summary with GET /api/orders/{id}?output_format=JSON for total_paid_tax_incl.

step2.py
import os, requests

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

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

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

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

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

async function orderDetailRows(idOrder) {
  const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
  return data.order_details || [];
}
3

Cross-reference the credit notes

Call GET /api/order_slip?filter[id_order]={id}&display=full&output_format=JSON to read every credit note issued for the order, total_products_tax_incl and total_shipping_tax_incl. When a line's product_quantity_refunded is already on the order_detail row, that number is usually the simplest source for the refunded quantity, and product_quantity_refunded * unit_price_tax_incl gives you the refunded amount for that line without needing to parse every order_slip_detail row by hand.

step3.py
def order_slips(id_order):
    data = api_get("order_slip", params={
        "filter[id_order]": id_order,
        "display": "full",
    })
    return data.get("order_slip") or []

def refunded_amount_for_row(row):
    qty_refunded = int(row.get("product_quantity_refunded") or 0)
    unit_price = float(row.get("unit_price_tax_incl") or 0)
    return round(qty_refunded * unit_price, 2)
step3.js
async function orderSlips(idOrder) {
  const data = await apiGet("order_slip", { "filter[id_order]": idOrder, display: "full" });
  return data.order_slip || [];
}

function refundedAmountForRow(row) {
  const qtyRefunded = Number(row.product_quantity_refunded || 0);
  const unitPrice = Number(row.unit_price_tax_incl || 0);
  return Math.round(qtyRefunded * unitPrice * 100) / 100;
}
4

Decide, with one pure function

Keep the comparison in its own function that takes a line's product_quantity, product_quantity_refunded, its total_price_tax_incl, and the refunded amount, and returns a plain result, nothing else. It checks the quantity overage and the amount overage separately, rounds to the cent, and applies a small epsilon so a one-cent rounding difference is not falsely flagged. No network calls happen inside it, which is what makes it easy to test on its own.

decide.py
def is_refund_overage(product_quantity, product_quantity_refunded, line_total_tax_incl,
                       refunded_amount_tax_incl, epsilon=0.01):
    quantity_overage = max(0, product_quantity_refunded - product_quantity)
    raw_amount_overage = round(refunded_amount_tax_incl - line_total_tax_incl, 2)
    amount_overage = raw_amount_overage if raw_amount_overage > epsilon else 0.0
    overage = (quantity_overage > 0) or (amount_overage > epsilon)
    return {
        "overage": overage,
        "quantity_overage": quantity_overage,
        "amount_overage": amount_overage,
    }
decide.js
export function isRefundOverage(productQuantity, productQuantityRefunded, lineTotalTaxIncl,
                                 refundedAmountTaxIncl, epsilon = 0.01) {
  const quantityOverage = Math.max(0, productQuantityRefunded - productQuantity);
  const rawAmountOverage = Math.round((refundedAmountTaxIncl - lineTotalTaxIncl) * 100) / 100;
  const amountOverage = rawAmountOverage > epsilon ? rawAmountOverage : 0.0;
  const overage = quantityOverage > 0 || amountOverage > epsilon;
  return {
    overage,
    quantity_overage: quantityOverage,
    amount_overage: amountOverage,
  };
}
5

Report by default, never mutate a past refund

When a line is flagged, the script always logs a report row with id_order, id_order_detail, product_quantity, product_quantity_refunded, total_price_tax_incl, the computed refunded amount, and the overage. It never writes to order_detail or order_slip. The only place this script is allowed to reject anything is a separate guard you call before creating a new refund, comparing the requested amount or quantity against the line's remaining unrefunded balance.

guard.py
def would_new_refund_overshoot(product_quantity, product_quantity_refunded,
                                line_total_tax_incl, already_refunded_tax_incl,
                                requested_quantity, requested_amount_tax_incl):
    """Preventive guard for a NEW refund request, before it is ever sent.

    Rejects when the requested quantity or amount would exceed the line's
    remaining unrefunded balance. This never touches a refund that already
    happened, it only stops the next one from repeating the mistake.
    """
    remaining_quantity = product_quantity - product_quantity_refunded
    remaining_amount = round(line_total_tax_incl - already_refunded_tax_incl, 2)
    return requested_quantity > remaining_quantity or requested_amount_tax_incl > remaining_amount + 0.01
guard.js
/**
 * Preventive guard for a NEW refund request, before it is ever sent.
 *
 * Rejects when the requested quantity or amount would exceed the line's
 * remaining unrefunded balance. This never touches a refund that already
 * happened, it only stops the next one from repeating the mistake.
 */
export function wouldNewRefundOvershoot(productQuantity, productQuantityRefunded,
                                         lineTotalTaxIncl, alreadyRefundedTaxIncl,
                                         requestedQuantity, requestedAmountTaxIncl) {
  const remainingQuantity = productQuantity - productQuantityRefunded;
  const remainingAmount = Math.round((lineTotalTaxIncl - alreadyRefundedTaxIncl) * 100) / 100;
  return requestedQuantity > remainingQuantity || requestedAmountTaxIncl > remainingAmount + 0.01;
}
6

Wire it together

The loop ties every piece together: pull each order's lines, compute the refunded amount per row, run is_refund_overage, and log a report row for anything flagged. There is no DRY_RUN=false path that writes here, because this script only ever reports on refunds that already exist. Run it on a schedule that matches how often refunds are processed, for example once a day, and treat every flagged row as a lead for finance to check against the credit note and the gateway.

Run it safe

This script never writes to order_detail or order_slip, so there is no dry run flag to flip for the report path itself. Treat every flagged order as a lead for a human to check in the order-edit screen and against the payment gateway, not a queue to auto-correct. If you also wire in the preventive guard for new refunds, keep it in the calling script that creates the refund, never as a background job that edits history.

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 and credit notes, flags every line where the refund exceeds the original, and never mutates a record that already exists.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
check_refund_overage.py
"""Detect PrestaShop order lines where a partial refund exceeds the line's own total.

PrestaShop's partial-refund flow, whether through the back office Order Refund form, the
actionOrderSlipAdd hook, or a direct write through the webservice, computes the refunded
amount from whatever the operator or API caller submits. There is no consistent
server-side cap comparing that number against the order line's own product_quantity and
total_price_tax_incl, so a client that skips the back-office form can post a refund that
exceeds the line total with no rejection. The confirmed side effect is that
product_quantity_refunded can exceed product_quantity, since PrestaShop does not
recompute or cap product_quantity against refunds already issued.

This script only ever reports. It never mutates an order_detail row or an order_slip,
because a refund is a financial transaction already reflected in a credit note and
possibly reconciled with a payment gateway. The only corrective code here is a preventive
guard meant to be called before a NEW refund is created, not a repair of history.

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"
ORDER_ID_RANGE = os.environ.get("ORDER_ID_RANGE", "1,50")
AUTH = (PRESTASHOP_WS_KEY, "")

EPSILON = 0.01


def is_refund_overage(product_quantity, product_quantity_refunded, line_total_tax_incl,
                       refunded_amount_tax_incl, epsilon=EPSILON):
    """Pure decision logic, no I/O.

    Compares the refunded quantity and amount against the order line's own quantity and
    total, and returns a dict describing whether either one overshoots, and by how much.
    Caller supplies all values already fetched from the API.
    """
    quantity_overage = max(0, product_quantity_refunded - product_quantity)
    raw_amount_overage = round(refunded_amount_tax_incl - line_total_tax_incl, 2)
    amount_overage = raw_amount_overage if raw_amount_overage > epsilon else 0.0
    overage = (quantity_overage > 0) or (amount_overage > epsilon)
    return {
        "overage": overage,
        "quantity_overage": quantity_overage,
        "amount_overage": amount_overage,
    }


def would_new_refund_overshoot(product_quantity, product_quantity_refunded,
                                line_total_tax_incl, already_refunded_tax_incl,
                                requested_quantity, requested_amount_tax_incl):
    """Preventive guard for a NEW refund request, before it is ever sent.

    Rejects when the requested quantity or amount would exceed the line's remaining
    unrefunded balance. This never touches a refund that already happened.
    """
    remaining_quantity = product_quantity - product_quantity_refunded
    remaining_amount = round(line_total_tax_incl - already_refunded_tax_incl, 2)
    return requested_quantity > remaining_quantity or requested_amount_tax_incl > remaining_amount + EPSILON


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 orders_in_range(id_range):
    data = api_get("orders", params={"filter[id]": f"[{id_range}]", "display": "full"})
    return data.get("orders") or []


def order_detail_rows(id_order):
    data = api_get("order_details", params={"filter[id_order]": id_order, "display": "full"})
    return data.get("order_details") or []


def refunded_amount_for_row(row):
    qty_refunded = int(row.get("product_quantity_refunded") or 0)
    unit_price = float(row.get("unit_price_tax_incl") or 0)
    return round(qty_refunded * unit_price, 2)


def run():
    flagged = 0
    for order in orders_in_range(ORDER_ID_RANGE):
        id_order = order["id"]
        for row in order_detail_rows(id_order):
            product_quantity = int(row.get("product_quantity") or 0)
            product_quantity_refunded = int(row.get("product_quantity_refunded") or 0)
            line_total_tax_incl = float(row.get("total_price_tax_incl") or 0)
            refunded_amount = refunded_amount_for_row(row)
            result = is_refund_overage(product_quantity, product_quantity_refunded,
                                        line_total_tax_incl, refunded_amount)
            if not result["overage"]:
                continue
            flagged += 1
            log.warning(
                "Refund overage. id_order=%s id_order_detail=%s product_quantity=%s "
                "product_quantity_refunded=%s total_price_tax_incl=%.2f refunded_amount=%.2f "
                "quantity_overage=%s amount_overage=%.2f",
                id_order, row.get("id"), product_quantity, product_quantity_refunded,
                line_total_tax_incl, refunded_amount,
                result["quantity_overage"], result["amount_overage"],
            )
    log.info("Done. %d line(s) flagged for review. DRY_RUN=%s (report only, no writes).", flagged, DRY_RUN)


if __name__ == "__main__":
    run()
check-refund-overage.js
/**
 * Detect PrestaShop order lines where a partial refund exceeds the line's own total.
 *
 * PrestaShop's partial-refund flow, whether through the back office Order Refund form, the
 * actionOrderSlipAdd hook, or a direct write through the webservice, computes the refunded
 * amount from whatever the operator or API caller submits. There is no consistent
 * server-side cap comparing that number against the order line's own product_quantity and
 * total_price_tax_incl, so a client that skips the back-office form can post a refund that
 * exceeds the line total with no rejection. The confirmed side effect is that
 * product_quantity_refunded can exceed product_quantity, since PrestaShop does not
 * recompute or cap product_quantity against refunds already issued.
 *
 * This script only ever reports. It never mutates an order_detail row or an order_slip,
 * because a refund is a financial transaction already reflected in a credit note and
 * possibly reconciled with a payment gateway. The only corrective code here is a
 * preventive guard meant to be called before a NEW refund is created, not a repair of
 * history.
 *
 * Guide: https://www.allanninal.dev/prestashop/refund-amount-exceeds-line-total/
 */
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 ORDER_ID_RANGE = process.env.ORDER_ID_RANGE || "1,50";

const EPSILON = 0.01;

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

/**
 * Pure decision logic, no I/O.
 *
 * Compares the refunded quantity and amount against the order line's own quantity and
 * total, and returns an object describing whether either one overshoots, and by how much.
 * Caller supplies all values already fetched from the API.
 */
export function isRefundOverage(productQuantity, productQuantityRefunded, lineTotalTaxIncl,
                                 refundedAmountTaxIncl, epsilon = EPSILON) {
  const quantityOverage = Math.max(0, productQuantityRefunded - productQuantity);
  const rawAmountOverage = Math.round((refundedAmountTaxIncl - lineTotalTaxIncl) * 100) / 100;
  const amountOverage = rawAmountOverage > epsilon ? rawAmountOverage : 0.0;
  const overage = quantityOverage > 0 || amountOverage > epsilon;
  return {
    overage,
    quantity_overage: quantityOverage,
    amount_overage: amountOverage,
  };
}

/**
 * Preventive guard for a NEW refund request, before it is ever sent.
 *
 * Rejects when the requested quantity or amount would exceed the line's remaining
 * unrefunded balance. This never touches a refund that already happened.
 */
export function wouldNewRefundOvershoot(productQuantity, productQuantityRefunded,
                                         lineTotalTaxIncl, alreadyRefundedTaxIncl,
                                         requestedQuantity, requestedAmountTaxIncl) {
  const remainingQuantity = productQuantity - productQuantityRefunded;
  const remainingAmount = Math.round((lineTotalTaxIncl - alreadyRefundedTaxIncl) * 100) / 100;
  return requestedQuantity > remainingQuantity || requestedAmountTaxIncl > remainingAmount + EPSILON;
}

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 ordersInRange(idRange) {
  const data = await apiGet("orders", { "filter[id]": `[${idRange}]`, display: "full" });
  return data.orders || [];
}

async function orderDetailRows(idOrder) {
  const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
  return data.order_details || [];
}

function refundedAmountForRow(row) {
  const qtyRefunded = Number(row.product_quantity_refunded || 0);
  const unitPrice = Number(row.unit_price_tax_incl || 0);
  return Math.round(qtyRefunded * unitPrice * 100) / 100;
}

export async function run() {
  let flagged = 0;
  for (const order of await ordersInRange(ORDER_ID_RANGE)) {
    const idOrder = order.id;
    for (const row of await orderDetailRows(idOrder)) {
      const productQuantity = Number(row.product_quantity || 0);
      const productQuantityRefunded = Number(row.product_quantity_refunded || 0);
      const lineTotalTaxIncl = Number(row.total_price_tax_incl || 0);
      const refundedAmount = refundedAmountForRow(row);
      const result = isRefundOverage(productQuantity, productQuantityRefunded, lineTotalTaxIncl, refundedAmount);
      if (!result.overage) continue;
      flagged++;
      console.warn(
        `Refund overage. id_order=${idOrder} id_order_detail=${row.id} ` +
          `product_quantity=${productQuantity} product_quantity_refunded=${productQuantityRefunded} ` +
          `total_price_tax_incl=${lineTotalTaxIncl.toFixed(2)} refunded_amount=${refundedAmount.toFixed(2)} ` +
          `quantity_overage=${result.quantity_overage} amount_overage=${result.amount_overage.toFixed(2)}`
      );
    }
  }
  console.log(`Done. ${flagged} line(s) flagged for review. DRY_RUN=${DRY_RUN} (report only, no writes).`);
}

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 is_refund_overage pure, the test needs no network and no PrestaShop store. It just feeds in plain numbers and checks the answer.

test_refund_overage.py
from check_refund_overage import is_refund_overage


def test_exact_match_is_not_overage():
    result = is_refund_overage(2, 2, 100.00, 100.00)
    assert result["overage"] is False
    assert result["quantity_overage"] == 0
    assert result["amount_overage"] == 0.0


def test_one_cent_rounding_is_not_overage():
    result = is_refund_overage(2, 2, 100.00, 100.01)
    assert result["overage"] is False


def test_quantity_overage_is_flagged():
    result = is_refund_overage(2, 3, 100.00, 100.00)
    assert result["overage"] is True
    assert result["quantity_overage"] == 1
    assert result["amount_overage"] == 0.0


def test_amount_overage_is_flagged():
    result = is_refund_overage(2, 2, 100.00, 150.00)
    assert result["overage"] is True
    assert result["quantity_overage"] == 0
    assert result["amount_overage"] == 50.00


def test_zero_quantity_line_with_refund_is_flagged():
    result = is_refund_overage(0, 1, 0.00, 25.00)
    assert result["overage"] is True
    assert result["quantity_overage"] == 1
    assert result["amount_overage"] == 25.00


def test_negative_refunded_amount_is_not_overage():
    result = is_refund_overage(2, 0, 100.00, -10.00)
    assert result["overage"] is False
    assert result["amount_overage"] == 0.0


def test_custom_epsilon_is_respected():
    result = is_refund_overage(2, 2, 100.00, 100.03, epsilon=0.05)
    assert result["overage"] is False
refund-overage.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isRefundOverage } from "./check-refund-overage.js";

test("exact match is not overage", () => {
  const result = isRefundOverage(2, 2, 100.00, 100.00);
  assert.equal(result.overage, false);
  assert.equal(result.quantity_overage, 0);
  assert.equal(result.amount_overage, 0.0);
});

test("one cent rounding is not overage", () => {
  const result = isRefundOverage(2, 2, 100.00, 100.01);
  assert.equal(result.overage, false);
});

test("quantity overage is flagged", () => {
  const result = isRefundOverage(2, 3, 100.00, 100.00);
  assert.equal(result.overage, true);
  assert.equal(result.quantity_overage, 1);
  assert.equal(result.amount_overage, 0.0);
});

test("amount overage is flagged", () => {
  const result = isRefundOverage(2, 2, 100.00, 150.00);
  assert.equal(result.overage, true);
  assert.equal(result.quantity_overage, 0);
  assert.equal(result.amount_overage, 50.00);
});

test("zero quantity line with refund is flagged", () => {
  const result = isRefundOverage(0, 1, 0.00, 25.00);
  assert.equal(result.overage, true);
  assert.equal(result.quantity_overage, 1);
  assert.equal(result.amount_overage, 25.00);
});

test("negative refunded amount is not overage", () => {
  const result = isRefundOverage(2, 0, 100.00, -10.00);
  assert.equal(result.overage, false);
  assert.equal(result.amount_overage, 0.0);
});

test("custom epsilon is respected", () => {
  const result = isRefundOverage(2, 2, 100.00, 100.03, 0.05);
  assert.equal(result.overage, false);
});

Case studies

Direct API write

The integration that refunded twice by accident

A support tool synced refund requests from a helpdesk ticket into PrestaShop through the webservice, writing a new order_slip for every ticket without ever checking what had already been refunded on that line. A retry after a timeout quietly created a second credit note for the same item.

Running the diagnostic across the last month of orders surfaced every line where product_quantity_refunded had passed product_quantity, letting finance catch the double refund and match it against the gateway before it showed up as a discrepancy in the monthly reconciliation.

Back office typo

The refund amount typed in the wrong field

An agent processing a partial refund in the back office meant to refund one unit of a two-unit line, but typed the full order total into the refund amount field instead of the per-unit price. The form let it through, and PrestaShop recorded a credit note bigger than the line had ever been worth.

The diagnostic flagged the exact order and line the next morning, with the stored total, the refunded amount, and the overage in dollars, so the team could void the extra and correct the credit note through their standard accounting process instead of finding it during an audit.

What good looks like

After this runs on a schedule, no over-refunded line hides until an audit finds it. Instead you get a clear, dated report showing the order, the line, the original quantity and total, the refunded amount, and the exact overage, so finance can check it against the credit note and the payment gateway. No historical refund is ever mutated. The only thing this script is allowed to prevent is the next refund request, using the same pure function as a guard before it is created.

FAQ

Why does PrestaShop accept a partial refund larger than the order line total?

PrestaShop's partial-refund flow, whether through the back office Order Refund form or a direct write through the webservice, computes the refunded amount from whatever the operator or API caller submits. There is no consistent server-side check comparing the requested refund quantity or amount against the order line's own product_quantity and total_price_tax_incl, so a client that skips the back-office form can post a refund that exceeds the line total with no rejection.

Is it safe to automatically correct a refund that overshoots the line total?

No. A refund is a financial transaction already reflected in a credit note and possibly reconciled with a payment gateway, so an automated tool should never mutate a historical refund record. The safe pattern is to flag every order and line where the refunded amount or quantity exceeds the original for a human to review, and only add a preventive check to the script that creates future refunds, rejecting a request before it is sent rather than editing one that already happened.

How do I detect orders where a refund exceeds the line total?

Pull each order's lines with GET order_details filtered by id_order to read product_quantity, product_quantity_refunded, and total_price_tax_incl per row, and cross-reference GET order_slip filtered by id_order for the credit notes already issued. Flag any row where product_quantity_refunded is greater than product_quantity, or where the refunded amount for that line is greater than total_price_tax_incl by more than a cent.

Related field notes

Citations

On the problem:

  1. PrestaShop/PrestaShop GitHub issue #21194: Partial refund in the back office accepts a refund amount more than the ordered amount and also gets reflected in the UI. github.com/PrestaShop/PrestaShop/issues/21194
  2. 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
  3. PrestaShop/PrestaShop GitHub issue #38840: Partial refund for quantity less than or equal to zero. github.com/PrestaShop/PrestaShop/issues/38840

On the solution:

  1. PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
  2. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
  3. PrestaShop Developer Documentation: Webservice reference, authentication and filtering. devdocs.prestashop-project.org/9/webservice/reference/

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.

Contact me on LinkedIn

Did this catch an over-refunded line?

If this saved you a wrong credit note or a reconciliation that did not add up, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all PrestaShop field notes