Skip to content

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.

Python and Node.js Medusa Admin API Flag and report, no silent writes
A calculator on yellow
Photo by Behnam Norouzi on Unsplash
The short answer

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.

Payment captured paid_total set Edit to cheaper variant current_order_total drops below paid_total paid_total not reflected Sign flips pending_difference > 0 Reports collect
The math is simple, current total minus paid total, but the captured paid_total is not correctly reflected before the diff runs, so the sign the app acts on 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:

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.

The key insight

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.

List paid, edited orders paid_total > 0, order_change Read raw totals current_order_total, paid_total Recompute with pure fn decideBalanceAction directions disagree? yes no, leave alone Report row human verifies, then acts
The script only ever reports. A human confirms the recomputed direction before force-confirming the edit or triggering a capture or refund.

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 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.

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/direction 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/direction 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 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.

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

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

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.

apply.py
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()
apply.js
async function addInternalNote(sdk, orderEditId, note) {
  return sdk.client.fetch(`/admin/order-edits/${orderEditId}`, {
    method: "POST",
    body: { internal_note: note },
  });
}
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. 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.

Run it safe

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.

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_wrong_balance_direction.py
"""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()
flag-wrong-balance-direction.js
/**
 * 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.

test_balance_direction.py
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
balance-direction.test.js
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

Cheaper variant swap

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.

Batch of post-capture edits

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.

What good looks like

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:

  1. medusajs/medusa GitHub issue #13068: [Bug]: Order Edit after captured payment, incorrect difference. github.com/medusajs/medusa/issues/13068
  2. medusajs/medusa GitHub issue #10392: [Bug]: unable to refund amount for an order with captured payment. github.com/medusajs/medusa/issues/10392
  3. medusajs/medusa GitHub issue #13067: [Bug]: Capture payment not working as intended. github.com/medusajs/medusa/issues/13067

On the solution:

  1. Medusa Documentation: Retrieve Order Totals Using Query, OrderSummary paid_total, pending_difference, current_order_total. docs.medusajs.com/resources/commerce-modules/order/order-totals
  2. Medusa Documentation: Order Change, OrderChange and OrderChangeAction, change_type edit. docs.medusajs.com/resources/commerce-modules/order/order-change
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes