Skip to content

Diagnostic

Refund created via API does not update the order's refunded quantity

A script calls the PrestaShop webservice to issue a refund, gets back a shiny new credit slip, and everyone assumes the order is now correctly marked as partially or fully refunded. Then someone opens the order and the line still shows zero units refunded. Here is why creating a credit slip through the API never touches the order line's own refund counters, and a script that finds every order where the two disagree so it can be repaired safely.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Hands holding phones
Photo by David Dvoracek on Unsplash
The short answer

Creating a credit slip through POST /api/order_slip, with order_slip_detail rows attached, only performs a generic ObjectModel insert into the order_slip and order_slip_detail tables that back that resource's declared field map. It never runs PrestaShop's back office refund logic in OrderSlip::create() or AdminOrdersController, which is the code that actually recalculates and writes order_detail.product_quantity_refunded, the total_refunded_tax_excl and total_refunded_tax_incl totals, and the matching stock movement. So you end up with a fully populated credit slip sitting next to an order line that still reports its pre-refund quantity. Run a Python or Node.js script that sums product_quantity from every order_slip_detail row per id_order_detail, compares that expected total against the stored product_quantity_refunded, and only writes the corrected value when DRY_RUN is explicitly turned off. Full code, tests, and citations are below.

The problem in plain words

In the back office, issuing a refund is not just one database write. When a staff member opens an order and issues a standard or partial refund, PrestaShop's controller code creates the credit slip rows, but it also walks through the order's lines and updates each affected order_detail row's product_quantity_refunded, recalculates the order's refunded totals, and records a stock movement if the item is meant to go back into inventory. All of that happens together, in the same request, because the same controller code path is responsible for all of it.

The webservice resource for credit slips does not know about any of that. POST /api/order_slip is a generic REST endpoint over the order_slip object model. Send it an order id and a list of order_slip_detail rows, each with an id_order_detail, a product_quantity, and an amount_tax_excl, and it inserts exactly those rows into order_slip and order_slip_detail. Nothing more. It never calls into OrderSlip::create() or the AdminOrdersController refund flow, so order_detail.product_quantity_refunded is left exactly where it was before the API call. The credit slip is real. The order line does not know about it.

Script calls API POST /api/order_slip Generic insert only order_slip, order_slip_detail refund controller never runs Order line unchanged product_quantity_refunded stale Reports look wrong
The credit slip is created, but the webservice never runs the back office code that would update the order line's own refund counters. The two disagree until something repairs order_detail directly.

Why it happens

The PrestaShop webservice resource system is built around each object model's declared field map. A resource like order_slip exposes the columns of the order_slip table, plus its nested order_slip_detail associations, for generic create, read, update, and delete. It has no idea that in the back office, creating a credit slip is supposed to also touch a different table's row. A few common ways stores hit this:

This exact gap between the webservice and the back office refund logic is documented by PrestaShop maintainers and reported independently on the forums, both pointing at the same root cause: the object model behind order_slip was never wired to also run OrderSlip::create()'s side effects. See the citations at the end for the exact reports.

The key insight

Writing order_detail.product_quantity_refunded directly bypasses the same business logic that was already skipped when the credit slip was created through the API, including the stock movement and the order state check. So the safe pattern is not "recalculate and write the field the moment a mismatch is found." It is "compute the expected quantity from the real order_slip_detail rows, log the delta by default, and only write under an explicit DRY_RUN=false run," with anything where the order's current_state does not already look like a refund left for a human instead of auto-corrected.

The fix, as a flow

We do not touch order_detail by default. We add a job that reads every credit slip for a suspected order, sums the refunded quantity that the credit slip rows actually claim per line, compares that sum against what the order line currently reports, and only writes the corrected value when DRY_RUN is off and the order's state looks consistent with a refund having happened.

List credit slips order_slip by id_order Sum expected quantity per id_order_detail Compare against order_details stored product_quantity_refunded Delta found? yes no, move on Report for staff Only if DRY_RUN=false and state consistent: PUT order_details/{id} writes expected quantity
The job only ever reads and reports by default. A corrective write only happens when DRY_RUN is off, and any order whose state looks inconsistent with a refund is flagged for a human instead.

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, order_slip, and order_histories, 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.

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

List the credit slips for a suspected order

Call GET /api/order_slip?filter[id_order]={id_order}&display=full&output_format=JSON to get every credit slip for the order, with each one's nested order_slip_detail rows. Each detail row carries the id_order_detail it refunds against and the product_quantity that credit slip claims for that line.

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_slips_for(id_order):
    data = api_get("order_slip", params={"filter[id_order]": id_order, "display": "full"})
    return data.get("order_slips") 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 orderSlipsFor(idOrder) {
  const data = await apiGet("order_slip", { "filter[id_order]": idOrder, display: "full" });
  return data.order_slips || [];
}
3

Read the order line's stored refunded quantity

For each id_order_detail referenced by the credit slips, call GET /api/order_details/{id_order_detail}?display=full&output_format=JSON to read the current product_quantity_refunded straight from the order line. This is the value the storefront and any report will actually see, whether or not the credit slips agree with it.

step3.py
def order_detail(id_order_detail):
    data = api_get(f"order_details/{id_order_detail}", params={"display": "full"})
    return data["order_detail"]
step3.js
async function orderDetail(idOrderDetail) {
  const data = await apiGet(`order_details/${idOrderDetail}`, { display: "full" });
  return data.order_detail;
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the stored product_quantity_refunded and the list of product_quantity values from every matching order_slip_detail row, and returns the expected total, the delta, and whether the line needs repair or review. No network calls happen inside it, which is what makes it easy to test with plain integers.

decide.py
def compute_refund_delta(stored_refunded_qty, order_slip_quantities):
    expected = sum(order_slip_quantities)
    delta = expected - stored_refunded_qty
    return {
        "expected": expected,
        "stored": stored_refunded_qty,
        "delta": delta,
        "needs_repair": expected > stored_refunded_qty,
        "needs_review": expected < stored_refunded_qty,
    }
decide.js
export function computeRefundDelta(storedRefundedQty, orderSlipQuantities) {
  const expected = orderSlipQuantities.reduce((a, b) => a + b, 0);
  const delta = expected - storedRefundedQty;
  return {
    expected,
    stored: storedRefundedQty,
    delta,
    needs_repair: expected > storedRefundedQty,
    needs_review: expected < storedRefundedQty,
  };
}
5

Repair only under an explicit dry run guard

When a line needs repair, and only when DRY_RUN=false, pull the full order_detail body, set product_quantity_refunded to the expected value, and send the entire object back with PUT, since PrestaShop's webservice requires the whole resource on a write, not a partial patch. If the order's current_state does not look consistent with a refund, for example no matching entry in order_histories for a refunded or cancelled state, skip the write and flag the order for a human instead, since needs_review cases (delta negative) always go to a human, never to an automatic write.

repair.py
def apply_expected_refund(id_order_detail, expected_qty):
    full = api_get(f"order_details/{id_order_detail}")["order_detail"]
    unit_price_tax_excl = float(full.get("unit_price_tax_excl", 0) or 0)
    unit_price_tax_incl = float(full.get("unit_price_tax_incl", 0) or 0)
    full["product_quantity_refunded"] = expected_qty
    if "total_refunded_tax_excl" in full:
        full["total_refunded_tax_excl"] = f"{expected_qty * unit_price_tax_excl:.6f}"
    if "total_refunded_tax_incl" in full:
        full["total_refunded_tax_incl"] = f"{expected_qty * unit_price_tax_incl:.6f}"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/order_details/{id_order_detail}",
        params={"output_format": "JSON"},
        json={"order_detail": full},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function applyExpectedRefund(idOrderDetail, expectedQty) {
  const full = (await apiGet(`order_details/${idOrderDetail}`)).order_detail;
  const unitPriceTaxExcl = Number(full.unit_price_tax_excl || 0);
  const unitPriceTaxIncl = Number(full.unit_price_tax_incl || 0);
  full.product_quantity_refunded = expectedQty;
  if ("total_refunded_tax_excl" in full) full.total_refunded_tax_excl = (expectedQty * unitPriceTaxExcl).toFixed(6);
  if ("total_refunded_tax_incl" in full) full.total_refunded_tax_incl = (expectedQty * unitPriceTaxIncl).toFixed(6);
  const url = new URL(`${PRESTASHOP_URL}/api/order_details/${idOrderDetail}`);
  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/${idOrderDetail}`);
  return res.json();
}

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never writes an order line unless the delta is a genuine repair and the operator has switched off dry run.

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.
fix_api_refund_quantity.py
"""Find and repair PrestaShop order lines whose refunded quantity is stale
after a credit slip was created through the webservice.

POST /api/order_slip only inserts rows into order_slip and order_slip_detail. It
never runs the back office refund logic in OrderSlip::create() or
AdminOrdersController, which is what actually recalculates and writes
order_detail.product_quantity_refunded, the refund totals, and the related stock
movement. So a credit slip can exist while the order line still reports its old
refunded quantity.

This script sums product_quantity from every order_slip_detail row per
id_order_detail to get the expected refunded quantity, compares it against the
stored product_quantity_refunded, and only writes the corrected value when
DRY_RUN is explicitly false. A negative delta (stored higher than expected) is
always flagged for a human, never auto-corrected.

Run on demand for a suspected order id, or on a schedule across recent orders.
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("fix_api_refund_quantity")

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_IDS = os.environ.get("ORDER_IDS", "1,2,3")
AUTH = (PRESTASHOP_WS_KEY, "")


def compute_refund_delta(stored_refunded_qty, order_slip_quantities):
    """Pure decision logic, no I/O.

    Sums order_slip_quantities to get the expected refunded quantity, and
    compares it against stored_refunded_qty. needs_repair means the API-created
    credit slips claim more refunded units than the order line shows, the
    exact symptom this script exists to fix. needs_review means the stored
    value is already higher than the credit slips justify, which is left for
    a human rather than corrected automatically.
    """
    expected = sum(order_slip_quantities)
    delta = expected - stored_refunded_qty
    return {
        "expected": expected,
        "stored": stored_refunded_qty,
        "delta": delta,
        "needs_repair": expected > stored_refunded_qty,
        "needs_review": expected < stored_refunded_qty,
    }


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_slips_for(id_order):
    data = api_get("order_slip", params={"filter[id_order]": id_order, "display": "full"})
    return data.get("order_slips") or []


def order_detail(id_order_detail):
    data = api_get(f"order_details/{id_order_detail}", params={"display": "full"})
    return data["order_detail"]


def order_history_states(id_order):
    data = api_get("order_histories", params={"filter[id_order]": id_order, "display": "full"})
    return [h.get("id_order_state") for h in (data.get("order_histories") or [])]


def refund_quantities_by_line(order_slips):
    """Group order_slip_detail rows by id_order_detail and collect their quantities."""
    by_line = {}
    for slip in order_slips:
        details = (slip.get("associations", {}) or {}).get("order_slip_detail") or slip.get("order_slip_detail") or []
        for row in details:
            id_order_detail = row["id_order_detail"]
            by_line.setdefault(id_order_detail, []).append(int(row["product_quantity"]))
    return by_line


def apply_expected_refund(id_order_detail, expected_qty):
    full = api_get(f"order_details/{id_order_detail}")["order_detail"]
    unit_price_tax_excl = float(full.get("unit_price_tax_excl", 0) or 0)
    unit_price_tax_incl = float(full.get("unit_price_tax_incl", 0) or 0)
    full["product_quantity_refunded"] = expected_qty
    if "total_refunded_tax_excl" in full:
        full["total_refunded_tax_excl"] = f"{expected_qty * unit_price_tax_excl:.6f}"
    if "total_refunded_tax_incl" in full:
        full["total_refunded_tax_incl"] = f"{expected_qty * unit_price_tax_incl:.6f}"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/order_details/{id_order_detail}",
        params={"output_format": "JSON"},
        json={"order_detail": full},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    checked = 0
    repaired = 0
    flagged_for_review = 0
    for id_order in [s.strip() for s in ORDER_IDS.split(",") if s.strip()]:
        slips = order_slips_for(id_order)
        if not slips:
            continue
        by_line = refund_quantities_by_line(slips)
        history_states = order_history_states(id_order)
        for id_order_detail, quantities in by_line.items():
            detail = order_detail(id_order_detail)
            stored = int(detail.get("product_quantity_refunded", 0) or 0)
            result = compute_refund_delta(stored, quantities)
            checked += 1
            if result["delta"] == 0:
                continue
            if result["needs_review"]:
                flagged_for_review += 1
                log.warning(
                    "Needs human review. id_order=%s id_order_detail=%s stored=%d expected=%d delta=%d",
                    id_order, id_order_detail, result["stored"], result["expected"], result["delta"],
                )
                continue
            if not history_states:
                flagged_for_review += 1
                log.warning(
                    "Skipping repair, no order_histories rows found. id_order=%s id_order_detail=%s",
                    id_order, id_order_detail,
                )
                continue
            log.info(
                "Refund quantity stale. id_order=%s id_order_detail=%s stored=%d expected=%d %s",
                id_order, id_order_detail, result["stored"], result["expected"],
                "would repair" if DRY_RUN else "repairing",
            )
            if not DRY_RUN:
                apply_expected_refund(id_order_detail, result["expected"])
                verify = order_detail(id_order_detail)
                log.info(
                    "Verified. id_order_detail=%s product_quantity_refunded=%s",
                    id_order_detail, verify.get("product_quantity_refunded"),
                )
            repaired += 1
    log.info(
        "Done. %d line(s) checked, %d repaired, %d flagged for review. DRY_RUN=%s",
        checked, repaired, flagged_for_review, DRY_RUN,
    )


if __name__ == "__main__":
    run()
fix-api-refund-quantity.js
/**
 * Find and repair PrestaShop order lines whose refunded quantity is stale
 * after a credit slip was created through the webservice.
 *
 * POST /api/order_slip only inserts rows into order_slip and order_slip_detail. It
 * never runs the back office refund logic in OrderSlip::create() or
 * AdminOrdersController, which is what actually recalculates and writes
 * order_detail.product_quantity_refunded, the refund totals, and the related stock
 * movement. So a credit slip can exist while the order line still reports its old
 * refunded quantity.
 *
 * This script sums product_quantity from every order_slip_detail row per
 * id_order_detail to get the expected refunded quantity, compares it against the
 * stored product_quantity_refunded, and only writes the corrected value when
 * DRY_RUN is explicitly false. A negative delta (stored higher than expected) is
 * always flagged for a human, never auto-corrected.
 *
 * Guide: https://www.allanninal.dev/prestashop/api-refund-not-updating-order-line/
 */
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_IDS = process.env.ORDER_IDS || "1,2,3";

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

/**
 * Pure decision logic, no I/O.
 *
 * Sums orderSlipQuantities to get the expected refunded quantity, and compares
 * it against storedRefundedQty. needsRepair means the API-created credit slips
 * claim more refunded units than the order line shows. needsReview means the
 * stored value is already higher than the credit slips justify, which is left
 * for a human rather than corrected automatically.
 */
export function computeRefundDelta(storedRefundedQty, orderSlipQuantities) {
  const expected = orderSlipQuantities.reduce((a, b) => a + b, 0);
  const delta = expected - storedRefundedQty;
  return {
    expected,
    stored: storedRefundedQty,
    delta,
    needs_repair: expected > storedRefundedQty,
    needs_review: expected < storedRefundedQty,
  };
}

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 orderSlipsFor(idOrder) {
  const data = await apiGet("order_slip", { "filter[id_order]": idOrder, display: "full" });
  return data.order_slips || [];
}

async function orderDetail(idOrderDetail) {
  const data = await apiGet(`order_details/${idOrderDetail}`, { display: "full" });
  return data.order_detail;
}

async function orderHistoryStates(idOrder) {
  const data = await apiGet("order_histories", { "filter[id_order]": idOrder, display: "full" });
  return (data.order_histories || []).map((h) => h.id_order_state);
}

function refundQuantitiesByLine(orderSlips) {
  const byLine = new Map();
  for (const slip of orderSlips) {
    const details = (slip.associations && slip.associations.order_slip_detail) || slip.order_slip_detail || [];
    for (const row of details) {
      const idOrderDetail = row.id_order_detail;
      if (!byLine.has(idOrderDetail)) byLine.set(idOrderDetail, []);
      byLine.get(idOrderDetail).push(Number(row.product_quantity));
    }
  }
  return byLine;
}

async function applyExpectedRefund(idOrderDetail, expectedQty) {
  const full = (await apiGet(`order_details/${idOrderDetail}`)).order_detail;
  const unitPriceTaxExcl = Number(full.unit_price_tax_excl || 0);
  const unitPriceTaxIncl = Number(full.unit_price_tax_incl || 0);
  full.product_quantity_refunded = expectedQty;
  if ("total_refunded_tax_excl" in full) full.total_refunded_tax_excl = (expectedQty * unitPriceTaxExcl).toFixed(6);
  if ("total_refunded_tax_incl" in full) full.total_refunded_tax_incl = (expectedQty * unitPriceTaxIncl).toFixed(6);
  const url = new URL(`${PRESTASHOP_URL}/api/order_details/${idOrderDetail}`);
  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/${idOrderDetail}`);
  return res.json();
}

export async function run() {
  let checked = 0;
  let repaired = 0;
  let flaggedForReview = 0;
  const orderIds = ORDER_IDS.split(",").map((s) => s.trim()).filter(Boolean);
  for (const idOrder of orderIds) {
    const slips = await orderSlipsFor(idOrder);
    if (!slips.length) continue;
    const byLine = refundQuantitiesByLine(slips);
    const historyStates = await orderHistoryStates(idOrder);
    for (const [idOrderDetail, quantities] of byLine.entries()) {
      const detail = await orderDetail(idOrderDetail);
      const stored = Number(detail.product_quantity_refunded || 0);
      const result = computeRefundDelta(stored, quantities);
      checked++;
      if (result.delta === 0) continue;
      if (result.needs_review) {
        flaggedForReview++;
        console.warn(
          `Needs human review. id_order=${idOrder} id_order_detail=${idOrderDetail} ` +
            `stored=${result.stored} expected=${result.expected} delta=${result.delta}`
        );
        continue;
      }
      if (!historyStates.length) {
        flaggedForReview++;
        console.warn(`Skipping repair, no order_histories rows found. id_order=${idOrder} id_order_detail=${idOrderDetail}`);
        continue;
      }
      console.log(
        `Refund quantity stale. id_order=${idOrder} id_order_detail=${idOrderDetail} ` +
          `stored=${result.stored} expected=${result.expected} ${DRY_RUN ? "would repair" : "repairing"}`
      );
      if (!DRY_RUN) {
        await applyExpectedRefund(idOrderDetail, result.expected);
        const verify = await orderDetail(idOrderDetail);
        console.log(`Verified. id_order_detail=${idOrderDetail} product_quantity_refunded=${verify.product_quantity_refunded}`);
      }
      repaired++;
    }
  }
  console.log(`Done. ${checked} line(s) checked, ${repaired} repaired, ${flaggedForReview} flagged for review. 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 repaired automatically and which get sent to a human. Because we kept compute_refund_delta pure, the test needs no network and no PrestaShop store. It just feeds in plain integers and checks the answer.

test_refund_delta.py
from fix_api_refund_quantity import compute_refund_delta


def test_matching_quantities_need_nothing():
    result = compute_refund_delta(2, [2])
    assert result["expected"] == 2
    assert result["delta"] == 0
    assert result["needs_repair"] is False
    assert result["needs_review"] is False


def test_stale_stored_quantity_needs_repair():
    result = compute_refund_delta(0, [3])
    assert result["expected"] == 3
    assert result["delta"] == 3
    assert result["needs_repair"] is True
    assert result["needs_review"] is False


def test_multiple_credit_slips_sum_together():
    result = compute_refund_delta(1, [1, 2])
    assert result["expected"] == 3
    assert result["delta"] == 2
    assert result["needs_repair"] is True


def test_stored_higher_than_slips_needs_review():
    result = compute_refund_delta(5, [2])
    assert result["expected"] == 2
    assert result["delta"] == -3
    assert result["needs_repair"] is False
    assert result["needs_review"] is True


def test_no_credit_slips_means_zero_expected():
    result = compute_refund_delta(0, [])
    assert result["expected"] == 0
    assert result["delta"] == 0
    assert result["needs_repair"] is False
    assert result["needs_review"] is False
refund-delta.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeRefundDelta } from "./fix-api-refund-quantity.js";

test("matching quantities need nothing", () => {
  const result = computeRefundDelta(2, [2]);
  assert.equal(result.expected, 2);
  assert.equal(result.delta, 0);
  assert.equal(result.needs_repair, false);
  assert.equal(result.needs_review, false);
});

test("stale stored quantity needs repair", () => {
  const result = computeRefundDelta(0, [3]);
  assert.equal(result.expected, 3);
  assert.equal(result.delta, 3);
  assert.equal(result.needs_repair, true);
  assert.equal(result.needs_review, false);
});

test("multiple credit slips sum together", () => {
  const result = computeRefundDelta(1, [1, 2]);
  assert.equal(result.expected, 3);
  assert.equal(result.delta, 2);
  assert.equal(result.needs_repair, true);
});

test("stored higher than slips needs review", () => {
  const result = computeRefundDelta(5, [2]);
  assert.equal(result.expected, 2);
  assert.equal(result.delta, -3);
  assert.equal(result.needs_repair, false);
  assert.equal(result.needs_review, true);
});

test("no credit slips means zero expected", () => {
  const result = computeRefundDelta(0, []);
  assert.equal(result.expected, 0);
  assert.equal(result.delta, 0);
  assert.equal(result.needs_repair, false);
  assert.equal(result.needs_review, false);
});

Case studies

Bulk refund migration

The store that refunded a season's returns through the API

A homeware store ran a return campaign at the end of a season and wrote a script that posted a credit slip through POST /api/order_slip for every approved return, since it was faster than clicking through the back office one order at a time. The credit slips all looked correct, PDFs and all, but the order lines never moved off their original refunded quantity.

Weeks later, a customer support agent pulled up one of those orders and saw zero units refunded on a line that clearly had a credit slip attached. Running the diagnostic across the batch of orders from the campaign surfaced every stale line at once, and the guarded repair fixed the ones where the order state already reflected the refund, while flagging a handful with no matching order history for a human to check first.

Marketplace integration

The connector that issued partial refunds for damaged items

A marketplace connector automatically issued a partial refund through the API whenever a buyer reported a damaged item on one line of a multi-item order. The credit slip amounts were always correct, but because the connector only ever called the webservice, order_detail.product_quantity_refunded silently stayed at zero on every line it touched.

The store's finance team noticed their refund totals reconciled against the payment processor, but individual order pages never reflected any refund, making it look like nothing had ever been credited back. The script's dry run report gave them an exact list of the affected lines and expected quantities before they turned off DRY_RUN and let it write the correct numbers back.

What good looks like

After this runs against a suspected order or on a schedule, an order's credit slips and its line-level refunded quantity agree with each other. Anything a repair could get wrong, a negative delta or an order whose state does not yet look like a refund, is reported for a human instead of silently corrected. The only field this script ever writes is product_quantity_refunded and its matching totals, and only under an explicit DRY_RUN=false run.

FAQ

Why does a credit slip created through the PrestaShop API not update product_quantity_refunded?

POST /api/order_slip only performs a generic insert into the order_slip and order_slip_detail tables that back the resource's declared field map. It does not run the back office refund logic in OrderSlip::create or AdminOrdersController, which is the code that actually recalculates and writes order_detail.product_quantity_refunded, the refund totals, and the related stock movement. Because the webservice bypasses that code path, the credit slip exists but the order line still reports its old refunded quantity.

Is it safe to write product_quantity_refunded directly to fix this?

Only under a guarded, reviewed repair. Writing the field directly skips the same business logic that was already skipped when the credit slip was created through the API, including the stock movement and the order state check, so a blind write can leave other parts of the order still inconsistent. The safe pattern is to compute the expected quantity from the real order_slip_detail rows, log the delta by default, and only write when DRY_RUN is explicitly turned off, flagging any order whose current_state does not already reflect a refund for a human to review instead.

How do I detect orders where an API-created refund did not update the order line?

Pull GET order_slip filtered by id_order and sum product_quantity per id_order_detail across all order_slip_detail rows to get the expected refunded quantity. Then pull GET order_details/{id_order_detail} and compare its stored product_quantity_refunded against that expected sum. Any order_detail where the stored value is lower than the expected sum has a stale, unapplied refund quantity.

Related field notes

Citations

On the problem:

  1. 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
  2. PrestaShop Forums: product_quantity_refunded not updated for refunded orders. prestashop.com/forums/topic/1031643-product_quantity_refunded-not-updated-for-refunded-orders
  3. PrestaShop/PrestaShop GitHub issue #21177: Standard Refund PrestaShop Back Office, quantity and amount refunded does not get updated in the UI. github.com/PrestaShop/PrestaShop/issues/21177

On the solution:

  1. PrestaShop Developer Documentation: Order slip webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_slip/
  2. PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
  3. PrestaShop Developer Documentation: Refunds in the back office order view. devdocs.prestashop-project.org/8/development/page-reference/back-office/order/view-order/refunds/

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 a stale refund?

If this saved you a wrong revenue report or a customer wondering where their refund went, 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