Reconciler Inventory & Reservations
Reservation records have no order id to trace back to
A customer emails asking where their order is. You pull up the inventory item, see a reservation holding the stock, and go looking for the order number on it. There isn't one. The reservation just sits there with a line_item_id and no way to click through to the order that made it. Here is why Medusa v2 built it this way and a small script that resolves the trace for you and reports the reservations it cannot.
Medusa v2 deliberately decoupled the Inventory module from the Order module for modularity. A reservation, ReservationItem, stores only a bare line_item_id string, not a real relation, and there is no module link registered between ReservationItem and the Order module's line items. The Admin API's default reservation fields never include order_id or an expandable line_item relation, so the dashboard has nothing to show. Even a hypothetical expansion would need one more hop, because OrderLineItem itself has no order_id column either, that relationship is modeled through a separate OrderItem join entity. Run a script that fetches reservations and orders, builds a lookup from each order's line items back to its order, and reports every reservation as traced, orphaned, or not order backed. Full code, tests, and a dry run guard are below.
The problem in plain words
Open a reservation in the Medusa Admin dashboard and look for the order it belongs to. There is no such field. The reservation record knows the inventory item, the location, and the quantity held, and it knows a line_item_id, but that id is just a plain string sitting on the row. It is not a relation Medusa can follow, and nothing links it forward to an order.
This is not a bug in the sense of broken code. It is the shape of the two modules involved. The Inventory module manages stock counts and reservations on its own, with no required knowledge of orders, carts, or checkouts. The Order module manages orders on its own, with no required knowledge of inventory. That separation is what lets you use the Inventory module standalone, but it means the one field an ops person actually wants, the order this reservation is for, was never wired up between them.
Why it happens
The Admin API and the data model agree with each other here, and both point the same way:
- In the Medusa source,
packages/medusa/src/api/admin/reservations/query-config.tsdefinesdefaultAdminReservationFieldsas onlyid,location_id,inventory_item_id,quantity,line_item_id,description,metadata,created_at,updated_at, plusinventory_item.*. There is noorder_idfield and noline_itemrelation in that list, so the Admin API can never return one and the dashboard can never render one. - There is no module link registered between
ReservationItemin the Inventory module andOrderLineItemorOrderItemin the Order module. Medusa v2's modules only know about each other through explicit links, and this pair was never linked. - Even if a
line_item.*expansion existed, it would land onOrderLineItem, which has no directorder_idcolumn of its own. The Order module models the Order to LineItem relationship through a separateOrderItemjoin entity, so reaching the order still takes one more hop through that join. - This is a known pain point, not a one-off. GitHub issue #14370 reports the order id column always sitting empty in the inventory item reservations table in the dashboard, and issue #9797 reports the reservation's order number never showing on the inventory page.
None of this is data corruption. The reservation and the order both exist and are both correct. The gap is that nothing in the schema or the API was ever built to connect them for you. See the citations at the end for the exact issues and docs.
Since there is no order_id to read, the only way to trace a reservation is to walk it backward through data you already have. Fetch orders with their items, build a lookup from each item's id to the order that owns it, then match every reservation's line_item_id against that lookup. This is exactly the OrderItem join Medusa's own schema uses internally, just done in application code instead of a database relation. A reservation that matches is traced. One that does not match anything is either not order backed at all, or its order fell outside the page of orders you fetched, or it is a genuinely stale row.
The fix, as a flow
We do not write anything destructive. The job lists reservations, lists orders with their items, resolves each reservation's line_item_id through a pure function, and produces a report. The only optional write is enrichment: stamping the resolved order_id onto the reservation's own metadata so future lookups are instant, and only for reservations the function marked as traced.
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 REPORT_PATH="reservation_trace_report.csv"
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 REPORT_PATH="reservation_trace_report.csv"
export DRY_RUN="true" // start safe, change to false to write
List reservations without asking for a relation that does not exist
Ask only for the fields Medusa actually has: id, inventory_item_id, location_id, quantity, line_item_id, created_at. There is no order_id or line_item expansion to request, so do not waste a call trying. Paginate with limit and offset since a busy store can carry hundreds of rows.
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_reservations(token):
reservations = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/reservations", {
"fields": "id,inventory_item_id,location_id,quantity,line_item_id,created_at",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
async function listReservations() {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const { reservations: page, count } = await sdk.admin.reservation.list({
fields: "id,inventory_item_id,location_id,quantity,line_item_id,created_at",
limit,
offset,
});
reservations.push(...page);
offset += limit;
if (offset >= count) return reservations;
}
}
List orders with their items to stand in for the OrderItem join
Fetch orders with fields=id,*items. Each order's items[] array is exactly the join Medusa models internally through OrderItem, just delivered to you already resolved. Build a lookup from every item id to the order it belongs to.
def list_orders(token):
orders = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,*items",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
async function listOrders() {
const orders = [];
let offset = 0;
const limit = 200;
while (true) {
const { orders: page, count } = await sdk.admin.order.list({
fields: "id,*items",
limit,
offset,
});
orders.push(...page);
offset += limit;
if (offset >= count) return orders;
}
}
Trace, with one pure function
Keep the trace logic in its own function that takes the reservations array and the orders array and returns a plain classification for each reservation. It never touches the network, so it is trivial to unit test with fixture data. A reservation with no line_item_id was never order backed. One whose line_item_id matches an order's item is traced. Anything else is an orphaned line item, either a stale reservation or one whose order sits outside the fetched page.
def trace_reservations_to_orders(reservations, orders):
"""Pure decision function. No I/O.
reservations: [{"id": str, "line_item_id": str | None, "inventory_item_id": str, "quantity": int}]
orders: [{"id": str, "items": [{"id": str}]}]
Returns [{"reservation_id": str, "order_id": str | None,
"status": "traced" | "orphaned_line_item" | "no_line_item"}]
"""
line_item_to_order = {}
for order in orders:
for item in order.get("items") or []:
line_item_to_order[item["id"]] = order["id"]
results = []
for r in reservations:
line_item_id = r.get("line_item_id")
if not line_item_id:
results.append({"reservation_id": r["id"], "order_id": None, "status": "no_line_item"})
continue
order_id = line_item_to_order.get(line_item_id)
if not order_id:
results.append({"reservation_id": r["id"], "order_id": None, "status": "orphaned_line_item"})
continue
results.append({"reservation_id": r["id"], "order_id": order_id, "status": "traced"})
return results
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, inventory_item_id: string, quantity: number }[]} reservations
* @param {{ id: string, items: { id: string }[] }[]} orders
* @returns {{ reservation_id: string, order_id: string | null, status: "traced" | "orphaned_line_item" | "no_line_item" }[]}
*/
export function traceReservationsToOrders(reservations, orders) {
const lineItemToOrder = new Map();
for (const order of orders) {
for (const item of order.items) {
lineItemToOrder.set(item.id, order.id);
}
}
return reservations.map((r) => {
if (!r.line_item_id) {
return { reservation_id: r.id, order_id: null, status: "no_line_item" };
}
const orderId = lineItemToOrder.get(r.line_item_id);
if (!orderId) {
return { reservation_id: r.id, order_id: null, status: "orphaned_line_item" };
}
return { reservation_id: r.id, order_id: orderId, status: "traced" };
});
}
Write the report, and enrich only what is safely traced
Write every result to a CSV row regardless of status, so ops can review orphaned and untraceable rows by hand. The only mutating call is optional and gated: when DRY_RUN is false, stamp the resolved order_id onto the reservation's own metadata for fast lookups later, but only for rows the function marked traced. Never write a guess for orphaned_line_item or no_line_item rows.
def stamp_resolved_order_id(token, reservation_id, order_id):
r = requests.post(
f"{BACKEND_URL}/admin/reservations/{reservation_id}",
headers={"Authorization": f"Bearer {token}"},
json={"metadata": {"resolved_order_id": order_id}},
timeout=30,
)
r.raise_for_status()
return r.json()
def write_report(path, rows):
import csv
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["reservation_id", "inventory_item_id", "order_id", "status"])
writer.writeheader()
for row in rows:
writer.writerow(row)
async function stampResolvedOrderId(reservationId, orderId) {
return sdk.admin.reservation.update(reservationId, {
metadata: { resolved_order_id: orderId },
});
}
function writeReport(path, rows) {
const fs = require("node:fs");
const header = "reservation_id,inventory_item_id,order_id,status";
const lines = rows.map((r) =>
[r.reservation_id, r.inventory_item_id, r.order_id || "", r.status].join(",")
);
fs.writeFileSync(path, [header, ...lines].join("\n") + "\n");
}
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 writes the CSV report, letting you review which reservations are traced, orphaned, or never order backed. Read the output, agree with it, then switch it off to let the metadata enrichment write. Run it on a schedule, or on demand whenever support needs to trace a reservation.
Always start with DRY_RUN=true. This script only ever writes enrichment metadata, never deletes a reservation and never guesses an order id for a row it could not trace. Reservations flagged orphaned_line_item are a signal for a human to look closer, possibly a stale reservation worth deleting with DELETE /admin/reservations/{id}, but only after manual confirmation, never automatically from this script.
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 the only write it ever makes is a metadata stamp on a reservation the pure function already confirmed as traced.
"""Trace Medusa reservations back to the orders they belong to.
Medusa v2 deliberately decouples the Inventory module from the Order module.
ReservationItem stores only a bare line_item_id string, not a real relation, and
there is no module link between ReservationItem and the Order module, so the
Admin API and dashboard can never show which order a reservation is for. This
lists reservations and orders, builds a line item to order lookup that stands in
for the Order module's own OrderItem join, and reports every reservation as
traced, orphaned, or not order backed. The only write is optional enrichment:
stamping the resolved order id into a traced reservation's own metadata.
Run on a schedule, or on demand. Safe to run again and again.
"""
import os
import csv
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("trace_reservation_orders")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
REPORT_PATH = os.environ.get("REPORT_PATH", "reservation_trace_report.csv")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
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}"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
def trace_reservations_to_orders(reservations, orders):
"""Pure decision function. No I/O.
reservations: [{"id": str, "line_item_id": str | None, "inventory_item_id": str, "quantity": int}]
orders: [{"id": str, "items": [{"id": str}]}]
Returns [{"reservation_id": str, "order_id": str | None,
"status": "traced" | "orphaned_line_item" | "no_line_item"}]
"""
line_item_to_order = {}
for order in orders:
for item in order.get("items") or []:
line_item_to_order[item["id"]] = order["id"]
results = []
for r in reservations:
line_item_id = r.get("line_item_id")
if not line_item_id:
results.append({"reservation_id": r["id"], "order_id": None, "status": "no_line_item"})
continue
order_id = line_item_to_order.get(line_item_id)
if not order_id:
results.append({"reservation_id": r["id"], "order_id": None, "status": "orphaned_line_item"})
continue
results.append({"reservation_id": r["id"], "order_id": order_id, "status": "traced"})
return results
def list_reservations(token):
reservations = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/reservations", {
"fields": "id,inventory_item_id,location_id,quantity,line_item_id,created_at",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
def list_orders(token):
orders = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,*items",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
def stamp_resolved_order_id(token, reservation_id, order_id):
return admin_post(token, f"/admin/reservations/{reservation_id}", {
"metadata": {"resolved_order_id": order_id},
})
def write_report(path, rows):
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["reservation_id", "inventory_item_id", "order_id", "status"])
writer.writeheader()
for row in rows:
writer.writerow(row)
def run():
token = get_admin_token()
reservations = list_reservations(token)
orders = list_orders(token)
trace = trace_reservations_to_orders(reservations, orders)
by_id = {r["id"]: r for r in reservations}
report_rows = []
enriched = 0
orphaned = 0
no_line_item = 0
for result in trace:
reservation = by_id[result["reservation_id"]]
report_rows.append({
"reservation_id": result["reservation_id"],
"inventory_item_id": reservation["inventory_item_id"],
"order_id": result["order_id"] or "",
"status": result["status"],
})
if result["status"] == "traced":
log.info(
"Reservation %s traced to order %s. %s",
result["reservation_id"], result["order_id"],
"would stamp metadata" if DRY_RUN else "stamping metadata",
)
if not DRY_RUN:
stamp_resolved_order_id(token, result["reservation_id"], result["order_id"])
enriched += 1
elif result["status"] == "orphaned_line_item":
log.warning(
"Reservation %s has an orphaned line_item_id, flagged for manual review.",
result["reservation_id"],
)
orphaned += 1
else:
no_line_item += 1
write_report(REPORT_PATH, report_rows)
log.info(
"Done. %d traced (%s), %d orphaned, %d not order backed. Report written to %s.",
enriched, "enriched" if not DRY_RUN else "would enrich", orphaned, no_line_item, REPORT_PATH,
)
if __name__ == "__main__":
run()
/**
* Trace Medusa reservations back to the orders they belong to.
*
* Medusa v2 deliberately decouples the Inventory module from the Order module.
* ReservationItem stores only a bare line_item_id string, not a real relation, and
* there is no module link between ReservationItem and the Order module, so the
* Admin API and dashboard can never show which order a reservation is for. This
* lists reservations and orders, builds a line item to order lookup that stands in
* for the Order module's own OrderItem join, and reports every reservation as
* traced, orphaned, or not order backed. The only write is optional enrichment:
* stamping the resolved order id into a traced reservation's own metadata.
* Run on a schedule, or on demand. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/reservation-missing-order-id/
*/
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";
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 REPORT_PATH = process.env.REPORT_PATH || "reservation_trace_report.csv";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, inventory_item_id: string, quantity: number }[]} reservations
* @param {{ id: string, items: { id: string }[] }[]} orders
* @returns {{ reservation_id: string, order_id: string | null, status: "traced" | "orphaned_line_item" | "no_line_item" }[]}
*/
export function traceReservationsToOrders(reservations, orders) {
const lineItemToOrder = new Map();
for (const order of orders) {
for (const item of order.items) {
lineItemToOrder.set(item.id, order.id);
}
}
return reservations.map((r) => {
if (!r.line_item_id) {
return { reservation_id: r.id, order_id: null, status: "no_line_item" };
}
const orderId = lineItemToOrder.get(r.line_item_id);
if (!orderId) {
return { reservation_id: r.id, order_id: null, status: "orphaned_line_item" };
}
return { reservation_id: r.id, order_id: orderId, status: "traced" };
});
}
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, 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} on POST ${path}`);
return res.json();
}
async function listReservations(token) {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/reservations", {
fields: "id,inventory_item_id,location_id,quantity,line_item_id,created_at",
limit,
offset,
});
reservations.push(...data.reservations);
offset += limit;
if (offset >= data.count) return reservations;
}
}
async function listOrders(token) {
const orders = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,*items",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
async function stampResolvedOrderId(token, reservationId, orderId) {
return adminPost(token, `/admin/reservations/${reservationId}`, {
metadata: { resolved_order_id: orderId },
});
}
function writeReport(path, rows) {
const header = "reservation_id,inventory_item_id,order_id,status";
const lines = rows.map((r) =>
[r.reservation_id, r.inventory_item_id, r.order_id || "", r.status].join(",")
);
writeFileSync(path, [header, ...lines].join("\n") + "\n");
}
export async function run() {
const token = await getAdminToken();
const reservations = await listReservations(token);
const orders = await listOrders(token);
const trace = traceReservationsToOrders(reservations, orders);
const byId = new Map(reservations.map((r) => [r.id, r]));
const reportRows = [];
let enriched = 0;
let orphaned = 0;
let noLineItem = 0;
for (const result of trace) {
const reservation = byId.get(result.reservation_id);
reportRows.push({
reservation_id: result.reservation_id,
inventory_item_id: reservation.inventory_item_id,
order_id: result.order_id || "",
status: result.status,
});
if (result.status === "traced") {
console.log(
`Reservation ${result.reservation_id} traced to order ${result.order_id}. ${DRY_RUN ? "would stamp metadata" : "stamping metadata"}`
);
if (!DRY_RUN) await stampResolvedOrderId(token, result.reservation_id, result.order_id);
enriched++;
} else if (result.status === "orphaned_line_item") {
console.warn(`Reservation ${result.reservation_id} has an orphaned line_item_id, flagged for manual review.`);
orphaned++;
} else {
noLineItem++;
}
}
writeReport(REPORT_PATH, reportRows);
console.log(
`Done. ${enriched} traced (${DRY_RUN ? "would enrich" : "enriched"}), ${orphaned} orphaned, ${noLineItem} not order backed. Report written to ${REPORT_PATH}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
trace_reservations_to_orders is the part most worth testing, because it decides whether a reservation is confidently traced, flagged as orphaned, or reported as never order backed. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain arrays and checks the answer.
from trace_reservation_orders import trace_reservations_to_orders
def reservation(**over):
base = {"id": "res_1", "line_item_id": "item_1", "inventory_item_id": "iitem_1", "quantity": 2}
base.update(over)
return base
def order(**over):
base = {"id": "order_1", "items": [{"id": "item_1"}]}
base.update(over)
return base
def test_traced_when_line_item_matches_an_order():
result = trace_reservations_to_orders([reservation()], [order()])
assert result == [{"reservation_id": "res_1", "order_id": "order_1", "status": "traced"}]
def test_no_line_item_when_reservation_has_none():
r = reservation(line_item_id=None)
result = trace_reservations_to_orders([r], [order()])
assert result == [{"reservation_id": "res_1", "order_id": None, "status": "no_line_item"}]
def test_orphaned_line_item_when_no_order_matches():
r = reservation(line_item_id="item_missing")
result = trace_reservations_to_orders([r], [order()])
assert result == [{"reservation_id": "res_1", "order_id": None, "status": "orphaned_line_item"}]
def test_orphaned_line_item_when_no_orders_at_all():
result = trace_reservations_to_orders([reservation()], [])
assert result[0]["status"] == "orphaned_line_item"
def test_multiple_reservations_resolve_independently():
reservations = [
reservation(id="res_1", line_item_id="item_1"),
reservation(id="res_2", line_item_id=None),
reservation(id="res_3", line_item_id="item_gone"),
]
result = trace_reservations_to_orders(reservations, [order()])
statuses = {r["reservation_id"]: r["status"] for r in result}
assert statuses == {"res_1": "traced", "res_2": "no_line_item", "res_3": "orphaned_line_item"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { traceReservationsToOrders } from "./trace-reservation-orders.js";
const reservation = (over = {}) => ({
id: "res_1",
line_item_id: "item_1",
inventory_item_id: "iitem_1",
quantity: 2,
...over,
});
const order = (over = {}) => ({ id: "order_1", items: [{ id: "item_1" }], ...over });
test("traced when line_item matches an order", () => {
const result = traceReservationsToOrders([reservation()], [order()]);
assert.deepEqual(result, [{ reservation_id: "res_1", order_id: "order_1", status: "traced" }]);
});
test("no_line_item when reservation has none", () => {
const r = reservation({ line_item_id: null });
const result = traceReservationsToOrders([r], [order()]);
assert.deepEqual(result, [{ reservation_id: "res_1", order_id: null, status: "no_line_item" }]);
});
test("orphaned_line_item when no order matches", () => {
const r = reservation({ line_item_id: "item_missing" });
const result = traceReservationsToOrders([r], [order()]);
assert.deepEqual(result, [{ reservation_id: "res_1", order_id: null, status: "orphaned_line_item" }]);
});
test("orphaned_line_item when there are no orders at all", () => {
const result = traceReservationsToOrders([reservation()], []);
assert.equal(result[0].status, "orphaned_line_item");
});
test("multiple reservations resolve independently", () => {
const reservations = [
reservation({ id: "res_1", line_item_id: "item_1" }),
reservation({ id: "res_2", line_item_id: null }),
reservation({ id: "res_3", line_item_id: "item_gone" }),
];
const result = traceReservationsToOrders(reservations, [order()]);
const statuses = Object.fromEntries(result.map((r) => [r.reservation_id, r.status]));
assert.deepEqual(statuses, { res_1: "traced", res_2: "no_line_item", res_3: "orphaned_line_item" });
});
Case studies
A customer support team stopped clicking through orders by hand
A mid-size store's support team fielded a steady stream of shipping delay tickets. Every one meant opening the inventory item, finding the reservation holding the stock, then manually searching orders for a matching line item, one at a time, hoping to spot the right one before the customer got impatient.
Running the tracer once a day gave them a CSV with the resolved order id sitting right next to each reservation. Support started from the CSV instead of the dashboard, and what used to take several minutes of guessing became a single lookup.
Orphaned rows surfaced a real cleanup backlog
An ops engineer running the script for the first time expected most reservations to trace cleanly. Instead a meaningful slice came back orphaned_line_item, all older reservations from carts that had never completed.
The report gave a concrete, reviewable list instead of a vague suspicion that something was wrong. The team cross-checked each orphaned row by hand before deciding which ones were safe to delete, exactly the kind of manual confirmation this script is built to require.
After this runs on a schedule, every traceable reservation carries a resolved_order_id in its own metadata, so support and ops can look it up directly instead of cross-referencing orders by hand. Reservations that cannot be traced are reported clearly as orphaned or never order backed, so nothing gets silently ignored. The script never deletes anything and never writes a guessed order id, it only ever enriches what it has already confirmed.
FAQ
Why does a Medusa reservation not show which order it belongs to?
Medusa v2 keeps the Inventory module decoupled from the Order module, so ReservationItem stores only a bare line_item_id string, not a real relation, and there is no module link registered between ReservationItem and the Order module. The Admin API's default reservation fields never include order_id or an expandable line_item relation, so the dashboard has nothing to show.
Can I expand line_item on a reservation to get the order?
No. There is no line_item relation registered on ReservationItem to expand in the first place. Even if there were, OrderLineItem itself has no order_id column, since the Order module models that relationship through a separate OrderItem join entity, so you would still need one more hop through OrderItem to reach the owning order.
How do I trace a reservation back to its order in Medusa v2?
Fetch reservations for their line_item_id, then fetch orders with their items and build a lookup from each order item's id to the order that owns it. Match every reservation's line_item_id against that lookup. A match means the reservation is traced, a miss means the line item cannot be found in any fetched order, and a reservation with no line_item_id at all was never tied to an order.
Related field notes
Citations
On the problem:
- Order ID column is always empty in Inventory Item reservations table. Medusa GitHub Issue #14370. github.com/medusajs/medusa/issues/14370
- Reservation order number not showing in the inventory page. Medusa GitHub Issue #9797. github.com/medusajs/medusa/issues/9797
On the solution:
- Medusa V2 Admin API Reference, Reservations. docs.medusajs.com/api/admin
- OrderItem, Order Module Data Models Reference. docs.medusajs.com/resources/references/order/models/OrderItem
- Links between Order Module and Other Modules. docs.medusajs.com/resources/commerce-modules/order/links-to-other-modules
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 save you a support ticket?
If this saved you from clicking through orders by hand or gave you a clean way to report on stuck reservations, 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