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.
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.
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:
outstanding_amountlives onOrderSummary, which the order module's totals computation builds by aggregatingorder_transactionrows, not by decrementing a stored field each time a refund happens.- The first
refundPaymentsWorkflowrun against an order does insert a neworder_transactionrow, and the summary recomputes from the updated set of rows exactly as expected. - As reported in medusajs/medusa issue #11481, subsequent runs of that same workflow on the same order or payment do not insert another row, so nothing tells the totals module there is new data, and the summary is never recomputed or persisted again from the updated payment totals.
- The underlying payment provider, Stripe or otherwise, has no knowledge of this gap. It processes the second and any later refund exactly as instructed, so the money genuinely leaves your account while Medusa's own record of it stalls.
- Related reports, issue #10842 on being unable to refund multiple times from a single order, and issue #10491 on refund actions failing once
outstanding_amountreads zero, both point at the same underlying cause: the admin dashboard and API are working from a cached number that stopped updating.
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.
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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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
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;
}
}
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.
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,
}
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,
};
}
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.
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),
}
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,
};
}
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- medusajs/medusa GitHub issue #11481: Outstanding amount doesn't change on multiple refunds. github.com/medusajs/medusa/issues/11481
- medusajs/medusa GitHub issue #10842: Cannot refund multiple times from a single order. github.com/medusajs/medusa/issues/10842
- 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:
- Medusa Documentation: Order Concepts, OrderSummary and totals. docs.medusajs.com/resources/commerce-modules/order/concepts
- Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
- 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.
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