Skip to content

Diagnostic Orders and Payments

Order stuck on pending payment after invoice is paid

The invoice is right there, its state is Paid, and total_paid matches grand_total. But the order itself still reads new or pending_payment, so it does not enter your processing queue, does not trigger shipment, and shows up on every report as unpaid revenue that was, in fact, already collected. Here is why order state and invoice state can disagree, and a small script that finds every order stuck like this without touching a single record on its own.

Python and Node.js REST API, read only detection Safe by default (report first)
Holding a smartphone
Photo by Clay Banks on Unsplash
The short answer

Order state and invoice state are updated by two separate write paths in Magento 2 and Adobe Commerce. When a payment is captured through a webhook, a custom payment gateway module, or an out of process API call that creates or updates the invoice and sets Invoice::STATE_PAID without also calling $order->setState(Order::STATE_PROCESSING)->setStatus(...) and saving the order, the invoice and total_paid reflect the successful payment while order.state and status stay at new or pending_payment. A script can list candidate orders over GET /rest/V1/orders, fetch each one's invoices over GET /rest/V1/invoices, and flag the mismatch. It should not silently rewrite the order, since the same symptom can also mean a partial capture or a refund race. Full code, tests, and a dry run guard are below.

The problem in plain words

When Magento's own checkout flow places an order and captures a payment through a payment method integrated the standard way, one call chain does the whole job: Order::place() runs, the payment method authorizes or captures, an invoice is created and marked paid through Invoice::pay(), and that same code path also moves the order to Order::STATE_PROCESSING with a matching status and saves it. Order and invoice update together because one piece of code is responsible for both.

A lot of real integrations do not go through that single chain. A payment gateway sends an asynchronous webhook once the customer's bank confirms the charge. A custom payment module reacts to that webhook by creating or updating the invoice directly, since that is the visible, auditable record of the money. But if that module stops there, the invoice save updates total_invoiced, total_paid, and the invoice's own state field to 2 for paid, while nothing in that code path calls the order's setState and setStatus, and nothing saves the order object with the new state. Magento's order.state, the field almost everything else keys off, from processing queues to shipment eligibility to sales reports, is left exactly where it was: new or pending_payment.

Gateway webhook payment captured Invoice created state = STATE_PAID order.setState never called Order not saved state stays as is Stuck on pending payment total_paid matches total, order.state disagrees
The invoice write path and the order write path are two separate updates. When only the invoice half runs, the money looks collected but order.state never moves off pending payment.

Why it happens

This is a recurring, documented gap in Magento's core issue tracker and its community forum: order status not changing to processing after invoicing, and an invoice reporting paid while the order remains on pending payment. See the citations at the end for the specific threads.

The key insight

A paid invoice sitting next to a pending order is not always the same bug. It can be the write path gap described above, but it can also be a partial capture, a currency mismatch between what the gateway reports and what Magento expects, or a refund race that briefly leaves totals in an unusual shape. Because order.state is meant to be internally managed by Magento, a script should never rewrite it on a guess. The safe pattern is detect, report, and let a human confirm the gateway capture before anything writes.

The fix, as a flow

We do not touch the live order or payment flow. We add a job that lists orders still on new or pending_payment, cross checks each one against its invoices and totals, and reports every mismatch with the exact numbers an operator needs. Only behind a DRY_RUN=false guard, and only once a human has confirmed the gateway actually captured the funds, does it send a narrowly scoped write that touches nothing but state and status.

Scheduled job runs on a timer List pending orders status in new, pending_payment Fetch matching invoices by order_id Paid invoice or totals match? yes no, report ok Flag for review human confirms, then write
The script only flags a mismatch. A real state change is a separate, narrowly scoped write that only happens once a human has confirmed the gateway capture.

Build it step by step

1

Get an admin bearer token

Authenticate the way any Magento REST client does. 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 repair 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 repair 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 orders still on pending payment

Search for orders whose status is pending or pending_payment. Read back entity_id, increment_id, state, status, total_paid, grand_total, and total_invoiced, since the decision function needs all of them.

step3.py
def candidate_orders(page_size=100):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][value]": "pending,pending_payment",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
        "searchCriteria[pageSize]": page_size,
    }
    return magento_get("/orders", params)["items"]
step3.js
async function candidateOrders(pageSize = 100) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][value]": "pending,pending_payment",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
    "searchCriteria[pageSize]": pageSize,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}
4

Fetch each order's invoices

For every candidate order, ask for its invoices filtered by order_id. Read each invoice's state, an integer where 1 is Open, 2 is Paid, and 3 is Cancelled, along with grand_total.

step4.py
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"]
step4.js
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;
}
5

Decide, with one pure function

Keep the decision in its own function that takes plain order data and the matching invoices and returns a plain verdict. A pure function like this is easy to read and easy to test, which we do later. The order must be on new or pending_payment, and either a matched invoice must report state === 2, or the order's own totals must already show the money collected through totalPaid >= grandTotal or totalInvoiced >= grandTotal. Anything else is left alone.

decide.py
PENDING_STATES = ("new", "pending_payment")
STATE_PAID = 2


def detect_pending_payment_mismatch(order, invoices):
    matched = [inv for inv in invoices if inv["orderId"] == order["entityId"]]
    paid_invoice = next((inv for inv in matched if inv["state"] == STATE_PAID), None)

    paid_by_amount = (
        order["totalPaid"] >= order["grandTotal"]
        or order["totalInvoiced"] >= order["grandTotal"]
    )

    if order["state"] in PENDING_STATES and (paid_invoice or paid_by_amount):
        if paid_invoice:
            reason = f"matched invoice {paid_invoice['entityId']} is state 2 (paid)"
        else:
            reason = "total_paid or total_invoiced already meets grand_total"
        return {
            "isMismatched": True,
            "reason": reason,
            "matchedInvoiceId": paid_invoice["entityId"] if paid_invoice else None,
        }

    return {"isMismatched": False, "reason": None, "matchedInvoiceId": None}
decide.js
const PENDING_STATES = new Set(["new", "pending_payment"]);
const STATE_PAID = 2;

export function detectPendingPaymentMismatch(order, invoices) {
  const matched = invoices.filter((inv) => inv.orderId === order.entityId);
  const paidInvoice = matched.find((inv) => inv.state === STATE_PAID) || null;

  const paidByAmount =
    order.totalPaid >= order.grandTotal || order.totalInvoiced >= order.grandTotal;

  if (PENDING_STATES.has(order.state) && (paidInvoice || paidByAmount)) {
    const reason = paidInvoice
      ? `matched invoice ${paidInvoice.entityId} is state 2 (paid)`
      : "total_paid or total_invoiced already meets grand_total";
    return {
      isMismatched: true,
      reason,
      matchedInvoiceId: paidInvoice ? paidInvoice.entityId : null,
    };
  }

  return { isMismatched: false, reason: null, matchedInvoiceId: null };
}
6

Report by default, repair only when gated

The default output is a structured record per flagged order: its increment_id, entity_id, order_state, order_status, matched_invoice_id, invoice_state, total_paid, and grand_total, for an operator to review. Only when DRY_RUN is false and a human has confirmed the gateway capture does the script send PUT /rest/V1/orders with {"entity": {"entity_id": <id>, "state": "processing", "status": "processing"}}, a narrowly scoped write of only those two fields that never touches invoice or payment records.

Run it safe

Always start with DRY_RUN=true. A paid-invoice, pending-order mismatch can also be a partial capture, a currency mismatch, or a refund race, so treat every flagged order as a lead to check with the payment gateway before anyone writes the order to processing.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, lists candidate orders, cross checks each one against its invoices, respects the dry run flag, and is safe to run again and again because by default it only reports.

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.
detect_pending_payment_mismatch.py
"""Detect Magento 2 orders stuck on pending payment after their invoice is paid, safely.

Order state and invoice state are two separate write paths. When a payment
gateway webhook, a custom payment module, or an out of process API call
creates or updates an invoice and marks it paid without also calling
order.setState(processing).setStatus(...) and saving the order, the invoice
and total_paid reflect the successful payment while order.state and status
stay on new or pending_payment. This reports every mismatch by default and
only gates a real state change behind DRY_RUN=false plus a human confirming
the gateway capture. 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("detect_pending_payment_mismatch")

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

PENDING_STATES = ("new", "pending_payment")
STATE_PAID = 2


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 magento_put_order_state(entity_id, state, status):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1/orders",
        json={"entity": {"entity_id": entity_id, "state": state, "status": status}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def candidate_orders(page_size=100):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][value]": "pending,pending_payment",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
        "searchCriteria[pageSize]": page_size,
    }
    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 detect_pending_payment_mismatch(order, invoices):
    matched = [inv for inv in invoices if inv["orderId"] == order["entityId"]]
    paid_invoice = next((inv for inv in matched if inv["state"] == STATE_PAID), None)

    paid_by_amount = (
        order["totalPaid"] >= order["grandTotal"]
        or order["totalInvoiced"] >= order["grandTotal"]
    )

    if order["state"] in PENDING_STATES and (paid_invoice or paid_by_amount):
        if paid_invoice:
            reason = f"matched invoice {paid_invoice['entityId']} is state 2 (paid)"
        else:
            reason = "total_paid or total_invoiced already meets grand_total"
        return {
            "isMismatched": True,
            "reason": reason,
            "matchedInvoiceId": paid_invoice["entityId"] if paid_invoice else None,
        }

    return {"isMismatched": False, "reason": None, "matchedInvoiceId": None}


def to_plain_order(raw):
    return {
        "entityId": str(raw["entity_id"]),
        "incrementId": raw.get("increment_id", ""),
        "state": raw.get("state", ""),
        "status": raw.get("status", ""),
        "grandTotal": float(raw.get("grand_total") or 0),
        "totalPaid": float(raw.get("total_paid") or 0),
        "totalInvoiced": float(raw.get("total_invoiced") or 0),
    }


def to_plain_invoices(raw_items, order_entity_id):
    return [
        {
            "entityId": str(item["entity_id"]),
            "orderId": order_entity_id,
            "state": item.get("state"),
            "grandTotal": float(item.get("grand_total") or 0),
        }
        for item in raw_items
    ]


def run():
    flagged = 0
    for raw_order in candidate_orders():
        order = to_plain_order(raw_order)
        raw_invoices = invoices_for_order(order["entityId"])
        invoices = to_plain_invoices(raw_invoices, order["entityId"])

        result = detect_pending_payment_mismatch(order, invoices)
        if not result["isMismatched"]:
            continue

        flagged += 1
        log.warning(
            "Order %s (id=%s) state=%s status=%s total_paid=%s grand_total=%s matched_invoice=%s. %s",
            order["incrementId"], order["entityId"], order["state"], order["status"],
            order["totalPaid"], order["grandTotal"], result["matchedInvoiceId"],
            result["reason"],
        )

        if not DRY_RUN:
            log.warning(
                "DRY_RUN is false: writing order %s to state=processing, status=processing "
                "(confirm the gateway capture before enabling this).",
                order["incrementId"],
            )
            magento_put_order_state(order["entityId"], "processing", "processing")

    log.info("Done. %d order(s) flagged.", flagged)


if __name__ == "__main__":
    run()
detect-pending-payment-mismatch.js
/**
 * Detect Magento 2 orders stuck on pending payment after their invoice is paid, safely.
 *
 * Order state and invoice state are two separate write paths. When a payment
 * gateway webhook, a custom payment module, or an out of process API call
 * creates or updates an invoice and marks it paid without also calling
 * order.setState(processing).setStatus(...) and saving the order, the
 * invoice and total_paid reflect the successful payment while order.state
 * and status stay on new or pending_payment. This reports every mismatch by
 * default and only gates a real state change behind DRY_RUN=false plus a
 * human confirming the gateway capture. Run on a schedule. Safe to run
 * again and again.
 *
 * Guide: https://www.allanninal.dev/magento/order-stuck-pending-payment-after-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 PENDING_STATES = new Set(["new", "pending_payment"]);
const STATE_PAID = 2;

export function detectPendingPaymentMismatch(order, invoices) {
  const matched = invoices.filter((inv) => inv.orderId === order.entityId);
  const paidInvoice = matched.find((inv) => inv.state === STATE_PAID) || null;

  const paidByAmount =
    order.totalPaid >= order.grandTotal || order.totalInvoiced >= order.grandTotal;

  if (PENDING_STATES.has(order.state) && (paidInvoice || paidByAmount)) {
    const reason = paidInvoice
      ? `matched invoice ${paidInvoice.entityId} is state 2 (paid)`
      : "total_paid or total_invoiced already meets grand_total";
    return {
      isMismatched: true,
      reason,
      matchedInvoiceId: paidInvoice ? paidInvoice.entityId : null,
    };
  }

  return { isMismatched: false, reason: null, matchedInvoiceId: null };
}

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 magentoPutOrderState(entityId, state, status) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/orders`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ entity: { entity_id: entityId, state, status } }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function candidateOrders(pageSize = 100) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][value]": "pending,pending_payment",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
    "searchCriteria[pageSize]": pageSize,
  };
  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;
}

function toPlainOrder(raw) {
  return {
    entityId: String(raw.entity_id),
    incrementId: raw.increment_id || "",
    state: raw.state || "",
    status: raw.status || "",
    grandTotal: Number(raw.grand_total || 0),
    totalPaid: Number(raw.total_paid || 0),
    totalInvoiced: Number(raw.total_invoiced || 0),
  };
}

function toPlainInvoices(rawItems, orderEntityId) {
  return rawItems.map((item) => ({
    entityId: String(item.entity_id),
    orderId: orderEntityId,
    state: item.state,
    grandTotal: Number(item.grand_total || 0),
  }));
}

export async function run() {
  let flagged = 0;
  const rawOrders = await candidateOrders();

  for (const rawOrder of rawOrders) {
    const order = toPlainOrder(rawOrder);
    const rawInvoices = await invoicesForOrder(order.entityId);
    const invoices = toPlainInvoices(rawInvoices, order.entityId);

    const result = detectPendingPaymentMismatch(order, invoices);
    if (!result.isMismatched) continue;

    flagged++;
    console.warn(
      `Order ${order.incrementId} (id=${order.entityId}) state=${order.state} status=${order.status} ` +
      `total_paid=${order.totalPaid} grand_total=${order.grandTotal} matched_invoice=${result.matchedInvoiceId}. ${result.reason}`
    );

    if (!DRY_RUN) {
      console.warn(
        `DRY_RUN is false: writing order ${order.incrementId} to state=processing, status=processing ` +
        `(confirm the gateway capture before enabling this).`
      );
      await magentoPutOrderState(order.entityId, "processing", "processing");
    }
  }

  console.log(`Done. ${flagged} order(s) flagged.`);
}

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

Add a test

The detection rule is the part most worth testing, because it decides whether an order gets flagged for review. Because we kept detect_pending_payment_mismatch pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture objects and checks the answer.

test_order_mismatch.py
from detect_pending_payment_mismatch import detect_pending_payment_mismatch


def order(**over):
    base = {
        "entityId": "101",
        "incrementId": "000000101",
        "state": "pending_payment",
        "status": "pending_payment",
        "grandTotal": 150.0,
        "totalPaid": 0.0,
        "totalInvoiced": 0.0,
    }
    base.update(over)
    return base


def invoice(**over):
    base = {"entityId": "501", "orderId": "101", "state": 2, "grandTotal": 150.0}
    base.update(over)
    return base


def test_mismatched_when_paid_invoice_exists():
    result = detect_pending_payment_mismatch(order(), [invoice()])
    assert result["isMismatched"] is True
    assert result["matchedInvoiceId"] == "501"


def test_mismatched_when_totals_already_paid_with_no_invoice():
    o = order(totalPaid=150.0)
    result = detect_pending_payment_mismatch(o, [])
    assert result["isMismatched"] is True
    assert result["matchedInvoiceId"] is None


def test_not_mismatched_when_order_already_processing():
    o = order(state="processing")
    result = detect_pending_payment_mismatch(o, [invoice()])
    assert result["isMismatched"] is False


def test_not_mismatched_when_invoice_open():
    result = detect_pending_payment_mismatch(order(), [invoice(state=1)])
    assert result["isMismatched"] is False


def test_not_mismatched_when_invoice_belongs_to_other_order():
    result = detect_pending_payment_mismatch(order(), [invoice(orderId="999")])
    assert result["isMismatched"] is False


def test_not_mismatched_when_nothing_paid_yet():
    result = detect_pending_payment_mismatch(order(), [])
    assert result["isMismatched"] is False
order-mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectPendingPaymentMismatch } from "./detect-pending-payment-mismatch.js";

const order = (over = {}) => ({
  entityId: "101",
  incrementId: "000000101",
  state: "pending_payment",
  status: "pending_payment",
  grandTotal: 150.0,
  totalPaid: 0.0,
  totalInvoiced: 0.0,
  ...over,
});

const invoice = (over = {}) => ({
  entityId: "501",
  orderId: "101",
  state: 2,
  grandTotal: 150.0,
  ...over,
});

test("mismatched when paid invoice exists", () => {
  const result = detectPendingPaymentMismatch(order(), [invoice()]);
  assert.equal(result.isMismatched, true);
  assert.equal(result.matchedInvoiceId, "501");
});

test("mismatched when totals already paid with no invoice", () => {
  const result = detectPendingPaymentMismatch(order({ totalPaid: 150.0 }), []);
  assert.equal(result.isMismatched, true);
  assert.equal(result.matchedInvoiceId, null);
});

test("not mismatched when order already processing", () => {
  const result = detectPendingPaymentMismatch(order({ state: "processing" }), [invoice()]);
  assert.equal(result.isMismatched, false);
});

test("not mismatched when invoice open", () => {
  const result = detectPendingPaymentMismatch(order(), [invoice({ state: 1 })]);
  assert.equal(result.isMismatched, false);
});

test("not mismatched when invoice belongs to other order", () => {
  const result = detectPendingPaymentMismatch(order(), [invoice({ orderId: "999" })]);
  assert.equal(result.isMismatched, false);
});

test("not mismatched when nothing paid yet", () => {
  const result = detectPendingPaymentMismatch(order(), []);
  assert.equal(result.isMismatched, false);
});

Case studies

Webhook gap

The PayPal integration that never told the order

A B2B store took payments through a PayPal integration that listened for an asynchronous capture webhook and, on success, created and paid the invoice directly through internal service code. It worked for months, until finance flagged that dozens of orders showing full payment in the invoice report were still sitting in the fulfillment queue as pending payment.

The detection job found the exact gap: the webhook handler paid the invoice but never called the order's state transition. Once the missing setState and setStatus call was added to the handler, new orders stopped drifting, and the script kept running to catch any older orders the fix had not touched.

Custom gateway module

The Stripe module that skipped one line

A custom Stripe payment module handled authorization and capture correctly and created a matching invoice on success. A code review months later found the module's capture handler was missing the order state update present in Magento's own default flow, a gap that had been quietly producing a trickle of stuck orders since launch.

Running the script in dry run first surfaced the full backlog with each order's total_paid next to its grand_total, which the payments team used to confirm every flagged order was genuinely captured with Stripe before manually moving them, rather than trusting the script to guess.

What good looks like

After this runs on a schedule, an invoice that settles without its order following along is caught within one detection cycle instead of surviving silently in a fulfillment queue that never sees it. The report carries the order's increment id, its current state and status, the matched invoice, and the exact totals, so whoever responds can confirm the gateway capture fast and move the order with confidence. Keep the actual state change gated behind that human confirmation, since that is what keeps the script from rewriting an order that only looks paid.

FAQ

Why does my Magento order stay on pending payment after the invoice shows paid?

Order state and invoice state are updated by two separate write paths. When a payment gateway webhook, a custom payment module, or an out of process API call creates or updates the invoice directly, setting it to paid, without also calling order.setState(processing) and setStatus and saving the order, the invoice and total_paid reflect the successful payment while order.state and status are left at new or pending_payment. It is a known gap in integrations that bypass Magento's normal Order::place() to Invoice::pay() chain.

How do I detect these mismatched orders without touching anything?

List orders whose status is pending or pending_payment through GET /rest/V1/orders with a searchCriteria filter, then for each one fetch its invoices through GET /rest/V1/invoices filtered by order_id. Flag a mismatch when the order is not processing or complete and either a matched invoice has state 2 (paid), or total_invoiced or total_paid already meets or exceeds grand_total. This is a read only check, so it is safe to run as often as you like.

Is it safe to have a script automatically move these orders to processing?

Not automatically. A paid invoice next to a pending order can also mean a partial capture, a currency mismatch, or a refund race, so the state needs a human to confirm the gateway actually captured funds before anything writes. The script reports every flagged order by default, and only behind a DRY_RUN=false guard and a human confirmation does it send a narrowly scoped PUT to /rest/V1/orders that sets only state and status to processing, leaving invoice and payment records untouched.

Related field notes

Citations

On the problem:

  1. GitHub Issue: order status pending payment not changing to processing after invoicing. github.com/magento/magento2/issues/26541
  2. Magento Forums: invoice status is paid but order remains as pending payment. community.magento.com invoice status is paid but order remains pending payment
  3. GitHub Issue: order with invoice status pending gets closed after shipping. github.com/magento/magento2/issues/34055

On the solution:

  1. Adobe Commerce: Orders endpoints, REST API reference. developer.adobe.com/commerce/webapi/rest/quick-reference/orders
  2. Adobe Commerce: Invoices endpoints, REST API reference. developer.adobe.com/commerce/webapi/rest/quick-reference/invoices
  3. Adobe Commerce: search using REST endpoints, including searchCriteria. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches

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 clear your stuck orders?

If this saved you a confusing sales report or a fulfillment queue that never saw a 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 Magento field notes