Reconciler Orders and fulfillment
Refund not reflected on the order
Someone refunds a customer, the money moves, the payment provider confirms it. But the order in Medusa still shows the old paid total, the old outstanding balance, as if nothing happened. Support reopens the ticket, finance cannot reconcile the books, and nobody can tell just by looking at the order that the refund ever occurred. Here is why Medusa v2 lets orders and payments drift apart like this, and a small script that finds the gap and closes it safely.
In Medusa v2, orders and payments are separate modules joined only through module links, and an order's displayed totals, summary.paid_total, refunded_total, and accounting_total, are a cached snapshot that only recomputes when a refund is applied through the official refundPaymentsWorkflow, the same workflow behind the admin Refund action. If a refund is instead recorded directly against the Payment module, for example by a custom payment provider's own refund() implementation or a webhook firing outside the workflow, the Payment module's ledger updates but no OrderChange is ever created, so the order's summary never recalculates. Run a script that sums the refunds actually recorded on each payment, compares that against order.summary.refunded_total, and for any order where the ledger is ahead, resyncs it by calling the same refund route the admin button uses with the exact amount already confirmed. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa v2 keeps orders and payments in two different modules. They are connected only by module links, not by shared rows in one table. That is a deliberate design choice, but it means an order's totals are not computed live every time you look at them. They are a snapshot, stored on order.summary, that gets refreshed only when something explicitly tells the order to recompute.
The thing that tells it to recompute is an OrderChange. When a refund goes through refundPaymentsWorkflow, that workflow both talks to the Payment module and emits the OrderChange that triggers the order to recalculate its summary. But if a refund happens some other way, a custom payment provider's refund() method writing a Refund record directly, or a provider webhook firing and updating the Payment module out of band, none of that touches the order side at all. The money moved. The Payment module knows about it. The order simply never heard.
Why it happens
The order's summary is derived state, not something recalculated on every read. A few common ways it drifts out of sync with reality:
- A custom or manual payment provider implements its own
refund()method that writes a Refund record straight to the Payment module without ever calling intorefundPaymentsWorkflow. - A payment provider's webhook fires asynchronously and updates payment state directly, outside any workflow that would also touch the order.
- Someone refunds a customer through the payment provider's own dashboard, for example a manual refund in the Stripe dashboard, completely bypassing Medusa altogether.
- GitHub issues such as #14214 and #11766 document this exact class of desync for manual and custom payment providers, where refund and order-total recalculation fall out of step with each other.
None of this is a bug in the sense of broken math. The Payment module's ledger is correct. The problem is that the order was never told to look at it again. See the citations at the end for the exact issues and docs.
Because directly mutating order.summary fields would bypass Medusa's totals-computation invariants and risk double-counting, the safe repair is never a hand edit. It is re-driving the same refundPaymentsWorkflow path the admin Refund button already uses, with the amount already confirmed on the Payment module side, never a recomputed or guessed number. And if the refund happened entirely outside Medusa, for example a manual dashboard refund with no safe way to re-derive the amount or idempotency, the right move is to flag it for a human, not to submit another refund call that risks a duplicate.
The fix, as a flow
We do not touch live checkouts or payment providers directly. The job lists orders with their payment collections, payments, and refunds expanded, sums each payment's real refund ledger, runs a pure decision function against the order's own summary, and only for orders where the ledger is ahead of the order does it call the existing refund route to resync. Everything else, in sync or over-refunded on the order side, is left for review.
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
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write
List orders with payments and refunds expanded
Ask for orders with summary, payment_collections, their payments, and each payment's refunds expanded. This is the only place both sides of the truth show up in one response: the Payment module's confirmed refunds, and the order's own cached totals. 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_payments(token):
orders = []
offset = 0
limit = 100
fields = "id,display_id,status,summary,*payment_collections,*payment_collections.payments,*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
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
const FIELDS =
"id,display_id,status,summary,*payment_collections,*payment_collections.payments,*payment_collections.payments.refunds";
async function listOrdersWithPayments() {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const { orders: page, count } = await sdk.admin.order.list({
fields: FIELDS,
limit,
offset,
});
orders.push(...page);
offset += limit;
if (offset >= count) return orders;
}
}
Cross-check against OrderChange history
Before resyncing anything, look at /admin/orders/{id}/changes to see whether a refund-type OrderChange was ever created and completed for that order. If the Payment module shows a Refund row but no matching change exists, that confirms the refund happened outside refundPaymentsWorkflow rather than being some other kind of data problem.
def has_refund_order_change(token, order_id):
data = admin_get(token, f"/admin/orders/{order_id}/changes", {"limit": 100})
changes = data.get("order_changes") or []
return any(
c.get("change_type") == "refund" and c.get("status") == "confirmed"
for c in changes
)
async function hasRefundOrderChange(orderId) {
const { order_changes: changes = [] } = await sdk.admin.order.listChanges(orderId, {
limit: 100,
});
return changes.some(
(c) => c.change_type === "refund" && c.status === "confirmed"
);
}
Decide, with one pure function
Keep the comparison in its own function that takes only the order's summary and its payment collections, never touches the network, and returns a plain decision. Flatten every payment's refunds into a ledger total, read the order's own refunded_total, and compare. A positive delta means a real refund the order has not absorbed. A negative delta means the order thinks more was refunded than the ledger shows, which is never auto-repaired, only flagged.
EPSILON = 0.01
def decide_refund_reconciliation(order):
"""Pure decision function. No I/O.
order: {
"id": str,
"summary": {"paid_total": float, "refunded_total": float,
"transaction_total": float, "accounting_total": float},
"payment_collections": [
{"status": str, "payments": [
{"amount": float, "captured_at": str | None,
"refunds": [{"amount": float, "created_at": str}]}
]}
],
}
Returns {"orderId", "needsSync", "ledgerRefundedTotal", "orderRefundedTotal",
"delta", "reason"} where reason is one of
"refund_not_reflected" | "over_refunded_on_order" | "in_sync".
"""
payments = [
payment
for collection in (order.get("payment_collections") or [])
for payment in (collection.get("payments") or [])
]
ledger_refunded_total = sum(
refund.get("amount", 0)
for payment in payments
for refund in (payment.get("refunds") or [])
)
order_refunded_total = (order.get("summary") or {}).get("refunded_total", 0)
delta = ledger_refunded_total - order_refunded_total
if delta > EPSILON:
reason = "refund_not_reflected"
needs_sync = True
elif delta < -EPSILON:
reason = "over_refunded_on_order"
needs_sync = True
else:
reason = "in_sync"
needs_sync = False
return {
"orderId": order.get("id"),
"needsSync": needs_sync,
"ledgerRefundedTotal": ledger_refunded_total,
"orderRefundedTotal": order_refunded_total,
"delta": delta,
"reason": reason,
}
const EPSILON = 0.01;
/**
* Pure decision function. No I/O.
*
* @param {{
* id: string,
* summary: { paid_total: number, refunded_total: number,
* transaction_total: number, accounting_total: number },
* payment_collections: Array<{ status: string, payments: Array<{
* amount: number, captured_at: string | null,
* refunds: Array<{ amount: number, created_at: string }>
* }> }>,
* }} order
* @returns {{ orderId: string, needsSync: boolean, ledgerRefundedTotal: number,
* orderRefundedTotal: number, delta: number,
* reason: "refund_not_reflected" | "over_refunded_on_order" | "in_sync" }}
*/
export function decideRefundReconciliation(order) {
const payments = (order.payment_collections || []).flatMap(
(collection) => collection.payments || []
);
const ledgerRefundedTotal = payments.reduce(
(sum, payment) =>
sum + (payment.refunds || []).reduce((s, r) => s + (r.amount || 0), 0),
0
);
const orderRefundedTotal = order.summary ? order.summary.refunded_total || 0 : 0;
const delta = ledgerRefundedTotal - orderRefundedTotal;
let reason;
let needsSync;
if (delta > EPSILON) {
reason = "refund_not_reflected";
needsSync = true;
} else if (delta < -EPSILON) {
reason = "over_refunded_on_order";
needsSync = true;
} else {
reason = "in_sync";
needsSync = false;
}
return {
orderId: order.id,
needsSync,
ledgerRefundedTotal,
orderRefundedTotal,
delta,
reason,
};
}
Resync only the safe direction, by re-driving the refund route
When a payment's refund ledger is ahead of the order, call the same admin route the Refund button uses, POST /admin/payments/{payment_id}/refund, with the delta amount already confirmed in the Payment module's own refunds. That route wraps refundPaymentsWorkflow, which both updates the payment and emits the OrderChange the order needs to recompute its summary. Never call it with a guessed amount, and never call it at all for an order flagged over_refunded_on_order.
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 resync_refund(token, payment_id, delta):
return admin_post(token, f"/admin/payments/{payment_id}/refund", {"amount": delta})
def get_order_refunded_total(token, order_id):
data = admin_get(token, f"/admin/orders/{order_id}", {
"fields": "summary,*payment_collections.payments.refunds",
})
return data["order"]["summary"]["refunded_total"]
async function resyncRefund(paymentId, delta) {
return sdk.admin.payment.refund(paymentId, { amount: delta });
}
async function getOrderRefundedTotal(orderId) {
const { order } = await sdk.admin.order.retrieve(orderId, {
fields: "summary,*payment_collections.payments.refunds",
});
return order.summary.refunded_total;
}
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, delta} tuples it would submit. Read the output, agree with it, then switch it off to let it write. After every real resync, re-fetch the order and confirm summary.refunded_total now equals the ledger total before counting the order as fixed. Run it on a schedule that matches how often refunds happen outside the normal flow, for example once an hour.
Always start with DRY_RUN=true. Only resync orders classified refund_not_reflected, using the exact delta already confirmed on the Payment module side. Never call the refund route for an order classified over_refunded_on_order, and never call it when the provider-side refund was created out of band, such as a manual dashboard refund, since resubmitting against a provider that already refunded risks a duplicate. Flag those 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 resyncs an order in the direction the Payment module's own ledger already confirmed.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Reconcile Medusa refunds that never reached the order's summary.
In Medusa v2, orders and payments are separate modules joined only through
module links. order.summary (paid_total, refunded_total, accounting_total) is
a cached snapshot that only recomputes when a refund runs through
refundPaymentsWorkflow. A refund recorded directly on the Payment module, by a
custom payment provider or a webhook outside the workflow, leaves the ledger
correct but the order stale. This lists orders with payments and refunds
expanded, flags any order where the ledger is ahead of the order, and resyncs
only that direction by calling the same refund route the admin Refund action
uses, with the exact amount already confirmed on the Payment module side.
Orders where the order shows more refunded than the ledger are flagged for
manual review, never auto-repaired.
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("reconcile_refunds")
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,status,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 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 decide_refund_reconciliation(order):
"""Pure decision function. No I/O.
order: {
"id": str,
"summary": {"paid_total": float, "refunded_total": float,
"transaction_total": float, "accounting_total": float},
"payment_collections": [
{"status": str, "payments": [
{"amount": float, "captured_at": str | None,
"refunds": [{"amount": float, "created_at": str}]}
]}
],
}
Returns {"orderId", "needsSync", "ledgerRefundedTotal", "orderRefundedTotal",
"delta", "reason"} where reason is one of
"refund_not_reflected" | "over_refunded_on_order" | "in_sync".
"""
payments = [
payment
for collection in (order.get("payment_collections") or [])
for payment in (collection.get("payments") or [])
]
ledger_refunded_total = sum(
refund.get("amount", 0)
for payment in payments
for refund in (payment.get("refunds") or [])
)
order_refunded_total = (order.get("summary") or {}).get("refunded_total", 0)
delta = ledger_refunded_total - order_refunded_total
if delta > EPSILON:
reason = "refund_not_reflected"
needs_sync = True
elif delta < -EPSILON:
reason = "over_refunded_on_order"
needs_sync = True
else:
reason = "in_sync"
needs_sync = False
return {
"orderId": order.get("id"),
"needsSync": needs_sync,
"ledgerRefundedTotal": ledger_refunded_total,
"orderRefundedTotal": order_refunded_total,
"delta": delta,
"reason": reason,
}
def list_orders_with_payments(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 first_payment_id(order):
for collection in order.get("payment_collections") or []:
for payment in collection.get("payments") or []:
if payment.get("id"):
return payment["id"]
return None
def resync_refund(token, payment_id, delta):
return admin_post(token, f"/admin/payments/{payment_id}/refund", {"amount": delta})
def get_order_refunded_total(token, order_id):
data = admin_get(token, f"/admin/orders/{order_id}", {
"fields": "summary,*payment_collections.payments.refunds",
})
return data["order"]["summary"]["refunded_total"]
def run():
token = get_admin_token()
orders = list_orders_with_payments(token)
synced = 0
flagged = 0
for order in orders:
outcome = decide_refund_reconciliation(order)
if not outcome["needsSync"]:
continue
if outcome["reason"] == "over_refunded_on_order":
log.warning(
"Order %s over-refunded on the order side (ledger=%s order=%s). Flagging for manual review.",
order.get("display_id") or order["id"], outcome["ledgerRefundedTotal"], outcome["orderRefundedTotal"],
)
flagged += 1
continue
payment_id = first_payment_id(order)
if payment_id is None:
log.warning("Order %s has a refund gap but no payment id found. Flagging.", order["id"])
flagged += 1
continue
log.warning(
"Order %s refund not reflected: ledger=%s order=%s delta=%s. %s",
order.get("display_id") or order["id"], outcome["ledgerRefundedTotal"],
outcome["orderRefundedTotal"], outcome["delta"],
"would resync" if DRY_RUN else "resyncing",
)
if not DRY_RUN:
resync_refund(token, payment_id, outcome["delta"])
confirmed_total = get_order_refunded_total(token, order["id"])
if abs(confirmed_total - outcome["ledgerRefundedTotal"]) > EPSILON:
log.warning(
" order %s did not resync as expected: ledger=%s order_now=%s",
order["id"], outcome["ledgerRefundedTotal"], confirmed_total,
)
else:
log.info(" order %s confirmed in sync: refunded_total=%s", order["id"], confirmed_total)
synced += 1
log.info(
"Done. %d order(s) %s, %d order(s) flagged for manual review.",
synced, "to resync" if DRY_RUN else "resynced", flagged,
)
if __name__ == "__main__":
run()
/**
* Reconcile Medusa refunds that never reached the order's summary.
*
* In Medusa v2, orders and payments are separate modules joined only through
* module links. order.summary (paid_total, refunded_total, accounting_total) is
* a cached snapshot that only recomputes when a refund runs through
* refundPaymentsWorkflow. A refund recorded directly on the Payment module, by a
* custom payment provider or a webhook outside the workflow, leaves the ledger
* correct but the order stale. This lists orders with payments and refunds
* expanded, flags any order where the ledger is ahead of the order, and resyncs
* only that direction by calling the same refund route the admin Refund action
* uses, with the exact amount already confirmed on the Payment module side.
* Orders where the order shows more refunded than the ledger are flagged for
* manual review, never auto-repaired.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/refund-not-reflected-on-the-order/
*/
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,status,summary,*payment_collections," +
"*payment_collections.payments,*payment_collections.payments.refunds";
/**
* Pure decision function. No I/O.
*
* @param {{
* id: string,
* summary: { paid_total: number, refunded_total: number,
* transaction_total: number, accounting_total: number },
* payment_collections: Array<{ status: string, payments: Array<{
* amount: number, captured_at: string | null,
* refunds: Array<{ amount: number, created_at: string }>
* }> }>,
* }} order
* @returns {{ orderId: string, needsSync: boolean, ledgerRefundedTotal: number,
* orderRefundedTotal: number, delta: number,
* reason: "refund_not_reflected" | "over_refunded_on_order" | "in_sync" }}
*/
export function decideRefundReconciliation(order) {
const payments = (order.payment_collections || []).flatMap(
(collection) => collection.payments || []
);
const ledgerRefundedTotal = payments.reduce(
(sum, payment) =>
sum + (payment.refunds || []).reduce((s, r) => s + (r.amount || 0), 0),
0
);
const orderRefundedTotal = order.summary ? order.summary.refunded_total || 0 : 0;
const delta = ledgerRefundedTotal - orderRefundedTotal;
let reason;
let needsSync;
if (delta > EPSILON) {
reason = "refund_not_reflected";
needsSync = true;
} else if (delta < -EPSILON) {
reason = "over_refunded_on_order";
needsSync = true;
} else {
reason = "in_sync";
needsSync = false;
}
return {
orderId: order.id,
needsSync,
ledgerRefundedTotal,
orderRefundedTotal,
delta,
reason,
};
}
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 listOrdersWithPayments(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 firstPaymentId(order) {
for (const collection of order.payment_collections || []) {
for (const payment of collection.payments || []) {
if (payment.id) return payment.id;
}
}
return null;
}
async function resyncRefund(token, paymentId, delta) {
return adminPost(token, `/admin/payments/${paymentId}/refund`, { amount: delta });
}
async function getOrderRefundedTotal(token, orderId) {
const data = await adminGet(token, `/admin/orders/${orderId}`, {
fields: "summary,*payment_collections.payments.refunds",
});
return data.order.summary.refunded_total;
}
export async function run() {
const token = await getAdminToken();
const orders = await listOrdersWithPayments(token);
let synced = 0;
let flagged = 0;
for (const order of orders) {
const outcome = decideRefundReconciliation(order);
if (!outcome.needsSync) continue;
if (outcome.reason === "over_refunded_on_order") {
console.warn(
`Order ${order.display_id || order.id} over-refunded on the order side (ledger=${outcome.ledgerRefundedTotal} order=${outcome.orderRefundedTotal}). Flagging for manual review.`
);
flagged++;
continue;
}
const paymentId = firstPaymentId(order);
if (paymentId === null) {
console.warn(`Order ${order.id} has a refund gap but no payment id found. Flagging.`);
flagged++;
continue;
}
console.warn(
`Order ${order.display_id || order.id} refund not reflected: ledger=${outcome.ledgerRefundedTotal} order=${outcome.orderRefundedTotal} delta=${outcome.delta}. ${DRY_RUN ? "would resync" : "resyncing"}`
);
if (!DRY_RUN) {
await resyncRefund(token, paymentId, outcome.delta);
const confirmedTotal = await getOrderRefundedTotal(token, order.id);
if (Math.abs(confirmedTotal - outcome.ledgerRefundedTotal) > EPSILON) {
console.warn(
` order ${order.id} did not resync as expected: ledger=${outcome.ledgerRefundedTotal} order_now=${confirmedTotal}`
);
} else {
console.log(` order ${order.id} confirmed in sync: refunded_total=${confirmedTotal}`);
}
}
synced++;
}
console.log(
`Done. ${synced} order(s) ${DRY_RUN ? "to resync" : "resynced"}, ${flagged} order(s) flagged for manual review.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
decide_refund_reconciliation is the part most worth testing, because it decides which orders are safe to resync and in which direction. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture orders and checks the answer.
from reconcile_refunds import decide_refund_reconciliation
def order(paid_total=100.0, refunded_total=0.0, payments=None):
return {
"id": "order_1",
"summary": {
"paid_total": paid_total,
"refunded_total": refunded_total,
"transaction_total": paid_total,
"accounting_total": paid_total - refunded_total,
},
"payment_collections": [
{"status": "authorized", "payments": payments or []}
],
}
def payment(amount=100.0, refunds=None):
return {"amount": amount, "captured_at": "2026-07-01T00:00:00Z", "refunds": refunds or []}
def test_in_sync_when_ledger_matches_order():
o = order(refunded_total=20.0, payments=[payment(refunds=[{"amount": 20.0, "created_at": "2026-07-02T00:00:00Z"}])])
result = decide_refund_reconciliation(o)
assert result["needsSync"] is False
assert result["reason"] == "in_sync"
assert result["delta"] == 0.0
def test_refund_not_reflected_when_ledger_ahead():
o = order(refunded_total=0.0, payments=[payment(refunds=[{"amount": 25.0, "created_at": "2026-07-02T00:00:00Z"}])])
result = decide_refund_reconciliation(o)
assert result["needsSync"] is True
assert result["reason"] == "refund_not_reflected"
assert result["ledgerRefundedTotal"] == 25.0
assert result["delta"] == 25.0
def test_over_refunded_on_order_when_order_ahead():
o = order(refunded_total=30.0, payments=[payment(refunds=[{"amount": 10.0, "created_at": "2026-07-02T00:00:00Z"}])])
result = decide_refund_reconciliation(o)
assert result["needsSync"] is True
assert result["reason"] == "over_refunded_on_order"
assert result["delta"] == -20.0
def test_sums_refunds_across_multiple_payments():
o = order(
refunded_total=15.0,
payments=[
payment(amount=50.0, refunds=[{"amount": 10.0, "created_at": "2026-07-02T00:00:00Z"}]),
payment(amount=50.0, refunds=[{"amount": 5.0, "created_at": "2026-07-03T00:00:00Z"}]),
],
)
result = decide_refund_reconciliation(o)
assert result["ledgerRefundedTotal"] == 15.0
assert result["needsSync"] is False
def test_within_epsilon_counts_as_in_sync():
o = order(refunded_total=19.995, payments=[payment(refunds=[{"amount": 20.0, "created_at": "2026-07-02T00:00:00Z"}])])
result = decide_refund_reconciliation(o)
assert result["needsSync"] is False
assert result["reason"] == "in_sync"
def test_no_payment_collections_is_in_sync_when_order_shows_zero():
o = {"id": "order_2", "summary": {"paid_total": 0, "refunded_total": 0, "transaction_total": 0, "accounting_total": 0}, "payment_collections": []}
result = decide_refund_reconciliation(o)
assert result["needsSync"] is False
assert result["ledgerRefundedTotal"] == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRefundReconciliation } from "./reconcile-refunds.js";
function order({ paidTotal = 100.0, refundedTotal = 0.0, payments = [] } = {}) {
return {
id: "order_1",
summary: {
paid_total: paidTotal,
refunded_total: refundedTotal,
transaction_total: paidTotal,
accounting_total: paidTotal - refundedTotal,
},
payment_collections: [{ status: "authorized", payments }],
};
}
function payment({ amount = 100.0, refunds = [] } = {}) {
return { amount, captured_at: "2026-07-01T00:00:00Z", refunds };
}
test("in sync when ledger matches order", () => {
const o = order({
refundedTotal: 20.0,
payments: [payment({ refunds: [{ amount: 20.0, created_at: "2026-07-02T00:00:00Z" }] })],
});
const result = decideRefundReconciliation(o);
assert.equal(result.needsSync, false);
assert.equal(result.reason, "in_sync");
assert.equal(result.delta, 0.0);
});
test("refund not reflected when ledger ahead", () => {
const o = order({
refundedTotal: 0.0,
payments: [payment({ refunds: [{ amount: 25.0, created_at: "2026-07-02T00:00:00Z" }] })],
});
const result = decideRefundReconciliation(o);
assert.equal(result.needsSync, true);
assert.equal(result.reason, "refund_not_reflected");
assert.equal(result.ledgerRefundedTotal, 25.0);
assert.equal(result.delta, 25.0);
});
test("over refunded on order when order ahead", () => {
const o = order({
refundedTotal: 30.0,
payments: [payment({ refunds: [{ amount: 10.0, created_at: "2026-07-02T00:00:00Z" }] })],
});
const result = decideRefundReconciliation(o);
assert.equal(result.needsSync, true);
assert.equal(result.reason, "over_refunded_on_order");
assert.equal(result.delta, -20.0);
});
test("sums refunds across multiple payments", () => {
const o = order({
refundedTotal: 15.0,
payments: [
payment({ amount: 50.0, refunds: [{ amount: 10.0, created_at: "2026-07-02T00:00:00Z" }] }),
payment({ amount: 50.0, refunds: [{ amount: 5.0, created_at: "2026-07-03T00:00:00Z" }] }),
],
});
const result = decideRefundReconciliation(o);
assert.equal(result.ledgerRefundedTotal, 15.0);
assert.equal(result.needsSync, false);
});
test("within epsilon counts as in sync", () => {
const o = order({
refundedTotal: 19.995,
payments: [payment({ refunds: [{ amount: 20.0, created_at: "2026-07-02T00:00:00Z" }] })],
});
const result = decideRefundReconciliation(o);
assert.equal(result.needsSync, false);
assert.equal(result.reason, "in_sync");
});
test("no payment collections is in sync when order shows zero", () => {
const o = {
id: "order_2",
summary: { paid_total: 0, refunded_total: 0, transaction_total: 0, accounting_total: 0 },
payment_collections: [],
};
const result = decideRefundReconciliation(o);
assert.equal(result.needsSync, false);
assert.equal(result.ledgerRefundedTotal, 0);
});
Case studies
The regional gateway that refunded quietly
A store integrated a regional payment gateway through a custom Medusa payment provider. The provider's own refund() implementation wrote a Refund record straight to the Payment module whenever support triggered a refund from the gateway's own dashboard, never touching refundPaymentsWorkflow. Customers received their money. The order in Medusa kept showing the full amount as paid, with support unable to explain the discrepancy when customers asked.
Running the reconciliation script in dry run surfaced every one of those orders as refund_not_reflected, with the delta matching exactly what the gateway's dashboard reported. Resyncing through the existing refund route brought summary.refunded_total back in line, and finance stopped seeing phantom paid totals in their reports.
A provider webhook beat the workflow to the punch
A payment provider's webhook fired the moment a refund was approved on its side, updating the Payment module directly before the admin action that triggered the refund had finished its own workflow run. The result was a handful of orders each month where the ledger showed a refund that the order's summary had not yet caught up to.
Because the script only acts on the safe direction, ledger ahead of order, it caught these automatically on its hourly run, resynced them with the confirmed delta, and re-fetched each order to confirm the totals matched before moving on. No manual dashboard digging was needed to explain the gap anymore.
After this runs on a schedule, the Payment module's refund ledger and the order's own summary agree, and every resync is confirmed by re-fetching the order rather than assumed. Refunds that happened through a custom provider or a webhook outside the normal workflow get caught and reconciled automatically, while anything where the order shows more refunded than the ledger, or where the refund happened entirely outside Medusa, is flagged for a human instead of guessed at. No order shows a paid total that customers, support, or finance cannot trust.
FAQ
Why does a Medusa order still show the old paid amount after a refund?
In Medusa v2, orders and payments live in separate modules joined only by module links. The order's summary, including paid_total and refunded_total, is a cached snapshot that only recomputes when a refund runs through the official refundPaymentsWorkflow. If a refund is recorded directly on the Payment module, for example by a custom payment provider or a webhook outside the workflow, no OrderChange is created and the order never recalculates.
Is it safe to fix a desynced refund with a script?
Yes, when the script never edits order.summary directly and instead re-drives the existing refund action, POST /admin/payments/{id}/refund, using only the amount already confirmed in the Payment module's refunds ledger. If the ledger shows more refunded than the order does, that is the safe direction to resync. If the order shows more refunded than the ledger, that is flagged for manual review instead of auto-repaired.
How do you detect a refund that never reached the order?
List orders with payment_collections, payments, and refunds expanded, sum every payment's refunds.amount into a ledger total, and compare it against order.summary.refunded_total. A mismatch means the Payment module and the order disagree. Cross-checking /admin/orders/{id}/changes confirms whether a refund-type OrderChange was ever created for that payment.
Related field notes
Citations
On the problem:
- Bug: Manual Payment Provider: Issues with Refund and Cancellation Workflows. Medusa GitHub Issue #14214. github.com/medusajs/medusa/issues/14214
- 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
- Bug: Refund not deleted if payment provider throws error. Medusa GitHub Issue #12192. github.com/medusajs/medusa/issues/12192
On the solution:
- Medusa Documentation: refundPayment, Payment Module Reference. docs.medusajs.com/resources/references/payment/refundPayment
- Medusa Documentation: refundPaymentsWorkflow, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow
- Medusa Admin User Guide: Manage Order Payments. docs.medusajs.com/user-guide/orders/payments
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 square up your refunds?
If this saved you a support escalation or a finance report that would not reconcile, 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