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.
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.
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:
- The "Round on each item" versus "Round on total" setting interacts with per-unit price rounding, so the sum of the rounded line totals no longer equals the order total computed a different way.
- A payment module, a PayPal or Mollie-style connector, writes directly to the order's total fields instead of going through the normal recompute path, so the write does not touch
order_detailat all. - An order is edited in the back office, a product line is added, removed, or changed, a discount is applied, or a partial refund is recorded, and the recompute of line totals and the cached order-level total drift out of sync.
- PrestaShop's own tracker has repeated, version-spanning reports of exactly this class of bug after an edit or under specific rounding modes.
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.
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.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with read access to orders, order_details, 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.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, only reports by default
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, only reports by default
Pull 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.
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 {}
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 || {};
}
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.
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]
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));
}
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.
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,
}
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,
};
}
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.
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()
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();
}
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- PrestaShop/PrestaShop GitHub issue #36369: Total products is different than order total. github.com/PrestaShop/PrestaShop/issues/36369
- PrestaShop/PrestaShop GitHub issue #20467: Wrong invoice total after editing a product. github.com/PrestaShop/PrestaShop/issues/20467
- PrestaShop/PrestaShop GitHub issue #17347: Changing order products randomly corrupts order prices and breaks editing. github.com/PrestaShop/PrestaShop/issues/17347
On the solution:
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
- PrestaShop Developer Documentation: Order details webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_details/
- 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.
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