Skip to content

Diagnostic Orders and Payments

Order closed prematurely while invoice is still pending

The order is fully shipped, the admin grid says Closed, and everyone moves on. Except nobody got paid. The invoice was created with Not Capture, or left pending on purpose for a later capture, and Magento's own automatic close logic never checked that. It only looked at whether there was anything left to ship or invoice, saw nothing, and closed the order with total_due still greater than zero. Here is why that check misses an unpaid invoice and a small script that finds every order this happened to.

Python and Node.js Magento REST API Safe by default (report first)
A pile of printed papers
Photo by Alexander Grey on Unsplash
The short answer

Magento's automatic order-state transition lives in Magento\Sales\Model\ResourceModel\Order\Handler\State::check(), and it runs on every order save, including the save that happens when you create a shipment. It closes the order whenever the order is not canceled, cannot be put on hold, canInvoice() is false, and canShip() is false, meaning every item is fully shipped. It never checks whether an existing invoice is still open and unpaid. So an invoice created Not Capture, followed later by a full shipment, closes the order even though the money was never collected. A script cannot safely rewrite order.state or order.status over REST, so the fix here is to detect every affected order through /rest/V1/orders, /rest/V1/invoices, and the shipment records, report it for review, and only capture or void the pending invoice once a human confirms the real payment status off platform. Full code, tests, and a dry run guard are below.

The problem in plain words

Magento decides an order's state on its own. Every time an order is saved, a resource model method runs a small set of checks and, if the conditions line up, flips the order to Closed automatically. Nobody clicks a button for this. It just happens as a side effect of another action, most often creating a shipment.

The check is simple by design: is the order canceled, can it be put on hold, can more of it still be invoiced, can more of it still be shipped. If the answer to all of invoiced and shipped is no, and the order is not canceled or holdable, Magento closes it. That logic makes sense for the common case, an order that was invoiced in full and shipped in full is genuinely done. It falls apart the moment an invoice exists but was deliberately left unpaid, because canInvoice() still comes back false even though the invoice itself never collected any money. Ship the rest of the order and the state handler closes it, total_due and all.

Invoice created Not Capture, state = Pending Order fully shipped canInvoice / canShip = false invoice state never checked State::check() runs on the shipment save Order Closed total_due > 0 Invoice still Pending money never collected
State::check() only asks whether more can be invoiced or shipped. It never asks whether the invoice that already exists was actually paid, so a Not Capture invoice rides along quietly to a closed, unpaid order.

Why it happens

This exact pattern has been reported against Magento's own issue tracker more than once: an order with an invoice still Pending gets closed right after shipping, and the closed status persists even when staff try to correct it by hand. See the citations at the end for the specific threads.

The key insight

State::check() reasons entirely about remaining invoiceable and shippable quantity. It was never written to ask "was the invoice that exists actually paid." So the bug is not that Magento fails to close the order, the order genuinely has nothing left to ship or invoice, it is that Closed silently implies fully paid to everyone reading the grid, and that implication is false whenever a Not Capture invoice is in the mix. Detecting the defect means cross referencing three things Magento already exposes over REST: the order's own status, its invoice's state field, and whether a shipment exists to confirm the close was triggered the usual way.

The fix, as a flow

We do not touch order.state or order.status directly, there is no supported REST write for that and forcing it desyncs sales_order_status_history and the order totals. Instead we add a job that lists closed orders, pulls each one's invoices and shipments, and reports every case where the order closed while an invoice was still open and money was still owed. The only REST write this job is allowed to make, and only outside dry run, is capturing or voiding the pending invoice once a human has confirmed the real payment status.

Scheduled job runs on a timer List closed orders status eq closed Read invoices and shipments per order Open invoice, still owed? yes no, report ok Flag for review human confirms, then capture or void
The script only flags orders where the invoice is still open and money is still owed. Everything fully paid, or without a confirming shipment, is left alone.

Build it step by step

1

Get an admin bearer token

The script authenticates like any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true"   # start safe, change to false to allow the invoice capture/void path
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true"   // start safe, change to false to allow the invoice capture/void path
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List closed orders, then read invoices and shipments for each one

Filter /rest/V1/orders with a searchCriteria filter on status eq closed, paging with pageSize. For every hit, fetch its invoices from /rest/V1/invoices filtered by order_id, and fetch its shipments filtered by order_id to confirm the close was triggered by an actual shipment, exactly as the research describes for State::check().

step3.py
def closed_orders(page_size=200):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][value]": "closed",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": 1,
    }
    return magento_get("/orders", params)["items"]


def invoices_for_order(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    return magento_get("/invoices", params)["items"]


def shipments_for_order(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    return magento_get("/shipment", params)["items"]
step3.js
async function closedOrders(pageSize = 200) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][value]": "closed",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": 1,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}

async function invoicesForOrder(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/invoices", params);
  return data.items;
}

async function shipmentsForOrder(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/shipment", params);
  return data.items;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order's own totals, its invoices, and whether a shipment exists, and returns whether the closure looks premature. A pure function like this is easy to read and easy to test, which we do later. An invoice state of 1 means STATE_OPEN, Magento's own term for Pending. Rounding on money math means we treat anything within a tenth of a cent as settled, not owed.

decide.py
INVOICE_STATE_OPEN = 1  # Magento's STATE_OPEN, shown as "Pending" in the admin

def classify_premature_closure(order, invoices, has_shipment):
    if order.get("status") != "closed":
        return {"isPrematureClosure": False, "reason": "order not closed"}

    if not has_shipment:
        return {"isPrematureClosure": False, "reason": "no shipment on record"}

    has_open_invoice = any(inv.get("state") == INVOICE_STATE_OPEN for inv in invoices)

    total_due = order.get("total_due", 0) or 0
    total_paid = order.get("total_paid", 0) or 0
    grand_total = order.get("grand_total", 0) or 0
    still_owes = total_due > 0.0001 or total_paid < (grand_total - 0.0001)

    if has_open_invoice and still_owes:
        return {
            "isPrematureClosure": True,
            "reason": "order closed with an unpaid (state=1/Pending) invoice and outstanding total_due",
        }

    return {"isPrematureClosure": False, "reason": "invoice fully paid or no outstanding balance"}
decide.js
const INVOICE_STATE_OPEN = 1; // Magento's STATE_OPEN, shown as "Pending" in the admin

export function classifyPrematureClosure(order, invoices, hasShipment) {
  if (order.status !== "closed") {
    return { isPrematureClosure: false, reason: "order not closed" };
  }

  if (!hasShipment) {
    return { isPrematureClosure: false, reason: "no shipment on record" };
  }

  const hasOpenInvoice = invoices.some((inv) => inv.state === INVOICE_STATE_OPEN);

  const totalDue = order.total_due || 0;
  const totalPaid = order.total_paid || 0;
  const grandTotal = order.grand_total || 0;
  const stillOwes = totalDue > 0.0001 || totalPaid < grandTotal - 0.0001;

  if (hasOpenInvoice && stillOwes) {
    return {
      isPrematureClosure: true,
      reason: "order closed with an unpaid (state=1/Pending) invoice and outstanding total_due",
    };
  }

  return { isPrematureClosure: false, reason: "invoice fully paid or no outstanding balance" };
}
5

Report by default, correct only the invoice, only when confirmed

The default output is one report row per affected order: increment_id, entity_id, invoice id, invoice state, and total_due, for merchant or ops review. There is no safe REST write for order.state or order.status, so this job never touches the order directly. If a human confirms the payment actually landed off platform, the only corrective REST action is on the invoice: POST /rest/V1/invoices/{invoiceId}/capture, or .../invoices/{id}/void if it should never be paid. Either call moves the invoice out of STATE_OPEN so the next order save recalculates the state honestly.

apply.py
def capture_invoice(invoice_id):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/invoices/{invoice_id}/capture",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def void_invoice(invoice_id):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/invoices/{invoice_id}/void",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function captureInvoice(invoiceId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/invoices/${invoiceId}/capture`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function voidInvoice(invoiceId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/invoices/${invoiceId}/void`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
Run it safe

Always start with DRY_RUN=true. Treat every flagged order as a report for a human to review first. Only call capture or void on a pending invoice once someone has confirmed, off platform, whether that payment actually arrived. Never attempt to set order.state or order.status directly, Magento does not expose a supported write for it and forcing it desyncs sales_order_status_history and the order totals.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, lists closed orders, cross checks each one's invoices and shipments, reports every premature closure, and only calls the invoice capture or void endpoints when DRY_RUN is false.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
flag_premature_closure.py
"""Flag Magento 2 orders closed prematurely while an invoice is still Pending.

Magento's Sales/Model/ResourceModel/Order/Handler/State::check() runs on every
order save, including the save triggered by creating a shipment. It closes an
order once it is not canceled, cannot be put on hold, canInvoice() is false,
and canShip() is false, meaning every item is fully shipped. It never checks
whether an existing invoice is still open (state = 1, "Pending"). An invoice
created Not Capture, followed by a full shipment, closes the order even though
total_due is still greater than zero.

There is no safe REST write for order.state or order.status, so this reports
by default. The only allowed write is on the invoice itself, capture or void,
and only when DRY_RUN is false and a human has confirmed real payment status.
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("flag_premature_closure")

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

INVOICE_STATE_OPEN = 1  # Magento's STATE_OPEN, shown as "Pending" in the admin


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def classify_premature_closure(order, invoices, has_shipment):
    if order.get("status") != "closed":
        return {"isPrematureClosure": False, "reason": "order not closed"}

    if not has_shipment:
        return {"isPrematureClosure": False, "reason": "no shipment on record"}

    has_open_invoice = any(inv.get("state") == INVOICE_STATE_OPEN for inv in invoices)

    total_due = order.get("total_due", 0) or 0
    total_paid = order.get("total_paid", 0) or 0
    grand_total = order.get("grand_total", 0) or 0
    still_owes = total_due > 0.0001 or total_paid < (grand_total - 0.0001)

    if has_open_invoice and still_owes:
        return {
            "isPrematureClosure": True,
            "reason": "order closed with an unpaid (state=1/Pending) invoice and outstanding total_due",
        }

    return {"isPrematureClosure": False, "reason": "invoice fully paid or no outstanding balance"}


def closed_orders(page_size=200):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][value]": "closed",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": 1,
    }
    return magento_get("/orders", params)["items"]


def invoices_for_order(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    return magento_get("/invoices", params)["items"]


def shipments_for_order(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    return magento_get("/shipment", params)["items"]


def capture_invoice(invoice_id):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/invoices/{invoice_id}/capture",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    flagged = 0
    for order in closed_orders():
        order_id = order.get("entity_id")
        invoices = invoices_for_order(order_id)
        shipments = shipments_for_order(order_id)
        result = classify_premature_closure(order, invoices, bool(shipments))

        if not result["isPrematureClosure"]:
            continue

        open_invoice = next((inv for inv in invoices if inv.get("state") == INVOICE_STATE_OPEN), None)
        log.warning(
            "Order %s (id=%s) closed prematurely: invoice_id=%s invoice_state=%s total_due=%s. %s",
            order.get("increment_id"), order_id,
            open_invoice.get("entity_id") if open_invoice else None,
            open_invoice.get("state") if open_invoice else None,
            order.get("total_due"),
            "reporting only, human must confirm payment before capture/void" if DRY_RUN else "reporting only (no auto write to the order)",
        )
        flagged += 1

    log.info("Done. %d order(s) flagged as closed with a pending invoice.", flagged)


if __name__ == "__main__":
    run()
flag-premature-closure.js
/**
 * Flag Magento 2 orders closed prematurely while an invoice is still Pending.
 *
 * Magento's Sales/Model/ResourceModel/Order/Handler/State::check() runs on
 * every order save, including the save triggered by creating a shipment. It
 * closes an order once it is not canceled, cannot be put on hold, canInvoice()
 * is false, and canShip() is false, meaning every item is fully shipped. It
 * never checks whether an existing invoice is still open (state = 1,
 * "Pending"). An invoice created Not Capture, followed by a full shipment,
 * closes the order even though total_due is still greater than zero.
 *
 * There is no safe REST write for order.state or order.status, so this
 * reports by default. The only allowed write is on the invoice itself,
 * capture or void, and only when DRY_RUN is false and a human has confirmed
 * real payment status. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/order-closed-with-pending-invoice/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const INVOICE_STATE_OPEN = 1; // Magento's STATE_OPEN, shown as "Pending" in the admin

export function classifyPrematureClosure(order, invoices, hasShipment) {
  if (order.status !== "closed") {
    return { isPrematureClosure: false, reason: "order not closed" };
  }

  if (!hasShipment) {
    return { isPrematureClosure: false, reason: "no shipment on record" };
  }

  const hasOpenInvoice = invoices.some((inv) => inv.state === INVOICE_STATE_OPEN);

  const totalDue = order.total_due || 0;
  const totalPaid = order.total_paid || 0;
  const grandTotal = order.grand_total || 0;
  const stillOwes = totalDue > 0.0001 || totalPaid < grandTotal - 0.0001;

  if (hasOpenInvoice && stillOwes) {
    return {
      isPrematureClosure: true,
      reason: "order closed with an unpaid (state=1/Pending) invoice and outstanding total_due",
    };
  }

  return { isPrematureClosure: false, reason: "invoice fully paid or no outstanding balance" };
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function closedOrders(pageSize = 200) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][value]": "closed",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": 1,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}

async function invoicesForOrder(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/invoices", params);
  return data.items;
}

async function shipmentsForOrder(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/shipment", params);
  return data.items;
}

async function captureInvoice(invoiceId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/invoices/${invoiceId}/capture`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

export async function run() {
  let flagged = 0;
  const orders = await closedOrders();
  for (const order of orders) {
    const orderId = order.entity_id;
    const invoices = await invoicesForOrder(orderId);
    const shipments = await shipmentsForOrder(orderId);
    const result = classifyPrematureClosure(order, invoices, shipments.length > 0);

    if (!result.isPrematureClosure) continue;

    const openInvoice = invoices.find((inv) => inv.state === INVOICE_STATE_OPEN);
    console.warn(
      `Order ${order.increment_id} (id=${orderId}) closed prematurely: invoice_id=${openInvoice?.entity_id} invoice_state=${openInvoice?.state} total_due=${order.total_due}. ${
        DRY_RUN ? "reporting only, human must confirm payment before capture/void" : "reporting only (no auto write to the order)"
      }`
    );
    flagged++;
  }

  console.log(`Done. ${flagged} order(s) flagged as closed with a pending invoice.`);
}

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

Add a test

The classification rule is the part most worth testing, because it decides which closed orders get surfaced as a defect. Because we kept classify_premature_closure pure, the test needs no network and no Magento store. It just feeds in plain fixtures and checks the answer.

test_closed_premature.py
from flag_premature_closure import classify_premature_closure


def order(**over):
    base = {"status": "closed", "total_paid": 100.0, "total_due": 0.0, "grand_total": 100.0}
    base.update(over)
    return base


def test_closed_and_paid_and_no_due_is_not_premature():
    result = classify_premature_closure(order(), [{"state": 2}], True)
    assert result["isPrematureClosure"] is False


def test_closed_with_open_invoice_and_due_and_shipment_is_premature():
    o = order(total_paid=40.0, total_due=60.0)
    result = classify_premature_closure(o, [{"state": 1}], True)
    assert result["isPrematureClosure"] is True


def test_not_closed_yet_is_not_premature():
    o = order(status="processing", total_paid=40.0, total_due=60.0)
    result = classify_premature_closure(o, [{"state": 1}], True)
    assert result["isPrematureClosure"] is False


def test_open_invoice_but_rounding_zero_due_is_not_premature():
    o = order(total_paid=100.0, total_due=0.00001)
    result = classify_premature_closure(o, [{"state": 1}], True)
    assert result["isPrematureClosure"] is False


def test_no_shipment_is_not_premature():
    o = order(total_paid=40.0, total_due=60.0)
    result = classify_premature_closure(o, [{"state": 1}], False)
    assert result["isPrematureClosure"] is False


def test_no_open_invoice_is_not_premature_even_with_due():
    o = order(total_paid=40.0, total_due=60.0)
    result = classify_premature_closure(o, [{"state": 2}], True)
    assert result["isPrematureClosure"] is False
premature-closure.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyPrematureClosure } from "./flag-premature-closure.js";

const order = (over = {}) => ({
  status: "closed",
  total_paid: 100.0,
  total_due: 0.0,
  grand_total: 100.0,
  ...over,
});

test("closed and paid and no due is not premature", () => {
  const result = classifyPrematureClosure(order(), [{ state: 2 }], true);
  assert.equal(result.isPrematureClosure, false);
});

test("closed with open invoice and due and shipment is premature", () => {
  const o = order({ total_paid: 40.0, total_due: 60.0 });
  const result = classifyPrematureClosure(o, [{ state: 1 }], true);
  assert.equal(result.isPrematureClosure, true);
});

test("not closed yet is not premature", () => {
  const o = order({ status: "processing", total_paid: 40.0, total_due: 60.0 });
  const result = classifyPrematureClosure(o, [{ state: 1 }], true);
  assert.equal(result.isPrematureClosure, false);
});

test("open invoice but rounding zero due is not premature", () => {
  const o = order({ total_paid: 100.0, total_due: 0.00001 });
  const result = classifyPrematureClosure(o, [{ state: 1 }], true);
  assert.equal(result.isPrematureClosure, false);
});

test("no shipment is not premature", () => {
  const o = order({ total_paid: 40.0, total_due: 60.0 });
  const result = classifyPrematureClosure(o, [{ state: 1 }], false);
  assert.equal(result.isPrematureClosure, false);
});

test("no open invoice is not premature even with due", () => {
  const o = order({ total_paid: 40.0, total_due: 60.0 });
  const result = classifyPrematureClosure(o, [{ state: 2 }], true);
  assert.equal(result.isPrematureClosure, false);
});

Case studies

Not Capture invoice

The B2B store invoicing ahead of payment terms

A B2B store issued invoices with Not Capture as a matter of process, since the buyer's finance team paid net thirty by wire and the store wanted the invoice on record early for its own accounting. Warehouse staff shipped the full order the same week it was placed, and the order quietly went to Closed days before any money moved.

Running the detection job weekly turned up a growing list of Closed orders still carrying an open invoice and a nonzero total_due. Finance used the report to chase the actual wire transfers, and once each one was confirmed, capturing the invoice let the next order save recalculate the state honestly.

Gateway capture delay

The gateway that captured asynchronously

A payment gateway integration authorized on order placement but captured the charge through a separate webhook a few minutes later. Under load, a handful of shipments were created and saved before the webhook landed, so State::check() saw a fully shipped order with an invoice still in STATE_OPEN and closed it early.

The store ran the script hourly in dry run. It surfaced exactly the orders where the capture webhook had lagged, letting the team confirm each one settled a few minutes later and close the loop without ever hand editing an order's state.

What good looks like

After this runs on a schedule, a prematurely closed order is caught within one detection cycle instead of sitting unnoticed as revenue that was never actually collected. The report carries the increment id, the invoice id and state, and the outstanding total_due, so whoever responds can chase the real payment status fast. The order's state itself is never touched directly, only the invoice is corrected once a human confirms what actually happened, which is what keeps the fix from lying just as confidently as the bug did.

FAQ

Why did my Magento order close when the invoice was never paid?

Magento's order-state handler, Sales/Model/ResourceModel/Order/Handler/State::check(), closes an order once it is not canceled, cannot be put on hold, and both canInvoice() and canShip() are false. Once every item is shipped there is nothing left to invoice or ship, so the handler closes the order, but it never looks at whether the existing invoice is still in the open, unpaid state. An invoice created with Not Capture, or left pending on purpose, slips through and the order closes with money still owed.

How do I find orders that closed with an unpaid invoice?

Pull orders with status closed from the REST API, then for each one fetch its invoices and check the state field, where 1 means open or pending. An order is affected when its status is closed, at least one invoice is still state 1, total_due is greater than zero, and a shipment exists for that order, since the shipment save is what triggers the automatic close.

Can I fix a prematurely closed order over the REST API?

Not by writing to the order directly. There is no supported REST endpoint to set order state or status, and forcing it desyncs the sales_order_status_history and totals. The safe corrective action is on the invoice itself, capturing it with POST /rest/V1/invoices/{invoiceId}/capture if the payment was confirmed out of band, or voiding it if it should never be paid, then letting the next order save recalculate the state honestly.

Related field notes

Citations

On the problem:

  1. GitHub Issue: order with invoice status pending get closed after shipping. github.com/magento/magento2/issues/34055
  2. GitHub Issue: order status always Closed after creating invoice and shipment. github.com/magento/magento2/issues/22337
  3. GitHub Issue: order with invoice status pending get closed after shipping (follow up). github.com/magento/magento2/issues/35769

On the solution:

  1. Adobe Commerce: Invoice Service REST API reference. adobe-commerce.redoc.ly/2.4.7-admin/tag/invoices
  2. Adobe Commerce: searching with REST endpoints using searchCriteria. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
  3. Adobe Commerce user guide: order status and order state. experienceleague.adobe.com order status and order state

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, payments, catalog data, or inventory 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 closed order that was never paid?

If this saved you from writing off revenue that was quietly never collected, 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 Magento field notes