Diagnostic Order Edits & Totals
Order summary drops tax from totals, triggering bogus refunds
A customer pays the full, correct total on a tax-inclusive order. Nothing is owed, nothing is overpaid. But the order's summary in Medusa reports the exact opposite: it thinks the customer overpaid by the tax amount, because the summary's own math never added the tax in. If anything in your stack, an automation, a reconciliation job, an order-edit workflow that "refunds the difference", is wired to that summary number, it will issue a refund for money the customer never actually overpaid. Here is why the summary drops tax and a script that catches it before it costs you real money.
In Medusa v2, confirmed on v2.10.1, the order summary's derived totals, summary.accounting_total, summary.current_order_total, and summary.pending_difference, are computed from subtotal + shipping_total, and tax_total is left out of that math entirely, even though the authoritative order.total field is computed correctly as subtotal plus shipping plus tax minus discounts. Because pending_difference is the field workflows use to decide how much is still owed versus overpaid, a tax-inclusive order whose customer paid the correct order.total looks, to the summary, like it was overpaid by exactly the tax amount. Any automation wired to pending_difference can issue a refund for that phantom overpayment. Pull orders with fields=id,display_id,total,tax_total,subtotal,shipping_total,summary.accounting_total,summary.pending_difference,summary.paid_total, flag any order where tax_total > 0 and the gap between order.total and summary.accounting_total is within a cent of tax_total, and never let the buggy field drive another automated refund. Full code, tests, and citations below.
The problem in plain words
Every Medusa v2 order carries two different views of what it is worth. order.total is the authoritative number, and Medusa computes it the way you would expect: subtotal, plus shipping, plus tax, minus any discounts. That field is correct. It is not the one causing trouble.
The order's summary is a second, derived view meant to answer a narrower question: given what has actually been paid, captured, and refunded, how much is still pending? To answer that it needs its own reference total, and that is where the bug lives. accounting_total, current_order_total, and the pending_difference built from them are computed as subtotal + shipping_total, with tax_total never added in. On an order with real tax, that reference total is short by exactly the tax amount, even though the customer paid the full, correct order.total, tax included.
Why it happens
This comes down to two totals living side by side on the same order, only one of which was built with tax in mind:
order.totalis computed the standard way, subtotal plus shipping plus tax minus discounts, and it is correct on every order that has tax.- The order's
summary, tracked as medusajs/medusa issue #13405, derivesaccounting_totalandcurrent_order_totalfromsubtotal + shipping_totalalone, with no term fortax_totalanywhere in that calculation. pending_difference, the field workflows use to decide whether an order still owes money or was overpaid, is built on top of that tax-shortaccounting_total, not onorder.total.- A customer who pays the full, correct
order.total, tax included, has genuinely paid everything owed. But compared against the summary's own reference point, that payment looks like it exceeds what was "owed" by precisely the tax amount. - Related reports, issue #10686 on incorrect outstanding and pending amounts, and issue #13972 on a rounding issue causing a one cent outstanding balance, point at the same family of problem: the summary's totals and the order's true total can silently disagree.
This is a common source of confusion because nothing errors and no exception is thrown. The order looks paid, the storefront shows a normal confirmation, and the only place the mismatch shows up is in a number most people never read directly, summary.pending_difference. The trouble starts the moment something does read it, an admin action that "refunds the difference," a reconciliation job that trusts the summary over the order, or any automation built on the assumption that pending_difference means what its name says. See the citations at the end for the exact issues and docs.
order.total is the ground truth on a Medusa order, tax included, and it is correct. summary.pending_difference is not a second source of truth, it is a derived number built from a total that forgot to add tax, and on any tax-inclusive order it will read wrong by exactly tax_total. Never let that field drive a refund or a capture decision on its own. Compute order.total - summary.paid_total yourself and trust that instead.
The fix, as a flow
We never let the buggy pending_difference trigger anything. We pull each order with its raw totals and its summary breakdown side by side, compute the exact drift between order.total and summary.accounting_total, and check whether that drift matches tax_total almost exactly. If it does, and a refund already fired for that same amount, we flag the order for manual review instead of trying to auto-correct it.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend with an admin account that can read orders and payment collections. Exchange the email and password for a JWT once and reuse it. DRY_RUN defaults to true, and even when it is false, this script only writes a note or metadata flag, never a refund or a recharge.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to write a flag/note only
// Node 18+ has fetch built in, no dependencies needed
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write a flag/note only
Authenticate against the Admin API
Exchange the admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every following call.
import os, requests
BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
List orders with the raw totals and the summary breakdown together
Ask for total, tax_total, subtotal, shipping_total, and the individual dotted summary.* fields in one call. Summary totals must be requested explicitly this way, fields=* alone will not return them. Page through with offset and limit using the response's count.
ORDER_FIELDS = (
"id,display_id,total,tax_total,subtotal,shipping_total,"
"summary.accounting_total,summary.current_order_total,summary.pending_difference,"
"summary.paid_total,summary.transaction_total,summary.refunded_total"
)
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_orders_with_summary(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": ORDER_FIELDS,
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
const ORDER_FIELDS =
"id,display_id,total,tax_total,subtotal,shipping_total," +
"summary.accounting_total,summary.current_order_total,summary.pending_difference," +
"summary.paid_total,summary.transaction_total,summary.refunded_total";
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
return res.json();
}
async function listOrdersWithSummary(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: ORDER_FIELDS,
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
Decide, with one pure function
Keep the whole decision in a function with no network calls. It takes the order's total, tax_total, and summary, and checks two things: is there tax on this order at all, and does the drift between order.total and summary.accounting_total land within a cent of tax_total. That signature, a gap that matches the tax and nothing else, is what separates this bug from a rounding artifact or a real partial refund. It always returns a corrected pending_difference too, computed directly from order.total and summary.paid_total, so callers have a trustworthy number to use in place of the buggy one.
EPSILON = 0.01
def detect_tax_dropped_from_summary(order, epsilon=EPSILON):
summary = order["summary"]
drift = order["total"] - summary["accounting_total"]
tax_total = order["tax_total"]
affected = tax_total > 0 and abs(drift - tax_total) <= epsilon
corrected_pending_difference = order["total"] - summary["paid_total"]
return {
"affected": affected,
"drift": drift,
"correctedPendingDifference": corrected_pending_difference,
}
const EPSILON = 0.01;
export function detectTaxDroppedFromSummary(order, epsilon = EPSILON) {
const { summary } = order;
const drift = order.total - summary.accounting_total;
const taxTotal = order.tax_total;
const affected = taxTotal > 0 && Math.abs(drift - taxTotal) <= epsilon;
const correctedPendingDifference = order.total - summary.paid_total;
return { affected, drift, correctedPendingDifference };
}
Cross-check whether a bogus refund already fired
Detecting the bug and detecting whether it already cost you money are two different questions. Pull each affected order's payment collections and their refunds, and check whether any refund amount lines up with the missing tax_total. That distinguishes "the bug is present but nothing acted on it yet" from "a refund already went out for money nobody actually overpaid."
def already_refunded_tax(order_id, tax_total, token, epsilon=EPSILON):
data = admin_get(
token,
f"/admin/orders/{order_id}/payment-collections",
{"fields": "id,status,*payments,*payments.refunds"},
)
for collection in data.get("payment_collections", []):
for payment in collection.get("payments") or []:
for refund in payment.get("refunds") or []:
if abs(refund.get("amount", 0) - tax_total) <= epsilon:
return True
return False
async function alreadyRefundedTax(orderId, taxTotal, token, epsilon = EPSILON) {
const data = await adminGet(token, `/admin/orders/${orderId}/payment-collections`, {
fields: "id,status,*payments,*payments.refunds",
});
for (const collection of data.payment_collections || []) {
for (const payment of collection.payments || []) {
for (const refund of payment.refunds || []) {
if (Math.abs((refund.amount || 0) - taxTotal) <= epsilon) return true;
}
}
}
return false;
}
Wire it together and flag, never auto-refund or auto-recharge
The loop lists orders, runs the pure decision on each, and for every affected order records a reporting-only snapshot with the correct total, the buggy accounting total, and the drift. When DRY_RUN=false and a bogus refund already fired, the only write is a note or metadata flag on the order, never a reversing charge, since Medusa has no un-refund primitive and re-charging a customer without consent is worse than a wrong dashboard number. A human decides whether to manually re-invoice the shortfall.
Never let summary.pending_difference drive another automated refund or capture while this bug is present. If a bogus refund already fired, do not auto-issue a reversing charge. Flag the order with POST /admin/orders/:id and metadata like {flagged_tax_refund_drift: true, expected_manual_recharge: tax_total}, gated by DRY_RUN, and let a human decide whether to manually re-invoice the customer through a new payment collection.
The full code
Here is the complete script in one file for each language. It authenticates, lists every order with its raw totals and summary breakdown, runs the pure detection function, cross-checks payment history for a bogus refund, and logs one finding per affected order. It never issues a refund or a recharge.
"""Flag Medusa v2 orders whose summary reports a phantom overpayment because
tax_total was left out of the summary's own totals math. order.total is
computed correctly as subtotal + shipping_total + tax_total minus discounts,
but summary.accounting_total and the pending_difference built on top of it
are computed from subtotal + shipping_total alone (see medusajs/medusa#13405).
A fully paid, tax-inclusive order therefore looks overpaid by exactly the
tax amount, and anything wired to pending_difference can issue a bogus
refund for money nobody actually overpaid. This script only flags the
divergence, and if a refund already matched the missing tax it flags that
too, gated behind DRY_RUN. It never issues a refund or a recharge. Safe to
run again and again.
Guide: https://www.allanninal.dev/medusa/order-summary-tax-excluded-bogus-refund/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_tax_dropped_from_summary")
BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
ADMIN_PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EPSILON = 0.01
ORDER_FIELDS = (
"id,display_id,total,tax_total,subtotal,shipping_total,"
"summary.accounting_total,summary.current_order_total,summary.pending_difference,"
"summary.paid_total,summary.transaction_total,summary.refunded_total"
)
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def admin_post(token, path, json_body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
json=json_body,
timeout=30,
)
r.raise_for_status()
return r.json()
def detect_tax_dropped_from_summary(order, epsilon=EPSILON):
"""Pure decision function. No I/O.
order: {
"total": float, "tax_total": float,
"summary": {"accounting_total": float, "current_order_total": float,
"pending_difference": float, "paid_total": float},
}
Returns {"affected", "drift", "correctedPendingDifference"}.
"""
summary = order["summary"]
drift = order["total"] - summary["accounting_total"]
tax_total = order["tax_total"]
affected = tax_total > 0 and abs(drift - tax_total) <= epsilon
corrected_pending_difference = order["total"] - summary["paid_total"]
return {
"affected": affected,
"drift": drift,
"correctedPendingDifference": corrected_pending_difference,
}
def already_refunded_tax(order_id, tax_total, token, epsilon=EPSILON):
data = admin_get(
token,
f"/admin/orders/{order_id}/payment-collections",
{"fields": "id,status,*payments,*payments.refunds"},
)
for collection in data.get("payment_collections", []):
for payment in collection.get("payments") or []:
for refund in payment.get("refunds") or []:
if abs(refund.get("amount", 0) - tax_total) <= epsilon:
return True
return False
def flag_order(order_id, tax_total, token):
admin_post(token, f"/admin/orders/{order_id}", {
"metadata": {"flagged_tax_refund_drift": True, "expected_manual_recharge": tax_total},
})
def list_orders_with_summary(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": ORDER_FIELDS,
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
def run():
token = get_admin_token()
raw_orders = list_orders_with_summary(token)
report = {}
flagged = 0
for raw_order in raw_orders:
outcome = detect_tax_dropped_from_summary(raw_order)
if not outcome["affected"]:
continue
order_id = raw_order["id"]
report[order_id] = {
"correct_total": raw_order["total"],
"buggy_accounting_total": raw_order["summary"]["accounting_total"],
"drift": raw_order["tax_total"],
}
already_refunded = already_refunded_tax(order_id, raw_order["tax_total"], token)
flagged += 1
log.warning(
"Order %s tax dropped from summary: correct_total=%s buggy_accounting_total=%s "
"drift=%s corrected_pending_difference=%s already_refunded=%s. %s",
raw_order.get("display_id") or order_id,
raw_order["total"], raw_order["summary"]["accounting_total"],
outcome["drift"], outcome["correctedPendingDifference"], already_refunded,
"would flag for review" if DRY_RUN else "flagging for review",
)
if already_refunded and not DRY_RUN:
flag_order(order_id, raw_order["tax_total"], token)
log.info(
"Done. %d order(s) with tax dropped from the summary flagged for manual review. "
"No refund or recharge was issued by this script.",
flagged,
)
return report
if __name__ == "__main__":
run()
/**
* Flag Medusa v2 orders whose summary reports a phantom overpayment because
* tax_total was left out of the summary's own totals math. order.total is
* computed correctly as subtotal + shipping_total + tax_total minus discounts,
* but summary.accounting_total and the pending_difference built on top of it
* are computed from subtotal + shipping_total alone (see medusajs/medusa#13405).
* A fully paid, tax-inclusive order therefore looks overpaid by exactly the
* tax amount, and anything wired to pending_difference can issue a bogus
* refund for money nobody actually overpaid. This script only flags the
* divergence, and if a refund already matched the missing tax it flags that
* too, gated behind DRY_RUN. It never issues a refund or a recharge. Safe to
* run again and again.
*
* Guide: https://www.allanninal.dev/medusa/order-summary-tax-excluded-bogus-refund/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EPSILON = 0.01;
const ORDER_FIELDS =
"id,display_id,total,tax_total,subtotal,shipping_total," +
"summary.accounting_total,summary.current_order_total,summary.pending_difference," +
"summary.paid_total,summary.transaction_total,summary.refunded_total";
export function detectTaxDroppedFromSummary(order, epsilon = EPSILON) {
// Pure: no I/O. order = { total, tax_total,
// summary: { accounting_total, current_order_total, pending_difference, paid_total } }
const { summary } = order;
const drift = order.total - summary.accounting_total;
const taxTotal = order.tax_total;
const affected = taxTotal > 0 && Math.abs(drift - taxTotal) <= epsilon;
const correctedPendingDifference = order.total - summary.paid_total;
return { affected, drift, correctedPendingDifference };
}
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
return res.json();
}
async function adminPost(token, path, jsonBody) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(jsonBody),
});
if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
return res.json();
}
async function alreadyRefundedTax(orderId, taxTotal, token, epsilon = EPSILON) {
const data = await adminGet(token, `/admin/orders/${orderId}/payment-collections`, {
fields: "id,status,*payments,*payments.refunds",
});
for (const collection of data.payment_collections || []) {
for (const payment of collection.payments || []) {
for (const refund of payment.refunds || []) {
if (Math.abs((refund.amount || 0) - taxTotal) <= epsilon) return true;
}
}
}
return false;
}
async function flagOrder(orderId, taxTotal, token) {
await adminPost(token, `/admin/orders/${orderId}`, {
metadata: { flagged_tax_refund_drift: true, expected_manual_recharge: taxTotal },
});
}
async function listOrdersWithSummary(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: ORDER_FIELDS,
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
export async function run() {
const token = await getAdminToken();
const rawOrders = await listOrdersWithSummary(token);
const report = {};
let flagged = 0;
for (const rawOrder of rawOrders) {
const outcome = detectTaxDroppedFromSummary(rawOrder);
if (!outcome.affected) continue;
const orderId = rawOrder.id;
report[orderId] = {
correct_total: rawOrder.total,
buggy_accounting_total: rawOrder.summary.accounting_total,
drift: rawOrder.tax_total,
};
const alreadyRefunded = await alreadyRefundedTax(orderId, rawOrder.tax_total, token);
flagged++;
console.warn(
`Order ${rawOrder.display_id || orderId} tax dropped from summary: ` +
`correct_total=${rawOrder.total} buggy_accounting_total=${rawOrder.summary.accounting_total} ` +
`drift=${outcome.drift} corrected_pending_difference=${outcome.correctedPendingDifference} ` +
`already_refunded=${alreadyRefunded}. ` +
`${DRY_RUN ? "would flag for review" : "flagging for review"}`
);
if (alreadyRefunded && !DRY_RUN) await flagOrder(orderId, rawOrder.tax_total, token);
}
console.log(
`Done. ${flagged} order(s) with tax dropped from the summary flagged for manual review. ` +
`No refund or recharge was issued by this script.`
);
return report;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The part worth testing is detect_tax_dropped_from_summary, since it decides which orders get flagged. It is pure, plain numbers in, a decision object out, so the tests need no network and no Medusa instance.
from detect_tax_dropped_from_summary import detect_tax_dropped_from_summary
def order(**over):
base = {
"total": 120.0,
"tax_total": 20.0,
"summary": {
"accounting_total": 100.0,
"current_order_total": 100.0,
"pending_difference": -20.0,
"paid_total": 120.0,
},
}
base.update(over)
return base
def test_affected_when_drift_matches_tax_total():
result = detect_tax_dropped_from_summary(order())
assert result["affected"] is True
assert result["drift"] == 20.0
assert result["correctedPendingDifference"] == 0.0
def test_not_affected_when_no_tax_on_order():
o = order(tax_total=0.0, total=100.0)
o["summary"]["accounting_total"] = 100.0
result = detect_tax_dropped_from_summary(o)
assert result["affected"] is False
def test_not_affected_with_legitimate_partial_refund():
# summary correctly reflects a partial refund, drift does not match tax
o = order()
o["summary"]["accounting_total"] = 110.0 # only $10 off, not the $20 tax
result = detect_tax_dropped_from_summary(o)
assert result["affected"] is False
def test_rounding_noise_within_epsilon_still_affected():
o = order()
o["summary"]["accounting_total"] = 100.004
result = detect_tax_dropped_from_summary(o)
assert result["affected"] is True
def test_rounding_noise_outside_epsilon_not_affected():
o = order()
o["summary"]["accounting_total"] = 99.9 # off by 0.1 beyond the 20.0 tax match
result = detect_tax_dropped_from_summary(o)
assert result["affected"] is False
def test_corrected_pending_difference_uses_total_minus_paid():
o = order(total=150.0)
o["summary"]["paid_total"] = 90.0
result = detect_tax_dropped_from_summary(o)
assert result["correctedPendingDifference"] == 60.0
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectTaxDroppedFromSummary } from "./detect-tax-dropped-from-summary.js";
const order = (over = {}) => ({
total: 120.0,
tax_total: 20.0,
summary: {
accounting_total: 100.0,
current_order_total: 100.0,
pending_difference: -20.0,
paid_total: 120.0,
},
...over,
});
test("affected when drift matches tax_total", () => {
const result = detectTaxDroppedFromSummary(order());
assert.equal(result.affected, true);
assert.equal(result.drift, 20.0);
assert.equal(result.correctedPendingDifference, 0.0);
});
test("not affected when no tax on order", () => {
const o = order({ tax_total: 0.0, total: 100.0 });
o.summary.accounting_total = 100.0;
const result = detectTaxDroppedFromSummary(o);
assert.equal(result.affected, false);
});
test("not affected with legitimate partial refund", () => {
const o = order();
o.summary.accounting_total = 110.0; // only $10 off, not the $20 tax
const result = detectTaxDroppedFromSummary(o);
assert.equal(result.affected, false);
});
test("rounding noise within epsilon still affected", () => {
const o = order();
o.summary.accounting_total = 100.004;
const result = detectTaxDroppedFromSummary(o);
assert.equal(result.affected, true);
});
test("rounding noise outside epsilon not affected", () => {
const o = order();
o.summary.accounting_total = 99.9; // off by 0.1 beyond the 20.0 tax match
const result = detectTaxDroppedFromSummary(o);
assert.equal(result.affected, false);
});
test("corrected pending difference uses total minus paid", () => {
const o = order({ total: 150.0 });
o.summary.paid_total = 90.0;
const result = detectTaxDroppedFromSummary(o);
assert.equal(result.correctedPendingDifference, 60.0);
});
Case studies
The nightly job that refunded every tax-inclusive order
A store running a nightly reconciliation job trusted summary.pending_difference as the source of truth for "money owed back to the customer." Every tax-inclusive order in the region came back negative by exactly its tax amount, and the job dutifully issued a small refund on each one, night after night, quietly bleeding out the tax portion of every sale.
Running the detection script against the order history flagged the exact pattern, drift equal to tax_total on every affected order, and the refunds already fired lined up perfectly with those amounts. The team turned off the auto-refund job, switched its "money owed" check to order.total - summary.paid_total, and used the flagged list to decide which customers needed a manual, deliberate re-invoice for the shortfall.
The support agent whose "refund the difference" button fired on a healthy order
A support agent opened an order to adjust a line item, and the order-edit screen offered to "refund the difference" based on the summary. The order had not actually changed price, the tax was simply missing from the summary's own math, so the button was offering to refund a customer who owed nothing and had paid correctly.
Because the team had already wired detection into their admin tooling, the screen showed a warning instead: this order's pending_difference does not match its true balance, flagged for tax drift, do not refund. The agent moved on to the actual edit without triggering a refund that would have needed to be manually reversed with no way to actually undo it.
Run this on a schedule and every order where the summary dropped tax from its own totals gets caught before automation acts on it, with the correct total, the buggy number, and the exact drift laid out for review. Nothing gets auto-refunded and nothing gets auto-recharged. When a bogus refund already happened, the order is flagged with the amount a human should consider re-invoicing, never silently reversed. Until Medusa ships the fix tracked in issue #13405, treat order.total - summary.paid_total as the number you actually trust.
FAQ
Why does a fully paid Medusa order look overpaid by the tax amount?
In Medusa v2, confirmed on v2.10.1, the order summary's derived totals, accounting_total, current_order_total, and pending_difference, are computed from subtotal plus shipping_total, and tax_total is left out of that math, even though the authoritative order.total field is computed correctly as subtotal plus shipping plus tax minus discounts. A customer who paid the correct order.total, tax included, looks to the summary like they overpaid by exactly the tax amount, because the summary's own reference point is short by that much.
How do I detect an order affected by this summary tax bug?
Pull orders with GET /admin/orders?fields=id,display_id,total,tax_total,subtotal,shipping_total,summary.accounting_total,summary.current_order_total,summary.pending_difference,summary.paid_total,summary.transaction_total,summary.refunded_total using the admin JWT, requesting the summary fields explicitly since fields=* alone will not return them. An order is affected when tax_total is greater than zero and the gap between order.total and summary.accounting_total is within a cent of tax_total itself. That signature, a drift equal to the tax and nothing else, rules out a rounding artifact or a real partial refund.
Is it safe to auto-fix the buggy pending_difference or reverse a bogus refund?
No. Treat this as unsafe to auto-repair at the workflow level. Re-running Medusa's own refund or capture workflow against corrupted totals can compound the error, and Medusa has no un-refund primitive, so if a bogus refund already fired you cannot safely auto-reverse it without the customer's consent. The safe pattern is to record a corrected snapshot in your own audit store, flag any order where a refund already matched the missing tax amount, and let a human decide whether to manually re-invoice the shortfall. Until Medusa ships a fix for issue #13405, treat order.total minus summary.paid_total as the trustworthy replacement for summary.pending_difference.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #13405: Order Summary Tax Calculation Incorrect in v2.10.1, causes payment discrepancy and unintended refunds. github.com/medusajs/medusa/issues/13405
- medusajs/medusa GitHub issue #10686: Incorrect outstanding amount and total pending amounts. github.com/medusajs/medusa/issues/10686
- medusajs/medusa GitHub issue #13972: Rounding issue causes outstanding amount of 0.01 on order summary. github.com/medusajs/medusa/issues/13972
On the solution:
- Medusa Documentation: Retrieve Order Totals Using Query. docs.medusajs.com/resources/commerce-modules/order/order-totals
- Medusa Documentation: Order Transactions. docs.medusajs.com/resources/commerce-modules/order/transactions
- Medusa Core Workflows Reference: refundPaymentsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/refundPaymentsWorkflow
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 catch a bogus refund before it fired?
If this saved you from refunding tax nobody overpaid, or from a confusing reconciliation, 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