Skip to content

Diagnostic

Order total wrong after cancelling a line item on an order with a voucher

A customer had a percent-off voucher applied at checkout, staff cancel one line from the order later on, and now the numbers on the order do not add up. The remaining product lines look right, shipping looks right, but total_paid and total_paid_tax_incl are off by an amount that traces straight back to the voucher. The discount was fixed against a cart total that no longer exists once the line is gone, and PrestaShop never re-derives it. Here is why that happens and a script that finds every order where it did.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
A sale sign on a building
Photo by Aleksi Partanen on Unsplash
The short answer

When you cancel a product from an order in Back Office > Orders, PrestaShop's order-editing logic recalculates the remaining product line totals, but it does not re-derive total_discounts from the order_cart_rules still attached to that order. A percent-of-total, fixed-amount, or free-shipping voucher was computed against the original cart, and once a line is cancelled that original total is gone, so the stored discount, and by extension total_paid and total_paid_tax_incl, go stale. Run a Python or Node.js script that sums the remaining order_details lines, adds shipping, subtracts the non-deleted order_cart_rules values, and compares that expected total against what the order actually reports. Full code, tests, and citations are below.

The problem in plain words

An order's headline numbers, total_paid and total_paid_tax_incl, are supposed to equal the sum of what the customer still owes for the remaining product lines, plus shipping, minus whatever voucher reduction still legitimately applies. That relationship holds the moment an order is placed. The trouble starts the first time someone edits the order afterward.

Cancelling a product line through OrderController or the Order class walks the remaining order_detail rows and recalculates their totals correctly. But the discount was never a percentage stored fresh every time. It was computed once, against the cart as it existed at checkout, and then written down as a fixed euro or dollar amount in order_cart_rules and rolled up into the order's total_discounts. When a line disappears, nothing goes back and asks "given what is left in this order, what should this voucher actually be worth now." The old figure just stays, or gets nudged incrementally, and it quietly stops matching reality.

Order: 3 lines + voucher discount fixed at checkout total Cancel one product line back office "cancel product" Product lines recalculated order_detail totals updated fine total_discounts left stale not re-derived from cart rules order_details: correct remaining lines sum properly total_paid: wrong voucher no longer matches cart
The remaining product lines recalculate correctly. The voucher figure does not, because nothing re-derives it from the cart rules still attached to the order.

Why it happens

The root cause sits in how PrestaShop's order-editing path treats the discount as a stored value to be nudged, not a formula to be recomputed. Documented ways it shows up:

The result is that total_paid and total_paid_tax_incl on the orders resource no longer equal the sum of the remaining order_details line totals minus the still-valid order_cart_rules reductions plus shipping and tax. See the citations at the end for the exact issues.

The key insight

This is financial data that other records may already point to. An invoice may already have been generated off the wrong figure, and an accounting export may already have picked it up. So the safe pattern is not "recalculate and overwrite every mismatched order automatically." It is "detect the mismatch, show the delta, and let a human decide whether the invoice needs a correction too," reserving the automatic write for an explicit operator override that always prints a before and after diff first.

The fix, as a flow

We do not touch orders by default. We add a job that reads each order's reported totals, sums its remaining order_details lines, sums the still-valid order_cart_rules reductions, and runs those through one pure function that computes what the total should be and compares it to what the order reports. Anything mismatched becomes a report row. Only with an explicit DRY_RUN=false override does the script perform the corrective PUT, and even then it never touches the order's state.

Read order totals GET orders/{id} Sum lines and cart rules order_details, order_cart_rules recompute_order_total lines + shipping - cart rules Delta beyond tolerance? yes no, move on Report the delta Corrective PUT only under explicit DRY_RUN=false, with a before/after diff
Detection is always safe to run. The corrective write is a separate, explicit step that a human turns on only after reviewing the diff.

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_cart_rules, plus write access to orders and order_histories only if you plan to ever run the repair. 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

Read an order's reported totals

Call GET /api/orders/{id}?output_format=JSON and keep total_paid, total_paid_tax_incl, total_paid_tax_excl, total_discounts, total_discounts_tax_incl, total_shipping, and id_cart. These are the figures the order itself claims, which the rest of the job checks against reality.

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 get_order(id_order):
    data = api_get(f"orders/{id_order}")
    return data.get("order") 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 getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`);
  return data.order || {};
}
3

Sum the remaining lines and the still-valid vouchers

Call GET /api/order_details?filter[id_order]={id}&display=full&output_format=JSON and sum total_price_tax_incl across the rows still present on the order. Call GET /api/order_cart_rules?filter[id_order]={id}&display=full&output_format=JSON and sum value across the rows where deleted is 0. Those two sums, plus total_shipping, are everything the pure function needs.

step3.py
def order_details_for(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_for(id_order):
    data = api_get("order_cart_rules", params={
        "filter[id_order]": id_order,
        "display": "full",
    })
    return data.get("order_cart_rules") or []
step3.js
async function orderDetailsFor(idOrder) {
  const data = await apiGet("order_details", {
    "filter[id_order]": idOrder,
    display: "full",
  });
  return data.order_details || [];
}

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

Decide, with one pure function

Keep the check in its own function that takes the already-fetched order_details, order_cart_rules, shipping, and the order's reported total, and returns the expected total, the delta, and whether it is mismatched. It also flags the invalid-discount shape from issue #11059: a negative cart rule value, or a tax-excluded sum larger than the tax-included one. No network call happens inside it, which is what makes it trivial to unit test against fixture combinations.

decide.py
from decimal import Decimal

def recompute_order_total(order_details, order_cart_rules, total_shipping,
                           reported_total_tax_incl, tolerance=Decimal("0.02")):
    lines_sum = sum(
        (Decimal(str(d["total_price_tax_incl"])) for d in order_details),
        Decimal("0"),
    )
    active_rules = [r for r in order_cart_rules if str(r.get("deleted", "0")) == "0"]
    cart_rules_sum = sum(
        (Decimal(str(r["value"])) for r in active_rules),
        Decimal("0"),
    )
    expected = lines_sum + Decimal(str(total_shipping)) - cart_rules_sum
    reported = Decimal(str(reported_total_tax_incl))
    delta = reported - expected

    invalid_shape = any(Decimal(str(r["value"])) < 0 for r in active_rules)
    tax_excl_sum = sum(
        (Decimal(str(r.get("value_tax_excl", r["value"]))) for r in active_rules),
        Decimal("0"),
    )
    if tax_excl_sum > cart_rules_sum:
        invalid_shape = True

    return {
        "expected_total": expected,
        "reported_total": reported,
        "delta": delta,
        "is_mismatched": abs(delta) > tolerance,
        "invalid_discount_shape": invalid_shape,
    }
decide.js
export function recomputeOrderTotal(orderDetails, orderCartRules, totalShipping,
                                     reportedTotalTaxIncl, tolerance = 0.02) {
  const linesSum = orderDetails.reduce((sum, d) => sum + Number(d.total_price_tax_incl), 0);
  const activeRules = orderCartRules.filter((r) => String(r.deleted ?? "0") === "0");
  const cartRulesSum = activeRules.reduce((sum, r) => sum + Number(r.value), 0);
  const expected = linesSum + Number(totalShipping) - cartRulesSum;
  const reported = Number(reportedTotalTaxIncl);
  const delta = reported - expected;

  let invalidShape = activeRules.some((r) => Number(r.value) < 0);
  const taxExclSum = activeRules.reduce(
    (sum, r) => sum + Number(r.value_tax_excl ?? r.value),
    0
  );
  if (taxExclSum > cartRulesSum) invalidShape = true;

  return {
    expected_total: expected,
    reported_total: reported,
    delta,
    is_mismatched: Math.abs(delta) > tolerance,
    invalid_discount_shape: invalidShape,
  };
}
5

Report every mismatched order, with the delta

When is_mismatched or invalid_discount_shape comes back true, log the order id, the delta amount, and which order_cart_rules rows fed the sum. This is the report a human reviews before deciding whether the order, and possibly an already-issued invoice, needs a correction.

report.py
def build_report_row(id_order, result, active_rule_ids):
    return {
        "id_order": id_order,
        "expected_total": round(float(result["expected_total"]), 2),
        "reported_total": round(float(result["reported_total"]), 2),
        "delta": round(float(result["delta"]), 2),
        "is_mismatched": result["is_mismatched"],
        "invalid_discount_shape": result["invalid_discount_shape"],
        "order_cart_rules_summed": active_rule_ids,
    }
report.js
export function buildReportRow(idOrder, result, activeRuleIds) {
  return {
    id_order: idOrder,
    expected_total: Math.round(result.expected_total * 100) / 100,
    reported_total: Math.round(result.reported_total * 100) / 100,
    delta: Math.round(result.delta * 100) / 100,
    is_mismatched: result.is_mismatched,
    invalid_discount_shape: result.invalid_discount_shape,
    order_cart_rules_summed: activeRuleIds,
  };
}
6

Wire it together with a dry run guard, repair only under explicit override

The loop ties every piece together: read each touched order's totals, its order_details, and its order_cart_rules, run them through recompute_order_total, and log a report row for anything flagged. By default the job only reports. Only when an operator explicitly sets DRY_RUN=false does it perform a corrective PUT /api/orders/{id} with recomputed total_discounts, total_discounts_tax_incl, total_discounts_tax_excl, and total_paid/total_paid_tax_incl/total_paid_tax_excl, always printing the before and after diff first, and it never edits the order's state, since state changes belong only to POST /api/order_histories.

Run it safe

Leave DRY_RUN=true until a human has reviewed the reported deltas, since an order's total may already be referenced by an invoice or an accounting export. When you do run the repair, it recomputes total_discounts from the sum of remaining order_cart_rules values rather than trusting the stale incremental figure, logs the order id, the delta, and which cart rule rows it summed, and never touches current_state, since order state changes go only through POST /api/order_histories with a new id_order_state.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every order you point it at, reports every mismatch with its delta, and only writes back to orders when DRY_RUN is explicitly set to false.

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_order_total_after_cancel.py
"""Detect PrestaShop orders whose total went stale after a product line was
cancelled while a voucher was attached.

Cancelling a product from an order in Back Office > Orders (OrderController /
the Order class) recalculates the remaining order_detail line totals, but it
does not re-derive total_discounts from the cart rules still attached to the
order (order_cart_rules). A cart rule computed as a percent-of-total, a fixed
amount, or free shipping was calculated once against the cart as it stood at
checkout, so once a line is cancelled that original cart total no longer
exists and the stored discount goes stale, along with total_paid and
total_paid_tax_incl. Tracked upstream across PrestaShop/PrestaShop issues
#17347, #23358, #23038, #28134, with the invalid-discount shape (negative or
tax_excl greater than tax_incl) tracked separately as issue #11059.

This script defaults to detect and report only, since an order's total may
already be referenced by an invoice or an accounting export. The corrective
PUT to orders only runs under an explicit DRY_RUN=false override, always
prints a before/after diff, and never touches current_state (order state
changes belong only to POST /api/order_histories).

Run on a schedule for orders touched since the last run. Safe to run again
and again in report mode.
"""
import os
import logging
from decimal import Decimal

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_order_total_after_cancel")

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 = [o.strip() for o in os.environ.get("ORDER_IDS", "").split(",") if o.strip()]
AUTH = (PRESTASHOP_WS_KEY, "")

TOLERANCE = Decimal("0.02")


def recompute_order_total(order_details, order_cart_rules, total_shipping,
                           reported_total_tax_incl, tolerance=TOLERANCE):
    """Pure decision function, no I/O.

    order_details is the list of order_details rows still present on the
    order (already fetched by the caller). order_cart_rules is the list of
    order_cart_rules rows for the order (already fetched). total_shipping and
    reported_total_tax_incl are plain values already read from the order.
    Returns the expected total, the delta against what the order reports,
    whether that delta exceeds tolerance, and whether the cart rule values
    have the invalid shape from issue #11059 (negative, or tax_excl sum
    greater than the tax_incl sum).
    """
    lines_sum = sum(
        (Decimal(str(d["total_price_tax_incl"])) for d in order_details),
        Decimal("0"),
    )
    active_rules = [r for r in order_cart_rules if str(r.get("deleted", "0")) == "0"]
    cart_rules_sum = sum(
        (Decimal(str(r["value"])) for r in active_rules),
        Decimal("0"),
    )
    expected = lines_sum + Decimal(str(total_shipping)) - cart_rules_sum
    reported = Decimal(str(reported_total_tax_incl))
    delta = reported - expected

    invalid_shape = any(Decimal(str(r["value"])) < 0 for r in active_rules)
    tax_excl_sum = sum(
        (Decimal(str(r.get("value_tax_excl", r["value"]))) for r in active_rules),
        Decimal("0"),
    )
    if tax_excl_sum > cart_rules_sum:
        invalid_shape = True

    return {
        "expected_total": expected,
        "reported_total": reported,
        "delta": delta,
        "is_mismatched": abs(delta) > tolerance,
        "invalid_discount_shape": invalid_shape,
    }


def build_report_row(id_order, result, active_rule_ids):
    return {
        "id_order": id_order,
        "expected_total": round(float(result["expected_total"]), 2),
        "reported_total": round(float(result["reported_total"]), 2),
        "delta": round(float(result["delta"]), 2),
        "is_mismatched": result["is_mismatched"],
        "invalid_discount_shape": result["invalid_discount_shape"],
        "order_cart_rules_summed": active_rule_ids,
    }


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 get_order(id_order):
    data = api_get(f"orders/{id_order}")
    return data.get("order") or {}


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


def apply_correction(id_order, order, result, active_rules):
    """Only called when DRY_RUN is explicitly false. Sends the full order
    body back with corrected discount and paid totals. Never touches
    current_state; state changes go only through POST /api/order_histories.
    """
    corrected = dict(order)
    cart_rules_sum = sum(Decimal(str(r["value"])) for r in active_rules) if active_rules else Decimal("0")
    tax_excl_sum = sum(
        Decimal(str(r.get("value_tax_excl", r["value"]))) for r in active_rules
    ) if active_rules else Decimal("0")
    corrected["total_discounts"] = str(cart_rules_sum)
    corrected["total_discounts_tax_incl"] = str(cart_rules_sum)
    corrected["total_discounts_tax_excl"] = str(tax_excl_sum)
    corrected["total_paid"] = str(result["expected_total"])
    corrected["total_paid_tax_incl"] = str(result["expected_total"])
    corrected["total_paid_tax_excl"] = str(result["expected_total"] - (cart_rules_sum - tax_excl_sum))
    corrected.pop("current_state", None)

    log.warning("BEFORE: total_paid_tax_incl=%s total_discounts=%s",
                order.get("total_paid_tax_incl"), order.get("total_discounts"))
    log.warning("AFTER:  total_paid_tax_incl=%s total_discounts=%s",
                corrected["total_paid_tax_incl"], corrected["total_discounts"])

    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{id_order}",
        params={"output_format": "JSON"},
        json={"order": corrected},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    if not ORDER_IDS:
        log.error("Set ORDER_IDS to a comma separated list of order ids to check.")
        return

    flagged = 0
    for id_order in ORDER_IDS:
        order = get_order(id_order)
        if not order:
            log.warning("Order %s not found, skipping.", id_order)
            continue
        details = order_details_for(id_order)
        cart_rules = order_cart_rules_for(id_order)
        active_rules = [r for r in cart_rules if str(r.get("deleted", "0")) == "0"]

        result = recompute_order_total(
            details, cart_rules, order.get("total_shipping", "0"),
            order.get("total_paid_tax_incl", "0"),
        )
        if not (result["is_mismatched"] or result["invalid_discount_shape"]):
            continue

        row = build_report_row(id_order, result, [r.get("id") for r in active_rules])
        flagged += 1
        log.warning(
            "Order %s total mismatch. expected=%.2f reported=%.2f delta=%.2f "
            "invalid_discount_shape=%s cart_rules=%s",
            row["id_order"], row["expected_total"], row["reported_total"],
            row["delta"], row["invalid_discount_shape"], row["order_cart_rules_summed"],
        )

        if not DRY_RUN:
            apply_correction(id_order, order, result, active_rules)
            log.info("Order %s corrected via PUT /api/orders/%s.", id_order, id_order)

    log.info(
        "Done. %d order(s) flagged. DRY_RUN=%s (repair only runs when explicitly false).",
        flagged, DRY_RUN,
    )


if __name__ == "__main__":
    run()
check-order-total-after-cancel.js
/**
 * Detect PrestaShop orders whose total went stale after a product line was
 * cancelled while a voucher was attached.
 *
 * Cancelling a product from an order in Back Office > Orders (OrderController /
 * the Order class) recalculates the remaining order_detail line totals, but it
 * does not re-derive total_discounts from the cart rules still attached to the
 * order (order_cart_rules). A cart rule computed as a percent-of-total, a fixed
 * amount, or free shipping was calculated once against the cart as it stood at
 * checkout, so once a line is cancelled that original cart total no longer
 * exists and the stored discount goes stale, along with total_paid and
 * total_paid_tax_incl. Tracked upstream across PrestaShop/PrestaShop issues
 * #17347, #23358, #23038, #28134, with the invalid-discount shape (negative or
 * tax_excl greater than tax_incl) tracked separately as issue #11059.
 *
 * This script defaults to detect and report only, since an order's total may
 * already be referenced by an invoice or an accounting export. The corrective
 * PUT to orders only runs under an explicit DRY_RUN=false override, always
 * prints a before/after diff, and never touches current_state (order state
 * changes belong only to POST /api/order_histories).
 *
 * Guide: https://www.allanninal.dev/prestashop/order-total-wrong-after-line-cancel-with-voucher/
 */
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 || "").split(",").map((s) => s.trim()).filter(Boolean);
const TOLERANCE = 0.02;

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

/**
 * Pure decision function, no I/O.
 *
 * orderDetails is the array of order_details rows still present on the order
 * (already fetched by the caller). orderCartRules is the array of
 * order_cart_rules rows for the order (already fetched). totalShipping and
 * reportedTotalTaxIncl are plain values already read from the order. Returns
 * the expected total, the delta against what the order reports, whether that
 * delta exceeds tolerance, and whether the cart rule values have the invalid
 * shape from issue #11059 (negative, or tax_excl sum greater than the
 * tax_incl sum).
 */
export function recomputeOrderTotal(orderDetails, orderCartRules, totalShipping,
                                     reportedTotalTaxIncl, tolerance = TOLERANCE) {
  const linesSum = orderDetails.reduce((sum, d) => sum + Number(d.total_price_tax_incl), 0);
  const activeRules = orderCartRules.filter((r) => String(r.deleted ?? "0") === "0");
  const cartRulesSum = activeRules.reduce((sum, r) => sum + Number(r.value), 0);
  const expected = linesSum + Number(totalShipping) - cartRulesSum;
  const reported = Number(reportedTotalTaxIncl);
  const delta = reported - expected;

  let invalidShape = activeRules.some((r) => Number(r.value) < 0);
  const taxExclSum = activeRules.reduce(
    (sum, r) => sum + Number(r.value_tax_excl ?? r.value),
    0
  );
  if (taxExclSum > cartRulesSum) invalidShape = true;

  return {
    expected_total: expected,
    reported_total: reported,
    delta,
    is_mismatched: Math.abs(delta) > tolerance,
    invalid_discount_shape: invalidShape,
  };
}

export function buildReportRow(idOrder, result, activeRuleIds) {
  return {
    id_order: idOrder,
    expected_total: Math.round(result.expected_total * 100) / 100,
    reported_total: Math.round(result.reported_total * 100) / 100,
    delta: Math.round(result.delta * 100) / 100,
    is_mismatched: result.is_mismatched,
    invalid_discount_shape: result.invalid_discount_shape,
    order_cart_rules_summed: activeRuleIds,
  };
}

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 getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`);
  return data.order || {};
}

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

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

/**
 * Only called when DRY_RUN is explicitly false. Sends the full order body
 * back with corrected discount and paid totals. Never touches current_state;
 * state changes go only through POST /api/order_histories.
 */
async function applyCorrection(idOrder, order, result, activeRules) {
  const cartRulesSum = activeRules.reduce((sum, r) => sum + Number(r.value), 0);
  const taxExclSum = activeRules.reduce((sum, r) => sum + Number(r.value_tax_excl ?? r.value), 0);
  const corrected = { ...order };
  corrected.total_discounts = String(cartRulesSum);
  corrected.total_discounts_tax_incl = String(cartRulesSum);
  corrected.total_discounts_tax_excl = String(taxExclSum);
  corrected.total_paid = String(result.expected_total);
  corrected.total_paid_tax_incl = String(result.expected_total);
  corrected.total_paid_tax_excl = String(result.expected_total - (cartRulesSum - taxExclSum));
  delete corrected.current_state;

  console.warn(`BEFORE: total_paid_tax_incl=${order.total_paid_tax_incl} total_discounts=${order.total_discounts}`);
  console.warn(`AFTER:  total_paid_tax_incl=${corrected.total_paid_tax_incl} total_discounts=${corrected.total_discounts}`);

  const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order: corrected }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
  return res.json();
}

export async function run() {
  if (!ORDER_IDS.length) {
    console.error("Set ORDER_IDS to a comma separated list of order ids to check.");
    return;
  }

  let flagged = 0;
  for (const idOrder of ORDER_IDS) {
    const order = await getOrder(idOrder);
    if (!order || !Object.keys(order).length) {
      console.warn(`Order ${idOrder} not found, skipping.`);
      continue;
    }
    const details = await orderDetailsFor(idOrder);
    const cartRules = await orderCartRulesFor(idOrder);
    const activeRules = cartRules.filter((r) => String(r.deleted ?? "0") === "0");

    const result = recomputeOrderTotal(
      details, cartRules, order.total_shipping || "0", order.total_paid_tax_incl || "0"
    );
    if (!(result.is_mismatched || result.invalid_discount_shape)) continue;

    const row = buildReportRow(idOrder, result, activeRules.map((r) => r.id));
    flagged++;
    console.warn(
      `Order ${row.id_order} total mismatch. expected=${row.expected_total.toFixed(2)} ` +
        `reported=${row.reported_total.toFixed(2)} delta=${row.delta.toFixed(2)} ` +
        `invalid_discount_shape=${row.invalid_discount_shape} cart_rules=${JSON.stringify(row.order_cart_rules_summed)}`
    );

    if (!DRY_RUN) {
      await applyCorrection(idOrder, order, result, activeRules);
      console.log(`Order ${idOrder} corrected via PUT /api/orders/${idOrder}.`);
    }
  }

  console.log(
    `Done. ${flagged} order(s) flagged. DRY_RUN=${DRY_RUN} (repair only runs when explicitly false).`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The recompute rule is the part most worth testing, because it decides whether a real order gets flagged, and what delta gets reported. Because we kept recompute_order_total pure, the test needs no network and no PrestaShop store. It just feeds in plain fixture values for a line removed with and without a voucher, stacked vouchers, a free-shipping voucher, and a percent-of-order voucher, and checks the answer.

test_order_total_after_cancel.py
from check_order_total_after_cancel import recompute_order_total


def line(**over):
    base = {"total_price_tax_incl": "50.00"}
    base.update(over)
    return base


def rule(**over):
    base = {"value": "10.00", "value_tax_excl": "8.33", "deleted": "0"}
    base.update(over)
    return base


def test_matches_when_totals_agree():
    lines = [line(total_price_tax_incl="90.00")]
    rules = [rule(value="10.00", value_tax_excl="8.33")]
    result = recompute_order_total(lines, rules, "5.00", "85.00")
    assert result["is_mismatched"] is False
    assert result["invalid_discount_shape"] is False


def test_mismatched_after_line_cancelled_voucher_stale():
    # One line remains (50.00) plus shipping (5.00), minus a 20.00 voucher that was
    # sized for the original two-line cart: expected is 50 + 5 - 20 = 35.00, but the
    # order still reports the pre-cancel total of 75.00 because total_paid was never
    # recalculated after the cancel.
    lines = [line(total_price_tax_incl="50.00")]
    rules = [rule(value="20.00", value_tax_excl="16.67")]
    reported_total = "75.00"  # stale: still reflects the order before the line was cancelled
    result = recompute_order_total(lines, rules, "5.00", reported_total)
    assert result["is_mismatched"] is True
    assert result["delta"] != 0


def test_no_voucher_no_mismatch():
    lines = [line(total_price_tax_incl="50.00")]
    result = recompute_order_total(lines, [], "5.00", "55.00")
    assert result["is_mismatched"] is False


def test_stacked_vouchers_summed_together():
    lines = [line(total_price_tax_incl="100.00")]
    rules = [rule(value="10.00", value_tax_excl="8.33"), rule(value="5.00", value_tax_excl="4.17")]
    result = recompute_order_total(lines, rules, "0.00", "85.00")
    assert result["is_mismatched"] is False


def test_free_shipping_voucher_zeroes_shipping_reduction():
    lines = [line(total_price_tax_incl="60.00")]
    rules = [rule(value="8.00", value_tax_excl="8.00")]  # models shipping value reduced to 0
    result = recompute_order_total(lines, rules, "8.00", "60.00")
    assert result["is_mismatched"] is False


def test_deleted_cart_rule_excluded_from_sum():
    lines = [line(total_price_tax_incl="90.00")]
    rules = [rule(value="10.00", deleted="1"), rule(value="5.00", value_tax_excl="4.17", deleted="0")]
    result = recompute_order_total(lines, rules, "0.00", "85.00")
    assert result["is_mismatched"] is False


def test_within_tolerance_not_mismatched():
    lines = [line(total_price_tax_incl="90.00")]
    rules = [rule(value="10.00", value_tax_excl="8.33")]
    result = recompute_order_total(lines, rules, "5.00", "85.01")
    assert result["is_mismatched"] is False


def test_negative_cart_rule_value_is_invalid_shape():
    lines = [line(total_price_tax_incl="90.00")]
    rules = [rule(value="-10.00", value_tax_excl="-8.33")]
    result = recompute_order_total(lines, rules, "0.00", "100.00")
    assert result["invalid_discount_shape"] is True


def test_tax_excl_greater_than_tax_incl_is_invalid_shape():
    lines = [line(total_price_tax_incl="90.00")]
    rules = [rule(value="10.00", value_tax_excl="12.00")]
    result = recompute_order_total(lines, rules, "0.00", "80.00")
    assert result["invalid_discount_shape"] is True
order-total-after-cancel.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { recomputeOrderTotal } from "./check-order-total-after-cancel.js";

const line = (over = {}) => ({ total_price_tax_incl: "50.00", ...over });
const rule = (over = {}) => ({ value: "10.00", value_tax_excl: "8.33", deleted: "0", ...over });

test("matches when totals agree", () => {
  const lines = [line({ total_price_tax_incl: "90.00" })];
  const rules = [rule({ value: "10.00", value_tax_excl: "8.33" })];
  const result = recomputeOrderTotal(lines, rules, "5.00", "85.00");
  assert.equal(result.is_mismatched, false);
  assert.equal(result.invalid_discount_shape, false);
});

test("mismatched after line cancelled, voucher stale", () => {
  // One line remains (50.00) plus shipping (5.00), minus a 20.00 voucher sized for the
  // original two-line cart: expected is 50 + 5 - 20 = 35.00, but the order still
  // reports the pre-cancel total of 75.00 because total_paid was never recalculated.
  const lines = [line({ total_price_tax_incl: "50.00" })];
  const rules = [rule({ value: "20.00", value_tax_excl: "16.67" })];
  const result = recomputeOrderTotal(lines, rules, "5.00", "75.00");
  assert.equal(result.is_mismatched, true);
  assert.notEqual(result.delta, 0);
});

test("no voucher no mismatch", () => {
  const lines = [line({ total_price_tax_incl: "50.00" })];
  const result = recomputeOrderTotal(lines, [], "5.00", "55.00");
  assert.equal(result.is_mismatched, false);
});

test("stacked vouchers summed together", () => {
  const lines = [line({ total_price_tax_incl: "100.00" })];
  const rules = [rule({ value: "10.00", value_tax_excl: "8.33" }), rule({ value: "5.00", value_tax_excl: "4.17" })];
  const result = recomputeOrderTotal(lines, rules, "0.00", "85.00");
  assert.equal(result.is_mismatched, false);
});

test("free shipping voucher zeroes shipping reduction", () => {
  const lines = [line({ total_price_tax_incl: "60.00" })];
  const rules = [rule({ value: "8.00", value_tax_excl: "8.00" })];
  const result = recomputeOrderTotal(lines, rules, "8.00", "60.00");
  assert.equal(result.is_mismatched, false);
});

test("deleted cart rule excluded from sum", () => {
  const lines = [line({ total_price_tax_incl: "90.00" })];
  const rules = [rule({ value: "10.00", deleted: "1" }), rule({ value: "5.00", value_tax_excl: "4.17", deleted: "0" })];
  const result = recomputeOrderTotal(lines, rules, "0.00", "85.00");
  assert.equal(result.is_mismatched, false);
});

test("within tolerance not mismatched", () => {
  const lines = [line({ total_price_tax_incl: "90.00" })];
  const rules = [rule({ value: "10.00", value_tax_excl: "8.33" })];
  const result = recomputeOrderTotal(lines, rules, "5.00", "85.01");
  assert.equal(result.is_mismatched, false);
});

test("negative cart rule value is invalid shape", () => {
  const lines = [line({ total_price_tax_incl: "90.00" })];
  const rules = [rule({ value: "-10.00", value_tax_excl: "-8.33" })];
  const result = recomputeOrderTotal(lines, rules, "0.00", "100.00");
  assert.equal(result.invalid_discount_shape, true);
});

test("tax_excl greater than tax_incl is invalid shape", () => {
  const lines = [line({ total_price_tax_incl: "90.00" })];
  const rules = [rule({ value: "10.00", value_tax_excl: "12.00" })];
  const result = recomputeOrderTotal(lines, rules, "0.00", "80.00");
  assert.equal(result.invalid_discount_shape, true);
});

Case studies

Percent-off voucher

The support team chasing a phantom refund gap

An outdoor gear store ran a storewide 15 percent voucher. A customer ordered three items, one turned out to be out of stock, and support cancelled that line from the order in the back office. The remaining two lines and shipping looked correct, but total_paid still reflected the 15 percent taken off the original three-item cart, not the smaller total that remained, so the order under-reported what the customer actually owed.

Running the diagnostic across recently edited orders surfaced the gap immediately, with the delta matching almost exactly the difference the stale percentage made on the cancelled line. Finance reviewed the flagged orders, corrected the ones that had not yet been invoiced, and left a note on the ones that had for manual follow-up.

Free shipping voucher

The store where cancelled orders kept underbilling shipping

A homeware shop offered free shipping above a spending threshold. When a line was cancelled and the remaining cart fell under that threshold, the order should have started owing real shipping again, but the free-shipping cart rule stayed applied at its original value, so total_paid never picked up the shipping charge the smaller order now actually needed.

The team scheduled the detection job against every order touched in the last day. It flagged the pattern within its first week of running, letting staff catch and correct the underbilled shipping before it became a habit across dozens of similar cancellations.

What good looks like

After this runs on a schedule, every order whose total went stale after a line cancel surfaces as a clear, dated report row with the exact delta, instead of a discrepancy someone stumbles on during a refund or a bank reconciliation. Nothing gets overwritten automatically. A human reviews the diff, decides whether an already-issued invoice needs a note, and only then flips on the explicit override that lets the script correct total_discounts and total_paid from the cart rules that are still actually valid.

FAQ

Why does cancelling a product break the order total when a voucher is applied?

PrestaShop recalculates the remaining product line totals when you cancel a product from an order in the back office, but it does not re-derive total_discounts from the cart rules still attached to the order. The voucher amount was computed against the original cart total, so once a line is removed that original total no longer exists, and the stored discount, and therefore total_paid and total_paid_tax_incl, go stale.

Is it safe to auto-correct total_paid on an order through the webservice?

Not by default. The order total may already be referenced by an invoice or an accounting export, so an automatic PUT can create a second discrepancy instead of fixing the first. The safe default is to detect and report the mismatch, and only perform the corrective PUT to orders under an explicit operator override with a before and after diff logged.

How do I detect that an order total went stale after a line cancel?

Sum total_price_tax_incl across the order's remaining order_details rows, add total_shipping, then subtract the value of every non-deleted row in order_cart_rules. Compare that expected total against the order's own total_paid_tax_incl. A difference beyond a two cent rounding tolerance, or a total_discounts value that is negative or where the tax-excluded figure exceeds the tax-included one, both mark the order as mismatched.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Changing order products randomly corrupts order prices and breaks editing. Issue #17347. github.com/PrestaShop/PrestaShop/issues/17347
  2. PrestaShop GitHub: Discounts (promo codes), multiple errors. Issue #23358. github.com/PrestaShop/PrestaShop/issues/23358
  3. PrestaShop GitHub: Invalid order->total_discounts after deleting voucher from back office. Issue #11059. github.com/PrestaShop/PrestaShop/issues/11059

On the solution:

  1. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/8/webservice/resources/orders/
  2. PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_details/
  3. PrestaShop Developer Documentation: Order cart rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_cart_rules/

Stuck on a tricky one?

If you have a problem in PrestaShop orders, payments, vouchers, 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 untangle your order totals?

If this saved you a confusing reconciliation or a support ticket about a wrong balance, 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