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.
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.
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 checkout/capture flow expects
authorizePaymentto returnauthorized, after which a separatecapturePaymentWorkflowstep runs later and callsaddOrderTransactionStepto record the amount against the order. - When a provider returns
captureddirectly fromauthorizePaymentinstead, to signal an auto-captured payment, Medusa's workflow short-circuits: it marks thePaymentrecord'scaptured_atbut never calls the step that inserts the order transaction. - Because
order.summary.paid_totalis derived by summingOrderTransactionrecords, not by readingPayment.amountdirectly, the order is left showing the full amount as outstanding even though the provider and thePaymententity both say it was captured. - This is documented upstream as medusajs/medusa issue #9887, "Returning 'capture' status from authorizePayment does not create order transactions," with issue #11766 describing the resulting incorrect outstanding and paid_total figures.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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")
}
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)
);
}
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.
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}
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 };
}
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.
# 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"),
)
// 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}`
);
}
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.
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.
"""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 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.
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"
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
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.
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.
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:
- medusajs/medusa GitHub issue #9887: Returning "capture" status from authorizePayment does not create order transactions. github.com/medusajs/medusa/issues/9887
- 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
- Medusa Documentation: Accept Payment in Checkout Flow (Payment Module). docs.medusajs.com/resources/commerce-modules/payment/payment-flow
On the solution:
- Medusa Core Workflows Reference: addOrderTransactionStep. docs.medusajs.com/resources/references/medusa-workflows/steps/addOrderTransactionStep
- Medusa Core Workflows Reference: capturePaymentWorkflow. docs.medusajs.com/resources/references/medusa-workflows/capturePaymentWorkflow
- 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.
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