Skip to content

Diagnostic

Order created in the wrong state when the amount paid does not match the total

A customer pays part of an order, or a payment module reports success on the wrong amount, and the order still lands on a normal, paid-looking state in PrestaShop. Nobody sees a warning. The order simply looks fine, current_state says "Payment accepted" or similar, and total_paid_real quietly disagrees with total_paid underneath it. Here is why PrestaShop lets that gap through during validation and a script that finds every order where the two amounts do not match, so a human can decide what to do about it.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Holding a smartphone
Photo by Clay Banks on Unsplash
The short answer

PrestaShop stores two amounts on every order: total_paid, what the order is supposed to cost, and total_paid_real, what has actually been recorded as paid so far. Order validation trusts the state a payment module or the back office asks for and writes it, along with an order_histories row, without independently re-checking that the two amounts agree. So a module that confirms an order on a partial payment, a wrong currency conversion, or a manual state change in the back office can leave the order sitting on a normal state such as "Payment accepted" while total_paid_real is short of, or higher than, total_paid. Run a Python or Node.js script that pulls every order's total_paid, total_paid_real, and current_state with GET /api/orders, and flags any order where the two amounts disagree by more than a small rounding tolerance. Full code, tests, and citations are below.

The problem in plain words

Every PrestaShop order carries total_paid, the total the order is expected to be worth, and total_paid_real, the running total of what payments have actually recorded against it. When a payment fully clears, the two should match. When a customer only pays part of an order, or a refund brings the real total down, they will legitimately differ for a while, and that is fine.

The trouble is what happens during order validation. When a payment module or the back office asks PrestaShop to move an order to a given state, the validation code writes that state and inserts the matching order_histories row. It does not stop to independently verify that total_paid_real actually equals total_paid before doing that. If the module reports success on a partial amount, or a currency conversion rounds the wrong way, or a manual override in the back office jumps the order to a paid-looking state early, the order still ends up validated into that ordinary state. There is no separate error state forced onto it, and nothing on the order screen shouts about the mismatch. It just sits there looking normal.

Payment module reports a paid amount Order validates state and history written amounts never compared Normal state looks paid, may not be Amounts disagree
The order is validated into whatever state it was asked for. total_paid_real and total_paid are never compared during that step, so a mismatch rides along unnoticed.

Why it happens

PrestaShop treats the requested state as the source of truth during validation, and treats the amount fields as bookkeeping that gets updated alongside it rather than checked against it. A few common ways the two drift apart:

Any of these leaves an order that support staff, accounting, and fulfillment all read as settled, when the underlying amounts say otherwise. That is a quiet source of chargebacks, shipped-but-unpaid orders, and revenue reports that do not reconcile with the payment gateway. See the citations at the end for the exact reports and docs.

The key insight

A mismatch between total_paid and total_paid_real is not always wrong. A partial payment, a pending refund, or an order still mid-installment plan will show a real, expected gap. So the safe pattern is not "force every mismatched order back to an error state automatically." It is "flag every mismatch for a human," and only move the order through a new, explicit order_histories entry after someone has looked at the actual payment record and confirmed what the state should be.

The fix, as a flow

We do not touch current_state directly, and we do not silently reverse anyone's state changes. We add a job that walks every order, compares its two amount fields with a small rounding tolerance, and reports anything that disagrees along with whether the current state claims to be a paid one. A confirmed repair only ever adds a new order_histories row, the same way the back office itself changes state.

List orders total_paid, total_paid_real Read current_state check order_states.paid amount_mismatch totals disagree past tolerance Affected? yes no, move on Report for staff Only after explicit confirmation: POST order_histories with reviewed state
The job only ever reads and reports by default. A new order_histories row is only posted after DRY_RUN is off and an operator explicitly confirms the repair, and current_state itself is never edited directly.

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 and order_states, plus write access to order_histories if you plan to run confirmed repairs. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   # start safe, only reports by default
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, only reports by default
2

List every order with its two amount fields

Call GET /api/orders?display=[id,reference,current_state,total_paid,total_paid_real]&output_format=JSON&limit=0 to pull every order's id, reference, current state, and both amount fields. Paginate as needed for large stores by adding limit=offset,count and looping until you get an empty page.

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 all_orders():
    data = api_get("orders", params={
        "display": "[id,reference,current_state,total_paid,total_paid_real]",
        "limit": "0",
    })
    return data.get("orders") 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 allOrders() {
  const data = await apiGet("orders", {
    display: "[id,reference,current_state,total_paid,total_paid_real]",
    limit: "0",
  });
  return data.orders || [];
}
3

Read the order states and which ones count as paid

Call GET /api/order_states?display=[id,paid]&output_format=JSON to know which state ids PrestaShop itself flags as paid. That lets the report tell you not just that an order has a mismatch, but whether the state it landed on is actively claiming to be settled, which is the more urgent case.

step3.py
def paid_state_ids():
    data = api_get("order_states", params={"display": "[id,paid]"})
    states = data.get("order_states") or []
    return {int(s["id"]) for s in states if str(s.get("paid")) in ("1", "true", "True")}
step3.js
async function paidStateIds() {
  const data = await apiGet("order_states", { display: "[id,paid]" });
  const states = data.order_states || [];
  return new Set(
    states.filter((s) => ["1", "true", "True"].includes(String(s.paid))).map((s) => Number(s.id))
  );
}
4

Decide, with one pure function

Keep the comparison in its own function that takes total_paid, total_paid_real, current_state, and the set of paid state ids, and returns a plain result, nothing else. It compares the two amounts with a small rounding tolerance, since currency math is never exact. If they disagree past that tolerance, it reports whether the current state is one PrestaShop itself flags as paid, which is the case worth escalating first. No network calls happen inside it, which is what makes it easy to test on its own.

decide.py
TOLERANCE = 0.01

def amount_mismatch(total_paid, total_paid_real, current_state, paid_state_ids):
    diff = round(total_paid_real - total_paid, 2)
    if abs(diff) <= TOLERANCE:
        return None
    return {
        "reason": "amount_mismatch",
        "total_paid": total_paid,
        "total_paid_real": total_paid_real,
        "difference": diff,
        "current_state_is_paid": current_state in paid_state_ids,
    }
decide.js
const TOLERANCE = 0.01;

export function amountMismatch(totalPaid, totalPaidReal, currentState, paidStateIds) {
  const diff = Math.round((totalPaidReal - totalPaid) * 100) / 100;
  if (Math.abs(diff) <= TOLERANCE) return null;
  return {
    reason: "amount_mismatch",
    total_paid: totalPaid,
    total_paid_real: totalPaidReal,
    difference: diff,
    current_state_is_paid: paidStateIds.has(currentState),
  };
}
5

Report by default, repair only on explicit confirmation

When an order is affected, the script always logs a report row with id_order, reference, current_state, total_paid, total_paid_real, and whether the state claims to be paid. It never edits current_state or the amount fields directly. Only when DRY_RUN=false and the operator has explicitly confirmed the repair does it POST a new order_histories row with the state a human decided the order should actually be in, the same mechanism the back office itself uses to change state.

repair.py
def apply_reviewed_state(id_order, reviewed_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": reviewed_state, "id_employee": 0}}
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function applyReviewedState(idOrder, reviewedState) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const body = { order_history: { id_order: idOrder, id_order_state: reviewedState, id_employee: 0 } };
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together: list every order, compare its amounts through amount_mismatch, and log a report row for anything affected, calling out the ones sitting on a state that claims to be paid. DRY_RUN defaults to true, so the script only ever reports unless you flip it off and pass an explicit confirmation flag for the repair step. Run it on a schedule that matches how often new orders and payments come in, for example every few hours.

Run it safe

Always start with DRY_RUN=true. Never edit total_paid, total_paid_real, or current_state directly, since a state change should only ever happen through a new order_histories row. Treat every report row as a lead for staff to confirm against the real payment record, not a queue to auto-repair, because a partial payment or a pending refund can produce a mismatch that is expected and correct.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every order and compares its amounts, reports every mismatch, respects the dry run flag, and only ever writes a new order_histories row when explicitly told to.

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_amount_mismatch.py
"""Detect PrestaShop orders whose total_paid_real does not match total_paid.

Order validation writes whatever state a payment module or the back office asks for,
along with the matching order_histories row, without independently re-checking that
total_paid_real actually equals total_paid. A module that confirms an order on a
partial payment, a currency rounding difference, or a manual state change in the back
office can all leave an order sitting on a normal, paid-looking state while the two
amount fields disagree underneath it.

This script flags affected orders by default. It never edits total_paid,
total_paid_real, or current_state directly, since a state change should only ever
happen through a new order_histories row. A confirmed repair posts that new row with
the state a human decided the order should actually be in, only when DRY_RUN is false
and the operator has explicitly confirmed it.

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_amount_mismatch")

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"
CONFIRM_REPAIR = os.environ.get("CONFIRM_REPAIR", "false").lower() == "true"
REVIEWED_STATE = int(os.environ.get("REVIEWED_STATE", "0"))
AUTH = (PRESTASHOP_WS_KEY, "")

TOLERANCE = 0.01


def amount_mismatch(total_paid, total_paid_real, current_state, paid_state_ids):
    """Pure decision function, no I/O.

    Compares total_paid_real against total_paid with a small rounding tolerance.
    Returns a dict describing the problem, including whether current_state is one
    PrestaShop itself flags as paid, or None when the amounts already agree.
    """
    diff = round(total_paid_real - total_paid, 2)
    if abs(diff) <= TOLERANCE:
        return None
    return {
        "reason": "amount_mismatch",
        "total_paid": total_paid,
        "total_paid_real": total_paid_real,
        "difference": diff,
        "current_state_is_paid": current_state in paid_state_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 all_orders():
    data = api_get("orders", params={
        "display": "[id,reference,current_state,total_paid,total_paid_real]",
        "limit": "0",
    })
    return data.get("orders") or []


def paid_state_ids():
    data = api_get("order_states", params={"display": "[id,paid]"})
    states = data.get("order_states") or []
    return {int(s["id"]) for s in states if str(s.get("paid")) in ("1", "true", "True")}


def apply_reviewed_state(id_order, reviewed_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": reviewed_state, "id_employee": 0}}
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    paid_states = paid_state_ids()
    flagged = 0
    repaired = 0
    for order in all_orders():
        id_order = order["id"]
        current_state = int(order["current_state"])
        total_paid = float(order["total_paid"])
        total_paid_real = float(order["total_paid_real"])
        problem = amount_mismatch(total_paid, total_paid_real, current_state, paid_states)
        if problem is None:
            continue
        flagged += 1
        urgent_note = " (current state claims to be paid)" if problem["current_state_is_paid"] else ""
        log.warning(
            "Order has an amount mismatch. id_order=%s reference=%s current_state=%s "
            "total_paid=%.2f total_paid_real=%.2f difference=%.2f%s",
            id_order, order.get("reference"), current_state,
            total_paid, total_paid_real, problem["difference"], urgent_note,
        )
        if not DRY_RUN and CONFIRM_REPAIR and REVIEWED_STATE:
            apply_reviewed_state(id_order, REVIEWED_STATE)
            repaired += 1
            log.info("Applied reviewed state=%s for id_order=%s (id_employee=0).", REVIEWED_STATE, id_order)
    log.info(
        "Done. %d order(s) flagged for review, %d repaired. DRY_RUN=%s CONFIRM_REPAIR=%s",
        flagged, repaired, DRY_RUN, CONFIRM_REPAIR,
    )


if __name__ == "__main__":
    run()
check-amount-mismatch.js
/**
 * Detect PrestaShop orders whose total_paid_real does not match total_paid.
 *
 * Order validation writes whatever state a payment module or the back office asks for,
 * along with the matching order_histories row, without independently re-checking that
 * total_paid_real actually equals total_paid. A module that confirms an order on a
 * partial payment, a currency rounding difference, or a manual state change in the back
 * office can all leave an order sitting on a normal, paid-looking state while the two
 * amount fields disagree underneath it.
 *
 * This script flags affected orders by default. It never edits total_paid,
 * total_paid_real, or current_state directly, since a state change should only ever
 * happen through a new order_histories row. A confirmed repair posts that new row with
 * the state a human decided the order should actually be in, only when DRY_RUN is false
 * and the operator has explicitly confirmed it.
 *
 * Guide: https://www.allanninal.dev/prestashop/order-state-mismatch-on-amount-paid/
 */
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 CONFIRM_REPAIR = (process.env.CONFIRM_REPAIR || "false").toLowerCase() === "true";
const REVIEWED_STATE = Number(process.env.REVIEWED_STATE || 0);

const TOLERANCE = 0.01;

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

/**
 * Pure decision function, no I/O.
 *
 * Compares totalPaidReal against totalPaid with a small rounding tolerance. Returns an
 * object describing the problem, including whether currentState is one PrestaShop
 * itself flags as paid, or null when the amounts already agree.
 */
export function amountMismatch(totalPaid, totalPaidReal, currentState, paidStateIds) {
  const diff = Math.round((totalPaidReal - totalPaid) * 100) / 100;
  if (Math.abs(diff) <= TOLERANCE) return null;
  return {
    reason: "amount_mismatch",
    total_paid: totalPaid,
    total_paid_real: totalPaidReal,
    difference: diff,
    current_state_is_paid: paidStateIds.has(currentState),
  };
}

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 allOrders() {
  const data = await apiGet("orders", {
    display: "[id,reference,current_state,total_paid,total_paid_real]",
    limit: "0",
  });
  return data.orders || [];
}

async function paidStateIds() {
  const data = await apiGet("order_states", { display: "[id,paid]" });
  const states = data.order_states || [];
  return new Set(
    states.filter((s) => ["1", "true", "True"].includes(String(s.paid))).map((s) => Number(s.id))
  );
}

async function applyReviewedState(idOrder, reviewedState) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const body = { order_history: { id_order: idOrder, id_order_state: reviewedState, id_employee: 0 } };
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
  return res.json();
}

export async function run() {
  const paidStates = await paidStateIds();
  let flagged = 0;
  let repaired = 0;
  for (const order of await allOrders()) {
    const idOrder = order.id;
    const currentState = Number(order.current_state);
    const totalPaid = Number(order.total_paid);
    const totalPaidReal = Number(order.total_paid_real);
    const problem = amountMismatch(totalPaid, totalPaidReal, currentState, paidStates);
    if (problem === null) continue;
    flagged++;
    const urgentNote = problem.current_state_is_paid ? " (current state claims to be paid)" : "";
    console.warn(
      `Order has an amount mismatch. id_order=${idOrder} reference=${order.reference} ` +
        `current_state=${currentState} total_paid=${totalPaid.toFixed(2)} ` +
        `total_paid_real=${totalPaidReal.toFixed(2)} difference=${problem.difference.toFixed(2)}${urgentNote}`
    );
    if (!DRY_RUN && CONFIRM_REPAIR && REVIEWED_STATE) {
      await applyReviewedState(idOrder, REVIEWED_STATE);
      repaired++;
      console.log(`Applied reviewed state=${REVIEWED_STATE} for id_order=${idOrder} (id_employee=0).`);
    }
  }
  console.log(
    `Done. ${flagged} order(s) flagged for review, ${repaired} repaired. DRY_RUN=${DRY_RUN} CONFIRM_REPAIR=${CONFIRM_REPAIR}`
  );
}

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 and which ones get called out as urgent. Because we kept amount_mismatch pure, the test needs no network and no PrestaShop store. It just feeds in plain numbers and checks the answer.

test_amount_mismatch.py
from check_amount_mismatch import amount_mismatch

PAID_STATES = {2, 5}


def test_matching_amounts_are_consistent():
    assert amount_mismatch(100.00, 100.00, 2, PAID_STATES) is None


def test_tiny_rounding_difference_is_consistent():
    assert amount_mismatch(99.995, 100.00, 2, PAID_STATES) is None


def test_partial_payment_is_flagged():
    result = amount_mismatch(100.00, 40.00, 1, PAID_STATES)
    assert result["reason"] == "amount_mismatch"
    assert result["difference"] == -60.00
    assert result["current_state_is_paid"] is False


def test_mismatch_on_a_state_flagged_as_paid_is_urgent():
    result = amount_mismatch(100.00, 40.00, 2, PAID_STATES)
    assert result["current_state_is_paid"] is True


def test_overpayment_is_flagged():
    result = amount_mismatch(100.00, 150.00, 5, PAID_STATES)
    assert result["difference"] == 50.00
    assert result["current_state_is_paid"] is True


def test_state_not_in_paid_set_is_not_urgent():
    result = amount_mismatch(100.00, 40.00, 9, PAID_STATES)
    assert result["current_state_is_paid"] is False
amount-mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { amountMismatch } from "./check-amount-mismatch.js";

const PAID_STATES = new Set([2, 5]);

test("matching amounts are consistent", () => {
  assert.equal(amountMismatch(100.00, 100.00, 2, PAID_STATES), null);
});

test("tiny rounding difference is consistent", () => {
  assert.equal(amountMismatch(99.995, 100.00, 2, PAID_STATES), null);
});

test("partial payment is flagged", () => {
  const result = amountMismatch(100.00, 40.00, 1, PAID_STATES);
  assert.equal(result.reason, "amount_mismatch");
  assert.equal(result.difference, -60.00);
  assert.equal(result.current_state_is_paid, false);
});

test("mismatch on a state flagged as paid is urgent", () => {
  const result = amountMismatch(100.00, 40.00, 2, PAID_STATES);
  assert.equal(result.current_state_is_paid, true);
});

test("overpayment is flagged", () => {
  const result = amountMismatch(100.00, 150.00, 5, PAID_STATES);
  assert.equal(result.difference, 50.00);
  assert.equal(result.current_state_is_paid, true);
});

test("state not in paid set is not urgent", () => {
  const result = amountMismatch(100.00, 40.00, 9, PAID_STATES);
  assert.equal(result.current_state_is_paid, false);
});

Case studies

Deposit orders

The custom furniture shop confirming on a deposit

A made-to-order furniture store took a thirty percent deposit up front and the balance on delivery. Their payment module was configured to validate the order straight into a "Payment accepted" state the moment the deposit cleared, since that was the fastest way to get the order into production. Nobody had told the workshop that total_paid_real was only ever a third of total_paid.

Running the diagnostic across open orders surfaced every deposit-only order as a flagged mismatch, all correctly non-urgent since the state itself was not one PrestaShop marks as paid. That gave the office a clean worklist to chase remaining balances before shipping, instead of relying on someone remembering which orders were deposits.

Gateway rounding

The multi-currency store with a rounding drift

A store selling in three currencies had a gateway that rounded its reported charge slightly differently than the cart total calculated, so a small number of orders ended up with total_paid_real a few cents higher or lower than total_paid, all while sitting on a state that PrestaShop does mark as paid.

The diagnostic's tolerance kept normal rounding out of the report, but flagged the handful of orders where the drift was large enough to matter, letting the team catch a currency conversion misconfiguration in the gateway settings before it multiplied across thousands of orders.

What good looks like

After this runs on a schedule, no order is ever silently sitting on a paid-looking state with a mismatched balance behind it. Instead you get a clear, dated report showing which orders have a gap and which of those are the urgent ones already flagged as paid, so staff can confirm the real payment record before any state change happens. current_state is never touched except through a reviewed, explicit order_histories entry.

FAQ

Why does PrestaShop create an order in a normal state when the amount paid is wrong?

Order validation moves an order to whatever state the payment module or the back office tells it to use, and it trusts that call. It does not independently re-check that total_paid_real actually equals total_paid before writing that state and its order_history row, so a module that reports success on a partial or mismatched payment can still land the order on a normal, paid-looking state.

Is it safe to automatically move a mismatched order back to an error state?

Not automatically. A partial payment, a rounding difference, or a manually adjusted total can all produce a mismatch that is expected and fine. The safe pattern is to flag every order where total_paid_real does not equal total_paid for a human to review, and only change the order's state through a new order_histories entry after someone confirms it, never by editing current_state directly.

How do I detect orders where the paid amount does not match the total?

Pull each order with GET orders, including total_paid, total_paid_real, and current_state, and compare the two amounts with a small tolerance for rounding. Cross-check the current_state against GET order_states to see whether the state is flagged as paid, so you can tell a mismatch on a state that claims to be paid apart from one on a state that does not.

Related field notes

Citations

On the problem:

  1. a. example.com

On the solution:

  1. a. example.com

Stuck on a tricky one?

If you have a problem in PrestaShop orders, order states, 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 quiet chargeback 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