Skip to content

Repair Payments & Refunds

Custom provider capture skips creating the order transaction

Your custom payment provider returns captured straight from authorizePayment, to say the charge already went through, for example a cash on delivery or a synchronous gateway that settles instantly. Medusa marks the Payment record as captured. But the order still shows the full amount outstanding, because the workflow that would have recorded the money against the order never ran. Here is why that step gets skipped 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)
A payment card
Photo by Avery Evans on Unsplash
The short answer

Medusa's order summary.paid_total is calculated only from OrderTransaction rows, never from the Payment entity directly. The normal checkout flow expects authorizePayment to return authorized, then a separate step in capturePaymentWorkflow called addOrderTransactionStep writes the transaction. If a custom provider instead returns captured directly from authorizePayment, Medusa sets captured_at on the Payment but never calls that step, so no transaction is written and paid_total stays short. Run a small Python or Node.js script that lists orders with captured payments, compares paid_total against the captured amount, 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, a payment provider is a plugin that answers questions like "did the charge go through." The checkout flow calls its authorizePayment method and expects one of a small set of answers back. The two that matter here are authorized, meaning the charge is on hold and needs a separate capture step, and captured, meaning the money already moved, which is what a synchronous gateway or a cash on delivery provider reports right away.

When the answer is authorized, everything works the way the docs describe. Later, capturePaymentWorkflow runs, calls the provider to capture the hold, and then runs addOrderTransactionStep, which is the piece that actually inserts an OrderTransaction row tying that amount to the order.

When the answer is captured straight from authorizePayment, Medusa short circuits. It sets captured_at on the Payment record right there, since as far as it is concerned the capture already happened. But it never reaches capturePaymentWorkflow, so addOrderTransactionStep never runs, and no OrderTransaction row is created. The Payment says captured. The provider says captured. The order's summary.paid_total, which only ever sums OrderTransaction rows, still says nothing was paid.

Custom provider authorizePayment returns "captured" Payment record captured_at is set addOrderTransactionStep never runs No OrderTransaction row is written paid_total stays short summary.paid_total sums only OrderTransaction rows, never Payment.amount, so the order looks unpaid.
The provider and the Payment record both agree the money arrived. The order does not, because paid_total is only ever built from order transactions.

Why it happens

Medusa's Payment Module was built around a two-step model: authorize, then capture. A custom provider that skips straight to captured is not wrong to do that, cash on delivery and some synchronous gateways genuinely settle in one call, but the workflow that ties a captured payment back to the order was written assuming the two steps always happen separately.

The key insight

paid_total is not a mirror of the payment provider. It is a ledger built entirely out of OrderTransaction rows. A payment can be fully captured, with the provider's own dashboard showing the charge, and the order can still look unpaid, because nobody ever wrote the row that says so. The fix is not to change what the provider returns. It is to make sure the missing transaction gets created for the payments that were genuinely captured, and to leave anything ambiguous, like multiple payments or prior refunds, for a human to confirm.

The fix, as a flow

We do not touch checkout. We add a job that lists orders and their payments, finds payments where the provider confirmed a capture but no matching order transaction exists, 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 partial match already on file, 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 paid_total 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 tells us paid_total, transaction_total, 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.transaction_total,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.transaction_total,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_order_transaction_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 decideOrderTransactionRepair(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 addOrderTransactionStep would write: order_id, amount, currency_code, reference: "payment", and reference_id set to the payment id.

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 calls out to that internal script 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 paid_total. Once you switch DRY_RUN off, it runs the transaction creation step through medusa exec and re-fetches the order to confirm paid_total now reflects the captured amount. 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_missing_order_transactions.py
"""Find Medusa v2 orders where a custom payment provider returned "captured"
straight from authorizePayment, skipping the order transaction that the
normal capturePaymentWorkflow would have written with addOrderTransactionStep.

Because order.summary.paid_total is computed purely from OrderTransaction
rows, not from Payment.amount or Payment.captured_at, these orders look
outstanding even though the provider and the Payment record both agree the
money was captured. 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-capture-skips-transaction/
"""
import os
import logging
import requests

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

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.transaction_total,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_order_transaction_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_order_transaction_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 a missing transaction, %d order(s) flagged for manual review.",
        to_create, "need" if DRY_RUN else "were repaired for", to_flag,
    )


if __name__ == "__main__":
    run()
find-missing-order-transactions.js
/**
 * Find Medusa v2 orders where a custom payment provider returned "captured"
 * straight from authorizePayment, skipping the order transaction that the
 * normal capturePaymentWorkflow would have written with addOrderTransactionStep.
 *
 * Because order.summary.paid_total is computed purely from OrderTransaction
 * rows, not from Payment.amount or Payment.captured_at, these orders look
 * outstanding even though the provider and the Payment record both agree the
 * money was captured. 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-capture-skips-transaction/
 */
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.transaction_total,summary.current_order_total," +
  "*payment_collections.payments";

export function decideOrderTransactionRepair(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 = decideOrderTransactionRepair(
      {
        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"} a missing transaction, ${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_order_transaction_repair is pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.

test_transaction_repair.py
from find_missing_order_transactions import decide_order_transaction_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_order_transaction_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_order_transaction_repair(order(), [payment(captured_at=None)], set())
    assert result["action"] == "noop"


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


def test_noop_when_canceled_even_if_captured_at_set():
    result = decide_order_transaction_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_order_transaction_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_order_transaction_repair(order(), payments, {"pay_1"})
    assert result["action"] == "flag_ambiguous"
transaction-repair.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOrderTransactionRepair } from "./find-missing-order-transactions.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 = decideOrderTransactionRepair(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 = decideOrderTransactionRepair(order(), [payment({ capturedAt: null })], new Set());
  assert.equal(result.action, "noop");
});

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

test("noop when canceled even if capturedAt is set", () => {
  const result = decideOrderTransactionRepair(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 = decideOrderTransactionRepair(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 = decideOrderTransactionRepair(order(), payments, new Set(["pay_1"]));
  assert.equal(result.action, "flag_ambiguous");
});

Case studies

Cash on delivery

The COD provider that reports capture instantly

A store built a custom cash on delivery provider. Since the driver confirms payment at the door, the team wrote authorizePayment to return captured right away instead of authorized, reasoning there was nothing left to capture later. It felt correct and it matched reality, but every one of those orders kept showing the full amount outstanding in the admin, even days after delivery.

Running the script in dry run against a week of COD orders turned up dozens of single, clean captured payments with no matching transaction. After a human skimmed the list and confirmed none had refunds or duplicate payments, the team ran it for real, and paid_total caught up across the board.

Synchronous gateway

The regional gateway with no separate capture step

A merchant integrated a regional card gateway that settles synchronously, no authorize-then-capture split at all. Their custom provider naturally returned captured from authorizePayment, since that is what actually happened. Finance kept flagging a handful of orders each week where the gateway's own dashboard showed a successful charge but Medusa's order summary said otherwise.

The team found that most flagged orders were the simple, single-payment case the script safely repairs. A few had a partial refund already on file, 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 captured payment from a custom provider stops leaving a silent gap in the order's ledger. Clean, single-payment cases get their missing transaction written automatically, paid_total catches up to reality, 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 my Medusa order still show an outstanding balance after a custom provider captures the payment?

paid_total on the order summary is computed purely from OrderTransaction rows, not from the Payment record. When a custom provider returns captured directly from authorizePayment, Medusa marks the Payment as captured but the workflow never runs the step that inserts the matching order transaction, so paid_total stays short even though the provider and the Payment both say the money arrived.

Is it safe to script a fix for missing order transactions?

Yes, when the script only creates a transaction for orders with exactly one captured, non-canceled payment and no existing transaction referencing it, runs in dry run first, and flags anything with multiple payments, partial captures, or prior refunds for a human to check instead of writing automatically.

What does addOrderTransactionStep actually do?

It is the workflow step, used inside capturePaymentWorkflow, that inserts an OrderTransaction row referencing a payment. That row is what the order summary sums to produce paid_total, so without it a captured payment never shows up as money received on the order, no matter what the Payment entity says.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #9887: Returning "capture" status from authorizePayment does not create order transactions. github.com/medusajs/medusa/issues/9887
  2. medusajs/medusa GitHub issue #11766: outstanding amount is incorrect after a payment has been captured from a custom payment provider. github.com/medusajs/medusa/issues/11766
  3. Medusa Documentation: Accept Payment in Checkout Flow (Payment Module). docs.medusajs.com/resources/commerce-modules/payment/payment-flow

On the solution:

  1. Medusa Core Workflows Reference: addOrderTransactionStep. docs.medusajs.com/resources/references/medusa-workflows/steps/addOrderTransactionStep
  2. Medusa Core Workflows Reference: capturePaymentWorkflow. docs.medusajs.com/resources/references/medusa-workflows/capturePaymentWorkflow
  3. Medusa Documentation: Transactions (Order Module). docs.medusajs.com/resources/commerce-modules/order/transactions

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