Reconciler Orders and fulfillment
Payment captured but order not paid
Stripe confirms the charge. Your custom provider confirms the charge. The money is gone from the customer's card and it is sitting in your account. But the Medusa order still shows not_paid, the summary still says nothing was paid, and anything downstream that gates on payment status just sits there waiting. Here is why a successful capture does not always mean the order agrees, and a script that finds the mismatch and repairs it the safe way.
In Medusa v2, an order's payment_status is not a stored field the capture call writes directly. It is derived from the order's linked payment_collections and their underlying Payment records, reconciled through the order's summary (paid_total, transaction_total, pending_difference). If the capture step succeeds against the provider but the linking step does not create the matching order transaction, or the payment.captured event is missed because the in-memory event bus dropped it, the Payment shows captured_at set while the order never advances off not_paid. Run a small Python or Node.js script that lists recently updated orders, sums each order's captured amounts against its reported payment_status and summary, and flags any mismatch. Behind a DRY_RUN guard it repairs the safe way, by re-invoking the capture route on the existing payment, not by writing status directly.
The problem in plain words
It is tempting to think of payment_status as a column you can just update, the way you would flip a boolean. It is not. Medusa computes it every time an order is read, by looking at the order's payment_collections, the Payment records under each collection, and the captures recorded against those payments, then reconciling all of that through the order's summary.
When capturePaymentWorkflow runs cleanly end to end, the provider confirms the capture, the workflow's linking step records the matching order transaction, an emitEventStep fires payment.captured, and the order's payment_collections and summary both move together. The problem shows up when that chain breaks partway. The provider-side capture can succeed, the Payment's own captured_at gets set, and yet the linking step that ties it back to the order's transaction total never runs, or the event that should trigger it never arrives. The money moved. The order does not know it.
Why it happens
Every one of these is a real gap in how the pieces connect, not a single bug with one cause:
payment_statusis computed frompayment_collectionsand their payments, reconciled through the order'ssummary(paid_total,transaction_total,pending_difference). It is never a plain column the capture call writes to.- If
capturePaymentWorkflow's capture step succeeds against the provider but the subsequent linking step does not create or emit the matching order transaction, the Payment showscaptured_atset while the PaymentCollection and order stay onnot_paidorawaiting. - The default event bus module is in-memory. It does not persist across processes, so a
payment.capturedevent can be lost if the worker that should react to it is not the same process, or restarts at the wrong moment. - A custom payment provider that returns
capturedstraight fromauthorizePayment, instead of letting the full capture workflow run, can leave Medusa with no order transaction to reconcile against at all, which is exactly the shape of GitHub issue #9887. - This exact symptom, a confirmed charge with
summary.paid_totalstuck at 0, is documented across multiple Medusa issues (#9887, #11766, #2629, #13067) and was acknowledged as fixed upstream in v2.11.1 for the automatic-capture path, which means older versions and custom providers can still hit it.
This is a common source of confusion because the payment gateway's own dashboard shows the charge as settled, so it looks like a Medusa display bug rather than a missing reconciliation step. See the citations at the end for the exact issues and docs.
You cannot fix this by writing payment_status directly. It is derived, not stored, so a forced write gets recomputed and can desync again on the very next workflow run. The safe pattern is to re-run the same workflow Medusa itself uses to reconcile, re-invoking capturePaymentWorkflow against the existing payment_id. Because the payment is already captured provider side, this cannot double charge. It only repeats the linking and event steps that should have run the first time.
The fix, as a flow
We do not touch checkout and we do not overwrite status. We list recently updated orders with their summary and payment_collections expanded, sum the captured amount across every payment on every collection with a pure function, and compare that against the order's reported status and paid total. Behind DRY_RUN, the repair step re-invokes the capture route on each flagged payment, one order at a time, then re-fetches the order to confirm it advanced.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read and capture payments. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
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, only logs the order_id/payment_id pairs
npm install @medusajs/js-sdk
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, only logs the order_id/payment_id pairs
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List recently updated orders with the fields to reconcile
Ask for orders with payment_status, the order's summary, and every payment_collections entry with its nested payments and captures. Page through with limit and offset. This one call carries everything the decision function needs, no second lookup required.
ORDER_FIELDS = (
"id,status,payment_status,*summary,"
"*payment_collections,*payment_collections.payments,"
"payment_collections.payments.captured_at,"
"payment_collections.payments.captures,"
"payment_collections.payments.captures.raw_amount"
)
def list_recent_orders(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/orders",
params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["orders"])
offset += limit
if offset >= body["count"]:
return out
const ORDER_FIELDS =
"id,status,payment_status,*summary," +
"*payment_collections,*payment_collections.payments," +
"payment_collections.payments.captured_at," +
"payment_collections.payments.captures," +
"payment_collections.payments.captures.raw_amount";
async function listRecentOrders(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.order.list({ fields: ORDER_FIELDS, limit, offset });
out.push(...body.orders);
offset += limit;
if (offset >= body.count) return out;
}
}
Decide, with one pure function
Keep the decision in a function with no network calls, so it is easy to read and easy to test. Sum captures[].raw_amount.value across every payment on every payment collection to get the total captured. An order is mismatched when money was captured but the order's own status or summary still says otherwise, or when a specific collection disagrees with its own payments.
UNPAID_STATUSES = {"not_paid", "awaiting"}
STALE_COLLECTION_STATUSES = {"not_paid", "awaiting", "authorized"}
def detect_payment_status_mismatch(order):
"""Pure: no I/O. order is a dict with payment_status, summary, payment_collections."""
total_captured = 0
stale_collection = False
for pc in order.get("payment_collections", []) or []:
pc_captured = 0
for payment in pc.get("payments", []) or []:
for capture in payment.get("captures", []) or []:
pc_captured += capture.get("raw_amount", {}).get("value", 0)
total_captured += pc_captured
if pc_captured > 0 and pc.get("status") in STALE_COLLECTION_STATUSES:
stale_collection = True
paid_total = (order.get("summary") or {}).get("raw_paid_total", {}).get("value", 0)
payment_status = order.get("payment_status")
reason = None
if total_captured > 0 and payment_status in UNPAID_STATUSES:
reason = "captured funds exist but payment_status is still %s" % payment_status
elif total_captured > 0 and paid_total == 0:
reason = "captured funds exist but summary.raw_paid_total is 0"
elif stale_collection:
reason = "a payment_collection is captured but its own status has not advanced"
return {
"orderId": order.get("id"),
"mismatched": reason is not None,
"reason": reason,
}
const UNPAID_STATUSES = new Set(["not_paid", "awaiting"]);
const STALE_COLLECTION_STATUSES = new Set(["not_paid", "awaiting", "authorized"]);
export function detectPaymentStatusMismatch(order) {
// Pure: no I/O. order has payment_status, summary, payment_collections.
let totalCaptured = 0;
let staleCollection = false;
for (const pc of order.payment_collections || []) {
let pcCaptured = 0;
for (const payment of pc.payments || []) {
for (const capture of payment.captures || []) {
pcCaptured += capture.raw_amount?.value || 0;
}
}
totalCaptured += pcCaptured;
if (pcCaptured > 0 && STALE_COLLECTION_STATUSES.has(pc.status)) {
staleCollection = true;
}
}
const paidTotal = order.summary?.raw_paid_total?.value || 0;
const paymentStatus = order.payment_status;
let reason = null;
if (totalCaptured > 0 && UNPAID_STATUSES.has(paymentStatus)) {
reason = `captured funds exist but payment_status is still ${paymentStatus}`;
} else if (totalCaptured > 0 && paidTotal === 0) {
reason = "captured funds exist but summary.raw_paid_total is 0";
} else if (staleCollection) {
reason = "a payment_collection is captured but its own status has not advanced";
}
return {
orderId: order.id,
mismatched: reason !== null,
reason,
};
}
Repair by re-invoking the capture route, never by writing status
For each flagged order, call the payment capture route with the existing payment_id. This re-runs the linking step and the payment.captured event that Medusa itself uses to reconcile the order. It is idempotent since the payment is already captured provider side, so it cannot double charge. Then re-fetch the order to confirm payment_status actually moved.
def reinvoke_capture(token, payment_id, amount=None):
headers = {"Authorization": f"Bearer {token}"}
payload = {"amount": amount} if amount is not None else {}
r = requests.post(
f"{BASE_URL}/admin/payments/{payment_id}/capture",
json=payload,
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def get_order(token, order_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/orders/{order_id}",
params={"fields": "id,payment_status,*summary,*payment_collections.payments.captures"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["order"]
async function reinvokeCapture(sdk, paymentId, amount) {
return sdk.admin.payment.capture(paymentId, amount != null ? { amount } : {});
}
async function getOrder(sdk, orderId) {
const body = await sdk.admin.order.retrieve(orderId, {
fields: "id,payment_status,*summary,*payment_collections.payments.captures",
});
return body.order;
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs the order_id/payment_id pairs it would re-trigger. Read the output, agree with it, then switch it off to let it call the capture route for real. If a mismatch survives the re-invoke, do not force a status write. Log it as needing a human, since that usually means there is no local Payment record to reconcile against at all.
Never write payment_status directly. It is derived, not stored, and a forced value gets recomputed on the next workflow run anyway. Always start with DRY_RUN=true, and if re-invoking the capture step does not clear a mismatch, report that order to a human instead of masking it with a status overwrite.
The full code
Here is the complete script in one file for each language. It authenticates, lists recent orders with the fields needed to reconcile, flags every mismatch with a pure function, and either logs the repair or re-invokes the capture route depending on DRY_RUN, confirming the result after.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa v2 orders where a payment was captured but the order never
advanced off not_paid, and repair them the safe way. Never writes payment_status
directly, since it is derived, not stored. DRY_RUN=true only logs the
order_id/payment_id pairs it would re-trigger. Safe to run again and again,
because re-invoking capture on an already captured payment cannot double charge.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_payment_status")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
UNPAID_STATUSES = {"not_paid", "awaiting"}
STALE_COLLECTION_STATUSES = {"not_paid", "awaiting", "authorized"}
ORDER_FIELDS = (
"id,status,payment_status,*summary,"
"*payment_collections,*payment_collections.payments,"
"payment_collections.payments.captured_at,"
"payment_collections.payments.captures,"
"payment_collections.payments.captures.raw_amount"
)
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_recent_orders(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/orders",
params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["orders"])
offset += limit
if offset >= body["count"]:
return out
def detect_payment_status_mismatch(order):
"""Pure: no I/O. order is a dict with payment_status, summary, payment_collections."""
total_captured = 0
stale_collection = False
for pc in order.get("payment_collections", []) or []:
pc_captured = 0
for payment in pc.get("payments", []) or []:
for capture in payment.get("captures", []) or []:
pc_captured += capture.get("raw_amount", {}).get("value", 0)
total_captured += pc_captured
if pc_captured > 0 and pc.get("status") in STALE_COLLECTION_STATUSES:
stale_collection = True
paid_total = (order.get("summary") or {}).get("raw_paid_total", {}).get("value", 0)
payment_status = order.get("payment_status")
reason = None
if total_captured > 0 and payment_status in UNPAID_STATUSES:
reason = "captured funds exist but payment_status is still %s" % payment_status
elif total_captured > 0 and paid_total == 0:
reason = "captured funds exist but summary.raw_paid_total is 0"
elif stale_collection:
reason = "a payment_collection is captured but its own status has not advanced"
return {
"orderId": order.get("id"),
"mismatched": reason is not None,
"reason": reason,
}
def flagged_payment_ids(order):
"""Collect the payment_id values worth re-invoking capture on for this order."""
ids = []
for pc in order.get("payment_collections", []) or []:
for payment in pc.get("payments", []) or []:
if payment.get("captured_at") and payment.get("id"):
ids.append(payment["id"])
return ids
def reinvoke_capture(token, payment_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/payments/{payment_id}/capture",
json={},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def get_order(token, order_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/orders/{order_id}",
params={"fields": ORDER_FIELDS},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["order"]
def run():
token = get_token()
orders = list_recent_orders(token)
flagged = []
for order in orders:
result = detect_payment_status_mismatch(order)
if result["mismatched"]:
flagged.append((order, result))
if not flagged:
log.info("No payment_status mismatches found across %d order(s).", len(orders))
return
for order, result in flagged:
payment_ids = flagged_payment_ids(order)
if not payment_ids:
log.warning(
"Order %s mismatched (%s) but has no local Payment with captured_at set. "
"Flagging to a human, not writing status.", order["id"], result["reason"],
)
continue
for payment_id in payment_ids:
log.info(
"Order %s payment %s: %s. %s",
order["id"], payment_id, result["reason"],
"Would re-invoke capture" if DRY_RUN else "Re-invoking capture",
)
if not DRY_RUN:
reinvoke_capture(token, payment_id)
if not DRY_RUN:
refreshed = get_order(token, order["id"])
still_mismatched = detect_payment_status_mismatch(refreshed)["mismatched"]
if still_mismatched:
log.warning(
"Order %s still mismatched after re-invoking capture. "
"Flagging to a human, not writing status.", order["id"],
)
else:
log.info("Order %s confirmed reconciled. payment_status=%s",
order["id"], refreshed["payment_status"])
log.info("Done. %d order(s) %s.", len(flagged), "to reconcile" if DRY_RUN else "processed")
if __name__ == "__main__":
run()
/**
* Find Medusa v2 orders where a payment was captured but the order never
* advanced off not_paid, and repair them the safe way. Never writes
* payment_status directly, since it is derived, not stored. DRY_RUN=true
* only logs the order_id/payment_id pairs it would re-trigger. Safe to run
* again and again, because re-invoking capture on an already captured
* payment cannot double charge.
*/
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const UNPAID_STATUSES = new Set(["not_paid", "awaiting"]);
const STALE_COLLECTION_STATUSES = new Set(["not_paid", "awaiting", "authorized"]);
const ORDER_FIELDS =
"id,status,payment_status,*summary," +
"*payment_collections,*payment_collections.payments," +
"payment_collections.payments.captured_at," +
"payment_collections.payments.captures," +
"payment_collections.payments.captures.raw_amount";
export function detectPaymentStatusMismatch(order) {
// Pure: no I/O. order has payment_status, summary, payment_collections.
let totalCaptured = 0;
let staleCollection = false;
for (const pc of order.payment_collections || []) {
let pcCaptured = 0;
for (const payment of pc.payments || []) {
for (const capture of payment.captures || []) {
pcCaptured += capture.raw_amount?.value || 0;
}
}
totalCaptured += pcCaptured;
if (pcCaptured > 0 && STALE_COLLECTION_STATUSES.has(pc.status)) {
staleCollection = true;
}
}
const paidTotal = order.summary?.raw_paid_total?.value || 0;
const paymentStatus = order.payment_status;
let reason = null;
if (totalCaptured > 0 && UNPAID_STATUSES.has(paymentStatus)) {
reason = `captured funds exist but payment_status is still ${paymentStatus}`;
} else if (totalCaptured > 0 && paidTotal === 0) {
reason = "captured funds exist but summary.raw_paid_total is 0";
} else if (staleCollection) {
reason = "a payment_collection is captured but its own status has not advanced";
}
return {
orderId: order.id,
mismatched: reason !== null,
reason,
};
}
function flaggedPaymentIds(order) {
const ids = [];
for (const pc of order.payment_collections || []) {
for (const payment of pc.payments || []) {
if (payment.captured_at && payment.id) ids.push(payment.id);
}
}
return ids;
}
async function login() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listRecentOrders(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.order.list({ fields: ORDER_FIELDS, limit, offset });
out.push(...body.orders);
offset += limit;
if (offset >= body.count) return out;
}
}
async function reinvokeCapture(sdk, paymentId) {
return sdk.admin.payment.capture(paymentId, {});
}
async function getOrder(sdk, orderId) {
const body = await sdk.admin.order.retrieve(orderId, { fields: ORDER_FIELDS });
return body.order;
}
export async function run() {
const sdk = await login();
const orders = await listRecentOrders(sdk);
const flagged = [];
for (const order of orders) {
const result = detectPaymentStatusMismatch(order);
if (result.mismatched) flagged.push([order, result]);
}
if (flagged.length === 0) {
console.log(`No payment_status mismatches found across ${orders.length} order(s).`);
return;
}
for (const [order, result] of flagged) {
const paymentIds = flaggedPaymentIds(order);
if (paymentIds.length === 0) {
console.warn(
`Order ${order.id} mismatched (${result.reason}) but has no local Payment with captured_at set. Flagging to a human, not writing status.`
);
continue;
}
for (const paymentId of paymentIds) {
console.log(
`Order ${order.id} payment ${paymentId}: ${result.reason}. ${DRY_RUN ? "Would re-invoke capture" : "Re-invoking capture"}`
);
if (!DRY_RUN) await reinvokeCapture(sdk, paymentId);
}
if (!DRY_RUN) {
const refreshed = await getOrder(sdk, order.id);
const stillMismatched = detectPaymentStatusMismatch(refreshed).mismatched;
if (stillMismatched) {
console.warn(`Order ${order.id} still mismatched after re-invoking capture. Flagging to a human, not writing status.`);
} else {
console.log(`Order ${order.id} confirmed reconciled. payment_status=${refreshed.payment_status}`);
}
}
}
console.log(`Done. ${flagged.length} order(s) ${DRY_RUN ? "to reconcile" : "processed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing is the one that decides the outcome, detect_payment_status_mismatch. It is pure, no network and no database, so the tests feed in plain order objects built from fixtures and check the answer.
from reconcile_payment_status import detect_payment_status_mismatch
def order(**over):
base = {
"id": "order_1",
"payment_status": "captured",
"summary": {"raw_paid_total": {"value": 5000}, "raw_transaction_total": {"value": 5000}},
"payment_collections": [
{
"status": "captured",
"payments": [
{"captured_at": "2026-07-01T00:00:00Z", "captures": [{"raw_amount": {"value": 5000}}]}
],
}
],
}
base.update(over)
return base
def test_no_mismatch_when_everything_agrees():
result = detect_payment_status_mismatch(order())
assert result == {"orderId": "order_1", "mismatched": False, "reason": None}
def test_mismatch_when_captured_but_status_not_paid():
o = order(payment_status="not_paid")
result = detect_payment_status_mismatch(o)
assert result["mismatched"] is True
assert "payment_status is still not_paid" in result["reason"]
def test_mismatch_when_captured_but_paid_total_zero():
o = order()
o["summary"]["raw_paid_total"]["value"] = 0
result = detect_payment_status_mismatch(o)
assert result["mismatched"] is True
assert "raw_paid_total is 0" in result["reason"]
def test_mismatch_when_collection_status_stale():
o = order()
o["payment_collections"][0]["status"] = "awaiting"
result = detect_payment_status_mismatch(o)
assert result["mismatched"] is True
def test_no_mismatch_when_nothing_captured():
o = order(payment_status="not_paid")
o["summary"]["raw_paid_total"]["value"] = 0
o["payment_collections"][0]["payments"][0]["captures"] = []
result = detect_payment_status_mismatch(o)
assert result["mismatched"] is False
def test_sums_captures_across_multiple_payment_collections():
o = order()
o["payment_collections"].append({
"status": "captured",
"payments": [
{"captured_at": "2026-07-02T00:00:00Z", "captures": [{"raw_amount": {"value": 1500}}]}
],
})
result = detect_payment_status_mismatch(o)
assert result["mismatched"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectPaymentStatusMismatch } from "./reconcile-payment-status.js";
const order = (over = {}) => ({
id: "order_1",
payment_status: "captured",
summary: { raw_paid_total: { value: 5000 }, raw_transaction_total: { value: 5000 } },
payment_collections: [
{
status: "captured",
payments: [
{ captured_at: "2026-07-01T00:00:00Z", captures: [{ raw_amount: { value: 5000 } }] },
],
},
],
...over,
});
test("no mismatch when everything agrees", () => {
const result = detectPaymentStatusMismatch(order());
assert.deepEqual(result, { orderId: "order_1", mismatched: false, reason: null });
});
test("mismatch when captured but status not_paid", () => {
const result = detectPaymentStatusMismatch(order({ payment_status: "not_paid" }));
assert.equal(result.mismatched, true);
assert.match(result.reason, /payment_status is still not_paid/);
});
test("mismatch when captured but paid_total zero", () => {
const o = order();
o.summary.raw_paid_total.value = 0;
const result = detectPaymentStatusMismatch(o);
assert.equal(result.mismatched, true);
assert.match(result.reason, /raw_paid_total is 0/);
});
test("mismatch when collection status stale", () => {
const o = order();
o.payment_collections[0].status = "awaiting";
const result = detectPaymentStatusMismatch(o);
assert.equal(result.mismatched, true);
});
test("no mismatch when nothing captured", () => {
const o = order({ payment_status: "not_paid" });
o.summary.raw_paid_total.value = 0;
o.payment_collections[0].payments[0].captures = [];
const result = detectPaymentStatusMismatch(o);
assert.equal(result.mismatched, false);
});
test("sums captures across multiple payment collections", () => {
const o = order();
o.payment_collections.push({
status: "captured",
payments: [
{ captured_at: "2026-07-02T00:00:00Z", captures: [{ raw_amount: { value: 1500 } }] },
],
});
const result = detectPaymentStatusMismatch(o);
assert.equal(result.mismatched, false);
});
Case studies
The provider that skipped the linking step
A store ran a custom payment provider that returned captured straight from authorizePayment instead of letting the full capture workflow run through its own steps. Stripe style dashboards on the provider's own side showed every charge settled, but the store's Medusa orders sat on not_paid by the dozen, and the fulfillment team assumed the checkout was broken.
Running the reconciler in dry run showed every affected order had a Payment with captured_at set and captures summing above zero, while payment_status never moved. Re-invoking the capture route on each flagged payment ran the linking step that should have run the first time, and the orders cleared without touching a single database row directly.
The restart that dropped a batch of payment.captured events
A team running Medusa without the Redis event bus module hit a burst of orders during a promotion. A worker process restarted mid burst, and every payment.captured event queued in memory at that moment was lost with it, even though the captures themselves went through fine at the provider.
The script's summed capture check caught the exact orders affected by comparing captured amounts against summary.raw_paid_total. A handful of orders still failed to reconcile after the re-invoke, since they had no local Payment record at all to hang the capture off. The team flagged those to a human instead of forcing a status write over an actual gap in the payment link.
Run this on a schedule against recently updated orders, or right after a spike in captures. It never writes payment_status directly, so it can never desync the order further. It reconciles the same way Medusa's own workflow does, by re-invoking capture on the payment that actually has the money, then confirms the order caught up. Anything that still disagrees after that gets reported to a human, because that is a missing transaction, not a timing gap a script should paper over.
FAQ
Why does my Medusa order still show not_paid after Stripe confirms the charge?
payment_status is not a field the capture call writes directly. It is derived from the order's payment_collections and their underlying Payment records, reconciled through the order's summary. If the capture step succeeds against the provider but the linking step does not create the matching order transaction, the Payment shows captured_at set while the order never advances off not_paid.
Is it safe to force payment_status to captured with a script?
No. payment_status is a derived value, not a plain column, so writing it directly can desync again on the next workflow run. The safe repair is to re-invoke capturePaymentWorkflow through the existing payment_id, which is idempotent since the payment is already captured provider side, so it cannot double charge.
What if re-running the capture step does not clear the mismatch?
That points to a deeper missing transaction bug rather than a timing gap, the kind reported in GitHub issue #9887 for providers that return captured from authorizePayment without the full workflow running. Flag that order to a human instead of writing status directly, since a forced overwrite would mask the bug rather than fix it.
Related field notes
Citations
On the problem:
- 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
- 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 #2629: Stripe automatic payment capture does not update the order payment status. github.com/medusajs/medusa/issues/2629
On the solution:
- Medusa Documentation: Accept Payment in Checkout Flow (Payment Module). docs.medusajs.com/resources/commerce-modules/payment/payment-flow
- Medusa Core Workflows Reference: capturePaymentWorkflow. docs.medusajs.com/resources/references/medusa-workflows/capturePaymentWorkflow
- Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear up a stuck payment status?
If this saved you from a confusing reconciliation or a scary forced status write, 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