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.
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.
Why it happens
- Medusa's order summary computes
outstanding_amountascurrent_order_totalminuspaid_total, andpaid_totalis a straight sum ofOrderTransactionrows. Neither value is read from thePaymententity directly. - The built-in
capturePaymentWorkflowalways ends by callingaddOrderTransactionStep, which is the piece that writes the row tying a captured amount to the order. Any capture path that skips or short circuits before that step leaves the ledger untouched. - Custom providers commonly trigger a capture from a place the checkout workflow does not control, a payment gateway webhook that flips the
Paymentto captured directly, a background job that reconciles an offline payment, or an admin action wired up outside the standard flow. Each of these can update thePaymentwithout ever reaching the step that writes the transaction. - Because the desync lives in the gap between two different records, the
Paymentand theOrderTransactiontable, it does not throw an error anywhere. The order just quietly keeps showing a balance due that was already collected.
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.
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 gives us paid_total, outstanding_amount, 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.outstanding_amount,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.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();
}
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_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}
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 };
}
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.
# 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"),
)
// 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 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.
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'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 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.
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"
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
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.
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.
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
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