Skip to content

Reconciler

Total paid real field doubles after a partial payment update

A partial payment comes in, someone or something confirms it a second time, and the order's total_paid_real quietly ends up at exactly twice the amount that was actually collected. The order_payment table may carry a duplicate row, the stored total no longer matches the sum of the real payments behind it, and nobody notices until finance tries to reconcile against the bank. Here is why PrestaShop's own payment recording code can fire twice for one payment, and a script that finds every order where the stored total disagrees with the real payment rows.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A card reader device
Photo by Towfiqu barbhuiya on Unsplash
The short answer

PrestaShop's Order::addOrderPayment() does two things at once: it inserts a row into order_payment, and it directly increments the order's own total_paid_real column by the payment amount before saving the order. Nothing stops that method from being called twice for the same real-world payment. A common trigger is Order::validateOrder() auto-adding a payment when the target order state has paid=1 set, followed by a separate order_history update, a webservice call, or a payment module such as PayPal calling addOrderPayment() again for the same amount. The result is a duplicate row in order_payment and a total_paid_real that no longer equals the sum of the real payment rows, sometimes landing at exactly double. Run a Python or Node.js script that pulls each order's total_paid_real from GET /api/orders/{id}, sums the matching rows from GET /api/order_payments?filter[order_reference]={reference}, and flags any order where the two disagree, especially when the stored total is about twice the real sum. Full code, tests, and citations are below.

The problem in plain words

Every PrestaShop order keeps two views of what has been paid. order_payment is a table of individual payment rows, each one a record of money that actually came in. total_paid_real is a single number cached on the order itself, meant to always equal the sum of those rows. The order object treats that cached number as convenient to read, but it is not computed live. It is written once, by hand, inside Order::addOrderPayment(), every time that method runs.

addOrderPayment() inserts the new row into order_payment and then does $this->total_paid_real += $order_payment->amount before calling Order::update() to save the order. That is fine the first time. The trouble is that PrestaShop itself, plus payment modules, plus the webservice, all have paths that can call this same method for what is really one payment. If validateOrder() auto-adds a payment because the target state has paid=1, and then a separate order_history change, a webservice write, or a payment module confirmation calls addOrderPayment() again for the same amount, the row gets duplicated and the cached total gets incremented a second time. Nothing in that path checks whether a matching payment already exists.

validateOrder() auto-adds a payment addOrderPayment() row inserted, total += amount called again, same payment History or module confirms the same amount Total doubled
Nothing checks whether a matching payment row already exists, so a second call to addOrderPayment for the same real payment inserts a duplicate row and adds the amount to total_paid_real a second time.

Why it happens

PrestaShop lets more than one code path call addOrderPayment() for what is really a single payment event, and none of them checks the existing order_payment rows first. A few common ways this plays out:

Whatever the trigger, the symptom looks the same: an extra row in order_payment, or a total_paid_real that no longer equals the sum of the rows that are actually there, sometimes ending up at exactly double the true amount collected. This is documented across several PrestaShop issue reports. See the citations at the end for the exact threads.

The key insight

order_payment is the source of truth. total_paid_real is a cache that addOrderPayment() maintains by hand, and caches can drift. A mismatch is not automatically a bug either, since a genuine partial payment can leave total_paid_real legitimately below total_paid. The signal worth acting on is when total_paid_real is close to exactly twice the sum of the order's real order_payment rows, or twice total_paid, which is the specific shape of the double-add bug rather than an ordinary shortfall.

The fix, as a flow

We do not touch a live order's payment fields automatically. We add a job that pulls each order's stored total, sums its real order_payment rows, and reports a mismatch, calling out the doubled case by name. A confirmed repair only happens after a human identifies the exact duplicate row, deletes that specific row, and then writes the recomputed total back.

Read order total_paid, total_paid_real Sum order_payment rows filter by order_reference reconcilePayment() mismatch and likelyDoubled checks Mismatch? yes no, move on Report for staff Only after confirming the duplicate row: DELETE it, then PUT recomputed total_paid_real
The job only ever reads and reports by default. A corrective delete and write happens only after DRY_RUN is off and a human has identified the specific duplicate order_payment row.

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_payments, plus write access to both 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

Read the order's stored total

Call GET /api/orders/{id}?output_format=JSON to read total_paid, total_paid_real, reference, and current_state. This is the cached number that addOrderPayment() maintains by hand, and the one we are about to check against the real payment rows.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

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

def get_order(id_order):
    data = api_get(f"orders/{id_order}")
    return data["order"]
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

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

async function getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`);
  return data.order;
}
3

Sum the real order_payment rows

Call GET /api/order_payments?filter[order_reference]={reference}&display=full&output_format=JSON. Note that order_payment is keyed by order_reference, not id_order, so filter on the order's reference string. Sum the amount field across the returned rows to get the true total actually collected.

step3.py
def order_payments_for_reference(reference):
    data = api_get("order_payments", params={
        "filter[order_reference]": reference,
        "display": "full",
    })
    payments = data.get("order_payments") or []
    if isinstance(payments, dict):
        payments = [payments]
    return payments

def sum_payment_amounts(reference):
    payments = order_payments_for_reference(reference)
    return [float(p["amount"]) for p in payments]
step3.js
async function orderPaymentsForReference(reference) {
  const data = await apiGet("order_payments", {
    "filter[order_reference]": reference,
    display: "full",
  });
  let payments = data.order_payments || [];
  if (!Array.isArray(payments)) payments = [payments];
  return payments;
}

async function paymentAmounts(reference) {
  const payments = await orderPaymentsForReference(reference);
  return payments.map((p) => Number(p.amount));
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the order's stored total_paid_real and the list of real payment amounts, and returns a plain result, nothing else. It sums the payments, compares that sum to the stored total within a small tolerance for rounding, and separately flags the specific shape of this bug: a stored total close to exactly twice the real sum, which is the signature of a duplicate addOrderPayment() call rather than an ordinary partial-payment shortfall. No network calls happen inside it, which is what makes it easy to test on its own.

decide.py
def reconcile_payment(order_total_paid_real, order_payment_amounts, total_paid=None, epsilon=0.01):
    sum_payments = round(sum(order_payment_amounts), 2)
    mismatch = abs(order_total_paid_real - sum_payments) > epsilon
    baseline = sum_payments if sum_payments > epsilon else (total_paid or 0)
    likely_doubled = baseline > epsilon and abs(order_total_paid_real - 2 * baseline) <= epsilon
    return {
        "mismatch": mismatch,
        "sumPayments": sum_payments,
        "delta": round(order_total_paid_real - sum_payments, 2),
        "likelyDoubled": likely_doubled,
    }
decide.js
export function reconcilePayment(orderTotalPaidReal, orderPaymentAmounts, totalPaid = null, epsilon = 0.01) {
  const sumPayments = Math.round(orderPaymentAmounts.reduce((a, b) => a + b, 0) * 100) / 100;
  const mismatch = Math.abs(orderTotalPaidReal - sumPayments) > epsilon;
  const baseline = sumPayments > epsilon ? sumPayments : (totalPaid || 0);
  const likelyDoubled = baseline > epsilon && Math.abs(orderTotalPaidReal - 2 * baseline) <= epsilon;
  return {
    mismatch,
    sumPayments,
    delta: Math.round((orderTotalPaidReal - sumPayments) * 100) / 100,
    likelyDoubled,
  };
}
5

Report by default, repair only on explicit confirmation

When an order is mismatched, the script always logs a report row with id_order, reference, total_paid, total_paid_real, sum_order_payments, and delta, calling out the doubled case by name. It never rewrites total_paid_real on its own. Only when DRY_RUN=false and the operator has confirmed the exact duplicate order_payment row does it delete that row by id and then PUT the order with total_paid_real set to the recomputed sum of the remaining rows, since order_payment is the source of truth and the cached total is derived from it.

repair.py
def delete_order_payment(id_order_payment):
    r = requests.delete(
        f"{PRESTASHOP_URL}/api/order_payments/{id_order_payment}",
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()

def put_corrected_total(order, corrected_total_paid_real):
    order = dict(order)
    order["total_paid_real"] = f"{corrected_total_paid_real:.2f}"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{order['id']}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function deleteOrderPayment(idOrderPayment) {
  const res = await fetch(`${PRESTASHOP_URL}/api/order_payments/${idOrderPayment}`, {
    method: "DELETE",
    headers: { Authorization: basicAuthHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on DELETE order_payments/${idOrderPayment}`);
}

async function putCorrectedTotal(order, correctedTotalPaidReal) {
  const url = new URL(`${PRESTASHOP_URL}/api/orders/${order.id}`);
  url.searchParams.set("output_format", "JSON");
  const body = { order: { ...order, total_paid_real: correctedTotalPaidReal.toFixed(2) } };
  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 orders/${order.id}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together: read each order, sum its real payments, run them through reconcile_payment, and log a report row for anything mismatched, calling out the doubled case as the most urgent. DRY_RUN defaults to true, so the script only ever reports unless you flip it off and supply the confirmed duplicate payment id for the repair step. Run it on a schedule that matches how often orders and payments come in, for example every few hours.

Run it safe

Always start with DRY_RUN=true. Never overwrite total_paid_real without first deleting the confirmed duplicate order_payment row, since order_payment is the source of truth and the cached total is only ever a derived number. A partial payment that legitimately falls short of the order total is normal and should never be auto-corrected.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks orders and reconciles each one against its real payment rows, reports every mismatch, respects the dry run flag, and only ever deletes a duplicate row and writes a corrected total 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.
reconcile_total_paid_real.py
"""Detect PrestaShop orders whose total_paid_real has doubled after a duplicate payment.

Order::addOrderPayment() both inserts a row into order_payment and directly increments
the order's own total_paid_real column before saving the order. Nothing checks whether
a matching payment already exists, so a partial-payment workflow that triggers this
method twice for the same real-world payment, for example an auto-added payment from
Order::validateOrder() plus a separate order_history update or a payment module call,
leaves order_payment with a duplicate row and total_paid_real incremented twice. The
stored total can end up exactly double the true sum of the real payment rows.

This script flags affected orders by default. It never rewrites total_paid_real on its
own, since order_payment is the source of truth and the cached total is only derived
from it. A confirmed repair deletes the specific duplicate order_payment row, then PUTs
the order with total_paid_real recomputed from the remaining rows, only when DRY_RUN is
false and the operator has supplied the confirmed duplicate payment id.

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

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

EPSILON = 0.01


def reconcile_payment(order_total_paid_real, order_payment_amounts, total_paid=None, epsilon=EPSILON):
    """Pure decision function, no I/O.

    Sums order_payment_amounts and compares that sum to order_total_paid_real within
    epsilon. mismatch is True when they disagree past the tolerance. likelyDoubled is
    True when order_total_paid_real is within epsilon of twice the real sum (or twice
    total_paid when there are no payment rows yet), which is the signature shape of the
    duplicate addOrderPayment() bug rather than an ordinary partial-payment shortfall.
    """
    sum_payments = round(sum(order_payment_amounts), 2)
    mismatch = abs(order_total_paid_real - sum_payments) > epsilon
    baseline = sum_payments if sum_payments > epsilon else (total_paid or 0)
    likely_doubled = baseline > epsilon and abs(order_total_paid_real - 2 * baseline) <= epsilon
    return {
        "mismatch": mismatch,
        "sumPayments": sum_payments,
        "delta": round(order_total_paid_real - sum_payments, 2),
        "likelyDoubled": likely_doubled,
    }


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["order"]


def order_payments_for_reference(reference):
    data = api_get("order_payments", params={
        "filter[order_reference]": reference,
        "display": "full",
    })
    payments = data.get("order_payments") or []
    if isinstance(payments, dict):
        payments = [payments]
    return payments


def delete_order_payment(id_order_payment):
    r = requests.delete(
        f"{PRESTASHOP_URL}/api/order_payments/{id_order_payment}",
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()


def put_corrected_total(order, corrected_total_paid_real):
    order = dict(order)
    order["total_paid_real"] = f"{corrected_total_paid_real:.2f}"
    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 id_order in ORDER_IDS:
        order = get_order(id_order)
        reference = order["reference"]
        total_paid = float(order["total_paid"])
        total_paid_real = float(order["total_paid_real"])
        payments = order_payments_for_reference(reference)
        amounts = [float(p["amount"]) for p in payments]
        result = reconcile_payment(total_paid_real, amounts, total_paid=total_paid)
        if not result["mismatch"]:
            continue
        flagged += 1
        doubled_note = " (looks doubled, likely a duplicate addOrderPayment call)" if result["likelyDoubled"] else ""
        log.warning(
            "Order has a payment mismatch. id_order=%s reference=%s total_paid=%.2f "
            "total_paid_real=%.2f sum_order_payments=%.2f delta=%.2f%s",
            id_order, reference, total_paid, total_paid_real,
            result["sumPayments"], result["delta"], doubled_note,
        )
        if not DRY_RUN and CONFIRM_DUPLICATE_PAYMENT_ID:
            delete_order_payment(CONFIRM_DUPLICATE_PAYMENT_ID)
            remaining = [
                float(p["amount"]) for p in payments
                if str(p.get("id")) != str(CONFIRM_DUPLICATE_PAYMENT_ID)
            ]
            corrected_total = round(sum(remaining), 2)
            put_corrected_total(order, corrected_total)
            repaired += 1
            log.info(
                "Deleted duplicate order_payment id=%s and set total_paid_real=%.2f for id_order=%s.",
                CONFIRM_DUPLICATE_PAYMENT_ID, corrected_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()
reconcile-total-paid-real.js
/**
 * Detect PrestaShop orders whose total_paid_real has doubled after a duplicate payment.
 *
 * Order::addOrderPayment() both inserts a row into order_payment and directly increments
 * the order's own total_paid_real column before saving the order. Nothing checks whether
 * a matching payment already exists, so a partial-payment workflow that triggers this
 * method twice for the same real-world payment, for example an auto-added payment from
 * Order::validateOrder() plus a separate order_history update or a payment module call,
 * leaves order_payment with a duplicate row and total_paid_real incremented twice. The
 * stored total can end up exactly double the true sum of the real payment rows.
 *
 * This script flags affected orders by default. It never rewrites total_paid_real on
 * its own, since order_payment is the source of truth and the cached total is only
 * derived from it. A confirmed repair deletes the specific duplicate order_payment row,
 * then PUTs the order with total_paid_real recomputed from the remaining rows, only when
 * DRY_RUN is false and the operator has supplied the confirmed duplicate payment id.
 *
 * Guide: https://www.allanninal.dev/prestashop/total-paid-real-doubled/
 */
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_DUPLICATE_PAYMENT_ID = process.env.CONFIRM_DUPLICATE_PAYMENT_ID || "";
const ORDER_IDS = (process.env.ORDER_IDS || "")
  .split(",")
  .map((x) => x.trim())
  .filter(Boolean)
  .map(Number);

const EPSILON = 0.01;

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

/**
 * Pure decision function, no I/O.
 *
 * Sums orderPaymentAmounts and compares that sum to orderTotalPaidReal within epsilon.
 * mismatch is true when they disagree past the tolerance. likelyDoubled is true when
 * orderTotalPaidReal is within epsilon of twice the real sum (or twice totalPaid when
 * there are no payment rows yet), which is the signature shape of the duplicate
 * addOrderPayment() bug rather than an ordinary partial-payment shortfall.
 */
export function reconcilePayment(orderTotalPaidReal, orderPaymentAmounts, totalPaid = null, epsilon = EPSILON) {
  const sumPayments = Math.round(orderPaymentAmounts.reduce((a, b) => a + b, 0) * 100) / 100;
  const mismatch = Math.abs(orderTotalPaidReal - sumPayments) > epsilon;
  const baseline = sumPayments > epsilon ? sumPayments : (totalPaid || 0);
  const likelyDoubled = baseline > epsilon && Math.abs(orderTotalPaidReal - 2 * baseline) <= epsilon;
  return {
    mismatch,
    sumPayments,
    delta: Math.round((orderTotalPaidReal - sumPayments) * 100) / 100,
    likelyDoubled,
  };
}

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

async function getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`);
  return data.order;
}

async function orderPaymentsForReference(reference) {
  const data = await apiGet("order_payments", {
    "filter[order_reference]": reference,
    display: "full",
  });
  let payments = data.order_payments || [];
  if (!Array.isArray(payments)) payments = [payments];
  return payments;
}

async function deleteOrderPayment(idOrderPayment) {
  const res = await fetch(`${PRESTASHOP_URL}/api/order_payments/${idOrderPayment}`, {
    method: "DELETE",
    headers: { Authorization: basicAuthHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on DELETE order_payments/${idOrderPayment}`);
}

async function putCorrectedTotal(order, correctedTotalPaidReal) {
  const url = new URL(`${PRESTASHOP_URL}/api/orders/${order.id}`);
  url.searchParams.set("output_format", "JSON");
  const body = { order: { ...order, total_paid_real: correctedTotalPaidReal.toFixed(2) } };
  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 orders/${order.id}`);
  return res.json();
}

export async function run() {
  let flagged = 0;
  let repaired = 0;
  for (const idOrder of ORDER_IDS) {
    const order = await getOrder(idOrder);
    const reference = order.reference;
    const totalPaid = Number(order.total_paid);
    const totalPaidReal = Number(order.total_paid_real);
    const payments = await orderPaymentsForReference(reference);
    const amounts = payments.map((p) => Number(p.amount));
    const result = reconcilePayment(totalPaidReal, amounts, totalPaid);
    if (!result.mismatch) continue;
    flagged++;
    const doubledNote = result.likelyDoubled ? " (looks doubled, likely a duplicate addOrderPayment call)" : "";
    console.warn(
      `Order has a payment mismatch. id_order=${idOrder} reference=${reference} ` +
        `total_paid=${totalPaid.toFixed(2)} total_paid_real=${totalPaidReal.toFixed(2)} ` +
        `sum_order_payments=${result.sumPayments.toFixed(2)} delta=${result.delta.toFixed(2)}${doubledNote}`
    );
    if (!DRY_RUN && CONFIRM_DUPLICATE_PAYMENT_ID) {
      await deleteOrderPayment(CONFIRM_DUPLICATE_PAYMENT_ID);
      const remaining = payments
        .filter((p) => String(p.id) !== String(CONFIRM_DUPLICATE_PAYMENT_ID))
        .map((p) => Number(p.amount));
      const correctedTotal = Math.round(remaining.reduce((a, b) => a + b, 0) * 100) / 100;
      await putCorrectedTotal(order, correctedTotal);
      repaired++;
      console.log(
        `Deleted duplicate order_payment id=${CONFIRM_DUPLICATE_PAYMENT_ID} and set total_paid_real=${correctedTotal.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 reconciliation rule is the part most worth testing, because it decides which orders get flagged and which ones get called out as a likely double-add. Because we kept reconcile_payment pure, the test needs no network and no PrestaShop store. It just feeds in plain numbers and checks the answer.

test_reconcile_payment.py
from reconcile_total_paid_real import reconcile_payment


def test_matching_totals_are_consistent():
    result = reconcile_payment(100.00, [40.00, 60.00])
    assert result["mismatch"] is False
    assert result["sumPayments"] == 100.00
    assert result["delta"] == 0.00
    assert result["likelyDoubled"] is False


def test_tiny_rounding_difference_is_consistent():
    result = reconcile_payment(100.00, [33.335, 33.335, 33.33])
    assert result["mismatch"] is False


def test_partial_payment_shortfall_is_not_doubled():
    result = reconcile_payment(40.00, [40.00])
    assert result["mismatch"] is False
    assert result["likelyDoubled"] is False


def test_doubled_total_is_flagged_and_marked_likely_doubled():
    result = reconcile_payment(120.00, [60.00])
    assert result["mismatch"] is True
    assert result["sumPayments"] == 60.00
    assert result["delta"] == 60.00
    assert result["likelyDoubled"] is True


def test_doubled_against_total_paid_when_no_payment_rows_yet():
    result = reconcile_payment(200.00, [], total_paid=100.00)
    assert result["mismatch"] is True
    assert result["likelyDoubled"] is True


def test_ordinary_mismatch_not_close_to_double_is_not_flagged_doubled():
    result = reconcile_payment(70.00, [60.00])
    assert result["mismatch"] is True
    assert result["likelyDoubled"] is False


def test_zero_payments_and_zero_total_paid_is_not_doubled():
    result = reconcile_payment(0.00, [])
    assert result["mismatch"] is False
    assert result["likelyDoubled"] is False
reconcile-payment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcilePayment } from "./reconcile-total-paid-real.js";

test("matching totals are consistent", () => {
  const result = reconcilePayment(100.00, [40.00, 60.00]);
  assert.equal(result.mismatch, false);
  assert.equal(result.sumPayments, 100.00);
  assert.equal(result.delta, 0.00);
  assert.equal(result.likelyDoubled, false);
});

test("tiny rounding difference is consistent", () => {
  const result = reconcilePayment(100.00, [33.335, 33.335, 33.33]);
  assert.equal(result.mismatch, false);
});

test("partial payment shortfall is not doubled", () => {
  const result = reconcilePayment(40.00, [40.00]);
  assert.equal(result.mismatch, false);
  assert.equal(result.likelyDoubled, false);
});

test("doubled total is flagged and marked likely doubled", () => {
  const result = reconcilePayment(120.00, [60.00]);
  assert.equal(result.mismatch, true);
  assert.equal(result.sumPayments, 60.00);
  assert.equal(result.delta, 60.00);
  assert.equal(result.likelyDoubled, true);
});

test("doubled against total paid when no payment rows yet", () => {
  const result = reconcilePayment(200.00, [], 100.00);
  assert.equal(result.mismatch, true);
  assert.equal(result.likelyDoubled, true);
});

test("ordinary mismatch not close to double is not flagged doubled", () => {
  const result = reconcilePayment(70.00, [60.00]);
  assert.equal(result.mismatch, true);
  assert.equal(result.likelyDoubled, false);
});

test("zero payments and zero total paid is not doubled", () => {
  const result = reconcilePayment(0.00, []);
  assert.equal(result.mismatch, false);
  assert.equal(result.likelyDoubled, false);
});

Case studies

Auto-added payment

The store whose paid state auto-confirmed twice

A store had its "Payment accepted" order state set to trigger both an automatic payment record through validateOrder() and a separate webhook from its payment module that also confirmed the payment when the gateway callback arrived. Most of the time the two events landed close enough together that nobody looked twice, but the order_payment table quietly grew a duplicate row on every order, and the finance export from PrestaShop started running noticeably higher than the true amount received in the merchant account.

Running the reconciliation script across a month of orders surfaced the full list, each one flagged as likelyDoubled with the exact delta. Once the team saw that the two totals were consistently a clean two-to-one ratio, they found the duplicate webhook trigger, and worked through the flagged orders with the finance team to confirm and remove the extra rows one by one.

Retried webhook

The PayPal integration that retried an IPN notification

A PayPal integration processed an IPN notification for a payment, but a network hiccup meant PayPal never got a clean acknowledgment and retried the same notification a few minutes later. The module had no check for a previously seen transaction reference, so it called addOrderPayment() a second time for the exact same amount, and total_paid_real jumped straight past total_paid.

The report flagged the order immediately, since total_paid_real ended up at exactly double the sum of the two identical order_payment rows. Cross-checking with order_histories showed the paid state had been applied twice within seconds, which matched a retried webhook rather than two separate legitimate payments, so the team could confidently delete the duplicate row.

What good looks like

After this runs on a schedule, no order's total_paid_real silently drifts away from what its order_payment rows actually show. Instead you get a clear, dated report of every mismatch, with the doubled cases called out by name so staff can chase the true duplicate row first. order_payment stays the source of truth, and total_paid_real is only ever corrected after a human confirms exactly which row was spurious.

FAQ

Why did my PrestaShop order's total_paid_real become exactly double the real amount?

Order::addOrderPayment() both inserts a row into order_payment and directly increments the order's own total_paid_real column. When a partial-payment workflow triggers that method twice for the same real-world payment, for example an automatic paid order state plus a separate order_history or webservice update or payment module call, total_paid_real gets incremented twice and can end up exactly double the true sum of order_payment rows.

Is it safe to just overwrite total_paid_real with the correct value?

Not by default. A legitimate partial or split payment can have total_paid_real that is genuinely less than total_paid, so a mismatch alone does not prove a bug. Treat this as flag and report first, and only apply a corrective write after a human confirms which order_payment row, if any, is a true duplicate.

How do I detect orders where total_paid_real has doubled?

Pull the order with GET orders to read total_paid, total_paid_real, and current_state, then pull GET order_payments filtered by order_reference and sum the amount field across rows. Flag the order when total_paid_real does not equal that sum, and especially when total_paid_real is close to twice the sum or twice total_paid, which is the signature of the double-add bug.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #15769: total_paid_real wrong value (doubled). github.com/PrestaShop/PrestaShop/issues/15769
  2. PrestaShop GitHub Issue #12588: Order payment duplicated depending on order state configuration. github.com/PrestaShop/PrestaShop/issues/12588
  3. PrestaShop GitHub Issue #22118: Amount at BO/Orders is the double of the total_spent order. github.com/PrestaShop/PrestaShop/issues/22118

On the solution:

  1. PrestaShop Webservice API Reference, covering the orders and order_payments resources. devdocs.prestashop-project.org/8/webservice/reference
  2. PrestaShop Webservice Getting Started, the CRUD basics for GET, PUT, and DELETE. devdocs.prestashop-project.org/8/webservice/getting-started
  3. PrestaShop Webservice Cheat Sheet, resources and filter syntax. devdocs.prestashop-project.org/8/webservice/cheat-sheet

Stuck on a tricky one?

If you have a problem in PrestaShop orders, payments, order states, 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 doubled payment total?

If this saved you a quiet reconciliation headache or a revenue report that did not match the bank, 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