Skip to content

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.

Python and Node.js Medusa Admin API Safe by default (dry run)
Warehouse storage racks
Photo by Petr on Unsplash
The short answer

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.

ReservationItem line_item_id (bare string) no module link OrderLineItem no order_id column OrderItem join entity, one more hop Order the id ops wants Dashboard shows nothing
ReservationItem has no path to Order at all. Even the Order module's own line item needs one more hop, through OrderItem, to reach the order it belongs to.

Why it happens

The Admin API and the data model agree with each other here, and both point the same way:

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.

The key insight

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.

List reservations id, line_item_id List orders and items build line item lookup Pure decision fn traceReservationsToOrders Traced and dry run off? yes no, report only CSV report row for ops review Stamp metadata resolved_order_id
Every reservation is classified first. A CSV row is always written. The metadata write only happens with DRY_RUN off, and only for reservations the function marked traced.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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
step2.js
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;
  }
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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
decide.js
/**
 * 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" };
  });
}
5

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.

step5.py
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)
step5.js
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");
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
trace_reservation_orders.py
"""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-reservation-orders.js
/**
 * 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.

test_reservation_trace.py
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"}
reservation-trace.test.js
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

Support escalation

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.

Reservation cleanup

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.

What good looks like

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:

  1. Order ID column is always empty in Inventory Item reservations table. Medusa GitHub Issue #14370. github.com/medusajs/medusa/issues/14370
  2. Reservation order number not showing in the inventory page. Medusa GitHub Issue #9797. github.com/medusajs/medusa/issues/9797

On the solution:

  1. Medusa V2 Admin API Reference, Reservations. docs.medusajs.com/api/admin
  2. OrderItem, Order Module Data Models Reference. docs.medusajs.com/resources/references/order/models/OrderItem
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes