Diagnostic Payments & Refunds
Only one refund per order or payment ever succeeds
A customer needs two partial refunds on the same order. The first one works fine. The second one, on the same order, sometimes even on the same payment, comes back with "Order does not have an outstanding balance to refund." The payment provider still shows money that could be returned. The customer is still owed it. But Medusa refuses to let anyone touch that payment again. Here is why the refund workflow checks the wrong number, and a small script that finds every order this happened to and tells you exactly how much is still owed.
Medusa v2's refund workflow historically validated a refund request against the order's cached summary.pending_difference, the order-level outstanding balance, instead of re-summing that specific payment's actual captures minus its existing refunds. The first refund on an order correctly zeroes out or flips the sign of that order-level balance, so validate-refund-step in the refund-payment workflow throws on every refund attempt after that, even though the payment itself still has capturable or refundable amount left. Run a script that, for each payment, independently sums captures[].raw_amount and refunds[].raw_amount, computes the true shortfall, and flags every order where that shortfall is real but the order's own balance already reads zero. Full code, tests, and a dry run guard are below.
The problem in plain words
An order in Medusa v2 carries a cached number, summary.pending_difference, that is meant to answer one question: how much money is still owed back and forth on this order. The admin trusts that number to decide whether the Refund button should even work. So does the refund-payment workflow itself, through a step called validate-refund-step.
That would be fine if the number tracked reality perfectly. It does not always. The order-level balance is computed once, at the order's scope, not per payment. When you issue the first refund, Medusa correctly updates that order-level number, often all the way to zero, because from the order's point of view the outstanding difference has been settled. But a payment can have multiple captures, and a payment can be refunded more than once, in pieces, over time. The order's single cached number cannot represent "this specific payment still has forty dollars of headroom" once the order-level math has already called the account even.
Why it happens
This is not one isolated glitch. It is a scope mismatch between what the order thinks it owes and what a single payment can actually still give back. A few things make it worse in practice:
- The refund-payment workflow's
validate-refund-stepreadsorder.summary.pending_difference, a single order-wide figure, rather than recomputingthis payment's captures minus this payment's refunds, which is the number that actually determines whether a refund can happen. - A related bug, tracked as issue #11766, means
captured_amounton the payment or payment collection is not always updated correctly after a capture, especially with custom or two-step payment providers such as Stripe's Cash App or Amazon Pay flows. That leaves the order's outstanding-amount bookkeeping permanently out of step with the true capture and refund ledger, independent of the refund-ordering problem above. - Because the order-level balance is a cached snapshot, once it reads zero or negative, it stays that way for every future refund attempt against that order, not just the second one. A third or fourth refund fails exactly the same way as the second.
- This exact class of failure is documented as reproducible in medusajs/medusa issues #10842, #10392, #10491, and #11766. PR #11832 partially addresses it by recomputing the refundable amount from the payment's own captures and refunds instead of the order-level balance, but stores on older releases do not have that fix.
None of this means the payment provider lost track of the money. Stripe, or whichever gateway is in use, still knows exactly what was captured and what has been refunded. The gap is entirely inside Medusa's own bookkeeping, between the order's cached summary and the payment's real ledger. See the citations at the end for the exact issues, the PR, and the API references.
Do not trust order.summary.pending_difference to tell you whether a payment can still be refunded. Trust the payment's own captures[].raw_amount and refunds[].raw_amount, summed independently. The order-level number is exactly the thing that is wrong here, so building a detector on top of it would just repeat the same mistake the workflow makes. And because firing a corrective refund is real money moving through a payment provider, the safe pattern is compute and flag first, never auto-fire, and only submit the live refund once a human has approved it on a store confirmed to be running the patched version.
The fix, as a flow
We do not patch Medusa's workflow code. The job pulls every order with its payment collections, payments, captures, and refunds expanded, runs a pure function per payment that sums captures and refunds independently of the order summary, and flags any payment where a real shortfall exists alongside a stale order-level balance. Under a dry run flag it only logs the computed delta. Only with the flag off, and only after a human has reviewed the list, does it call the same refund route the admin button uses.
Build it step by step
Authenticate against the Admin API
Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
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, change to false to write
// 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" // start safe, change to false to write
List orders with captures and refunds expanded
Ask for orders with summary and every payment's captures and refunds expanded. This is the one call that puts both sides of the discrepancy in front of you: the order's own cached balance, and the payment's real ledger. Paginate with limit and offset.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
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_ledger(token):
orders = []
offset = 0
limit = 100
fields = (
"id,display_id,summary,*payment_collections,"
"*payment_collections.payments,*payment_collections.payments.captures,"
"*payment_collections.payments.refunds"
)
while True:
data = admin_get(token, "/admin/orders", {
"fields": fields,
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
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 ${res.status} on GET ${path}`);
return res.json();
}
const ORDER_FIELDS =
"id,display_id,summary,*payment_collections," +
"*payment_collections.payments,*payment_collections.payments.captures," +
"*payment_collections.payments.refunds";
async function listOrdersWithLedger(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;
}
}
Cross-check at the payment level
Once you have a candidate payment, confirm the discrepancy directly against that payment rather than the order, since the payment is the object the patched validation step actually evaluates. This also gives you captured_at, useful for telling apart a payment that has not been captured yet from one that genuinely has refundable headroom.
def get_payment_ledger(token, payment_id):
return admin_get(token, f"/admin/payments/{payment_id}", {
"fields": "id,captured_at,captures.raw_amount,refunds.raw_amount",
})["payment"]
async function getPaymentLedger(token, paymentId) {
const data = await adminGet(token, `/admin/payments/${paymentId}`, {
fields: "id,captured_at,captures.raw_amount,refunds.raw_amount",
});
return data.payment;
}
Decide, with one pure function
Keep the math in its own function that takes a payment's captures and refunds plus the order's cached balance, and never touches the network. Sum captures[].raw_amount into a captured total, sum refunds[].raw_amount into a refunded total, and subtract. A payment is flagged as silently blocked only when it still owes a real shortfall and the order's own pending_difference already reads zero or negative, which is exactly the condition that makes the order-level check reject a legitimate refund.
EPSILON = 0.01
def compute_refund_shortfall(payment, order_pending_difference):
"""Pure decision function. No I/O.
payment: {"id": str, "captures": [{"raw_amount": float}], "refunds": [{"raw_amount": float}]}
order_pending_difference: float, the order's own cached summary.pending_difference
Returns {"paymentId", "capturedTotal", "refundedTotal", "shortfall", "isSilentlyBlocked"}.
"""
captured_total = sum(c.get("raw_amount", 0) for c in (payment.get("captures") or []))
refunded_total = sum(r.get("raw_amount", 0) for r in (payment.get("refunds") or []))
shortfall = captured_total - refunded_total
is_silently_blocked = shortfall > EPSILON and order_pending_difference <= EPSILON
return {
"paymentId": payment.get("id"),
"capturedTotal": captured_total,
"refundedTotal": refunded_total,
"shortfall": shortfall,
"isSilentlyBlocked": is_silently_blocked,
}
const EPSILON = 0.01;
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, captures: Array<{ raw_amount: number }>, refunds: Array<{ raw_amount: number }> }} payment
* @param {number} orderPendingDifference the order's own cached summary.pending_difference
* @returns {{ paymentId: string, capturedTotal: number, refundedTotal: number,
* shortfall: number, isSilentlyBlocked: boolean }}
*/
export function computeRefundShortfall(payment, orderPendingDifference) {
const capturedTotal = (payment.captures || []).reduce((sum, c) => sum + (c.raw_amount || 0), 0);
const refundedTotal = (payment.refunds || []).reduce((sum, r) => sum + (r.raw_amount || 0), 0);
const shortfall = capturedTotal - refundedTotal;
const isSilentlyBlocked = shortfall > EPSILON && orderPendingDifference <= EPSILON;
return {
paymentId: payment.id,
capturedTotal,
refundedTotal,
shortfall,
isSilentlyBlocked,
};
}
Fire the make-up refund only when it is safe
Only call the live refund route when the store is confirmed patched, DRY_RUN is false, and a human has approved the flagged list. The amount is always the shortfall already computed from the payment's own ledger, never a guess. On an unpatched store, do not call this at all, since the same validation error will reject the corrective refund too. Report the order and payment for manual processing in the payment provider's own dashboard instead.
def admin_post(token, path, json_body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
json=json_body,
timeout=30,
)
r.raise_for_status()
return r.json()
def fire_makeup_refund(token, payment_id, shortfall):
return admin_post(token, f"/admin/payments/{payment_id}/refund", {"amount": shortfall})
async function adminPost(token, path, jsonBody) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(jsonBody),
});
if (!res.ok) throw new Error(`Medusa ${res.status} on POST ${path}`);
return res.json();
}
async function fireMakeupRefund(token, paymentId, shortfall) {
return adminPost(token, `/admin/payments/${paymentId}/refund`, { amount: shortfall });
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On every run, leave DRY_RUN on so the script only logs the {order_id, payment_id, shortfall} tuples it finds. Read the output, confirm the store version, get a human to approve the list, then switch DRY_RUN off to let it write. Run it on a schedule, for example once a day, since a silently blocked refund does not resolve itself.
Always start with DRY_RUN=true. This is real money moving through a payment provider, so never auto-fire the corrective refund. Confirm the store is on a Medusa release that includes PR #11832 before flipping DRY_RUN off, since an unpatched store rejects the corrective refund with the same validation error it rejected the original one with. On an unpatched store, report the order id, payment id, and shortfall for manual processing directly in the payment provider's dashboard instead.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever computes the shortfall from each payment's own ledger, never from the order's cached balance.
"""Find Medusa orders where a second refund silently failed.
Medusa v2's refund-payment workflow historically validates a refund request
against the order's cached summary.pending_difference instead of re-summing
that specific payment's actual captures minus its existing refunds. The first
refund on an order correctly zeroes or flips the sign of the order-level
balance, so validate-refund-step rejects every refund attempt after that with
"Order does not have an outstanding balance to refund", even though the
payment itself may still have capturable or refundable amount left. This
lists orders with captures and refunds expanded, computes the true shortfall
per payment independent of the order summary, and flags every payment that is
silently blocked. It never fires a refund unless DRY_RUN is false and a human
has approved the list, since this is real money moving through a provider.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("refund_shortfall")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
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.captures,"
"*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 admin_post(token, path, json_body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
json=json_body,
timeout=30,
)
r.raise_for_status()
return r.json()
def compute_refund_shortfall(payment, order_pending_difference):
"""Pure decision function. No I/O.
payment: {"id": str, "captures": [{"raw_amount": float}], "refunds": [{"raw_amount": float}]}
order_pending_difference: float, the order's own cached summary.pending_difference
Returns {"paymentId", "capturedTotal", "refundedTotal", "shortfall", "isSilentlyBlocked"}.
"""
captured_total = sum(c.get("raw_amount", 0) for c in (payment.get("captures") or []))
refunded_total = sum(r.get("raw_amount", 0) for r in (payment.get("refunds") or []))
shortfall = captured_total - refunded_total
is_silently_blocked = shortfall > EPSILON and order_pending_difference <= EPSILON
return {
"paymentId": payment.get("id"),
"capturedTotal": captured_total,
"refundedTotal": refunded_total,
"shortfall": shortfall,
"isSilentlyBlocked": is_silently_blocked,
}
def list_orders_with_ledger(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 payments_of(order):
return [
payment
for collection in (order.get("payment_collections") or [])
for payment in (collection.get("payments") or [])
]
def fire_makeup_refund(token, payment_id, shortfall):
return admin_post(token, f"/admin/payments/{payment_id}/refund", {"amount": shortfall})
def run():
token = get_admin_token()
orders = list_orders_with_ledger(token)
flagged = 0
for order in orders:
pending_difference = (order.get("summary") or {}).get("pending_difference", 0)
for payment in payments_of(order):
outcome = compute_refund_shortfall(payment, pending_difference)
if not outcome["isSilentlyBlocked"]:
continue
log.warning(
"Order %s payment %s silently blocked: captured=%s refunded=%s shortfall=%s. %s",
order.get("display_id") or order["id"], outcome["paymentId"],
outcome["capturedTotal"], outcome["refundedTotal"], outcome["shortfall"],
"would refund" if DRY_RUN else "refunding",
)
if not DRY_RUN:
fire_makeup_refund(token, outcome["paymentId"], outcome["shortfall"])
flagged += 1
log.info(
"Done. %d payment(s) %s.",
flagged, "flagged, none refunded (dry run)" if DRY_RUN else "refunded",
)
if __name__ == "__main__":
run()
/**
* Find Medusa orders where a second refund silently failed.
*
* Medusa v2's refund-payment workflow historically validates a refund request
* against the order's cached summary.pending_difference instead of re-summing
* that specific payment's actual captures minus its existing refunds. The
* first refund on an order correctly zeroes or flips the sign of the
* order-level balance, so validate-refund-step rejects every refund attempt
* after that with "Order does not have an outstanding balance to refund",
* even though the payment itself may still have capturable or refundable
* amount left. This lists orders with captures and refunds expanded,
* computes the true shortfall per payment independent of the order summary,
* and flags every payment that is silently blocked. It never fires a refund
* unless DRY_RUN is false and a human has approved the list, since this is
* real money moving through a provider.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/second-refund-fails/
*/
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.captures," +
"*payment_collections.payments.refunds";
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, captures: Array<{ raw_amount: number }>, refunds: Array<{ raw_amount: number }> }} payment
* @param {number} orderPendingDifference the order's own cached summary.pending_difference
* @returns {{ paymentId: string, capturedTotal: number, refundedTotal: number,
* shortfall: number, isSilentlyBlocked: boolean }}
*/
export function computeRefundShortfall(payment, orderPendingDifference) {
const capturedTotal = (payment.captures || []).reduce((sum, c) => sum + (c.raw_amount || 0), 0);
const refundedTotal = (payment.refunds || []).reduce((sum, r) => sum + (r.raw_amount || 0), 0);
const shortfall = capturedTotal - refundedTotal;
const isSilentlyBlocked = shortfall > EPSILON && orderPendingDifference <= EPSILON;
return {
paymentId: payment.id,
capturedTotal,
refundedTotal,
shortfall,
isSilentlyBlocked,
};
}
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 ${res.status} on GET ${path}`);
return res.json();
}
async function adminPost(token, path, jsonBody) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(jsonBody),
});
if (!res.ok) throw new Error(`Medusa ${res.status} on POST ${path}`);
return res.json();
}
async function listOrdersWithLedger(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;
}
}
function paymentsOf(order) {
return (order.payment_collections || []).flatMap((collection) => collection.payments || []);
}
async function fireMakeupRefund(token, paymentId, shortfall) {
return adminPost(token, `/admin/payments/${paymentId}/refund`, { amount: shortfall });
}
export async function run() {
const token = await getAdminToken();
const orders = await listOrdersWithLedger(token);
let flagged = 0;
for (const order of orders) {
const pendingDifference = order.summary ? order.summary.pending_difference || 0 : 0;
for (const payment of paymentsOf(order)) {
const outcome = computeRefundShortfall(payment, pendingDifference);
if (!outcome.isSilentlyBlocked) continue;
console.warn(
`Order ${order.display_id || order.id} payment ${outcome.paymentId} silently blocked: captured=${outcome.capturedTotal} refunded=${outcome.refundedTotal} shortfall=${outcome.shortfall}. ${DRY_RUN ? "would refund" : "refunding"}`
);
if (!DRY_RUN) {
await fireMakeupRefund(token, outcome.paymentId, outcome.shortfall);
}
flagged++;
}
}
console.log(
`Done. ${flagged} payment(s) ${DRY_RUN ? "flagged, none refunded (dry run)" : "refunded"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
compute_refund_shortfall is the part most worth testing, because it decides which payments actually have real money still owed versus which orders merely look wrong. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture payments and checks the answer.
from refund_shortfall import compute_refund_shortfall
def payment(captures=None, refunds=None):
return {
"id": "pay_1",
"captures": captures if captures is not None else [{"raw_amount": 100.0}],
"refunds": refunds if refunds is not None else [],
}
def test_silently_blocked_when_payment_has_headroom_but_order_reads_zero():
p = payment(refunds=[{"raw_amount": 40.0}])
result = compute_refund_shortfall(p, 0)
assert result["capturedTotal"] == 100.0
assert result["refundedTotal"] == 40.0
assert result["shortfall"] == 60.0
assert result["isSilentlyBlocked"] is True
def test_not_blocked_when_order_still_shows_a_balance():
p = payment(refunds=[{"raw_amount": 40.0}])
result = compute_refund_shortfall(p, 60.0)
assert result["isSilentlyBlocked"] is False
def test_not_blocked_when_fully_refunded():
p = payment(refunds=[{"raw_amount": 100.0}])
result = compute_refund_shortfall(p, 0)
assert result["shortfall"] == 0.0
assert result["isSilentlyBlocked"] is False
def test_sums_multiple_captures_and_refunds():
p = payment(
captures=[{"raw_amount": 50.0}, {"raw_amount": 50.0}],
refunds=[{"raw_amount": 20.0}, {"raw_amount": 20.0}],
)
result = compute_refund_shortfall(p, 0)
assert result["capturedTotal"] == 100.0
assert result["refundedTotal"] == 40.0
assert result["shortfall"] == 60.0
assert result["isSilentlyBlocked"] is True
def test_negative_order_pending_difference_still_counts_as_blocked():
p = payment(refunds=[{"raw_amount": 40.0}])
result = compute_refund_shortfall(p, -5.0)
assert result["isSilentlyBlocked"] is True
def test_within_epsilon_shortfall_is_not_blocked():
p = payment(captures=[{"raw_amount": 100.0}], refunds=[{"raw_amount": 99.995}])
result = compute_refund_shortfall(p, 0)
assert result["isSilentlyBlocked"] is False
def test_no_captures_means_zero_shortfall():
p = payment(captures=[], refunds=[])
result = compute_refund_shortfall(p, 0)
assert result["capturedTotal"] == 0.0
assert result["shortfall"] == 0.0
assert result["isSilentlyBlocked"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeRefundShortfall } from "./refund-shortfall.js";
const payment = (over = {}) => ({
id: "pay_1",
captures: [{ raw_amount: 100.0 }],
refunds: [],
...over,
});
test("silently blocked when payment has headroom but order reads zero", () => {
const p = payment({ refunds: [{ raw_amount: 40.0 }] });
const result = computeRefundShortfall(p, 0);
assert.equal(result.capturedTotal, 100.0);
assert.equal(result.refundedTotal, 40.0);
assert.equal(result.shortfall, 60.0);
assert.equal(result.isSilentlyBlocked, true);
});
test("not blocked when order still shows a balance", () => {
const p = payment({ refunds: [{ raw_amount: 40.0 }] });
const result = computeRefundShortfall(p, 60.0);
assert.equal(result.isSilentlyBlocked, false);
});
test("not blocked when fully refunded", () => {
const p = payment({ refunds: [{ raw_amount: 100.0 }] });
const result = computeRefundShortfall(p, 0);
assert.equal(result.shortfall, 0.0);
assert.equal(result.isSilentlyBlocked, false);
});
test("sums multiple captures and refunds", () => {
const p = payment({
captures: [{ raw_amount: 50.0 }, { raw_amount: 50.0 }],
refunds: [{ raw_amount: 20.0 }, { raw_amount: 20.0 }],
});
const result = computeRefundShortfall(p, 0);
assert.equal(result.capturedTotal, 100.0);
assert.equal(result.refundedTotal, 40.0);
assert.equal(result.shortfall, 60.0);
assert.equal(result.isSilentlyBlocked, true);
});
test("negative order pending difference still counts as blocked", () => {
const p = payment({ refunds: [{ raw_amount: 40.0 }] });
const result = computeRefundShortfall(p, -5.0);
assert.equal(result.isSilentlyBlocked, true);
});
test("within epsilon shortfall is not blocked", () => {
const p = payment({ captures: [{ raw_amount: 100.0 }], refunds: [{ raw_amount: 99.995 }] });
const result = computeRefundShortfall(p, 0);
assert.equal(result.isSilentlyBlocked, false);
});
test("no captures means zero shortfall", () => {
const p = payment({ captures: [], refunds: [] });
const result = computeRefundShortfall(p, 0);
assert.equal(result.capturedTotal, 0.0);
assert.equal(result.shortfall, 0.0);
assert.equal(result.isSilentlyBlocked, false);
});
Case studies
The order that needed two partial refunds
A customer bought two items on one order and returned them a week apart. Support refunded the first item fine. When the second item came back, the same Refund button on the same order threw "Order does not have an outstanding balance to refund," even though the payment had captured both items and only one refund had gone out. Support assumed the return was somehow already processed and closed the ticket, leaving the customer genuinely still owed money.
Running the shortfall script against the store's orders surfaced the payment immediately: captured total matched both items, refunded total matched only the first, and the order's own balance already read zero. The team confirmed the store was on a patched Medusa release, reviewed the flagged list, and fired the make-up refund for the exact shortfall.
The two-step capture that broke the ledger twice over
A store using a custom payment provider with a two-step capture flow, similar to Stripe's Cash App or Amazon Pay integrations, hit both bugs at once. The provider's captured_amount was not always updated correctly after capture, so the order's outstanding-amount bookkeeping was already unreliable before any refund happened. Once a first refund did land, every later refund attempt on those orders failed the same way.
Because the store had not yet upgraded past the release with the PR #11832 fix, the team did not fire any corrective refunds through Medusa. Instead they ran the script in report-only mode, exported the flagged order ids, payment ids, and shortfalls, and processed those refunds directly in the payment provider's dashboard until the platform upgrade shipped.
After this runs on a schedule, no payment quietly keeps money it should have given back just because the order's cached balance already reads zero. Every shortfall is computed from the payment's own captures and refunds, reviewed by a human, and only ever resolved by a live refund on a store confirmed to be patched. On an unpatched store, the same list becomes the manual to-do sheet for the payment provider dashboard instead of a guess.
FAQ
Why does my second refund on a Medusa order fail?
The refund-payment workflow's validate-refund-step historically checked the order's cached summary.pending_difference instead of re-summing that specific payment's captures minus its existing refunds. The first refund correctly zeroes or flips the sign of the order-level balance, so every refund attempt after that throws Order does not have an outstanding balance to refund, even though the payment itself may still have money left to give back.
Is it safe to fire the missing refund automatically?
Not blindly. A refund is a real settlement with a payment provider, so the script only computes and logs the shortfall by default under DRY_RUN. It calls POST /admin/payments/{payment_id}/refund for real only when DRY_RUN is false and a human has approved the flagged list, and only after confirming the store is running a Medusa version that includes the PR 11832 fix, since an unpatched store rejects the corrective refund with the same validation error.
How do I detect an order that silently blocked a second refund?
For each payment, sum captures[].raw_amount and refunds[].raw_amount yourself, independent of order.summary. If capturedTotal minus refundedTotal is greater than zero, the payment still has refundable headroom. If at the same time the order's own pending_difference already reads zero or negative, that mismatch is the signature of a silently rejected second refund, because the order-level check will reject a legitimate refund that the payment itself can still afford.
Related field notes
Citations
On the problem:
- medusajs/medusa Issue #10842: Cannot refund multiple times from a single order. github.com/medusajs/medusa/issues/10842
- medusajs/medusa Issue #10491: Unable to Refund Captured Payments in Orders with $0 Outstanding Amount. github.com/medusajs/medusa/issues/10491
- medusajs/medusa Issue #11766: outstanding amount is incorrect after a payment has been captured from a custom payment provider. github.com/medusajs/medusa/issues/11766
On the solution:
- Medusa Payment Module Reference: the
refundPaymentfunction. docs.medusajs.com/resources/references/payment/refundPayment - Medusa Core Workflows Reference:
refundPaymentsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow - Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
Stuck on a tricky one?
If you have a problem in Medusa orders, payments, inventory, or fulfillment 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 find your missing refund?
If this saved you a support escalation or a customer who was owed money and never got it, 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