Skip to content

Diagnostic Payments & Refunds

Custom provider capture leaves outstanding amount desynced

Your custom payment provider captures the money and everyone agrees it went through, the provider dashboard, the webhook log, even the Payment record. But the order in the Medusa admin still shows a balance due. outstanding_amount refuses to move even though paid_total looks like it should have caught up. Here is why that number stops tracking reality and a small script that finds the orders stuck like this and repairs them safely.

Python and Node.js Admin API Safe by default (dry run)
Paying with a phone
Photo by CardMapr.nl on Unsplash
The short answer

order.summary.outstanding_amount is not stored on its own. It is calculated as current_order_total minus paid_total, and paid_total is a sum of OrderTransaction rows, never a read of the Payment entity. A custom provider can mark its own capture as fully settled and set captured_at on the Payment, and still leave the order desynced if the code path that called it never finishes with the same order transaction step the built-in capturePaymentWorkflow always runs. The Payment says captured. The provider says captured. outstanding_amount still counts that money as owed. Run a small Python or Node.js script that lists orders, compares the captured payment total against the transaction ledger, and creates the missing transaction only for the clear, single-payment cases. Full code, tests, and a dry run guard are below.

The problem in plain words

In Medusa v2, the amount a customer still owes on an order is not a number anyone writes directly. It is worked out fresh every time the order summary is read, by taking the order's current total and subtracting whatever has actually been recorded as paid. That recorded amount comes from one place only, a running list of OrderTransaction rows tied to the order.

A custom payment provider's job is narrower than that. It authorizes and captures money with whatever gateway or manual process it wraps, and it reports back a status. When everything runs through the paths Medusa ships, capturing a payment always ends with a step that writes one of those transaction rows, so the ledger and the provider agree.

The trouble starts when a custom provider's capture is triggered from somewhere outside that normal path, a webhook handler, a background reconciliation job, an admin action wired up by hand, anything that calls the provider or updates the Payment record without running through to the step that adds the transaction. The capture is completely real. The Payment shows captured_at. But paid_total never moves, because it only ever counts transactions, so outstanding_amount stays exactly where it was before the money arrived.

Custom provider captures outside the normal workflow path Payment record captured_at is set order transaction step never runs paid_total does not move outstanding stays wrong outstanding_amount = current_order_total minus paid_total, and paid_total only ever counts OrderTransaction rows.
The provider and the Payment record both agree the money arrived. outstanding_amount does not, because it is derived only from the order transaction ledger.

Why it happens

The key insight

outstanding_amount is not a live measurement of what a provider has collected. It is arithmetic done against a ledger, and the ledger only grows when something explicitly writes to it. A captured payment that never produces a transaction row is invisible to that arithmetic, no matter how confident the provider is that the money moved. The fix is not to change what the provider reports. It is to make sure the missing transaction gets written for the payments that were genuinely captured, and to leave anything ambiguous, like more than one payment or a partial match already on file, for a human to confirm.

The fix, as a flow

We do not touch checkout or the provider. We add a job that lists orders with their payments and their existing transactions, finds payments the provider reports as captured that have no matching transaction row, and creates that one missing transaction through the same internal building block Medusa's own capture workflow uses. Anything with more than one captured payment, or a reference set that only partially covers them, is reported for a human instead of auto-repaired.

Scheduled job runs on a timer List orders + payments payments, transactions Compare captured vs paid_total, existing refs Single clear gap? yes no, flag for review Create transaction outstanding repaired
The script only writes a transaction for a single, unambiguous captured payment. Multiple payments, partial refunds, or existing partial transactions are always flagged for a human first.

Build it step by step

1

Get an admin session and set up your environment

Authenticate against the emailpass strategy to get a JWT, then send it as a bearer token on every admin call. Keep the backend URL and admin 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)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, change to false to write
2

List orders with their payments and totals

Pull orders with their payment collections and summary totals, paging with offset and limit. This gives us paid_total, outstanding_amount, and current_order_total for every order in one pass.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
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"]

def list_orders(token, offset=0, limit=50):
    r = requests.get(
        f"{BACKEND_URL}/admin/orders",
        headers={"Authorization": f"Bearer {token}"},
        params={
            "fields": "id,display_id,currency_code,summary.paid_total,"
                      "summary.outstanding_amount,summary.current_order_total,"
                      "*payment_collections.payments",
            "offset": offset,
            "limit": limit,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL;
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;
}

async function listOrders(token, offset = 0, limit = 50) {
  const params = new URLSearchParams({
    fields: "id,display_id,currency_code,summary.paid_total," +
      "summary.outstanding_amount,summary.current_order_total," +
      "*payment_collections.payments",
    offset: String(offset),
    limit: String(limit),
  });
  const res = await fetch(`${BACKEND_URL}/admin/orders?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}
3

Fetch each order's transactions to check existing references

For every order, read its transactions too, so we can see which payments already have a matching row with reference=payment and reference_id=<payment_id>. Any payment already covered is left alone.

step3.py
def get_order_transactions(token, order_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/orders/{order_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,*transactions"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["order"].get("transactions") or []

def existing_payment_refs(transactions):
    return {
        t["reference_id"]
        for t in transactions
        if t.get("reference") == "payment" and t.get("reference_id")
    }
step3.js
async function getOrderTransactions(token, orderId) {
  const params = new URLSearchParams({ fields: "id,*transactions" });
  const res = await fetch(`${BACKEND_URL}/admin/orders/${orderId}?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.order.transactions || [];
}

export function existingPaymentRefs(transactions) {
  return new Set(
    transactions
      .filter((t) => t.reference === "payment" && t.reference_id)
      .map((t) => t.reference_id)
  );
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, its payments, and the set of existing transaction references, and returns an action. It never touches the network, so it is easy to read and easy to test. It is strict on purpose: any order with more than one captured payment, or a reference set that partially covers the captured payments, is flagged for a human rather than auto-repaired.

decide.py
def decide_outstanding_repair(order, payments, existing_transaction_refs):
    captured = [p for p in payments if p.get("captured_at") and not p.get("canceled_at")]
    if not captured:
        return {"action": "noop", "order_id": order["id"], "missing_amount": 0, "payment_id": None}

    expected_captured = sum(p["amount"] for p in captured)
    covered = sum(1 for p in captured if p["id"] in existing_transaction_refs)

    if len(captured) > 1 or (0 < covered < len(captured)):
        return {"action": "flag_ambiguous", "order_id": order["id"], "missing_amount": 0, "payment_id": None}

    payment = captured[0]
    if payment["id"] not in existing_transaction_refs and order["paid_total"] < expected_captured:
        return {
            "action": "create_transaction",
            "order_id": order["id"],
            "missing_amount": expected_captured - order["paid_total"],
            "payment_id": payment["id"],
        }

    return {"action": "noop", "order_id": order["id"], "missing_amount": 0, "payment_id": None}
decide.js
export function decideOutstandingRepair(order, payments, existingTransactionRefs) {
  const captured = payments.filter((p) => p.capturedAt && !p.canceledAt);
  if (captured.length === 0) {
    return { action: "noop", orderId: order.id, missingAmount: 0, paymentId: null };
  }

  const expectedCaptured = captured.reduce((sum, p) => sum + p.amount, 0);
  const covered = captured.filter((p) => existingTransactionRefs.has(p.id)).length;

  if (captured.length > 1 || (covered > 0 && covered < captured.length)) {
    return { action: "flag_ambiguous", orderId: order.id, missingAmount: 0, paymentId: null };
  }

  const [payment] = captured;
  if (!existingTransactionRefs.has(payment.id) && order.paidTotal < expectedCaptured) {
    return {
      action: "create_transaction",
      orderId: order.id,
      missingAmount: expectedCaptured - order.paidTotal,
      paymentId: payment.id,
    };
  }

  return { action: "noop", orderId: order.id, missingAmount: 0, paymentId: null };
}
5

Create the missing order transaction

There is no public REST endpoint that inserts an order transaction directly, so this has to run server side, for example a custom workflow or a script invoked with medusa exec. Inside your Medusa project, resolve the order module and call createOrderTransactions with the same shape the built-in capture step would write: order_id, amount, currency_code, reference: "payment", and reference_id set to the payment id. Once that row exists, paid_total catches up and outstanding_amount recalculates correctly on the next read.

apply.py (medusa exec target, run inside the Medusa project)
# Node/TypeScript runs inside the Medusa project via `medusa exec`.
# This Python script only logs the reconciliation record in DRY_RUN mode
# and reports the command to run to perform the actual write.
#
# medusa-exec/create-order-transaction.ts (illustrative, runs in the Medusa app):
#
#   import { Modules } from "@medusajs/framework/utils"
#   export default async function createOrderTransaction({ container, args }) {
#     const orderModuleService = container.resolve(Modules.ORDER)
#     const [orderId, amount, currencyCode, paymentId] = args
#     await orderModuleService.createOrderTransactions({
#       order_id: orderId,
#       amount: Number(amount),
#       currency_code: currencyCode,
#       reference: "payment",
#       reference_id: paymentId,
#     })
#   }
#
# Run with:
#   npx medusa exec ./src/scripts/create-order-transaction.ts <order_id> <amount> <currency_code> <payment_id>

def log_repair_record(record):
    """DRY_RUN path: only log what would be written."""
    log.info(
        "Would create transaction. order_id=%s payment_id=%s amount=%s currency_code=%s",
        record["order_id"], record["payment_id"], record["missing_amount"], record.get("currency_code"),
    )
apply.js (medusa exec target, run inside the Medusa project)
// This admin/API-side script cannot insert an order transaction directly,
// since there is no public REST endpoint for it. The actual write has to
// run server side inside the Medusa project, for example:
//
// src/scripts/create-order-transaction.ts
//
//   import { Modules } from "@medusajs/framework/utils";
//   export default async function createOrderTransaction({ container, args }) {
//     const orderModuleService = container.resolve(Modules.ORDER);
//     const [orderId, amount, currencyCode, paymentId] = args;
//     await orderModuleService.createOrderTransactions({
//       order_id: orderId,
//       amount: Number(amount),
//       currency_code: currencyCode,
//       reference: "payment",
//       reference_id: paymentId,
//     });
//   }
//
// Run with:
//   npx medusa exec ./src/scripts/create-order-transaction.ts <order_id> <amount> <currency_code> <payment_id>

function logRepairRecord(record) {
  // DRY_RUN path: only log what would be written.
  console.log(
    `Would create transaction. order_id=${record.orderId} payment_id=${record.paymentId} amount=${record.missingAmount}`
  );
}
6

Wire it together with a dry run guard

The loop ties every piece together: list orders, fetch payments and transactions, run the pure decision function, and act on the result. In dry run, the script only logs the reconciliation record and the recomputed outstanding_amount. Once you switch DRY_RUN off, it reports the exact medusa exec command to run and you re-fetch the order to confirm outstanding_amount now matches. Anything flagged flag_ambiguous, meaning multiple payments or a partial reference match, is never written automatically.

Run it safe

Always start with DRY_RUN=true. Treat any order with multiple payments, prior refunds, or split payment collections as report only, and have a human confirm before writing, since this mutates the order's financial ledger.

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 only ever proposes a write for the single, unambiguous case where one captured payment is missing its order transaction.

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.
find_outstanding_desync.py
"""Find Medusa v2 orders where a custom payment provider's capture leaves
order.summary.outstanding_amount out of sync with what was actually captured.

outstanding_amount is derived as current_order_total minus paid_total, and
paid_total is computed purely from OrderTransaction rows, never from the
Payment entity directly. When a custom provider's capture path finishes
without running the same order transaction step the built-in
capturePaymentWorkflow always runs, the Payment shows captured_at set but no
transaction backs it, so outstanding_amount keeps counting money that already
arrived. This lists orders and payments, flags the mismatch, and in
DRY_RUN=false mode reports the exact medusa exec command to run to write the
missing transaction. Multiple payments, partial captures, or prior refunds on
an order are always flagged for manual review, never auto-repaired.

Guide: https://www.allanninal.dev/medusa/custom-provider-outstanding-desync/
"""
import os
import logging
import requests

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

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"

ORDERS_FIELDS = (
    "id,display_id,currency_code,summary.paid_total,"
    "summary.outstanding_amount,summary.current_order_total,"
    "*payment_collections.payments"
)


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_orders(token, offset=0, limit=50):
    r = requests.get(
        f"{BACKEND_URL}/admin/orders",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": ORDERS_FIELDS, "offset": offset, "limit": limit},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def get_order_transactions(token, order_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/orders/{order_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,*transactions"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["order"].get("transactions") or []


def existing_payment_refs(transactions):
    return {
        t["reference_id"]
        for t in transactions
        if t.get("reference") == "payment" and t.get("reference_id")
    }


def flatten_payments(order):
    payments = []
    for collection in order.get("payment_collections") or []:
        for payment in collection.get("payments") or []:
            payments.append(payment)
    return payments


def decide_outstanding_repair(order, payments, existing_transaction_refs):
    """Pure decision function. No I/O.

    order: {"id": str, "currency_code": str, "paid_total": float}
    payments: [{"id": str, "amount": float, "captured_at": str | None, "canceled_at": str | None}, ...]
    existing_transaction_refs: set of payment ids already covered by an OrderTransaction

    Returns {"action": "create_transaction" | "flag_ambiguous" | "noop",
             "order_id": str, "missing_amount": float, "payment_id": str | None}
    """
    captured = [p for p in payments if p.get("captured_at") and not p.get("canceled_at")]
    if not captured:
        return {"action": "noop", "order_id": order["id"], "missing_amount": 0, "payment_id": None}

    expected_captured = sum(p["amount"] for p in captured)
    covered = sum(1 for p in captured if p["id"] in existing_transaction_refs)

    if len(captured) > 1 or (0 < covered < len(captured)):
        return {"action": "flag_ambiguous", "order_id": order["id"], "missing_amount": 0, "payment_id": None}

    payment = captured[0]
    if payment["id"] not in existing_transaction_refs and order["paid_total"] < expected_captured:
        return {
            "action": "create_transaction",
            "order_id": order["id"],
            "missing_amount": expected_captured - order["paid_total"],
            "payment_id": payment["id"],
        }

    return {"action": "noop", "order_id": order["id"], "missing_amount": 0, "payment_id": None}


def iter_orders(token):
    offset = 0
    limit = 50
    while True:
        data = list_orders(token, offset=offset, limit=limit)
        for order in data.get("orders", []):
            yield order
        offset += limit
        if offset >= data.get("count", 0):
            return


def run():
    token = get_admin_token()
    to_create = 0
    to_flag = 0
    for order in iter_orders(token):
        payments = flatten_payments(order)
        if not payments:
            continue
        transactions = get_order_transactions(token, order["id"])
        refs = existing_payment_refs(transactions)
        decision = decide_outstanding_repair(
            {
                "id": order["id"],
                "currency_code": order["currency_code"],
                "paid_total": (order.get("summary") or {}).get("paid_total", 0),
            },
            payments,
            refs,
        )

        if decision["action"] == "flag_ambiguous":
            log.warning("Order %s has an ambiguous capture history. Flagging for manual review.", order["id"])
            to_flag += 1
            continue

        if decision["action"] != "create_transaction":
            continue

        record = {
            "order_id": decision["order_id"],
            "payment_id": decision["payment_id"],
            "amount": decision["missing_amount"],
            "currency_code": order["currency_code"],
        }

        if DRY_RUN:
            log.info(
                "Would create transaction. order_id=%s payment_id=%s amount=%s currency_code=%s",
                record["order_id"], record["payment_id"], record["amount"], record["currency_code"],
            )
        else:
            log.info(
                "Run inside the Medusa project: npx medusa exec ./src/scripts/create-order-transaction.ts "
                "%s %s %s %s",
                record["order_id"], record["amount"], record["currency_code"], record["payment_id"],
            )
        to_create += 1

    log.info(
        "Done. %d order(s) %s an outstanding_amount repair, %d order(s) flagged for manual review.",
        to_create, "need" if DRY_RUN else "were repaired for", to_flag,
    )


if __name__ == "__main__":
    run()
find-outstanding-desync.js
/**
 * Find Medusa v2 orders where a custom payment provider's capture leaves
 * order.summary.outstanding_amount out of sync with what was actually captured.
 *
 * outstanding_amount is derived as current_order_total minus paid_total, and
 * paid_total is computed purely from OrderTransaction rows, never from the
 * Payment entity directly. When a custom provider's capture path finishes
 * without running the same order transaction step the built-in
 * capturePaymentWorkflow always runs, the Payment shows captured_at set but no
 * transaction backs it, so outstanding_amount keeps counting money that already
 * arrived. This lists orders and payments, flags the mismatch, and in
 * DRY_RUN=false mode reports the exact medusa exec command to run to write the
 * missing transaction. Multiple payments, partial captures, or prior refunds on
 * an order are always flagged for manual review, never auto-repaired.
 *
 * Guide: https://www.allanninal.dev/medusa/custom-provider-outstanding-desync/
 */
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 ORDERS_FIELDS =
  "id,display_id,currency_code,summary.paid_total," +
  "summary.outstanding_amount,summary.current_order_total," +
  "*payment_collections.payments";

export function decideOutstandingRepair(order, payments, existingTransactionRefs) {
  const captured = payments.filter((p) => p.capturedAt && !p.canceledAt);
  if (captured.length === 0) {
    return { action: "noop", orderId: order.id, missingAmount: 0, paymentId: null };
  }

  const expectedCaptured = captured.reduce((sum, p) => sum + p.amount, 0);
  const covered = captured.filter((p) => existingTransactionRefs.has(p.id)).length;

  if (captured.length > 1 || (covered > 0 && covered < captured.length)) {
    return { action: "flag_ambiguous", orderId: order.id, missingAmount: 0, paymentId: null };
  }

  const [payment] = captured;
  if (!existingTransactionRefs.has(payment.id) && order.paidTotal < expectedCaptured) {
    return {
      action: "create_transaction",
      orderId: order.id,
      missingAmount: expectedCaptured - order.paidTotal,
      paymentId: payment.id,
    };
  }

  return { action: "noop", orderId: order.id, missingAmount: 0, paymentId: null };
}

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 listOrders(token, offset, limit) {
  const params = new URLSearchParams({ fields: ORDERS_FIELDS, offset: String(offset), limit: String(limit) });
  const res = await fetch(`${BACKEND_URL}/admin/orders?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

async function getOrderTransactions(token, orderId) {
  const params = new URLSearchParams({ fields: "id,*transactions" });
  const res = await fetch(`${BACKEND_URL}/admin/orders/${orderId}?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.order.transactions || [];
}

function existingPaymentRefs(transactions) {
  return new Set(
    transactions.filter((t) => t.reference === "payment" && t.reference_id).map((t) => t.reference_id)
  );
}

function flattenPayments(order) {
  const payments = [];
  for (const collection of order.payment_collections || []) {
    for (const payment of collection.payments || []) {
      payments.push({
        id: payment.id,
        amount: payment.amount,
        capturedAt: payment.captured_at,
        canceledAt: payment.canceled_at,
      });
    }
  }
  return payments;
}

async function* iterOrders(token) {
  let offset = 0;
  const limit = 50;
  while (true) {
    const data = await listOrders(token, offset, limit);
    for (const order of data.orders || []) yield order;
    offset += limit;
    if (offset >= (data.count || 0)) return;
  }
}

export async function run() {
  const token = await getAdminToken();
  let toCreate = 0;
  let toFlag = 0;

  for await (const order of iterOrders(token)) {
    const payments = flattenPayments(order);
    if (payments.length === 0) continue;

    const transactions = await getOrderTransactions(token, order.id);
    const refs = existingPaymentRefs(transactions);
    const decision = decideOutstandingRepair(
      {
        id: order.id,
        currencyCode: order.currency_code,
        paidTotal: order.summary?.paid_total || 0,
      },
      payments,
      refs
    );

    if (decision.action === "flag_ambiguous") {
      console.warn(`Order ${order.id} has an ambiguous capture history. Flagging for manual review.`);
      toFlag++;
      continue;
    }

    if (decision.action !== "create_transaction") continue;

    const record = {
      orderId: decision.orderId,
      paymentId: decision.paymentId,
      amount: decision.missingAmount,
      currencyCode: order.currency_code,
    };

    if (DRY_RUN) {
      console.log(
        `Would create transaction. order_id=${record.orderId} payment_id=${record.paymentId} amount=${record.amount} currency_code=${record.currencyCode}`
      );
    } else {
      console.log(
        `Run inside the Medusa project: npx medusa exec ./src/scripts/create-order-transaction.ts ${record.orderId} ${record.amount} ${record.currencyCode} ${record.paymentId}`
      );
    }
    toCreate++;
  }

  console.log(
    `Done. ${toCreate} order(s) ${DRY_RUN ? "need" : "were repaired for"} an outstanding_amount repair, ${toFlag} 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

The decision rule is the part most worth testing, because it decides whether the script proposes writing a financial ledger row. Because decide_outstanding_repair is pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.

test_outstanding_desync.py
from find_outstanding_desync import decide_outstanding_repair


def order(**over):
    base = {"id": "order_1", "currency_code": "usd", "paid_total": 0}
    base.update(over)
    return base


def payment(**over):
    base = {"id": "pay_1", "amount": 100, "captured_at": "2026-07-10T00:00:00Z", "canceled_at": None}
    base.update(over)
    return base


def test_creates_transaction_when_single_captured_payment_missing_ref():
    result = decide_outstanding_repair(order(), [payment()], set())
    assert result["action"] == "create_transaction"
    assert result["order_id"] == "order_1"
    assert result["payment_id"] == "pay_1"
    assert result["missing_amount"] == 100


def test_noop_when_no_payments_captured():
    result = decide_outstanding_repair(order(), [payment(captured_at=None)], set())
    assert result["action"] == "noop"


def test_noop_when_captured_payment_already_has_ref():
    result = decide_outstanding_repair(order(paid_total=100), [payment()], {"pay_1"})
    assert result["action"] == "noop"


def test_noop_when_canceled_even_if_captured_at_set():
    result = decide_outstanding_repair(order(), [payment(canceled_at="2026-07-11T00:00:00Z")], set())
    assert result["action"] == "noop"


def test_flags_ambiguous_when_multiple_captured_payments():
    payments = [payment(id="pay_1"), payment(id="pay_2")]
    result = decide_outstanding_repair(order(), payments, set())
    assert result["action"] == "flag_ambiguous"


def test_flags_ambiguous_when_partial_reference_coverage():
    payments = [payment(id="pay_1", canceled_at=None), payment(id="pay_2", canceled_at=None)]
    # only one of the two captured payments has an existing transaction, but len(captured) > 1
    # already forces flag_ambiguous, this also covers the "some but not all" partial case
    result = decide_outstanding_repair(order(), payments, {"pay_1"})
    assert result["action"] == "flag_ambiguous"
outstanding-desync.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOutstandingRepair } from "./find-outstanding-desync.js";

const order = (over = {}) => ({ id: "order_1", currencyCode: "usd", paidTotal: 0, ...over });
const payment = (over = {}) => ({ id: "pay_1", amount: 100, capturedAt: "2026-07-10T00:00:00Z", canceledAt: null, ...over });

test("creates transaction when single captured payment missing ref", () => {
  const result = decideOutstandingRepair(order(), [payment()], new Set());
  assert.equal(result.action, "create_transaction");
  assert.equal(result.orderId, "order_1");
  assert.equal(result.paymentId, "pay_1");
  assert.equal(result.missingAmount, 100);
});

test("noop when no payments captured", () => {
  const result = decideOutstandingRepair(order(), [payment({ capturedAt: null })], new Set());
  assert.equal(result.action, "noop");
});

test("noop when captured payment already has ref", () => {
  const result = decideOutstandingRepair(order({ paidTotal: 100 }), [payment()], new Set(["pay_1"]));
  assert.equal(result.action, "noop");
});

test("noop when canceled even if capturedAt is set", () => {
  const result = decideOutstandingRepair(order(), [payment({ canceledAt: "2026-07-11T00:00:00Z" })], new Set());
  assert.equal(result.action, "noop");
});

test("flags ambiguous when multiple captured payments", () => {
  const payments = [payment({ id: "pay_1" }), payment({ id: "pay_2" })];
  const result = decideOutstandingRepair(order(), payments, new Set());
  assert.equal(result.action, "flag_ambiguous");
});

test("flags ambiguous when partial reference coverage", () => {
  const payments = [payment({ id: "pay_1" }), payment({ id: "pay_2" })];
  const result = decideOutstandingRepair(order(), payments, new Set(["pay_1"]));
  assert.equal(result.action, "flag_ambiguous");
});

Case studies

Webhook-driven capture

The gateway that confirmed capture by webhook

A store's custom provider authorized the payment during checkout, but the actual capture was confirmed later by an asynchronous webhook from the gateway. The webhook handler updated the Payment record directly to reflect the capture, since that felt like the simplest way to react to the event, but it never called back into the workflow step that writes the order transaction.

Support kept getting tickets about orders that showed a balance due days after the customer's card had already been charged. Running the script in dry run against a week of orders turned up a clean batch of single, unambiguous captured payments with no matching transaction. A human confirmed none had refunds, and the repair caught outstanding_amount up across the board.

Manual reconciliation job

The nightly job that marked offline payments captured

A wholesale operation ran a nightly script that matched incoming bank transfers to draft payments and flipped them to captured once the money was confirmed. That script talked to the payment module directly and stopped there, so the order's ledger never learned about the newly captured amount.

The team found most flagged orders were the simple, single-payment case the script safely repairs. A handful had two payment attempts on the same order from a retried transfer, and those were correctly left as flag_ambiguous for someone to check by hand before touching the ledger.

What good looks like

After this runs on a schedule, a capture from a custom provider stops leaving a silent gap between the payment record and the order's ledger. Clean, single-payment cases get their missing transaction written, outstanding_amount catches up to what was actually collected, and anything with multiple payments or refunds waits for a human instead of getting guessed at. The provider, the Payment record, and the order all agree again.

FAQ

Why does order.summary.outstanding_amount not match what my custom provider actually captured?

outstanding_amount is derived on the order summary as current_order_total minus paid_total, and paid_total is a sum of OrderTransaction rows, not a read of the Payment entity. When a custom provider's capture path does not end with the same order transaction step the built-in capture workflow uses, the Payment shows the money as captured but no transaction backs it, so outstanding_amount keeps counting money that already arrived.

Is it safe to script a fix for a desynced outstanding_amount?

Yes, when the script only proposes a repair for orders where the captured payment total is unambiguous, meaning a single non-canceled captured payment whose amount does not already have a matching transaction, runs in dry run first, and leaves anything with multiple payments, partial captures, or existing refunds for a human to check.

What is the difference between paid_total and outstanding_amount on a Medusa order?

paid_total is the sum of OrderTransaction rows recorded against the order. outstanding_amount is calculated from that sum, as current_order_total minus paid_total. Because outstanding_amount is derived rather than stored independently, any gap in the transaction ledger shows up as an outstanding balance even when a provider has actually captured the full amount.

Related field notes

Citations

On the problem:

  1. a

On the solution:

  1. a

Stuck on a tricky one?

If you have a problem in Medusa orders, payments, inventory, 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 fix a reconciliation gap for you?

If this saved you a pile of manual ledger checks or a wrong revenue report, 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