Skip to content

Diagnostic Orders & Order States

Order reaches a paid state despite the product being out of stock

A customer checks out, the payment module confirms the charge, and the order lands on Payment accepted. Only the product sold out a few seconds earlier, from a different order, and nobody re-checked. The order is financially paid and logically broken at the same time. Here is why PrestaShop lets this slip through, how to find every order it already happened to, and a script that flags the damage without touching a single euro that was already captured.

Python and Node.js PrestaShop Webservice API Report only (no financial writes)
Holding a card and a phone
Photo by Nathana Reboucas on Unsplash
The short answer

PrestaShop checks stock when a product is added to the cart, but it does not re-verify stock_available against the cart contents at the final "Order with obligation to pay" step, and payment modules such as COD or Mollie can call validateOrder() with a paid id_order_state without PrestaShop re-checking out_of_stock first. If stock is depleted by a concurrent order in that window, the order still gets written as paid. Run a small Python or Node.js script that pulls the paid order_states, lists orders currently sitting in one of them, reads each order's lines from order_details, and checks the matching stock_availables row. An order line is flagged when the order is paid, the policy denies backorders, and quantity is 0 or lower. The script only reports; a human decides what happens next. Full code, tests, and the decision function are below.

The problem in plain words

When a shopper adds a product to their cart, PrestaShop looks at stock_available.quantity and the product's out_of_stock setting and decides whether the add is allowed. That check happens once, at that moment.

What it does not do is happen again. Not at the "Order with obligation to pay" step, and not inside a payment module's validateOrder() callback, which is the code path that actually writes the order row and its first order_histories entry with a paid id_order_state. Between the moment the cart was built and the moment payment is confirmed, another order can sell the last unit. Or a module like cash on delivery or Mollie calls validateOrder() straight through to a paid state without PrestaShop asking whether the product is still sellable. Either way, the order gets a paid current_state and a normal-looking order_histories row, while stock_available.quantity for that product now sits at zero or below with backorders denied. The money is real and captured. The line item never should have sold.

Add to cart stock checked once Stock depleted concurrent order sells the last unit validateOrder never re-checks validateOrder() writes paid state no stock re-check Order paid stock is 0, deny
Stock is only ever checked at add-to-cart time. Nothing between there and a confirmed payment asks again whether the product is still sellable.

Why it happens

This is a long-standing gap in PrestaShop core, confirmed against the project's own issue tracker, not a one-off store misconfiguration. A few concrete ways stores end up with a paid order sitting on depleted stock:

See the citations at the end for the exact issue threads this behavior is reported and reproduced in.

The key insight

Not every paid order with an empty stock row is a defect. If the product's out_of_stock policy is 1 (allow backorders), a negative or zero quantity on a paid order is expected and intentional; the store chose to keep selling. The order line worth flagging is the one where out_of_stock is 0 (deny backorders) and quantity is 0 or below on an order that is already paid. That single condition, paid plus deny plus insufficient stock, is what separates a real oversell from a normal backorder sale, and it is the whole decision our script makes.

The fix, as a flow

We never touch the payment, the order state, or the stock row. The script pulls the set of paid order_states, lists orders currently sitting in one of them, reads each order's lines from order_details, and checks the matching stock_availables row for each product and combination. Every match is written to a report row. Only with a human-approved review state id and DRY_RUN off does it add an order_histories entry moving the order to an existing manual-review state, never a new paid or unpaid transition.

Auditor job runs on demand List paid orders order_states paid=1, then orders in that state Read lines and stock order_details, stock_availables Deny and qty <= 0? yes no, backorder ok, skip Report, then optional review state
Only paid orders with denied backorders and insufficient stock get reported, and only a human-approved review state is ever posted through order_histories.

Build it step by step

1

Enable the Webservice API and get a key

In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the order_states, orders, order_details, stock_availables, and order_histories resources. 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, change to false only with a human-approved review_state_id
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, change to false only with a human-approved review_state_id
2

Talk to the Webservice API

Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and POST and raises on a bad status.

step2.py
import os, requests

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]

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

def api_post(path, body):
    r = requests.post(
        f"{BASE_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPost(path, body) {
  const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
    method: "POST",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
3

Find the paid order states, then the orders sitting in them

Ask order_states for the ones flagged paid=1, such as "Payment accepted" and "Payment accepted (COD)". Then ask orders for the ones whose current_state is in that set, within your audit window.

step3.py
def paid_state_ids():
    data = api_get("order_states", {"filter[paid]": "1", "display": "full"})
    states = data.get("order_states") or []
    return [int(s["id"]) for s in states]

def paid_orders(paid_ids, date_from, date_to):
    ids_filter = "[" + "|".join(str(i) for i in paid_ids) + "]"
    data = api_get("orders", {
        "filter[current_state]": ids_filter,
        "display": "full",
        "date": "1",
        "filter[date_add]": f"[{date_from},{date_to}]",
    })
    return data.get("orders") or []
step3.js
async function paidStateIds() {
  const data = await apiGet("order_states", { "filter[paid]": "1", display: "full" });
  const states = data.order_states || [];
  return states.map((s) => Number(s.id));
}

async function paidOrders(paidIds, dateFrom, dateTo) {
  const idsFilter = "[" + paidIds.join("|") + "]";
  const data = await apiGet("orders", {
    "filter[current_state]": idsFilter,
    display: "full",
    date: "1",
    "filter[date_add]": `[${dateFrom},${dateTo}]`,
  });
  return data.orders || [];
}
4

Read each order's lines and the matching stock rows

For each order, pull its lines from order_details, then for each product and combination pair, look up the matching stock_availables row to get the current quantity and out_of_stock policy.

step4.py
def order_lines(order_id):
    data = api_get("order_details", {"filter[id_order]": order_id, "display": "full"})
    rows = data.get("order_details") or []
    return [{
        "productId": int(r["product_id"]),
        "productAttributeId": int(r.get("product_attribute_id") or 0),
        "productQuantity": int(r["product_quantity"]),
    } for r in rows]

def stock_for_line(product_id, product_attribute_id):
    data = api_get("stock_availables", {
        "filter[id_product]": product_id,
        "filter[id_product_attribute]": product_attribute_id,
        "display": "full",
    })
    rows = data.get("stock_availables") or []
    if not rows:
        return None
    row = rows[0]
    return {"quantity": int(row["quantity"]), "outOfStock": int(row["out_of_stock"])}
step4.js
async function orderLines(orderId) {
  const data = await apiGet("order_details", { "filter[id_order]": orderId, display: "full" });
  const rows = data.order_details || [];
  return rows.map((r) => ({
    productId: Number(r.product_id),
    productAttributeId: Number(r.product_attribute_id || 0),
    productQuantity: Number(r.product_quantity),
  }));
}

async function stockForLine(productId, productAttributeId) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": productId,
    "filter[id_product_attribute]": productAttributeId,
    display: "full",
  });
  const rows = data.stock_availables || [];
  if (rows.length === 0) return null;
  const row = rows[0];
  return { quantity: Number(row.quantity), outOfStock: Number(row.out_of_stock) };
}
5

Decide, with one pure function

Keep the decision in its own function that takes the order id, its current state, the set of paid state ids, its lines, and a map of stock by line key, and returns whether to flag it and why. An order that is not paid is never flagged, no matter what stock says. On a paid order, a line is only flagged when the policy denies backorders (out_of_stock equal to 0) and stock is insufficient for what was ordered.

decide.py
def decide_out_of_stock_paid_flag(order_id, current_state_id, paid_state_ids, order_lines, stock_by_line_key):
    is_paid = current_state_id in paid_state_ids
    if not is_paid:
        return {"flagged": False, "reasons": []}

    reasons = []
    for line in order_lines:
        key = f"{line['productId']}:{line['productAttributeId']}"
        stock = stock_by_line_key.get(key)
        if stock is None:
            continue
        deny_backorder = stock["outOfStock"] == 0
        insufficient = stock["quantity"] < line["productQuantity"] or stock["quantity"] <= 0
        if deny_backorder and insufficient:
            reasons.append(
                f"line {key}: qty {stock['quantity']} < needed {line['productQuantity']}, backorders denied"
            )

    return {"flagged": len(reasons) > 0, "reasons": reasons}
decide.js
export function decideOutOfStockPaidFlag({ orderId, currentStateId, paidStateIds, orderLines, stockByLineKey }) {
  const isPaid = paidStateIds.includes(currentStateId);
  if (!isPaid) return { flagged: false, reasons: [] };

  const reasons = [];
  for (const line of orderLines) {
    const key = `${line.productId}:${line.productAttributeId}`;
    const stock = stockByLineKey.get(key);
    if (!stock) continue;
    const denyBackorder = stock.outOfStock === 0;
    const insufficient = stock.quantity < line.productQuantity || stock.quantity <= 0;
    if (denyBackorder && insufficient) {
      reasons.push(`line ${key}: qty ${stock.quantity} < needed ${line.productQuantity}, backorders denied`);
    }
  }

  return { flagged: reasons.length > 0, reasons };
}
6

Report, and only optionally move to a review state

Always write a report row for every flagged line. Never PATCH the order's current_state directly, and never invent a new paid or unpaid transition. The only optional write is an order_histories entry that moves the order to an existing "Awaiting stock replenishment" or manual-review state, and it only fires when DRY_RUN is false and a human has supplied the review_state_id. Otherwise the script only logs the payload it would have sent.

Run it safe

Always start with DRY_RUN=true. The payment is already captured, so this script never cancels, never refunds, and never edits orders.current_state directly. Order state changes only ever go through order_histories, and only to a state a human already approved.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and the only write it can ever make is an order_histories entry to a human-approved review state.

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.
audit_paid_out_of_stock.py
"""Find PrestaShop orders that reached a paid state despite the product being out of stock.

PrestaShop checks stock when an item is added to the cart, but never re-verifies
stock_available against the cart at the final checkout step or inside a payment
module's validateOrder() callback. If stock is depleted by a concurrent order, or a
module writes a paid state directly, the order ends up paid while the product's
out_of_stock policy denies backorders and quantity is 0 or lower.

This script only reports. The optional, DRY_RUN-guarded corrective step only ever adds
an order_histories entry to an existing, human-approved review state; it never edits
orders.current_state directly and never invents a new paid or unpaid transition.
Safe to run again and again.
"""
import os
import time
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audit_paid_out_of_stock")

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUDIT_WINDOW_DAYS = int(os.environ.get("AUDIT_WINDOW_DAYS", "30"))
REVIEW_STATE_ID = os.environ.get("REVIEW_STATE_ID")  # human-approved id_order_state, optional


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


def api_post(path, body):
    r = requests.post(
        f"{BASE_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


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


def paid_orders(paid_ids, date_from, date_to):
    ids_filter = "[" + "|".join(str(i) for i in paid_ids) + "]"
    data = api_get("orders", {
        "filter[current_state]": ids_filter,
        "display": "full",
        "date": "1",
        "filter[date_add]": f"[{date_from},{date_to}]",
    })
    return data.get("orders") or []


def order_lines(order_id):
    data = api_get("order_details", {"filter[id_order]": order_id, "display": "full"})
    rows = data.get("order_details") or []
    return [{
        "productId": int(r["product_id"]),
        "productAttributeId": int(r.get("product_attribute_id") or 0),
        "productQuantity": int(r["product_quantity"]),
    } for r in rows]


def stock_for_line(product_id, product_attribute_id):
    data = api_get("stock_availables", {
        "filter[id_product]": product_id,
        "filter[id_product_attribute]": product_attribute_id,
        "display": "full",
    })
    rows = data.get("stock_availables") or []
    if not rows:
        return None
    row = rows[0]
    return {"quantity": int(row["quantity"]), "outOfStock": int(row["out_of_stock"])}


def decide_out_of_stock_paid_flag(order_id, current_state_id, paid_state_ids_set, order_lines_list, stock_by_line_key):
    is_paid = current_state_id in paid_state_ids_set
    if not is_paid:
        return {"flagged": False, "reasons": []}

    reasons = []
    for line in order_lines_list:
        key = f"{line['productId']}:{line['productAttributeId']}"
        stock = stock_by_line_key.get(key)
        if stock is None:
            continue
        deny_backorder = stock["outOfStock"] == 0
        insufficient = stock["quantity"] < line["productQuantity"] or stock["quantity"] <= 0
        if deny_backorder and insufficient:
            reasons.append(
                f"line {key}: qty {stock['quantity']} < needed {line['productQuantity']}, backorders denied"
            )

    return {"flagged": len(reasons) > 0, "reasons": reasons}


def post_review_history(order_id, review_state_id):
    body = {"order_history": {"id_order": order_id, "id_order_state": review_state_id}}
    if DRY_RUN or not review_state_id:
        log.info("Dry run (or no review_state_id): would POST order_histories %s", body)
        return None
    return api_post("order_histories", body)


def run():
    paid_ids = paid_state_ids()
    date_to = time.strftime("%Y-%m-%d")
    date_from = time.strftime(
        "%Y-%m-%d", time.localtime(time.time() - AUDIT_WINDOW_DAYS * 86400)
    )
    flagged = 0
    for order in paid_orders(paid_ids, date_from, date_to):
        order_id = int(order["id"])
        current_state_id = int(order["current_state"])
        lines = order_lines(order_id)
        stock_by_key = {}
        for line in lines:
            key = f"{line['productId']}:{line['productAttributeId']}"
            stock = stock_for_line(line["productId"], line["productAttributeId"])
            if stock is not None:
                stock_by_key[key] = stock

        decision = decide_out_of_stock_paid_flag(order_id, current_state_id, paid_ids, lines, stock_by_key)
        if not decision["flagged"]:
            continue

        for reason in decision["reasons"]:
            log.warning("Order %s flagged: %s", order_id, reason)
        if REVIEW_STATE_ID:
            post_review_history(order_id, int(REVIEW_STATE_ID))
        flagged += 1

    log.info("Done. %d order(s) flagged for paid-despite-out-of-stock.", flagged)


if __name__ == "__main__":
    run()
audit-paid-out-of-stock.js
/**
 * Find PrestaShop orders that reached a paid state despite the product being out of stock.
 *
 * PrestaShop checks stock when an item is added to the cart, but never re-verifies
 * stock_available against the cart at the final checkout step or inside a payment
 * module's validateOrder() callback. If stock is depleted by a concurrent order, or a
 * module writes a paid state directly, the order ends up paid while the product's
 * out_of_stock policy denies backorders and quantity is 0 or lower.
 *
 * This script only reports. The optional, DRY_RUN-guarded corrective step only ever adds
 * an order_histories entry to an existing, human-approved review state; it never edits
 * orders.current_state directly and never invents a new paid or unpaid transition.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/paid-order-despite-out-of-stock-product/
 */
import { pathToFileURL } from "node:url";

const BASE_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const AUDIT_WINDOW_DAYS = Number(process.env.AUDIT_WINDOW_DAYS || 30);
const REVIEW_STATE_ID = process.env.REVIEW_STATE_ID; // human-approved id_order_state, optional

export function decideOutOfStockPaidFlag({ orderId, currentStateId, paidStateIds, orderLines, stockByLineKey }) {
  const isPaid = paidStateIds.includes(currentStateId);
  if (!isPaid) return { flagged: false, reasons: [] };

  const reasons = [];
  for (const line of orderLines) {
    const key = `${line.productId}:${line.productAttributeId}`;
    const stock = stockByLineKey.get(key);
    if (!stock) continue;
    const denyBackorder = stock.outOfStock === 0;
    const insufficient = stock.quantity < line.productQuantity || stock.quantity <= 0;
    if (denyBackorder && insufficient) {
      reasons.push(`line ${key}: qty ${stock.quantity} < needed ${line.productQuantity}, backorders denied`);
    }
  }

  return { flagged: reasons.length > 0, reasons };
}

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

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPost(path, body) {
  const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
    method: "POST",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

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

async function paidOrders(paidIds, dateFrom, dateTo) {
  const idsFilter = "[" + paidIds.join("|") + "]";
  const data = await apiGet("orders", {
    "filter[current_state]": idsFilter,
    display: "full",
    date: "1",
    "filter[date_add]": `[${dateFrom},${dateTo}]`,
  });
  return data.orders || [];
}

async function orderLines(orderId) {
  const data = await apiGet("order_details", { "filter[id_order]": orderId, display: "full" });
  const rows = data.order_details || [];
  return rows.map((r) => ({
    productId: Number(r.product_id),
    productAttributeId: Number(r.product_attribute_id || 0),
    productQuantity: Number(r.product_quantity),
  }));
}

async function stockForLine(productId, productAttributeId) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": productId,
    "filter[id_product_attribute]": productAttributeId,
    display: "full",
  });
  const rows = data.stock_availables || [];
  if (rows.length === 0) return null;
  const row = rows[0];
  return { quantity: Number(row.quantity), outOfStock: Number(row.out_of_stock) };
}

async function postReviewHistory(orderId, reviewStateId) {
  const body = { order_history: { id_order: orderId, id_order_state: reviewStateId } };
  if (DRY_RUN || !reviewStateId) {
    console.log(`Dry run (or no review_state_id): would POST order_histories`, body);
    return null;
  }
  return apiPost("order_histories", body);
}

function isoDate(date) {
  return date.toISOString().slice(0, 10);
}

export async function run() {
  const paidIds = await paidStateIds();
  const now = new Date();
  const dateTo = isoDate(now);
  const dateFrom = isoDate(new Date(now.getTime() - AUDIT_WINDOW_DAYS * 86400 * 1000));

  let flagged = 0;
  const orders = await paidOrders(paidIds, dateFrom, dateTo);
  for (const order of orders) {
    const orderId = Number(order.id);
    const currentStateId = Number(order.current_state);
    const lines = await orderLines(orderId);
    const stockByLineKey = new Map();
    for (const line of lines) {
      const key = `${line.productId}:${line.productAttributeId}`;
      const stock = await stockForLine(line.productId, line.productAttributeId);
      if (stock) stockByLineKey.set(key, stock);
    }

    const decision = decideOutOfStockPaidFlag({
      orderId,
      currentStateId,
      paidStateIds: paidIds,
      orderLines: lines,
      stockByLineKey,
    });
    if (!decision.flagged) continue;

    for (const reason of decision.reasons) {
      console.warn(`Order ${orderId} flagged: ${reason}`);
    }
    if (REVIEW_STATE_ID) {
      await postReviewHistory(orderId, Number(REVIEW_STATE_ID));
    }
    flagged++;
  }

  console.log(`Done. ${flagged} order(s) flagged for paid-despite-out-of-stock.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which paid orders are a real oversell versus an intentional backorder. Because decide_out_of_stock_paid_flag is pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_paid_out_of_stock.py
from audit_paid_out_of_stock import decide_out_of_stock_paid_flag

PAID_IDS = [2, 12]


def test_paid_qty_zero_deny_is_flagged():
    lines = [{"productId": 1, "productAttributeId": 0, "productQuantity": 1}]
    stock = {"1:0": {"quantity": 0, "outOfStock": 0}}
    result = decide_out_of_stock_paid_flag(101, 2, PAID_IDS, lines, stock)
    assert result["flagged"] is True
    assert len(result["reasons"]) == 1


def test_paid_qty_five_deny_not_flagged():
    lines = [{"productId": 1, "productAttributeId": 0, "productQuantity": 1}]
    stock = {"1:0": {"quantity": 5, "outOfStock": 0}}
    result = decide_out_of_stock_paid_flag(102, 2, PAID_IDS, lines, stock)
    assert result["flagged"] is False


def test_paid_qty_negative_allow_backorder_not_flagged():
    lines = [{"productId": 1, "productAttributeId": 0, "productQuantity": 1}]
    stock = {"1:0": {"quantity": -2, "outOfStock": 1}}
    result = decide_out_of_stock_paid_flag(103, 2, PAID_IDS, lines, stock)
    assert result["flagged"] is False


def test_not_paid_never_flagged_regardless_of_stock():
    lines = [{"productId": 1, "productAttributeId": 0, "productQuantity": 1}]
    stock = {"1:0": {"quantity": -5, "outOfStock": 0}}
    result = decide_out_of_stock_paid_flag(104, 1, PAID_IDS, lines, stock)
    assert result["flagged"] is False
    assert result["reasons"] == []


def test_multiple_lines_one_insufficient_flags_with_one_reason():
    lines = [
        {"productId": 1, "productAttributeId": 0, "productQuantity": 1},
        {"productId": 2, "productAttributeId": 0, "productQuantity": 2},
    ]
    stock = {
        "1:0": {"quantity": 10, "outOfStock": 0},
        "2:0": {"quantity": 0, "outOfStock": 0},
    }
    result = decide_out_of_stock_paid_flag(105, 12, PAID_IDS, lines, stock)
    assert result["flagged"] is True
    assert len(result["reasons"]) == 1
    assert "2:0" in result["reasons"][0]


def test_quantity_exactly_equals_needed_not_flagged():
    lines = [{"productId": 1, "productAttributeId": 0, "productQuantity": 3}]
    stock = {"1:0": {"quantity": 3, "outOfStock": 0}}
    result = decide_out_of_stock_paid_flag(106, 2, PAID_IDS, lines, stock)
    assert result["flagged"] is False
paid-out-of-stock.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOutOfStockPaidFlag } from "./audit-paid-out-of-stock.js";

const PAID_IDS = [2, 12];

test("paid, qty zero, deny is flagged", () => {
  const lines = [{ productId: 1, productAttributeId: 0, productQuantity: 1 }];
  const stock = new Map([["1:0", { quantity: 0, outOfStock: 0 }]]);
  const result = decideOutOfStockPaidFlag({ orderId: 101, currentStateId: 2, paidStateIds: PAID_IDS, orderLines: lines, stockByLineKey: stock });
  assert.equal(result.flagged, true);
  assert.equal(result.reasons.length, 1);
});

test("paid, qty five, deny is not flagged", () => {
  const lines = [{ productId: 1, productAttributeId: 0, productQuantity: 1 }];
  const stock = new Map([["1:0", { quantity: 5, outOfStock: 0 }]]);
  const result = decideOutOfStockPaidFlag({ orderId: 102, currentStateId: 2, paidStateIds: PAID_IDS, orderLines: lines, stockByLineKey: stock });
  assert.equal(result.flagged, false);
});

test("paid, negative qty, allow backorder is not flagged", () => {
  const lines = [{ productId: 1, productAttributeId: 0, productQuantity: 1 }];
  const stock = new Map([["1:0", { quantity: -2, outOfStock: 1 }]]);
  const result = decideOutOfStockPaidFlag({ orderId: 103, currentStateId: 2, paidStateIds: PAID_IDS, orderLines: lines, stockByLineKey: stock });
  assert.equal(result.flagged, false);
});

test("not paid is never flagged regardless of stock", () => {
  const lines = [{ productId: 1, productAttributeId: 0, productQuantity: 1 }];
  const stock = new Map([["1:0", { quantity: -5, outOfStock: 0 }]]);
  const result = decideOutOfStockPaidFlag({ orderId: 104, currentStateId: 1, paidStateIds: PAID_IDS, orderLines: lines, stockByLineKey: stock });
  assert.equal(result.flagged, false);
  assert.deepEqual(result.reasons, []);
});

test("multiple lines, one insufficient, flags with one reason", () => {
  const lines = [
    { productId: 1, productAttributeId: 0, productQuantity: 1 },
    { productId: 2, productAttributeId: 0, productQuantity: 2 },
  ];
  const stock = new Map([
    ["1:0", { quantity: 10, outOfStock: 0 }],
    ["2:0", { quantity: 0, outOfStock: 0 }],
  ]);
  const result = decideOutOfStockPaidFlag({ orderId: 105, currentStateId: 12, paidStateIds: PAID_IDS, orderLines: lines, stockByLineKey: stock });
  assert.equal(result.flagged, true);
  assert.equal(result.reasons.length, 1);
  assert.ok(result.reasons[0].includes("2:0"));
});

test("quantity exactly equals needed is not flagged", () => {
  const lines = [{ productId: 1, productAttributeId: 0, productQuantity: 3 }];
  const stock = new Map([["1:0", { quantity: 3, outOfStock: 0 }]]);
  const result = decideOutOfStockPaidFlag({ orderId: 106, currentStateId: 2, paidStateIds: PAID_IDS, orderLines: lines, stockByLineKey: stock });
  assert.equal(result.flagged, false);
});

Case studies

Concurrent checkout

Two shoppers, one last unit

A limited run sneaker drop sold its last pair to two different carts within the same second. Both carts had passed the add-to-cart check while stock was still at one. The slower checkout still finished, its payment module confirmed the charge, and the order landed on Payment accepted with the product now sitting at quantity 0 and backorders denied.

The audit script caught the second order the next morning, with the exact reason line naming the product and combination. Support reached out to the customer before a single unit shipped, offered a restock date, and the store never had to explain an oversold order after the fact.

Cash on delivery

COD orders that paid themselves

A store using a cash on delivery module found dozens of orders every week landing straight on Payment accepted (COD) the moment the order was placed, without any real confirmation that the item was still in stock. Several of those turned out to be for products that had sold out minutes earlier.

Running the auditor daily surfaced every affected order with quantity and policy context attached. The team set an "Awaiting stock replenishment" state as the approved review target, and the script's optional step moved flagged orders there for a human to work, while every capture already made stayed untouched.

What good looks like

After this runs on a schedule, nobody discovers an oversold, already-paid order from an angry customer email. Every affected order is on a report with the product, the quantity, and the exact reason, and only a state a human already approved gets touched through order_histories. No payment is ever cancelled or refunded automatically, and current_state is never edited directly.

FAQ

Why does PrestaShop let an order become paid when the product is out of stock?

PrestaShop checks stock availability when the item is added to the cart, but it does not re-verify stock_available against the cart contents at the final Order with obligation to pay step or inside a payment module's validateOrder() callback. If stock runs out from a concurrent order between cart creation and payment confirmation, the order can still be written with a paid current_state even though the product is now out of stock with backorders denied.

Is it safe to auto cancel or refund these orders?

No. The payment has already been captured, so auto cancelling or refunding would mutate financial state without a human involved. The safe pattern is to flag and report every affected order, and optionally move it to an existing manual review order state through order_histories, never by editing current_state directly and never by inventing a new paid or unpaid transition.

How do I find which orders are affected?

Pull the paid order_states, list orders currently sitting in one of those states, read each order's order_details for the product and quantity ordered, then check stock_availables for that product and combination. An order is flagged when it is paid, the product's out_of_stock policy denies backorders, and the current quantity is zero or negative for that line.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub Issue #10762: Stock quantity are not verified at the last step of the checkout. github.com/PrestaShop/PrestaShop/issues/10762
  2. PrestaShop GitHub Issue #10923: Orders are placed on "On Backorder (paid)" with 1 item in stock. github.com/PrestaShop/PrestaShop/issues/10923
  3. Mollie PrestaShop GitHub Issue #349: Backorder status set to (Paid) without accepted payment. github.com/mollie/PrestaShop/issues/349

On the solution:

  1. PrestaShop Developer Documentation: the stock_availables Webservice resource. devdocs.prestashop-project.org webservice resources stock_availables
  2. PrestaShop Developer Documentation: the order_states Webservice resource. devdocs.prestashop-project.org webservice resources order_states
  3. PrestaShop Developer Documentation: the orders Webservice resource. devdocs.prestashop-project.org webservice resources orders

Stuck on a tricky one?

If you have a problem in PrestaShop stock, orders, 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 bad order before it shipped?

If this saved you from an oversold, already-paid order, 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