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.

Python and Node.js Medusa Admin API Reconcile first, never force-write status
The short answer

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.

Provider captures Payment.captured_at set Linking step or payment.captured event is missed order never told summary.paid_total stays at 0 payment_status not_paid
The Payment record and the order's summary can disagree. The capture happened, but nothing carried that fact back to the order.

Why it happens

Every one of these is a real gap in how the pieces connect, not a single bug with one cause:

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.

The key insight

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.

List recent orders summary, payment_collections Sum captured amounts across payments.captures Compare to status payment_status, paid_total Mismatch found? yes no, leave alone Re-invoke capture or flag to a human
The script flags first. Repair re-invokes the same capture route Medusa uses, one payment at a time, and reports what it cannot fix instead of forcing a status write.

Build it step by step

1

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.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, only logs the order_id/payment_id pairs
setup (shell)
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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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,
    }
decide.js
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,
  };
}
5

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.

apply.py
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"]
apply.js
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;
}
6

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.

Run it safe

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.

reconcile_payment_status.py
"""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()
reconcile-payment-status.js
/**
 * 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.

test_payment_status_mismatch.py
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
payment-status-mismatch.test.js
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

Custom payment provider

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.

In-memory event bus

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.

What good looks like

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:

  1. 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
  2. medusajs/medusa GitHub issue #9887: Returning "capture" status from authorizePayment does not create order transactions. github.com/medusajs/medusa/issues/9887
  3. 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:

  1. Medusa Documentation: Accept Payment in Checkout Flow (Payment Module). docs.medusajs.com/resources/commerce-modules/payment/payment-flow
  2. Medusa Core Workflows Reference: capturePaymentWorkflow. docs.medusajs.com/resources/references/medusa-workflows/capturePaymentWorkflow
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes