Skip to content

Diagnostic Orders & Financials

Invoice number stays empty after an order is marked shipped via API

You POST a new row to order_histories to move the order to Shipped, the call succeeds, and the order's state updates in the back office right away. But the invoice number field stays blank, the customer portal has nothing to download, and accounting cannot reconcile the order to an invoice line. Nothing errored. PrestaShop simply never created the order_invoice row in the first place, because the specific state change your integration posted was never the one that triggers it. Here is why that gap opens up on the webservice path and a script that finds every order stuck this way and backfills the invoice for the ones it is actually safe to fix.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A pile of printed papers
Photo by Alexander Grey on Unsplash
The short answer

An invoice number is not a property of the order itself. It lives on a separate order_invoice row that PrestaShop only creates inside Order::setInvoice(), which only runs when a new order_histories entry is added for an order state whose own invoice flag is set to 1, and only while PS_INVOICE is enabled store wide. Change the order's state directly, post a history row for a state without that flag, or catch a store with invoicing switched off, and the state changes cleanly while no invoice is ever generated. The order looks shipped everywhere except on the one field that matters to accounting. Run a Python or Node.js script that lists orders sitting on an invoice-eligible current state, checks each one's order_invoices list, and for the safe case, an eligible state with no invoice row and no evidence one was ever created, calls the same webservice path that back-office actions use to generate it. Full code, tests, and citations are below.

The problem in plain words

In the back office, moving an order to a shipped or delivered state is really two things happening together: PrestaShop writes a new row to order_history recording the state change, and if that target state is configured with invoice = 1, it also runs the invoicing step that creates an order_invoice row and assigns it the next number in the sequence. Both of those happen inside the same request because the back office always drives state changes through OrderHistory::changeIdOrderState(), the one code path that does both jobs.

The webservice API only exposes the first half. A POST to order_histories creates the history row and updates current_state, which is exactly what the order needs to look shipped. But the webservice resource for order_histories does not invoke the same invoicing side effect that the back office controller runs after a manual state change on some releases, and if the specific order state used in the POST does not carry the invoice flag, or if PS_INVOICE is switched off for the shop, no order_invoice row is ever created regardless of how the state was changed. Either way, the order ends up shipped with nothing to show for it on the invoice number field.

POST order_histories id_order_state = Shipped current_state updates order looks shipped setInvoice() never runs order_invoice row never created Number empty
The history row lands and the order looks shipped, but nothing on the webservice path ran the invoicing step, so no order_invoice row and no number ever get created.

Why it happens

The invoice number is a side effect of a state change, not a property PrestaShop computes on demand from the order. That makes it easy to trigger the state change while missing the side effect entirely. A few common ways stores end up here:

In every case the symptom looks the same from the storefront: the order is Shipped, but "My invoices" in the customer account has nothing listed, and finance cannot find a number to key against payment records. See the citations at the end for the exact behavior and threads.

The key insight

Not every order sitting on a shipped-looking state without an invoice is actually missing one by mistake. If the order's current state was never configured to carry an invoice, generating one anyway overrides a deliberate setting in Order Settings, Statuses. The safe pattern only fixes the case where the current state is genuinely invoice-eligible, PS_INVOICE is on, and no order_invoice row exists yet for that order. That is PrestaShop's own definition of "should have an invoice," just never actually created. Anything else is left for a human to decide.

The fix, as a flow

We do not touch current_state or the order history at all. We add a job that lists orders sitting on the shipped or delivered state you invoice against, reads back each order's order_invoices, and for orders that are eligible and have none, calls the webservice invoice generation path once, the same one the back office uses, so the order gets the exact number it would have received the moment it changed state.

List shipped orders current_state = target Read order_invoices and state.invoice flag eligible and no invoice yet? yes no, skip Generate invoice order_invoice created Number assigned
Only orders on an eligible state with no invoice row yet get one generated. Orders on a state that was never meant to invoice are left alone.

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_states, and order_invoices. Keep the shop URL and key in environment variables, never in the file. If you plan to run repairs, also confirm Preferences, Invoices, Enable invoices (PS_INVOICE) is on, since the script must never fight that setting.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export SHIPPED_STATE_ID="4"   # the invoice-eligible state you target, e.g. Shipped
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 SHIPPED_STATE_ID="4"   // the invoice-eligible state you target, e.g. Shipped
export DRY_RUN="true"         // start safe, only reports by default
2

List orders sitting on the target state

Call GET /api/orders?filter[current_state]=[ID_ORDER_STATE]&display=full&output_format=JSON to pull every order currently on the state you invoice against, with id, reference, and current_state. Add a date filter to scope the run to a recent window on a busy store.

step2.py
import os, requests

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

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

def orders_on_state(state_id):
    data = api_get("orders", params={
        "filter[current_state]": f"[{state_id}]",
        "display": "full",
    })
    return data.get("orders") or []
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

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

async function ordersOnState(stateId) {
  const data = await apiGet("orders", {
    "filter[current_state]": `[${stateId}]`,
    display: "full",
  });
  return data.orders || [];
}
3

Read the order state's invoice flag and the order's existing invoices

Call GET /api/order_states/[id]?display=full&output_format=JSON once per state you use, to confirm invoice is 1 for that state. Then for each order, call GET /api/orders/[id]?display=full&output_format=JSON and read the associations.order_invoices list, which is empty when no order_invoice row exists yet.

step3.py
def order_state_is_invoiceable(state_id):
    data = api_get(f"order_states/{state_id}", params={"display": "full"})
    state = data.get("order_state") or {}
    return str(state.get("invoice")) == "1"

def order_invoices_for(order_id):
    data = api_get(f"orders/{order_id}", params={"display": "full"})
    order = data.get("order") or {}
    associations = order.get("associations") or {}
    return associations.get("order_invoices") or []
step3.js
async function orderStateIsInvoiceable(stateId) {
  const data = await apiGet(`order_states/${stateId}`, { display: "full" });
  const state = data.order_state || {};
  return String(state.invoice) === "1";
}

async function orderInvoicesFor(orderId) {
  const data = await apiGet(`orders/${orderId}`, { display: "full" });
  const order = data.order || {};
  const associations = order.associations || {};
  return associations.order_invoices || [];
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, whether its current state is invoice-eligible, whether PS_INVOICE is on for the shop, and the order's existing invoices, and returns a plain action. It only ever recommends generating an invoice when the state is genuinely eligible, invoicing is enabled store wide, and no invoice row exists yet. Anything else is left alone, since forcing an invoice onto a state that was never configured to carry one overrides a deliberate setting.

decide.py
def decide_invoice_repair(order, state_is_invoiceable, invoicing_enabled, existing_invoices):
    if not invoicing_enabled:
        return {"action": "skip", "reason": "ps_invoice_disabled"}

    if not state_is_invoiceable:
        return {"action": "skip", "reason": "current_state_not_invoice_eligible"}

    if existing_invoices:
        return {"action": "none", "reason": "invoice_already_exists"}

    if not order.get("valid"):
        return {"action": "flag_manual_review", "reason": "order_not_valid_yet"}

    return {"action": "generate_invoice", "reason": "eligible_state_missing_invoice"}
decide.js
export function decideInvoiceRepair(order, stateIsInvoiceable, invoicingEnabled, existingInvoices) {
  if (!invoicingEnabled) {
    return { action: "skip", reason: "ps_invoice_disabled" };
  }

  if (!stateIsInvoiceable) {
    return { action: "skip", reason: "current_state_not_invoice_eligible" };
  }

  if (existingInvoices && existingInvoices.length > 0) {
    return { action: "none", reason: "invoice_already_exists" };
  }

  if (!order.valid) {
    return { action: "flag_manual_review", reason: "order_not_valid_yet" };
  }

  return { action: "generate_invoice", reason: "eligible_state_missing_invoice" };
}
5

Generate the invoice through the order_invoices resource

When the action is generate_invoice, POST /api/order_invoices?output_format=JSON with the id_order, which creates the order_invoice row and assigns it the next number, exactly what would have happened at the moment the eligible state was first reached. We never touch current_state or write a new history row, only the invoice resource.

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

Wire it together with a dry run guard

The loop ties every piece together: list every order on the target state, check the state's invoice flag and the shop's PS_INVOICE setting once, read each order's existing invoices, run it through decide_invoice_repair, and log what it would do. DRY_RUN defaults to true, so the script only logs the intended change and stops. Only when DRY_RUN=false does it call generate_invoice. Orders flagged for manual review are never written to, no matter what DRY_RUN says.

Run it safe

Always start with DRY_RUN=true. Never generate an invoice for a state whose own invoice flag is 0, that setting is a deliberate choice in Order Settings, Statuses. And never override a shop where PS_INVOICE is off, since some stores intentionally handle invoicing outside PrestaShop entirely.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every order sitting on the invoice-eligible state you target, decides the safe action for each, respects the dry run flag, and only ever writes a new order_invoice row for the deterministic case.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
backfill_missing_invoice.py
"""Detect and safely backfill PrestaShop orders missing an invoice after an API state change.

An invoice number is not read off the order, it lives on a separate order_invoice row
that PrestaShop only creates inside Order::setInvoice(), which only runs when a new
order_histories entry is added for a state whose own order_state.invoice flag is 1,
and only while PS_INVOICE is enabled for the shop. A webservice POST to order_histories
updates current_state correctly but does not always trigger that same invoicing side
effect, and if the target state was never flagged as invoice-eligible, or the shop has
PS_INVOICE off, no order_invoice row is ever created no matter how the state changed.

This script lists orders sitting on an invoice-eligible current state, reads each
order's existing order_invoices, and only ever writes for the safe, deterministic case:
the state is genuinely eligible, PS_INVOICE is on, the order is valid, and no invoice
row exists yet. Anything else is left alone or flagged for manual review.

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

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
SHIPPED_STATE_ID = int(os.environ.get("SHIPPED_STATE_ID", "4"))
INVOICING_ENABLED = os.environ.get("PS_INVOICE_ENABLED", "true").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def decide_invoice_repair(order, state_is_invoiceable, invoicing_enabled, existing_invoices):
    """Pure decision function, no I/O.

    order: {id, reference, valid, current_state}
    state_is_invoiceable: bool, order_state.invoice == 1 for order.current_state
    invoicing_enabled: bool, PS_INVOICE for the shop
    existing_invoices: list, order.associations.order_invoices
    Returns a dict with an action of none, generate_invoice, flag_manual_review, or skip.
    """
    if not invoicing_enabled:
        return {"action": "skip", "reason": "ps_invoice_disabled"}

    if not state_is_invoiceable:
        return {"action": "skip", "reason": "current_state_not_invoice_eligible"}

    if existing_invoices:
        return {"action": "none", "reason": "invoice_already_exists"}

    if not order.get("valid"):
        return {"action": "flag_manual_review", "reason": "order_not_valid_yet"}

    return {"action": "generate_invoice", "reason": "eligible_state_missing_invoice"}


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


def order_state_is_invoiceable(state_id):
    data = api_get(f"order_states/{state_id}", params={"display": "full"})
    state = data.get("order_state") or {}
    return str(state.get("invoice")) == "1"


def order_invoices_for(order_id):
    data = api_get(f"orders/{order_id}", params={"display": "full"})
    order = data.get("order") or {}
    associations = order.get("associations") or {}
    return associations.get("order_invoices") or []


def generate_invoice(order_id):
    body = {"order_invoice": {"id_order": order_id}}
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_invoices",
        params={"output_format": "JSON"},
        json=body,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    generated = 0
    flagged = 0
    skipped = 0
    state_is_invoiceable = order_state_is_invoiceable(SHIPPED_STATE_ID)

    for order in orders_on_state(SHIPPED_STATE_ID):
        id_order = order["id"]
        reference = order.get("reference")
        existing_invoices = order_invoices_for(id_order)
        decision = decide_invoice_repair(order, state_is_invoiceable, INVOICING_ENABLED, existing_invoices)

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

        if decision["action"] == "skip":
            skipped += 1
            log.info("Order %s (id=%s) skipped: %s", reference, id_order, decision["reason"])
            continue

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

        log.info("Order %s (id=%s) missing invoice. %s",
                  reference, id_order, "would generate" if DRY_RUN else "generating")
        if DRY_RUN:
            continue

        generate_invoice(id_order)
        generated += 1

    log.info("Done. %d invoice(s) generated, %d flagged, %d skipped. DRY_RUN=%s",
              generated, flagged, skipped, DRY_RUN)


if __name__ == "__main__":
    run()
backfill-missing-invoice.js
/**
 * Detect and safely backfill PrestaShop orders missing an invoice after an API state change.
 *
 * An invoice number is not read off the order, it lives on a separate order_invoice row
 * that PrestaShop only creates inside Order::setInvoice(), which only runs when a new
 * order_histories entry is added for a state whose own order_state.invoice flag is 1,
 * and only while PS_INVOICE is enabled for the shop. A webservice POST to order_histories
 * updates current_state correctly but does not always trigger that same invoicing side
 * effect, and if the target state was never flagged as invoice-eligible, or the shop has
 * PS_INVOICE off, no order_invoice row is ever created no matter how the state changed.
 *
 * This script lists orders sitting on an invoice-eligible current state, reads each
 * order's existing order_invoices, and only ever writes for the safe, deterministic case:
 * the state is genuinely eligible, PS_INVOICE is on, the order is valid, and no invoice
 * row exists yet. Anything else is left alone or flagged for manual review.
 *
 * Guide: https://www.allanninal.dev/prestashop/invoice-number-missing-after-api-status-change/
 */
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 SHIPPED_STATE_ID = Number(process.env.SHIPPED_STATE_ID || 4);
const INVOICING_ENABLED = (process.env.PS_INVOICE_ENABLED || "true").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

/**
 * Pure decision function, no I/O.
 *
 * order: { id, reference, valid, current_state }
 * stateIsInvoiceable: boolean, order_state.invoice == 1 for order.current_state
 * invoicingEnabled: boolean, PS_INVOICE for the shop
 * existingInvoices: array, order.associations.order_invoices
 * Returns { action: "none" | "generate_invoice" | "flag_manual_review" | "skip", reason }
 */
export function decideInvoiceRepair(order, stateIsInvoiceable, invoicingEnabled, existingInvoices) {
  if (!invoicingEnabled) {
    return { action: "skip", reason: "ps_invoice_disabled" };
  }

  if (!stateIsInvoiceable) {
    return { action: "skip", reason: "current_state_not_invoice_eligible" };
  }

  if (existingInvoices && existingInvoices.length > 0) {
    return { action: "none", reason: "invoice_already_exists" };
  }

  if (!order.valid) {
    return { action: "flag_manual_review", reason: "order_not_valid_yet" };
  }

  return { action: "generate_invoice", reason: "eligible_state_missing_invoice" };
}

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

async function orderStateIsInvoiceable(stateId) {
  const data = await apiGet(`order_states/${stateId}`, { display: "full" });
  const state = data.order_state || {};
  return String(state.invoice) === "1";
}

async function orderInvoicesFor(orderId) {
  const data = await apiGet(`orders/${orderId}`, { display: "full" });
  const order = data.order || {};
  const associations = order.associations || {};
  return associations.order_invoices || [];
}

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

export async function run() {
  let generated = 0;
  let flagged = 0;
  let skipped = 0;
  const stateIsInvoiceable = await orderStateIsInvoiceable(SHIPPED_STATE_ID);

  for (const order of await ordersOnState(SHIPPED_STATE_ID)) {
    const idOrder = order.id;
    const reference = order.reference;
    const existingInvoices = await orderInvoicesFor(idOrder);
    const decision = decideInvoiceRepair(order, stateIsInvoiceable, INVOICING_ENABLED, existingInvoices);

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

    if (decision.action === "skip") {
      skipped++;
      console.log(`Order ${reference} (id=${idOrder}) skipped: ${decision.reason}`);
      continue;
    }

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

    console.log(`Order ${reference} (id=${idOrder}) missing invoice. ${DRY_RUN ? "would generate" : "generating"}`);
    if (DRY_RUN) continue;

    await generateInvoice(idOrder);
    generated++;
  }

  console.log(`Done. ${generated} invoice(s) generated, ${flagged} flagged, ${skipped} skipped. DRY_RUN=${DRY_RUN}`);
}

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

Add a test

The decision function is the part most worth testing, because it decides which orders get a number generated automatically and which ones get left alone or sent to a human. Because we kept decide_invoice_repair pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_invoice_number_missing.py
from backfill_missing_invoice import decide_invoice_repair


def order(**over):
    base = {"id": 501, "reference": "ABCDE12345", "valid": True, "current_state": 4}
    base.update(over)
    return base


def test_generates_invoice_when_eligible_enabled_and_missing():
    result = decide_invoice_repair(order(), True, True, [])
    assert result["action"] == "generate_invoice"
    assert result["reason"] == "eligible_state_missing_invoice"


def test_none_when_invoice_already_exists():
    result = decide_invoice_repair(order(), True, True, [{"id": 9, "number": 1042}])
    assert result["action"] == "none"
    assert result["reason"] == "invoice_already_exists"


def test_skips_when_state_not_invoice_eligible():
    result = decide_invoice_repair(order(), False, True, [])
    assert result["action"] == "skip"
    assert result["reason"] == "current_state_not_invoice_eligible"


def test_skips_when_ps_invoice_disabled():
    result = decide_invoice_repair(order(), True, False, [])
    assert result["action"] == "skip"
    assert result["reason"] == "ps_invoice_disabled"


def test_flags_when_order_not_valid():
    result = decide_invoice_repair(order(valid=False), True, True, [])
    assert result["action"] == "flag_manual_review"
    assert result["reason"] == "order_not_valid_yet"


def test_ps_invoice_disabled_wins_over_ineligible_state():
    result = decide_invoice_repair(order(), False, False, [])
    assert result["action"] == "skip"
    assert result["reason"] == "ps_invoice_disabled"
backfill-missing-invoice.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideInvoiceRepair } from "./backfill-missing-invoice.js";

const order = (over = {}) => ({ id: 501, reference: "ABCDE12345", valid: true, current_state: 4, ...over });

test("generates invoice when eligible, enabled, and missing", () => {
  const result = decideInvoiceRepair(order(), true, true, []);
  assert.equal(result.action, "generate_invoice");
  assert.equal(result.reason, "eligible_state_missing_invoice");
});

test("none when invoice already exists", () => {
  const result = decideInvoiceRepair(order(), true, true, [{ id: 9, number: 1042 }]);
  assert.equal(result.action, "none");
  assert.equal(result.reason, "invoice_already_exists");
});

test("skips when state not invoice eligible", () => {
  const result = decideInvoiceRepair(order(), false, true, []);
  assert.equal(result.action, "skip");
  assert.equal(result.reason, "current_state_not_invoice_eligible");
});

test("skips when PS_INVOICE disabled", () => {
  const result = decideInvoiceRepair(order(), true, false, []);
  assert.equal(result.action, "skip");
  assert.equal(result.reason, "ps_invoice_disabled");
});

test("flags when order not valid", () => {
  const result = decideInvoiceRepair(order({ valid: false }), true, true, []);
  assert.equal(result.action, "flag_manual_review");
  assert.equal(result.reason, "order_not_valid_yet");
});

test("PS_INVOICE disabled wins over ineligible state", () => {
  const result = decideInvoiceRepair(order(), false, false, []);
  assert.equal(result.action, "skip");
  assert.equal(result.reason, "ps_invoice_disabled");
});

Case studies

Custom shipped state

The 3PL integration that used its own status

A store had a third-party logistics provider push shipment confirmations into PrestaShop by posting to order_histories with a custom "Shipped by 3PL" state the developer had created a year earlier for reporting purposes. Nobody had ticked the invoice flag on that state, because at the time it was only meant to track a shipping milestone, not billing.

Running the script against that state's id showed every order correctly flagged with current_state_not_invoice_eligible, none of them auto-generated. That was the right call, since the fix here was for the store to update the order state configuration once, not for a script to quietly override it order by order.

Historical backfill

The migration that imported two years of orders

A store migrating from another platform imported two years of historic orders, writing order_history rows directly through a database script to preserve the original shipped and delivered dates. That bypassed Order::setInvoice() entirely, so thousands of otherwise normal, already-shipped orders had no order_invoice row and no number for accounting to reference during the year-end reconciliation.

Since PS_INVOICE was on and the imported states genuinely carried the invoice flag, the script generated the missing invoices for every valid order in one dry-run-then-real pass, in numeric sequence order as PrestaShop assigned them, closing the gap without anyone hand-clicking through two years of orders.

What good looks like

After this runs on a schedule, orders that reach an invoice-eligible state through the webservice never sit invisible to accounting for long. The genuinely missing case, an eligible state with no invoice row yet, gets backfilled with the same number PrestaShop would have assigned automatically. Orders on a state that was never meant to invoice stay exactly as configured, since that decision belongs in Order Settings, Statuses, not in a script's judgment call.

FAQ

Why is my PrestaShop order's invoice number empty after I changed its state through the API?

An invoice is only created when a new order_histories row is added for a state whose order_state.invoice flag is set to 1 and PS_INVOICE is enabled, at which point PrestaShop calls Order::setInvoice() to create the order_invoice row and assign its number. If the webservice caller posts to order_histories with a state that does not have that flag, or if PS_INVOICE is off, no order_invoice row is ever created, so the order has no invoice number to show.

Is it safe to generate the missing invoice automatically once I find one?

Yes, for the narrow case where the order's current order_histories row already points at a logable, invoice-eligible state and PS_INVOICE is on, but no order_invoice row exists for that order yet. In that case calling the same invoice creation path PrestaShop itself uses is a safe, idempotent repair. If the order state itself does not carry the invoice flag, do not force an invoice, since that changes what the order state configuration was set up to do.

How do I find orders missing an invoice number through the webservice API?

List orders with GET orders filtered to the shipped or delivered state you use, read each order's current_state and its order_invoices, and flag any order whose current state is invoice-eligible but whose order_invoices list is empty. That reproduces the same condition PrestaShop's own invoice controller checks before it lets a number generate.

Related field notes

Citations

On the problem:

  1. PrestaShop Developer Documentation: the order_histories resource and how state changes are recorded. devdocs.prestashop-project.org/9/webservice/resources/order_histories
  2. PrestaShop Developer Documentation: the order_states resource, including the invoice flag. devdocs.prestashop-project.org/9/webservice/resources/order_states
  3. PrestaShop Forums: invoice not generated after changing order status through the webservice. forum.prestashop.com/forum/customization-en/webservice

On the solution:

  1. PrestaShop Developer Documentation: the order_invoices resource. devdocs.prestashop-project.org/9/webservice/resources/order_invoices
  2. PrestaShop Developer Documentation: the orders resource, including the associations block. devdocs.prestashop-project.org/9/webservice/resources/orders
  3. PrestaShop Help Center: how invoices are generated and the Enable invoices setting. devdocs.prestashop-project.org/9/basics/configuration

Stuck on a tricky one?

If you have a problem in PrestaShop orders, invoicing, 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 clear a batch of missing invoices?

If this saved you a pile of manual invoice generation or an awkward finance ticket, 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