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.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

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.

Refund recorded custom provider or webhook Payment module ledger updates fine no OrderChange emitted Order module never told to recompute order.summary stale refunded_total unchanged old paid_total still shown
The refund is real and the Payment module knows about it. But without an OrderChange, the order's cached summary never recomputes, so the storefront and admin keep showing the pre-refund totals.

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:

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.

The key insight

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.

List orders payments, refunds, summary Sum ledger refunds across payment_collections Pure decision fn decideRefundReconciliation Ledger ahead of order total? yes, dry run off no, in sync or flag In sync or flagged left for a human POST refund route summary recomputes
Only orders where the Payment module's refund ledger is ahead of the order's own total are resynced, and only by re-driving the existing refund route with the amount the ledger already confirmed.

Build it step by step

1

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.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
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
2

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.

step2.py
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
step2.js
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;
  }
}
3

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.

step3.py
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
    )
step3.js
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"
  );
}
4

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.

decide.py
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,
    }
decide.js
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,
  };
}
5

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.

step5.py
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"]
step5.js
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;
}
6

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.

Run it safe

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_refunds.py
"""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-refunds.js
/**
 * 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.

test_refund_reconciliation.py
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
reconcile-refunds.test.js
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

Custom payment provider

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.

Webhook race

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.

What good looks like

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:

  1. Bug: Manual Payment Provider: Issues with Refund and Cancellation Workflows. Medusa GitHub Issue #14214. github.com/medusajs/medusa/issues/14214
  2. 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
  3. Bug: Refund not deleted if payment provider throws error. Medusa GitHub Issue #12192. github.com/medusajs/medusa/issues/12192

On the solution:

  1. Medusa Documentation: refundPayment, Payment Module Reference. docs.medusajs.com/resources/references/payment/refundPayment
  2. Medusa Documentation: refundPaymentsWorkflow, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes