Skip to content

Reconciler

Orders created via webservice land in a payment error state

You POST an order to the PrestaShop webservice, the call returns fine, and the order shows up, sitting on "Payment error" instead of the state you expected. Nobody declined a card. Nothing actually failed. PrestaShop simply compared the amount your integration sent against what the cart really adds up to, found the two did not agree, and forced the order into the error state on the spot. Here is why that comparison trips so easily on webservice-created orders and a reconciler that finds every affected order and repairs the safe cases.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Paying with a phone
Photo by CardMapr.nl on Unsplash
The short answer

When you create an order through the webservice, PrestaShop's order validation, PaymentModule::validateOrder() and, on some 1.7.x releases, Order::createOrderFromCart() where the check was moved (see PrestaShop/PrestaShop#15834), compares the cart's real total against the amount_paid value you supplied using number_format() at _PS_PRICE_COMPUTE_PRECISION_. If the two disagree, the order is forced into Configuration::PS_OS_ERROR, the "Payment error" state, typically id 8. Webservice payloads frequently omit or miscalculate total_shipping or total_paid_real, because the API never computes shipping or tax for you, so the number you sent and the number the order actually settles on drift apart by exactly the rounding or missing-shipping amount. Run a Python or Node.js script that lists orders sitting in the error state, cross-checks each against its order_payments row and the recomputed cart total, and repairs only the safe, deterministic case where total_paid is already correct but the payment row disagrees with it. Full code, tests, and citations are below.

The problem in plain words

PrestaShop does not just trust that an order you create is paid because you said so. Order validation runs a comparison: it takes the cart's computed total_paid and checks it against the amount_paid figure that came in on the order request. If number_format(cart_total_paid, _PS_PRICE_COMPUTE_PRECISION_) does not equal number_format(amount_paid, _PS_PRICE_COMPUTE_PRECISION_), the order does not get the state you asked for. It gets forced into the "Payment error" state instead, no matter what payment status you intended.

Through the back office, this rarely bites because the cart, the shipping, and the payment total are all computed by the same code path. Through the webservice, you are the one assembling the order body, and the API does not compute shipping or tax on your behalf. It is easy to send a total_paid that adds up to the products alone, or that used a stale shipping estimate, while the cart PrestaShop actually validates against includes the real shipping cost. The two numbers disagree by a few cents or a full shipping line, and the order lands in error before anyone touches a payment gateway.

Webservice POST sends amount_paid Cart total computed products + shipping totals do not match Payment error Configuration::PS_OS_ERROR Order stuck
The order is forced into Payment error the moment amount_paid disagrees with the cart's real computed total, regardless of the state the caller asked for.

Why it happens

The comparison itself is intentional, PrestaShop should not silently validate an order whose numbers do not add up. What makes it common on webservice-created orders is how easy it is to feed that comparison a wrong number in the first place. A few common ways stores end up here:

The result is a trickle of orders that look identical to a genuinely failed payment, when the money and the order were both fine, only the number sent alongside them was off. See the citations at the end for the exact issues and forum threads.

The key insight

A mismatch between total_paid and the recorded order_payments.amount is not always the caller's fault to fix the same way. If total_paid itself already matches the true, recomputed cart total, and only the payment row disagrees with it, that is a safe, deterministic fix, correct the payment row to match the order. But if total_paid is the thing that is wrong, changing it is an accounting decision, not a script's call, since it affects invoicing and revenue reporting. So the safe pattern flags that case for a human instead of guessing which number was ever right.

The fix, as a flow

We do not touch current_state directly. We add a reconciler that lists every order sitting in the error state, reads its recorded payment amount, recomputes the true cart total from the order's own lines, and only ever writes when the order's own total already agrees with the cart, in which case it corrects the payment row and then advances the order out of error through a new order_histories entry, the same mechanism the back office uses.

List error orders current_state = PS_OS_ERROR Read payment + cart order_payments, recompute total order total matches cart? yes no, flag for review Correct payment order_payments.amount Advance state order_histories Manual review order total itself is wrong
Only the deterministic case writes: order total already matches the cart, only the payment row is off. Anything where the order total itself is wrong goes to a human.

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_payments, and carts, plus write access to order_payments and order_histories if you plan to run 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 ERROR_STATE_ID="8"   # Configuration::PS_OS_ERROR on most installs
export PAID_STATE_ID="2"    # state to move a repaired order into
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 ERROR_STATE_ID="8"   // Configuration::PS_OS_ERROR on most installs
export PAID_STATE_ID="2"    // state to move a repaired order into
export DRY_RUN="true"       // start safe, only reports by default
2

List the orders sitting in Payment error

Call GET /api/orders?filter[current_state]=[ID_ORDER_STATE_ERROR]&display=full&output_format=JSON to pull every order currently on that state, with id, reference, id_cart, total_paid, total_paid_real, and current_state. Add &date=1&filter[date_add]=[today's date range] to scope the run to recent orders on a busy store.

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 orders_in_error(error_state_id):
    data = api_get("orders", params={
        "filter[current_state]": f"[{error_state_id}]",
        "display": "full",
    })
    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 ordersInError(errorStateId) {
  const data = await apiGet("orders", {
    "filter[current_state]": `[${errorStateId}]`,
    display: "full",
  });
  return data.orders || [];
}
3

Read the recorded payment and recompute the true cart total

For each order, call GET /api/order_payments?filter[order_reference]=[reference]&display=full&output_format=JSON to read the recorded amount. Then recompute the authoritative total, either from GET /api/carts/[id_cart]?display=full&output_format=JSON or from the order's own order_details rows, as total_products_wt + total_shipping - total_discounts, rounded to _PS_PRICE_COMPUTE_PRECISION_, two decimals.

step3.py
def order_payment_for(reference):
    data = api_get("order_payments", params={
        "filter[order_reference]": reference,
        "display": "full",
    })
    rows = data.get("order_payments") or []
    return rows[0] if rows else None

def computed_cart_total(cart):
    products = float(cart.get("total_products_wt", 0))
    shipping = float(cart.get("total_shipping", 0))
    discounts = float(cart.get("total_discounts", 0))
    return round(products + shipping - discounts, 2)
step3.js
async function orderPaymentFor(reference) {
  const data = await apiGet("order_payments", {
    "filter[order_reference]": reference,
    display: "full",
  });
  const rows = data.order_payments || [];
  return rows[0] || null;
}

function computedCartTotal(cart) {
  const products = Number(cart.total_products_wt || 0);
  const shipping = Number(cart.total_shipping || 0);
  const discounts = Number(cart.total_discounts || 0);
  return Math.round((products + shipping - discounts) * 100) / 100;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, its order_payments row, and the recomputed cart total, and returns a plain action, nothing else. It reproduces the core comparison PrestaShop itself runs, number_format(order.total_paid, 2) != number_format(order_payments.amount, 2), but only ever recommends a write when the order's own total already agrees with the cart. If the order's total_paid itself diverges from the recomputed cart total, or if there is no order_payments row at all, it flags the order for manual review instead of guessing.

decide.py
def decide_order_payment_repair(order, order_payment, computed_cart_total, precision=2):
    def r(n):
        return round(float(n), precision)

    order_total = r(order["total_paid"])
    cart_total = r(computed_cart_total)

    if order_payment is None:
        return {"action": "flag_manual_review", "reason": "no_order_payment_row_found"}

    paid_amount = r(order_payment["amount"])

    if order_total != cart_total:
        return {"action": "flag_manual_review", "reason": "order_total_paid_diverges_from_cart_total"}

    if paid_amount != order_total:
        return {
            "action": "correct_payment_amount",
            "reason": "order_payment_amount_mismatches_order_total_paid",
            "corrected_amount": order_total,
        }

    return {"action": "none", "reason": "totals_reconciled"}
decide.js
export function decideOrderPaymentRepair(order, orderPayment, computedCartTotal, precision = 2) {
  const round = (n) => Number(Number(n).toFixed(precision));
  const orderTotal = round(order.total_paid);
  const cartTotal = round(computedCartTotal);

  if (!orderPayment) {
    return { action: "flag_manual_review", reason: "no_order_payment_row_found" };
  }
  const paidAmount = round(orderPayment.amount);

  if (orderTotal !== cartTotal) {
    return { action: "flag_manual_review", reason: "order_total_paid_diverges_from_cart_total" };
  }

  if (paidAmount !== orderTotal) {
    return {
      action: "correct_payment_amount",
      reason: "order_payment_amount_mismatches_order_total_paid",
      correctedAmount: orderTotal,
    };
  }

  return { action: "none", reason: "totals_reconciled" };
}
5

Apply the safe repair and advance the state through order_histories

When the action is correct_payment_amount, PUT /api/order_payments/[id_order_payment]?output_format=JSON with the full resource body, only amount changed to the order's total_paid, then POST /api/order_histories?output_format=JSON with the id of the paid or awaiting state to move the order out of error. current_state is never edited directly, only through a new history row, exactly the way the back office itself changes state.

repair.py
def correct_order_payment(order_payment, corrected_amount):
    body = {"order_payment": {
        "id": order_payment["id"],
        "order_reference": order_payment["order_reference"],
        "amount": corrected_amount,
        "payment_method": order_payment.get("payment_method"),
        "date_add": order_payment.get("date_add"),
    }}
    r = requests.put(
        f"{PRESTASHOP_URL}/api/order_payments/{order_payment['id']}",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def advance_order_state(id_order, id_order_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": id_order_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 correctOrderPayment(orderPayment, correctedAmount) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_payments/${orderPayment.id}`);
  url.searchParams.set("output_format", "JSON");
  const body = {
    order_payment: {
      id: orderPayment.id,
      order_reference: orderPayment.order_reference,
      amount: correctedAmount,
      payment_method: orderPayment.payment_method,
      date_add: orderPayment.date_add,
    },
  };
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT order_payments`);
  return res.json();
}

async function advanceOrderState(idOrder, idOrderState) {
  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: idOrderState, 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 in error, read its payment row and recomputed cart total, run it through decide_order_payment_repair, and log what it would do. DRY_RUN defaults to true, so the script only logs the intended change, old amount and new amount, and stops. Only when DRY_RUN=false does it correct the payment row and advance the state. Anything flagged for manual review is never written to, no matter what DRY_RUN says.

Run it safe

Always start with DRY_RUN=true. Never edit current_state directly, a state change should only ever happen through a new order_histories row. And never auto-correct total_paid itself, since that number feeds invoicing and accounting, only the order_payments.amount row is safe to correct automatically, and only when the order's own total already agrees with the cart.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every order sitting in Payment error, decides the safe action for each, respects the dry run flag, and only ever writes the payment row and the state advance for the deterministic case.

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.
reconcile_payment_error.py
"""Detect and safely repair PrestaShop orders stuck in Payment error after webservice creation.

Order validation, PaymentModule::validateOrder() and, on some 1.7.x releases,
Order::createOrderFromCart() where the check moved (PrestaShop/PrestaShop#15834),
compares the cart's computed total against the amount_paid the caller supplied and
forces the order into Configuration::PS_OS_ERROR, the Payment error state, whenever
number_format(cart_total_paid, precision) != number_format(amount_paid, precision).
Webservice integrations often omit or miscalculate total_shipping or total_paid_real,
since the API never computes shipping or tax for you, so the number sent and the
number the order actually settles on drift apart.

This script lists orders in the error state, reads each order_payments row, recomputes
the true cart total, and only ever writes for the safe, deterministic case: the order's
own total_paid already agrees with the cart, but the recorded payment amount does not.
If total_paid itself diverges from the cart, the order is flagged for manual review,
since changing total_paid affects invoicing and accounting integrity.

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("reconcile_payment_error")

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
ERROR_STATE_ID = int(os.environ.get("ERROR_STATE_ID", "8"))
PAID_STATE_ID = int(os.environ.get("PAID_STATE_ID", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def decide_order_payment_repair(order, order_payment, computed_cart_total, precision=2):
    """Pure decision function, no I/O.

    order: {id, total_paid, total_paid_real, current_state}
    order_payment: {amount} or None
    computed_cart_total: number, total_products_wt + total_shipping - total_discounts
    Returns a dict with an action of none, correct_payment_amount, or flag_manual_review.
    """
    def r(n):
        return round(float(n), precision)

    order_total = r(order["total_paid"])
    cart_total = r(computed_cart_total)

    if order_payment is None:
        return {"action": "flag_manual_review", "reason": "no_order_payment_row_found"}

    paid_amount = r(order_payment["amount"])

    if order_total != cart_total:
        return {"action": "flag_manual_review", "reason": "order_total_paid_diverges_from_cart_total"}

    if paid_amount != order_total:
        return {
            "action": "correct_payment_amount",
            "reason": "order_payment_amount_mismatches_order_total_paid",
            "corrected_amount": order_total,
        }

    return {"action": "none", "reason": "totals_reconciled"}


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_error():
    data = api_get("orders", params={
        "filter[current_state]": f"[{ERROR_STATE_ID}]",
        "display": "full",
    })
    return data.get("orders") or []


def order_payment_for(reference):
    data = api_get("order_payments", params={
        "filter[order_reference]": reference,
        "display": "full",
    })
    rows = data.get("order_payments") or []
    return rows[0] if rows else None


def computed_cart_total(cart):
    products = float(cart.get("total_products_wt", 0))
    shipping = float(cart.get("total_shipping", 0))
    discounts = float(cart.get("total_discounts", 0))
    return round(products + shipping - discounts, 2)


def cart_total_for(id_cart):
    data = api_get(f"carts/{id_cart}", params={"display": "full"})
    cart = data.get("cart") or {}
    return computed_cart_total(cart)


def correct_order_payment(order_payment, corrected_amount):
    body = {"order_payment": {
        "id": order_payment["id"],
        "order_reference": order_payment["order_reference"],
        "amount": corrected_amount,
        "payment_method": order_payment.get("payment_method"),
        "date_add": order_payment.get("date_add"),
    }}
    r = requests.put(
        f"{PRESTASHOP_URL}/api/order_payments/{order_payment['id']}",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def advance_order_state(id_order, id_order_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": id_order_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():
    repaired = 0
    flagged = 0
    for order in orders_in_error():
        id_order = order["id"]
        reference = order.get("reference")
        payment = order_payment_for(reference)
        cart_total = cart_total_for(order["id_cart"])
        decision = decide_order_payment_repair(order, payment, cart_total)

        if decision["action"] == "none":
            continue

        if decision["action"] == "flag_manual_review":
            flagged += 1
            log.warning("Order %s (id=%s) flagged for manual review: %s",
                        reference, id_order, decision["reason"])
            continue

        old_amount = payment["amount"]
        new_amount = decision["corrected_amount"]
        log.info("Order %s (id=%s) payment amount %s -> %s. %s",
                 reference, id_order, old_amount, new_amount,
                 "would correct" if DRY_RUN else "correcting")
        if DRY_RUN:
            continue

        correct_order_payment(payment, new_amount)
        advance_order_state(id_order, PAID_STATE_ID)
        repaired += 1

    log.info("Done. %d order(s) repaired, %d flagged for review. DRY_RUN=%s", repaired, flagged, DRY_RUN)


if __name__ == "__main__":
    run()
reconcile-payment-error.js
/**
 * Detect and safely repair PrestaShop orders stuck in Payment error after webservice creation.
 *
 * Order validation, PaymentModule::validateOrder() and, on some 1.7.x releases,
 * Order::createOrderFromCart() where the check moved (PrestaShop/PrestaShop#15834),
 * compares the cart's computed total against the amount_paid the caller supplied and
 * forces the order into Configuration::PS_OS_ERROR, the Payment error state, whenever
 * number_format(cart_total_paid, precision) != number_format(amount_paid, precision).
 * Webservice integrations often omit or miscalculate total_shipping or total_paid_real,
 * since the API never computes shipping or tax for you, so the number sent and the
 * number the order actually settles on drift apart.
 *
 * This script lists orders in the error state, reads each order_payments row, recomputes
 * the true cart total, and only ever writes for the safe, deterministic case: the order's
 * own total_paid already agrees with the cart, but the recorded payment amount does not.
 * If total_paid itself diverges from the cart, the order is flagged for manual review,
 * since changing total_paid affects invoicing and accounting integrity.
 *
 * Guide: https://www.allanninal.dev/prestashop/webservice-order-payment-error-mismatch/
 */
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 ERROR_STATE_ID = Number(process.env.ERROR_STATE_ID || 8);
const PAID_STATE_ID = Number(process.env.PAID_STATE_ID || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

/**
 * Pure decision function, no I/O.
 *
 * order: { id, total_paid, total_paid_real, current_state }
 * orderPayment: { amount } or null
 * computedCartTotal: number, total_products_wt + total_shipping - total_discounts
 * Returns { action: "none" | "correct_payment_amount" | "flag_manual_review", reason, correctedAmount? }
 */
export function decideOrderPaymentRepair(order, orderPayment, computedCartTotal, precision = 2) {
  const round = (n) => Number(Number(n).toFixed(precision));
  const orderTotal = round(order.total_paid);
  const cartTotal = round(computedCartTotal);

  if (!orderPayment) {
    return { action: "flag_manual_review", reason: "no_order_payment_row_found" };
  }
  const paidAmount = round(orderPayment.amount);

  if (orderTotal !== cartTotal) {
    return { action: "flag_manual_review", reason: "order_total_paid_diverges_from_cart_total" };
  }

  if (paidAmount !== orderTotal) {
    return {
      action: "correct_payment_amount",
      reason: "order_payment_amount_mismatches_order_total_paid",
      correctedAmount: orderTotal,
    };
  }

  return { action: "none", reason: "totals_reconciled" };
}

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 ordersInError() {
  const data = await apiGet("orders", {
    "filter[current_state]": `[${ERROR_STATE_ID}]`,
    display: "full",
  });
  return data.orders || [];
}

async function orderPaymentFor(reference) {
  const data = await apiGet("order_payments", {
    "filter[order_reference]": reference,
    display: "full",
  });
  const rows = data.order_payments || [];
  return rows[0] || null;
}

export function computedCartTotal(cart) {
  const products = Number(cart.total_products_wt || 0);
  const shipping = Number(cart.total_shipping || 0);
  const discounts = Number(cart.total_discounts || 0);
  return Math.round((products + shipping - discounts) * 100) / 100;
}

async function cartTotalFor(idCart) {
  const data = await apiGet(`carts/${idCart}`, { display: "full" });
  const cart = data.cart || {};
  return computedCartTotal(cart);
}

async function correctOrderPayment(orderPayment, correctedAmount) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_payments/${orderPayment.id}`);
  url.searchParams.set("output_format", "JSON");
  const body = {
    order_payment: {
      id: orderPayment.id,
      order_reference: orderPayment.order_reference,
      amount: correctedAmount,
      payment_method: orderPayment.payment_method,
      date_add: orderPayment.date_add,
    },
  };
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT order_payments`);
  return res.json();
}

async function advanceOrderState(idOrder, idOrderState) {
  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: idOrderState, 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() {
  let repaired = 0;
  let flagged = 0;
  for (const order of await ordersInError()) {
    const idOrder = order.id;
    const reference = order.reference;
    const payment = await orderPaymentFor(reference);
    const cartTotal = await cartTotalFor(order.id_cart);
    const decision = decideOrderPaymentRepair(order, payment, cartTotal);

    if (decision.action === "none") continue;

    if (decision.action === "flag_manual_review") {
      flagged++;
      console.warn(`Order ${reference} (id=${idOrder}) flagged for manual review: ${decision.reason}`);
      continue;
    }

    const oldAmount = payment.amount;
    const newAmount = decision.correctedAmount;
    console.log(
      `Order ${reference} (id=${idOrder}) payment amount ${oldAmount} -> ${newAmount}. ${DRY_RUN ? "would correct" : "correcting"}`
    );
    if (DRY_RUN) continue;

    await correctOrderPayment(payment, newAmount);
    await advanceOrderState(idOrder, PAID_STATE_ID);
    repaired++;
  }
  console.log(`Done. ${repaired} order(s) repaired, ${flagged} flagged for review. DRY_RUN=${DRY_RUN}`);
}

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

Add a test

The decision function is the part most worth testing, because it decides which orders get a safe automatic fix and which ones get sent to a human. Because we kept decide_order_payment_repair pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_webservice_payment_error.py
from reconcile_payment_error import decide_order_payment_repair


def order(**over):
    base = {"id": 101, "total_paid": 100.00, "total_paid_real": 0.0, "current_state": 8}
    base.update(over)
    return base


def test_totals_reconciled_when_everything_matches():
    result = decide_order_payment_repair(order(), {"amount": 100.00}, 100.00)
    assert result["action"] == "none"
    assert result["reason"] == "totals_reconciled"


def test_flags_missing_order_payment_row():
    result = decide_order_payment_repair(order(), None, 100.00)
    assert result["action"] == "flag_manual_review"
    assert result["reason"] == "no_order_payment_row_found"


def test_flags_when_order_total_diverges_from_cart():
    # total_paid (100) does not match the recomputed cart total (85), a missed shipping line
    result = decide_order_payment_repair(order(total_paid=100.00), {"amount": 100.00}, 85.00)
    assert result["action"] == "flag_manual_review"
    assert result["reason"] == "order_total_paid_diverges_from_cart_total"


def test_corrects_payment_amount_when_order_total_is_right_but_payment_row_is_not():
    result = decide_order_payment_repair(order(total_paid=100.00), {"amount": 40.00}, 100.00)
    assert result["action"] == "correct_payment_amount"
    assert result["corrected_amount"] == 100.00


def test_tiny_rounding_within_precision_is_treated_as_equal():
    result = decide_order_payment_repair(order(total_paid=100.004), {"amount": 100.00}, 100.001)
    assert result["action"] == "none"


def test_respects_custom_precision():
    result = decide_order_payment_repair(order(total_paid=100.0), {"amount": 100.0}, 100.0, precision=0)
    assert result["action"] == "none"
reconcile-payment-error.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOrderPaymentRepair } from "./reconcile-payment-error.js";

const order = (over = {}) => ({ id: 101, total_paid: 100.00, total_paid_real: 0.0, current_state: 8, ...over });

test("totals reconciled when everything matches", () => {
  const result = decideOrderPaymentRepair(order(), { amount: 100.00 }, 100.00);
  assert.equal(result.action, "none");
  assert.equal(result.reason, "totals_reconciled");
});

test("flags missing order payment row", () => {
  const result = decideOrderPaymentRepair(order(), null, 100.00);
  assert.equal(result.action, "flag_manual_review");
  assert.equal(result.reason, "no_order_payment_row_found");
});

test("flags when order total diverges from cart", () => {
  const result = decideOrderPaymentRepair(order({ total_paid: 100.00 }), { amount: 100.00 }, 85.00);
  assert.equal(result.action, "flag_manual_review");
  assert.equal(result.reason, "order_total_paid_diverges_from_cart_total");
});

test("corrects payment amount when order total is right but payment row is not", () => {
  const result = decideOrderPaymentRepair(order({ total_paid: 100.00 }), { amount: 40.00 }, 100.00);
  assert.equal(result.action, "correct_payment_amount");
  assert.equal(result.correctedAmount, 100.00);
});

test("tiny rounding within precision is treated as equal", () => {
  const result = decideOrderPaymentRepair(order({ total_paid: 100.004 }), { amount: 100.00 }, 100.001);
  assert.equal(result.action, "none");
});

test("respects custom precision", () => {
  const result = decideOrderPaymentRepair(order({ total_paid: 100.0 }), { amount: 100.0 }, 100.0, 0);
  assert.equal(result.action, "none");
});

Case studies

Missing shipping

The marketplace connector that forgot to price shipping

A store synced orders from a marketplace through a custom connector posting to /api/orders. The connector summed line items into total_paid but never added the shipping fee the marketplace itself had already charged the buyer, since that number lived in a different part of the marketplace payload the developer had not wired up yet. Every order with a shipping charge landed straight in Payment error, even though the buyer had paid in full on the marketplace side.

Running the reconciler against the affected date range showed most of them flagged for manual review, since the true cart total (products plus the real shipping) did not match what the connector had sent. That surfaced the missing field in the connector rather than letting the team quietly patch each order's total by hand.

Rounding drift

The subscription billing job with a one-cent gap

A recurring billing job created renewal orders through the webservice each month. Its own price calculation, done in a different currency library than PrestaShop's cart rules, occasionally rounded a renewal total one cent differently than the cart PrestaShop computed. Those orders validated straight into Payment error, a handful every billing cycle, even though the order's own total_paid was otherwise correct.

For those, the reconciler found the order's total_paid already matched the recomputed cart total exactly, only the recorded payment row was a cent short. That is precisely the deterministic case: the payment row got corrected, the order advanced out of error, and no accounting figure on the order itself ever changed.

What good looks like

After this runs on a schedule, orders created through the webservice stop piling up silently in Payment error. The safe, deterministic mismatches, where the order's own total was always right and only the payment row drifted, get corrected and advanced automatically. Everything else, where the order total itself does not add up, goes to a human with the exact numbers needed to decide which figure was ever correct, instead of a script guessing on your accounting's behalf.

FAQ

Why does an order created through the PrestaShop webservice land in Payment error?

PrestaShop compares the cart's computed total against the amount_paid value the webservice caller supplied, using number_format at the price compute precision. Webservice integrations often omit or miscalculate total_shipping or total_paid_real because the API does not compute shipping or tax for you, so the totals disagree and the order is forced into the Payment error state, Configuration::PS_OS_ERROR.

Is it safe to automatically correct every order stuck in Payment error?

Only for one specific case: when the order's total_paid already matches the recomputed cart total, but the recorded order_payments.amount row disagrees with it. That is a safe, deterministic fix to the payment row. If total_paid itself diverges from the true cart total, do not auto-correct it, since changing it affects invoicing and accounting integrity and needs a human decision on which figure is authoritative.

How do I detect these mismatched orders through the webservice API?

List orders whose current_state is the Payment error state with GET orders filtered on current_state, then for each one read its order_payments row and recompute the true cart total from total_products_wt, total_shipping, and total_discounts. Flag it as a real mismatch only when total_paid does not equal the order_payments amount at two decimal places, which reproduces the core comparison PrestaShop itself runs during order validation.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #19219: webservice create order sets total_paid_real with shipping fees. github.com/PrestaShop/PrestaShop/issues/19219
  2. PrestaShop GitHub Issue #15834: amount paid is no longer validated when creating an order. github.com/PrestaShop/PrestaShop/issues/15834
  3. PrestaShop Forums: creating an order with Webservice results in Payment error. forum.prestashop.com/topic/995248-creating-an-order-with-webservice-payment-error

On the solution:

  1. PrestaShop Developer Documentation: the orders resource. devdocs.prestashop-project.org/9/webservice/resources/orders
  2. PrestaShop Developer Documentation: the order_payments resource. devdocs.prestashop-project.org/9/webservice/resources/order_payments
  3. PrestaShop Developer Documentation: the order_histories resource. devdocs.prestashop-project.org/9/webservice/resources/order_histories

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 clear a batch of Payment error orders?

If this saved you a pile of manual reconciliation or a support ticket you could not explain, 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