Diagnostic
Credit slip amount ignores the original voucher discount
A customer used a voucher, the order total dropped, and everyone paid the net amount. Then a refund comes in, PrestaShop writes a credit slip, and the number on it is bigger than what the customer actually paid, because the discount never made it into the math. Here is why a PrestaShop credit slip can quietly hand back the voucher as extra refund, and a script that finds every order slip this already happened to so accounting can fix it by hand.
A voucher, or cart rule, reduces an order's total order-wide and is stored in order_cart_rules, linked to id_order, not to any single order line. When a refund creates an order_slip, either PrestaShop's own refund computation or the separate PDF or HTML credit slip template can total the refund from each line's gross unit_price_tax_incl instead of the net amount the customer actually paid after the voucher, so the credit slip ends up bigger than it should be. Run a Python or Node.js script that pulls each order's lines, its vouchers, and its issued credit slips, computes the expected refund with the discount properly prorated, and flags any order_slip whose amount overshoots that expectation. Full code, tests, and citations are below.
The problem in plain words
When a customer applies a voucher at checkout, PrestaShop reduces the order's total and records that reduction as a row in order_cart_rules, tied to the order as a whole through id_order. The individual lines in order_detail keep their own gross price, the price before the voucher, because the discount was never split back down onto each line. That is fine while the order just sits there, the order-wide total already reflects the discount and everyone paid the net amount.
The trouble starts when part or all of the order gets refunded. Generating a credit slip means creating an OrderSlip, and that computation needs to know how much of the voucher applies to the products being refunded. Because the discount lives at the order level and not on the line, the core refund computation, and separately the PDF or HTML template that renders the credit slip, can each total the refund from the line's own gross unit_price_tax_incl rather than the net amount after the voucher's share of that line. The result is a credit slip whose total_products_tax_incl or amount is larger than what the customer ever paid, effectively refunding the discount back to them a second time.
Why it happens
PrestaShop's order model keeps the voucher and the line prices in two places that a refund has to reconcile itself, and it does not always do that consistently. A few common ways it shows up:
- The voucher reduction lives in
order_cart_ruleslinked toid_order, with no column tying a specific slice of it to a specificorder_detailrow, so any refund code has to derive the proration itself instead of reading it off the line. - The core
OrderSlipgeneration for a standard refund and the code path for a partial refund do not share one code path, so a proration fix applied to one can be missing from the other in a given PrestaShop version. - The PDF and HTML credit slip templates,
HTMLTemplateOrderSlipand the related renderer, pull their own totals for display and have separately been found to use the line's gross price rather than the net amount, so even when the underlying refund record is correct the printed document can disagree with it. - This has been reported repeatedly against different versions and different refund paths rather than fixed once, for example GitHub issues #18319, #19214, #28284, and #34958, which is why a generic patch should not be assumed present in any given store's PrestaShop install.
Any of these leaves a credit slip that hands the customer back their voucher discount a second time, on top of the refund they were actually owed, which is a source of wrong accounting and refunds that quietly exceed what was paid. See the citations at the end for the exact reports and docs.
An order_slip is not a number you can safely recompute after the fact. It is an accounting and legal document, often already reflected in an exported invoice, a posted accounting entry, or a refund that already left the bank, and PrestaShop's webservice has no supported endpoint to edit or delete a posted credit slip. The safe pattern is not "recalculate every credit slip and overwrite it." It is "flag every order slip where the voucher looks ignored," and let accounting staff issue a correcting credit slip or partial reversal through the back office, which is the only path that keeps the accounting trail consistent.
The fix, as a flow
We do not touch any existing order_slip row. We add a job that pulls each order's lines, its vouchers, and its issued credit slips, computes what the refund should have been once the voucher's share is prorated in, and reports anything where the recorded credit slip is bigger than that by more than a rounding tolerance. The output goes to accounting staff, who issue any correction through the back office themselves.
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, order_cart_rules, 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.
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 an order's lines and its vouchers
Call GET /api/order_details?filter[id_order]={id}&display=full&output_format=JSON for each line's product_quantity, unit_price_tax_incl, and total_price_tax_incl. Call GET /api/order_cart_rules?filter[id_order]={id}&display=full&output_format=JSON and sum the value or value_tax_incl fields to get the total voucher reduction for the order. You can also read the order's own summary with GET /api/orders/{id}?output_format=JSON for total_paid_real and total_paid_tax_incl.
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 []
def order_cart_rules(id_order):
data = api_get("order_cart_rules", params={
"filter[id_order]": id_order,
"display": "full",
})
return data.get("order_cart_rules") 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 orderDetailRows(idOrder) {
const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
return data.order_details || [];
}
async function orderCartRules(idOrder) {
const data = await apiGet("order_cart_rules", { "filter[id_order]": idOrder, display: "full" });
return data.order_cart_rules || [];
}
Cross-reference the credit slips already issued
Call GET /api/order_slip?filter[id_order]={id}&display=full&output_format=JSON for every credit note on the order, reading total_products_tax_incl, total_shipping_tax_incl, and amount. Each of these was generated at some point from the refunded quantity on one or more lines, so the comparison in the next step needs the line totals, the voucher sum, and this recorded amount together.
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 slip_amount(slip):
products = float(slip.get("total_products_tax_incl") or 0)
shipping = float(slip.get("total_shipping_tax_incl") or 0)
amount = slip.get("amount")
return float(amount) if amount is not None else round(products + shipping, 2)
async function orderSlips(idOrder) {
const data = await apiGet("order_slip", { "filter[id_order]": idOrder, display: "full" });
return data.order_slip || [];
}
function slipAmount(slip) {
const products = Number(slip.total_products_tax_incl || 0);
const shipping = Number(slip.total_shipping_tax_incl || 0);
return slip.amount != null ? Number(slip.amount) : Math.round((products + shipping) * 100) / 100;
}
Decide, with one pure function
Keep the math in its own function that takes plain line items, the voucher total, the pre-discount products total, and any refunded shipping, and returns the expected refund as a plain number. It computes a discount ratio from the voucher, prorates each line by how much of its quantity was refunded, and applies the ratio to the sum before adding shipping back. A second, equally small function compares that expected number against what the order_slip actually recorded, with a rounding tolerance. Neither function touches the network, which is what makes them easy to test on their own.
from decimal import Decimal
def expected_refund_amount(line_items, voucher_total_tax_incl, products_total_before_discount_tax_incl,
shipping_refund_tax_incl=Decimal("0")):
if products_total_before_discount_tax_incl:
discount_ratio = voucher_total_tax_incl / products_total_before_discount_tax_incl
else:
discount_ratio = Decimal("0")
gross_refund = Decimal("0")
for line in line_items:
qty_ordered = line["qty_ordered"]
if qty_ordered > 0:
prorated = line["line_total_tax_incl"] * (Decimal(line["qty_refunded"]) / Decimal(qty_ordered))
else:
prorated = Decimal("0")
gross_refund += prorated
return round(gross_refund * (Decimal("1") - discount_ratio) + shipping_refund_tax_incl, 2)
def is_slip_overstated(actual_slip_amount, expected_amount, tolerance=Decimal("0.02")):
return (actual_slip_amount - expected_amount) > tolerance
export function expectedRefundAmount(lineItems, voucherTotalTaxIncl, productsTotalBeforeDiscountTaxIncl,
shippingRefundTaxIncl = 0) {
const discountRatio = productsTotalBeforeDiscountTaxIncl
? voucherTotalTaxIncl / productsTotalBeforeDiscountTaxIncl
: 0;
let grossRefund = 0;
for (const line of lineItems) {
const qtyOrdered = line.qty_ordered;
const prorated = qtyOrdered > 0 ? line.line_total_tax_incl * (line.qty_refunded / qtyOrdered) : 0;
grossRefund += prorated;
}
const result = grossRefund * (1 - discountRatio) + shippingRefundTaxIncl;
return Math.round(result * 100) / 100;
}
export function isSlipOverstated(actualSlipAmount, expectedAmount, tolerance = 0.02) {
return actualSlipAmount - expectedAmount > tolerance;
}
Report by default, never touch a posted credit slip
When an order_slip is flagged, the script logs a report row with id_order, id_order_slip, voucher_value_detected, expected_refund, actual_slip_amount, and overstated_by. It never writes to order_slip, and only orders that actually have a row in order_cart_rules are checked this way, which keeps ordinary tax or shipping rounding noise from ever being confused with the voucher-ignored case. Every flagged row is a lead for accounting to manually issue a correcting credit slip or partial reversal through Orders, Credit Slips.
This script never writes to order_slip, so there is no dry run flag to flip for the report path itself. An order_slip is an accounting and legal document, so treat every flagged row as a lead for accounting staff to check against the invoice and the actual bank refund, then correct by hand through Orders, Credit Slips in the back office. That is the only path guaranteed to keep the accounting trail consistent.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks a range of orders, pulls their lines, vouchers, and credit slips, computes the expected refund with the discount prorated in, and flags every order slip that overstated it, sorted by the size of the overstatement.
"""Detect PrestaShop credit slips that ignored the order's own voucher discount.
A voucher, or cart rule, reduces an order's total order-wide and is stored in
order_cart_rules, linked to id_order, not to any single order_detail line. When a refund
creates an order_slip, PrestaShop's core refund computation, and separately the PDF or
HTML credit slip template, can each total the refund from a line's gross
unit_price_tax_incl instead of the net amount the customer actually paid after the
voucher. The result is a credit slip whose total_products_tax_incl or amount is bigger
than it should be, effectively handing the voucher discount back as extra refund. This is
a long-standing, repeatedly reported defect (GitHub #18319, #19214, #28284, #34958)
rather than a one-off bug, and different refund paths have each been found to skip the
voucher reduction differently, so a generic patch should not be assumed present in any
given store's PrestaShop version.
This script only ever reports. It never mutates an order_slip, because a credit slip is
an accounting and legal document, often already reflected in an exported invoice, a
posted accounting entry, or a refund that already left the bank. Every flagged order is a
lead for accounting staff to correct by hand through Orders, Credit Slips in the back
office.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
from decimal import Decimal, ROUND_HALF_UP
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_credit_slip_voucher")
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, "")
TOLERANCE = Decimal("0.02")
def _d(value):
return Decimal(str(value if value is not None else 0))
def expected_refund_amount(line_items, voucher_total_tax_incl, products_total_before_discount_tax_incl,
shipping_refund_tax_incl=Decimal("0")):
"""Pure decision logic, no I/O.
Prorates each refunded line by its own qty_refunded / qty_ordered, sums those into a
gross refund, then applies the order-level discount_ratio derived from the voucher
total before adding back any refunded shipping. Caller supplies all values already
fetched from the API.
"""
if products_total_before_discount_tax_incl:
discount_ratio = voucher_total_tax_incl / products_total_before_discount_tax_incl
else:
discount_ratio = Decimal("0")
gross_refund = Decimal("0")
for line in line_items:
qty_ordered = line["qty_ordered"]
if qty_ordered > 0:
prorated = line["line_total_tax_incl"] * (Decimal(line["qty_refunded"]) / Decimal(qty_ordered))
else:
prorated = Decimal("0")
gross_refund += prorated
result = gross_refund * (Decimal("1") - discount_ratio) + shipping_refund_tax_incl
return result.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def is_slip_overstated(actual_slip_amount, expected_amount, tolerance=TOLERANCE):
"""Pure decision logic, no I/O. True when the recorded credit slip amount exceeds
the expected refund by more than the rounding tolerance."""
return (actual_slip_amount - expected_amount) > tolerance
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 order_cart_rules(id_order):
data = api_get("order_cart_rules", params={"filter[id_order]": id_order, "display": "full"})
return data.get("order_cart_rules") or []
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 slip_amount(slip):
products = _d(slip.get("total_products_tax_incl"))
shipping = _d(slip.get("total_shipping_tax_incl"))
amount = slip.get("amount")
return _d(amount) if amount is not None else (products + shipping)
def run():
flagged = 0
for order in orders_in_range(ORDER_ID_RANGE):
id_order = order["id"]
rules = order_cart_rules(id_order)
if not rules:
continue # no voucher on this order, nothing to check
voucher_total = sum((_d(r.get("value_tax_incl") or r.get("value")) for r in rules), Decimal("0"))
rows = order_detail_rows(id_order)
products_total_before_discount = sum((_d(row.get("total_price_tax_incl")) for row in rows), Decimal("0"))
line_items = [
{
"qty_ordered": int(row.get("product_quantity") or 0),
"qty_refunded": int(row.get("product_quantity_refunded") or 0),
"line_total_tax_incl": _d(row.get("total_price_tax_incl")),
}
for row in rows
]
expected = expected_refund_amount(line_items, voucher_total, products_total_before_discount)
for slip in order_slips(id_order):
actual = slip_amount(slip)
if not is_slip_overstated(actual, expected):
continue
flagged += 1
log.warning(
"Credit slip overstated. id_order=%s id_order_slip=%s voucher_value_detected=%.2f "
"expected_refund=%.2f actual_slip_amount=%.2f overstated_by=%.2f",
id_order, slip.get("id"), voucher_total, expected, actual, actual - expected,
)
log.info("Done. %d order slip(s) flagged for review. DRY_RUN=%s (report only, no writes).", flagged, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop credit slips that ignored the order's own voucher discount.
*
* A voucher, or cart rule, reduces an order's total order-wide and is stored in
* order_cart_rules, linked to id_order, not to any single order_detail line. When a
* refund creates an order_slip, PrestaShop's core refund computation, and separately the
* PDF or HTML credit slip template, can each total the refund from a line's gross
* unit_price_tax_incl instead of the net amount the customer actually paid after the
* voucher. The result is a credit slip whose total_products_tax_incl or amount is bigger
* than it should be, effectively handing the voucher discount back as extra refund. This
* is a long-standing, repeatedly reported defect (GitHub #18319, #19214, #28284, #34958)
* rather than a one-off bug, and different refund paths have each been found to skip the
* voucher reduction differently, so a generic patch should not be assumed present in any
* given store's PrestaShop version.
*
* This script only ever reports. It never mutates an order_slip, because a credit slip
* is an accounting and legal document, often already reflected in an exported invoice, a
* posted accounting entry, or a refund that already left the bank. Every flagged order
* is a lead for accounting staff to correct by hand through Orders, Credit Slips in the
* back office.
*
* Guide: https://www.allanninal.dev/prestashop/credit-slip-ignores-voucher-discount/
*/
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 TOLERANCE = 0.02;
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision logic, no I/O.
*
* Prorates each refunded line by its own qty_refunded / qty_ordered, sums those into a
* gross refund, then applies the order-level discount ratio derived from the voucher
* total before adding back any refunded shipping. Caller supplies all values already
* fetched from the API.
*/
export function expectedRefundAmount(lineItems, voucherTotalTaxIncl, productsTotalBeforeDiscountTaxIncl,
shippingRefundTaxIncl = 0) {
const discountRatio = productsTotalBeforeDiscountTaxIncl
? voucherTotalTaxIncl / productsTotalBeforeDiscountTaxIncl
: 0;
let grossRefund = 0;
for (const line of lineItems) {
const qtyOrdered = line.qty_ordered;
const prorated = qtyOrdered > 0 ? line.line_total_tax_incl * (line.qty_refunded / qtyOrdered) : 0;
grossRefund += prorated;
}
const result = grossRefund * (1 - discountRatio) + shippingRefundTaxIncl;
return Math.round(result * 100) / 100;
}
/**
* Pure decision logic, no I/O. True when the recorded credit slip amount exceeds the
* expected refund by more than the rounding tolerance.
*/
export function isSlipOverstated(actualSlipAmount, expectedAmount, tolerance = TOLERANCE) {
return actualSlipAmount - expectedAmount > tolerance;
}
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 || [];
}
async function orderCartRules(idOrder) {
const data = await apiGet("order_cart_rules", { "filter[id_order]": idOrder, display: "full" });
return data.order_cart_rules || [];
}
async function orderSlips(idOrder) {
const data = await apiGet("order_slip", { "filter[id_order]": idOrder, display: "full" });
return data.order_slip || [];
}
function slipAmount(slip) {
const products = Number(slip.total_products_tax_incl || 0);
const shipping = Number(slip.total_shipping_tax_incl || 0);
return slip.amount != null ? Number(slip.amount) : products + shipping;
}
export async function run() {
let flagged = 0;
for (const order of await ordersInRange(ORDER_ID_RANGE)) {
const idOrder = order.id;
const rules = await orderCartRules(idOrder);
if (!rules.length) continue; // no voucher on this order, nothing to check
const voucherTotal = rules.reduce((sum, r) => sum + Number(r.value_tax_incl ?? r.value ?? 0), 0);
const rows = await orderDetailRows(idOrder);
const productsTotalBeforeDiscount = rows.reduce((sum, row) => sum + Number(row.total_price_tax_incl || 0), 0);
const lineItems = rows.map((row) => ({
qty_ordered: Number(row.product_quantity || 0),
qty_refunded: Number(row.product_quantity_refunded || 0),
line_total_tax_incl: Number(row.total_price_tax_incl || 0),
}));
const expected = expectedRefundAmount(lineItems, voucherTotal, productsTotalBeforeDiscount);
for (const slip of await orderSlips(idOrder)) {
const actual = slipAmount(slip);
if (!isSlipOverstated(actual, expected)) continue;
flagged++;
console.warn(
`Credit slip overstated. id_order=${idOrder} id_order_slip=${slip.id} ` +
`voucher_value_detected=${voucherTotal.toFixed(2)} expected_refund=${expected.toFixed(2)} ` +
`actual_slip_amount=${actual.toFixed(2)} overstated_by=${(actual - expected).toFixed(2)}`
);
}
}
console.log(`Done. ${flagged} order slip(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 two decision functions are the part most worth testing, because they decide which credit slips get flagged for review. Because we kept expected_refund_amount and is_slip_overstated pure, the tests need no network and no PrestaShop store. They just feed in plain numbers and check the answer, including an order with no voucher, a full refund, a partial refund, and a zero-quantity line.
from decimal import Decimal
from check_credit_slip_voucher import expected_refund_amount, is_slip_overstated
def test_full_refund_with_no_voucher_matches_line_total():
lines = [{"qty_ordered": 2, "qty_refunded": 2, "line_total_tax_incl": Decimal("100.00")}]
result = expected_refund_amount(lines, Decimal("0"), Decimal("100.00"))
assert result == Decimal("100.00")
def test_full_refund_with_voucher_prorates_the_discount():
# order total 100, a 10 voucher was applied, so a full refund should be 90
lines = [{"qty_ordered": 1, "qty_refunded": 1, "line_total_tax_incl": Decimal("100.00")}]
result = expected_refund_amount(lines, Decimal("10.00"), Decimal("100.00"))
assert result == Decimal("90.00")
def test_partial_refund_prorates_both_quantity_and_voucher():
# 2 of 4 units refunded on a 200 line, with a 20 voucher on a 200 order
lines = [{"qty_ordered": 4, "qty_refunded": 2, "line_total_tax_incl": Decimal("200.00")}]
result = expected_refund_amount(lines, Decimal("20.00"), Decimal("200.00"))
# gross prorated = 100.00, discount_ratio = 0.10, expected = 90.00
assert result == Decimal("90.00")
def test_zero_qty_ordered_line_contributes_nothing():
lines = [{"qty_ordered": 0, "qty_refunded": 0, "line_total_tax_incl": Decimal("50.00")}]
result = expected_refund_amount(lines, Decimal("0"), Decimal("50.00"))
assert result == Decimal("0.00")
def test_zero_products_total_gives_zero_discount_ratio():
lines = [{"qty_ordered": 1, "qty_refunded": 1, "line_total_tax_incl": Decimal("0.00")}]
result = expected_refund_amount(lines, Decimal("5.00"), Decimal("0"))
assert result == Decimal("0.00")
def test_shipping_refund_is_added_after_the_discount():
lines = [{"qty_ordered": 1, "qty_refunded": 1, "line_total_tax_incl": Decimal("100.00")}]
result = expected_refund_amount(lines, Decimal("10.00"), Decimal("100.00"), shipping_refund_tax_incl=Decimal("5.00"))
assert result == Decimal("95.00")
def test_slip_matching_expected_is_not_overstated():
assert is_slip_overstated(Decimal("90.00"), Decimal("90.00")) is False
def test_slip_within_tolerance_is_not_overstated():
assert is_slip_overstated(Decimal("90.01"), Decimal("90.00")) is False
def test_slip_ignoring_voucher_is_overstated():
# slip totaled the gross line instead of the net amount after the voucher
assert is_slip_overstated(Decimal("100.00"), Decimal("90.00")) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { expectedRefundAmount, isSlipOverstated } from "./check-credit-slip-voucher.js";
test("full refund with no voucher matches line total", () => {
const lines = [{ qty_ordered: 2, qty_refunded: 2, line_total_tax_incl: 100.00 }];
assert.equal(expectedRefundAmount(lines, 0, 100.00), 100.00);
});
test("full refund with voucher prorates the discount", () => {
// order total 100, a 10 voucher was applied, so a full refund should be 90
const lines = [{ qty_ordered: 1, qty_refunded: 1, line_total_tax_incl: 100.00 }];
assert.equal(expectedRefundAmount(lines, 10.00, 100.00), 90.00);
});
test("partial refund prorates both quantity and voucher", () => {
// 2 of 4 units refunded on a 200 line, with a 20 voucher on a 200 order
const lines = [{ qty_ordered: 4, qty_refunded: 2, line_total_tax_incl: 200.00 }];
assert.equal(expectedRefundAmount(lines, 20.00, 200.00), 90.00);
});
test("zero qty ordered line contributes nothing", () => {
const lines = [{ qty_ordered: 0, qty_refunded: 0, line_total_tax_incl: 50.00 }];
assert.equal(expectedRefundAmount(lines, 0, 50.00), 0.00);
});
test("zero products total gives zero discount ratio", () => {
const lines = [{ qty_ordered: 1, qty_refunded: 1, line_total_tax_incl: 0.00 }];
assert.equal(expectedRefundAmount(lines, 5.00, 0), 0.00);
});
test("shipping refund is added after the discount", () => {
const lines = [{ qty_ordered: 1, qty_refunded: 1, line_total_tax_incl: 100.00 }];
assert.equal(expectedRefundAmount(lines, 10.00, 100.00, 5.00), 95.00);
});
test("slip matching expected is not overstated", () => {
assert.equal(isSlipOverstated(90.00, 90.00), false);
});
test("slip within tolerance is not overstated", () => {
assert.equal(isSlipOverstated(90.01, 90.00), false);
});
test("slip ignoring voucher is overstated", () => {
// slip totaled the gross line instead of the net amount after the voucher
assert.equal(isSlipOverstated(100.00, 90.00), true);
});
Case studies
The returned order that paid back the coupon twice
A fashion store ran a sitewide 10 percent off code. A customer used it, the order total dropped to the net amount, and they paid that net amount by card. When the whole order was returned, staff processed a full refund in the back office and PrestaShop generated a credit slip for the original gross total, ten percent more than the customer had ever paid.
Running the diagnostic across a month of orders surfaced every credit slip whose amount exceeded the expected refund once the voucher was prorated back in, letting accounting catch the overstatement and issue a correcting reversal before the gateway settlement closed for the month.
The partial return that ignored a stacked voucher
A buyer ordered three items with a fixed-amount voucher applied to the cart. They returned one item, and the partial refund calculation used that item's full listed price rather than its share of the discount, since the voucher had no direct link to that specific order_detail row.
The diagnostic flagged the exact order, the credit slip id, the voucher value it detected, the expected refund, and the overstatement in currency, giving the finance team what they needed to correct the credit slip by hand instead of finding the gap during a quarterly audit.
After this runs on a schedule, no voucher-ignored credit slip hides until an audit finds it. Instead you get a clear, dated report showing the order, the credit slip, the voucher value detected, the expected refund, the actual slip amount, and the exact overstatement, sorted so the biggest discrepancies surface first. No posted order_slip is ever mutated. Every correction happens through Orders, Credit Slips in the back office, by a human who can see the invoice and the bank refund at the same time.
FAQ
Why does a PrestaShop credit slip ignore the voucher discount on the order?
A voucher, or cart rule, reduces an order's total order-wide and is stored in order_cart_rules linked to id_order, not per order line. When a refund creates an order_slip, PrestaShop's refund computation and the PDF or HTML credit slip template can each total the refund from the order_detail line's gross unit_price_tax_incl instead of the net amount the customer actually paid after the voucher, so the credit slip overstates the refund by roughly the discount that should have applied to it.
Is it safe to automatically correct an overstated credit slip?
No. An order_slip is an accounting and legal document, often already reflected in an exported invoice, an accounting entry, or an actual bank refund, and PrestaShop's webservice has no supported endpoint to edit or delete a posted credit slip. The safe pattern is to flag every order_slip that overstated its refund for a human in accounting to review and correct manually through Orders, Credit Slips in the back office, never to silently rewrite the record.
How do I detect a credit slip that overstated a refund because of a voucher?
Pull the order's lines from order_details and any vouchers from order_cart_rules filtered by id_order, compute the expected refund by prorating each refunded line and applying the order-level discount ratio from the voucher total, then compare that to the amount already recorded on order_slip. Flag any order_slip whose amount exceeds the expected refund by more than a small rounding tolerance when the order has at least one row in order_cart_rules.
Related field notes
Citations
On the problem:
- PrestaShop/PrestaShop GitHub issue #18319: Credit slip generated when refunding a discounted cart has an incorrect amount. github.com/PrestaShop/PrestaShop/issues/18319
- PrestaShop/PrestaShop GitHub issue #19214: Credit slip total tab in the HTML template does not use the right data for display. github.com/PrestaShop/PrestaShop/issues/19214
- PrestaShop/PrestaShop GitHub issue #28284: Credit slip using all of the voucher. github.com/PrestaShop/PrestaShop/issues/28284
On the solution:
- PrestaShop Developer Documentation: Order slip webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_slip/
- PrestaShop Developer Documentation: Order cart rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_cart_rules/
- PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
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 an overstated credit slip?
If this saved you a wrong refund 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