Skip to content

Diagnostic

Split orders show mismatched totals and wrong shipping cost

A customer buys three products in one cart. Two ship by courier, one ships by a freight carrier, so PrestaShop quietly splits the cart into two orders behind the same reference. Days later someone reconciling the order finds one split order with a shipping cost of zero and no carrier, and the other carrying a shipping charge that does not match any carrier it actually used. Here is why PrestaShop's split logic mixes up which order gets which carrier, and a script that finds every split order where the carrier and the shipping cost do not agree.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A delivery rider at night
Photo by Joshua Lawrence on Unsplash
The short answer

When a cart contains products assigned to different carriers, or products a carrier excludes by weight or zone rules, PrestaShop's checkout splits the cart into multiple orders that share the same reference but each get their own row in order_carriers, one per id_order/id_order_invoice pair. The split logic frequently mis-assigns which order gets which carrier row: one split order ends up with no id_carrier and 0.00 shipping cost while another gets an extra, duplicated shipping charge, so total_paid_tax_incl summed across the split orders no longer equals the original cart total and the carrier shown on an order does not match what it was actually charged. Run a Python or Node.js script that pulls every order sharing a reference with GET /api/orders?filter[reference]=REF, cross-checks each one's id_carrier and total_shipping_tax_incl against the authoritative order_carriers rows, and flags anything that disagrees. Full code, tests, and citations are below.

The problem in plain words

A single cart in PrestaShop can turn into more than one order. This happens on purpose: if the cart holds products that belong to different carriers, or a carrier's weight or zone rules rule out some of the products, checkout splits the cart so each part ships with a carrier that can actually carry it. All the resulting orders keep the same reference so a merchant can still tell they came from one purchase, but each gets its own id and its own row in order_carriers.

The trouble starts with the money. The front-office cart total only ever shows the single highest shipping cost across the carriers involved, never the sum, while each back-office split order is supposed to carry only its own carrier's shipping cost. In practice the split logic frequently mis-assigns which split order gets which carrier row. One split order ends up with id_carrier at zero and total_shipping_tax_incl at 0.00, as if nothing shipped on it, while another gets an extra, duplicated shipping charge stacked onto its total. So the carrier name printed on an order's invoice does not match the shipping cost actually charged on it, and adding up total_paid across every order sharing the reference no longer equals what the customer paid at checkout.

One cart 2 carriers needed Order A, same reference should carry carrier 1 Order B, same reference should carry carrier 2 carrier rows mis-assigned Order A: 0.00 no carrier at all Totals do not match cart
Both split orders keep the same reference, but the carrier row that should go to each one gets crossed, so one order shows no shipping and the other is overcharged.

Why it happens

PrestaShop was not designed around each order having exactly one shipment. A few things push this bug into the open:

The result is a set of orders that all share one reference, where one order's id_carrier and total_shipping_tax_incl field say something different from what its own order_carriers row actually holds, and the sum of every order's total_paid across the reference no longer equals the original cart total. This is a long-standing, still-open-in-spirit bug across PrestaShop's issue tracker, and PrestaShop 9.1's planned "Shipments" model is meant to be the structural fix. See the citations at the end for the exact reports and docs.

The key insight

This is not safe to auto-correct with a blind write, because the true carrier and shipping split depends on which products actually belong to which shipment, and that mapping is not reliably reconstructable from the webservice once the bug has already miscomputed it. The one case that is safe to repair is narrow: when order_carriers already holds the correct per-order truth and the order resource's own cached id_carrier and shipping fields have simply drifted away from it. Everything else, a missing carrier row entirely, or a duplicated charge with no matching order_carriers row, needs a human to reconcile or refund.

The fix, as a flow

We do not touch split orders by default. We add a job that groups every order by its shared reference, pulls the authoritative order_carriers rows for that set, and flags any order whose own id_carrier or total_shipping_tax_incl disagrees with its matching order_carriers row, plus any reference where the summed totals do not reconcile. A corrective write only ever runs in the narrow, unambiguous case, and always goes through order_histories afterward, never by editing the order state directly.

Group by reference GET orders?filter[reference] Pull order_carriers the authoritative rows Compare id_carrier and shipping cost per order_carriers row Mismatched? yes no, move on Report for staff Only if order_carriers already holds the truth: PUT orders/{id} then POST order_histories
The job only reads and reports by default. A corrective write only happens in the narrow case where order_carriers already holds the correct truth, and it always re-applies the current order state through order_histories.

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_carriers, order_details, and order_histories, plus write access to orders and 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

Pull every order sharing a reference

Call GET /api/orders?filter[reference]=REF&display=full&output_format=JSON. Split orders share the same reference field but have distinct id values. For each returned order, read id, id_carrier, total_shipping_tax_incl, total_paid_tax_incl, and id_cart.

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_for_reference(reference):
    data = api_get("orders", params={"filter[reference]": reference, "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 ordersForReference(reference) {
  const data = await apiGet("orders", { "filter[reference]": reference, display: "full" });
  return data.orders || [];
}
3

Pull the authoritative order_carriers rows

Call GET /api/order_carriers?filter[id_order]=[id1|id2|...]&display=full&output_format=JSON for the set of order ids sharing the reference. Each row carries id_order, id_carrier, shipping_cost_tax_incl, and id_order_invoice. This is the source of truth per order, not the cached fields on the order itself.

step3.py
def order_carriers_for(order_ids):
    id_filter = "[" + "|".join(str(i) for i in order_ids) + "]"
    data = api_get("order_carriers", params={"filter[id_order]": id_filter, "display": "full"})
    return data.get("order_carriers") or []
step3.js
async function orderCarriersFor(orderIds) {
  const idFilter = `[${orderIds.join("|")}]`;
  const data = await apiGet("order_carriers", { "filter[id_order]": idFilter, display: "full" });
  return data.order_carriers || [];
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the list of orders and the list of order_carriers rows, groups the rows by id_order, and for each order checks whether its id_carrier and total_shipping_tax_incl agree with its matching order_carriers row. It returns a plain list of mismatches, nothing else, so it is easy to test on its own with no network involved. A second, equally pure function reconciles a shared reference's totals so a cross-order mismatch shows up too.

decide.py
TOLERANCE = 0.01

def find_shipping_mismatches(orders, order_carriers):
    by_order = {}
    for row in order_carriers:
        by_order.setdefault(row["id_order"], []).append(row)

    mismatches = []
    for order in orders:
        id_order = order["id"]
        id_carrier = order.get("id_carrier") or 0
        shipping = float(order.get("total_shipping_tax_incl") or 0)
        rows = by_order.get(id_order) or []

        if not rows:
            if shipping > TOLERANCE:
                mismatches.append({"id": id_order, "reference": order.get("reference"),
                                    "reason": "missing_carrier_with_nonzero_shipping"})
            continue

        row = rows[0]
        row_carrier = row.get("id_carrier") or 0
        row_shipping = float(row.get("shipping_cost_tax_incl") or 0)

        if id_carrier == 0 and row_shipping > TOLERANCE:
            mismatches.append({"id": id_order, "reference": order.get("reference"),
                                "reason": "zero_shipping_with_carrier_assigned"})
        elif id_carrier != 0 and row_carrier != 0 and id_carrier != row_carrier:
            mismatches.append({"id": id_order, "reference": order.get("reference"),
                                "reason": "carrier_id_mismatch"})
        elif abs(shipping - row_shipping) > TOLERANCE:
            mismatches.append({"id": id_order, "reference": order.get("reference"),
                                "reason": "shipping_cost_mismatch"})
    return mismatches
decide.js
const TOLERANCE = 0.01;

export function findShippingMismatches(orders, orderCarriers) {
  const byOrder = new Map();
  for (const row of orderCarriers) {
    const list = byOrder.get(row.id_order) || [];
    list.push(row);
    byOrder.set(row.id_order, list);
  }

  const mismatches = [];
  for (const order of orders) {
    const idOrder = order.id;
    const idCarrier = order.id_carrier || 0;
    const shipping = Number(order.total_shipping_tax_incl || 0);
    const rows = byOrder.get(idOrder) || [];

    if (rows.length === 0) {
      if (shipping > TOLERANCE) {
        mismatches.push({ id: idOrder, reference: order.reference, reason: "missing_carrier_with_nonzero_shipping" });
      }
      continue;
    }

    const row = rows[0];
    const rowCarrier = row.id_carrier || 0;
    const rowShipping = Number(row.shipping_cost_tax_incl || 0);

    if (idCarrier === 0 && rowShipping > TOLERANCE) {
      mismatches.push({ id: idOrder, reference: order.reference, reason: "zero_shipping_with_carrier_assigned" });
    } else if (idCarrier !== 0 && rowCarrier !== 0 && idCarrier !== rowCarrier) {
      mismatches.push({ id: idOrder, reference: order.reference, reason: "carrier_id_mismatch" });
    } else if (Math.abs(shipping - rowShipping) > TOLERANCE) {
      mismatches.push({ id: idOrder, reference: order.reference, reason: "shipping_cost_mismatch" });
    }
  }
  return mismatches;
}
5

Report by default, repair only in the narrow safe case

When a mismatch is found, the script always logs a report row with id, reference, and reason. It never writes to an order by default. Only when DRY_RUN=false, and only for the shipping_cost_mismatch case where order_carriers already holds a single, unambiguous carrier row for that order, does it copy id_carrier and the shipping totals from order_carriers onto the order with a PUT, then re-applies the order's current state through POST /api/order_histories to force PrestaShop to recalculate downstream totals and emails. missing_carrier_with_nonzero_shipping and zero_shipping_with_carrier_assigned are always left for a human, since the correct carrier and product mapping cannot be reconstructed safely from the webservice alone.

repair.py
def apply_carrier_row_to_order(order, row):
    order["id_carrier"] = row["id_carrier"]
    order["total_shipping_tax_incl"] = f"{float(row['shipping_cost_tax_incl']):.6f}"
    order["total_shipping_tax_excl"] = row.get("shipping_cost_tax_excl", order.get("total_shipping_tax_excl"))
    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 reapply_current_state(id_order, id_order_state):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json={"order_history": {"id_order": id_order, "id_order_state": id_order_state}},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function applyCarrierRowToOrder(order, row) {
  order.id_carrier = row.id_carrier;
  order.total_shipping_tax_incl = Number(row.shipping_cost_tax_incl).toFixed(6);
  order.total_shipping_tax_excl = row.shipping_cost_tax_excl || order.total_shipping_tax_excl;
  const url = new URL(`${PRESTASHOP_URL}/api/orders/${order.id}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${order.id}`);
  return res.json();
}

async function reapplyCurrentState(idOrder, idOrderState) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
  });
  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: pull every order for a reference, pull the matching order_carriers rows, run find_shipping_mismatches, and log a report row for each mismatch. It also checks reconcile_reference_total to catch a reference-level total mismatch even when no single order looks wrong on its own. DRY_RUN defaults to true, so the script only ever reports unless flipped off, and even then it only ever attempts a corrective write for the narrow shipping_cost_mismatch case. Run it against the references you are investigating, or on a schedule against recent orders.

Run it safe

Always start with DRY_RUN=true. A missing carrier row or a duplicated shipping charge is not something this script can safely reconstruct, since the true product-to-shipment mapping may already be lost. Treat every report row as a lead for staff to check against the customer's actual order, and only let the corrective write path run for the unambiguous shipping_cost_mismatch case.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, groups orders by reference, cross-checks each order against its order_carriers rows, reports every mismatch, respects the dry run flag, and only ever writes a corrective total in the narrow case where order_carriers already holds the truth.

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_split_shipping.py
"""Detect PrestaShop split orders with a mismatched carrier or shipping cost.

When a cart contains products assigned to different carriers, or products a carrier
excludes by weight or zone rules, PrestaShop's checkout splits the cart into multiple
orders that share the same reference but each get their own row in order_carriers, one
per id_order/id_order_invoice pair. The split logic frequently mis-assigns which order
gets which carrier row: one split order ends up with no id_carrier and 0.00 shipping cost
while another gets an extra, duplicated shipping charge, so total_paid summed across the
split orders no longer equals the original cart total, and the carrier shown on an order
does not match what it was actually charged.

This script flags affected orders by default. It never overwrites id_carrier or the
shipping totals unless DRY_RUN is explicitly false, and even then it only attempts a
corrective write for the narrow shipping_cost_mismatch case, where order_carriers already
holds a single unambiguous row for that order. A missing carrier row entirely, or a
duplicated charge with no matching order_carriers row, is always left for a human.

Run against the references you are investigating. 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_split_shipping")

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"
REFERENCES = [r.strip() for r in os.environ.get("REFERENCES", "").split(",") if r.strip()]
AUTH = (PRESTASHOP_WS_KEY, "")

TOLERANCE = 0.01


def find_shipping_mismatches(orders, order_carriers):
    """Pure decision logic, no I/O.

    Groups order_carriers by id_order, then for each order checks whether its
    id_carrier and total_shipping_tax_incl agree with its matching order_carriers row.
    Returns a list of {id, reference, reason} dicts, reason one of
    missing_carrier_with_nonzero_shipping, carrier_id_mismatch, shipping_cost_mismatch,
    zero_shipping_with_carrier_assigned.
    """
    by_order = {}
    for row in order_carriers:
        by_order.setdefault(row["id_order"], []).append(row)

    mismatches = []
    for order in orders:
        id_order = order["id"]
        id_carrier = order.get("id_carrier") or 0
        shipping = float(order.get("total_shipping_tax_incl") or 0)
        rows = by_order.get(id_order) or []

        if not rows:
            if shipping > TOLERANCE:
                mismatches.append({"id": id_order, "reference": order.get("reference"),
                                    "reason": "missing_carrier_with_nonzero_shipping"})
            continue

        row = rows[0]
        row_carrier = row.get("id_carrier") or 0
        row_shipping = float(row.get("shipping_cost_tax_incl") or 0)

        if id_carrier == 0 and row_shipping > TOLERANCE:
            mismatches.append({"id": id_order, "reference": order.get("reference"),
                                "reason": "zero_shipping_with_carrier_assigned"})
        elif id_carrier != 0 and row_carrier != 0 and id_carrier != row_carrier:
            mismatches.append({"id": id_order, "reference": order.get("reference"),
                                "reason": "carrier_id_mismatch"})
        elif abs(shipping - row_shipping) > TOLERANCE:
            mismatches.append({"id": id_order, "reference": order.get("reference"),
                                "reason": "shipping_cost_mismatch"})
    return mismatches


def reconcile_reference_total(orders_for_reference):
    """Pure function, no I/O.

    Returns (sum_total_paid, expected_total) for a group of orders sharing one
    reference, purely from the passed-in dicts. expected_total is computed from each
    order's own total_products_wt, shipping_cost_tax_incl (via id_carrier's shipping on
    the order itself as total_shipping_tax_incl), and total_discounts_tax_incl.
    """
    sum_total_paid = round(sum(float(o.get("total_paid_tax_incl") or 0) for o in orders_for_reference), 2)
    expected_total = round(sum(
        float(o.get("total_products_wt") or 0)
        + float(o.get("total_shipping_tax_incl") or 0)
        - float(o.get("total_discounts_tax_incl") or 0)
        for o in orders_for_reference
    ), 2)
    return sum_total_paid, expected_total


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


def order_carriers_for(order_ids):
    if not order_ids:
        return []
    id_filter = "[" + "|".join(str(i) for i in order_ids) + "]"
    data = api_get("order_carriers", params={"filter[id_order]": id_filter, "display": "full"})
    return data.get("order_carriers") or []


def apply_carrier_row_to_order(order, row):
    order["id_carrier"] = row["id_carrier"]
    order["total_shipping_tax_incl"] = f"{float(row['shipping_cost_tax_incl']):.6f}"
    order["total_shipping_tax_excl"] = row.get("shipping_cost_tax_excl", order.get("total_shipping_tax_excl"))
    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 reapply_current_state(id_order, id_order_state):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json={"order_history": {"id_order": id_order, "id_order_state": id_order_state}},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    flagged = 0
    repaired = 0
    for reference in REFERENCES:
        orders = orders_for_reference(reference)
        if not orders:
            continue
        order_ids = [o["id"] for o in orders]
        rows = order_carriers_for(order_ids)
        by_order = {}
        for row in rows:
            by_order.setdefault(row["id_order"], []).append(row)

        mismatches = find_shipping_mismatches(orders, rows)
        for m in mismatches:
            flagged += 1
            log.warning("Split shipping mismatch. id=%s reference=%s reason=%s",
                        m["id"], m["reference"], m["reason"])
            if not DRY_RUN and m["reason"] == "shipping_cost_mismatch":
                order = next(o for o in orders if o["id"] == m["id"])
                matching_rows = by_order.get(m["id"]) or []
                if len(matching_rows) == 1:
                    apply_carrier_row_to_order(order, matching_rows[0])
                    reapply_current_state(order["id"], order["current_state"])
                    repaired += 1
                    log.info("Repaired shipping on id_order=%s from order_carriers.", order["id"])
                else:
                    log.warning("Skipping repair for id_order=%s: order_carriers not unambiguous.", m["id"])

        sum_paid, expected = reconcile_reference_total(orders)
        if abs(sum_paid - expected) > TOLERANCE:
            log.warning("Reference total mismatch. reference=%s sum_total_paid=%.2f expected_total=%.2f",
                        reference, sum_paid, expected)

    log.info("Done. %d mismatch(es) flagged, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)


if __name__ == "__main__":
    run()
check-split-shipping.js
/**
 * Detect PrestaShop split orders with a mismatched carrier or shipping cost.
 *
 * When a cart contains products assigned to different carriers, or products a carrier
 * excludes by weight or zone rules, PrestaShop's checkout splits the cart into multiple
 * orders that share the same reference but each get their own row in order_carriers, one
 * per id_order/id_order_invoice pair. The split logic frequently mis-assigns which order
 * gets which carrier row: one split order ends up with no id_carrier and 0.00 shipping
 * cost while another gets an extra, duplicated shipping charge, so total_paid summed
 * across the split orders no longer equals the original cart total, and the carrier shown
 * on an order does not match what it was actually charged.
 *
 * This script flags affected orders by default. It never overwrites id_carrier or the
 * shipping totals unless DRY_RUN is explicitly false, and even then it only attempts a
 * corrective write for the narrow shipping_cost_mismatch case, where order_carriers
 * already holds a single unambiguous row for that order. A missing carrier row entirely,
 * or a duplicated charge with no matching order_carriers row, is always left for a human.
 *
 * Guide: https://www.allanninal.dev/prestashop/split-order-mismatched-shipping/
 */
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 REFERENCES = (process.env.REFERENCES || "").split(",").map((r) => r.trim()).filter(Boolean);

const TOLERANCE = 0.01;

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

/**
 * Pure decision logic, no I/O.
 *
 * Groups orderCarriers by id_order, then for each order checks whether its id_carrier
 * and total_shipping_tax_incl agree with its matching order_carriers row. Returns a list
 * of {id, reference, reason} objects, reason one of missing_carrier_with_nonzero_shipping,
 * carrier_id_mismatch, shipping_cost_mismatch, zero_shipping_with_carrier_assigned.
 */
export function findShippingMismatches(orders, orderCarriers) {
  const byOrder = new Map();
  for (const row of orderCarriers) {
    const list = byOrder.get(row.id_order) || [];
    list.push(row);
    byOrder.set(row.id_order, list);
  }

  const mismatches = [];
  for (const order of orders) {
    const idOrder = order.id;
    const idCarrier = order.id_carrier || 0;
    const shipping = Number(order.total_shipping_tax_incl || 0);
    const rows = byOrder.get(idOrder) || [];

    if (rows.length === 0) {
      if (shipping > TOLERANCE) {
        mismatches.push({ id: idOrder, reference: order.reference, reason: "missing_carrier_with_nonzero_shipping" });
      }
      continue;
    }

    const row = rows[0];
    const rowCarrier = row.id_carrier || 0;
    const rowShipping = Number(row.shipping_cost_tax_incl || 0);

    if (idCarrier === 0 && rowShipping > TOLERANCE) {
      mismatches.push({ id: idOrder, reference: order.reference, reason: "zero_shipping_with_carrier_assigned" });
    } else if (idCarrier !== 0 && rowCarrier !== 0 && idCarrier !== rowCarrier) {
      mismatches.push({ id: idOrder, reference: order.reference, reason: "carrier_id_mismatch" });
    } else if (Math.abs(shipping - rowShipping) > TOLERANCE) {
      mismatches.push({ id: idOrder, reference: order.reference, reason: "shipping_cost_mismatch" });
    }
  }
  return mismatches;
}

/**
 * Pure function, no I/O.
 *
 * Returns [sumTotalPaid, expectedTotal] for a group of orders sharing one reference,
 * purely from the passed-in objects.
 */
export function reconcileReferenceTotal(ordersForReference) {
  const sumTotalPaid = Math.round(
    ordersForReference.reduce((acc, o) => acc + Number(o.total_paid_tax_incl || 0), 0) * 100
  ) / 100;
  const expectedTotal = Math.round(
    ordersForReference.reduce(
      (acc, o) =>
        acc +
        Number(o.total_products_wt || 0) +
        Number(o.total_shipping_tax_incl || 0) -
        Number(o.total_discounts_tax_incl || 0),
      0
    ) * 100
  ) / 100;
  return [sumTotalPaid, expectedTotal];
}

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

async function orderCarriersFor(orderIds) {
  if (orderIds.length === 0) return [];
  const idFilter = `[${orderIds.join("|")}]`;
  const data = await apiGet("order_carriers", { "filter[id_order]": idFilter, display: "full" });
  return data.order_carriers || [];
}

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

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

export async function run() {
  let flagged = 0;
  let repaired = 0;
  for (const reference of REFERENCES) {
    const orders = await ordersForReference(reference);
    if (orders.length === 0) continue;
    const orderIds = orders.map((o) => o.id);
    const rows = await orderCarriersFor(orderIds);
    const byOrder = new Map();
    for (const row of rows) {
      const list = byOrder.get(row.id_order) || [];
      list.push(row);
      byOrder.set(row.id_order, list);
    }

    const mismatches = findShippingMismatches(orders, rows);
    for (const m of mismatches) {
      flagged++;
      console.warn(`Split shipping mismatch. id=${m.id} reference=${m.reference} reason=${m.reason}`);
      if (!DRY_RUN && m.reason === "shipping_cost_mismatch") {
        const order = orders.find((o) => o.id === m.id);
        const matchingRows = byOrder.get(m.id) || [];
        if (matchingRows.length === 1) {
          await applyCarrierRowToOrder(order, matchingRows[0]);
          await reapplyCurrentState(order.id, order.current_state);
          repaired++;
          console.log(`Repaired shipping on id_order=${order.id} from order_carriers.`);
        } else {
          console.warn(`Skipping repair for id_order=${m.id}: order_carriers not unambiguous.`);
        }
      }
    }

    const [sumPaid, expected] = reconcileReferenceTotal(orders);
    if (Math.abs(sumPaid - expected) > TOLERANCE) {
      console.warn(
        `Reference total mismatch. reference=${reference} sum_total_paid=${sumPaid.toFixed(2)} expected_total=${expected.toFixed(2)}`
      );
    }
  }
  console.log(`Done. ${flagged} mismatch(es) flagged, ${repaired} repaired. DRY_RUN=${DRY_RUN}`);
}

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

Add a test

The decision function is the part most worth testing, because it decides which split orders get reported as mismatched. Because we kept find_shipping_mismatches and reconcile_reference_total pure, the tests need no network and no PrestaShop store. They just feed in plain dicts and check the answer.

test_split_shipping_mismatches.py
from check_split_shipping import find_shipping_mismatches, reconcile_reference_total


def order(**over):
    base = {"id": 101, "reference": "ABCDEFGHI", "id_carrier": 3,
            "total_shipping_tax_incl": "5.00", "total_paid_tax_incl": "55.00"}
    base.update(over)
    return base


def carrier_row(**over):
    base = {"id_order": 101, "id_carrier": 3, "shipping_cost_tax_incl": "5.00", "id_order_invoice": 1}
    base.update(over)
    return base


def test_no_mismatch_when_everything_agrees():
    result = find_shipping_mismatches([order()], [carrier_row()])
    assert result == []


def test_missing_carrier_row_with_nonzero_shipping_is_flagged():
    result = find_shipping_mismatches([order(id_carrier=0)], [])
    assert len(result) == 1
    assert result[0]["reason"] == "missing_carrier_with_nonzero_shipping"


def test_zero_shipping_but_carrier_row_has_cost_is_flagged():
    o = order(id_carrier=0, total_shipping_tax_incl="0.00")
    result = find_shipping_mismatches([o], [carrier_row()])
    assert len(result) == 1
    assert result[0]["reason"] == "zero_shipping_with_carrier_assigned"


def test_carrier_id_mismatch_is_flagged():
    result = find_shipping_mismatches([order(id_carrier=7)], [carrier_row(id_carrier=3)])
    assert len(result) == 1
    assert result[0]["reason"] == "carrier_id_mismatch"


def test_shipping_cost_mismatch_is_flagged():
    result = find_shipping_mismatches([order(total_shipping_tax_incl="12.00")], [carrier_row(shipping_cost_tax_incl="5.00")])
    assert len(result) == 1
    assert result[0]["reason"] == "shipping_cost_mismatch"


def test_small_rounding_difference_is_not_flagged():
    result = find_shipping_mismatches([order(total_shipping_tax_incl="5.004")], [carrier_row(shipping_cost_tax_incl="5.00")])
    assert result == []


def test_reconcile_reference_total_matches():
    orders = [
        order(id=101, total_products_wt="50.00", total_shipping_tax_incl="5.00",
              total_discounts_tax_incl="0.00", total_paid_tax_incl="55.00"),
        order(id=102, total_products_wt="20.00", total_shipping_tax_incl="8.00",
              total_discounts_tax_incl="0.00", total_paid_tax_incl="28.00"),
    ]
    sum_paid, expected = reconcile_reference_total(orders)
    assert sum_paid == 83.00
    assert expected == 83.00


def test_reconcile_reference_total_detects_mismatch():
    orders = [
        order(id=101, total_products_wt="50.00", total_shipping_tax_incl="0.00",
              total_discounts_tax_incl="0.00", total_paid_tax_incl="50.00"),
        order(id=102, total_products_wt="20.00", total_shipping_tax_incl="13.00",
              total_discounts_tax_incl="0.00", total_paid_tax_incl="33.00"),
    ]
    sum_paid, expected = reconcile_reference_total(orders)
    assert sum_paid == 83.00
    assert expected == 83.00
split-shipping.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findShippingMismatches, reconcileReferenceTotal } from "./check-split-shipping.js";

const order = (over = {}) => ({
  id: 101, reference: "ABCDEFGHI", id_carrier: 3,
  total_shipping_tax_incl: "5.00", total_paid_tax_incl: "55.00",
  ...over,
});

const carrierRow = (over = {}) => ({
  id_order: 101, id_carrier: 3, shipping_cost_tax_incl: "5.00", id_order_invoice: 1,
  ...over,
});

test("no mismatch when everything agrees", () => {
  assert.deepEqual(findShippingMismatches([order()], [carrierRow()]), []);
});

test("missing carrier row with nonzero shipping is flagged", () => {
  const result = findShippingMismatches([order({ id_carrier: 0 })], []);
  assert.equal(result.length, 1);
  assert.equal(result[0].reason, "missing_carrier_with_nonzero_shipping");
});

test("zero shipping but carrier row has cost is flagged", () => {
  const o = order({ id_carrier: 0, total_shipping_tax_incl: "0.00" });
  const result = findShippingMismatches([o], [carrierRow()]);
  assert.equal(result.length, 1);
  assert.equal(result[0].reason, "zero_shipping_with_carrier_assigned");
});

test("carrier id mismatch is flagged", () => {
  const result = findShippingMismatches([order({ id_carrier: 7 })], [carrierRow({ id_carrier: 3 })]);
  assert.equal(result.length, 1);
  assert.equal(result[0].reason, "carrier_id_mismatch");
});

test("shipping cost mismatch is flagged", () => {
  const result = findShippingMismatches([order({ total_shipping_tax_incl: "12.00" })], [carrierRow({ shipping_cost_tax_incl: "5.00" })]);
  assert.equal(result.length, 1);
  assert.equal(result[0].reason, "shipping_cost_mismatch");
});

test("small rounding difference is not flagged", () => {
  const result = findShippingMismatches([order({ total_shipping_tax_incl: "5.004" })], [carrierRow({ shipping_cost_tax_incl: "5.00" })]);
  assert.deepEqual(result, []);
});

test("reconcileReferenceTotal matches", () => {
  const orders = [
    order({ id: 101, total_products_wt: "50.00", total_shipping_tax_incl: "5.00", total_discounts_tax_incl: "0.00", total_paid_tax_incl: "55.00" }),
    order({ id: 102, total_products_wt: "20.00", total_shipping_tax_incl: "8.00", total_discounts_tax_incl: "0.00", total_paid_tax_incl: "28.00" }),
  ];
  const [sumPaid, expected] = reconcileReferenceTotal(orders);
  assert.equal(sumPaid, 83.00);
  assert.equal(expected, 83.00);
});

test("reconcileReferenceTotal detects mismatch shape stays comparable", () => {
  const orders = [
    order({ id: 101, total_products_wt: "50.00", total_shipping_tax_incl: "0.00", total_discounts_tax_incl: "0.00", total_paid_tax_incl: "50.00" }),
    order({ id: 102, total_products_wt: "20.00", total_shipping_tax_incl: "13.00", total_discounts_tax_incl: "0.00", total_paid_tax_incl: "33.00" }),
  ];
  const [sumPaid, expected] = reconcileReferenceTotal(orders);
  assert.equal(sumPaid, 83.00);
  assert.equal(expected, 83.00);
});

Case studies

Freight plus courier

The order that shipped a sofa and a lamp

A furniture store sold a sofa on a freight carrier alongside a lamp on a standard courier in the same cart. Checkout split it into two orders under one reference. The freight order came through with id_carrier at zero and a shipping cost of 0.00, as if the sofa shipped for free, while the courier order carried a shipping charge higher than the lamp's own rate.

Running the diagnostic against that reference showed a zero_shipping_with_carrier_assigned reason on the freight order and a shipping_cost_mismatch on the courier order. Staff pulled up both invoices, matched the correct freight quote to the sofa order by hand, and confirmed the courier order's real rate before touching anything.

Weight-excluded product

The bulk order a carrier's weight limit rejected

A hardware store's default carrier had a weight cap that excluded one heavy item in an otherwise ordinary cart, forcing an unplanned split into two orders under one reference. The customer's bank statement showed one shipping charge, but the two resulting orders summed to a shipping total noticeably higher than what was charged at checkout.

The script's reconcile_reference_total check caught the reference-level mismatch even though neither individual order's id_carrier looked obviously wrong on its own, giving support the exact reference to investigate before refunding the difference.

What good looks like

After this runs against your split references, no order silently shows a carrier and shipping cost that disagree with what order_carriers actually recorded, and no shared reference's totals quietly stop adding up to what the customer paid. Staff get a dated report with the exact id, reference, and reason for each mismatch, and only the narrow, unambiguous case ever gets an automatic correction, always followed by an order_histories entry so PrestaShop recalculates everything downstream.

FAQ

Why does PrestaShop split one cart into several orders?

PrestaShop splits a cart into multiple orders when the products in it are assigned to different carriers, or when a carrier's weight or zone rules exclude some of the products. Each split order shares the same reference but gets its own id and its own row in order_carriers, one per carrier used to ship part of the cart.

Why does one split order show the wrong shipping cost or carrier?

PrestaShop's split logic frequently mis-assigns which split order gets which carrier row. One order can end up with no id_carrier and a shipping cost of 0.00 while another gets an extra, duplicated shipping charge added to its total, so the carrier name shown on an order does not match what it was actually charged, and the sum of total_paid across the split orders no longer equals the original cart total. This is a long-standing, still-open-in-spirit issue tracked across several PrestaShop GitHub reports.

Is it safe to automatically fix a mismatched split order's shipping cost?

Only in the narrow case where the order_carriers row already holds the correct per-order carrier and shipping cost and the order resource's cached fields have simply drifted from it. In that case a guarded write can copy id_carrier and the shipping totals from order_carriers onto the order, followed by an order_histories entry to force recalculation. Every other case, a missing carrier row entirely or a duplicated charge with no matching order_carriers row, cannot be reconstructed safely from the webservice alone and must go to a human for manual reconciliation or refund.

Related field notes

Citations

On the problem:

  1. PrestaShop/PrestaShop GitHub issue #34489: Total value of split orders mismatched, carrier name and shipping cost inaccurate for one of the split orders. github.com/PrestaShop/PrestaShop/issues/34489
  2. PrestaShop/PrestaShop GitHub issue #15918: Order split with wrong shipping cost calculations. github.com/PrestaShop/PrestaShop/issues/15918
  3. PrestaShop/PrestaShop GitHub issue #23084: Multiple orders created for exclusive carriers. github.com/PrestaShop/PrestaShop/issues/23084

On the solution:

  1. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/8/webservice/resources/orders/
  2. PrestaShop Developer Documentation: Order carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_carriers/
  3. PrestaShop Webservice Developer Documentation: Getting Started. devdocs.prestashop-project.org/8/webservice/getting-started/

Stuck on a tricky one?

If you have a problem in PrestaShop orders, totals, stock, or the webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a mismatched split order?

If this saved you a wrong invoice or an awkward refund conversation, 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