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.
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."
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:
- The order edit path,
beginOrderEditOrderWorkflowandupdateOrderEditOrderWorkflow, feeds intoconfirmOrderEditRequestWorkflowonce the edit is confirmed. Any change to quantity or unit price is treated as invalidating the order's current totals snapshot. - As part of recalculating totals, the workflow cancels the order's existing
payment_collection, transitioning it tostatus: "canceled", and resetsorder.payment_statustonot_paid. This is a regression introduced in Medusa 2.7 and tracked as GitHub issue#12200. - The workflow does not reliably create and attach a new, capturable payment collection in every path. Force-confirmed admin edits, particularly with the manual payment provider, are the case most often reported as leaving the order with nothing to capture against, as described in GitHub issue
#5612. capturePaymentWorkflowchecks the parent payment collection's status before it will act on a payment, and refuses outright when that status iscanceled, throwing an error such as "The payment: pay_... has been canceled." This holds even though the order itself is not canceled and itssummarystill shows an amount due.- A related but separate issue, GitHub
#11591, shows that even when a new payment collection is created, it can fail to account for amounts already paid on the order, which is one more reason to never assume the replacement collection matches what is actually owed without checking.
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.
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.
Build it step by step
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.
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
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
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.
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"]
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;
}
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.
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
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;
}
}
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.
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}
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 };
}
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.
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()
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 },
});
}
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- medusajs/medusa GitHub issue #12200: [Question]: Editing an order (changing quantity or item prices). github.com/medusajs/medusa/issues/12200
- medusajs/medusa GitHub issue #5612: 'Order Edit' workflow incorrect when using manual payments. github.com/medusajs/medusa/issues/5612
- 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:
- Medusa Documentation: Payment Module (Payment Module). docs.medusajs.com/resources/commerce-modules/payment
- Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
- 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.
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