Diagnostic Fulfillment & Returns
Fulfillment status stays Delivered after a full return and refund
Every item on the order came back. The warehouse checked it in, support issued the refund, the customer got their money. By every measure the order is closed out. But open it in the Medusa admin and the fulfillment status still reads Delivered, as if none of that happened. Reports built off fulfillment status count it as a completed sale that shipped clean, and nobody can tell just by glancing at the order that the whole thing was reversed. Here is why Medusa v2 leaves fulfillment status frozen like this and a small script that finds the fully returned orders and fixes the picture safely.
In Medusa v2, an order's fulfillment_status is computed from its fulfillment records, shipped and delivered quantities, and it is never recomputed as part of receiving a return or issuing a refund. Those are two separate workflows that touch the Return and the order's payment summary, not the fulfillment side. So a fully returned and fully refunded order keeps whatever fulfillment status its last shipment event left it at, almost always delivered. Run a script that lists orders with their fulfillments, items, and returns expanded, checks that every fulfilled item has a matching received quantity on a completed return and that the refund on the order summary covers it, and only for those fully closed orders does it flag or relabel the order so reporting reflects reality. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa v2 keeps fulfillment as its own concern. An order's fulfillment_status field, values like not_fulfilled, fulfilled, partially_shipped, shipped, partially_delivered, and delivered, exists to answer one question: how much of what was fulfilled has shipped and been delivered. It has nothing to say about whether the customer kept the goods.
A return and a refund are a different story entirely. Receiving a return updates the Return record's received quantities. Issuing a refund updates the order's payment summary, its refunded_total and accounting_total. Neither of those two workflows recomputes fulfillment_status, because nothing in Medusa's core wires a completed return back into the fulfillment state machine. The order is fully unwound in every way that matters to the customer and to finance, and the fulfillment status just sits there saying Delivered.
Why it happens
Fulfillment status in Medusa v2 is derived state, updated only by the fulfillment workflows themselves. A few common ways an order ends up stuck on Delivered after it should read as returned:
- A return is created and received through
receiveReturnWorkflow, which updates the Return's received quantities and the order's inventory, but does not touch the order'sfulfillment_statusfield at all. - A refund is issued separately through the payment side, updating
summary.refunded_total, with no link back to the fulfillment state machine that would tell it the shipped items came back. - Staff exchange or return an order manually across two different admin screens, the Returns screen and the Payments screen, and never expect either one to change what the Fulfillment tab shows.
- Custom storefronts or support tools that call the return and refund routes directly, skipping any of the order's own read models, leave the cached
fulfillment_statusexactly where the last shipment event set it.
None of this is a data corruption bug. Every individual record, the Return, the refund, the original Fulfillment, is internally correct. The gap is that fulfillment_status was never designed to answer the question "did the customer keep this," only "did we ship and deliver it." See the citations at the end for the exact docs and references.
Because fulfillment_status is a computed field driven by Medusa's own fulfillment workflows, hand editing it directly on the order row would fight the next fulfillment event and could silently drift again. The safe repair is never a raw field write. It is a read-only check that an order's return is fully received and its refund fully covers the returned items, followed by a targeted, idempotent update, either a status flag your own systems trust or a supported admin action, applied only to orders that are truly done. Anything partially returned, partially refunded, or still missing a received quantity is left alone for a human.
The fix, as a flow
We do not touch the live storefront or the return and refund routes themselves. The job lists orders with their fulfillments, items, and returns expanded, runs a pure decision function that compares fulfilled quantities against received quantities and the refund total against the returned value, and only for orders that are fully returned and fully refunded does it flag the mismatch for repair. Everything still in progress is left for the normal return flow to finish.
Build it step by step
Authenticate against the Admin API
Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to write
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write
List orders with fulfillments, items, and returns expanded
Ask for orders with fulfillment_status, summary, the order's items, its fulfillments, and its returns with their line items expanded. This is the only place both sides of the truth show up together: what was fulfilled and delivered, and what actually came back. Paginate with limit and offset.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_orders_with_returns(token):
orders = []
offset = 0
limit = 100
fields = "id,display_id,fulfillment_status,summary,*items,*returns,*returns.items"
while True:
data = admin_get(token, "/admin/orders", {
"fields": fields,
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
const FIELDS = "id,display_id,fulfillment_status,summary,*items,*returns,*returns.items";
async function listOrdersWithReturns() {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const { orders: page, count } = await sdk.admin.order.list({
fields: FIELDS,
limit,
offset,
});
orders.push(...page);
offset += limit;
if (offset >= count) return orders;
}
}
Cross-check against the returns themselves
Before flagging anything, look at /admin/returns?order_id= to confirm each return's own status is received, not still requested or partially_received. A return that has not fully landed at the warehouse should never be treated as a reason to touch fulfillment status.
def fully_received_returns(token, order_id):
data = admin_get(token, "/admin/returns", {
"order_id": order_id,
"fields": "id,status,*items",
"limit": 50,
})
returns = data.get("returns") or []
return [r for r in returns if r.get("status") == "received"]
async function fullyReceivedReturns(orderId) {
const { returns = [] } = await sdk.admin.return.list({
order_id: orderId,
fields: "id,status,*items",
limit: 50,
});
return returns.filter((r) => r.status === "received");
}
Decide, with one pure function
Keep the comparison in its own function that takes only the order's items, its received returns, and its refund summary, never touches the network, and returns a plain decision. Sum the quantity fulfilled per line item, sum the quantity received back across every completed return, and check the refunded total against the returned value. An order is stuck_delivered only when every fulfilled unit has a matching received unit, the refund covers it, and fulfillment_status still reads delivered or partially_delivered. Anything still missing a received unit is in_progress and left alone.
EPSILON = 0.01
STUCK_STATUSES = {"delivered", "partially_delivered"}
def decide_fulfillment_repair(order):
"""Pure decision function. No I/O.
order: {
"id": str,
"fulfillment_status": str,
"summary": {"refunded_total": float},
"items": [{"id": str, "quantity": float, "unit_price": float}],
"returns": [{"status": str, "items": [{"item_id": str, "quantity": float}]}],
}
Returns {"orderId", "isStuck", "fulfilledQty", "receivedQty",
"returnedValue", "refundedTotal", "reason"} where reason is one of
"stuck_delivered" | "in_progress" | "not_returned".
"""
items = order.get("items") or []
fulfilled_qty = sum(item.get("quantity", 0) for item in items)
price_by_item = {item.get("id"): item.get("unit_price", 0) for item in items}
received_qty = 0.0
returned_value = 0.0
for ret in order.get("returns") or []:
if ret.get("status") != "received":
continue
for line in ret.get("items") or []:
qty = line.get("quantity", 0)
received_qty += qty
returned_value += qty * price_by_item.get(line.get("item_id"), 0)
refunded_total = (order.get("summary") or {}).get("refunded_total", 0)
status = order.get("fulfillment_status")
if received_qty <= 0:
reason = "not_returned"
is_stuck = False
elif received_qty + EPSILON < fulfilled_qty:
reason = "in_progress"
is_stuck = False
elif refunded_total + EPSILON < returned_value:
reason = "in_progress"
is_stuck = False
elif status in STUCK_STATUSES:
reason = "stuck_delivered"
is_stuck = True
else:
reason = "not_returned"
is_stuck = False
return {
"orderId": order.get("id"),
"isStuck": is_stuck,
"fulfilledQty": fulfilled_qty,
"receivedQty": received_qty,
"returnedValue": returned_value,
"refundedTotal": refunded_total,
"reason": reason,
}
const EPSILON = 0.01;
const STUCK_STATUSES = new Set(["delivered", "partially_delivered"]);
/**
* Pure decision function. No I/O.
*
* @param {{
* id: string,
* fulfillment_status: string,
* summary: { refunded_total: number },
* items: Array<{ id: string, quantity: number, unit_price: number }>,
* returns: Array<{ status: string, items: Array<{ item_id: string, quantity: number }> }>,
* }} order
* @returns {{ orderId: string, isStuck: boolean, fulfilledQty: number, receivedQty: number,
* returnedValue: number, refundedTotal: number,
* reason: "stuck_delivered" | "in_progress" | "not_returned" }}
*/
export function decideFulfillmentRepair(order) {
const items = order.items || [];
const fulfilledQty = items.reduce((sum, item) => sum + (item.quantity || 0), 0);
const priceByItem = new Map(items.map((item) => [item.id, item.unit_price || 0]));
let receivedQty = 0;
let returnedValue = 0;
for (const ret of order.returns || []) {
if (ret.status !== "received") continue;
for (const line of ret.items || []) {
const qty = line.quantity || 0;
receivedQty += qty;
returnedValue += qty * (priceByItem.get(line.item_id) || 0);
}
}
const refundedTotal = order.summary ? order.summary.refunded_total || 0 : 0;
const status = order.fulfillment_status;
let reason;
let isStuck;
if (receivedQty <= 0) {
reason = "not_returned";
isStuck = false;
} else if (receivedQty + EPSILON < fulfilledQty) {
reason = "in_progress";
isStuck = false;
} else if (refundedTotal + EPSILON < returnedValue) {
reason = "in_progress";
isStuck = false;
} else if (STUCK_STATUSES.has(status)) {
reason = "stuck_delivered";
isStuck = true;
} else {
reason = "not_returned";
isStuck = false;
}
return {
orderId: order.id,
isStuck,
fulfilledQty,
receivedQty,
returnedValue,
refundedTotal,
reason,
};
}
Repair only through a supported action, never a raw field write
When an order is flagged stuck_delivered, do not update fulfillment_status on the order row directly. Instead add a review tag through /admin/orders/{id} that your own reporting and support tooling can trust, for example returned-and-refunded, so dashboards stop counting it as a clean delivered sale. If your Medusa version exposes a supported cancel or archive action for fully returned fulfillments, prefer that over any custom field write.
def admin_post(token, path, json_body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
json=json_body,
timeout=30,
)
r.raise_for_status()
return r.json()
def tag_returned_and_refunded(token, order_id, review_tag):
return admin_post(token, f"/admin/orders/{order_id}", {
"metadata": {review_tag: True},
})
async function tagReturnedAndRefunded(orderId, reviewTag) {
return sdk.admin.order.update(orderId, {
metadata: { [reviewTag]: true },
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {order_id, reason} pairs it would tag. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often returns close out, for example once an hour.
Always start with DRY_RUN=true. Only act on orders classified stuck_delivered, where every fulfilled unit has a matching received unit on a completed return and the refund already covers the returned value. Never touch an order classified in_progress, since the return may still be on its way back, and never write to fulfillment_status directly. A metadata tag or a supported admin action keeps the repair reversible and easy to audit.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever tags orders that are fully returned and fully refunded.
"""Flag Medusa orders whose fulfillment status is stuck on Delivered after a
full return and refund.
In Medusa v2, order.fulfillment_status is derived only from fulfillment
records, shipped and delivered quantities. Receiving a return through
receiveReturnWorkflow updates the Return's received quantities, and issuing a
refund updates the order's payment summary, but neither workflow recomputes
fulfillment_status. A fully returned, fully refunded order can sit forever
showing delivered as if the customer still has the goods. This lists orders
with items, fulfillments, and returns expanded, flags any order where every
fulfilled unit has a matching received unit on a completed return and the
refund covers it, and tags only those orders for review. It never writes
fulfillment_status directly.
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_stuck_fulfillment")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
REVIEW_TAG = os.environ.get("REVIEW_TAG", "returned-and-refunded")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EPSILON = 0.01
STUCK_STATUSES = {"delivered", "partially_delivered"}
ORDER_FIELDS = "id,display_id,fulfillment_status,summary,*items,*returns,*returns.items"
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def admin_post(token, path, json_body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
json=json_body,
timeout=30,
)
r.raise_for_status()
return r.json()
def decide_fulfillment_repair(order):
"""Pure decision function. No I/O.
order: {
"id": str,
"fulfillment_status": str,
"summary": {"refunded_total": float},
"items": [{"id": str, "quantity": float, "unit_price": float}],
"returns": [{"status": str, "items": [{"item_id": str, "quantity": float}]}],
}
Returns {"orderId", "isStuck", "fulfilledQty", "receivedQty",
"returnedValue", "refundedTotal", "reason"} where reason is one of
"stuck_delivered" | "in_progress" | "not_returned".
"""
items = order.get("items") or []
fulfilled_qty = sum(item.get("quantity", 0) for item in items)
price_by_item = {item.get("id"): item.get("unit_price", 0) for item in items}
received_qty = 0.0
returned_value = 0.0
for ret in order.get("returns") or []:
if ret.get("status") != "received":
continue
for line in ret.get("items") or []:
qty = line.get("quantity", 0)
received_qty += qty
returned_value += qty * price_by_item.get(line.get("item_id"), 0)
refunded_total = (order.get("summary") or {}).get("refunded_total", 0)
status = order.get("fulfillment_status")
if received_qty <= 0:
reason = "not_returned"
is_stuck = False
elif received_qty + EPSILON < fulfilled_qty:
reason = "in_progress"
is_stuck = False
elif refunded_total + EPSILON < returned_value:
reason = "in_progress"
is_stuck = False
elif status in STUCK_STATUSES:
reason = "stuck_delivered"
is_stuck = True
else:
reason = "not_returned"
is_stuck = False
return {
"orderId": order.get("id"),
"isStuck": is_stuck,
"fulfilledQty": fulfilled_qty,
"receivedQty": received_qty,
"returnedValue": returned_value,
"refundedTotal": refunded_total,
"reason": reason,
}
def list_orders_with_returns(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": ORDER_FIELDS,
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
def tag_returned_and_refunded(token, order_id, review_tag):
return admin_post(token, f"/admin/orders/{order_id}", {
"metadata": {review_tag: True},
})
def run():
token = get_admin_token()
orders = list_orders_with_returns(token)
flagged = 0
for order in orders:
outcome = decide_fulfillment_repair(order)
if not outcome["isStuck"]:
continue
log.warning(
"Order %s stuck on %s after a full return (fulfilled=%s received=%s refunded=%s). %s",
order.get("display_id") or order["id"], order.get("fulfillment_status"),
outcome["fulfilledQty"], outcome["receivedQty"], outcome["refundedTotal"],
"would tag" if DRY_RUN else "tagging",
)
if not DRY_RUN:
tag_returned_and_refunded(token, order["id"], REVIEW_TAG)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")
if __name__ == "__main__":
run()
/**
* Flag Medusa orders whose fulfillment status is stuck on Delivered after a
* full return and refund.
*
* In Medusa v2, order.fulfillment_status is derived only from fulfillment
* records, shipped and delivered quantities. Receiving a return through
* receiveReturnWorkflow updates the Return's received quantities, and issuing
* a refund updates the order's payment summary, but neither workflow
* recomputes fulfillment_status. A fully returned, fully refunded order can
* sit forever showing delivered as if the customer still has the goods. This
* lists orders with items, fulfillments, and returns expanded, flags any
* order where every fulfilled unit has a matching received unit on a
* completed return and the refund covers it, and tags only those orders for
* review. It never writes fulfillment_status directly.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/fulfillment-status-stuck-delivered/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const REVIEW_TAG = process.env.REVIEW_TAG || "returned-and-refunded";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EPSILON = 0.01;
const STUCK_STATUSES = new Set(["delivered", "partially_delivered"]);
const ORDER_FIELDS = "id,display_id,fulfillment_status,summary,*items,*returns,*returns.items";
/**
* Pure decision function. No I/O.
*
* @param {{
* id: string,
* fulfillment_status: string,
* summary: { refunded_total: number },
* items: Array<{ id: string, quantity: number, unit_price: number }>,
* returns: Array<{ status: string, items: Array<{ item_id: string, quantity: number }> }>,
* }} order
* @returns {{ orderId: string, isStuck: boolean, fulfilledQty: number, receivedQty: number,
* returnedValue: number, refundedTotal: number,
* reason: "stuck_delivered" | "in_progress" | "not_returned" }}
*/
export function decideFulfillmentRepair(order) {
const items = order.items || [];
const fulfilledQty = items.reduce((sum, item) => sum + (item.quantity || 0), 0);
const priceByItem = new Map(items.map((item) => [item.id, item.unit_price || 0]));
let receivedQty = 0;
let returnedValue = 0;
for (const ret of order.returns || []) {
if (ret.status !== "received") continue;
for (const line of ret.items || []) {
const qty = line.quantity || 0;
receivedQty += qty;
returnedValue += qty * (priceByItem.get(line.item_id) || 0);
}
}
const refundedTotal = order.summary ? order.summary.refunded_total || 0 : 0;
const status = order.fulfillment_status;
let reason;
let isStuck;
if (receivedQty <= 0) {
reason = "not_returned";
isStuck = false;
} else if (receivedQty + EPSILON < fulfilledQty) {
reason = "in_progress";
isStuck = false;
} else if (refundedTotal + EPSILON < returnedValue) {
reason = "in_progress";
isStuck = false;
} else if (STUCK_STATUSES.has(status)) {
reason = "stuck_delivered";
isStuck = true;
} else {
reason = "not_returned";
isStuck = false;
}
return {
orderId: order.id,
isStuck,
fulfilledQty,
receivedQty,
returnedValue,
refundedTotal,
reason,
};
}
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
return res.json();
}
async function adminPost(token, path, jsonBody) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(jsonBody),
});
if (!res.ok) throw new Error(`Medusa ${res.status} on POST ${path}`);
return res.json();
}
async function listOrdersWithReturns(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: ORDER_FIELDS,
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
async function tagReturnedAndRefunded(token, orderId, reviewTag) {
return adminPost(token, `/admin/orders/${orderId}`, {
metadata: { [reviewTag]: true },
});
}
export async function run() {
const token = await getAdminToken();
const orders = await listOrdersWithReturns(token);
let flagged = 0;
for (const order of orders) {
const outcome = decideFulfillmentRepair(order);
if (!outcome.isStuck) continue;
console.warn(
`Order ${order.display_id || order.id} stuck on ${order.fulfillment_status} after a full return (fulfilled=${outcome.fulfilledQty} received=${outcome.receivedQty} refunded=${outcome.refundedTotal}). ${DRY_RUN ? "would tag" : "tagging"}`
);
if (!DRY_RUN) {
await tagReturnedAndRefunded(token, order.id, REVIEW_TAG);
}
flagged++;
}
console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
decide_fulfillment_repair is the part most worth testing, because it decides which orders are truly done and safe to flag. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture orders and checks the answer.
from flag_stuck_fulfillment import decide_fulfillment_repair
def order(fulfillment_status="delivered", refunded_total=0.0, items=None, returns=None):
return {
"id": "order_1",
"fulfillment_status": fulfillment_status,
"summary": {"refunded_total": refunded_total},
"items": items or [{"id": "item_1", "quantity": 2, "unit_price": 50.0}],
"returns": returns or [],
}
def received(status="received", lines=None):
return {"status": status, "items": lines or [{"item_id": "item_1", "quantity": 2}]}
def test_stuck_delivered_when_fully_returned_and_refunded():
o = order(refunded_total=100.0, returns=[received()])
result = decide_fulfillment_repair(o)
assert result["isStuck"] is True
assert result["reason"] == "stuck_delivered"
assert result["receivedQty"] == 2
assert result["returnedValue"] == 100.0
def test_not_returned_when_no_returns_present():
o = order(refunded_total=0.0, returns=[])
result = decide_fulfillment_repair(o)
assert result["isStuck"] is False
assert result["reason"] == "not_returned"
def test_in_progress_when_return_partially_received():
o = order(refunded_total=50.0, returns=[received(lines=[{"item_id": "item_1", "quantity": 1}])])
result = decide_fulfillment_repair(o)
assert result["isStuck"] is False
assert result["reason"] == "in_progress"
def test_in_progress_when_received_but_refund_not_issued_yet():
o = order(refunded_total=0.0, returns=[received()])
result = decide_fulfillment_repair(o)
assert result["isStuck"] is False
assert result["reason"] == "in_progress"
def test_ignores_returns_not_yet_received():
o = order(refunded_total=0.0, returns=[received(status="requested")])
result = decide_fulfillment_repair(o)
assert result["isStuck"] is False
assert result["reason"] == "not_returned"
def test_not_stuck_when_fulfillment_status_already_updated():
o = order(fulfillment_status="canceled", refunded_total=100.0, returns=[received()])
result = decide_fulfillment_repair(o)
assert result["isStuck"] is False
assert result["reason"] == "not_returned"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideFulfillmentRepair } from "./flag-stuck-fulfillment.js";
function order({ fulfillmentStatus = "delivered", refundedTotal = 0.0, items, returns = [] } = {}) {
return {
id: "order_1",
fulfillment_status: fulfillmentStatus,
summary: { refunded_total: refundedTotal },
items: items || [{ id: "item_1", quantity: 2, unit_price: 50.0 }],
returns,
};
}
function received({ status = "received", lines } = {}) {
return { status, items: lines || [{ item_id: "item_1", quantity: 2 }] };
}
test("stuck delivered when fully returned and refunded", () => {
const o = order({ refundedTotal: 100.0, returns: [received()] });
const result = decideFulfillmentRepair(o);
assert.equal(result.isStuck, true);
assert.equal(result.reason, "stuck_delivered");
assert.equal(result.receivedQty, 2);
assert.equal(result.returnedValue, 100.0);
});
test("not returned when no returns present", () => {
const o = order({ refundedTotal: 0.0, returns: [] });
const result = decideFulfillmentRepair(o);
assert.equal(result.isStuck, false);
assert.equal(result.reason, "not_returned");
});
test("in progress when return partially received", () => {
const o = order({ refundedTotal: 50.0, returns: [received({ lines: [{ item_id: "item_1", quantity: 1 }] })] });
const result = decideFulfillmentRepair(o);
assert.equal(result.isStuck, false);
assert.equal(result.reason, "in_progress");
});
test("in progress when received but refund not issued yet", () => {
const o = order({ refundedTotal: 0.0, returns: [received()] });
const result = decideFulfillmentRepair(o);
assert.equal(result.isStuck, false);
assert.equal(result.reason, "in_progress");
});
test("ignores returns not yet received", () => {
const o = order({ refundedTotal: 0.0, returns: [received({ status: "requested" })] });
const result = decideFulfillmentRepair(o);
assert.equal(result.isStuck, false);
assert.equal(result.reason, "not_returned");
});
test("not stuck when fulfillment status already updated", () => {
const o = order({ fulfillmentStatus: "canceled", refundedTotal: 100.0, returns: [received()] });
const result = decideFulfillmentRepair(o);
assert.equal(result.isStuck, false);
assert.equal(result.reason, "not_returned");
});
Case studies
A clothing brand's dashboard kept counting returned orders as sold
A clothing store ran a weekly report off fulfillment_status to track how much stock had actually gone out the door. Every order that came back for a full refund, wrong size, changed mind, still read Delivered, because neither the return nor the refund touched that field. The team overcounted units shipped by a growing margin every month and could not explain the gap to their warehouse partner.
Running the detection script in dry run surfaced every one of those orders as stuck_delivered, with the received quantity and refund matching exactly. Tagging them with returned-and-refunded let the weekly report exclude them, and the shipped-units number finally matched what the warehouse counted.
Support kept reopening tickets on orders that were already resolved
A support team's internal tool flagged any order still showing Delivered as eligible for a "where's my order" follow up. Fully refunded return orders kept showing up in that queue because their fulfillment status never changed, so agents wasted time investigating orders that were already closed out from the customer's side.
Because the script only acts on the safe case, fully received and fully refunded, it caught these automatically on its hourly run and tagged them before the queue picked them up. Agents stopped chasing tickets on orders that had nothing left to resolve.
After this runs on a schedule, every order that is truly done, fully returned and fully refunded, gets a clear tag your reporting and support tools can trust, instead of sitting invisible under a Delivered label that no longer reflects reality. Anything still mid-return is left completely alone, so nothing is ever tagged early. No dashboard counts a returned order as a clean sale, and no one has to open the order and cross-reference the Returns and Payments tabs by hand to know what actually happened.
FAQ
Why does a Medusa order still show fulfillment status Delivered after a full return?
An order's fulfillment_status is derived from its fulfillment records, not from its returns or refunds. Receiving a return and issuing a refund updates the Return and the order's payment summary, but neither step is what recomputes fulfillment_status. If nothing calls back into the fulfillment side, the order keeps showing whatever status the last fulfillment event left it at, which is usually Delivered.
Is it safe to change fulfillment status with a script?
Yes, when the script never writes fulfillment_status directly and instead only touches orders where every item has been received back through a completed Return with a matching refund on the order summary. Checking that the return is fully received and the refund total covers the returned items first means the script only acts on orders that are truly done, and it is safe to run again and again.
How do you detect a fulfillment status that never updated after a return?
List orders with fulfillments, items, and returns expanded, sum each return's received quantities against the original fulfilled quantities, and compare the refunded total on the order summary against the returned items' value. When every item is fully returned and refunded but fulfillment_status still reads delivered or partially_delivered, that order is stuck and safe to flag or repair.
Related field notes
Citations
On the problem:
- Medusa Documentation: Order module concepts, fulfillment_status and derived order state. docs.medusajs.com/resources/commerce-modules/order/concepts
- Medusa Documentation: Return and refund flow in the Order module. docs.medusajs.com/resources/commerce-modules/order/return-exchange
- Medusa GitHub: Fulfillment module reference and status computation. github.com/medusajs/medusa
On the solution:
- Medusa Documentation: receiveReturnWorkflow, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/receiveReturnWorkflow
- Medusa Documentation: refundPaymentsWorkflow, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow
- Medusa Admin User Guide: Manage Order Returns. docs.medusajs.com/user-guide/orders/returns
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows 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 clear up your fulfillment reports?
If this saved you a confusing support escalation or a dashboard that would not add up, 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