Repair Payments & Refunds
Sub-cent rounding mislabels a paid order as partially captured
The customer paid in full. Stripe or your provider settled the charge to the cent, the money is in your account, and by any normal measure the order is done. But Medusa still shows payment_status as partially_captured, and anything downstream that gates on a fully captured order just sits there. Here is why a sub-cent remainder trips Medusa's own rounding tolerance and a small script that clears the false positive the same way Medusa itself would.
Medusa v2 derives an order's payment status inside getLastPaymentStatus in the Payment module, by comparing the payment collection's amount against the summed captured_amount using a fixed tolerance constant, MEDUSA_EPSILON, that defaults to 0.0001. Most payment providers settle to 2 decimal places, but internal BigNumber math on line items, taxes, and promotions can produce a collection amount with 3 or more decimal digits, such as 9.9946 against a captured 9.99. That 0.0046 remainder is bigger than the 0.0001 epsilon, so the order is flagged partially_captured even though it was paid in full to the cent. Run a small Python or Node.js script that lists orders with their payment collections, computes the delta between amount and captured_amount, and flags anything under one currency minor unit as a rounding artifact rather than a real shortfall. Behind a DRY_RUN guard it repairs the safe way, by capturing the exact remainder through the normal capture route, never by writing status directly.
The problem in plain words
It looks like a display bug. The provider's own dashboard says the charge settled. The customer's bank statement says the charge settled. But Medusa's order still carries payment_status: partially_captured, as if a few cents were left uncollected.
Nothing was left uncollected. What actually differs is precision. A payment provider like Stripe or PayPal only understands money to 2 decimal places, so whatever it captures comes back as something like 9.99. Medusa's own totals, on the other hand, are built out of BigNumber math across line items, taxes, and promotions, and that arithmetic can land on a value with 3 or more decimal digits, like 9.9946. When Medusa compares the payment collection's amount against the captured_amount to decide the status, that 0.0046 gap is real math, just math with no cent-level meaning. It is smaller than a cent, but it is bigger than the tolerance Medusa allows for, so the comparison fails and the order gets mislabeled.
Why it happens
This is a precision mismatch built into how Medusa computes status, not a one-off bug in a single order:
- Medusa v2 derives payment status inside
getLastPaymentStatusin the Payment module, by comparing the payment collection's totalamountagainst the summedcaptured_amountwith a fixed tolerance,MEDUSA_EPSILON, that defaults to0.0001. - Most payment providers, Stripe and PayPal included, settle and report captures to 2 decimal places, since that is the smallest unit their systems track for most currencies.
- Medusa's own totals are computed with BigNumber math across line items, taxes, and promotions, and that chain of multiplication and division can produce a result with 3 or more decimal digits that never gets rounded back down to the cent before the comparison runs.
- When the collection amount carries that extra sub-cent remainder, for example
9.9946against a captured9.99, the0.0046delta is bigger than the0.0001epsilon, so the equality check fails and the status resolves topartially_capturedinstead ofcaptured. MEDUSA_EPSILONis not a payments-only constant. It is shared with tax and promotion rounding logic elsewhere in the codebase, which is exactly why Medusa maintainers have not simply bumped it up. Widening it enough to absorb this gap would also widen the tolerance used to catch real discrepancies in tax and promotion totals.
This is a common source of confusion because every number involved looks correct in isolation. The provider settled the exact card amount. The order total in Medusa is the exact tax-and-promotion-adjusted amount. Neither side is wrong, they are just expressed at different precision, and Medusa's status comparison was not built to treat sub-cent noise as equal to zero. See the citations at the end for the exact issues and docs.
You cannot fix this by writing payment_collection.status directly. It is computed by getLastPaymentStatus every time Medusa reads the order, so a forced write gets recomputed and overwritten on the very next read. The safe pattern is to close the actual gap, not paper over the label. Capture the exact outstanding remainder, the sub-cent delta itself, through Medusa's own capture route. Once captured_amount equals amount exactly, the same comparison Medusa already runs naturally resolves the status to captured, no manual status mutation required.
The fix, as a flow
We do not touch checkout and we do not overwrite status. We list orders with their payment collections expanded, compute the delta between amount and captured_amount with a pure function, and only treat it as a rounding artifact when it is greater than zero and smaller than one currency minor unit, for example under 0.01 for a 2 decimal currency. Behind DRY_RUN, the repair step captures exactly that delta on the underlying payment, then Medusa's own status computation catches up on the next read.
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 orders 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
// 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, only logs the order_id/payment_id pairs
Authenticate against the Admin API
Exchange credentials for a token once. Both languages talk to the same REST route, POST /auth/user/emailpass, and reuse the token as a Bearer header on the calls that follow.
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"]
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;
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.token;
}
List orders with their payment collections
Ask for each order's payment_status and its payment_collections, with amount, captured_amount, and status on every collection. Page through with limit and offset. This single call carries everything the decision function needs.
ORDER_FIELDS = (
"id,display_id,status,payment_status,currency_code,"
"*payment_collections,"
"payment_collections.amount,"
"payment_collections.captured_amount,"
"payment_collections.status"
)
def list_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,display_id,status,payment_status,currency_code," +
"*payment_collections," +
"payment_collections.amount," +
"payment_collections.captured_amount," +
"payment_collections.status";
async function listOrders(token) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const url = new URL(`${BASE_URL}/admin/orders`);
url.searchParams.set("fields", ORDER_FIELDS);
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
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. Compute delta = amount - captured_amount, scale a currency minor unit from currencyDecimalDigits so a 0 decimal currency like JPY still behaves correctly, and only call it a rounding artifact when 0 < delta < minorUnit. Anything at or below zero needs no action. Anything at or above one full minor unit is a real outstanding balance, not noise, and gets flagged instead of auto-captured.
def classify_capture_delta(amount, captured_amount, currency_decimal_digits):
"""Pure: no I/O. Returns delta, whether it looks like a rounding artifact,
and the action to take: clear, flag, or none."""
delta = round(amount - captured_amount, currency_decimal_digits + 4)
minor_unit = 10 ** (-currency_decimal_digits)
if delta <= 0:
return {"delta": delta, "isRoundingArtifact": False, "action": "none"}
if delta < minor_unit:
return {"delta": delta, "isRoundingArtifact": True, "action": "clear"}
return {"delta": delta, "isRoundingArtifact": False, "action": "flag"}
export function classifyCaptureDelta(amount, capturedAmount, currencyDecimalDigits) {
// Pure: no I/O. Returns delta, whether it looks like a rounding artifact,
// and the action to take: "clear", "flag", or "none".
const scale = 10 ** (currencyDecimalDigits + 4);
const delta = Math.round((amount - capturedAmount) * scale) / scale;
const minorUnit = 10 ** -currencyDecimalDigits;
if (delta <= 0) return { delta, isRoundingArtifact: false, action: "none" };
if (delta < minorUnit) return { delta, isRoundingArtifact: true, action: "clear" };
return { delta, isRoundingArtifact: false, action: "flag" };
}
Clear the artifact by capturing the exact remainder
For each payment flagged clear, call the capture route with the computed delta as the amount, not with the full order total. This nudges captured_amount to equal amount exactly. Medusa's own getLastPaymentStatus recomputes the status on the next read, so the collection and order resolve to captured without any manual status write.
def capture_remainder(token, payment_id, delta):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/payments/{payment_id}/capture",
json={"amount": delta},
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,*payment_collections"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["order"]
async function captureRemainder(token, paymentId, delta) {
const res = await fetch(`${BASE_URL}/admin/payments/${paymentId}/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ amount: delta }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
async function getOrder(token, orderId) {
const url = new URL(`${BASE_URL}/admin/orders/${orderId}`);
url.searchParams.set("fields", "id,payment_status,*payment_collections");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
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 and the computed delta it would capture. Read the output, confirm every delta really is under a minor unit, then switch it off to let it write. Anything classified flag is left alone and reported, since a delta at or above a full minor unit is a real outstanding balance that needs a human, not a script.
Never write payment_collection.status directly. It is computed by getLastPaymentStatus on every read, so a forced value gets overwritten anyway. Always start with DRY_RUN=true, and only auto-capture a delta that is strictly less than one currency minor unit. Anything bigger gets flagged for a human instead, since that is real money still outstanding.
The full code
Here is the complete script in one file for each language. It authenticates, lists orders with their payment collections, classifies every delta with a pure function, and either logs the repair or captures the exact remainder depending on DRY_RUN, skipping anything that is not a rounding artifact.
"""Find Medusa v2 orders mislabeled partially_captured by a sub-cent BigNumber
remainder, and clear the false positive the safe way. Never writes
payment_collection.status directly, since it is computed by getLastPaymentStatus
on every read. DRY_RUN=true only logs the order_id/payment_id pairs and the
computed delta it would capture. Safe to run again and again, because it only
captures a delta strictly smaller than one currency minor unit.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("clear_rounding_mislabel")
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"
# Number of decimal digits Medusa uses for each currency's minor unit.
# Extend this map for any other zero decimal currencies your store supports.
ZERO_DECIMAL_CURRENCIES = {"jpy", "krw", "vnd"}
ORDER_FIELDS = (
"id,display_id,status,payment_status,currency_code,"
"*payment_collections,"
"payment_collections.amount,"
"payment_collections.captured_amount,"
"payment_collections.status"
)
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_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 currency_decimal_digits(currency_code):
return 0 if (currency_code or "").lower() in ZERO_DECIMAL_CURRENCIES else 2
def classify_capture_delta(amount, captured_amount, currency_decimal_digits):
"""Pure: no I/O. Returns delta, whether it looks like a rounding artifact,
and the action to take: clear, flag, or none."""
delta = round(amount - captured_amount, currency_decimal_digits + 4)
minor_unit = 10 ** (-currency_decimal_digits)
if delta <= 0:
return {"delta": delta, "isRoundingArtifact": False, "action": "none"}
if delta < minor_unit:
return {"delta": delta, "isRoundingArtifact": True, "action": "clear"}
return {"delta": delta, "isRoundingArtifact": False, "action": "flag"}
def payment_ids_for_collection(token, order_id, collection_id):
"""The list endpoint above does not expand nested payments, so fetch the
order once more with payments expanded only for collections we plan to act on."""
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/orders/{order_id}",
params={"fields": "id,*payment_collections.payments"},
headers=headers,
timeout=30,
)
r.raise_for_status()
order = r.json()["order"]
for pc in order.get("payment_collections", []) or []:
if pc.get("id") == collection_id:
return [p["id"] for p in (pc.get("payments") or []) if p.get("id")]
return []
def capture_remainder(token, payment_id, delta):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/payments/{payment_id}/capture",
json={"amount": delta},
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,*payment_collections"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["order"]
def run():
token = get_token()
orders = list_orders(token)
to_clear = []
to_flag = []
for order in orders:
digits = currency_decimal_digits(order.get("currency_code"))
for pc in order.get("payment_collections", []) or []:
if pc.get("status") != "partially_captured":
continue
result = classify_capture_delta(pc.get("amount", 0), pc.get("captured_amount", 0), digits)
if result["action"] == "clear":
to_clear.append((order, pc, result))
elif result["action"] == "flag":
to_flag.append((order, pc, result))
for order, pc, result in to_flag:
log.warning(
"Order %s collection %s: delta %s is a real outstanding balance, not rounding. Flagging for review.",
order["id"], pc.get("id"), result["delta"],
)
if not to_clear:
log.info("No rounding-artifact mislabels found across %d order(s).", len(orders))
return
cleared = 0
for order, pc, result in to_clear:
payment_ids = payment_ids_for_collection(token, order["id"], pc.get("id"))
if not payment_ids:
log.warning(
"Order %s collection %s delta %s looks like a rounding artifact but has no "
"payment to capture against. Flagging for review.", order["id"], pc.get("id"), result["delta"],
)
continue
payment_id = payment_ids[0]
log.info(
"Order %s payment %s: delta %s under one minor unit. %s",
order["id"], payment_id, result["delta"],
"Would capture remainder" if DRY_RUN else "Capturing remainder",
)
if not DRY_RUN:
capture_remainder(token, payment_id, result["delta"])
cleared += 1
if not DRY_RUN:
for order, pc, _ in to_clear:
refreshed = get_order(token, order["id"])
log.info("Order %s payment_status is now %s.", refreshed["id"], refreshed["payment_status"])
log.info("Done. %d order(s) %s. %d flagged for review.", cleared,
"to clear" if DRY_RUN else "cleared", len(to_flag))
if __name__ == "__main__":
run()
/**
* Find Medusa v2 orders mislabeled partially_captured by a sub-cent BigNumber
* remainder, and clear the false positive the safe way. Never writes
* payment_collection.status directly, since it is computed by
* getLastPaymentStatus on every read. DRY_RUN=true only logs the
* order_id/payment_id pairs and the computed delta it would capture. Safe to
* run again and again, because it only captures a delta strictly smaller
* than one currency minor unit.
*/
import { pathToFileURL } from "node:url";
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";
// Number of decimal digits Medusa uses for each currency's minor unit.
// Extend this set for any other zero decimal currencies your store supports.
const ZERO_DECIMAL_CURRENCIES = new Set(["jpy", "krw", "vnd"]);
const ORDER_FIELDS =
"id,display_id,status,payment_status,currency_code," +
"*payment_collections," +
"payment_collections.amount," +
"payment_collections.captured_amount," +
"payment_collections.status";
export function currencyDecimalDigits(currencyCode) {
return ZERO_DECIMAL_CURRENCIES.has((currencyCode || "").toLowerCase()) ? 0 : 2;
}
export function classifyCaptureDelta(amount, capturedAmount, currencyDecimalDigits) {
// Pure: no I/O. Returns delta, whether it looks like a rounding artifact,
// and the action to take: "clear", "flag", or "none".
const scale = 10 ** (currencyDecimalDigits + 4);
const delta = Math.round((amount - capturedAmount) * scale) / scale;
const minorUnit = 10 ** -currencyDecimalDigits;
if (delta <= 0) return { delta, isRoundingArtifact: false, action: "none" };
if (delta < minorUnit) return { delta, isRoundingArtifact: true, action: "clear" };
return { delta, isRoundingArtifact: false, action: "flag" };
}
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.token;
}
async function listOrders(token) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const url = new URL(`${BASE_URL}/admin/orders`);
url.searchParams.set("fields", ORDER_FIELDS);
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
out.push(...body.orders);
offset += limit;
if (offset >= body.count) return out;
}
}
async function paymentIdsForCollection(token, orderId, collectionId) {
// The list endpoint above does not expand nested payments, so fetch the
// order once more with payments expanded only for collections we act on.
const url = new URL(`${BASE_URL}/admin/orders/${orderId}`);
url.searchParams.set("fields", "id,*payment_collections.payments");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
const pc = (body.order.payment_collections || []).find((c) => c.id === collectionId);
return pc ? (pc.payments || []).map((p) => p.id).filter(Boolean) : [];
}
async function captureRemainder(token, paymentId, delta) {
const res = await fetch(`${BASE_URL}/admin/payments/${paymentId}/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ amount: delta }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
async function getOrder(token, orderId) {
const url = new URL(`${BASE_URL}/admin/orders/${orderId}`);
url.searchParams.set("fields", "id,payment_status,*payment_collections");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.order;
}
export async function run() {
const token = await getToken();
const orders = await listOrders(token);
const toClear = [];
const toFlag = [];
for (const order of orders) {
const digits = currencyDecimalDigits(order.currency_code);
for (const pc of order.payment_collections || []) {
if (pc.status !== "partially_captured") continue;
const result = classifyCaptureDelta(pc.amount || 0, pc.captured_amount || 0, digits);
if (result.action === "clear") toClear.push([order, pc, result]);
else if (result.action === "flag") toFlag.push([order, pc, result]);
}
}
for (const [order, pc, result] of toFlag) {
console.warn(
`Order ${order.id} collection ${pc.id}: delta ${result.delta} is a real outstanding balance, not rounding. Flagging for review.`
);
}
if (toClear.length === 0) {
console.log(`No rounding-artifact mislabels found across ${orders.length} order(s).`);
return;
}
let cleared = 0;
for (const [order, pc, result] of toClear) {
const paymentIds = await paymentIdsForCollection(token, order.id, pc.id);
if (paymentIds.length === 0) {
console.warn(
`Order ${order.id} collection ${pc.id} delta ${result.delta} looks like a rounding artifact but has no payment to capture against. Flagging for review.`
);
continue;
}
const paymentId = paymentIds[0];
console.log(
`Order ${order.id} payment ${paymentId}: delta ${result.delta} under one minor unit. ${DRY_RUN ? "Would capture remainder" : "Capturing remainder"}`
);
if (!DRY_RUN) await captureRemainder(token, paymentId, result.delta);
cleared++;
}
if (!DRY_RUN) {
for (const [order] of toClear) {
const refreshed = await getOrder(token, order.id);
console.log(`Order ${refreshed.id} payment_status is now ${refreshed.payment_status}.`);
}
}
console.log(`Done. ${cleared} order(s) ${DRY_RUN ? "to clear" : "cleared"}. ${toFlag.length} flagged for review.`);
}
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, classify_capture_delta. It is pure, no network and no database, so the tests feed in plain numbers and check the answer, including the JPY-style zero decimal case where the minor unit scales differently.
from clear_rounding_mislabel import classify_capture_delta
def test_sub_cent_remainder_is_cleared():
result = classify_capture_delta(9.9946, 9.99, 2)
assert result["action"] == "clear"
assert result["isRoundingArtifact"] is True
assert round(result["delta"], 4) == 0.0046
def test_real_outstanding_balance_is_flagged():
result = classify_capture_delta(10.00, 9.50, 2)
assert result["action"] == "flag"
assert result["isRoundingArtifact"] is False
def test_fully_captured_needs_no_action():
result = classify_capture_delta(10.00, 10.00, 2)
assert result["action"] == "none"
assert result["delta"] == 0
def test_overcaptured_needs_no_action():
result = classify_capture_delta(10.00, 10.01, 2)
assert result["action"] == "none"
def test_delta_exactly_at_minor_unit_is_flagged_not_cleared():
result = classify_capture_delta(10.01, 10.00, 2)
assert result["action"] == "flag"
def test_zero_decimal_currency_scales_minor_unit():
# JPY has no decimal places, so its minor unit is 1, not 0.01.
result = classify_capture_delta(1000.4, 1000, 0)
assert result["action"] == "clear"
def test_zero_decimal_currency_flags_a_full_unit_gap():
result = classify_capture_delta(1001, 1000, 0)
assert result["action"] == "flag"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCaptureDelta } from "./clear-rounding-mislabel.js";
test("sub-cent remainder is cleared", () => {
const result = classifyCaptureDelta(9.9946, 9.99, 2);
assert.equal(result.action, "clear");
assert.equal(result.isRoundingArtifact, true);
assert.equal(Math.round(result.delta * 10000) / 10000, 0.0046);
});
test("real outstanding balance is flagged", () => {
const result = classifyCaptureDelta(10.0, 9.5, 2);
assert.equal(result.action, "flag");
assert.equal(result.isRoundingArtifact, false);
});
test("fully captured needs no action", () => {
const result = classifyCaptureDelta(10.0, 10.0, 2);
assert.equal(result.action, "none");
assert.equal(result.delta, 0);
});
test("overcaptured needs no action", () => {
const result = classifyCaptureDelta(10.0, 10.01, 2);
assert.equal(result.action, "none");
});
test("delta exactly at minor unit is flagged not cleared", () => {
const result = classifyCaptureDelta(10.01, 10.0, 2);
assert.equal(result.action, "flag");
});
test("zero decimal currency scales minor unit", () => {
// JPY has no decimal places, so its minor unit is 1, not 0.01.
const result = classifyCaptureDelta(1000.4, 1000, 0);
assert.equal(result.action, "clear");
});
test("zero decimal currency flags a full unit gap", () => {
const result = classifyCaptureDelta(1001, 1000, 0);
assert.equal(result.action, "flag");
});
Case studies
The order that looked partially captured on every tax-inclusive SKU
A store selling in a region with tax-inclusive pricing and a percentage-off promotion started seeing a steady trickle of orders sit at partially_captured right after checkout, even though Stripe showed every one of them settled in full. Support kept re-explaining to customers that nothing was actually owed, but the dashboard kept implying otherwise.
Running the script in dry run showed every flagged collection had a delta somewhere between 0.001 and 0.009, always under a cent, always on orders where tax and a promotion both touched the same line items. Capturing that exact remainder cleared every one of them to captured without a single manual status edit.
The cart with enough line items for the rounding to add up
A wholesale storefront with carts running to dozens of line items noticed the mislabel scaled with cart size. More line items meant more BigNumber division happening across quantities and unit prices, so bigger carts were more likely to land on a sub-cent remainder than single item orders.
The team ran the script against a week of orders first, and it correctly separated genuine underpayments, carts where a partial refund had left a real cent or two outstanding, from the rounding artifacts. Only the artifacts got auto-captured. The rest stayed flagged for the finance team to review by hand.
Run this against orders flagged partially_captured whenever the label does not match what your payment provider shows. It never writes status directly, so it can never make the desync worse. It closes the actual sub-cent gap by capturing the exact remainder, the same action a human would take through the Admin, and lets Medusa's own getLastPaymentStatus confirm the order is captured on the next read. Anything at or above a full currency minor unit stays flagged for a human, because that is real money still outstanding, not noise a script should quietly absorb.
FAQ
Why does Medusa show my order as partially_captured when I captured the full amount?
Medusa v2 derives payment status by comparing the payment collection's total amount against the summed captured amount, using a fixed tolerance called MEDUSA_EPSILON that defaults to 0.0001. Internal BigNumber math on line items, taxes, and promotions can leave a collection amount with 3 or more decimal digits, like 9.9946, while a payment provider only settles to the cent, like 9.99. That 0.0046 remainder is bigger than the 0.0001 epsilon, so the order is flagged partially_captured even though it was paid in full to the cent.
Why has Medusa not just increased the MEDUSA_EPSILON constant?
MEDUSA_EPSILON is shared between payment status rounding and the tax and promotion rounding logic elsewhere in the codebase. Widening it enough to absorb sub-cent payment remainders would also widen the tolerance used to catch real discrepancies in tax and promotion totals, which risks masking genuine bugs instead of just fixing the payment status false positive.
Is it safe to fix this by editing payment_collection.status directly in the database?
No. payment_status and payment_collection.status are computed by getLastPaymentStatus every time Medusa reads the order, so a direct database write gets overwritten on the next read. The safe repair is to capture the exact outstanding remainder through the normal capture route, which makes captured_amount equal amount and lets Medusa's own status computation resolve to captured on its own.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #13971: Payment status wrongly set to partially captured when captured amount vs payment collection amount difference is under 0.01. github.com/medusajs/medusa/issues/13971
- medusajs/medusa GitHub issue #13972: Rounding issue causes outstanding amount of 0.01 on order summary. github.com/medusajs/medusa/issues/13972
- Medusa Admin User Guide: Manage Order Payments in Medusa Admin. docs.medusajs.com/user-guide/orders/payments
On the solution:
- Medusa Payment Module Reference: capturePayment. docs.medusajs.com/resources/references/payment/capturePayment
- Medusa Documentation: Payment Module (commerce modules). docs.medusajs.com/resources/commerce-modules/payment
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
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 false partially captured label?
If this saved you from a confusing payment status or a risky epsilon tweak, 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