Skip to content

Diagnostic Order Edits & Totals

Editing an order cancels its payment collection and blocks capture

A support agent adds a line, bumps a quantity, or corrects a unit price on an order that already has money attached to it. The edit saves fine. But now the order's payment collection shows canceled, payment_status has dropped back to not_paid, and every attempt to capture a payment on that order fails with a plain "has been canceled" error. The order is not canceled. The customer still owes money. Here is why the order edit workflow leaves orders in this state and a script that finds them safely, without guessing at a fix.

Python and Node.js Medusa Admin API Flag and report, no silent writes
A laptop and a credit card
Photo by SumUp on Unsplash
The short answer

In Medusa v2, a regression introduced in 2.7 and tracked as GitHub issue #12200 makes the order edit workflow (beginOrderEditOrderWorkflow / updateOrderEditOrderWorkflow into confirmOrderEditRequestWorkflow) treat any quantity or unit price change as invalidating the order's existing totals. As part of recalculating, it cancels the order's current payment_collection and resets payment_status to not_paid, expecting a fresh collection to be created for the new amount owed. That new collection does not always get created and attached, especially on force-confirmed admin edits made with the manual payment provider, so the order is left with only a permanently canceled collection. Medusa's capturePaymentWorkflow refuses to act on a payment whose parent collection is canceled, so the order can no longer be captured through the Admin API or dashboard even though it still expects payment. Run a small Python or Node.js script that lists orders with a confirmed edit, checks whether the only payment collection left is canceled while money is still owed, and reports each one for a human to review. It never tries to touch the canceled collection, it only flags.

The problem in plain words

Order edits in Medusa are supposed to be a routine thing. A customer wants one more unit, a price was quoted wrong, an item needs swapping before it ships. You open the order, add the change, and confirm it. Totals recalculate, the invoice reflects the new amount, everyone moves on.

What actually happens underneath is more drastic. Because Medusa cannot cleanly reprice one line without touching the order's whole totals snapshot, the edit workflow cancels the existing payment_collection outright and drops payment_status back to not_paid, on the assumption that a brand new collection will be created for whatever is now owed. In a lot of real setups, especially force-confirmed admin edits on the manual payment provider, that replacement collection never shows up. The order is left holding a payment collection stuck at canceled, with no live collection anywhere to capture against. Try to capture and the Admin API stops you cold: "The payment: pay_... has been canceled."

Admin edits order qty or unit price change confirmOrderEditRequestWorkflow cancels payment_collection expects a fresh one to attach no replacement attached collection: canceled payment_status: not_paid Capture blocked
The edit itself succeeds. The payment collection it cancels along the way is the part that never gets replaced, so capture has nothing left to act on.

Why it happens

This is a workflow ordering gap in how Medusa v2 handles order edits, not a one-off bug in a single store's setup:

This is a common source of confusion because nothing on the order looks broken at a glance. The order status is fine, the edit applied, the new total is correct. It is only when someone tries to capture that the "has been canceled" error surfaces, and by then the merchant is usually staring at an order that should be simple to collect on. See the citations at the end for the exact issues and docs.

The key insight

There is no supported Admin API route to un-cancel a payment_collection, and writing around that by manufacturing a new collection without review risks desyncing order.summary and difference_due even further. So this is not a script you auto-fix with. The safe pattern is to detect and report only: find every order where the only payment collection left is canceled and money is still owed, hand that list to a human, and let a person decide the outstanding amount is correct before a new payment collection is created and captured against.

The fix, as a flow

We do not touch checkout and we do not try to write over the canceled collection. We list orders with a confirmed edit, expand each order's payment collections, and run a pure decision function that flags an order only when it is not_paid, has no live collection to capture against, has at least one canceled collection, and still has an amount due. Everything flagged goes into a report. Nothing is written unless DRY_RUN is off and a human has reviewed the row.

List edited orders order_change: edit, confirmed Expand collections payment_collections, summary Classify with pure fn classifyOrderPaymentEditState blocked true? yes no, leave alone Report row human reviews, then acts
The script only ever reports. A human reviews the amount due and, only then, creates a new payment collection to capture against, never the canceled one.

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 orders and 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 order_id/amount_due pairs
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 DRY_RUN="true"   // start safe, only reports order_id/amount_due pairs
2

Authenticate against the Admin API

Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.

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
import Medusa from "@medusajs/js-sdk";

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;

const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });

async function login() {
  await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
  return sdk;
}
3

List orders with a confirmed edit and their payment collections

Ask for orders with payment_status, the order's summary, every payment_collections entry, and the order_change relation. Page through with limit and offset. For each order, check the change history for an OrderChange with change_type: "edit" whose status is requested or confirmed, so we only look at edits that were actually applied, not left as a draft.

step3.py
ORDER_FIELDS = (
    "id,display_id,status,payment_status,*summary,"
    "*payment_collections,*order_change"
)
EDIT_CHANGE_STATUSES = {"requested", "confirmed"}

def has_confirmed_edit(order):
    change = order.get("order_change") or {}
    return (
        change.get("change_type") == "edit"
        and change.get("status") in EDIT_CHANGE_STATUSES
    )

def list_edited_orders(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/orders",
            params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(o for o in body["orders"] if has_confirmed_edit(o))
        offset += limit
        if offset >= body["count"]:
            return out
step3.js
const ORDER_FIELDS =
  "id,display_id,status,payment_status,*summary," +
  "*payment_collections,*order_change";
const EDIT_CHANGE_STATUSES = new Set(["requested", "confirmed"]);

export function hasConfirmedEdit(order) {
  const change = order.order_change || {};
  return change.change_type === "edit" && EDIT_CHANGE_STATUSES.has(change.status);
}

async function listEditedOrders(sdk) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const body = await sdk.admin.order.list({ fields: ORDER_FIELDS, limit, offset });
    out.push(...body.orders.filter(hasConfirmedEdit));
    offset += limit;
    if (offset >= body.count) return out;
  }
}
4

Decide, with one pure function

Keep the decision in a function with no network calls, so it is easy to read and easy to test. An order is blocked when payment_status is not_paid, there is no payment collection with a status that could still be captured (not_paid, awaiting, authorized, partially_authorized), at least one collection is canceled, and the amount due is greater than zero. Anything else, including a fully refunded or canceled order, is left alone.

decide.py
CAPTURABLE_STATUSES = {"not_paid", "awaiting", "authorized", "partially_authorized"}

def classify_order_payment_edit_state(order):
    """Pure: no I/O. order has payment_status, payment_collections, summary."""
    if order.get("payment_status") != "not_paid":
        return {"blocked": False, "reason": None, "canceledCollectionId": None, "amountDue": 0}

    collections = order.get("payment_collections") or []
    has_capturable = any(pc.get("status") in CAPTURABLE_STATUSES for pc in collections)
    canceled = next((pc for pc in collections if pc.get("status") == "canceled"), None)

    summary = order.get("summary") or {}
    amount_due = summary.get("raw_difference_due")
    if amount_due is None:
        amount_due = sum(
            pc.get("amount", 0) for pc in collections if pc.get("status") != "captured"
        )

    if not has_capturable and canceled is not None and amount_due > 0:
        return {
            "blocked": True,
            "reason": "canceled_collection_blocks_capture",
            "canceledCollectionId": canceled.get("id"),
            "amountDue": amount_due,
        }

    return {"blocked": False, "reason": None, "canceledCollectionId": None, "amountDue": 0}
decide.js
const CAPTURABLE_STATUSES = new Set(["not_paid", "awaiting", "authorized", "partially_authorized"]);

export function classifyOrderPaymentEditState(order) {
  // Pure: no I/O. order has payment_status, payment_collections, summary.
  if (order.payment_status !== "not_paid") {
    return { blocked: false, reason: null, canceledCollectionId: null, amountDue: 0 };
  }

  const collections = order.payment_collections || [];
  const hasCapturable = collections.some((pc) => CAPTURABLE_STATUSES.has(pc.status));
  const canceled = collections.find((pc) => pc.status === "canceled") || null;

  let amountDue = order.summary?.raw_difference_due;
  if (amountDue == null) {
    amountDue = collections
      .filter((pc) => pc.status !== "captured")
      .reduce((sum, pc) => sum + (pc.amount || 0), 0);
  }

  if (!hasCapturable && canceled && amountDue > 0) {
    return {
      blocked: true,
      reason: "canceled_collection_blocks_capture",
      canceledCollectionId: canceled.id,
      amountDue,
    };
  }

  return { blocked: false, reason: null, canceledCollectionId: null, amountDue: 0 };
}
5

Report, never mutate the canceled collection

When an order is blocked, emit a report row with the order id, display id, payment status, the canceled collection id, and the amount due. There is no supported route to un-cancel a payment collection, so the script never attempts that. If a human confirms the amount and turns DRY_RUN off, the only supported next step is creating a fresh payment collection for the difference owed, attaching a session, and capturing that new payment, all separate operator-confirmed calls.

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


def create_payment_session(token, collection_id, provider_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/payment-collections/{collection_id}/payment-sessions",
        json={"provider_id": provider_id},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function createPaymentCollection(sdk, orderId, amount) {
  const body = await sdk.client.fetch(`/admin/orders/${orderId}/payment-collections`, {
    method: "POST",
    body: { amount },
  });
  return body.payment_collection;
}

async function createPaymentSession(sdk, collectionId, providerId) {
  return sdk.client.fetch(`/admin/payment-collections/${collectionId}/payment-sessions`, {
    method: "POST",
    body: { provider_id: providerId },
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On every run, leave DRY_RUN on to only print the report rows. This script intentionally never flips to a write path on its own, since capturing the wrong amount on a re-priced order is worse than leaving it flagged. Treat the report as the trigger for a human to review order.summary.raw_difference_due and, only after that, run the create-collection and capture steps by hand or in a follow-up, reviewed job.

Run it safe

Never call any route that mutates the existing canceled payment_collection's status. There is not one. Always start with DRY_RUN=true, and only create a new payment collection and capture against it after a human has confirmed the amount matches order.summary.raw_difference_due.

The full code

Here is the complete script in one file for each language. It authenticates, lists orders with a confirmed edit, classifies each one with a pure function, and reports every order that is blocked by a canceled payment collection with an outstanding balance. It never writes anything by default.

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.
flag_edit_cancels_payment.py
"""Find Medusa v2 orders where a confirmed order edit canceled the payment
collection and left no capturable collection behind, while the order still
owes money. This is not auto-fixable: there is no supported route to
un-cancel a payment_collection. DRY_RUN=true (default) only reports the
affected orders. Only when DRY_RUN=false and a human has reviewed the
amount should a new payment collection be created and captured against.
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_edit_cancels_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"
PAYMENT_PROVIDER_ID = os.environ.get("MEDUSA_PAYMENT_PROVIDER_ID", "pp_system_default")

ORDER_FIELDS = (
    "id,display_id,status,payment_status,*summary,"
    "*payment_collections,*order_change"
)
EDIT_CHANGE_STATUSES = {"requested", "confirmed"}
CAPTURABLE_STATUSES = {"not_paid", "awaiting", "authorized", "partially_authorized"}


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 has_confirmed_edit(order):
    change = order.get("order_change") or {}
    return (
        change.get("change_type") == "edit"
        and change.get("status") in EDIT_CHANGE_STATUSES
    )


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


def classify_order_payment_edit_state(order):
    """Pure: no I/O. order has payment_status, payment_collections, summary."""
    if order.get("payment_status") != "not_paid":
        return {"blocked": False, "reason": None, "canceledCollectionId": None, "amountDue": 0}

    collections = order.get("payment_collections") or []
    has_capturable = any(pc.get("status") in CAPTURABLE_STATUSES for pc in collections)
    canceled = next((pc for pc in collections if pc.get("status") == "canceled"), None)

    summary = order.get("summary") or {}
    amount_due = summary.get("raw_difference_due")
    if amount_due is None:
        amount_due = sum(
            pc.get("amount", 0) for pc in collections if pc.get("status") != "captured"
        )

    if not has_capturable and canceled is not None and amount_due > 0:
        return {
            "blocked": True,
            "reason": "canceled_collection_blocks_capture",
            "canceledCollectionId": canceled.get("id"),
            "amountDue": amount_due,
        }

    return {"blocked": False, "reason": None, "canceledCollectionId": None, "amountDue": 0}


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


def create_payment_session(token, collection_id, provider_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/payment-collections/{collection_id}/payment-sessions",
        json={"provider_id": provider_id},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_token()
    orders = list_edited_orders(token)

    flagged = []
    for order in orders:
        result = classify_order_payment_edit_state(order)
        if result["blocked"]:
            flagged.append((order, result))

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

    for order, result in flagged:
        log.warning(
            "Order %s (display #%s): canceled_collection=%s amount_due=%s. %s",
            order["id"], order.get("display_id"), result["canceledCollectionId"],
            result["amountDue"],
            "Would report only" if DRY_RUN else "Reported, awaiting operator action",
        )
        if not DRY_RUN:
            log.warning(
                "Order %s: DRY_RUN is off, but this script never auto-creates a "
                "payment collection. Confirm amount_due=%s against "
                "order.summary.raw_difference_due, then call "
                "create_payment_collection() and create_payment_session() by hand.",
                order["id"], result["amountDue"],
            )

    log.info("Done. %d order(s) blocked by a canceled payment collection.", len(flagged))


if __name__ == "__main__":
    run()
flag-edit-cancels-payment.js
/**
 * Find Medusa v2 orders where a confirmed order edit canceled the payment
 * collection and left no capturable collection behind, while the order
 * still owes money. This is not auto-fixable: there is no supported route
 * to un-cancel a payment_collection. DRY_RUN=true (default) only reports
 * the affected orders. Only when DRY_RUN=false and a human has reviewed
 * the amount should a new payment collection be created and captured
 * against.
 */
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 PAYMENT_PROVIDER_ID = process.env.MEDUSA_PAYMENT_PROVIDER_ID || "pp_system_default";

const ORDER_FIELDS =
  "id,display_id,status,payment_status,*summary," +
  "*payment_collections,*order_change";
const EDIT_CHANGE_STATUSES = new Set(["requested", "confirmed"]);
const CAPTURABLE_STATUSES = new Set(["not_paid", "awaiting", "authorized", "partially_authorized"]);

export function hasConfirmedEdit(order) {
  const change = order.order_change || {};
  return change.change_type === "edit" && EDIT_CHANGE_STATUSES.has(change.status);
}

export function classifyOrderPaymentEditState(order) {
  // Pure: no I/O. order has payment_status, payment_collections, summary.
  if (order.payment_status !== "not_paid") {
    return { blocked: false, reason: null, canceledCollectionId: null, amountDue: 0 };
  }

  const collections = order.payment_collections || [];
  const hasCapturable = collections.some((pc) => CAPTURABLE_STATUSES.has(pc.status));
  const canceled = collections.find((pc) => pc.status === "canceled") || null;

  let amountDue = order.summary?.raw_difference_due;
  if (amountDue == null) {
    amountDue = collections
      .filter((pc) => pc.status !== "captured")
      .reduce((sum, pc) => sum + (pc.amount || 0), 0);
  }

  if (!hasCapturable && canceled && amountDue > 0) {
    return {
      blocked: true,
      reason: "canceled_collection_blocks_capture",
      canceledCollectionId: canceled.id,
      amountDue,
    };
  }

  return { blocked: false, reason: null, canceledCollectionId: null, amountDue: 0 };
}

async function login() {
  const { default: Medusa } = await import("@medusajs/js-sdk");
  const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
  await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
  return sdk;
}

async function listEditedOrders(sdk) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const body = await sdk.admin.order.list({ fields: ORDER_FIELDS, limit, offset });
    out.push(...body.orders.filter(hasConfirmedEdit));
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function createPaymentCollection(sdk, orderId, amount) {
  return sdk.client.fetch(`/admin/orders/${orderId}/payment-collections`, {
    method: "POST",
    body: { amount },
  });
}

async function createPaymentSession(sdk, collectionId, providerId) {
  return sdk.client.fetch(`/admin/payment-collections/${collectionId}/payment-sessions`, {
    method: "POST",
    body: { provider_id: providerId },
  });
}

export async function run() {
  const sdk = await login();
  const orders = await listEditedOrders(sdk);

  const flagged = [];
  for (const order of orders) {
    const result = classifyOrderPaymentEditState(order);
    if (result.blocked) flagged.push([order, result]);
  }

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

  for (const [order, result] of flagged) {
    console.warn(
      `Order ${order.id} (display #${order.display_id}): canceled_collection=${result.canceledCollectionId} amount_due=${result.amountDue}. ${DRY_RUN ? "Would report only" : "Reported, awaiting operator action"}`
    );
    if (!DRY_RUN) {
      console.warn(
        `Order ${order.id}: DRY_RUN is off, but this script never auto-creates a payment collection. Confirm amount_due=${result.amountDue} against order.summary.raw_difference_due, then call createPaymentCollection() and createPaymentSession() by hand.`
      );
    }
  }

  console.log(`Done. ${flagged.length} order(s) blocked by a canceled payment collection.`);
}

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, classify_order_payment_edit_state. It is pure, no network and no database, so the tests feed in plain order objects built from fixtures and check the answer against a healthy order, an order with an active collection, an order with only a canceled collection and an outstanding balance, and a fully refunded or canceled order that should never be flagged.

test_order_edit_payment_state.py
from flag_edit_cancels_payment import classify_order_payment_edit_state


def order(**over):
    base = {
        "id": "order_1",
        "payment_status": "not_paid",
        "summary": {"raw_difference_due": 5000},
        "payment_collections": [
            {"id": "paycol_1", "status": "canceled", "amount": 5000},
        ],
    }
    base.update(over)
    return base


def test_blocked_when_only_canceled_collection_and_amount_due():
    result = classify_order_payment_edit_state(order())
    assert result["blocked"] is True
    assert result["reason"] == "canceled_collection_blocks_capture"
    assert result["canceledCollectionId"] == "paycol_1"
    assert result["amountDue"] == 5000


def test_not_blocked_when_healthy_paid_order():
    o = order(payment_status="captured", summary={"raw_difference_due": 0})
    result = classify_order_payment_edit_state(o)
    assert result == {"blocked": False, "reason": None, "canceledCollectionId": None, "amountDue": 0}


def test_not_blocked_when_an_active_collection_exists():
    o = order(payment_collections=[
        {"id": "paycol_1", "status": "canceled", "amount": 5000},
        {"id": "paycol_2", "status": "not_paid", "amount": 5000},
    ])
    result = classify_order_payment_edit_state(o)
    assert result["blocked"] is False


def test_blocked_with_only_canceled_collection_and_outstanding_balance():
    o = order(payment_collections=[{"id": "paycol_9", "status": "canceled", "amount": 12000}],
              summary={"raw_difference_due": 12000})
    result = classify_order_payment_edit_state(o)
    assert result["blocked"] is True
    assert result["canceledCollectionId"] == "paycol_9"
    assert result["amountDue"] == 12000


def test_not_blocked_when_fully_refunded_or_canceled_with_no_amount_due():
    o = order(summary={"raw_difference_due": 0})
    result = classify_order_payment_edit_state(o)
    assert result["blocked"] is False


def test_not_blocked_when_payment_status_is_not_not_paid():
    o = order(payment_status="awaiting")
    result = classify_order_payment_edit_state(o)
    assert result["blocked"] is False


def test_falls_back_to_summing_uncaptured_collections_when_summary_missing():
    o = order(summary={}, payment_collections=[
        {"id": "paycol_1", "status": "canceled", "amount": 3000},
    ])
    result = classify_order_payment_edit_state(o)
    assert result["blocked"] is True
    assert result["amountDue"] == 3000
order-edit-payment-state.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderPaymentEditState } from "./flag-edit-cancels-payment.js";

const order = (over = {}) => ({
  id: "order_1",
  payment_status: "not_paid",
  summary: { raw_difference_due: 5000 },
  payment_collections: [
    { id: "paycol_1", status: "canceled", amount: 5000 },
  ],
  ...over,
});

test("blocked when only canceled collection and amount due", () => {
  const result = classifyOrderPaymentEditState(order());
  assert.equal(result.blocked, true);
  assert.equal(result.reason, "canceled_collection_blocks_capture");
  assert.equal(result.canceledCollectionId, "paycol_1");
  assert.equal(result.amountDue, 5000);
});

test("not blocked when healthy paid order", () => {
  const o = order({ payment_status: "captured", summary: { raw_difference_due: 0 } });
  const result = classifyOrderPaymentEditState(o);
  assert.deepEqual(result, { blocked: false, reason: null, canceledCollectionId: null, amountDue: 0 });
});

test("not blocked when an active collection exists", () => {
  const o = order({
    payment_collections: [
      { id: "paycol_1", status: "canceled", amount: 5000 },
      { id: "paycol_2", status: "not_paid", amount: 5000 },
    ],
  });
  const result = classifyOrderPaymentEditState(o);
  assert.equal(result.blocked, false);
});

test("blocked with only canceled collection and outstanding balance", () => {
  const o = order({
    payment_collections: [{ id: "paycol_9", status: "canceled", amount: 12000 }],
    summary: { raw_difference_due: 12000 },
  });
  const result = classifyOrderPaymentEditState(o);
  assert.equal(result.blocked, true);
  assert.equal(result.canceledCollectionId, "paycol_9");
  assert.equal(result.amountDue, 12000);
});

test("not blocked when fully refunded or canceled with no amount due", () => {
  const o = order({ summary: { raw_difference_due: 0 } });
  const result = classifyOrderPaymentEditState(o);
  assert.equal(result.blocked, false);
});

test("not blocked when payment_status is not not_paid", () => {
  const o = order({ payment_status: "awaiting" });
  const result = classifyOrderPaymentEditState(o);
  assert.equal(result.blocked, false);
});

test("falls back to summing uncaptured collections when summary missing", () => {
  const o = order({
    summary: {},
    payment_collections: [{ id: "paycol_1", status: "canceled", amount: 3000 }],
  });
  const result = classifyOrderPaymentEditState(o);
  assert.equal(result.blocked, true);
  assert.equal(result.amountDue, 3000);
});

Case studies

Force-confirmed admin edit

The manual payment order that lost its collection

A support team used the Admin dashboard to bump a quantity on a wholesale order paid through the manual payment provider, force-confirming the edit so it would not wait on customer approval. The edit applied and the new total looked right. Days later, finance tried to capture the outstanding balance and got a flat "The payment: pay_... has been canceled." error with no obvious next step in the dashboard.

Running the flag script in dry run surfaced the order immediately: payment_status was not_paid, the only payment collection on the order was canceled, and summary.raw_difference_due still showed the full new total owed. Finance confirmed the amount, then created a fresh payment collection and captured against that, leaving the canceled one untouched.

Batch of repriced orders

A pricing correction across dozens of orders

A store corrected a unit price mistake across several dozen open orders using a script that called the order edit workflow directly. Every edit succeeded, but a meaningful chunk of those orders quietly lost their payment collection to cancellation in the process, and nobody noticed until the next capture pass failed on a subset of them.

Rather than trying to guess which orders were affected, the team ran the classification function across every order touched by the batch job. It cleanly separated the ones with a live collection left to capture from the ones truly stuck on a canceled collection with money still due, so the follow-up work only touched the orders that actually needed a new payment collection.

What good looks like

Run this after any batch of order edits, or on a schedule while #12200 remains open upstream. It never touches the canceled payment collection and never guesses at an amount to capture. It reports exactly which orders are stuck, with the amount a human needs to confirm before creating a new payment collection and capturing against it. That keeps a workflow bug from turning into an accidental over-charge or a mismatched summary.

FAQ

Why did editing my Medusa order cancel its payment collection?

In Medusa v2 the order edit workflow treats any quantity or unit price change as invalidating the order's existing totals, so as part of recalculating it cancels the current payment collection and resets payment_status to not_paid. It expects a fresh payment collection to be created for the new amount owed, but that step does not always happen, which is a known regression tracked as GitHub issue 12200.

Can I capture a payment on a canceled payment collection?

No. Medusa's capturePaymentWorkflow refuses to act on a payment whose parent payment collection has a status of canceled, and returns an error like The payment: pay_... has been canceled. There is no supported route to un-cancel a payment_collection, so trying to capture against it will always fail.

What is the safe fix when an order edit leaves only a canceled payment collection?

Do not try to mutate the canceled payment_collection's status. Once a human has confirmed the outstanding amount against order.summary.raw_difference_due, create a brand new payment collection for that order with POST /admin/orders/:id/payment-collections, attach a payment session, and capture against that new collection instead.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #12200: [Question]: Editing an order (changing quantity or item prices). github.com/medusajs/medusa/issues/12200
  2. medusajs/medusa GitHub issue #5612: 'Order Edit' workflow incorrect when using manual payments. github.com/medusajs/medusa/issues/5612
  3. medusajs/medusa GitHub issue #11591: Payment Collections Don't Account for Previously Paid Amounts When Creating New Collections. github.com/medusajs/medusa/issues/11591

On the solution:

  1. Medusa Documentation: Payment Module (Payment Module). docs.medusajs.com/resources/commerce-modules/payment
  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 untangle a stuck payment collection?

If this saved you from guessing at a fix or over-capturing on a repriced order, 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