Skip to content

Reconciler Payments & Refunds

New payment collection ignores amounts already captured

A customer already paid for their order. Then a price edit bumps the total, Medusa opens a new payment collection to cover the difference, and that new collection is sized for the entire new total, not the small amount actually left owing. The customer either gets asked to pay twice or a staff member sees a scary number and assumes nothing was ever collected. Here is why the order-edit path builds the new collection off the wrong number, and a script that finds every order where this happened and can reconcile it safely.

Python and Node.js Medusa Admin API Flag by default, guarded repair
Paying online with a card
Photo by rupixen on Unsplash
The short answer

When an order edit or order-change workflow raises the price and Medusa needs a new payment collection, createOrderPaymentCollectionWorkflow builds the collection's amount from the order's current total, not from order.summary.pending_difference, the field that already nets out paid_total and refunded_total. The workflow never queries the order's existing payment_collections or transactions to subtract what is already captured, so a partially paid order that gets a price bump ends up with a new collection demanding the full new total instead of just the outstanding balance. This is a confirmed upstream pattern, see medusajs/medusa issue #11591 and related reports below. Pull each order with its summary, payment_collections, and transactions expanded, sum the open collections and compare that against pending_difference, and flag any order where a prior capture exists alongside an over-sized open collection. Full code and a guarded repair path are below.

The problem in plain words

An order's summary in Medusa v2 already knows how to answer "what is still owed." pending_difference is computed as the current order total, minus what has been paid, minus what has been refunded. It is exactly the number a new payment collection should be built from whenever the price changes after some money has already come in.

But the workflow that actually creates that new collection, createOrderPaymentCollectionWorkflow, does not reach for pending_difference. It reads the order's current total directly and uses that as the collection amount. On a brand new order with no prior payment, current total and pending difference are the same number, so nothing looks wrong. The gap only shows up once an order has already been partly paid and then gets edited, because the workflow never looks at the order's existing payment_collections or transactions to net out the capture that already happened.

Order captured paid_total = 100 Order edit runs new total = 120 createOrderPaymentCollectionWorkflow reads current_order_total, not pending_difference New collection = 120 should have been 20 Prior capture of 100 was never netted out customer is billed for the whole new total again Open collection: 120 actual pending_difference: 20
The order was already captured for 100. After an edit raises the total to 120, the workflow builds the new collection from the new total instead of the 20 that is actually still owed.

Why it happens

This traces back to how the order-edit-triggered payment collection creation is wired, not to anything wrong with the summary itself:

This is a common source of confusion because nothing errors and the new collection is technically valid, it is just sized wrong. Staff see a large "amount due" on an order that was mostly paid already, and either chase the customer for money that is not owed, or a script blindly charges the new collection in full and takes a duplicate payment. See the citations at the end for the exact issues and docs.

The key insight

Never trust a new open payment collection's amount at face value on an order that has any prior capture. Always compare the sum of open collections against order.summary.pending_difference first. If the open total is bigger than what pending_difference says is actually owed, and a capture already happened, that is the bug fingerprint, not a legitimate balance due. Canceling and recreating a live collection is not something to do automatically, since it can orphan a customer's in-flight payment session, so repair stays behind an explicit flag.

The fix, as a flow

We list candidate orders that have both recent edits and payments, compute what is actually owed from each order's own summary, and compare it against the open collections Medusa created. Anything that does not match, with a prior capture in the picture, gets flagged. Only under an explicit operator flag, and only when the picture is unambiguous, do we cancel the stale collection and create a correctly sized one.

List orders summary, collections, transactions Compute pending_difference vs open total Over-sized and prior capture? yes, flag Flag for review DRY_RUN=false and one open collection: recreate no, matches Move on collection is sized correctly
Flagging is always the default. Only with an explicit operator flag, and only when exactly one open collection exists, does the script cancel the stale collection and create one sized to the real pending_difference.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend with an admin account that can read and write orders and payment collections. Exchange the email and password for a JWT once and reuse it. DRY_RUN defaults to true, since the repair path cancels and recreates a live payment collection.

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 cancel and recreate
setup (shell)
// 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 cancel and recreate
2

Authenticate against the Admin API

Exchange the admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every following call.

step2.py
import os, requests

BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]
step2.js
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;

async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}
3

List orders with summary, payment collections, and transactions expanded

Ask for summary, payment_collections, the payments on each collection, and transactions. This gives us pending_difference, every open collection's amount and status, and enough evidence of prior captures to fingerprint the bug. Page through with offset and limit so the job covers the whole store.

step3.py
ORDER_FIELDS = (
    "id,display_id,status,*summary,*payment_collections,"
    "*payment_collections.payments,*transactions"
)

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_collections(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
step3.js
const ORDER_FIELDS =
  "id,display_id,status,*summary,*payment_collections," +
  "*payment_collections.payments,*transactions";

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
  return res.json();
}

async function listOrdersWithCollections(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;
  }
}
4

Decide, with one pure function

Keep the whole decision in a function with no network calls. It takes the order's summary numbers and its open payment collections, sums the open collections, and compares that against pending_difference. It only recommends a repair when exactly one open collection exists and a prior capture happened, since more than one open collection is ambiguous and needs a human. Anything with nothing owed is left alone.

decide.py
OPEN_STATUSES = {"not_paid", "awaiting"}
DEFAULT_EPSILON = 0.01

def reconcile_outstanding_amount(summary, open_collections, epsilon=DEFAULT_EPSILON):
    pending_difference = (
        summary["currentOrderTotal"] - summary["paidTotal"] - summary["refundedTotal"]
    )

    candidates = [c for c in open_collections if c["status"] in OPEN_STATUSES]
    open_total = sum(c["amount"] for c in candidates)

    if pending_difference <= epsilon:
        return {"action": "none", "correctAmount": max(pending_difference, 0), "staleCollectionIds": []}

    over_sized = (open_total - pending_difference) > epsilon
    prior_capture = summary["paidTotal"] > 0

    if not (over_sized and prior_capture):
        return {"action": "none", "correctAmount": max(pending_difference, 0), "staleCollectionIds": []}

    if len(candidates) == 1:
        return {
            "action": "recreate",
            "correctAmount": max(pending_difference, 0),
            "staleCollectionIds": [candidates[0]["id"]],
        }

    return {
        "action": "flag",
        "correctAmount": max(pending_difference, 0),
        "staleCollectionIds": [c["id"] for c in candidates],
    }
decide.js
const OPEN_STATUSES = new Set(["not_paid", "awaiting"]);
const DEFAULT_EPSILON = 0.01;

export function reconcileOutstandingAmount(summary, openCollections, epsilon = DEFAULT_EPSILON) {
  const pendingDifference = summary.currentOrderTotal - summary.paidTotal - summary.refundedTotal;

  const candidates = openCollections.filter((c) => OPEN_STATUSES.has(c.status));
  const openTotal = candidates.reduce((sum, c) => sum + c.amount, 0);

  if (pendingDifference <= epsilon) {
    return { action: "none", correctAmount: Math.max(pendingDifference, 0), staleCollectionIds: [] };
  }

  const overSized = openTotal - pendingDifference > epsilon;
  const priorCapture = summary.paidTotal > 0;

  if (!(overSized && priorCapture)) {
    return { action: "none", correctAmount: Math.max(pendingDifference, 0), staleCollectionIds: [] };
  }

  if (candidates.length === 1) {
    return {
      action: "recreate",
      correctAmount: Math.max(pendingDifference, 0),
      staleCollectionIds: [candidates[0].id],
    };
  }

  return {
    action: "flag",
    correctAmount: Math.max(pendingDifference, 0),
    staleCollectionIds: candidates.map((c) => c.id),
  };
}
5

Shape the raw order into what the decision function needs

The Admin API response nests the numbers the decision needs inside summary and inside each payment_collections entry. Flatten that into the plain summary and openCollections shape the pure function expects. Nothing here calls the network, it only reshapes data already in hand.

shape.py
def to_decision_input(raw_order):
    summary = raw_order.get("summary") or {}
    collections = raw_order.get("payment_collections") or []

    decision_summary = {
        "currentOrderTotal": summary.get("current_order_total", 0),
        "paidTotal": summary.get("paid_total", 0),
        "refundedTotal": summary.get("refunded_total", 0),
        "transactionTotal": summary.get("transaction_total", 0),
    }
    open_collections = [
        {"id": c.get("id"), "amount": c.get("amount", 0), "status": c.get("status")}
        for c in collections
    ]

    return {
        "id": raw_order.get("id"),
        "displayId": raw_order.get("display_id"),
        "summary": decision_summary,
        "openCollections": open_collections,
    }
shape.js
function toDecisionInput(rawOrder) {
  const summary = rawOrder.summary || {};
  const collections = rawOrder.payment_collections || [];

  const decisionSummary = {
    currentOrderTotal: summary.current_order_total || 0,
    paidTotal: summary.paid_total || 0,
    refundedTotal: summary.refunded_total || 0,
    transactionTotal: summary.transaction_total || 0,
  };
  const openCollections = collections.map((c) => ({
    id: c.id,
    amount: c.amount || 0,
    status: c.status,
  }));

  return {
    id: rawOrder.id,
    displayId: rawOrder.display_id,
    summary: decisionSummary,
    openCollections,
  };
}
6

Wire it together with a dry run guard

The loop lists orders, shapes each one, runs the decision, and logs the {order_id, old_amount, reconciled_amount, prior_captured_total} triple for anything not clean. When the action is recreate and DRY_RUN=false, it re-fetches pending_difference fresh immediately before writing, to avoid racing a further edit, then cancels the stale collection and creates one sized to that fresh amount. Anything ambiguous stays flag only, for a human.

apply.py
def cancel_collection(token, collection_id):
    r = requests.post(
        f"{BACKEND_URL}/admin/payment-collections/{collection_id}/mark-as-canceled",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def create_collection(token, order_id, amount, currency_code):
    r = requests.post(
        f"{BACKEND_URL}/admin/payment-collections",
        headers={"Authorization": f"Bearer {token}"},
        json={"order_id": order_id, "amount": amount, "currency_code": currency_code},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def refetch_pending_difference(token, order_id):
    data = admin_get(token, f"/admin/orders/{order_id}", {"fields": "id,*summary"})
    return data["order"]["summary"].get("pending_difference", 0)
apply.js
async function cancelCollection(token, collectionId) {
  const res = await fetch(
    `${BACKEND_URL}/admin/payment-collections/${collectionId}/mark-as-canceled`,
    { method: "POST", headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa cancel ${res.status}`);
  return res.json();
}

async function createCollection(token, orderId, amount, currencyCode) {
  const res = await fetch(`${BACKEND_URL}/admin/payment-collections`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ order_id: orderId, amount, currency_code: currencyCode }),
  });
  if (!res.ok) throw new Error(`Medusa create-collection ${res.status}`);
  return res.json();
}

async function refetchPendingDifference(token, orderId) {
  const data = await adminGet(token, `/admin/orders/${orderId}`, { fields: "id,*summary" });
  return data.order.summary.pending_difference || 0;
}
Run it safe

Leave DRY_RUN=true until you have read the flagged list and trust it. The repair path cancels a live payment collection, which can orphan an in-flight customer payment session, so it only ever runs on an order with exactly one open collection and a prior capture, and it always re-reads pending_difference immediately before writing. Orders with more than one open collection, or with nothing owed, are always left for a human.

The full code

Here is the complete script in one file for each language. It authenticates, lists every order with its summary and payment collections, runs the pure reconciliation decision, logs the full before-and-after triple, and only under an explicit flag cancels and recreates the one stale collection on an unambiguous order.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
reconcile_new_collection.py
"""Flag, and optionally repair, Medusa v2 orders where an order-edit-triggered
payment collection was sized off the order's current total instead of
order.summary.pending_difference. createOrderPaymentCollectionWorkflow does not
net out prior captures recorded in payment_collections and transactions, so a
partially paid order that gets a price bump ends up with a new collection
demanding the full new total instead of just what remains outstanding
(see medusajs/medusa#11591, #10686, #13068). This script is report-only by
default. Under an explicit DRY_RUN=false, it repairs only unambiguous cases:
exactly one open collection, a prior capture, and something genuinely owed.
Safe to run again and again.

Guide: https://www.allanninal.dev/medusa/new-collection-ignores-prior-capture/
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_new_collection")

BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
ADMIN_PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

OPEN_STATUSES = {"not_paid", "awaiting"}
DEFAULT_EPSILON = 0.01

ORDER_FIELDS = (
    "id,display_id,status,*summary,*payment_collections,"
    "*payment_collections.payments,*transactions"
)


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 reconcile_outstanding_amount(summary, open_collections, epsilon=DEFAULT_EPSILON):
    """Pure decision function. No I/O.

    summary: {currentOrderTotal, paidTotal, refundedTotal, transactionTotal}
    open_collections: [{id, amount, status}]

    Returns {"action": "none" | "flag" | "recreate", "correctAmount", "staleCollectionIds"}.
    """
    pending_difference = (
        summary["currentOrderTotal"] - summary["paidTotal"] - summary["refundedTotal"]
    )

    candidates = [c for c in open_collections if c["status"] in OPEN_STATUSES]
    open_total = sum(c["amount"] for c in candidates)

    if pending_difference <= epsilon:
        return {"action": "none", "correctAmount": max(pending_difference, 0), "staleCollectionIds": []}

    over_sized = (open_total - pending_difference) > epsilon
    prior_capture = summary["paidTotal"] > 0

    if not (over_sized and prior_capture):
        return {"action": "none", "correctAmount": max(pending_difference, 0), "staleCollectionIds": []}

    if len(candidates) == 1:
        return {
            "action": "recreate",
            "correctAmount": max(pending_difference, 0),
            "staleCollectionIds": [candidates[0]["id"]],
        }

    return {
        "action": "flag",
        "correctAmount": max(pending_difference, 0),
        "staleCollectionIds": [c["id"] for c in candidates],
    }


def to_decision_input(raw_order):
    summary = raw_order.get("summary") or {}
    collections = raw_order.get("payment_collections") or []

    decision_summary = {
        "currentOrderTotal": summary.get("current_order_total", 0),
        "paidTotal": summary.get("paid_total", 0),
        "refundedTotal": summary.get("refunded_total", 0),
        "transactionTotal": summary.get("transaction_total", 0),
    }
    open_collections = [
        {"id": c.get("id"), "amount": c.get("amount", 0), "status": c.get("status")}
        for c in collections
    ]

    return {
        "id": raw_order.get("id"),
        "displayId": raw_order.get("display_id"),
        "currencyCode": raw_order.get("currency_code"),
        "summary": decision_summary,
        "openCollections": open_collections,
    }


def list_orders_with_collections(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_pending_difference(token, order_id):
    data = admin_get(token, f"/admin/orders/{order_id}", {"fields": "id,*summary"})
    return data["order"]["summary"].get("pending_difference", 0)


def cancel_collection(token, collection_id):
    r = requests.post(
        f"{BACKEND_URL}/admin/payment-collections/{collection_id}/mark-as-canceled",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def create_collection(token, order_id, amount, currency_code):
    r = requests.post(
        f"{BACKEND_URL}/admin/payment-collections",
        headers={"Authorization": f"Bearer {token}"},
        json={"order_id": order_id, "amount": amount, "currency_code": currency_code},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_admin_token()
    raw_orders = list_orders_with_collections(token)

    flagged = 0
    repaired = 0
    for raw_order in raw_orders:
        decision_input = to_decision_input(raw_order)
        outcome = reconcile_outstanding_amount(
            decision_input["summary"], decision_input["openCollections"]
        )
        if outcome["action"] == "none":
            continue

        old_amount = sum(
            c["amount"] for c in decision_input["openCollections"] if c["status"] in OPEN_STATUSES
        )
        log.warning(
            "Order %s action=%s old_amount=%s reconciled_amount=%s prior_captured_total=%s "
            "stale_collection_ids=%s",
            decision_input["displayId"] or decision_input["id"],
            outcome["action"], old_amount, outcome["correctAmount"],
            decision_input["summary"]["paidTotal"], outcome["staleCollectionIds"],
        )
        flagged += 1

        if outcome["action"] == "recreate" and not DRY_RUN:
            fresh_amount = refetch_pending_difference(token, decision_input["id"])
            cancel_collection(token, outcome["staleCollectionIds"][0])
            create_collection(
                token, decision_input["id"], fresh_amount, decision_input["currencyCode"]
            )
            repaired += 1

    log.info(
        "Done. %d order(s) flagged, %d repaired. %s",
        flagged, repaired,
        "Dry run, no writes made." if DRY_RUN else "Repairs applied where unambiguous.",
    )


if __name__ == "__main__":
    run()
reconcile-new-collection.js
/**
 * Flag, and optionally repair, Medusa v2 orders where an order-edit-triggered
 * payment collection was sized off the order's current total instead of
 * order.summary.pending_difference. createOrderPaymentCollectionWorkflow does
 * not net out prior captures recorded in payment_collections and
 * transactions, so a partially paid order that gets a price bump ends up with
 * a new collection demanding the full new total instead of just what remains
 * outstanding (see medusajs/medusa#11591, #10686, #13068). This script is
 * report-only by default. Under an explicit DRY_RUN=false, it repairs only
 * unambiguous cases: exactly one open collection, a prior capture, and
 * something genuinely owed. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/new-collection-ignores-prior-capture/
 */
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 OPEN_STATUSES = new Set(["not_paid", "awaiting"]);
const DEFAULT_EPSILON = 0.01;

const ORDER_FIELDS =
  "id,display_id,status,*summary,*payment_collections," +
  "*payment_collections.payments,*transactions";

export function reconcileOutstandingAmount(summary, openCollections, epsilon = DEFAULT_EPSILON) {
  // Pure: no I/O. summary = {currentOrderTotal, paidTotal, refundedTotal,
  // transactionTotal}, openCollections = [{id, amount, status}].
  const pendingDifference = summary.currentOrderTotal - summary.paidTotal - summary.refundedTotal;

  const candidates = openCollections.filter((c) => OPEN_STATUSES.has(c.status));
  const openTotal = candidates.reduce((sum, c) => sum + c.amount, 0);

  if (pendingDifference <= epsilon) {
    return { action: "none", correctAmount: Math.max(pendingDifference, 0), staleCollectionIds: [] };
  }

  const overSized = openTotal - pendingDifference > epsilon;
  const priorCapture = summary.paidTotal > 0;

  if (!(overSized && priorCapture)) {
    return { action: "none", correctAmount: Math.max(pendingDifference, 0), staleCollectionIds: [] };
  }

  if (candidates.length === 1) {
    return {
      action: "recreate",
      correctAmount: Math.max(pendingDifference, 0),
      staleCollectionIds: [candidates[0].id],
    };
  }

  return {
    action: "flag",
    correctAmount: Math.max(pendingDifference, 0),
    staleCollectionIds: candidates.map((c) => c.id),
  };
}

function toDecisionInput(rawOrder) {
  const summary = rawOrder.summary || {};
  const collections = rawOrder.payment_collections || [];

  const decisionSummary = {
    currentOrderTotal: summary.current_order_total || 0,
    paidTotal: summary.paid_total || 0,
    refundedTotal: summary.refunded_total || 0,
    transactionTotal: summary.transaction_total || 0,
  };
  const openCollections = collections.map((c) => ({
    id: c.id,
    amount: c.amount || 0,
    status: c.status,
  }));

  return {
    id: rawOrder.id,
    displayId: rawOrder.display_id,
    currencyCode: rawOrder.currency_code,
    summary: decisionSummary,
    openCollections,
  };
}

async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
  return res.json();
}

async function listOrdersWithCollections(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 refetchPendingDifference(token, orderId) {
  const data = await adminGet(token, `/admin/orders/${orderId}`, { fields: "id,*summary" });
  return data.order.summary.pending_difference || 0;
}

async function cancelCollection(token, collectionId) {
  const res = await fetch(
    `${BACKEND_URL}/admin/payment-collections/${collectionId}/mark-as-canceled`,
    { method: "POST", headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa cancel ${res.status}`);
  return res.json();
}

async function createCollection(token, orderId, amount, currencyCode) {
  const res = await fetch(`${BACKEND_URL}/admin/payment-collections`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ order_id: orderId, amount, currency_code: currencyCode }),
  });
  if (!res.ok) throw new Error(`Medusa create-collection ${res.status}`);
  return res.json();
}

export async function run() {
  const token = await getAdminToken();
  const rawOrders = await listOrdersWithCollections(token);

  let flagged = 0;
  let repaired = 0;
  for (const rawOrder of rawOrders) {
    const decisionInput = toDecisionInput(rawOrder);
    const outcome = reconcileOutstandingAmount(decisionInput.summary, decisionInput.openCollections);
    if (outcome.action === "none") continue;

    const oldAmount = decisionInput.openCollections
      .filter((c) => OPEN_STATUSES.has(c.status))
      .reduce((sum, c) => sum + c.amount, 0);
    console.warn(
      `Order ${decisionInput.displayId || decisionInput.id} action=${outcome.action} ` +
        `old_amount=${oldAmount} reconciled_amount=${outcome.correctAmount} ` +
        `prior_captured_total=${decisionInput.summary.paidTotal} ` +
        `stale_collection_ids=${JSON.stringify(outcome.staleCollectionIds)}`
    );
    flagged++;

    if (outcome.action === "recreate" && !DRY_RUN) {
      const freshAmount = await refetchPendingDifference(token, decisionInput.id);
      await cancelCollection(token, outcome.staleCollectionIds[0]);
      await createCollection(token, decisionInput.id, freshAmount, decisionInput.currencyCode);
      repaired++;
    }
  }

  console.log(
    `Done. ${flagged} order(s) flagged, ${repaired} repaired. ` +
      `${DRY_RUN ? "Dry run, no writes made." : "Repairs applied where unambiguous."}`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The part worth testing is reconcile_outstanding_amount, since it decides which orders get flagged versus repaired. It is pure, plain numbers and arrays in, a decision object out, so the tests need no network and no Medusa instance.

test_reconcile_collection.py
from reconcile_new_collection import reconcile_outstanding_amount


def summary(**over):
    base = {"currentOrderTotal": 120.0, "paidTotal": 100.0, "refundedTotal": 0.0, "transactionTotal": 100.0}
    base.update(over)
    return base


def test_none_when_nothing_owed():
    result = reconcile_outstanding_amount(summary(currentOrderTotal=100.0), [])
    assert result["action"] == "none"


def test_none_when_open_collection_matches_pending_difference():
    # pending_difference = 120 - 100 - 0 = 20, open collection is 20: correct.
    collections = [{"id": "paycol_1", "amount": 20.0, "status": "not_paid"}]
    result = reconcile_outstanding_amount(summary(), collections)
    assert result["action"] == "none"


def test_recreate_when_single_open_collection_sized_off_full_total():
    # pending_difference = 20, but the open collection was created for the full new total 120.
    collections = [{"id": "paycol_1", "amount": 120.0, "status": "not_paid"}]
    result = reconcile_outstanding_amount(summary(), collections)
    assert result["action"] == "recreate"
    assert result["correctAmount"] == 20.0
    assert result["staleCollectionIds"] == ["paycol_1"]


def test_flag_when_multiple_open_collections_are_ambiguous():
    collections = [
        {"id": "paycol_1", "amount": 70.0, "status": "not_paid"},
        {"id": "paycol_2", "amount": 60.0, "status": "awaiting"},
    ]
    result = reconcile_outstanding_amount(summary(), collections)
    assert result["action"] == "flag"
    assert set(result["staleCollectionIds"]) == {"paycol_1", "paycol_2"}


def test_none_when_no_prior_capture_even_if_over_sized_looking():
    # A fresh order, nothing paid yet: current total and pending difference are the same,
    # so a collection at the full total is correct, not a bug.
    collections = [{"id": "paycol_1", "amount": 120.0, "status": "not_paid"}]
    result = reconcile_outstanding_amount(
        summary(currentOrderTotal=120.0, paidTotal=0.0), collections
    )
    assert result["action"] == "none"


def test_canceled_collections_are_ignored_in_the_open_total():
    collections = [
        {"id": "paycol_1", "amount": 120.0, "status": "canceled"},
        {"id": "paycol_2", "amount": 20.0, "status": "not_paid"},
    ]
    result = reconcile_outstanding_amount(summary(), collections)
    assert result["action"] == "none"


def test_rounding_epsilon_does_not_false_positive():
    collections = [{"id": "paycol_1", "amount": 20.004, "status": "not_paid"}]
    result = reconcile_outstanding_amount(summary(), collections)
    assert result["action"] == "none"
reconcile.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileOutstandingAmount } from "./reconcile-new-collection.js";

const summary = (over = {}) => ({
  currentOrderTotal: 120.0,
  paidTotal: 100.0,
  refundedTotal: 0.0,
  transactionTotal: 100.0,
  ...over,
});

test("none when nothing owed", () => {
  const result = reconcileOutstandingAmount(summary({ currentOrderTotal: 100.0 }), []);
  assert.equal(result.action, "none");
});

test("none when open collection matches pending difference", () => {
  const collections = [{ id: "paycol_1", amount: 20.0, status: "not_paid" }];
  const result = reconcileOutstandingAmount(summary(), collections);
  assert.equal(result.action, "none");
});

test("recreate when single open collection sized off full total", () => {
  const collections = [{ id: "paycol_1", amount: 120.0, status: "not_paid" }];
  const result = reconcileOutstandingAmount(summary(), collections);
  assert.equal(result.action, "recreate");
  assert.equal(result.correctAmount, 20.0);
  assert.deepEqual(result.staleCollectionIds, ["paycol_1"]);
});

test("flag when multiple open collections are ambiguous", () => {
  const collections = [
    { id: "paycol_1", amount: 70.0, status: "not_paid" },
    { id: "paycol_2", amount: 60.0, status: "awaiting" },
  ];
  const result = reconcileOutstandingAmount(summary(), collections);
  assert.equal(result.action, "flag");
  assert.deepEqual(new Set(result.staleCollectionIds), new Set(["paycol_1", "paycol_2"]));
});

test("none when no prior capture even if over-sized looking", () => {
  const collections = [{ id: "paycol_1", amount: 120.0, status: "not_paid" }];
  const result = reconcileOutstandingAmount(
    summary({ currentOrderTotal: 120.0, paidTotal: 0.0 }),
    collections
  );
  assert.equal(result.action, "none");
});

test("canceled collections are ignored in the open total", () => {
  const collections = [
    { id: "paycol_1", amount: 120.0, status: "canceled" },
    { id: "paycol_2", amount: 20.0, status: "not_paid" },
  ];
  const result = reconcileOutstandingAmount(summary(), collections);
  assert.equal(result.action, "none");
});

test("rounding epsilon does not false positive", () => {
  const collections = [{ id: "paycol_1", amount: 20.004, status: "not_paid" }];
  const result = reconcileOutstandingAmount(summary(), collections);
  assert.equal(result.action, "none");
});

Case studies

Order edit

The support agent who added a rush shipping fee

A customer had already paid in full for their order. Support added a rush shipping fee through an order edit to get it out the door faster, and Medusa opened a new payment collection to cover the increase. The collection showed the entire new total, not the small shipping fee difference, and a different agent nearly asked the customer to pay for the whole order again.

Running the reconciliation script against the store's orders flagged it right away: one open collection sized at the full new total, a prior capture already on file, and a pending_difference that was only the shipping fee. The team fixed the one collection by hand before anyone charged the customer twice, and later turned on the guarded auto-repair for the same pattern going forward.

Wholesale

The B2B account with a renegotiated line item

A wholesale buyer had already paid a deposit against a large order. A pricing correction on one line item raised the order total, and the new payment collection Medusa created showed the buyer owing the full corrected total again, not just the gap between the deposit and the new price. Finance flagged it as a serious error and paused the account.

The detection script confirmed it in minutes: prior capture equal to the deposit, one open collection sized off the full new total, and pending_difference matching only the renegotiated gap. Finance reconciled the one order manually, and the operator turned the guarded repair on for the rest of that account's backlog once they trusted the flagged list.

What good looks like

Run this against orders that have both a recent edit and a payment, and every over-sized collection surfaces with the exact old amount, the correctly reconciled amount, and how much was already captured, before anyone gets billed twice or scared by a number that was never really owed. The default stays flag-only. The guarded repair only ever touches the one clean, unambiguous case, and it always re-reads the order's real outstanding balance immediately before writing.

FAQ

Why does Medusa create a new payment collection for the full order total instead of what is still owed?

When an order edit raises the price and Medusa needs a new payment collection to cover the difference, createOrderPaymentCollectionWorkflow builds the collection's amount from the order's current total rather than from order.summary.pending_difference, which already accounts for what was paid and refunded. The workflow does not query the order's existing payment_collections and transactions to net out prior captures, so on a partially paid order the new collection is sized for the whole new total instead of only the outstanding balance.

How do I detect an order affected by this payment collection bug?

Fetch the order with GET /admin/orders and fields=id,display_id,status,*summary,*payment_collections,*payment_collections.payments,*transactions. Sum the amount of any payment_collections with status not_paid or awaiting into new_collection_total, and compare it against summary.pending_difference. If new_collection_total is greater than pending_difference by more than a cent, and the order already has a payment_collection with status completed or a transaction with reference capture, that combination is the signature of this bug: a prior capture exists and the new open collection was sized off the current total instead of what remains unpaid.

Is it safe to auto-fix the payment collection once I find a mismatch?

Not by default. Canceling and recreating a live payment collection can orphan an in-flight customer payment session, so the script only flags by default. Under an explicit DRY_RUN=false operator flag, and only when exactly one open collection exists, it cancels the stale one with mark-as-canceled, re-fetches summary.pending_difference fresh to avoid racing further edits, and creates a corrected collection scoped to that amount. Orders with pending_difference at or below zero or with more than one open collection are always left for a human to review.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #11591: Payment Collections Don't Account for Previously Paid Amounts When Creating New Collections. github.com/medusajs/medusa/issues/11591
  2. medusajs/medusa GitHub issue #10686: Incorrect Outstanding amount and Total Pending amounts. github.com/medusajs/medusa/issues/10686
  3. medusajs/medusa GitHub issue #13068: Order Edit after captured payment, incorrect difference. github.com/medusajs/medusa/issues/13068

On the solution:

  1. Medusa Documentation: Retrieve Order Totals Using Query, order.summary and pending_difference. docs.medusajs.com/resources/commerce-modules/order/order-totals
  2. Medusa Commerce Modules: Payment Collection. docs.medusajs.com/resources/commerce-modules/payment/payment-collection
  3. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this stop a customer from being billed twice?

If this saved you from a duplicate charge or a scary looking balance that was never real, 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