Diagnostic Order Edits & Totals
Order edit after capture computes the refund direction backwards
A support agent swaps a line item for a cheaper variant on an order that already has a captured payment. The edit confirms fine, the new total is lower, and the customer is owed money back. But the UI, or a cached balance field, reports a positive amount to collect instead of a negative amount to refund. Nobody asked to collect more from a customer who is due a refund. Here is why the sign flips after capture and a read only audit script that catches the mismatch before anyone acts on it.
In Medusa v2, an order's summary.pending_difference is meant to be current_order_total - paid_total: negative means a refund is owed to the customer, positive means the customer still owes more. This is computed by the order module's totals calculator over the OrderChange and OrderChangeAction records staged for an edit. GitHub issues #13068 and #13067 report that once a payment has already been captured, the summary's paid_total is not correctly reflected before the edit's new totals are diffed against it, so the recomputed difference effectively gets the operands reversed or stale, flipping the sign. Swapping to a cheaper variant during an order edit can then report a positive "amount to collect" instead of the negative "amount to refund" a correct recomputation would produce. Run a small Python or Node.js script that pulls the order and its pending edit, independently recomputes current_order_total - paid_total, and flags any order where that recomputed sign disagrees with the direction the app is using. It never calls a capture or refund route, it only flags.
The problem in plain words
An order edit is supposed to be simple arithmetic. The customer already paid some amount. The edit changes what the order is worth. Subtract what was paid from what it is worth now, and the sign of that number tells you which way the money should move. Negative, you owe them. Positive, they owe you.
That arithmetic depends on Medusa correctly knowing paid_total at the moment it diffs the new total against it. When a payment has already been captured before the edit, the reported bugs show that captured amount is not always reflected in the summary before the new totals get compared, so the calculation can end up comparing the wrong pair of numbers, or comparing stale ones. The result carries the wrong sign. A customer who swapped into a cheaper variant, and who is owed a refund, ends up with the order reporting an amount to collect instead. Nothing in the order data itself is corrupted, but the number the interface hands a human to act on now points the wrong way.
Why it happens
This is a computed field ordering gap in Medusa v2's order totals, not a one-off bug in a single store's data:
- The order module's totals calculator derives
summary.pending_differencefromcurrent_order_total - paid_total, computed over theOrderChangeandOrderChangeActionrecords staged for an edit, as documented in the Order module's totals reference. - When a payment has already been captured before the edit, GitHub issue
#13068("Order Edit after captured payment, incorrect difference") reports that the reported difference comes out with the wrong sign once a variant is swapped for a cheaper one after capture. - The related issue
#13067("Capture payment not working as intended") points at the same root cause from another angle, captured amounts not being correctly reflected in the summary before totals are recalculated, so the operands the diff runs on are effectively reversed or stale. - Issue
#10392("unable to refund amount for an order with captured payment") shows the same family of totals desync surfacing as a blocked refund attempt, reinforcing that this sits in the summary and order edit total calculation path, not in one store's custom code.
This is a common source of confusion because nothing about the order looks wrong at a glance. The edit applied, the new total is correct, the order is not corrupted. It is only when someone reads the direction off the summary, or a cached difference_due style field built on top of it, that the reversed sign quietly points them at the wrong action. See the citations at the end for the exact issues and docs.
This is a computed field display and decision bug in Medusa core, not corrupted data, so there is nothing in the database to correct through the Admin API. The safe pattern is not to try to force the sign back or fix the underlying totals yourself. It is to independently recompute current_order_total - paid_total from the raw fields, compare that sign against whatever the app or UI is using to decide "collect" or "refund," and flag every order where they disagree for a human to verify before any money moves.
The fix, as a flow
We do not touch checkout and we do not touch payments. We pull each order with its computed summary and any pending or recently confirmed edit, run a pure function that recomputes the expected direction from current_order_total and paid_total, and compare that against the direction the app reports. Everything that disagrees becomes a flagged report row. Nothing is captured or refunded automatically, ever.
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 order edits. 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/direction 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/direction 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 paid orders with a pending or recent edit
Ask for orders whose summary.paid_total, summary.current_order_total, and summary.pending_difference are expanded, along with the order's order_change relation. Page through with limit and offset. For each order, keep only the ones with paid_total > 0 that also carry an OrderChange of change_type: "edit" that is requested or confirmed, since those are the ones where the direction actually matters.
ORDER_FIELDS = (
"id,display_id,status,*summary,*items,*order_change"
)
EDIT_CHANGE_STATUSES = {"requested", "confirmed"}
def has_relevant_edit(order):
change = order.get("order_change") or {}
return (
change.get("change_type") == "edit"
and change.get("status") in EDIT_CHANGE_STATUSES
)
def list_paid_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()
for o in body["orders"]:
summary = o.get("summary") or {}
if summary.get("paid_total", 0) > 0 and has_relevant_edit(o):
out.append(o)
offset += limit
if offset >= body["count"]:
return out
const ORDER_FIELDS = "id,display_id,status,*summary,*items,*order_change";
const EDIT_CHANGE_STATUSES = new Set(["requested", "confirmed"]);
export function hasRelevantEdit(order) {
const change = order.order_change || {};
return change.change_type === "edit" && EDIT_CHANGE_STATUSES.has(change.status);
}
async function listPaidEditedOrders(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.order.list({ fields: ORDER_FIELDS, limit, offset });
for (const o of body.orders) {
const paidTotal = o.summary?.paid_total || 0;
if (paidTotal > 0 && hasRelevantEdit(o)) out.push(o);
}
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. It recomputes current_order_total - paid_total and returns a direction: refund when the difference is negative, collect when positive, none when zero. This is the exact operand order the regression in #13068 gets backwards, so the function is written to make that mistake structurally impossible to make again.
def decide_balance_action(current_order_total, paid_total):
"""Pure: no I/O. pending_difference semantics per Medusa OrderSummary:
current_order_total - paid_total. Negative means refund owed to the
customer, positive means more is owed by the customer."""
diff = float(current_order_total) - float(paid_total)
if diff == 0:
return {"pendingDifference": 0.0, "direction": "none"}
if diff < 0:
return {"pendingDifference": diff, "direction": "refund"}
return {"pendingDifference": diff, "direction": "collect"}
export function decideBalanceAction(currentOrderTotal, paidTotal) {
// Pure: no I/O. pending_difference semantics per Medusa OrderSummary:
// current_order_total - paid_total. Negative means refund owed to the
// customer, positive means more is owed by the customer.
const diff = Number(currentOrderTotal) - Number(paidTotal);
if (diff === 0) return { pendingDifference: 0, direction: "none" };
return diff < 0
? { pendingDifference: diff, direction: "refund" }
: { pendingDifference: diff, direction: "collect" };
}
Compare against the reported direction, and flag disagreements
For each order, take the direction the app or UI is using, whatever cached difference_due style field or sign it decided on, and compare it against the recomputed direction from step four. When they disagree, emit a report row with the order id, the order edit id, paid_total, current_order_total, the recomputed pending_difference, and the recomputed direction. This never mutates anything.
def add_internal_note(token, order_edit_id, note):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/order-edits/{order_edit_id}",
json={"internal_note": note},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
async function addInternalNote(sdk, orderEditId, note) {
return sdk.client.fetch(`/admin/order-edits/${orderEditId}`, {
method: "POST",
body: { internal_note: note },
});
}
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. When a human has reviewed a flagged order and DRY_RUN is off, the script's only write is an internal note on the order edit that names the suspected bug and asks for manual verification, never a capture or refund call, and never a call to confirm the order edit on the app's behalf.
Never call /admin/payments/{id}/capture or /admin/payments/{id}/refund automatically based on the flagged direction. Always start with DRY_RUN=true, and require a human to confirm the correct direction from the recomputed pendingDifference sign before triggering POST /admin/order-edits/{id}/confirm and the corresponding capture or refund action.
The full code
Here is the complete script in one file for each language. It authenticates, lists paid orders with a pending or confirmed edit, recomputes the expected balance direction with a pure function, and reports every order where that direction disagrees with what the app is using. It never writes anything by default, and even with writes on, it only ever adds an internal note.
"""Find Medusa v2 orders where a captured payment plus a pending or
confirmed order edit reports the balance direction backwards, refund
owed reported as collect, or the reverse. This is not auto-fixable: it
is a computed field bug in Medusa core (GitHub issues #13068, #13067),
not corrupted data. DRY_RUN=true (default) only reports the affected
orders. Only when DRY_RUN=false and a human has reviewed the direction
does the script add an internal_note to the order edit, never a
capture or refund call.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_wrong_balance_direction")
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"
ORDER_FIELDS = "id,display_id,status,*summary,*items,*order_change"
EDIT_CHANGE_STATUSES = {"requested", "confirmed"}
NOTE_TEMPLATE = (
"Suspected reversed refund-direction bug (medusajs/medusa#13068) "
"-- verify manually before force-confirming or capturing/refunding payment. "
"recomputed_direction={direction} pending_difference={pending_difference}"
)
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_relevant_edit(order):
change = order.get("order_change") or {}
return (
change.get("change_type") == "edit"
and change.get("status") in EDIT_CHANGE_STATUSES
)
def list_paid_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()
for o in body["orders"]:
summary = o.get("summary") or {}
if summary.get("paid_total", 0) > 0 and has_relevant_edit(o):
out.append(o)
offset += limit
if offset >= body["count"]:
return out
def decide_balance_action(current_order_total, paid_total):
"""Pure: no I/O. pending_difference semantics per Medusa OrderSummary:
current_order_total - paid_total. Negative means refund owed to the
customer, positive means more is owed by the customer."""
diff = float(current_order_total) - float(paid_total)
if diff == 0:
return {"pendingDifference": 0.0, "direction": "none"}
if diff < 0:
return {"pendingDifference": diff, "direction": "refund"}
return {"pendingDifference": diff, "direction": "collect"}
def reported_direction(order):
"""Read the direction the app/UI is currently using, from the order's
own summary.pending_difference, the exact field the bug can flip."""
summary = order.get("summary") or {}
reported = summary.get("pending_difference")
if reported is None:
return None
if reported == 0:
return "none"
return "refund" if reported < 0 else "collect"
def add_internal_note(token, order_edit_id, note):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/order-edits/{order_edit_id}",
json={"internal_note": note},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
token = get_token()
orders = list_paid_edited_orders(token)
flagged = []
for order in orders:
summary = order.get("summary") or {}
expected = decide_balance_action(
summary.get("current_order_total", 0), summary.get("paid_total", 0)
)
reported = reported_direction(order)
if reported is not None and reported != expected["direction"]:
flagged.append((order, expected, reported))
if not flagged:
log.info("No direction mismatches found across %d paid, edited order(s).", len(orders))
return
for order, expected, reported in flagged:
change = order.get("order_change") or {}
order_edit_id = change.get("id")
summary = order.get("summary") or {}
log.warning(
"Order %s (display #%s): reported=%s expected=%s paid_total=%s "
"current_order_total=%s pending_difference=%s. %s",
order["id"], order.get("display_id"), reported, expected["direction"],
summary.get("paid_total"), summary.get("current_order_total"),
expected["pendingDifference"],
"Would report only" if DRY_RUN else "Reported, adding internal note",
)
if not DRY_RUN and order_edit_id:
note = NOTE_TEMPLATE.format(
direction=expected["direction"],
pending_difference=expected["pendingDifference"],
)
add_internal_note(token, order_edit_id, note)
log.info("Done. %d order(s) flagged with a mismatched balance direction.", len(flagged))
if __name__ == "__main__":
run()
/**
* Find Medusa v2 orders where a captured payment plus a pending or
* confirmed order edit reports the balance direction backwards, refund
* owed reported as collect, or the reverse. This is not auto-fixable:
* it is a computed field bug in Medusa core (GitHub issues #13068,
* #13067), not corrupted data. DRY_RUN=true (default) only reports the
* affected orders. Only when DRY_RUN=false and a human has reviewed the
* direction does the script add an internal_note to the order edit,
* never a capture or refund call.
*/
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 ORDER_FIELDS = "id,display_id,status,*summary,*items,*order_change";
const EDIT_CHANGE_STATUSES = new Set(["requested", "confirmed"]);
const NOTE_TEMPLATE = (direction, pendingDifference) =>
`Suspected reversed refund-direction bug (medusajs/medusa#13068) -- ` +
`verify manually before force-confirming or capturing/refunding payment. ` +
`recomputed_direction=${direction} pending_difference=${pendingDifference}`;
export function hasRelevantEdit(order) {
const change = order.order_change || {};
return change.change_type === "edit" && EDIT_CHANGE_STATUSES.has(change.status);
}
export function decideBalanceAction(currentOrderTotal, paidTotal) {
// Pure: no I/O. pending_difference semantics per Medusa OrderSummary:
// current_order_total - paid_total. Negative means refund owed to the
// customer, positive means more is owed by the customer.
const diff = Number(currentOrderTotal) - Number(paidTotal);
if (diff === 0) return { pendingDifference: 0, direction: "none" };
return diff < 0
? { pendingDifference: diff, direction: "refund" }
: { pendingDifference: diff, direction: "collect" };
}
export function reportedDirection(order) {
// Read the direction the app/UI is currently using, from the order's
// own summary.pending_difference, the exact field the bug can flip.
const summary = order.summary || {};
const reported = summary.pending_difference;
if (reported == null) return null;
if (reported === 0) return "none";
return reported < 0 ? "refund" : "collect";
}
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 listPaidEditedOrders(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.order.list({ fields: ORDER_FIELDS, limit, offset });
for (const o of body.orders) {
const paidTotal = o.summary?.paid_total || 0;
if (paidTotal > 0 && hasRelevantEdit(o)) out.push(o);
}
offset += limit;
if (offset >= body.count) return out;
}
}
async function addInternalNote(sdk, orderEditId, note) {
return sdk.client.fetch(`/admin/order-edits/${orderEditId}`, {
method: "POST",
body: { internal_note: note },
});
}
export async function run() {
const sdk = await login();
const orders = await listPaidEditedOrders(sdk);
const flagged = [];
for (const order of orders) {
const summary = order.summary || {};
const expected = decideBalanceAction(summary.current_order_total || 0, summary.paid_total || 0);
const reported = reportedDirection(order);
if (reported !== null && reported !== expected.direction) {
flagged.push([order, expected, reported]);
}
}
if (flagged.length === 0) {
console.log(`No direction mismatches found across ${orders.length} paid, edited order(s).`);
return;
}
for (const [order, expected, reported] of flagged) {
const change = order.order_change || {};
const orderEditId = change.id;
const summary = order.summary || {};
console.warn(
`Order ${order.id} (display #${order.display_id}): reported=${reported} expected=${expected.direction} ` +
`paid_total=${summary.paid_total} current_order_total=${summary.current_order_total} ` +
`pending_difference=${expected.pendingDifference}. ${DRY_RUN ? "Would report only" : "Reported, adding internal note"}`
);
if (!DRY_RUN && orderEditId) {
await addInternalNote(sdk, orderEditId, NOTE_TEMPLATE(expected.direction, expected.pendingDifference));
}
}
console.log(`Done. ${flagged.length} order(s) flagged with a mismatched balance direction.`);
}
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 direction, decide_balance_action. It is pure, no network and no database, so the tests feed in plain numbers and check the answer against a cheaper-swap refund case, a pricier-swap collect case, a no-op edit, and assert the function is never fed the operands swapped, which is the exact regression in #13068.
from flag_wrong_balance_direction import decide_balance_action, reported_direction
def test_cheaper_swap_expects_refund():
result = decide_balance_action(current_order_total=4000, paid_total=6000)
assert result["direction"] == "refund"
assert result["pendingDifference"] == -2000
def test_pricier_swap_expects_collect():
result = decide_balance_action(current_order_total=8000, paid_total=6000)
assert result["direction"] == "collect"
assert result["pendingDifference"] == 2000
def test_no_op_edit_expects_none():
result = decide_balance_action(current_order_total=6000, paid_total=6000)
assert result["direction"] == "none"
assert result["pendingDifference"] == 0
def test_operands_are_never_fed_swapped():
# The exact regression in #13068 is feeding (paid_total, current_order_total)
# instead of (current_order_total, paid_total). Swapping the arguments here
# must flip the sign, proving the function is order-sensitive as intended.
forward = decide_balance_action(current_order_total=4000, paid_total=6000)
swapped = decide_balance_action(current_order_total=6000, paid_total=4000)
assert forward["direction"] != swapped["direction"]
assert forward["pendingDifference"] == -swapped["pendingDifference"]
def test_reported_direction_reads_negative_as_refund():
order = {"summary": {"pending_difference": -1500}}
assert reported_direction(order) == "refund"
def test_reported_direction_reads_positive_as_collect():
order = {"summary": {"pending_difference": 1500}}
assert reported_direction(order) == "collect"
def test_reported_direction_missing_summary_is_none():
assert reported_direction({"summary": {}}) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideBalanceAction, reportedDirection } from "./flag-wrong-balance-direction.js";
test("cheaper swap expects refund", () => {
const result = decideBalanceAction(4000, 6000);
assert.equal(result.direction, "refund");
assert.equal(result.pendingDifference, -2000);
});
test("pricier swap expects collect", () => {
const result = decideBalanceAction(8000, 6000);
assert.equal(result.direction, "collect");
assert.equal(result.pendingDifference, 2000);
});
test("no-op edit expects none", () => {
const result = decideBalanceAction(6000, 6000);
assert.equal(result.direction, "none");
assert.equal(result.pendingDifference, 0);
});
test("operands are never fed swapped", () => {
// The exact regression in #13068 is feeding (paidTotal, currentOrderTotal)
// instead of (currentOrderTotal, paidTotal). Swapping the arguments here
// must flip the sign, proving the function is order-sensitive as intended.
const forward = decideBalanceAction(4000, 6000);
const swapped = decideBalanceAction(6000, 4000);
assert.notEqual(forward.direction, swapped.direction);
assert.equal(forward.pendingDifference, -swapped.pendingDifference);
});
test("reportedDirection reads negative as refund", () => {
assert.equal(reportedDirection({ summary: { pending_difference: -1500 } }), "refund");
});
test("reportedDirection reads positive as collect", () => {
assert.equal(reportedDirection({ summary: { pending_difference: 1500 } }), "collect");
});
test("reportedDirection missing summary is null", () => {
assert.equal(reportedDirection({ summary: {} }), null);
});
Case studies
The refund that looked like a bill
A support agent swapped a customer into a lower priced variant of an item on an order that had already been captured in full. The edit confirmed cleanly and the new total was correct on the order detail page. But the balance panel showed a positive amount to collect, and the agent nearly sent the customer an invoice for money the store actually owed them.
Running the audit script in dry run caught it immediately: current_order_total was below paid_total, so the recomputed direction was refund, while the summary's own pending_difference reported collect. The flagged row went to a human, who confirmed the refund and processed it manually instead of trusting the reversed field.
A pricing correction across dozens of captured orders
A store corrected a catalog pricing mistake across several dozen already-captured orders by running order edits programmatically. Every edit confirmed without error, but a portion of those orders ended up with a mismatched balance direction once the correction lowered the total below what had already been paid.
Rather than trusting the app's own difference field across the batch, the team ran the audit script against every affected order. It cleanly separated the ones whose recomputed direction matched what the app reported from the handful that were reversed, so the follow-up refund review only touched the orders that actually needed a human's eyes.
Run this audit after any order edit made on an already-captured order, or on a schedule while #13068 and #13067 remain open upstream. It never guesses at a fix and never calls a capture or refund route on its own. It reports exactly which orders have a reversed balance direction, with the recomputed sign a human needs to confirm before any money moves. That keeps a display bug from turning into a wrong refund or an accidental double charge.
FAQ
Why does my Medusa order edit say collect more when I should refund the customer?
In Medusa v2, an order's summary.pending_difference is meant to equal current_order_total minus paid_total, where a negative number means a refund is owed. GitHub issues 13068 and 13067 report that once a payment has been captured, the summary's paid_total is not correctly reflected before the new totals are diffed, so the recomputed difference can effectively swap the operands and flip the sign, making a cheaper swap look like an amount to collect instead of a refund.
How do I check whether my order is affected by the reversed balance direction bug?
Pull the order with GET /admin/orders/{id} expanding summary.paid_total, summary.current_order_total, and summary.pending_difference, then independently recompute expected_pending_difference as current_order_total minus paid_total. If the sign of that recomputed value disagrees with what the UI or a cached difference field is showing, the order is likely hitting the reversed direction bug. This is a read only audit that needs no admin write.
Is it safe to auto-fix the reversed refund direction?
No. This is a computed field bug in Medusa core, not corrupted data, so there is nothing in the database to correct through the Admin API. The safe response is to flag the order with an internal note for a human to verify, and never call a payment capture or refund route automatically based on the flagged direction.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #13068: [Bug]: Order Edit after captured payment, incorrect difference. github.com/medusajs/medusa/issues/13068
- medusajs/medusa GitHub issue #10392: [Bug]: unable to refund amount for an order with captured payment. github.com/medusajs/medusa/issues/10392
- medusajs/medusa GitHub issue #13067: [Bug]: Capture payment not working as intended. github.com/medusajs/medusa/issues/13067
On the solution:
- Medusa Documentation: Retrieve Order Totals Using Query, OrderSummary paid_total, pending_difference, current_order_total. docs.medusajs.com/resources/commerce-modules/order/order-totals
- Medusa Documentation: Order Change, OrderChange and OrderChangeAction, change_type edit. docs.medusajs.com/resources/commerce-modules/order/order-change
- Medusa V2 Admin API Reference, order-edits request and confirm routes. 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 catch a reversed refund direction?
If this saved you from sending a bill to a customer who was owed a refund, 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