Repair Payments & Refunds
Refund rejected on a captured order showing zero outstanding
The card was charged, the capture went through, and the money is sitting in your account. But when support tries to refund the customer, Medusa throws back "Order does not have an outstanding balance to refund" as if nothing was ever paid. The payment is real and it is refundable. The order's own summary just does not agree. Here is why that gate can be wrong, and a small script that decides refunds from the payment ledger instead.
In Medusa v2, the Admin dashboard's Refund action and most custom refund code check the order's derived summary fields, paid_total, refunded_total, and outstanding_amount, instead of the actual captured amount recorded on the Payment module. When that summary is computed or cached incorrectly after a capture, for example with a custom payment provider, multiple payment collections, or rounding in totals recalculation, it can read outstanding_amount as zero while the payment is still fully refundable. Run a small Python or Node.js script that lists captured, non-refunded orders, computes the true refundable amount as payment.amount minus payment.amount_refunded straight from the Payment module, and calls the refund route directly whenever that ledger says money is still owed, even if the order's own summary disagrees. Full code, tests, and a dry run guard are below.
The problem in plain words
When a payment is captured in Medusa v2, the money moves and the Payment module records it: an amount, a captured_at timestamp, and an amount_refunded that starts at zero. That record is the actual ledger of what happened to the money.
The order itself does not read that ledger directly when you click Refund. It reads its own summary, a derived snapshot with fields like paid_total, refunded_total, and transaction_total/outstanding_amount, that is supposed to be kept in sync with the Payment module. Most of the time it is. But when the summary is computed or cached incorrectly after a capture, it can settle on outstanding_amount = 0 even though the payment sitting right underneath it has never been refunded. The refund guard trusts that broken zero, not the payment, and blocks a request that should have gone through.
Why it happens
The order's outstanding_amount, along with paid_total and refunded_total, is derived state on summary, not a number pulled fresh from the Payment module on every request. A few common ways it drifts:
- A custom payment provider captures the payment and writes its own state, but the recalculation that should follow does not run the way Medusa's built-in providers expect, so the summary is left stale or wrong.
- An order has multiple payment collections, and the totals recalculation across them does not add up the way a single-collection order would, leaving the combined outstanding figure off.
- Rounding during totals recalculation, especially with BigNumber and decimal conversions, can settle the summary on exactly zero when the true figure is a small positive amount.
- GitHub issues #9261, #10491, and #11766 document this exact class of problem: a captured, unrefunded order where the Admin dashboard reports zero outstanding and refuses to refund a payment that is plainly still sitting there refundable.
None of this means the money vanished. The Payment module's record of what was captured and what has been refunded is still correct. The problem is that the gate asks the wrong question, it asks the summary "is there anything owed," instead of asking the payment "how much of you is still refundable." See the citations at the end for the exact issues and docs.
Marking a refund blocked is supposed to protect against double-refunding money that is already gone. But the order summary is not the source of truth for that, the Payment module is. So the safe pattern is not "trust outstanding_amount and give up when it is zero." It is "trust payment.amount minus payment.amount_refunded, and treat a summary that disagrees as a discrepancy to flag for audit, never as a reason to block a refund that the ledger itself says is still owed."
The fix, as a flow
We do not touch the Admin dashboard's own refund button. The job lists captured, non-refunded orders with their payments expanded, computes the true refundable amount from each payment record, re-confirms it right before writing, and calls the refund route on the Payment module directly, bypassing the order-level summary gate entirely. Anything that fails the ledger check is left alone.
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 captured orders with payments expanded
Ask for orders with summary and payment_collections.payments expanded, paginating with limit and offset. Keep orders whose payment collection status is authorized or captured, since those are the only ones where a refund could ever be legitimate.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ORDER_FIELDS = (
"id,display_id,status,summary.paid_total,summary.refunded_total,"
"summary.transaction_total,*payment_collections,*payment_collections.payments"
)
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_captured_orders(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 BACKEND_URL = process.env.MEDUSA_BACKEND_URL;
const ORDER_FIELDS =
"id,display_id,status,summary.paid_total,summary.refunded_total," +
"summary.transaction_total,*payment_collections,*payment_collections.payments";
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 listCapturedOrders(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;
}
}
Read the true refundable amount from each payment
Every payment carries amount, amount_refunded, and captured_at. That triplet is the actual ledger, independent of anything the order's summary thinks. A payment is only refundable at all once captured_at is set, and the amount still owed is always amount minus amount_refunded, never something read off the order.
def to_decimal(value):
return float(value or 0)
def payments_of(order):
return [
payment
for collection in (order.get("payment_collections") or [])
for payment in (collection.get("payments") or [])
]
function toDecimal(value) {
return Number(value || 0);
}
function paymentsOf(order) {
return (order.payment_collections || []).flatMap(
(collection) => collection.payments || []
);
}
Decide, with one pure function
Keep the decision in its own function that takes only a payment, the order's (possibly wrong) summary, and the amount requested, and never touches the network. It trusts the payment ledger as the source of truth for how much is refundable, and treats the summary's own view of outstanding as a diagnostic signal only, never as the blocking condition. That is exactly the fix for the bug: the case where the summary says zero outstanding but the payment is still captured and refundable must return allowed, not rejected.
def decide_refund(payment, order_summary, requested_amount):
"""Pure decision function. No I/O.
payment: {"amount": float, "amount_refunded": float, "captured_at": str | None}
order_summary: {"transaction_total": float, "paid_total": float, "refunded_total": float}
requested_amount: float
Returns {"allow": bool, "refundable_amount": str, "reason": str | None}.
"""
if payment.get("captured_at") is None:
return {"allow": False, "refundable_amount": "0", "reason": "not_captured"}
payment_refundable = to_decimal(payment.get("amount")) - to_decimal(payment.get("amount_refunded"))
summary_refundable = to_decimal(order_summary.get("paid_total")) - to_decimal(order_summary.get("refunded_total"))
# The payment ledger is the source of truth. The order summary is a
# diagnostic signal only, never the blocking condition.
true_refundable = payment_refundable
if to_decimal(requested_amount) > true_refundable:
return {"allow": False, "refundable_amount": str(true_refundable), "reason": "exceeds_refundable"}
if summary_refundable <= 0 and payment_refundable > 0:
return {
"allow": True,
"refundable_amount": str(true_refundable),
"reason": "summary_outstanding_zero_but_payment_captured",
}
return {"allow": True, "refundable_amount": str(true_refundable), "reason": None}
/**
* Pure decision function. No I/O.
*
* @param {{ amount: string|number, amount_refunded: string|number, captured_at: string|null }} payment
* @param {{ transaction_total: string|number, paid_total: string|number, refunded_total: string|number }} orderSummary
* @param {string|number} requestedAmount
* @returns {{ allow: boolean, refundable_amount: string, reason?: string }}
*/
export function decideRefund(payment, orderSummary, requestedAmount) {
if (payment.captured_at == null) {
return { allow: false, refundable_amount: "0", reason: "not_captured" };
}
const paymentRefundable = toDecimal(payment.amount) - toDecimal(payment.amount_refunded);
const summaryRefundable = toDecimal(orderSummary.paid_total) - toDecimal(orderSummary.refunded_total);
// The payment ledger is the source of truth. The order summary is a
// diagnostic signal only, never the blocking condition.
const trueRefundable = paymentRefundable;
if (toDecimal(requestedAmount) > trueRefundable) {
return { allow: false, refundable_amount: String(trueRefundable), reason: "exceeds_refundable" };
}
if (summaryRefundable <= 0 && paymentRefundable > 0) {
return {
allow: true,
refundable_amount: String(trueRefundable),
reason: "summary_outstanding_zero_but_payment_captured",
};
}
return { allow: true, refundable_amount: String(trueRefundable) };
}
Re-confirm right before writing, then refund on the Payment module directly
Before calling the refund route, re-fetch the payment by id and re-check that captured_at is still set and that amount_refunded plus the amount you are about to send does not exceed amount. This guards against a race where another refund landed between your list call and your write. Then call POST /admin/payments/{payment_id}/refund directly, which bypasses the order-level summary gate entirely.
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 refetch_payment(token, payment_id):
data = admin_get(token, f"/admin/payments/{payment_id}", {
"fields": "id,amount,amount_refunded,captured_at,*payment_collection",
})
return data["payment"]
def refund_payment(token, payment_id, amount):
payment = refetch_payment(token, payment_id)
if payment.get("captured_at") is None:
raise RuntimeError(f"payment {payment_id} is not captured, refusing to refund")
if to_decimal(payment.get("amount_refunded")) + to_decimal(amount) > to_decimal(payment.get("amount")):
raise RuntimeError(f"payment {payment_id} refund would exceed captured amount")
return admin_post(token, f"/admin/payments/{payment_id}/refund", {"amount": amount})
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 refetchPayment(token, paymentId) {
const data = await adminGet(token, `/admin/payments/${paymentId}`, {
fields: "id,amount,amount_refunded,captured_at,*payment_collection",
});
return data.payment;
}
async function refundPayment(token, paymentId, amount) {
const payment = await refetchPayment(token, paymentId);
if (payment.captured_at == null) {
throw new Error(`payment ${paymentId} is not captured, refusing to refund`);
}
if (toDecimal(payment.amount_refunded) + toDecimal(amount) > toDecimal(payment.amount)) {
throw new Error(`payment ${paymentId} refund would exceed captured amount`);
}
return adminPost(token, `/admin/payments/${paymentId}/refund`, { amount });
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {order_id, payment_id, refundable_amount, requested amount} tuples decide_refund produced. Read the output, agree with it, then switch it off to let it write. After every real refund, re-fetch the order and log the before and after summary.refunded_total, since a stale summary bug like this may still need a manual recalculation follow-up on the order side. Run it on a schedule, or on demand whenever support hits the blocked-refund error.
Always start with DRY_RUN=true. Only refund the amount decide_refund reports as refundable_amount, never a guessed number, and never more than payment.amount minus payment.amount_refunded at the moment of the write. If the order's summary still looks wrong after a successful refund, that discrepancy is worth a manual order-summary recalculation, not another automated write.
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 always re-derives the refundable amount from the payment record at write time, never from a cached order summary.
"""Refund Medusa payments that are wrongly blocked by a zero-outstanding order summary.
In Medusa v2, the Admin dashboard's Refund action and most custom refund code
check the order's derived summary fields, paid_total, refunded_total, and
outstanding_amount, instead of the actual captured amount on the Payment
module record. When that summary is computed or cached incorrectly after a
capture, for example with a custom payment provider, multiple payment
collections, or rounding in totals recalculation, it can read
outstanding_amount as zero while the payment is still fully refundable, and
the guard throws "Order does not have an outstanding balance to refund" on a
perfectly legitimate refund. This lists captured, non-refunded orders with
their payments expanded, computes the true refundable amount as
payment.amount minus payment.amount_refunded straight from the Payment
module, re-confirms it right before writing, and calls the refund route
directly, bypassing the unreliable order-summary gate.
Run on demand or 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_from_ledger")
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"
ORDER_FIELDS = (
"id,display_id,status,summary.paid_total,summary.refunded_total,"
"summary.transaction_total,*payment_collections,*payment_collections.payments"
)
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 to_decimal(value):
return float(value or 0)
def payments_of(order):
return [
payment
for collection in (order.get("payment_collections") or [])
for payment in (collection.get("payments") or [])
]
def decide_refund(payment, order_summary, requested_amount):
"""Pure decision function. No I/O.
payment: {"amount": float, "amount_refunded": float, "captured_at": str | None}
order_summary: {"transaction_total": float, "paid_total": float, "refunded_total": float}
requested_amount: float
Returns {"allow": bool, "refundable_amount": str, "reason": str | None}.
"""
if payment.get("captured_at") is None:
return {"allow": False, "refundable_amount": "0", "reason": "not_captured"}
payment_refundable = to_decimal(payment.get("amount")) - to_decimal(payment.get("amount_refunded"))
summary_refundable = to_decimal(order_summary.get("paid_total")) - to_decimal(order_summary.get("refunded_total"))
# The payment ledger is the source of truth. The order summary is a
# diagnostic signal only, never the blocking condition.
true_refundable = payment_refundable
if to_decimal(requested_amount) > true_refundable:
return {"allow": False, "refundable_amount": str(true_refundable), "reason": "exceeds_refundable"}
if summary_refundable <= 0 and payment_refundable > 0:
return {
"allow": True,
"refundable_amount": str(true_refundable),
"reason": "summary_outstanding_zero_but_payment_captured",
}
return {"allow": True, "refundable_amount": str(true_refundable), "reason": None}
def list_captured_orders(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 refetch_payment(token, payment_id):
data = admin_get(token, f"/admin/payments/{payment_id}", {
"fields": "id,amount,amount_refunded,captured_at,*payment_collection",
})
return data["payment"]
def refund_payment(token, payment_id, amount):
payment = refetch_payment(token, payment_id)
if payment.get("captured_at") is None:
raise RuntimeError(f"payment {payment_id} is not captured, refusing to refund")
if to_decimal(payment.get("amount_refunded")) + to_decimal(amount) > to_decimal(payment.get("amount")):
raise RuntimeError(f"payment {payment_id} refund would exceed captured amount")
return admin_post(token, f"/admin/payments/{payment_id}/refund", {"amount": amount})
def get_order_refunded_total(token, order_id):
data = admin_get(token, f"/admin/orders/{order_id}", {
"fields": "summary.refunded_total,*payment_collections.payments",
})
return data["order"]["summary"]["refunded_total"]
def run():
token = get_admin_token()
orders = list_captured_orders(token)
refunded = 0
skipped = 0
for order in orders:
summary = order.get("summary") or {}
for payment in payments_of(order):
payment_refundable = to_decimal(payment.get("amount")) - to_decimal(payment.get("amount_refunded"))
if payment_refundable <= 0:
continue
outcome = decide_refund(payment, summary, payment_refundable)
label = order.get("display_id") or order["id"]
if not outcome["allow"]:
log.info("Order %s payment %s not refunded: %s", label, payment.get("id"), outcome["reason"])
skipped += 1
continue
if outcome["reason"] == "summary_outstanding_zero_but_payment_captured":
log.warning(
"Order %s payment %s: summary reads zero outstanding but payment is captured and refundable=%s. %s",
label, payment.get("id"), outcome["refundable_amount"],
"would refund" if DRY_RUN else "refunding",
)
else:
log.info(
"Order %s payment %s refundable=%s. %s",
label, payment.get("id"), outcome["refundable_amount"],
"would refund" if DRY_RUN else "refunding",
)
if not DRY_RUN:
before = summary.get("refunded_total")
refund_payment(token, payment["id"], outcome["refundable_amount"])
after = get_order_refunded_total(token, order["id"])
log.info(" order %s summary.refunded_total before=%s after=%s", order["id"], before, after)
refunded += 1
log.info(
"Done. %d payment(s) %s, %d skipped.",
refunded, "to refund" if DRY_RUN else "refunded", skipped,
)
if __name__ == "__main__":
run()
/**
* Refund Medusa payments that are wrongly blocked by a zero-outstanding order summary.
*
* In Medusa v2, the Admin dashboard's Refund action and most custom refund code
* check the order's derived summary fields, paid_total, refunded_total, and
* outstanding_amount, instead of the actual captured amount on the Payment
* module record. When that summary is computed or cached incorrectly after a
* capture, for example with a custom payment provider, multiple payment
* collections, or rounding in totals recalculation, it can read
* outstanding_amount as zero while the payment is still fully refundable, and
* the guard throws "Order does not have an outstanding balance to refund" on a
* perfectly legitimate refund. This lists captured, non-refunded orders with
* their payments expanded, computes the true refundable amount as
* payment.amount minus payment.amount_refunded straight from the Payment
* module, re-confirms it right before writing, and calls the refund route
* directly, bypassing the unreliable order-summary gate.
* Run on demand or on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/refund-blocked-zero-outstanding/
*/
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 ORDER_FIELDS =
"id,display_id,status,summary.paid_total,summary.refunded_total," +
"summary.transaction_total,*payment_collections,*payment_collections.payments";
function toDecimal(value) {
return Number(value || 0);
}
function paymentsOf(order) {
return (order.payment_collections || []).flatMap(
(collection) => collection.payments || []
);
}
/**
* Pure decision function. No I/O.
*
* @param {{ amount: string|number, amount_refunded: string|number, captured_at: string|null }} payment
* @param {{ transaction_total: string|number, paid_total: string|number, refunded_total: string|number }} orderSummary
* @param {string|number} requestedAmount
* @returns {{ allow: boolean, refundable_amount: string, reason?: string }}
*/
export function decideRefund(payment, orderSummary, requestedAmount) {
if (payment.captured_at == null) {
return { allow: false, refundable_amount: "0", reason: "not_captured" };
}
const paymentRefundable = toDecimal(payment.amount) - toDecimal(payment.amount_refunded);
const summaryRefundable = toDecimal(orderSummary.paid_total) - toDecimal(orderSummary.refunded_total);
// The payment ledger is the source of truth. The order summary is a
// diagnostic signal only, never the blocking condition.
const trueRefundable = paymentRefundable;
if (toDecimal(requestedAmount) > trueRefundable) {
return { allow: false, refundable_amount: String(trueRefundable), reason: "exceeds_refundable" };
}
if (summaryRefundable <= 0 && paymentRefundable > 0) {
return {
allow: true,
refundable_amount: String(trueRefundable),
reason: "summary_outstanding_zero_but_payment_captured",
};
}
return { allow: true, refundable_amount: String(trueRefundable) };
}
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 listCapturedOrders(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;
}
}
async function refetchPayment(token, paymentId) {
const data = await adminGet(token, `/admin/payments/${paymentId}`, {
fields: "id,amount,amount_refunded,captured_at,*payment_collection",
});
return data.payment;
}
async function refundPayment(token, paymentId, amount) {
const payment = await refetchPayment(token, paymentId);
if (payment.captured_at == null) {
throw new Error(`payment ${paymentId} is not captured, refusing to refund`);
}
if (toDecimal(payment.amount_refunded) + toDecimal(amount) > toDecimal(payment.amount)) {
throw new Error(`payment ${paymentId} refund would exceed captured amount`);
}
return adminPost(token, `/admin/payments/${paymentId}/refund`, { amount });
}
async function getOrderRefundedTotal(token, orderId) {
const data = await adminGet(token, `/admin/orders/${orderId}`, {
fields: "summary.refunded_total,*payment_collections.payments",
});
return data.order.summary.refunded_total;
}
export async function run() {
const token = await getAdminToken();
const orders = await listCapturedOrders(token);
let refunded = 0;
let skipped = 0;
for (const order of orders) {
const summary = order.summary || {};
for (const payment of paymentsOf(order)) {
const paymentRefundable = toDecimal(payment.amount) - toDecimal(payment.amount_refunded);
if (paymentRefundable <= 0) continue;
const outcome = decideRefund(payment, summary, paymentRefundable);
const label = order.display_id || order.id;
if (!outcome.allow) {
console.log(`Order ${label} payment ${payment.id} not refunded: ${outcome.reason}`);
skipped++;
continue;
}
if (outcome.reason === "summary_outstanding_zero_but_payment_captured") {
console.warn(
`Order ${label} payment ${payment.id}: summary reads zero outstanding but payment is captured and refundable=${outcome.refundable_amount}. ${DRY_RUN ? "would refund" : "refunding"}`
);
} else {
console.log(
`Order ${label} payment ${payment.id} refundable=${outcome.refundable_amount}. ${DRY_RUN ? "would refund" : "refunding"}`
);
}
if (!DRY_RUN) {
const before = summary.refunded_total;
await refundPayment(token, payment.id, outcome.refundable_amount);
const after = await getOrderRefundedTotal(token, order.id);
console.log(` order ${order.id} summary.refunded_total before=${before} after=${after}`);
}
refunded++;
}
}
console.log(
`Done. ${refunded} payment(s) ${DRY_RUN ? "to refund" : "refunded"}, ${skipped} skipped.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
decide_refund is the part most worth testing, because it decides whether a real refund is allowed to go through. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture payments and summaries and checks the answer.
from refund_from_ledger import decide_refund
def payment(**over):
base = {"amount": 100.0, "amount_refunded": 0.0, "captured_at": "2026-07-01T00:00:00Z"}
base.update(over)
return base
def summary(**over):
base = {"transaction_total": 100.0, "paid_total": 100.0, "refunded_total": 0.0}
base.update(over)
return base
def test_allows_refund_when_captured_and_summary_agrees():
result = decide_refund(payment(), summary(), 100.0)
assert result["allow"] is True
assert result["refundable_amount"] == "100.0"
def test_blocks_when_not_captured():
result = decide_refund(payment(captured_at=None), summary(), 100.0)
assert result["allow"] is False
assert result["reason"] == "not_captured"
def test_blocks_when_requested_exceeds_refundable():
result = decide_refund(payment(amount_refunded=40.0), summary(refunded_total=0.0), 100.0)
assert result["allow"] is False
assert result["reason"] == "exceeds_refundable"
def test_allows_when_summary_reads_zero_outstanding_but_payment_is_captured():
# This is the exact bug: the order summary thinks nothing is owed,
# but the payment ledger says it is still fully refundable.
result = decide_refund(payment(), summary(paid_total=100.0, refunded_total=100.0), 100.0)
assert result["allow"] is True
assert result["reason"] == "summary_outstanding_zero_but_payment_captured"
assert result["refundable_amount"] == "100.0"
def test_partial_refund_within_remaining_amount():
result = decide_refund(payment(amount_refunded=60.0), summary(refunded_total=60.0), 40.0)
assert result["allow"] is True
assert result["refundable_amount"] == "40.0"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRefund } from "./refund-from-ledger.js";
const payment = (over = {}) => ({ amount: 100.0, amount_refunded: 0.0, captured_at: "2026-07-01T00:00:00Z", ...over });
const summary = (over = {}) => ({ transaction_total: 100.0, paid_total: 100.0, refunded_total: 0.0, ...over });
test("allows refund when captured and summary agrees", () => {
const result = decideRefund(payment(), summary(), 100.0);
assert.equal(result.allow, true);
assert.equal(result.refundable_amount, "100");
});
test("blocks when not captured", () => {
const result = decideRefund(payment({ captured_at: null }), summary(), 100.0);
assert.equal(result.allow, false);
assert.equal(result.reason, "not_captured");
});
test("blocks when requested exceeds refundable", () => {
const result = decideRefund(payment({ amount_refunded: 40.0 }), summary({ refunded_total: 0.0 }), 100.0);
assert.equal(result.allow, false);
assert.equal(result.reason, "exceeds_refundable");
});
test("allows when summary reads zero outstanding but payment is captured", () => {
// This is the exact bug: the order summary thinks nothing is owed,
// but the payment ledger says it is still fully refundable.
const result = decideRefund(payment(), summary({ paid_total: 100.0, refunded_total: 100.0 }), 100.0);
assert.equal(result.allow, true);
assert.equal(result.reason, "summary_outstanding_zero_but_payment_captured");
assert.equal(result.refundable_amount, "100");
});
test("partial refund within remaining amount", () => {
const result = decideRefund(payment({ amount_refunded: 60.0 }), summary({ refunded_total: 60.0 }), 40.0);
assert.equal(result.allow, true);
assert.equal(result.refundable_amount, "40");
});
Case studies
The regional gateway that never told the summary
A store integrated a regional payment gateway through a custom Medusa payment provider. Captures went through cleanly and the Payment module recorded them correctly, but the totals recalculation that was supposed to follow did not run the way Medusa's built-in providers expect. Support tried to refund a cancelled order and hit "Order does not have an outstanding balance to refund" on an order that had obviously just been paid in full.
Running the script in dry run listed exactly that order, flagged with summary_outstanding_zero_but_payment_captured, and reported the true refundable amount straight from the payment. Switching off dry run refunded it correctly, and the team filed the summary staleness as a separate follow-up rather than blocking the customer's money on it.
A split payment order that miscounted its own total
An order had been split across two payment collections during a promotional flow, and the combined outstanding figure on the order's summary came out wrong once both were captured. The Admin dashboard flatly refused every refund attempt on the order, even though each individual payment record showed a clean, fully captured, unrefunded amount.
Because the script never asks the order what it thinks is owed, it worked through each payment on its own terms, refunded the ones the ledger confirmed were refundable, and logged the before and after summary.refunded_total so the discrepancy was visible for a later cleanup of the order's cached totals.
After this runs, a captured payment is never held hostage by a summary that miscounted. Every refund decision comes from payment.amount minus payment.amount_refunded, re-confirmed at the moment of the write, so support can resolve customers without waiting on an order-summary bug fix. Cases where the summary disagrees are logged for audit rather than silently accepted, so the underlying staleness still gets its own follow-up instead of quietly persisting.
FAQ
Why does Medusa say an order has no outstanding balance when the payment was captured?
The refund guard in the Admin dashboard and in most custom refund code checks the order's derived summary fields, such as paid_total, refunded_total, and outstanding_amount, rather than the actual captured amount on the Payment module record. When the summary is computed or cached incorrectly after a capture, for example with a custom payment provider, multiple payment collections, or rounding during totals recalculation, it can read outstanding_amount as zero even though the payment itself is still fully refundable.
Is it safe to refund directly against the payment when the order summary says zero outstanding?
Yes, as long as you first confirm captured_at is set on the payment and that amount_refunded plus the amount you are about to refund does not exceed the captured amount. That check uses the Payment module record itself, which is the actual ledger of what was captured and refunded, so it is safe to trust even when the order's own summary is stale or miscomputed.
How do you detect orders where the summary disagrees with the payment ledger?
List orders with payment_collections and their payments expanded, then for each payment compute payment.amount minus payment.amount_refunded. Compare that true refundable amount against the order's summary.paid_total minus summary.refunded_total. Any order where the payment ledger shows money still refundable but the summary reads zero or negative is a case where the naive outstanding balance gate would wrongly block a legitimate refund.
Related field notes
Citations
On the problem:
- Unable to Refund Payment for an Order, "Order does not have an outstanding balance to refund" Error. Medusa GitHub Issue #9261. github.com/medusajs/medusa/issues/9261
- Bug Report: Unable to Refund Captured Payments in Orders with $0 Outstanding Amount. Medusa GitHub Issue #10491. github.com/medusajs/medusa/issues/10491
- Bug: outstanding amount is incorrect after a payment has been captured from a custom payment provider. Medusa GitHub Issue #11766. github.com/medusajs/medusa/issues/11766
On the solution:
- Medusa Documentation: refundPaymentsWorkflow, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow
- Medusa Documentation: Transactions, Order Module. docs.medusajs.com/resources/commerce-modules/order/transactions
- Medusa Documentation: Payment, Payment Module. docs.medusajs.com/resources/commerce-modules/payment/payment
Stuck on a tricky one?
If you have a problem in Medusa storefront access, 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 unblock a refund?
If this saved you a support escalation or a customer waiting on money that was already sitting there refundable, 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