Skip to content

Diagnostic

Order total does not match the sum of its order line totals

Someone reconciles an order against its invoice and the numbers do not add up. The order header says one total, and when you add up every line on the order, plus shipping, minus discounts, you get a different number. Nothing on the order screen calls this out. Here is why PrestaShop lets its cached order total drift away from what the lines actually add up to, and a script that finds every order where the two disagree so a human can decide what to do about it.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A calculator on a table
Photo by Behnam Norouzi on Unsplash
The short answer

PrestaShop computes and caches an order's totals, total_paid, total_paid_tax_incl, and total_paid_tax_excl, on the orders table, separately from each line's own total on the order_detail table, total_price_tax_incl and total_price_tax_excl. These two sources of truth are only reconciled by specific code paths, cart validation and the OrderAmountUpdater run during a back office edit. When a rounding setting interacts with per-unit price rounding, when a module writes directly to the order totals, or when someone edits an order's lines, discounts, or a partial refund in the back office, the two can drift out of sync. Run a Python or Node.js script that pulls an order's header and its order_detail rows with GET /api/orders/{id_order} and GET /api/order_details, sums the line totals plus shipping minus discounts, and flags any order where that computed total disagrees with total_paid_tax_incl by more than a small rounding tolerance. Full code, tests, and citations are below.

The problem in plain words

Every PrestaShop order carries its own totals right on the orders table: total_paid, total_paid_tax_incl, and total_paid_tax_excl. Every product on that order also carries its own total, on a separate order_detail row, as total_price_tax_incl and total_price_tax_excl. In the ordinary case, adding up every line's total, then adding shipping and subtracting any discounts, gets you back to the order's cached total.

The trouble is that PrestaShop treats those two totals as separate sources of truth. The order-level total is computed once and cached, and it is only recomputed by a handful of specific code paths, cart validation when the order is first created, and the OrderAmountUpdater that runs when someone edits the order in the back office. Anything that touches one side without going through those paths, a rounding setting interacting with per-unit price rounding, a payment module writing straight to the order's total fields, or an edit that adds, removes, or changes a product line, a discount, or a partial refund, can leave the cached order total quietly out of step with what the lines actually add up to. PrestaShop's own issue tracker has repeated, version-spanning reports of exactly this gap.

order_detail rows each line's own total edit, rounding, refund not always reconciled orders.total_paid cached separately module write, BO edit Totals disagree nothing flags it Invoice is wrong
Line totals and the order's own cached total live in two places. They only get reconciled by specific code paths, so an edit, a rounding setting, or a module write on one side can leave the other stale.

Why it happens

PrestaShop's order total is a cached value, not a live sum. A few common ways the cache drifts away from the lines it is supposed to represent:

Any of these leaves an order whose header total and whose line-by-line total quietly disagree, which is a source of wrong invoices, failed reconciliation against a payment gateway, and confused customers when the numbers on their receipt do not add up. See the citations at the end for the exact reports and docs.

The key insight

A mismatch between the order's total and the sum of its lines is not safe to auto-fix. The drift can come from a bad module write, a bad manual back office edit, a legitimate rounding-mode difference, or a partial refund not yet reflected in the totals, and blindly overwriting total_paid to match the line sum can hide the real defect, for example a wrong tax rate on one line, or corrupt a legally required invoice amount. So the safe pattern is not "recompute every mismatched order automatically." It is "flag every mismatch for a human," and only attempt a corrective write under an explicit operator override, after re-checking that no order state change or refund is currently in flight.

The fix, as a flow

We do not touch total_paid by default. We add a job that pulls each order's header and its order_detail rows, computes what the total should be from the lines plus shipping minus discounts, and reports anything that disagrees past a small tolerance. A confirmed repair only happens under an explicit DRY_RUN=false override, and only after checking the order's history for a pending state change or refund.

Pull order + lines orders, order_details Sum line totals + shipping - discounts Compare to total_paid_tax_incl diff = order - computed Mismatched? yes no, move on Report for staff Only if DRY_RUN=false and no refund in flight: PUT orders/{id_order} with recomputed totals
The job only ever reads and reports by default. A corrective write only happens when DRY_RUN is off and the operator has re-verified no pending order_state change or refund is in flight.

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_histories, plus write access to orders 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

Pull the order header

Call GET /api/orders/{id_order}?output_format=JSON to read total_paid_tax_incl, total_paid_tax_excl, total_paid_real, total_shipping, and total_discounts. For a batch run over many orders, use the filtered list form instead, GET /api/orders?filter[id]=[1,50]&display=full&output_format=JSON.

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

Pull the order lines and sum them

Call GET /api/order_details?filter[id_order]={id_order}&display=full&output_format=JSON to get every line on the order, and sum total_price_tax_incl across all rows. Keep the tax-excluded sum too, since some stores reconcile against total_paid_tax_excl instead.

step3.py
def order_detail_line_totals(id_order):
    data = api_get("order_details", params={
        "filter[id_order]": id_order,
        "display": "full",
    })
    details = data.get("order_details") or []
    return [float(d["total_price_tax_incl"]) for d in details]
step3.js
async function orderDetailLineTotals(idOrder) {
  const data = await apiGet("order_details", {
    "filter[id_order]": idOrder,
    display: "full",
  });
  const details = data.order_details || [];
  return details.map((d) => Number(d.total_price_tax_incl));
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the order's total_paid_tax_incl, the list of line totals, total_shipping, and total_discounts, and returns a plain result, nothing else. It sums the lines, adds shipping, subtracts discounts, and compares that computed total against the order's stored total with a small rounding tolerance. No network calls happen inside it, which is what makes it easy to test on its own.

decide.py
def diff_order_total(order_total_paid_tax_incl, line_totals_tax_incl, total_shipping, total_discounts, epsilon=0.02):
    computed_total = round(sum(line_totals_tax_incl) + total_shipping - total_discounts, 2)
    diff = round(order_total_paid_tax_incl - computed_total, 2)
    return {
        "computed_total": computed_total,
        "diff": diff,
        "mismatched": abs(diff) > epsilon,
    }
decide.js
export function diffOrderTotal(orderTotalPaidTaxIncl, lineTotalsTaxIncl, totalShipping, totalDiscounts, epsilon = 0.02) {
  const lineSum = lineTotalsTaxIncl.reduce((a, b) => a + b, 0);
  const computedTotal = Math.round((lineSum + totalShipping - totalDiscounts) * 100) / 100;
  const diff = Math.round((orderTotalPaidTaxIncl - computedTotal) * 100) / 100;
  return {
    computed_total: computedTotal,
    diff,
    mismatched: Math.abs(diff) > epsilon,
  };
}
5

Report by default, repair only on explicit confirmation

When an order is mismatched, the script always logs a report row with id_order, reference, current_state, the stored total, the computed total, and the difference. It never overwrites total_paid_tax_incl by default. Only when DRY_RUN=false does it re-verify with GET /api/order_histories?filter[id_order]={id_order} that no order state change or refund is currently in flight, then attempt a corrective PUT /api/orders/{id_order} recomputing the totals from the line sum plus shipping minus discounts.

repair.py
def has_pending_history(id_order):
    data = api_get("order_histories", params={"filter[id_order]": id_order, "display": "full"})
    return len(data.get("order_histories") or []) == 0

def apply_recomputed_total(order, computed_total):
    order["total_paid"] = f"{computed_total:.6f}"
    order["total_paid_tax_incl"] = f"{computed_total:.6f}"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{order['id']}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function hasPendingHistory(idOrder) {
  const data = await apiGet("order_histories", { "filter[id_order]": idOrder, display: "full" });
  return (data.order_histories || []).length === 0;
}

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

Wire it together with a dry run guard

The loop ties every piece together: pull each order, pull its lines, compare with diff_order_total, and log a report row for anything mismatched. DRY_RUN defaults to true, so the script only ever reports unless you flip it off, in which case it re-checks for a pending history entry before attempting the corrective write. Run it on a schedule that matches how often orders get edited in the back office, for example once a day.

Run it safe

Always start with DRY_RUN=true. A mismatch can be a legitimate rounding difference, a partial refund not yet reflected, or a real defect such as a wrong tax rate on a line, so treat every report row as a lead for staff to check in the order-edit screen, using the "Refresh totals" or recalculate option there, not a queue to auto-repair. Only run the corrective write path when you have a specific reason to trust the recomputed number over the stored one.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks orders, pulls their lines, compares totals, reports every mismatch, respects the dry run flag, and only ever writes a corrective total when explicitly told to and after checking for a pending history entry.

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.py
"""Detect PrestaShop orders whose total_paid does not match the sum of their lines.

PrestaShop computes and caches an order's total_paid, total_paid_tax_incl, and
total_paid_tax_excl on the orders table separately from each line's own total on the
order_detail table (total_price_tax_incl and total_price_tax_excl). The two sources of
truth are only reconciled by specific code paths, cart validation and the
OrderAmountUpdater run during a back office edit. A rounding-mode setting, a module
writing straight to the order totals, or a back office edit to a product line, a
discount, or a partial refund can all leave the cached order total out of step with
what the lines actually add up to.

This script flags affected orders by default. It never overwrites total_paid,
total_paid_tax_incl, or total_paid_tax_excl unless DRY_RUN is explicitly false, and even
then it re-checks that no pending order_history entry (representing an in-flight state
change or refund) exists before attempting the corrective write.

Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDER_ID_RANGE = os.environ.get("ORDER_ID_RANGE", "1,50")
AUTH = (PRESTASHOP_WS_KEY, "")

EPSILON = 0.02


def diff_order_total(order_total_paid_tax_incl, line_totals_tax_incl, total_shipping, total_discounts, epsilon=EPSILON):
    """Pure decision logic, no I/O.

    Sums line_totals_tax_incl, adds shipping, subtracts discounts, compares against
    order_total_paid_tax_incl, and returns a dict describing the computed total, the
    difference, and whether that difference is past the tolerance. Caller supplies all
    values already fetched from the API.
    """
    computed_total = round(sum(line_totals_tax_incl) + total_shipping - total_discounts, 2)
    diff = round(order_total_paid_tax_incl - computed_total, 2)
    return {
        "computed_total": computed_total,
        "diff": diff,
        "mismatched": abs(diff) > epsilon,
    }


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


def orders_in_range(id_range):
    data = api_get("orders", params={"filter[id]": f"[{id_range}]", "display": "full"})
    return data.get("orders") or []


def order_detail_line_totals(id_order):
    data = api_get("order_details", params={"filter[id_order]": id_order, "display": "full"})
    details = data.get("order_details") or []
    return [float(d["total_price_tax_incl"]) for d in details]


def has_pending_history(id_order):
    data = api_get("order_histories", params={"filter[id_order]": id_order, "display": "full"})
    return len(data.get("order_histories") or []) == 0


def apply_recomputed_total(order, computed_total):
    order["total_paid"] = f"{computed_total:.6f}"
    order["total_paid_tax_incl"] = f"{computed_total:.6f}"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{order['id']}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    flagged = 0
    repaired = 0
    for order in orders_in_range(ORDER_ID_RANGE):
        id_order = order["id"]
        total_paid_tax_incl = float(order["total_paid_tax_incl"])
        total_shipping = float(order.get("total_shipping") or 0)
        total_discounts = float(order.get("total_discounts") or 0)
        line_totals = order_detail_line_totals(id_order)
        result = diff_order_total(total_paid_tax_incl, line_totals, total_shipping, total_discounts)
        if not result["mismatched"]:
            continue
        flagged += 1
        log.warning(
            "Order total mismatch. id_order=%s reference=%s current_state=%s "
            "stored_total=%.2f computed_total=%.2f diff=%.2f",
            id_order, order.get("reference"), order.get("current_state"),
            total_paid_tax_incl, result["computed_total"], result["diff"],
        )
        if not DRY_RUN:
            if has_pending_history(id_order):
                log.warning("Skipping repair for id_order=%s: no order_histories rows found.", id_order)
                continue
            apply_recomputed_total(order, result["computed_total"])
            repaired += 1
            log.info("Applied recomputed total=%.2f for id_order=%s.", result["computed_total"], id_order)
    log.info("Done. %d order(s) flagged for review, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)


if __name__ == "__main__":
    run()
check-order-total.js
/**
 * Detect PrestaShop orders whose total_paid does not match the sum of their lines.
 *
 * PrestaShop computes and caches an order's total_paid, total_paid_tax_incl, and
 * total_paid_tax_excl on the orders table separately from each line's own total on the
 * order_detail table (total_price_tax_incl and total_price_tax_excl). The two sources of
 * truth are only reconciled by specific code paths, cart validation and the
 * OrderAmountUpdater run during a back office edit. A rounding-mode setting, a module
 * writing straight to the order totals, or a back office edit to a product line, a
 * discount, or a partial refund can all leave the cached order total out of step with
 * what the lines actually add up to.
 *
 * This script flags affected orders by default. It never overwrites total_paid,
 * total_paid_tax_incl, or total_paid_tax_excl unless DRY_RUN is explicitly false, and
 * even then it re-checks that no pending order_history entry (representing an in-flight
 * state change or refund) exists before attempting the corrective write.
 *
 * Guide: https://www.allanninal.dev/prestashop/order-total-mismatch-with-line-sum/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ORDER_ID_RANGE = process.env.ORDER_ID_RANGE || "1,50";

const EPSILON = 0.02;

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

/**
 * Pure decision logic, no I/O.
 *
 * Sums lineTotalsTaxIncl, adds shipping, subtracts discounts, compares against
 * orderTotalPaidTaxIncl, and returns an object describing the computed total, the
 * difference, and whether that difference is past the tolerance. Caller supplies all
 * values already fetched from the API.
 */
export function diffOrderTotal(orderTotalPaidTaxIncl, lineTotalsTaxIncl, totalShipping, totalDiscounts, epsilon = EPSILON) {
  const lineSum = lineTotalsTaxIncl.reduce((a, b) => a + b, 0);
  const computedTotal = Math.round((lineSum + totalShipping - totalDiscounts) * 100) / 100;
  const diff = Math.round((orderTotalPaidTaxIncl - computedTotal) * 100) / 100;
  return {
    computed_total: computedTotal,
    diff,
    mismatched: Math.abs(diff) > epsilon,
  };
}

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

async function ordersInRange(idRange) {
  const data = await apiGet("orders", { "filter[id]": `[${idRange}]`, display: "full" });
  return data.orders || [];
}

async function orderDetailLineTotals(idOrder) {
  const data = await apiGet("order_details", { "filter[id_order]": idOrder, display: "full" });
  const details = data.order_details || [];
  return details.map((d) => Number(d.total_price_tax_incl));
}

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

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

export async function run() {
  let flagged = 0;
  let repaired = 0;
  for (const order of await ordersInRange(ORDER_ID_RANGE)) {
    const idOrder = order.id;
    const totalPaidTaxIncl = Number(order.total_paid_tax_incl);
    const totalShipping = Number(order.total_shipping || 0);
    const totalDiscounts = Number(order.total_discounts || 0);
    const lineTotals = await orderDetailLineTotals(idOrder);
    const result = diffOrderTotal(totalPaidTaxIncl, lineTotals, totalShipping, totalDiscounts);
    if (!result.mismatched) continue;
    flagged++;
    console.warn(
      `Order total mismatch. id_order=${idOrder} reference=${order.reference} ` +
        `current_state=${order.current_state} stored_total=${totalPaidTaxIncl.toFixed(2)} ` +
        `computed_total=${result.computed_total.toFixed(2)} diff=${result.diff.toFixed(2)}`
    );
    if (!DRY_RUN) {
      if (await hasPendingHistory(idOrder)) {
        console.warn(`Skipping repair for id_order=${idOrder}: no order_histories rows found.`);
        continue;
      }
      await applyRecomputedTotal(order, result.computed_total);
      repaired++;
      console.log(`Applied recomputed total=${result.computed_total.toFixed(2)} for id_order=${idOrder}.`);
    }
  }
  console.log(`Done. ${flagged} order(s) flagged for review, ${repaired} repaired. DRY_RUN=${DRY_RUN}`);
}

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

Add a test

The decision function is the part most worth testing, because it decides which orders get flagged for review. Because we kept diff_order_total pure, the test needs no network and no PrestaShop store. It just feeds in plain numbers and checks the answer.

test_order_total_diff.py
from check_order_total import diff_order_total


def test_matching_totals_are_consistent():
    result = diff_order_total(110.00, [50.00, 50.00], 10.00, 0.00)
    assert result["computed_total"] == 110.00
    assert result["diff"] == 0.00
    assert result["mismatched"] is False


def test_tiny_rounding_difference_is_consistent():
    result = diff_order_total(110.01, [50.00, 50.00], 10.00, 0.00)
    assert result["mismatched"] is False


def test_missing_line_is_flagged():
    result = diff_order_total(110.00, [50.00], 10.00, 0.00)
    assert result["computed_total"] == 60.00
    assert result["diff"] == 50.00
    assert result["mismatched"] is True


def test_discount_reduces_computed_total():
    result = diff_order_total(90.00, [50.00, 50.00], 10.00, 20.00)
    assert result["computed_total"] == 90.00
    assert result["mismatched"] is False


def test_stale_total_after_edit_is_flagged():
    result = diff_order_total(150.00, [50.00, 50.00], 10.00, 0.00)
    assert result["computed_total"] == 110.00
    assert result["diff"] == 40.00
    assert result["mismatched"] is True


def test_custom_epsilon_is_respected():
    result = diff_order_total(110.03, [50.00, 50.00], 10.00, 0.00, epsilon=0.05)
    assert result["mismatched"] is False
order-total-diff.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffOrderTotal } from "./check-order-total.js";

test("matching totals are consistent", () => {
  const result = diffOrderTotal(110.00, [50.00, 50.00], 10.00, 0.00);
  assert.equal(result.computed_total, 110.00);
  assert.equal(result.diff, 0.00);
  assert.equal(result.mismatched, false);
});

test("tiny rounding difference is consistent", () => {
  const result = diffOrderTotal(110.01, [50.00, 50.00], 10.00, 0.00);
  assert.equal(result.mismatched, false);
});

test("missing line is flagged", () => {
  const result = diffOrderTotal(110.00, [50.00], 10.00, 0.00);
  assert.equal(result.computed_total, 60.00);
  assert.equal(result.diff, 50.00);
  assert.equal(result.mismatched, true);
});

test("discount reduces computed total", () => {
  const result = diffOrderTotal(90.00, [50.00, 50.00], 10.00, 20.00);
  assert.equal(result.computed_total, 90.00);
  assert.equal(result.mismatched, false);
});

test("stale total after edit is flagged", () => {
  const result = diffOrderTotal(150.00, [50.00, 50.00], 10.00, 0.00);
  assert.equal(result.computed_total, 110.00);
  assert.equal(result.diff, 40.00);
  assert.equal(result.mismatched, true);
});

test("custom epsilon is respected", () => {
  const result = diffOrderTotal(110.03, [50.00, 50.00], 10.00, 0.00, 0.05);
  assert.equal(result.mismatched, false);
});

Case studies

Back office edit

The store that removed a line and left the total stale

A support agent removed a duplicated product line from an order in the back office to fix a customer complaint, but the edit was made through a workflow that skipped the normal recompute step. The order kept its old, higher total_paid while the order_detail rows now only added up to a smaller number.

Running the diagnostic across recent orders surfaced the exact difference, forty dollars, the price of the removed line, letting finance catch it before the invoice went out instead of after a customer called asking why they were charged for a product no longer on the order.

Rounding mode

The multi-item cart under Round on each item

A store selling low-priced items in bulk switched its tax rounding setting from Round on total to Round on each item, which shifted how each line's total_price_tax_incl rounded on high-quantity lines. A handful of large orders ended up with a computed line sum a few cents away from the cached total_paid.

The diagnostic's tolerance kept ordinary single-item rounding out of the report, but flagged the handful of large, high-quantity orders where the drift crossed a few cents, letting the team confirm the rounding setting change was working as intended everywhere else.

What good looks like

After this runs on a schedule, no order silently carries a stale total that disagrees with what its lines actually add up to. Instead you get a clear, dated report showing the stored total, the computed total, and the exact difference, so staff can check the order-edit screen and decide whether to recalculate, adjust a line, or leave it as a legitimate rounding difference. total_paid is never overwritten except through an explicit, confirmed override.

FAQ

Why does a PrestaShop order total not match the sum of its order lines?

PrestaShop stores an order's total_paid on the orders table and each line's total_price_tax_incl on the order_detail table separately, and only reconciles them during specific code paths such as cart validation or an OrderAmountUpdater run during a back office edit. A rounding-mode change, a module writing straight to the order totals, or a back office edit to a product line, a discount, or a partial refund can leave the cached order total out of step with what the lines actually add up to.

Is it safe to automatically overwrite total_paid to match the line sum?

Not automatically. The drift can come from a legitimate rounding-mode difference, a bad module write, a manual back office mistake, or a partial refund not yet reflected in the totals, and blindly overwriting total_paid can hide a real defect such as a wrong tax rate on a line, or corrupt a legally required invoice amount. The safe pattern is to flag every mismatch for a human to review in the order-edit screen, and only correct it under an explicit operator override after checking there is no pending state change or refund in flight.

How do I detect orders where the total does not match the line sum?

Pull the order with GET orders/{id_order} to read total_paid_tax_incl, total_shipping, and total_discounts, then pull GET order_details filtered by id_order and sum total_price_tax_incl across all rows. Add total_shipping, subtract total_discounts, and compare the result against total_paid_tax_incl with a small tolerance for rounding, flagging anything that disagrees by more than a couple of cents.

Related field notes

Citations

On the problem:

  1. PrestaShop/PrestaShop GitHub issue #36369: Total products is different than order total. github.com/PrestaShop/PrestaShop/issues/36369
  2. PrestaShop/PrestaShop GitHub issue #20467: Wrong invoice total after editing a product. github.com/PrestaShop/PrestaShop/issues/20467
  3. PrestaShop/PrestaShop GitHub issue #17347: Changing order products randomly corrupts order prices and breaks editing. github.com/PrestaShop/PrestaShop/issues/17347

On the solution:

  1. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
  2. PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
  3. PrestaShop Developer Documentation: Order invoices webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_invoices/

Stuck on a tricky one?

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

If this saved you a wrong invoice or a revenue report that did not reconcile, 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