Skip to content

Diagnostic Payments & Refunds

Outstanding amount does not update after the first refund

A customer gets a partial refund, and the order's outstanding amount drops exactly the way it should. A second refund goes out through the same payment provider, Stripe shows the money leaving your account, but the order's outstanding amount in the Medusa admin does not move at all. It just sits there reporting the balance from after refund number one, as if nothing happened since. Here is why the order's summary stops recomputing after the first refund, and a script that finds every order where the reported number and the real one have drifted apart.

Python and Node.js Medusa Admin API Flag only, never auto-write
A bank card on a laptop
Photo by CardMapr.nl on Unsplash
The short answer

outstanding_amount is not a number Medusa decrements when a refund happens. It is a derived field on the order's summary, computed by the totals module from rows in order_transaction, which record paid and refunded amounts over time. The first refund on an order correctly inserts a new order_transaction row and the summary recomputes from it. As tracked in medusajs/medusa issue #11481, a second run of refundPaymentsWorkflow against the same order or payment does not insert another transaction row, so the summary never recomputes again, and outstanding_amount freezes at whatever it reported after the first refund, even while the payment provider keeps processing more. There is no safe way to PATCH this field directly. Pull every order with its summary, payment_collections, and refunds expanded, recompute the true outstanding amount from the provider-confirmed capture and refund totals, and flag any order where that diverges from what the API reports. A human reconciles it from there. Full code and tests below.

The problem in plain words

Every order in Medusa v2 carries a summary, an OrderSummary object with fields like paid_total, refunded_total, and outstanding_amount. None of those are columns that get edited in place. They are computed on the fly by the totals module, which walks the order's order_transaction rows, the ledger of every payment and refund event tied to that order, and adds them up.

That means outstanding_amount is only ever as fresh as the last time something wrote a new row into order_transaction and triggered the summary to recompute. The first time you refund an order, refundPaymentsWorkflow does exactly that: it writes the transaction row, and the summary picks it up correctly. The trouble starts on the second refund against the same order or the same payment. Reporters tracing this in the Medusa repository found that the workflow does not insert a new transaction row that time, so nothing tells the summary computation there is anything new to add up. The order's cached outstanding_amount stays exactly where it was after refund one, permanently, no matter how many more refunds actually go through at the payment provider.

Refund #1 runs refundPaymentsWorkflow order_transaction row inserted, correctly summary recomputes outstanding_amount correct Refund #2 runs same order or payment no new row inserted summary never recomputes outstanding_amount frozen provider actually refunded more
The first refund updates the ledger and the summary together, correctly. The second refund on the same order updates the ledger at the provider but never adds a new order_transaction row, so the summary is stuck reporting stale numbers.

Why it happens

This comes from how order totals are derived rather than stored, combined with a gap in how repeat refunds write to the ledger:

This is a common source of confusion because nothing errors. The refund call to the payment provider succeeds, the order looks unchanged in the dashboard, and the natural read is that the refund did not go through, when the truth is that it went through fine and the order's own bookkeeping just never noticed. That gap is also what lets staff or automated flows re-run a refund against an "outstanding" balance that was never real to begin with. See the citations at the end for the exact issues and docs.

The key insight

There is no field to PATCH here. outstanding_amount is owned entirely by the order module's summary computation over order_transaction, and forcing a write against it risks double-counting a refund that is only partially reflected. The only safe move is to compute the true outstanding amount yourself from the payment provider's own confirmed totals, compare it against what the API reports, and flag the gap for a human. Never call POST /admin/orders/:id/refund again from a script to try to fix it, that is exactly the repeat-refund failure mode this bug creates.

The fix, as a flow

We never try to write a corrected number back onto the order. We pull every order with its summary, payment collections, payments, and refunds expanded, recompute what the outstanding amount should truly be from the captures and refunds Medusa itself already recorded against each payment, and flag any order where that diverges from the cached summary by more than a rounding epsilon.

List orders summary, payments, refunds Compute true outstanding from ledger Diverges from reported summary? yes, flag Report for review no write to the order no, matches Move on summary is trustworthy here
The script only ever reads and compares. Every affected order gets a computed true outstanding value and a list of the refunds behind it, left for a human to reconcile through the admin dashboard.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend with an admin account that can read orders and payments. Exchange the email and password for a JWT once and reuse it. DRY_RUN defaults to true, but this script never writes to an order regardless, since there is no safe field to write.

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"   # this script only ever reports, it never writes
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // this script only ever reports, it never writes
2

Authenticate against the Admin API

Exchange the admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every following call.

step2.py
import os, requests

BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

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

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

List orders with summary, payments, and refunds expanded

Ask for summary, payment_collections, the payments on each collection, and the refunds on each payment. This one call gives us both the cached number Medusa reports and the raw ledger of captures and refunds we need to recompute the true value ourselves. Page through with offset and limit so the job covers the whole store.

step3.py
ORDER_FIELDS = (
    "id,display_id,*summary,*payment_collections,"
    "*payment_collections.payments,*payment_collections.payments.refunds"
)

def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_orders_with_refunds(token):
    orders = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/orders", {
            "fields": ORDER_FIELDS,
            "limit": limit,
            "offset": offset,
        })
        orders.extend(data["orders"])
        offset += limit
        if offset >= data["count"]:
            return orders
step3.js
const ORDER_FIELDS =
  "id,display_id,*summary,*payment_collections," +
  "*payment_collections.payments,*payment_collections.payments.refunds";

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
  return res.json();
}

async function listOrdersWithRefunds(token) {
  const orders = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const data = await adminGet(token, "/admin/orders", {
      fields: ORDER_FIELDS,
      limit,
      offset,
    });
    orders.push(...data.orders);
    offset += limit;
    if (offset >= data.count) return orders;
  }
}
4

Decide, with one pure function

Keep the whole decision in a function with no network calls. It takes the order's total, the captures, the refunds, and the reported outstanding_amount, and recomputes what the outstanding amount should truly be: the order total, minus everything captured, plus everything refunded. It only flags an order when there has been more than one refund event and the two numbers disagree by more than a cent, since a single refund is exactly the case Medusa handles correctly.

decide.py
EPSILON = 0.01

def detect_stale_outstanding(order):
    captures = order.get("captures") or []
    refunds = order.get("refunds") or []

    true_outstanding = (
        order["total"]
        - sum(c["amount"] for c in captures)
        + sum(r["amount"] for r in refunds)
    )
    refund_count = len(refunds)
    delta = order["reportedOutstanding"] - true_outstanding
    affected = refund_count > 1 and abs(delta) > EPSILON

    return {
        "affected": affected,
        "trueOutstanding": true_outstanding,
        "reportedOutstanding": order["reportedOutstanding"],
        "delta": delta,
        "refundCount": refund_count,
    }
decide.js
const EPSILON = 0.01;

export function detectStaleOutstanding(order) {
  const captures = order.captures || [];
  const refunds = order.refunds || [];

  const trueOutstanding =
    order.total -
    captures.reduce((sum, c) => sum + c.amount, 0) +
    refunds.reduce((sum, r) => sum + r.amount, 0);
  const refundCount = refunds.length;
  const delta = order.reportedOutstanding - trueOutstanding;
  const affected = refundCount > 1 && Math.abs(delta) > EPSILON;

  return {
    affected,
    trueOutstanding,
    reportedOutstanding: order.reportedOutstanding,
    delta,
    refundCount,
  };
}
5

Shape the raw order into what the decision function needs

The Admin API response nests captures and refunds under payment collections and payments. Flatten that into the plain total, captures, refunds, and reportedOutstanding shape the pure function expects, then run the decision on it. Nothing here calls the network, it only reshapes data already in hand.

shape.py
def to_decision_input(raw_order):
    payments = [
        payment
        for collection in (raw_order.get("payment_collections") or [])
        for payment in (collection.get("payments") or [])
    ]
    captures = [{"amount": p.get("amount", 0)} for p in payments if p.get("captured_at")]
    refunds = [
        {"id": r.get("id"), "amount": r.get("amount", 0), "created_at": r.get("created_at")}
        for p in payments
        for r in (p.get("refunds") or [])
    ]
    summary = raw_order.get("summary") or {}

    return {
        "id": raw_order.get("id"),
        "displayId": raw_order.get("display_id"),
        "total": summary.get("raw_current_order_total", raw_order.get("total", 0)),
        "captures": captures,
        "refunds": refunds,
        "reportedOutstanding": summary.get("outstanding_amount", 0),
    }
shape.js
function toDecisionInput(rawOrder) {
  const payments = (rawOrder.payment_collections || []).flatMap(
    (collection) => collection.payments || []
  );
  const captures = payments
    .filter((p) => p.captured_at)
    .map((p) => ({ amount: p.amount || 0 }));
  const refunds = payments.flatMap((p) =>
    (p.refunds || []).map((r) => ({ id: r.id, amount: r.amount || 0, created_at: r.created_at }))
  );
  const summary = rawOrder.summary || {};

  return {
    id: rawOrder.id,
    displayId: rawOrder.display_id,
    total: summary.raw_current_order_total ?? rawOrder.total ?? 0,
    captures,
    refunds,
    reportedOutstanding: summary.outstanding_amount || 0,
  };
}
6

Wire it together and report, never repair

The loop lists orders, shapes each one, runs the decision, and logs one finding per affected order with the computed true outstanding value against the reported one, plus the refund ids and amounts behind it. There is nothing for DRY_RUN=false to write, since calling the refund endpoint again from here would trigger exactly the failure mode being diagnosed. Point a human at the flagged orders and let them reconcile through the admin dashboard's Order, Payment, Refund panel.

Run it safe

Do not call POST /admin/orders/:id/refund again from this script under any DRY_RUN=false path to try to fix a flagged order. That is the exact repeat-refund failure mode this bug produces. The only supported repair is a human reconciling the order through the Medusa admin dashboard, or re-running the order's transaction sync.

The full code

Here is the complete script in one file for each language. It authenticates, lists every order with its summary and refund history, recomputes the true outstanding amount with a pure decision function, and logs one finding per affected order. It never writes anything back to Medusa.

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.
detect_stale_outstanding.py
"""Flag Medusa v2 orders whose outstanding_amount stopped updating after the
first refund. outstanding_amount is a derived field on the order's summary,
computed by the totals module from order_transaction rows, not a value that
gets decremented directly. The first refund on an order inserts a new
transaction row and the summary recomputes correctly, but a second
refundPaymentsWorkflow run on the same order or payment does not insert
another row (see medusajs/medusa#11481), so the summary is never recomputed
again and outstanding_amount freezes while the payment provider keeps
processing more refunds. There is no safe PATCH for this field, so this
script only flags the divergence for a human to reconcile. It never calls
the refund endpoint again. Safe to run again and again.

Guide: https://www.allanninal.dev/medusa/outstanding-amount-stale-after-refund/
"""
import os
import logging
import requests

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

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

EPSILON = 0.01

ORDER_FIELDS = (
    "id,display_id,*summary,*payment_collections,"
    "*payment_collections.payments,*payment_collections.payments.refunds"
)


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def detect_stale_outstanding(order):
    """Pure decision function. No I/O.

    order: {
      "total": float,
      "captures": [{"amount": float}],
      "refunds": [{"id": str, "amount": float, "created_at": str}],
      "reportedOutstanding": float,
    }

    Returns {"affected", "trueOutstanding", "reportedOutstanding", "delta",
             "refundCount"}.
    """
    captures = order.get("captures") or []
    refunds = order.get("refunds") or []

    true_outstanding = (
        order["total"]
        - sum(c["amount"] for c in captures)
        + sum(r["amount"] for r in refunds)
    )
    refund_count = len(refunds)
    delta = order["reportedOutstanding"] - true_outstanding
    affected = refund_count > 1 and abs(delta) > EPSILON

    return {
        "affected": affected,
        "trueOutstanding": true_outstanding,
        "reportedOutstanding": order["reportedOutstanding"],
        "delta": delta,
        "refundCount": refund_count,
    }


def to_decision_input(raw_order):
    payments = [
        payment
        for collection in (raw_order.get("payment_collections") or [])
        for payment in (collection.get("payments") or [])
    ]
    captures = [{"amount": p.get("amount", 0)} for p in payments if p.get("captured_at")]
    refunds = [
        {"id": r.get("id"), "amount": r.get("amount", 0), "created_at": r.get("created_at")}
        for p in payments
        for r in (p.get("refunds") or [])
    ]
    summary = raw_order.get("summary") or {}

    return {
        "id": raw_order.get("id"),
        "displayId": raw_order.get("display_id"),
        "total": summary.get("raw_current_order_total", raw_order.get("total", 0)),
        "captures": captures,
        "refunds": refunds,
        "reportedOutstanding": summary.get("outstanding_amount", 0),
    }


def list_orders_with_refunds(token):
    orders = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/orders", {
            "fields": ORDER_FIELDS,
            "limit": limit,
            "offset": offset,
        })
        orders.extend(data["orders"])
        offset += limit
        if offset >= data["count"]:
            return orders


def run():
    token = get_admin_token()
    raw_orders = list_orders_with_refunds(token)

    flagged = 0
    for raw_order in raw_orders:
        decision_input = to_decision_input(raw_order)
        outcome = detect_stale_outstanding(decision_input)
        if not outcome["affected"]:
            continue

        flagged += 1
        log.warning(
            "Order %s stale outstanding_amount: reported=%s true=%s delta=%s "
            "refund_count=%d refunds=%s. %s",
            decision_input["displayId"] or decision_input["id"],
            outcome["reportedOutstanding"], outcome["trueOutstanding"], outcome["delta"],
            outcome["refundCount"],
            [(r["id"], r["amount"], r["created_at"]) for r in decision_input["refunds"]],
            "would flag for review" if DRY_RUN else "flagging for review",
        )

    log.info(
        "Done. %d order(s) with a stale outstanding_amount flagged for manual reconciliation. "
        "No orders were written to.",
        flagged,
    )


if __name__ == "__main__":
    run()
detect-stale-outstanding.js
/**
 * Flag Medusa v2 orders whose outstanding_amount stopped updating after the
 * first refund. outstanding_amount is a derived field on the order's summary,
 * computed by the totals module from order_transaction rows, not a value that
 * gets decremented directly. The first refund on an order inserts a new
 * transaction row and the summary recomputes correctly, but a second
 * refundPaymentsWorkflow run on the same order or payment does not insert
 * another row (see medusajs/medusa#11481), so the summary is never recomputed
 * again and outstanding_amount freezes while the payment provider keeps
 * processing more refunds. There is no safe PATCH for this field, so this
 * script only flags the divergence for a human to reconcile. It never calls
 * the refund endpoint again. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/outstanding-amount-stale-after-refund/
 */
import { pathToFileURL } from "node:url";

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

const EPSILON = 0.01;

const ORDER_FIELDS =
  "id,display_id,*summary,*payment_collections," +
  "*payment_collections.payments,*payment_collections.payments.refunds";

export function detectStaleOutstanding(order) {
  // Pure: no I/O. order = { total, captures: [{amount}],
  // refunds: [{id, amount, created_at}], reportedOutstanding }
  const captures = order.captures || [];
  const refunds = order.refunds || [];

  const trueOutstanding =
    order.total -
    captures.reduce((sum, c) => sum + c.amount, 0) +
    refunds.reduce((sum, r) => sum + r.amount, 0);
  const refundCount = refunds.length;
  const delta = order.reportedOutstanding - trueOutstanding;
  const affected = refundCount > 1 && Math.abs(delta) > EPSILON;

  return {
    affected,
    trueOutstanding,
    reportedOutstanding: order.reportedOutstanding,
    delta,
    refundCount,
  };
}

function toDecisionInput(rawOrder) {
  const payments = (rawOrder.payment_collections || []).flatMap(
    (collection) => collection.payments || []
  );
  const captures = payments
    .filter((p) => p.captured_at)
    .map((p) => ({ amount: p.amount || 0 }));
  const refunds = payments.flatMap((p) =>
    (p.refunds || []).map((r) => ({ id: r.id, amount: r.amount || 0, created_at: r.created_at }))
  );
  const summary = rawOrder.summary || {};

  return {
    id: rawOrder.id,
    displayId: rawOrder.display_id,
    total: summary.raw_current_order_total ?? rawOrder.total ?? 0,
    captures,
    refunds,
    reportedOutstanding: summary.outstanding_amount || 0,
  };
}

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

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
  return res.json();
}

async function listOrdersWithRefunds(token) {
  const orders = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const data = await adminGet(token, "/admin/orders", {
      fields: ORDER_FIELDS,
      limit,
      offset,
    });
    orders.push(...data.orders);
    offset += limit;
    if (offset >= data.count) return orders;
  }
}

export async function run() {
  const token = await getAdminToken();
  const rawOrders = await listOrdersWithRefunds(token);

  let flagged = 0;
  for (const rawOrder of rawOrders) {
    const decisionInput = toDecisionInput(rawOrder);
    const outcome = detectStaleOutstanding(decisionInput);
    if (!outcome.affected) continue;

    flagged++;
    console.warn(
      `Order ${decisionInput.displayId || decisionInput.id} stale outstanding_amount: ` +
        `reported=${outcome.reportedOutstanding} true=${outcome.trueOutstanding} ` +
        `delta=${outcome.delta} refund_count=${outcome.refundCount} ` +
        `refunds=${JSON.stringify(decisionInput.refunds)}. ` +
        `${DRY_RUN ? "would flag for review" : "flagging for review"}`
    );
  }

  console.log(
    `Done. ${flagged} order(s) with a stale outstanding_amount flagged for manual reconciliation. ` +
      `No orders were written to.`
  );
}

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

Add a test

The part worth testing is detect_stale_outstanding, since it decides which orders get flagged. It is pure, plain numbers and arrays in, a decision object out, so the tests need no network and no Medusa instance.

test_outstanding_detection.py
from detect_stale_outstanding import detect_stale_outstanding


def order(**over):
    base = {
        "total": 100.0,
        "captures": [{"amount": 100.0}],
        "refunds": [{"id": "ref_1", "amount": 20.0, "created_at": "2026-07-01T00:00:00Z"}],
        "reportedOutstanding": 0.0,
    }
    base.update(over)
    return base


def test_not_affected_with_a_single_refund_in_sync():
    # One refund, summary correctly reflects it: not the bug.
    result = detect_stale_outstanding(order())
    assert result["affected"] is False
    assert result["refundCount"] == 1


def test_affected_when_second_refund_never_moved_the_summary():
    refunds = [
        {"id": "ref_1", "amount": 20.0, "created_at": "2026-07-01T00:00:00Z"},
        {"id": "ref_2", "amount": 20.0, "created_at": "2026-07-05T00:00:00Z"},
    ]
    # summary still reports the balance from after refund #1 only
    result = detect_stale_outstanding(order(refunds=refunds, reportedOutstanding=0.0))
    assert result["affected"] is True
    assert result["trueOutstanding"] == 20.0
    assert result["delta"] == -20.0
    assert result["refundCount"] == 2


def test_not_affected_when_multiple_refunds_but_summary_matches():
    refunds = [
        {"id": "ref_1", "amount": 20.0, "created_at": "2026-07-01T00:00:00Z"},
        {"id": "ref_2", "amount": 20.0, "created_at": "2026-07-05T00:00:00Z"},
    ]
    # true outstanding = 100 - 100 + 40 = 40, and summary agrees
    result = detect_stale_outstanding(order(refunds=refunds, reportedOutstanding=40.0))
    assert result["affected"] is False


def test_rounding_epsilon_does_not_false_positive():
    refunds = [
        {"id": "ref_1", "amount": 20.0, "created_at": "2026-07-01T00:00:00Z"},
        {"id": "ref_2", "amount": 20.0, "created_at": "2026-07-05T00:00:00Z"},
    ]
    result = detect_stale_outstanding(order(refunds=refunds, reportedOutstanding=40.005))
    assert result["affected"] is False


def test_true_outstanding_computed_from_captures_and_refunds():
    result = detect_stale_outstanding(order(total=150.0, captures=[{"amount": 100.0}], refunds=[]))
    assert result["trueOutstanding"] == 50.0
    assert result["refundCount"] == 0
    assert result["affected"] is False
outstanding.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectStaleOutstanding } from "./detect-stale-outstanding.js";

const order = (over = {}) => ({
  total: 100.0,
  captures: [{ amount: 100.0 }],
  refunds: [{ id: "ref_1", amount: 20.0, created_at: "2026-07-01T00:00:00Z" }],
  reportedOutstanding: 0.0,
  ...over,
});

test("not affected with a single refund in sync", () => {
  const result = detectStaleOutstanding(order());
  assert.equal(result.affected, false);
  assert.equal(result.refundCount, 1);
});

test("affected when second refund never moved the summary", () => {
  const refunds = [
    { id: "ref_1", amount: 20.0, created_at: "2026-07-01T00:00:00Z" },
    { id: "ref_2", amount: 20.0, created_at: "2026-07-05T00:00:00Z" },
  ];
  const result = detectStaleOutstanding(order({ refunds, reportedOutstanding: 0.0 }));
  assert.equal(result.affected, true);
  assert.equal(result.trueOutstanding, 20.0);
  assert.equal(result.delta, -20.0);
  assert.equal(result.refundCount, 2);
});

test("not affected when multiple refunds but summary matches", () => {
  const refunds = [
    { id: "ref_1", amount: 20.0, created_at: "2026-07-01T00:00:00Z" },
    { id: "ref_2", amount: 20.0, created_at: "2026-07-05T00:00:00Z" },
  ];
  const result = detectStaleOutstanding(order({ refunds, reportedOutstanding: 40.0 }));
  assert.equal(result.affected, false);
});

test("rounding epsilon does not false positive", () => {
  const refunds = [
    { id: "ref_1", amount: 20.0, created_at: "2026-07-01T00:00:00Z" },
    { id: "ref_2", amount: 20.0, created_at: "2026-07-05T00:00:00Z" },
  ];
  const result = detectStaleOutstanding(order({ refunds, reportedOutstanding: 40.005 }));
  assert.equal(result.affected, false);
});

test("true outstanding computed from captures and refunds", () => {
  const result = detectStaleOutstanding(order({ total: 150.0, captures: [{ amount: 100.0 }], refunds: [] }));
  assert.equal(result.trueOutstanding, 50.0);
  assert.equal(result.refundCount, 0);
  assert.equal(result.affected, false);
});

Case studies

Repeat refund

The support team that refunded the same order twice

A customer disputed a damaged item, and support issued a partial refund. Two weeks later the same customer complained about a shipping fee, and a different agent looked at the order, saw an outstanding amount, and issued a second refund against it through Stripe. Stripe processed both refunds without complaint, but the Medusa dashboard kept reporting the balance from right after the first one.

Running the detection script against the store's orders flagged this one immediately, refund count of two, reported outstanding at zero, true outstanding negative, meaning more had gone out than the order admitted to. The team caught it before a third agent could see that same stale zero and think there was nothing left to refund, or worse, think there was still money owed.

Reconciliation

The monthly close that would not tie out

A finance team reconciling Stripe payouts against Medusa order totals kept finding a handful of orders each month where the refunded amount in Stripe was larger than what Medusa's order summary showed. Nobody could explain the gap, and each month it meant manually opening a dozen orders and comparing timestamps by hand.

Running the script against the full order list surfaced exactly which orders had drifted and by how much, with the refund ids and dates already lined up. What used to be an afternoon of manual comparison became a five minute review of a short flagged list, and the team learned to expect it specifically on orders that had been refunded more than once.

What good looks like

Run this on a schedule and you catch every order where the reported outstanding amount has drifted from what actually happened at the payment provider, before a second refund gets issued against a number that was never real. The script never writes to an order, it only computes the truth from the same ledger Medusa already recorded and hands a human the exact delta and the refunds behind it, so reconciliation through the admin dashboard is a lookup instead of a guess.

FAQ

Why does outstanding_amount stop changing after the first refund in Medusa?

outstanding_amount is not a stored number you can decrement. It is a derived field on the order's summary, computed by the totals module from rows in order_transaction. The first refund on an order correctly inserts a new transaction row and the summary recomputes from it. Reporters tracing this bug found that a second refundPaymentsWorkflow run on the same order or payment does not insert another transaction row, so the summary is never recomputed again, and outstanding_amount stays frozen at whatever it was after refund number one, even though the payment provider keeps processing more refunds underneath it.

How do I detect that an order has this stale outstanding_amount bug?

Pull each order with GET /admin/orders?fields=id,display_id,*summary,*payment_collections,*payment_collections.payments,*payment_collections.payments.refunds using the admin JWT. For each order, recompute the true outstanding amount as order.total minus the sum of captured amounts plus the sum of every refund actually recorded against the payments. Compare that to summary.outstanding_amount. If the order has more than one refund event and the two numbers disagree by more than a cent, that mismatch is the signature of the stale-after-first-refund bug. Cross-check the provider-confirmed total with GET /admin/payments/:id if you want a second source.

Can I just PATCH the order to fix outstanding_amount once I find a mismatch?

No. outstanding_amount is not a directly writable field on the order, it is owned by the order module's summary computation from order_transaction rows, so there is no safe PATCH for it. Calling POST /admin/orders/:id/refund again to try to force a recalculation would trigger the exact repeat-refund failure mode this bug creates, refunding real money a second time against a total that was already stale. The safe repair is to flag the order with the computed true outstanding value and let a human reconcile it through the Medusa admin dashboard's Order, Payment, Refund panel, or by re-running the order's transaction sync.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #11481: Outstanding amount doesn't change on multiple refunds. github.com/medusajs/medusa/issues/11481
  2. medusajs/medusa GitHub issue #10842: Cannot refund multiple times from a single order. github.com/medusajs/medusa/issues/10842
  3. medusajs/medusa GitHub issue #10491: Unable to Refund Captured Payments in Orders with $0 Outstanding Amount. github.com/medusajs/medusa/issues/10491

On the solution:

  1. Medusa Documentation: Order Concepts, OrderSummary and totals. docs.medusajs.com/resources/commerce-modules/order/concepts
  2. Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
  3. Medusa Core Workflows Reference: refundPaymentsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow

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 stale balance before a bad refund?

If this saved you from a repeat refund or a confusing reconciliation, 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