Skip to content

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.

Python and Node.js Medusa Admin API Flag only, never auto-refund
Checking a phone
Photo by Vitaly Gariev on Unsplash
The short answer

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.

order.total subtotal + shipping + tax customer pays in full correct amount, tax included summary.accounting_total subtotal + shipping, tax dropped reference is short by exactly tax_total pending_difference compares paid vs. accounting_total looks overpaid by exactly tax_total
order.total adds tax correctly. The summary's accounting_total does not. pending_difference is built from the tax-short number, so a fully paid order reports a phantom overpayment equal to the tax.

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:

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.

The key insight

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.

List orders total, tax_total, summary.* Compute drift total - accounting_total Drift equals tax_total? yes, flag Report for review no auto refund or re-charge no, matches Trust the summary no tax-shaped drift here
The script only ever reads and compares. A flagged order gets a corrected reference total and, if a refund already matched the missing tax, a tag pointing a human at it. Nothing is auto-refunded or auto-recharged.

Build it step by step

1

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.

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, change to false to write a flag/note only
setup (shell)
// 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
2

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.

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

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.

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

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.

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

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."

crosscheck.py
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
crosscheck.js
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;
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
detect_tax_dropped_from_summary.py
"""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()
detect-tax-dropped-from-summary.js
/**
 * 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.

test_tax_dropped_detection.py
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
tax-dropped.test.js
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

Auto-reconciliation

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.

Order edit

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.

What good looks like

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:

  1. 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
  2. medusajs/medusa GitHub issue #10686: Incorrect outstanding amount and total pending amounts. github.com/medusajs/medusa/issues/10686
  3. 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:

  1. Medusa Documentation: Retrieve Order Totals Using Query. docs.medusajs.com/resources/commerce-modules/order/order-totals
  2. Medusa Documentation: Order Transactions. docs.medusajs.com/resources/commerce-modules/order/transactions
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes