Diagnostic Credit Memos and Refunds
Order status wrong after partial or zero total refund
A customer got a store-credit-only refund, or you refunded just the shipping, or a bundle item was partially credited back. The credit memo saved fine. But the order still says Processing days later, even though nothing is left to pay back. Or the opposite happens, one partial refund and the order jumps straight to Closed while most of the balance is still owed. Here is why Magento's own refund logic misreads these totals and a small script that finds every order it happened to.
Magento 2 derives order state and status largely from invoiced, shipped, and refunded totals through Magento\Sales\Model\Order::getIsInProcess(), Order::setState(), and the logic that runs on the sales_order_creditmemo_save_after event, rather than recomputing the status from a single authoritative rule every time a credit memo posts. A zero-total credit memo, a shipping-only refund, or a partial refund on a bundle or configurable item can make the check that compares total_refunded against total_paid come out wrong, leaving a fully refunded order on Processing or Complete, or, per the inverse Adobe Commerce bug, forcing an order to Closed after only a partial refund. There is no safe REST write for order state or status, so the fix here is to detect every affected order through /rest/V1/orders and /rest/V1/creditmemo, report it for review, and let a human trigger the correct transition in Admin. Full code, tests, and a dry run guard are below.
The problem in plain words
Magento does not have one place that says "here is this order's correct status, recompute it now." Instead the status falls out of a handful of totals the order carries around: how much was invoiced, how much was shipped, how much was refunded, how much was paid. Various pieces of core code, from Order::getIsInProcess() to the creditmemo total collectors under Magento\Sales\Model\Order\Creditmemo\Total\*, each look at a slice of those totals and nudge the state one way or another.
That works for the common case, refund the whole order in one credit memo and it closes cleanly. It falls apart at the edges. A credit memo can legitimately have a zero grand total, for example when a refund is issued entirely as store credit rather than back to the original payment method. A refund can cover only shipping, leaving every line item's refunded amount at zero even though the customer got money back. A bundle or configurable product can be partially refunded on some of its sub items but not others, which muddies what "fully refunded" even means for that line. In each of these cases the comparison between total_refunded and total_paid that core code relies on does not line up cleanly, and the order status stops matching reality.
Why it happens
- A refund issued entirely as store credit, so the credit memo's
grand_totalandbase_grand_totalare zero even though the customer's balance is settled. - A refund of shipping only, or of a small fee, which does not move
total_refundedanywhere close tototal_paidyet is, in context, the entire refund the order was ever going to get. - A partial refund on a bundle or configurable product's sub items, where some child line items are credited and others are not, so what counts as "fully refunded" for that parent line is ambiguous to the totals-based check.
- The reverse defect, tracked by Adobe as ACSD-49392, where a genuinely partial refund on an order gets the order forced to Closed anyway, when a partial refund alone should never close an order on its own.
This is a long-standing, repeatedly reported core bug rather than a merchant configuration mistake. It shows up across several separate GitHub issues describing the same family of symptom: a credit memo posts, the totals look fine in the credit memo itself, and the order's status simply does not follow. See the citations at the end for the specific threads and the Adobe Commerce patch notes.
There is no single rule engine in Magento that says "a credit memo just posted, here is what the order's status should be, set it." Instead the status is an emergent property of several totals-based checks scattered across Order::setState(), Order::getIsInProcess(), and the creditmemo total collectors, and those checks were written assuming a normal, full-value refund. Detecting the defect means comparing the same totals Magento already exposes over REST, total_refunded, total_paid, total_invoiced, against the order's actual status, and also checking whether any of the order's credit memos has a zero grand_total, since that is the shape that most often confuses the core logic.
The fix, as a flow
We do not touch order.state or order.status directly. Order state transitions are core business logic gated by Magento's internal state machine, configured in sales.xml under <order><states>, and there is no supported, documented REST write for status alone. Instead we add a job that lists candidate orders, pulls each one's credit memos, runs the same totals comparison a human would, and reports every mismatch in either direction so an admin can correct it from the Credit Memos grid or the order view.
Build it step by step
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.
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 status history comment
// 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 status history comment
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.
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()
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();
}
List candidate orders, then read credit memos for each one
Filter /rest/V1/orders with a searchCriteria filter on status in (processing, complete), paging with pageSize and currentPage. For every hit, fetch its credit memos from /rest/V1/creditmemo filtered by order_id, reading back grand_total, base_grand_total, and state.
def candidate_orders(page_size=200):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing,complete",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": 1,
}
return magento_get("/orders", params)["items"]
def creditmemos_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("/creditmemo", params)["items"]
async function candidateOrders(pageSize = 200) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing,complete",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": 1,
};
const data = await magentoGet("/orders", params);
return data.items;
}
async function creditmemosForOrder(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("/creditmemo", params);
return data.items;
}
Decide, with one pure function
Keep the decision in its own function that takes the order's totals, its credit memos, and its current status, and returns the expected status plus whether that disagrees with reality. A pure function like this is easy to read and easy to test, which we do later. Nothing invoiced yet means no refund-driven transition applies at all, so we leave the status alone in that case. Money math uses a small epsilon since floats never compare exactly.
EPSILON = 0.01
def expected_order_status(order_totals, credit_memos, current_status):
total_invoiced = order_totals.get("totalInvoiced", 0) or 0
total_paid = order_totals.get("totalPaid", 0) or 0
total_refunded = order_totals.get("totalRefunded", 0) or 0
if total_invoiced <= 0:
return {"expected": current_status, "isMismatch": False}
is_fully_refunded = total_refunded >= total_paid - EPSILON
has_zero_total_memo = any(cm.get("grandTotal") == 0 for cm in credit_memos)
if credit_memos and has_zero_total_memo and total_refunded >= total_paid - EPSILON:
is_fully_refunded = True
if is_fully_refunded:
expected = "closed"
elif 0 < total_refunded < total_paid - EPSILON:
expected = "processing" if current_status == "closed" else current_status
else:
expected = current_status
return {"expected": expected, "isMismatch": expected != current_status}
const EPSILON = 0.01;
export function expectedOrderStatus(orderTotals, creditMemos, currentStatus) {
const totalInvoiced = orderTotals.totalInvoiced || 0;
const totalPaid = orderTotals.totalPaid || 0;
const totalRefunded = orderTotals.totalRefunded || 0;
if (totalInvoiced <= 0) {
return { expected: currentStatus, isMismatch: false };
}
let isFullyRefunded = totalRefunded >= totalPaid - EPSILON;
const hasZeroTotalMemo = creditMemos.some((cm) => cm.grandTotal === 0);
if (creditMemos.length > 0 && hasZeroTotalMemo && totalRefunded >= totalPaid - EPSILON) {
isFullyRefunded = true;
}
let expected;
if (isFullyRefunded) {
expected = "closed";
} else if (totalRefunded > 0 && totalRefunded < totalPaid - EPSILON) {
expected = currentStatus === "closed" ? "processing" : currentStatus;
} else {
expected = currentStatus;
}
return { expected, isMismatch: expected !== currentStatus };
}
Report by default, leave the transition to Admin
The default output is one report row per mismatched order: increment_id, entity_id, current status, expected status, and the totals that drove the decision. There is no safe REST write for order status alone, so this job never sets it directly. If a human confirms the mismatch, the only optional write, and only outside dry run, is a status history comment added through PUT /rest/V1/orders/{id} with a status_histories entry, so the note shows up on the order for whoever triggers the real transition from the Credit Memos grid or the order view's Close order action.
def add_status_history_comment(order_id, comment):
payload = {
"entity": {
"entity_id": order_id,
"status_histories": [
{"comment": comment, "is_customer_notified": 0, "is_visible_on_front": 0}
],
}
}
r = requests.put(
f"{MAGENTO_URL}/rest/V1/orders/{order_id}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
async function addStatusHistoryComment(orderId, comment) {
const payload = {
entity: {
entity_id: orderId,
status_histories: [
{ comment, is_customer_notified: 0, is_visible_on_front: 0 },
],
},
};
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders/${orderId}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Always start with DRY_RUN=true. Treat every flagged order as a report for a human to review first. Never attempt to set order.status or order.state directly through a public REST write, Magento does not expose a supported one for status alone and forcing it can desync the order's state from its status. The only optional write this job makes is a visible-to-staff status history comment, never a status change, and only once a human is ready to act on it.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, lists processing and complete orders, cross checks each one's credit memos and totals, reports every status mismatch, and only adds a status history comment when DRY_RUN is false.
"""Flag Magento 2 orders whose status disagrees with their refund totals.
Magento derives order state and status largely from totals such as
total_refunded and total_paid, via Order::getIsInProcess(), Order::setState(),
and the creditmemo save observers, rather than recomputing status from a
single authoritative rule each time a credit memo posts. A zero-total credit
memo (store-credit-only refunds), a shipping-only refund, or a partial refund
on a bundle/configurable item can make the totals comparison come out wrong,
leaving a fully refunded order on Processing or Complete, or forcing an
order to Closed after only a partial refund.
There is no safe REST write for order.status alone, so this reports by
default. The only optional write is a status history comment, and only when
DRY_RUN is false. 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_status_after_refund")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EPSILON = 0.01
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 expected_order_status(order_totals, credit_memos, current_status):
total_invoiced = order_totals.get("totalInvoiced", 0) or 0
total_paid = order_totals.get("totalPaid", 0) or 0
total_refunded = order_totals.get("totalRefunded", 0) or 0
if total_invoiced <= 0:
return {"expected": current_status, "isMismatch": False}
is_fully_refunded = total_refunded >= total_paid - EPSILON
has_zero_total_memo = any(cm.get("grandTotal") == 0 for cm in credit_memos)
if credit_memos and has_zero_total_memo and total_refunded >= total_paid - EPSILON:
is_fully_refunded = True
if is_fully_refunded:
expected = "closed"
elif 0 < total_refunded < total_paid - EPSILON:
expected = "processing" if current_status == "closed" else current_status
else:
expected = current_status
return {"expected": expected, "isMismatch": expected != current_status}
def candidate_orders(page_size=200):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing,complete",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": 1,
}
return magento_get("/orders", params)["items"]
def creditmemos_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("/creditmemo", params)["items"]
def add_status_history_comment(order_id, comment):
payload = {
"entity": {
"entity_id": order_id,
"status_histories": [
{"comment": comment, "is_customer_notified": 0, "is_visible_on_front": 0}
],
}
}
r = requests.put(
f"{MAGENTO_URL}/rest/V1/orders/{order_id}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
flagged = 0
for order in candidate_orders():
order_id = order.get("entity_id")
current_status = order.get("status")
memos = creditmemos_for_order(order_id)
credit_memos = [{"grandTotal": m.get("grand_total"), "state": m.get("state")} for m in memos]
order_totals = {
"totalInvoiced": order.get("total_invoiced"),
"totalPaid": order.get("total_paid"),
"totalRefunded": order.get("total_refunded"),
}
result = expected_order_status(order_totals, credit_memos, current_status)
if not result["isMismatch"]:
continue
comment = (
f"Flagged: total_refunded={order_totals['totalRefunded']} "
f"total_paid={order_totals['totalPaid']} status={current_status} "
f"expected={result['expected']}"
)
log.warning(
"Order %s (id=%s) status mismatch: current=%s expected=%s total_refunded=%s total_paid=%s. %s",
order.get("increment_id"), order_id, current_status, result["expected"],
order_totals["totalRefunded"], order_totals["totalPaid"],
"would add status history comment" if DRY_RUN else "adding status history comment",
)
if not DRY_RUN:
add_status_history_comment(order_id, comment)
flagged += 1
log.info("Done. %d order(s) flagged with a status mismatch after refund.", flagged)
if __name__ == "__main__":
run()
/**
* Flag Magento 2 orders whose status disagrees with their refund totals.
*
* Magento derives order state and status largely from totals such as
* total_refunded and total_paid, via Order::getIsInProcess(), Order::setState(),
* and the creditmemo save observers, rather than recomputing status from a
* single authoritative rule each time a credit memo posts. A zero-total credit
* memo (store-credit-only refunds), a shipping-only refund, or a partial
* refund on a bundle/configurable item can make the totals comparison come
* out wrong, leaving a fully refunded order on Processing or Complete, or
* forcing an order to Closed after only a partial refund.
*
* There is no safe REST write for order.status alone, so this reports by
* default. The only optional write is a status history comment, and only
* when DRY_RUN is false. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/order-status-wrong-after-refund/
*/
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 EPSILON = 0.01;
export function expectedOrderStatus(orderTotals, creditMemos, currentStatus) {
const totalInvoiced = orderTotals.totalInvoiced || 0;
const totalPaid = orderTotals.totalPaid || 0;
const totalRefunded = orderTotals.totalRefunded || 0;
if (totalInvoiced <= 0) {
return { expected: currentStatus, isMismatch: false };
}
let isFullyRefunded = totalRefunded >= totalPaid - EPSILON;
const hasZeroTotalMemo = creditMemos.some((cm) => cm.grandTotal === 0);
if (creditMemos.length > 0 && hasZeroTotalMemo && totalRefunded >= totalPaid - EPSILON) {
isFullyRefunded = true;
}
let expected;
if (isFullyRefunded) {
expected = "closed";
} else if (totalRefunded > 0 && totalRefunded < totalPaid - EPSILON) {
expected = currentStatus === "closed" ? "processing" : currentStatus;
} else {
expected = currentStatus;
}
return { expected, isMismatch: expected !== currentStatus };
}
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 candidateOrders(pageSize = 200) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": "processing,complete",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": 1,
};
const data = await magentoGet("/orders", params);
return data.items;
}
async function creditmemosForOrder(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("/creditmemo", params);
return data.items;
}
async function addStatusHistoryComment(orderId, comment) {
const payload = {
entity: {
entity_id: orderId,
status_histories: [
{ comment, is_customer_notified: 0, is_visible_on_front: 0 },
],
},
};
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders/${orderId}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
export async function run() {
let flagged = 0;
const orders = await candidateOrders();
for (const order of orders) {
const orderId = order.entity_id;
const currentStatus = order.status;
const memos = await creditmemosForOrder(orderId);
const creditMemos = memos.map((m) => ({ grandTotal: m.grand_total, state: m.state }));
const orderTotals = {
totalInvoiced: order.total_invoiced,
totalPaid: order.total_paid,
totalRefunded: order.total_refunded,
};
const result = expectedOrderStatus(orderTotals, creditMemos, currentStatus);
if (!result.isMismatch) continue;
const comment = `Flagged: total_refunded=${orderTotals.totalRefunded} total_paid=${orderTotals.totalPaid} status=${currentStatus} expected=${result.expected}`;
console.warn(
`Order ${order.increment_id} (id=${orderId}) status mismatch: current=${currentStatus} expected=${result.expected} total_refunded=${orderTotals.totalRefunded} total_paid=${orderTotals.totalPaid}. ${
DRY_RUN ? "would add status history comment" : "adding status history comment"
}`
);
if (!DRY_RUN) await addStatusHistoryComment(orderId, comment);
flagged++;
}
console.log(`Done. ${flagged} order(s) flagged with a status mismatch after refund.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get surfaced as a defect. Because we kept expected_order_status pure, the test needs no network and no Magento store. It just feeds in plain fixtures and checks the answer, matching the reported GitHub scenarios directly: a zero-total memo, a partial bundle-item memo, and a fully-refunded order still stuck on Processing.
from flag_status_after_refund import expected_order_status
def totals(**over):
base = {"totalInvoiced": 100.0, "totalPaid": 100.0, "totalRefunded": 0.0}
base.update(over)
return base
def test_nothing_invoiced_yet_is_never_a_mismatch():
result = expected_order_status(totals(totalInvoiced=0, totalPaid=0), [], "pending")
assert result["isMismatch"] is False
assert result["expected"] == "pending"
def test_fully_refunded_but_still_processing_is_a_mismatch():
result = expected_order_status(totals(totalRefunded=100.0), [{"grandTotal": 100.0}], "processing")
assert result["expected"] == "closed"
assert result["isMismatch"] is True
def test_zero_total_memo_covering_full_balance_is_treated_as_fully_refunded():
result = expected_order_status(totals(totalRefunded=100.0), [{"grandTotal": 0.0}], "complete")
assert result["expected"] == "closed"
assert result["isMismatch"] is True
def test_partial_refund_never_forces_closed_on_its_own():
result = expected_order_status(totals(totalRefunded=40.0), [{"grandTotal": 40.0}], "closed")
assert result["expected"] == "processing"
assert result["isMismatch"] is True
def test_partial_refund_leaves_processing_alone():
result = expected_order_status(totals(totalRefunded=40.0), [{"grandTotal": 40.0}], "processing")
assert result["expected"] == "processing"
assert result["isMismatch"] is False
def test_already_closed_and_fully_refunded_is_not_a_mismatch():
result = expected_order_status(totals(totalRefunded=100.0), [{"grandTotal": 100.0}], "closed")
assert result["isMismatch"] is False
def test_no_refund_at_all_is_not_a_mismatch():
result = expected_order_status(totals(), [], "processing")
assert result["isMismatch"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { expectedOrderStatus } from "./flag-status-after-refund.js";
const totals = (over = {}) => ({ totalInvoiced: 100.0, totalPaid: 100.0, totalRefunded: 0.0, ...over });
test("nothing invoiced yet is never a mismatch", () => {
const result = expectedOrderStatus(totals({ totalInvoiced: 0, totalPaid: 0 }), [], "pending");
assert.equal(result.isMismatch, false);
assert.equal(result.expected, "pending");
});
test("fully refunded but still processing is a mismatch", () => {
const result = expectedOrderStatus(totals({ totalRefunded: 100.0 }), [{ grandTotal: 100.0 }], "processing");
assert.equal(result.expected, "closed");
assert.equal(result.isMismatch, true);
});
test("zero total memo covering full balance is treated as fully refunded", () => {
const result = expectedOrderStatus(totals({ totalRefunded: 100.0 }), [{ grandTotal: 0.0 }], "complete");
assert.equal(result.expected, "closed");
assert.equal(result.isMismatch, true);
});
test("partial refund never forces closed on its own", () => {
const result = expectedOrderStatus(totals({ totalRefunded: 40.0 }), [{ grandTotal: 40.0 }], "closed");
assert.equal(result.expected, "processing");
assert.equal(result.isMismatch, true);
});
test("partial refund leaves processing alone", () => {
const result = expectedOrderStatus(totals({ totalRefunded: 40.0 }), [{ grandTotal: 40.0 }], "processing");
assert.equal(result.expected, "processing");
assert.equal(result.isMismatch, false);
});
test("already closed and fully refunded is not a mismatch", () => {
const result = expectedOrderStatus(totals({ totalRefunded: 100.0 }), [{ grandTotal: 100.0 }], "closed");
assert.equal(result.isMismatch, false);
});
test("no refund at all is not a mismatch", () => {
const result = expectedOrderStatus(totals(), [], "processing");
assert.equal(result.isMismatch, false);
});
Case studies
The store that refunded entirely to store credit
A fashion retailer let customer service issue refunds as store credit instead of back to the card, mainly to keep the sale on the books and encourage a repeat purchase. Each of those credit memos had a grand_total of zero, since no money moved back to the original payment method. The orders behind them stayed on Processing indefinitely, even though customer service considered the case fully resolved.
Running the detection job weekly surfaced every one of these orders as a mismatch, expected status closed, current status processing. Support used the report to close them out from the order view in one pass, and stopped seeing them resurface in the "orders needing attention" filter every week.
The bundle where only one component was refunded
A furniture store sold bundles where a customer could return one accessory from a multi-item bundle without returning the whole set. The credit memo against that sub item was small relative to the order's grand total, and the core totals comparison never recognized the refund as complete for the order as a whole, even though the customer's specific complaint was closed.
The team ran the script in dry run first, reviewed the small number of orders it flagged as still Processing with a partial refund on record, and used the report to decide case by case whether the order should move to Closed or stay open pending further action, rather than guessing from the credit memo total alone.
After this runs on a schedule, a refund that leaves the order status wrong, in either direction, gets caught within one detection cycle instead of sitting quietly in the order grid. The report carries the increment id, the current and expected status, and the totals that drove the decision, so whoever reviews it can act fast. The order's status itself is never touched directly, only a note is added for staff, which keeps the real transition in the hands of a human using the same Credit Memos grid and Close order action Magento already provides.
FAQ
Why does my Magento order stay Processing after it was fully refunded?
Magento derives order state from totals such as total_refunded and total_paid rather than recomputing it from a single authoritative rule each time a credit memo posts. When the refund includes a zero-total credit memo, such as a store-credit-only refund or a refund of shipping only, the comparison the core code makes between total_refunded and total_paid can fail to recognize the order as fully refunded, so it never transitions to Closed and is left on Processing or Complete instead.
How do I find orders with the wrong status after a refund?
Pull orders in status processing or complete from the REST API, then for each one fetch its credit memos filtered by order_id. Compare the sum of credit memo totals and the order's own total_refunded, total_paid, and total_invoiced fields against a simple rule: if the order is fully refunded but not marked closed, or if it was only partially refunded but was forced to closed, the status disagrees with the totals and the order should be flagged.
Can I fix the order status directly over the Magento REST API?
Not safely. Order state and status are gated by Magento's internal state machine defined in sales.xml and Order::setState, and there is no supported public REST write for status alone. POSTing an arbitrary status back through /rest/V1/orders can desync order state and status. The safe path is to report the mismatch, optionally add a status history comment through PUT /rest/V1/orders/{id}, and let a human trigger the correct transition from the Credit Memos grid or the order view in Admin.
Related field notes
Citations
On the problem:
- GitHub Issue: Credit Memo with Zero Total, order status Complete and not Closed. github.com/magento/magento2/issues/22762
- GitHub Issue: order remains in status processing after shipping, if items get partially refunded. github.com/magento/magento2/issues/35528
- Adobe Commerce: ACSD-49392, order status changes to closed after partial refund. experienceleague.adobe.com ACSD-49392
On the solution:
- Adobe Commerce: Credit memos in the Admin. experienceleague.adobe.com credit memos
- Adobe Commerce: order status reference. experienceleague.adobe.com order status
- Adobe Commerce: order workflow and processing. experienceleague.adobe.com order processing
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.
Did this catch a status that never matched the refund?
If this saved you from chasing a report that never reconciled, 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