Skip to content

Diagnostic Order Edits & Totals

Draft orders cannot get a payment collection created

A merchant creates a draft order for a phone quote or a custom wholesale deal, expects to collect payment on it the same way as a normal order, and calls the payment-collection route. Nothing happens, or the call fails outright. The draft order sits there with an empty payment_collections relation and no way to register the payment. It is not a bug in your store. A draft order never has a cart_id, and the payment-collection route everyone reaches for first expects one. Here is why that happens and a script that finds the stuck draft orders so you can route them to the fix that actually works.

Python and Node.js Medusa Admin API Detect first, DRY_RUN-guarded repair
Holding a card and phone
Photo by Nathana Reboucas on Unsplash
The short answer

In Medusa v2, a draft order is an Order module record with is_draft_order: true that is created directly by the draft-order workflows, without a cart. The store-facing route, POST /store/payment-collections, is cart-centric and expects a cart_id, so it cannot help a draft order, which has none. Medusa maintainers confirmed on GitHub issue #14501 that this is expected behavior, not a bug. The actual mechanism is createOrUpdateOrderPaymentCollectionWorkflow, exposed through POST /admin/draft-orders/:id/payment-collections, which links a payment collection to the order through the order_payment_collection table using order_id, bypassing the cart entirely. Run a small Python or Node.js script that lists draft orders, flags the ones with no payment collection and a positive pending_difference, and, only when you explicitly turn off a dry run, creates the order-linked payment collection and marks it paid. Full code, tests, and the dry run guard are below.

The problem in plain words

A normal Medusa order starts life as a cart. A customer adds items, goes through checkout, and by the time an order exists, it already has a cart_id sitting behind it, plus a payment collection created along the way. Everything about payments in Medusa, including the store-facing API, was built with that cart in mind.

A draft order skips all of that. It is created directly through the draft-order workflows, as an Order module record with is_draft_order: true, so a staff member can build an order by hand for a phone order, a custom quote, or a wholesale deal, without a shopper ever touching a cart. That is the whole point of a draft order. But it means there is no cart_id to hand to the cart-centric payment-collection route, so calling POST /store/payment-collections for a draft order is not a matter of a missing parameter. It is structurally the wrong door. The draft order is left with an empty payment_collections relation and no obvious way to register payment.

Draft order created is_draft_order: true, no cart_id POST /store/payment-collections expects a cart_id draft order has none fails or does nothing payment_collections: [] pending_difference > 0 No way to collect pay
The draft order is real and the amount owed is real. What it is missing is the cart_id that the store payment-collections route needs, and it will never have one.

Why it happens

This comes down to two payment-collection paths in Medusa v2 that look similar but are not interchangeable:

This is a common source of confusion because the API surface does not make the distinction obvious. Nothing in the draft order object tells you which payment route applies, so it takes hitting the wall once, or reading the maintainer's answer on the issue, to learn that draft orders need their own door. See the citations at the end for the exact issue and docs.

The key insight

A draft order without a cart_id is not a broken draft order. It is the normal shape of every draft order. So the fix is never "find the missing cart_id," it is "route draft orders to the order-linked payment-collection workflow instead of the cart one." Detection should separate a genuinely stuck draft order, one with no payment collection and money still owed, from one that only looks stuck because you checked the wrong field.

The fix, as a flow

We pull draft orders and their payment collections, check each one against its outstanding amount, and only for the ones that are truly stuck, either report the finding or, once a human turns off dry run, create the order-linked payment collection and mark it paid. We never call the cart-based route for a draft order.

List draft orders /admin/draft-orders Read summary and collections pending_difference, payment_collections Classify with pure fn decideDraftOrderPaymentAction stuck? no payment yes no, order is OK Order-linked fix create + mark as paid
The script never tries the cart route for a draft order. It flags the truly stuck ones, and the repair, gated by DRY_RUN, goes through the order-linked workflow instead.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend and an admin user with rights to read draft orders and manage payments. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.

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 DRY_RUN="true"   # start safe, only reports stuck draft order ids
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, only reports stuck draft order ids
2

Authenticate against the Admin API

Exchange the admin email and password for a JWT with a single POST to the auth route, then send it as a Bearer token on every call that follows.

step2.py
import os, requests

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]
step2.js
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;

async function getToken() {
  const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}
3

List draft orders with their summary and payment collections

Call the dedicated draft-order route with fields=id,display_id,status,*summary,*payment_collections. Draft orders are Order module records with is_draft_order=true, using the same order_ id prefix as a normal order, but exposed here through their own admin route. Page through with limit and offset so the job covers a large backlog.

step3.py
DRAFT_ORDER_FIELDS = "id,display_id,status,*summary,*payment_collections"

def list_draft_orders(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/draft-orders",
            params={"fields": DRAFT_ORDER_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["draft_orders"])
        offset += limit
        if offset >= body["count"]:
            return out
step3.js
const DRAFT_ORDER_FIELDS = "id,display_id,status,*summary,*payment_collections";

async function listDraftOrders(token) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const res = await fetch(
      `${BASE_URL}/admin/draft-orders?fields=${encodeURIComponent(DRAFT_ORDER_FIELDS)}&limit=${limit}&offset=${offset}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    out.push(...body.draft_orders);
    offset += limit;
    if (offset >= body.count) return out;
  }
}
4

Decide, with one pure function

Keep the decision in a function with no network calls. It takes whether the order is a draft order, its status, whether it has a usable cart_id, its payment collections, and the pending difference, and returns one of three actions. A draft order that is completed, or already has a payment collection, or has nothing pending, is OK. A draft order with no payment collection and a positive pending difference is routed to the order-linked workflow, never flagged as a missing-cart bug, because a draft order never has a real cart_id to check.

decide.py
def decide_draft_order_payment_action(order):
    """Pure: no I/O. order has isDraftOrder, status, hasCartId,
    paymentCollections, pendingDifference."""
    if not order["isDraftOrder"]:
        return "OK"
    if order["status"] == "completed":
        return "OK"

    has_collection = len(order["paymentCollections"]) > 0
    if not has_collection and order["pendingDifference"] > 0:
        # Draft orders never have a real cart_id, so the cart-based
        # payment-collection creation path is structurally inapplicable;
        # route to the order-linked workflow instead of flagging a false
        # "missing cart" bug.
        if order["hasCartId"]:
            return "NEEDS_ORDER_PAYMENT_COLLECTION"
        return "FLAG_STUCK_NO_PAYMENT"

    return "OK"
decide.js
export function decideDraftOrderPaymentAction(order) {
  // Pure: no I/O. order has isDraftOrder, status, hasCartId,
  // paymentCollections, pendingDifference.
  if (!order.isDraftOrder) return "OK";
  if (order.status === "completed") return "OK";

  const hasCollection = order.paymentCollections.length > 0;
  if (!hasCollection && order.pendingDifference > 0) {
    // Draft orders never have a real cart_id, so the cart-based
    // payment-collection creation path is structurally inapplicable;
    // route to the order-linked workflow instead of flagging a false
    // "missing cart" bug.
    return order.hasCartId ? "NEEDS_ORDER_PAYMENT_COLLECTION" : "FLAG_STUCK_NO_PAYMENT";
  }

  return "OK";
}
5

Repair through the order-linked workflow, never the cart route

When a draft order is genuinely stuck and you have turned off dry run, call POST /admin/draft-orders/:id/payment-collections. Under the hood this runs createOrUpdateOrderPaymentCollectionWorkflow(container).run({ input: { order_id } }) and writes to the order_payment_collection link table, needing no cart_id at all, a mechanism confirmed by Medusa maintainer @NicolasGorga on issue #14501. Then complete the flow with POST /admin/payment-collections/:payment_collection_id/mark-as-paid, the same "Mark as paid" action the Admin dashboard exposes for an order with a positive outstanding amount.

apply.py
def create_order_payment_collection(token, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{order_id}/payment-collections",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["payment_collection"]


def mark_payment_collection_paid(token, payment_collection_id, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/payment-collections/{payment_collection_id}/mark-as-paid",
        json={"order_id": order_id},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function createOrderPaymentCollection(token, orderId) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${orderId}/payment-collections`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.payment_collection;
}

async function markPaymentCollectionPaid(token, paymentCollectionId, orderId) {
  const res = await fetch(
    `${BASE_URL}/admin/payment-collections/${paymentCollectionId}/mark-as-paid`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ order_id: orderId }),
    }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together. Because manually creating a payment collection and marking it paid changes financial state, leave DRY_RUN on for the first runs so the script only reports the affected draft order ids, display ids, and pending amounts. Read the report, confirm the amounts with a human, then turn DRY_RUN off to let it create the order-linked payment collection and mark it paid.

Run it safe

Never call POST /store/payment-collections for a draft order. It is cart-centric and cannot help. Always start with DRY_RUN=true, and only flip it off once a human has confirmed the pending amount on each reported draft order.

The full code

Here is the complete script in one file for each language. It authenticates, lists draft orders, classifies each one with a pure function, reports every draft order that is stuck with no payment collection, and, only when DRY_RUN is off, repairs it through the order-linked workflow.

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.
fix_draft_order_payment.py
"""Find Medusa v2 draft orders that cannot get a payment collection through
the cart-centric store route, because a draft order never has a cart_id.
DRY_RUN=true (default) only reports the affected draft orders. Only when
DRY_RUN=false does it create the payment collection through the
order-linked workflow and mark it paid.
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_draft_order_payment")

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

DRAFT_ORDER_FIELDS = "id,display_id,status,*summary,*payment_collections"


def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_draft_orders(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/draft-orders",
            params={"fields": DRAFT_ORDER_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["draft_orders"])
        offset += limit
        if offset >= body["count"]:
            return out


def decide_draft_order_payment_action(order):
    """Pure: no I/O. order has isDraftOrder, status, hasCartId,
    paymentCollections, pendingDifference."""
    if not order["isDraftOrder"]:
        return "OK"
    if order["status"] == "completed":
        return "OK"

    has_collection = len(order["paymentCollections"]) > 0
    if not has_collection and order["pendingDifference"] > 0:
        # Draft orders never have a real cart_id, so the cart-based
        # payment-collection creation path is structurally inapplicable;
        # route to the order-linked workflow instead of flagging a false
        # "missing cart" bug.
        if order["hasCartId"]:
            return "NEEDS_ORDER_PAYMENT_COLLECTION"
        return "FLAG_STUCK_NO_PAYMENT"

    return "OK"


def to_decision_input(raw_order):
    summary = raw_order.get("summary") or {}
    return {
        "isDraftOrder": True,
        "status": raw_order.get("status"),
        "hasCartId": False,
        "paymentCollections": raw_order.get("payment_collections") or [],
        "pendingDifference": float(summary.get("pending_difference") or 0),
    }


def create_order_payment_collection(token, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{order_id}/payment-collections",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["payment_collection"]


def mark_payment_collection_paid(token, payment_collection_id, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/payment-collections/{payment_collection_id}/mark-as-paid",
        json={"order_id": order_id},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_token()
    draft_orders = list_draft_orders(token)

    flagged = []
    for raw_order in draft_orders:
        action = decide_draft_order_payment_action(to_decision_input(raw_order))
        if action == "FLAG_STUCK_NO_PAYMENT":
            flagged.append(raw_order)

    if not flagged:
        log.info("No stuck draft orders found across %d draft order(s).", len(draft_orders))
        return

    for order in flagged:
        pending = (order.get("summary") or {}).get("pending_difference")
        log.warning(
            "Draft order %s (display #%s): no payment collection, pending_difference=%s. %s",
            order["id"], order.get("display_id"), pending,
            "Would create order-linked payment collection and mark paid" if DRY_RUN else "Repairing",
        )
        if not DRY_RUN:
            collection = create_order_payment_collection(token, order["id"])
            mark_payment_collection_paid(token, collection["id"], order["id"])

    log.info("Done. %d draft order(s) %s.", len(flagged), "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
fix-draft-order-payment.js
/**
 * Find Medusa v2 draft orders that cannot get a payment collection through
 * the cart-centric store route, because a draft order never has a cart_id.
 * DRY_RUN=true (default) only reports the affected draft orders. Only when
 * DRY_RUN=false does it create the payment collection through the
 * order-linked workflow and mark it paid.
 */
import { pathToFileURL } from "node:url";

const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const DRAFT_ORDER_FIELDS = "id,display_id,status,*summary,*payment_collections";

export function decideDraftOrderPaymentAction(order) {
  // Pure: no I/O. order has isDraftOrder, status, hasCartId,
  // paymentCollections, pendingDifference.
  if (!order.isDraftOrder) return "OK";
  if (order.status === "completed") return "OK";

  const hasCollection = order.paymentCollections.length > 0;
  if (!hasCollection && order.pendingDifference > 0) {
    // Draft orders never have a real cart_id, so the cart-based
    // payment-collection creation path is structurally inapplicable;
    // route to the order-linked workflow instead of flagging a false
    // "missing cart" bug.
    return order.hasCartId ? "NEEDS_ORDER_PAYMENT_COLLECTION" : "FLAG_STUCK_NO_PAYMENT";
  }

  return "OK";
}

function toDecisionInput(rawOrder) {
  const summary = rawOrder.summary || {};
  return {
    isDraftOrder: true,
    status: rawOrder.status,
    hasCartId: false,
    paymentCollections: rawOrder.payment_collections || [],
    pendingDifference: Number(summary.pending_difference || 0),
  };
}

async function getToken() {
  const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function listDraftOrders(token) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const res = await fetch(
      `${BASE_URL}/admin/draft-orders?fields=${encodeURIComponent(DRAFT_ORDER_FIELDS)}&limit=${limit}&offset=${offset}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    out.push(...body.draft_orders);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function createOrderPaymentCollection(token, orderId) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${orderId}/payment-collections`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.payment_collection;
}

async function markPaymentCollectionPaid(token, paymentCollectionId, orderId) {
  const res = await fetch(
    `${BASE_URL}/admin/payment-collections/${paymentCollectionId}/mark-as-paid`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ order_id: orderId }),
    }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

export async function run() {
  const token = await getToken();
  const draftOrders = await listDraftOrders(token);

  const flagged = draftOrders.filter(
    (rawOrder) => decideDraftOrderPaymentAction(toDecisionInput(rawOrder)) === "FLAG_STUCK_NO_PAYMENT"
  );

  if (flagged.length === 0) {
    console.log(`No stuck draft orders found across ${draftOrders.length} draft order(s).`);
    return;
  }

  for (const order of flagged) {
    const pending = order.summary?.pending_difference;
    console.warn(
      `Draft order ${order.id} (display #${order.display_id}): no payment collection, pending_difference=${pending}. ${DRY_RUN ? "Would create order-linked payment collection and mark paid" : "Repairing"}`
    );
    if (!DRY_RUN) {
      const collection = await createOrderPaymentCollection(token, order.id);
      await markPaymentCollectionPaid(token, collection.id, order.id);
    }
  }

  console.log(`Done. ${flagged.length} draft order(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The function worth testing is the one that decides the outcome, decide_draft_order_payment_action. It is pure, no network and no Medusa instance, so the tests feed in plain order objects and check the answer against a healthy draft order, a completed one, one that already has a payment collection, and the two truly stuck shapes, with and without a cart_id.

test_draft_order_payment.py
from fix_draft_order_payment import decide_draft_order_payment_action


def order(**over):
    base = {
        "isDraftOrder": True,
        "status": "pending",
        "hasCartId": False,
        "paymentCollections": [],
        "pendingDifference": 5000,
    }
    base.update(over)
    return base


def test_flags_stuck_when_no_collection_and_amount_pending():
    assert decide_draft_order_payment_action(order()) == "FLAG_STUCK_NO_PAYMENT"


def test_ok_when_not_a_draft_order():
    assert decide_draft_order_payment_action(order(isDraftOrder=False)) == "OK"


def test_ok_when_completed():
    assert decide_draft_order_payment_action(order(status="completed")) == "OK"


def test_ok_when_payment_collection_already_exists():
    o = order(paymentCollections=[{"id": "paycol_1", "status": "not_paid"}])
    assert decide_draft_order_payment_action(o) == "OK"


def test_ok_when_nothing_pending():
    assert decide_draft_order_payment_action(order(pendingDifference=0)) == "OK"


def test_needs_order_payment_collection_when_cart_id_present():
    o = order(hasCartId=True)
    assert decide_draft_order_payment_action(o) == "NEEDS_ORDER_PAYMENT_COLLECTION"


def test_flag_stuck_takes_priority_over_a_false_missing_cart_read():
    # Draft orders never have a real cart_id in practice, so this is the
    # branch that actually fires for real draft orders.
    o = order(hasCartId=False, pendingDifference=125.5)
    assert decide_draft_order_payment_action(o) == "FLAG_STUCK_NO_PAYMENT"
draft-order-payment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideDraftOrderPaymentAction } from "./fix-draft-order-payment.js";

const order = (over = {}) => ({
  isDraftOrder: true,
  status: "pending",
  hasCartId: false,
  paymentCollections: [],
  pendingDifference: 5000,
  ...over,
});

test("flags stuck when no collection and amount pending", () => {
  assert.equal(decideDraftOrderPaymentAction(order()), "FLAG_STUCK_NO_PAYMENT");
});

test("ok when not a draft order", () => {
  assert.equal(decideDraftOrderPaymentAction(order({ isDraftOrder: false })), "OK");
});

test("ok when completed", () => {
  assert.equal(decideDraftOrderPaymentAction(order({ status: "completed" })), "OK");
});

test("ok when payment collection already exists", () => {
  const o = order({ paymentCollections: [{ id: "paycol_1", status: "not_paid" }] });
  assert.equal(decideDraftOrderPaymentAction(o), "OK");
});

test("ok when nothing pending", () => {
  assert.equal(decideDraftOrderPaymentAction(order({ pendingDifference: 0 })), "OK");
});

test("needs order payment collection when cart_id present", () => {
  const o = order({ hasCartId: true });
  assert.equal(decideDraftOrderPaymentAction(o), "NEEDS_ORDER_PAYMENT_COLLECTION");
});

test("flag stuck takes priority over a false missing cart read", () => {
  const o = order({ hasCartId: false, pendingDifference: 125.5 });
  assert.equal(decideDraftOrderPaymentAction(o), "FLAG_STUCK_NO_PAYMENT");
});

Case studies

Phone order quote

The custom quote nobody could collect on

A support agent built a draft order over the phone for a customer wanting a bespoke bundle, then tried to trigger payment the same way the storefront checkout does, by posting to the payment-collections route with what they assumed was the cart. The call kept failing, and the agent spent an afternoon convinced something was broken in their Medusa install.

Running the detection script surfaced the real shape of the problem in seconds: the draft order had payment_collections: [] and a positive pending_difference, with no cart_id to speak of, because none ever existed. Once the team switched to the order-linked route, POST /admin/draft-orders/:id/payment-collections, the payment collection appeared immediately and the order was marked paid.

Wholesale backlog

A batch of draft orders stuck for weeks

A B2B seller created dozens of draft orders for negotiated wholesale deals, then discovered weeks later that none of them had ever been marked paid, because the internal tool used to trigger payment was calling the cart-centric API and silently doing nothing for every one of them.

The team ran the detection script in dry run across all draft orders and got back a clean list of the exact ids and pending amounts that were truly stuck. After a quick review, they turned off DRY_RUN and let the script create the order-linked payment collection and mark each one paid, closing out weeks of backlog in one pass.

What good looks like

After this runs, every draft order with money still owed and no payment collection gets caught, whether you check it after a batch of manual orders or on a schedule. Nothing gets routed to the cart-based payment route ever again, because the pure decision function only ever points at the order-linked workflow. The report tells a human exactly which draft orders need a look, and the repair only writes once DRY_RUN is explicitly turned off.

FAQ

Why can I not create a payment collection for a Medusa draft order?

A draft order is an Order module record created directly through the draft-order workflows, with is_draft_order set to true and no cart_id attached. The store-facing route, POST /store/payment-collections, is cart-centric and expects a cart_id, so calling it for a draft order fails or does nothing useful. Medusa maintainers confirmed on GitHub issue 14501 that this is expected behavior, not a bug.

What is the correct way to attach a payment collection to a draft order?

Use the order-linked path instead of the cart route. POST /admin/draft-orders/:id/payment-collections runs createOrUpdateOrderPaymentCollectionWorkflow with the order_id, which writes to the order_payment_collection link table and needs no cart_id at all. That is the mechanism Medusa maintainer NicolasGorga pointed to on issue 14501.

How do I finish marking a draft order as paid once it has a payment collection?

Once the order-linked payment collection exists, call POST /admin/payment-collections/:id/mark-as-paid with { order_id } in the body. That is the same Mark as paid action the Admin dashboard exposes for orders with a positive outstanding amount, and it completes the flow without ever touching the cart-based route.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #14501: [Bug]: Impossible to create a Payment Collection for a Draft Order. github.com/medusajs/medusa/issues/14501
  2. Medusa Documentation: Draft Orders Plugin (Order Module). docs.medusajs.com/resources/commerce-modules/order/draft-orders
  3. Medusa Documentation: How to Manage Draft Orders. docs.medusajs.com/v1/modules/orders/admin/manage-draft-orders

On the solution:

  1. Medusa Documentation: Draft Orders Plugin (Order Module). docs.medusajs.com/resources/commerce-modules/order/draft-orders
  2. Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
  3. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa 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 unstick a draft order?

If this saved you from a wrong turn down the cart-based route, 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