Diagnostic Orders and fulfillment
Order stuck not fulfilled past SLA
The payment captured days ago. The customer is waiting. But the order's fulfillment_status still reads not_fulfilled, and nobody noticed because nothing in Medusa raised a flag on its own. Here is why placing an order and fulfilling it are two separate things in Medusa v2, how the automation that is supposed to bridge them can quietly fail, and a script that finds every paid order sitting past your SLA so a human can catch it before the customer complains.
In Medusa v2, capturing a payment only changes payment_status. Nothing forces a fulfillment to be created, so the order can sit at fulfillment_status not_fulfilled forever if the automation that should trigger a fulfillment, an order.placed subscriber, a scheduled job, or a warehouse integration, silently fails. This gets worse in production without the Redis-backed Event Bus and Workflow Engine modules, since the default in-memory ones do not persist events or job state across restarts or multiple instances. The Admin API also cannot filter orders server-side by fulfillment_status or payment_status, so there is no built-in query for "paid but unfulfilled." Run a script that pages through orders, computes is_paid, is_unfulfilled, and age in hours per order, and flags every one that is paid, unfulfilled, older than your SLA, not canceled, and not already flagged. Full code, tests, and a dry run guard are below.
The problem in plain words
It is easy to assume that once an order is paid, Medusa takes it from there. In Medusa v2 it does not, on purpose. Placing an order and fulfilling an order are two separate concerns handled by two separate parts of the system. Payment capture updates payment_status to captured. That is all it does. Whether a fulfillment gets created, whether a warehouse gets notified, whether a shipping label gets generated, all of that depends on something else reacting to the order being placed.
That something else is usually an order.placed event, picked up by a subscriber, that kicks off a workflow to create the fulfillment, or a scheduled job that polls for orders ready to ship, or an integration that pushes the order to a third-party warehouse system. If that subscriber throws, if the job never runs, or if the integration is down and nobody is watching its error logs, the order just sits there. payment_status says captured. fulfillment_status still says not_fulfilled. Nothing else changes on its own, and nothing in Medusa raises an alarm, because from Medusa's point of view this is a perfectly valid, unremarkable state for an order to be in for as long as it takes someone to notice.
Why it happens
Medusa v2 deliberately keeps "the order was placed and paid" separate from "the order was fulfilled," so a store can plug in whatever fulfillment automation fits its warehouse. A few common ways that automation breaks down:
- An
order.placedsubscriber that is supposed to create the fulfillment throws an unhandled error, and the failure only shows up in a log nobody is tailing. - A scheduled job that should poll for orders ready to fulfill stops running, or never gets deployed to a given environment, and nobody notices because there is no default alert for a missed job.
- The default in-memory Event Bus and Workflow Engine modules are used in production instead of their Redis-backed equivalents. Those in-memory modules do not persist across process restarts or across multiple app instances, so an
order.placedevent, or an in-flight workflow step, can simply be lost when a deploy restarts the process (see medusajs/medusa issues #7850, #11141, #7223). - A third-party warehouse or 3PL integration that is supposed to receive the order and confirm a fulfillment goes down or rejects the push, and the order is never retried because nothing is watching for that failure either.
Compounding all of this, the Admin API and Order module cannot filter orders server-side by fulfillment_status or payment_status. That is a confirmed limitation, not a workaround waiting to be discovered (see issues #11600 and #14095). There is no built-in admin query for "show me every paid order that is still unfulfilled." You have to page through orders and compute that yourself. See the citations at the end for the exact issues and docs.
Do not auto-create a fulfillment to paper over this. Picking, packing, and shipping are real warehouse actions, and creating a fulfillment against stale or unreviewed inventory data could ship something that should not ship. The safe pattern is to detect and flag, never to fabricate the missing step. Compute the breach with a pure function, then patch metadata on the breached order so a human can review it, always behind a DRY_RUN guard that defaults to on.
The fix, as a flow
We never create a fulfillment. The job pages through orders with the fields it needs already expanded, since it cannot filter by status server-side, computes whether each order is paid, unfulfilled, and older than the SLA with one pure function, and for anything that breaches, patches the order's metadata to flag it for review. Everything else is left untouched.
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 SLA_HOURS="48"
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 SLA_HOURS="48"
export DRY_RUN="true" // start safe, change to false to write
Page through orders with the fields the decision needs
Since fulfillment_status and payment_status cannot be used as server-side filters, ask for every order and expand exactly what you need to decide: payment_collections and fulfillments, plus created_at and metadata. Page with limit and offset until you have walked the whole list.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ORDER_FIELDS = (
"id,display_id,email,created_at,status,fulfillment_status,payment_status,"
"*payment_collections,*fulfillments,metadata"
)
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(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
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
const ORDER_FIELDS =
"id,display_id,email,created_at,status,fulfillment_status,payment_status," +
"*payment_collections,*fulfillments,metadata";
async function listOrders() {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const { orders: page, count } = await sdk.admin.order.list({
fields: ORDER_FIELDS,
limit,
offset,
});
orders.push(...page);
offset += limit;
if (offset >= count) return orders;
}
}
Decide, with one pure function
Keep the whole rule in a function that takes an order, the current time, and the SLA in hours, and returns whether it breached. It computes is_paid from payment_status being captured, or every payment_collections entry being captured, and is_unfulfilled from fulfillment_status being not_fulfilled or partially_fulfilled, or an empty fulfillments array. It excludes canceled orders and orders that already carry metadata.sla_flagged, so a second run never re-flags the same order.
from datetime import datetime
UNFULFILLED_STATUSES = {"not_fulfilled", "partially_fulfilled"}
def _is_paid(order):
if order.get("payment_status") == "captured":
return True
collections = order.get("payment_collections") or []
if collections:
return all(pc.get("status") == "captured" for pc in collections)
return False
def _is_unfulfilled(order):
if order.get("fulfillment_status") in UNFULFILLED_STATUSES:
return True
return len(order.get("fulfillments") or []) == 0
def evaluate_order_sla(order, now_ms, sla_hours):
"""Pure decision function. No I/O. order.created_at is an ISO 8601 string."""
metadata = order.get("metadata") or {}
already_flagged = metadata.get("sla_flagged") is True
created_at = order.get("created_at")
if not created_at:
return {"breached": False, "already_flagged": already_flagged, "age_hours": 0.0, "reason": "missing created_at"}
created_ms = datetime.fromisoformat(created_at.replace("Z", "+00:00")).timestamp() * 1000
age_hours = (now_ms - created_ms) / 3_600_000
if order.get("status") == "canceled":
return {"breached": False, "already_flagged": already_flagged, "age_hours": age_hours, "reason": "canceled"}
if already_flagged:
return {"breached": False, "already_flagged": True, "age_hours": age_hours, "reason": "already flagged"}
if not _is_paid(order):
return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "not paid"}
if not _is_unfulfilled(order):
return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "already fulfilled"}
if age_hours <= sla_hours:
return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "within SLA"}
return {"breached": True, "already_flagged": False, "age_hours": age_hours}
const UNFULFILLED_STATUSES = new Set(["not_fulfilled", "partially_fulfilled"]);
function isPaid(order) {
if (order.payment_status === "captured") return true;
const collections = order.payment_collections || [];
if (collections.length) return collections.every((pc) => pc.status === "captured");
return false;
}
function isUnfulfilled(order) {
if (UNFULFILLED_STATUSES.has(order.fulfillment_status)) return true;
return (order.fulfillments || []).length === 0;
}
/** Pure decision function. No I/O. order.created_at is an ISO 8601 string. */
export function evaluateOrderSla(order, nowMs, slaHours) {
const metadata = order.metadata || {};
const alreadyFlagged = metadata.sla_flagged === true;
if (!order.created_at) {
return { breached: false, alreadyFlagged, ageHours: 0, reason: "missing created_at" };
}
const ageHours = (nowMs - Date.parse(order.created_at)) / 3_600_000;
if (order.status === "canceled") {
return { breached: false, alreadyFlagged, ageHours, reason: "canceled" };
}
if (alreadyFlagged) {
return { breached: false, alreadyFlagged: true, ageHours, reason: "already flagged" };
}
if (!isPaid(order)) {
return { breached: false, alreadyFlagged: false, ageHours, reason: "not paid" };
}
if (!isUnfulfilled(order)) {
return { breached: false, alreadyFlagged: false, ageHours, reason: "already fulfilled" };
}
if (ageHours <= slaHours) {
return { breached: false, alreadyFlagged: false, ageHours, reason: "within SLA" };
}
return { breached: true, alreadyFlagged: false, ageHours };
}
Flag a breached order, never fulfill it
When an order breaches, patch its metadata with sla_flagged, a timestamp, and the breach hours, so the team can find it and so the script does not flag it again next run. Medusa replaces the whole metadata object on a write rather than deep-merging, so always spread the existing metadata first.
def admin_post(token, path, body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
def flag_order(token, order, age_hours):
metadata = dict(order.get("metadata") or {}) # spread first, never overwrite blindly
metadata["sla_flagged"] = True
metadata["sla_flagged_at"] = datetime.utcnow().isoformat() + "Z"
metadata["sla_breach_hours"] = int(age_hours)
return admin_post(token, f"/admin/orders/{order['id']}", {"metadata": metadata})
async function adminPost(token, path, body) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
async function flagOrder(token, order, ageHours) {
const metadata = { ...(order.metadata || {}) }; // spread first, never overwrite blindly
metadata.sla_flagged = true;
metadata.sla_flagged_at = new Date().toISOString();
metadata.sla_breach_hours = Math.floor(ageHours);
return adminPost(token, `/admin/orders/${order.id}`, { metadata });
}
Wire it together with a dry run guard
The loop ties every piece together: authenticate, page through orders, run the pure decision function on each one, and only call the metadata patch when DRY_RUN is off. On the first few runs, leave DRY_RUN=true so the script only logs which orders it would flag. Read the output, agree with it, then switch it off. Run it on a schedule that fits your SLA, for example every hour if your SLA is measured in hours.
Always start with DRY_RUN=true. This script never creates a fulfillment and never touches inventory. The only write it will ever make is a metadata patch on the specific order that breached, so it is safe to run again and again, even hourly, without risk of shipping against stale data.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through every order, flags every order that is paid, unfulfilled, past your SLA, not canceled, and not already flagged, and only writes when DRY_RUN is off.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Flag Medusa orders that are paid but still not fulfilled past your SLA.
In Medusa v2, placing an order and fulfilling an order are decoupled. Capturing
a payment only updates payment_status. Nothing forces a fulfillment to be
created, so an order can sit with fulfillment_status "not_fulfilled" (or
"partially_fulfilled") indefinitely if the automation that should create a
fulfillment, an order.placed subscriber, a scheduled job, or a warehouse
integration, silently fails. This is worse in production when the default
in-memory Event Bus and Workflow Engine modules are used instead of their
Redis-backed equivalents, because events and job runs do not persist across
process restarts or multiple instances.
The Admin API cannot filter orders server-side by fulfillment_status or
payment_status, so this pages through orders and computes the SLA breach
client-side. It never creates a fulfillment. Picking, packing, and shipping
are real-world actions this script cannot safely fabricate. It only patches
metadata to flag a breached order for human review, and only when DRY_RUN is
off. 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_sla_breached_orders")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
SLA_HOURS = float(os.environ.get("SLA_HOURS", "48"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
UNFULFILLED_STATUSES = {"not_fulfilled", "partially_fulfilled"}
ORDER_FIELDS = (
"id,display_id,email,created_at,status,fulfillment_status,payment_status,"
"*payment_collections,*fulfillments,metadata"
)
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, body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
def _is_paid(order):
if order.get("payment_status") == "captured":
return True
collections = order.get("payment_collections") or []
if collections:
return all(pc.get("status") == "captured" for pc in collections)
return False
def _is_unfulfilled(order):
if order.get("fulfillment_status") in UNFULFILLED_STATUSES:
return True
return len(order.get("fulfillments") or []) == 0
def evaluate_order_sla(order, now_ms, sla_hours):
"""Pure decision function. No I/O.
order: {status, payment_status, fulfillment_status, fulfillments?, created_at,
metadata?}
now_ms: current time in epoch milliseconds (passed in, not read from the clock)
sla_hours: hours after which a paid, unfulfilled order is considered breached
Returns {breached, already_flagged, age_hours, reason?}.
"""
metadata = order.get("metadata") or {}
already_flagged = metadata.get("sla_flagged") is True
created_at = order.get("created_at")
if not created_at:
return {"breached": False, "already_flagged": already_flagged, "age_hours": 0.0, "reason": "missing created_at"}
created_ms = _parse_iso_to_ms(created_at)
age_hours = (now_ms - created_ms) / 3_600_000
if order.get("status") == "canceled":
return {"breached": False, "already_flagged": already_flagged, "age_hours": age_hours, "reason": "canceled"}
is_paid = _is_paid(order)
is_unfulfilled = _is_unfulfilled(order)
if already_flagged:
return {"breached": False, "already_flagged": True, "age_hours": age_hours, "reason": "already flagged"}
if not is_paid:
return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "not paid"}
if not is_unfulfilled:
return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "already fulfilled"}
if age_hours <= sla_hours:
return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "within SLA"}
return {"breached": True, "already_flagged": False, "age_hours": age_hours}
def _parse_iso_to_ms(iso):
from datetime import datetime
return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000
def list_orders(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 flag_order(token, order):
metadata = dict(order.get("metadata") or {})
metadata["sla_flagged"] = True
metadata["sla_flagged_at"] = _now_iso()
metadata["sla_breach_hours"] = int(evaluate_order_sla(order, _now_ms(), SLA_HOURS)["age_hours"])
return admin_post(token, f"/admin/orders/{order['id']}", {"metadata": metadata})
def _now_ms():
import time
return time.time() * 1000
def _now_iso():
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
def run():
token = get_admin_token()
now_ms = _now_ms()
flagged = 0
for order in list_orders(token):
result = evaluate_order_sla(order, now_ms, SLA_HOURS)
if not result["breached"]:
continue
log.warning(
"Order %s (%s) breached SLA: paid but %s for %.1fh. %s",
order.get("display_id"), order["id"], order.get("fulfillment_status"),
result["age_hours"], "would flag" if DRY_RUN else "flagging",
)
if not DRY_RUN:
flag_order(token, order)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Flag Medusa orders that are paid but still not fulfilled past your SLA.
*
* In Medusa v2, placing an order and fulfilling an order are decoupled. Capturing
* a payment only updates payment_status. Nothing forces a fulfillment to be
* created, so an order can sit with fulfillment_status "not_fulfilled" (or
* "partially_fulfilled") indefinitely if the automation that should create a
* fulfillment, an order.placed subscriber, a scheduled job, or a warehouse
* integration, silently fails. This is worse in production when the default
* in-memory Event Bus and Workflow Engine modules are used instead of their
* Redis-backed equivalents, because events and job runs do not persist across
* process restarts or multiple instances.
*
* The Admin API cannot filter orders server-side by fulfillment_status or
* payment_status, so this pages through orders and computes the SLA breach
* client-side. It never creates a fulfillment. Picking, packing, and shipping
* are real-world actions this script cannot safely fabricate. It only patches
* metadata to flag a breached order for human review, and only when DRY_RUN is
* off. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/order-stuck-not-fulfilled-past-sla/
*/
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 SLA_HOURS = Number(process.env.SLA_HOURS || 48);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const UNFULFILLED_STATUSES = new Set(["not_fulfilled", "partially_fulfilled"]);
const ORDER_FIELDS =
"id,display_id,email,created_at,status,fulfillment_status,payment_status," +
"*payment_collections,*fulfillments,metadata";
function isPaid(order) {
if (order.payment_status === "captured") return true;
const collections = order.payment_collections || [];
if (collections.length) return collections.every((pc) => pc.status === "captured");
return false;
}
function isUnfulfilled(order) {
if (UNFULFILLED_STATUSES.has(order.fulfillment_status)) return true;
return (order.fulfillments || []).length === 0;
}
/**
* Pure decision function. No I/O.
*
* @param {{ status: string, payment_status: string, fulfillment_status: string,
* fulfillments?: Array<{ id: string }>, created_at: string,
* metadata?: Record | null,
* payment_collections?: Array<{ status: string }> }} order
* @param {number} nowMs current time in epoch milliseconds, passed in
* @param {number} slaHours hours after which a paid, unfulfilled order is breached
* @returns {{ breached: boolean, alreadyFlagged: boolean, ageHours: number, reason?: string }}
*/
export function evaluateOrderSla(order, nowMs, slaHours) {
const metadata = order.metadata || {};
const alreadyFlagged = metadata.sla_flagged === true;
if (!order.created_at) {
return { breached: false, alreadyFlagged, ageHours: 0, reason: "missing created_at" };
}
const ageHours = (nowMs - Date.parse(order.created_at)) / 3_600_000;
if (order.status === "canceled") {
return { breached: false, alreadyFlagged, ageHours, reason: "canceled" };
}
if (alreadyFlagged) {
return { breached: false, alreadyFlagged: true, ageHours, reason: "already flagged" };
}
if (!isPaid(order)) {
return { breached: false, alreadyFlagged: false, ageHours, reason: "not paid" };
}
if (!isUnfulfilled(order)) {
return { breached: false, alreadyFlagged: false, ageHours, reason: "already fulfilled" };
}
if (ageHours <= slaHours) {
return { breached: false, alreadyFlagged: false, ageHours, reason: "within SLA" };
}
return { breached: true, alreadyFlagged: false, ageHours };
}
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}`);
return res.json();
}
async function adminPost(token, path, body) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
async function listOrders(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 flagOrder(token, order, ageHours) {
const metadata = { ...(order.metadata || {}) };
metadata.sla_flagged = true;
metadata.sla_flagged_at = new Date().toISOString();
metadata.sla_breach_hours = Math.floor(ageHours);
return adminPost(token, `/admin/orders/${order.id}`, { metadata });
}
export async function run() {
const token = await getAdminToken();
const nowMs = Date.now();
let flagged = 0;
for (const order of await listOrders(token)) {
const result = evaluateOrderSla(order, nowMs, SLA_HOURS);
if (!result.breached) continue;
console.warn(
`Order ${order.display_id} (${order.id}) breached SLA: paid but ${order.fulfillment_status} for ${result.ageHours.toFixed(1)}h. ${DRY_RUN ? "would flag" : "flagging"}`
);
if (!DRY_RUN) await flagOrder(token, order, result.ageHours);
flagged++;
}
console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
evaluate_order_sla is the part most worth testing, because it decides which orders get flagged for review. It is pure, taking the current time as an argument instead of reading the clock, so the test needs no network and no Medusa backend. It just feeds in plain order objects and a fixed timestamp, and checks the answer.
from datetime import datetime
from flag_sla_breached_orders import evaluate_order_sla
NOW_MS = datetime.fromisoformat("2026-07-10T00:00:00+00:00").timestamp() * 1000
def order(**over):
base = {
"status": "completed",
"payment_status": "captured",
"fulfillment_status": "not_fulfilled",
"fulfillments": [],
"created_at": "2026-07-06T00:00:00Z", # 96h before NOW_MS
"metadata": {},
}
base.update(over)
return base
def test_breached_when_paid_unfulfilled_and_past_sla():
result = evaluate_order_sla(order(), NOW_MS, 48)
assert result["breached"] is True
assert result["already_flagged"] is False
assert round(result["age_hours"]) == 96
def test_not_breached_when_within_sla():
o = order(created_at="2026-07-09T12:00:00Z") # 12h before NOW_MS
assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False
def test_not_breached_when_not_paid():
o = order(payment_status="not_paid")
assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False
def test_breached_when_paid_via_payment_collections():
o = order(payment_status=None, payment_collections=[{"status": "captured"}])
assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is True
def test_not_breached_when_already_fulfilled():
o = order(fulfillment_status="fulfilled", fulfillments=[{"id": "ful_1"}])
assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False
def test_not_breached_when_canceled():
o = order(status="canceled")
assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False
def test_not_breached_when_already_flagged():
o = order(metadata={"sla_flagged": True})
result = evaluate_order_sla(o, NOW_MS, 48)
assert result["breached"] is False
assert result["already_flagged"] is True
def test_exactly_at_sla_boundary_is_not_breached():
o = order(created_at="2026-07-08T00:00:00Z") # exactly 48h before NOW_MS
assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateOrderSla } from "./flag-sla-breached-orders.js";
const NOW_MS = Date.parse("2026-07-10T00:00:00Z");
const order = (over = {}) => ({
status: "completed",
payment_status: "captured",
fulfillment_status: "not_fulfilled",
fulfillments: [],
created_at: "2026-07-06T00:00:00Z", // 96h before NOW_MS
metadata: {},
...over,
});
test("breached when paid, unfulfilled, and past SLA", () => {
const result = evaluateOrderSla(order(), NOW_MS, 48);
assert.equal(result.breached, true);
assert.equal(result.alreadyFlagged, false);
assert.equal(Math.round(result.ageHours), 96);
});
test("not breached when within SLA", () => {
const result = evaluateOrderSla(order({ created_at: "2026-07-09T12:00:00Z" }), NOW_MS, 48);
assert.equal(result.breached, false);
});
test("not breached when not paid", () => {
const result = evaluateOrderSla(order({ payment_status: "not_paid" }), NOW_MS, 48);
assert.equal(result.breached, false);
});
test("breached when paid via payment_collections", () => {
const result = evaluateOrderSla(
order({ payment_status: null, payment_collections: [{ status: "captured" }] }),
NOW_MS,
48
);
assert.equal(result.breached, true);
});
test("not breached when already fulfilled", () => {
const result = evaluateOrderSla(
order({ fulfillment_status: "fulfilled", fulfillments: [{ id: "ful_1" }] }),
NOW_MS,
48
);
assert.equal(result.breached, false);
});
test("not breached when canceled", () => {
const result = evaluateOrderSla(order({ status: "canceled" }), NOW_MS, 48);
assert.equal(result.breached, false);
});
test("not breached when already flagged", () => {
const result = evaluateOrderSla(order({ metadata: { sla_flagged: true } }), NOW_MS, 48);
assert.equal(result.breached, false);
assert.equal(result.alreadyFlagged, true);
});
test("exactly at SLA boundary is not breached", () => {
const result = evaluateOrderSla(order({ created_at: "2026-07-08T00:00:00Z" }), NOW_MS, 48);
assert.equal(result.breached, false);
});
Case studies
A deploy broke the order.placed subscriber and nobody knew for four days
A team shipped an unrelated schema change that broke a field the fulfillment-creating order.placed subscriber depended on. The subscriber started throwing on every order, but the error only landed in a log stream nobody was tailing that week. Orders kept coming in, payments kept capturing, and a growing pile of paid orders sat at not_fulfilled.
The team started running the flag script hourly with SLA_HOURS=24. On the very first run it surfaced ninety-plus breached orders at once, which was alarming enough to get someone to check the subscriber logs immediately, find the schema mismatch, and fix it the same day.
Every deploy quietly dropped a few order.placed events
A smaller store ran Medusa without the Redis-backed Event Bus and Workflow Engine modules, relying on the in-memory defaults. Every time a deploy restarted the app, any order.placed event or workflow step that was in flight at that exact moment was simply gone. It only affected a handful of orders per deploy, so it never showed up as an obvious outage, just a slow drip of orders that never got fulfilled.
Running this script on a daily schedule turned that invisible drip into a short, visible list the ops team could review and manually push through, while the underlying fix, moving to the Redis-backed modules, got scheduled and shipped.
After this runs on a schedule, a paid order that falls through the fulfillment automation gets caught within one SLA window instead of sitting there until a customer complains. Nothing was auto-fulfilled and no inventory was touched. A human sees the exact list of breached orders, with how long each one has been waiting, and decides what to do next, whether that is retrying the automation, fulfilling manually, or digging into why the subscriber or job failed in the first place.
FAQ
Why does a paid Medusa order stay not_fulfilled?
In Medusa v2, placing an order and fulfilling an order are decoupled. Capturing a payment only updates payment_status. Nothing forces a fulfillment to be created, so the order can sit at fulfillment_status not_fulfilled indefinitely if the automation meant to trigger fulfillment, an order.placed subscriber, a scheduled job, or a warehouse integration, silently fails or never ran.
Can I use the Admin API to filter orders by fulfillment_status or payment_status?
No, not server-side. This is a confirmed limitation in Medusa v2's Admin API and Order module. You have to page through orders with the Admin API, expand the fields you need such as payment_collections and fulfillments, and compute which ones are paid but unfulfilled and how old they are on the client side.
Should a script auto-create the missing fulfillment?
No. Picking, packing, and shipping are real warehouse actions a script cannot safely fabricate, and creating a fulfillment against stale inventory data could make things worse. The safe pattern is to flag the breached order, for example by patching its metadata for human review, guarded behind a DRY_RUN flag, rather than ever creating a fulfillment automatically.
Related field notes
Citations
On the problem:
- Medusa Documentation: Events and Subscribers. docs.medusajs.com/learn/fundamentals/events-and-subscribers
- [Bug]: Unable to filter or order by fulfillment / payment status in admin UI and with graph query. Medusa GitHub Issue #14095. github.com/medusajs/medusa/issues/14095
- [Bug]: API Admin order filter on payment_status & fulfillment_status especially. Medusa GitHub Issue #11600. github.com/medusajs/medusa/issues/11600
On the solution:
- Manage Order Fulfillments in Medusa Admin. Medusa Admin User Guide. docs.medusajs.com/user-guide/orders/fulfillments
- Medusa Documentation: order, JS SDK Admin Reference. docs.medusajs.com/resources/references/js-sdk/admin/order
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
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 catch a stuck order before your customer did?
If this saved you from a support ticket about an order that never shipped, or from writing this SLA sweep from scratch, 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