Diagnostic Regions, pricing, and currency

Tax-inclusive pricing shows wrong totals

A cart looks fine at a glance, but the numbers do not reconcile. The subtotal plus the tax total does not equal the total, or a shipping line seems to have tax baked in twice, or a discount only makes sense if you assume the wrong basis. Nothing crashed and no error was logged. Here is why tax-inclusive settings in Medusa v2 live in more places than most teams realize, how they drift apart, and a script that finds every context where they disagree so you can fix the setting, not guess at a number.

Python and Node.js Medusa Admin API Flag and report, never rewrite totals
The short answer

Whether a price is tax-inclusive is not a single global flag in Medusa v2. It is decided per calculation context by a PricePreference record keyed on either region_id or currency_code, with an is_tax_inclusive boolean. The same idea is also set independently on Region, Currency, PriceList, and ShippingOption. Because these live in different admin screens, they can drift out of sync, for example a region turns tax-inclusive pricing on but an older shipping option price was created before the switch. calculatePrices then computes is_calculated_price_tax_inclusive inconsistently across line items, and the cart or order totals stop reconciling: subtotal + tax_total no longer equals total. Run a script that lists regions, price preferences, price lists, and shipping options, and cross-checks every price's context against the preference map. Full code, tests, and a dry run guard are below.

The problem in plain words

It is easy to assume "tax-inclusive" is one switch you flip for a store and everything downstream just follows it. In Medusa v2 it is not. The Pricing module keeps its own record for this, a PricePreference, and it is scoped narrowly: one preference per region_id, or one per currency_code. On top of that, Region, Currency, PriceList, and ShippingOption each carry their own independent tax-inclusive style setting.

Nothing forces all of those to agree. A store can turn tax-inclusive pricing on for a region in one admin screen, while a shipping option price or a price list override tied to that same region was created earlier, before the switch, and never got touched. A currency-level preference can also quietly conflict with the region-level one. When a cart is priced, calculatePrices in the Pricing module resolves is_calculated_price_tax_inclusive and is_original_price_tax_inclusive for each line item using whatever context applies to it. If that resolution disagrees across line items in the same cart, the totals engine backs tax out of some amounts, treating them as already including it, while treating others as exclusive and adding tax on top. The displayed subtotal, tax_total, and total stop reconciling, even though every individual number came from a real, valid calculation.

PricePreference region_id: is_tax_inclusive=true ShippingOption price created before the switch calculatePrices per line item inconsistent per item Some inclusive, some exclusive subtotal + tax_total ≠ total Region, Currency, PriceList, and ShippingOption each keep their own includes_tax setting too
The region's tax-inclusive preference and an older shipping option price disagree. calculatePrices resolves each line item on its own context, so the cart totals stop reconciling.

Why it happens

Tax inclusivity is configured in several different admin screens in Medusa v2, and nothing keeps them synchronized automatically. A few common ways stores end up here:

This is a real, reported failure mode, not a hypothetical: merchants have hit the exact "shipping option ignores region's tax-inclusive setting" behavior, the mixed inclusive and exclusive discount case, and outright incorrect order summary tax calculations in production. See the citations at the end for the exact issues and docs.

The key insight

Never rewrite an existing order's totals or a price amount directly to paper over this. Those numbers must be recalculated by Medusa's own totals workflow, not overwritten by a script guessing at what they should have been. The only thing that is always safe is to compare every price's effective region_id and currency_code context against the PricePreference records that apply to it, and flag every context where a preference is missing or two preferences disagree. Repair, if a human approves it, is limited to writing the PricePreference itself, never the historical prices or totals it should have governed.

The fix, as a flow

We do not touch any existing order or price amount. The job lists regions and their currencies, lists every PricePreference, then lists price lists and shipping options and builds a lightweight context for each price they carry. A pure decision function compares each context's region and currency preferences and flags anything that disagrees or has no preference configured at all. Only a human-approved PricePreference write is ever considered, and only when DRY_RUN is off.

List regions and PricePreferences List price contexts price lists, shipping options Pure decision fn findTaxInclusivityMismatches Preferences agree? no, report only Flagged report sent to the merchant yes, matches No action needed preference already set
Every mismatch is reported. A PricePreference write only happens after a human approves it, and even then it never touches an existing price amount or order total.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.

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
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, change to false to write
2

List regions and every PricePreference

Ask for regions with their currency_code, and separately page through /admin/price-preferences. Build two maps in memory, one from region_id to is_tax_inclusive and one from currency_code to is_tax_inclusive, since a preference can be scoped to either attribute.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

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_regions(token):
    regions = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/regions", {
            "fields": "id,name,currency_code,automatic_taxes",
            "limit": limit,
            "offset": offset,
        })
        regions.extend(data["regions"])
        offset += limit
        if offset >= data["count"]:
            return regions

def list_price_preferences(token):
    preferences = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/price-preferences", {
            "fields": "id,attribute,value,is_tax_inclusive",
            "limit": limit,
            "offset": offset,
        })
        preferences.extend(data["price_preferences"])
        offset += limit
        if offset >= data["count"]:
            return preferences
step2.js
import Medusa from "@medusajs/js-sdk";

const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });

async function listRegions() {
  const regions = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const { regions: page, count } = await sdk.admin.region.list({
      fields: "id,name,currency_code,automatic_taxes",
      limit,
      offset,
    });
    regions.push(...page);
    offset += limit;
    if (offset >= count) return regions;
  }
}

async function listPricePreferences() {
  const preferences = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const { price_preferences: page, count } = await sdk.admin.pricePreference.list({
      fields: "id,attribute,value,is_tax_inclusive",
      limit,
      offset,
    });
    preferences.push(...page);
    offset += limit;
    if (offset >= count) return preferences;
  }
}
3

List price lists and shipping options as price contexts

Expand each price list's prices and each shipping option's prices with their price_rules, and reduce every price down to the region_id or currency_code it is actually scoped to. Each one becomes a small context record the decision function can check against the preference maps.

step3.py
def _rule_value(price, key):
    for rule in price.get("price_rules") or []:
        if rule.get("attribute") == key:
            return rule.get("value")
    return price.get(key)

def price_lists_as_contexts(token):
    data = admin_get(token, "/admin/price-lists", {
        "fields": "id,title,status,*prices",
        "limit": 100,
    })
    contexts = []
    for price_list in data["price_lists"]:
        for price in price_list.get("prices") or []:
            contexts.append({
                "source_type": "price_list",
                "source_id": price_list["id"],
                "region_id": _rule_value(price, "region_id"),
                "currency_code": price.get("currency_code"),
            })
    return contexts

def shipping_options_as_contexts(token):
    data = admin_get(token, "/admin/shipping-options", {
        "fields": "id,name,*prices,*prices.price_rules",
        "limit": 100,
    })
    contexts = []
    for option in data["shipping_options"]:
        for price in option.get("prices") or []:
            contexts.append({
                "source_type": "shipping_option",
                "source_id": option["id"],
                "region_id": _rule_value(price, "region_id"),
                "currency_code": price.get("currency_code"),
            })
    return contexts
step3.js
function ruleValue(price, key) {
  for (const rule of price.price_rules || []) {
    if (rule.attribute === key) return rule.value;
  }
  return price[key];
}

async function priceListsAsContexts() {
  const { price_lists } = await sdk.admin.priceList.list({
    fields: "id,title,status,*prices",
    limit: 100,
  });
  const contexts = [];
  for (const priceList of price_lists) {
    for (const price of priceList.prices || []) {
      contexts.push({
        source_type: "price_list",
        source_id: priceList.id,
        region_id: ruleValue(price, "region_id"),
        currency_code: price.currency_code,
      });
    }
  }
  return contexts;
}

async function shippingOptionsAsContexts() {
  const { shipping_options } = await sdk.admin.shippingOption.list({
    fields: "id,name,*prices,*prices.price_rules",
    limit: 100,
  });
  const contexts = [];
  for (const option of shipping_options) {
    for (const price of option.prices || []) {
      contexts.push({
        source_type: "shipping_option",
        source_id: option.id,
        region_id: ruleValue(price, "region_id"),
        currency_code: price.currency_code,
      });
    }
  }
  return contexts;
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the preference records and the price contexts and returns the flagged list. It never touches the network, so it is trivial to unit test. A context is flagged when its region preference and currency preference disagree, or when neither exists at all, meaning it silently falls back to an unconfigured default.

decide.py
def find_tax_inclusivity_mismatches(preferences, price_contexts):
    """Pure decision function. No I/O.

    preferences: [{"attribute": "region_id" | "currency_code", "value": str,
                   "is_tax_inclusive": bool}, ...]
    price_contexts: [{"source_type": "price_list" | "shipping_option", "source_id": str,
                       "region_id": str | None, "currency_code": str | None}, ...]

    Returns a list of mismatches: {source_type, source_id, region_id, currency_code,
    region_pref, currency_pref, reason}.
    """
    region_pref_map = {}
    currency_pref_map = {}
    for pref in preferences:
        if pref["attribute"] == "region_id":
            region_pref_map[pref["value"]] = pref["is_tax_inclusive"]
        elif pref["attribute"] == "currency_code":
            currency_pref_map[pref["value"]] = pref["is_tax_inclusive"]

    mismatches = []
    for ctx in price_contexts:
        region_pref = region_pref_map.get(ctx.get("region_id"))
        currency_pref = currency_pref_map.get(ctx.get("currency_code"))

        if region_pref is not None and currency_pref is not None and region_pref != currency_pref:
            reason = "region/currency preference conflict"
        elif region_pref is None and currency_pref is None:
            reason = "no preference configured, defaults may drift"
        else:
            continue

        mismatches.append({
            "source_type": ctx["source_type"],
            "source_id": ctx["source_id"],
            "region_id": ctx.get("region_id"),
            "currency_code": ctx.get("currency_code"),
            "region_pref": region_pref,
            "currency_pref": currency_pref,
            "reason": reason,
        })
    return mismatches
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {Array<{ attribute: "region_id"|"currency_code", value: string, is_tax_inclusive: boolean }>} preferences
 * @param {Array<{ source_type: "price_list"|"shipping_option", source_id: string,
 *   region_id?: string, currency_code?: string }>} priceContexts
 * @returns {Array<{ source_type: string, source_id: string, region_id?: string,
 *   currency_code?: string, region_pref?: boolean, currency_pref?: boolean, reason: string }>}
 */
export function findTaxInclusivityMismatches(preferences, priceContexts) {
  const regionPrefMap = new Map();
  const currencyPrefMap = new Map();
  for (const pref of preferences) {
    if (pref.attribute === "region_id") regionPrefMap.set(pref.value, pref.is_tax_inclusive);
    else if (pref.attribute === "currency_code") currencyPrefMap.set(pref.value, pref.is_tax_inclusive);
  }

  const mismatches = [];
  for (const ctx of priceContexts) {
    const regionPref = regionPrefMap.get(ctx.region_id);
    const currencyPref = currencyPrefMap.get(ctx.currency_code);

    let reason;
    if (regionPref !== undefined && currencyPref !== undefined && regionPref !== currencyPref) {
      reason = "region/currency preference conflict";
    } else if (regionPref === undefined && currencyPref === undefined) {
      reason = "no preference configured, defaults may drift";
    } else {
      continue;
    }

    mismatches.push({
      source_type: ctx.source_type,
      source_id: ctx.source_id,
      region_id: ctx.region_id,
      currency_code: ctx.currency_code,
      region_pref: regionPref,
      currency_pref: currencyPref,
      reason,
    });
  }
  return mismatches;
}
5

Spot-check live orders for the symptom

Pull recent orders and assert subtotal + tax_total + shipping_total is within a small rounding tolerance of total. This does not diagnose the root cause on its own, but it tells you whether the drift you found in step 4 has already reached a real order, which is useful evidence for the report.

step5.py
ROUNDING_TOLERANCE = 0.02

def orders_with_bad_totals(token):
    data = admin_get(token, "/admin/orders", {
        "fields": "id,region_id,currency_code,subtotal,tax_total,shipping_total,total",
        "limit": 100,
    })
    bad = []
    for order in data["orders"]:
        expected = (order.get("subtotal") or 0) + (order.get("tax_total") or 0) + (order.get("shipping_total") or 0)
        if abs(expected - (order.get("total") or 0)) > ROUNDING_TOLERANCE:
            bad.append(order)
    return bad
step5.js
const ROUNDING_TOLERANCE = 0.02;

async function ordersWithBadTotals() {
  const { orders } = await sdk.admin.order.list({
    fields: "id,region_id,currency_code,subtotal,tax_total,shipping_total,total",
    limit: 100,
  });
  return orders.filter((order) => {
    const expected = (order.subtotal || 0) + (order.tax_total || 0) + (order.shipping_total || 0);
    return Math.abs(expected - (order.total || 0)) > ROUNDING_TOLERANCE;
  });
}
6

Wire it together and report, never silently rewrite

The loop ties every piece together: list regions and preferences, list price list and shipping option contexts, run the pure decision function, and log every mismatch alongside the order spot-check. Nothing is ever written except a PricePreference for a specific region_id or currency_code, and only after a human sets APPLY_FIX_FOR to the exact context they approved. Historical order totals and existing price amounts are never touched directly.

Run it safe

Always start with DRY_RUN=true. This script never rewrites an order's totals or a price amount. The only write it will ever make is a PricePreference create or update for a region or currency a human has explicitly approved, so a bulk rewrite of live pricing or tax data can never happen by accident.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every region, price list, and shipping option, flags every tax-inclusivity mismatch, and only ever writes the one approved PricePreference when DRY_RUN is off.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

find_tax_inclusivity_mismatches.py
"""Flag Medusa contexts where tax-inclusive pricing settings disagree.

Whether a price is tax-inclusive is not one global flag in Medusa v2. It is
decided per calculation context by a PricePreference keyed on region_id or
currency_code, and the same includes_tax concept is set again independently on
Region, Currency, PriceList, and ShippingOption. Because those settings are
configured in different admin screens, they can drift out of sync, so
calculatePrices resolves is_calculated_price_tax_inclusive inconsistently
across line items and the cart totals engine stops reconciling: subtotal plus
tax_total no longer equals total. This never rewrites an order's totals or a
price amount. It only ever writes the specific PricePreference a human has
approved. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_tax_inclusivity_mismatches")

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ROUNDING_TOLERANCE = 0.02

# Optional, human-approved fix for exactly one context.
# Format: "region_id|currency_code:value:true|false"
# Example: "region_id:reg_01ABC:true"
APPLY_FIX_FOR = None
_raw_fix = os.environ.get("APPLY_FIX_FOR", "").strip()
if _raw_fix:
    _attribute, _value, _flag = _raw_fix.split(":")
    APPLY_FIX_FOR = {"attribute": _attribute, "value": _value, "is_tax_inclusive": _flag.lower() == "true"}


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, body):
    r = requests.post(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def find_tax_inclusivity_mismatches(preferences, price_contexts):
    """Pure decision function. No I/O.

    preferences: [{"attribute": "region_id" | "currency_code", "value": str,
                   "is_tax_inclusive": bool}, ...]
    price_contexts: [{"source_type": "price_list" | "shipping_option", "source_id": str,
                       "region_id": str | None, "currency_code": str | None}, ...]

    Returns a list of mismatches: {source_type, source_id, region_id, currency_code,
    region_pref, currency_pref, reason}.
    """
    region_pref_map = {}
    currency_pref_map = {}
    for pref in preferences:
        if pref["attribute"] == "region_id":
            region_pref_map[pref["value"]] = pref["is_tax_inclusive"]
        elif pref["attribute"] == "currency_code":
            currency_pref_map[pref["value"]] = pref["is_tax_inclusive"]

    mismatches = []
    for ctx in price_contexts:
        region_pref = region_pref_map.get(ctx.get("region_id"))
        currency_pref = currency_pref_map.get(ctx.get("currency_code"))

        if region_pref is not None and currency_pref is not None and region_pref != currency_pref:
            reason = "region/currency preference conflict"
        elif region_pref is None and currency_pref is None:
            reason = "no preference configured, defaults may drift"
        else:
            continue

        mismatches.append({
            "source_type": ctx["source_type"],
            "source_id": ctx["source_id"],
            "region_id": ctx.get("region_id"),
            "currency_code": ctx.get("currency_code"),
            "region_pref": region_pref,
            "currency_pref": currency_pref,
            "reason": reason,
        })
    return mismatches


def list_regions(token):
    regions = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/regions", {
            "fields": "id,name,currency_code,automatic_taxes",
            "limit": limit,
            "offset": offset,
        })
        regions.extend(data["regions"])
        offset += limit
        if offset >= data["count"]:
            return regions


def list_price_preferences(token):
    preferences = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/price-preferences", {
            "fields": "id,attribute,value,is_tax_inclusive",
            "limit": limit,
            "offset": offset,
        })
        preferences.extend(data["price_preferences"])
        offset += limit
        if offset >= data["count"]:
            return preferences


def _rule_value(price, key):
    for rule in price.get("price_rules") or []:
        if rule.get("attribute") == key:
            return rule.get("value")
    return price.get(key)


def price_lists_as_contexts(token):
    data = admin_get(token, "/admin/price-lists", {
        "fields": "id,title,status,*prices",
        "limit": 100,
    })
    contexts = []
    for price_list in data["price_lists"]:
        for price in price_list.get("prices") or []:
            contexts.append({
                "source_type": "price_list",
                "source_id": price_list["id"],
                "region_id": _rule_value(price, "region_id"),
                "currency_code": price.get("currency_code"),
            })
    return contexts


def shipping_options_as_contexts(token):
    data = admin_get(token, "/admin/shipping-options", {
        "fields": "id,name,*prices,*prices.price_rules",
        "limit": 100,
    })
    contexts = []
    for option in data["shipping_options"]:
        for price in option.get("prices") or []:
            contexts.append({
                "source_type": "shipping_option",
                "source_id": option["id"],
                "region_id": _rule_value(price, "region_id"),
                "currency_code": price.get("currency_code"),
            })
    return contexts


def orders_with_bad_totals(token):
    data = admin_get(token, "/admin/orders", {
        "fields": "id,region_id,currency_code,subtotal,tax_total,shipping_total,total",
        "limit": 100,
    })
    bad = []
    for order in data["orders"]:
        expected = (order.get("subtotal") or 0) + (order.get("tax_total") or 0) + (order.get("shipping_total") or 0)
        if abs(expected - (order.get("total") or 0)) > ROUNDING_TOLERANCE:
            bad.append(order)
    return bad


def upsert_price_preference(token, attribute, value, is_tax_inclusive):
    return admin_post(token, "/admin/price-preferences", {
        "attribute": attribute,
        "value": value,
        "is_tax_inclusive": is_tax_inclusive,
    })


def run():
    token = get_admin_token()
    preferences = list_price_preferences(token)

    price_contexts = price_lists_as_contexts(token) + shipping_options_as_contexts(token)
    mismatches = find_tax_inclusivity_mismatches(preferences, price_contexts)

    for mismatch in mismatches:
        log.warning(
            "%s %s: region=%s currency=%s region_pref=%s currency_pref=%s reason=%s",
            mismatch["source_type"], mismatch["source_id"],
            mismatch["region_id"], mismatch["currency_code"],
            mismatch["region_pref"], mismatch["currency_pref"], mismatch["reason"],
        )

    bad_orders = orders_with_bad_totals(token)
    for order in bad_orders:
        log.warning(
            "Order %s totals do not reconcile: subtotal=%s tax_total=%s shipping_total=%s total=%s",
            order["id"], order.get("subtotal"), order.get("tax_total"),
            order.get("shipping_total"), order.get("total"),
        )

    applied = 0
    if APPLY_FIX_FOR is not None:
        log.info(
            "%s POST /admin/price-preferences %s",
            "Would call" if DRY_RUN else "Calling", APPLY_FIX_FOR,
        )
        if not DRY_RUN:
            upsert_price_preference(
                token, APPLY_FIX_FOR["attribute"], APPLY_FIX_FOR["value"], APPLY_FIX_FOR["is_tax_inclusive"],
            )
        applied = 1

    log.info(
        "Done. %d mismatch(es) flagged, %d order(s) with bad totals, %d preference write %s.",
        len(mismatches), len(bad_orders), applied, "to apply" if DRY_RUN else "applied",
    )


if __name__ == "__main__":
    run()
find-tax-inclusivity-mismatches.js
/**
 * Flag Medusa contexts where tax-inclusive pricing settings disagree.
 *
 * Whether a price is tax-inclusive is not one global flag in Medusa v2. It is
 * decided per calculation context by a PricePreference keyed on region_id or
 * currency_code, and the same includes_tax concept is set again independently on
 * Region, Currency, PriceList, and ShippingOption. Because those settings are
 * configured in different admin screens, they can drift out of sync, so
 * calculatePrices resolves is_calculated_price_tax_inclusive inconsistently
 * across line items and the cart totals engine stops reconciling: subtotal plus
 * tax_total no longer equals total. This never rewrites an order's totals or a
 * price amount. It only ever writes the specific PricePreference a human has
 * approved. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/tax-inclusive-pricing-shows-wrong-totals/
 */
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";

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 ROUNDING_TOLERANCE = 0.02;

// Optional, human-approved fix for exactly one context.
// Format: "region_id|currency_code:value:true|false"
function parseApplyFixFor(raw) {
  const trimmed = (raw || "").trim();
  if (!trimmed) return null;
  const [attribute, value, flag] = trimmed.split(":");
  return { attribute, value, is_tax_inclusive: flag.toLowerCase() === "true" };
}

const APPLY_FIX_FOR = parseApplyFixFor(process.env.APPLY_FIX_FOR);

const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });

/**
 * Pure decision function. No I/O.
 *
 * @param {Array<{ attribute: "region_id"|"currency_code", value: string, is_tax_inclusive: boolean }>} preferences
 * @param {Array<{ source_type: "price_list"|"shipping_option", source_id: string,
 *   region_id?: string, currency_code?: string }>} priceContexts
 * @returns {Array<{ source_type: string, source_id: string, region_id?: string,
 *   currency_code?: string, region_pref?: boolean, currency_pref?: boolean, reason: string }>}
 */
export function findTaxInclusivityMismatches(preferences, priceContexts) {
  const regionPrefMap = new Map();
  const currencyPrefMap = new Map();
  for (const pref of preferences) {
    if (pref.attribute === "region_id") regionPrefMap.set(pref.value, pref.is_tax_inclusive);
    else if (pref.attribute === "currency_code") currencyPrefMap.set(pref.value, pref.is_tax_inclusive);
  }

  const mismatches = [];
  for (const ctx of priceContexts) {
    const regionPref = regionPrefMap.get(ctx.region_id);
    const currencyPref = currencyPrefMap.get(ctx.currency_code);

    let reason;
    if (regionPref !== undefined && currencyPref !== undefined && regionPref !== currencyPref) {
      reason = "region/currency preference conflict";
    } else if (regionPref === undefined && currencyPref === undefined) {
      reason = "no preference configured, defaults may drift";
    } else {
      continue;
    }

    mismatches.push({
      source_type: ctx.source_type,
      source_id: ctx.source_id,
      region_id: ctx.region_id,
      currency_code: ctx.currency_code,
      region_pref: regionPref,
      currency_pref: currencyPref,
      reason,
    });
  }
  return mismatches;
}

function ruleValue(price, key) {
  for (const rule of price.price_rules || []) {
    if (rule.attribute === key) return rule.value;
  }
  return price[key];
}

async function listRegions() {
  const regions = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const { regions: page, count } = await sdk.admin.region.list({
      fields: "id,name,currency_code,automatic_taxes",
      limit,
      offset,
    });
    regions.push(...page);
    offset += limit;
    if (offset >= count) return regions;
  }
}

async function listPricePreferences() {
  const preferences = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const { price_preferences: page, count } = await sdk.admin.pricePreference.list({
      fields: "id,attribute,value,is_tax_inclusive",
      limit,
      offset,
    });
    preferences.push(...page);
    offset += limit;
    if (offset >= count) return preferences;
  }
}

async function priceListsAsContexts() {
  const { price_lists } = await sdk.admin.priceList.list({
    fields: "id,title,status,*prices",
    limit: 100,
  });
  const contexts = [];
  for (const priceList of price_lists) {
    for (const price of priceList.prices || []) {
      contexts.push({
        source_type: "price_list",
        source_id: priceList.id,
        region_id: ruleValue(price, "region_id"),
        currency_code: price.currency_code,
      });
    }
  }
  return contexts;
}

async function shippingOptionsAsContexts() {
  const { shipping_options } = await sdk.admin.shippingOption.list({
    fields: "id,name,*prices,*prices.price_rules",
    limit: 100,
  });
  const contexts = [];
  for (const option of shipping_options) {
    for (const price of option.prices || []) {
      contexts.push({
        source_type: "shipping_option",
        source_id: option.id,
        region_id: ruleValue(price, "region_id"),
        currency_code: price.currency_code,
      });
    }
  }
  return contexts;
}

async function ordersWithBadTotals() {
  const { orders } = await sdk.admin.order.list({
    fields: "id,region_id,currency_code,subtotal,tax_total,shipping_total,total",
    limit: 100,
  });
  return orders.filter((order) => {
    const expected = (order.subtotal || 0) + (order.tax_total || 0) + (order.shipping_total || 0);
    return Math.abs(expected - (order.total || 0)) > ROUNDING_TOLERANCE;
  });
}

async function upsertPricePreference(attribute, value, isTaxInclusive) {
  return sdk.admin.pricePreference.create({
    attribute,
    value,
    is_tax_inclusive: isTaxInclusive,
  });
}

export async function run() {
  await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });

  const preferences = await listPricePreferences();
  const priceContexts = [...(await priceListsAsContexts()), ...(await shippingOptionsAsContexts())];
  const mismatches = findTaxInclusivityMismatches(preferences, priceContexts);

  for (const mismatch of mismatches) {
    console.warn(
      `${mismatch.source_type} ${mismatch.source_id}: region=${mismatch.region_id} currency=${mismatch.currency_code} region_pref=${mismatch.region_pref} currency_pref=${mismatch.currency_pref} reason=${mismatch.reason}`
    );
  }

  const badOrders = await ordersWithBadTotals();
  for (const order of badOrders) {
    console.warn(
      `Order ${order.id} totals do not reconcile: subtotal=${order.subtotal} tax_total=${order.tax_total} shipping_total=${order.shipping_total} total=${order.total}`
    );
  }

  let applied = 0;
  if (APPLY_FIX_FOR) {
    console.log(`${DRY_RUN ? "Would call" : "Calling"} POST /admin/price-preferences ${JSON.stringify(APPLY_FIX_FOR)}`);
    if (!DRY_RUN) {
      await upsertPricePreference(APPLY_FIX_FOR.attribute, APPLY_FIX_FOR.value, APPLY_FIX_FOR.is_tax_inclusive);
    }
    applied = 1;
  }

  console.log(
    `Done. ${mismatches.length} mismatch(es) flagged, ${badOrders.length} order(s) with bad totals, ${applied} preference write ${DRY_RUN ? "to apply" : "applied"}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

find_tax_inclusivity_mismatches is the part most worth testing, because it decides which contexts get reported. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain preference and context records, and checks the flagged list.

test_tax_inclusivity_mismatches.py
from find_tax_inclusivity_mismatches import find_tax_inclusivity_mismatches

REGION_PREF_TRUE = {"attribute": "region_id", "value": "reg_1", "is_tax_inclusive": True}
CURRENCY_PREF_FALSE = {"attribute": "currency_code", "value": "eur", "is_tax_inclusive": False}


def context(**over):
    base = {"source_type": "shipping_option", "source_id": "so_1", "region_id": "reg_1", "currency_code": "eur"}
    base.update(over)
    return base


def test_no_mismatch_when_region_and_currency_prefs_agree():
    agree_pref = {"attribute": "currency_code", "value": "eur", "is_tax_inclusive": True}
    findings = find_tax_inclusivity_mismatches([REGION_PREF_TRUE, agree_pref], [context()])
    assert findings == []


def test_conflict_when_region_and_currency_prefs_disagree():
    findings = find_tax_inclusivity_mismatches([REGION_PREF_TRUE, CURRENCY_PREF_FALSE], [context()])
    assert len(findings) == 1
    assert findings[0]["reason"] == "region/currency preference conflict"
    assert findings[0]["region_pref"] is True
    assert findings[0]["currency_pref"] is False


def test_no_preference_configured_at_all():
    findings = find_tax_inclusivity_mismatches([], [context(region_id="reg_unknown", currency_code="jpy")])
    assert len(findings) == 1
    assert findings[0]["reason"] == "no preference configured, defaults may drift"
    assert findings[0]["region_pref"] is None
    assert findings[0]["currency_pref"] is None


def test_no_mismatch_when_only_one_preference_exists_and_no_conflict_possible():
    findings = find_tax_inclusivity_mismatches([REGION_PREF_TRUE], [context(currency_code="usd")])
    assert findings == []


def test_source_type_and_id_are_preserved_on_the_finding():
    findings = find_tax_inclusivity_mismatches(
        [REGION_PREF_TRUE, CURRENCY_PREF_FALSE],
        [context(source_type="price_list", source_id="plist_9")],
    )
    assert findings[0]["source_type"] == "price_list"
    assert findings[0]["source_id"] == "plist_9"


def test_multiple_contexts_only_flags_the_mismatched_one():
    ok_pref = {"attribute": "region_id", "value": "reg_ok", "is_tax_inclusive": True}
    ok_ctx = context(source_id="so_ok", region_id="reg_ok", currency_code="usd")
    bad_ctx = context(source_id="so_bad")
    findings = find_tax_inclusivity_mismatches([REGION_PREF_TRUE, CURRENCY_PREF_FALSE, ok_pref], [ok_ctx, bad_ctx])
    assert len(findings) == 1
    assert findings[0]["source_id"] == "so_bad"
tax-inclusivity-mismatches.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findTaxInclusivityMismatches } from "./find-tax-inclusivity-mismatches.js";

const REGION_PREF_TRUE = { attribute: "region_id", value: "reg_1", is_tax_inclusive: true };
const CURRENCY_PREF_FALSE = { attribute: "currency_code", value: "eur", is_tax_inclusive: false };

const context = (over = {}) => ({
  source_type: "shipping_option",
  source_id: "so_1",
  region_id: "reg_1",
  currency_code: "eur",
  ...over,
});

test("no mismatch when region and currency prefs agree", () => {
  const agreePref = { attribute: "currency_code", value: "eur", is_tax_inclusive: true };
  const findings = findTaxInclusivityMismatches([REGION_PREF_TRUE, agreePref], [context()]);
  assert.deepEqual(findings, []);
});

test("conflict when region and currency prefs disagree", () => {
  const findings = findTaxInclusivityMismatches([REGION_PREF_TRUE, CURRENCY_PREF_FALSE], [context()]);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].reason, "region/currency preference conflict");
  assert.equal(findings[0].region_pref, true);
  assert.equal(findings[0].currency_pref, false);
});

test("no preference configured at all", () => {
  const findings = findTaxInclusivityMismatches([], [context({ region_id: "reg_unknown", currency_code: "jpy" })]);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].reason, "no preference configured, defaults may drift");
  assert.equal(findings[0].region_pref, undefined);
  assert.equal(findings[0].currency_pref, undefined);
});

test("no mismatch when only one preference exists and no conflict possible", () => {
  const findings = findTaxInclusivityMismatches([REGION_PREF_TRUE], [context({ currency_code: "usd" })]);
  assert.deepEqual(findings, []);
});

test("source type and id are preserved on the finding", () => {
  const findings = findTaxInclusivityMismatches(
    [REGION_PREF_TRUE, CURRENCY_PREF_FALSE],
    [context({ source_type: "price_list", source_id: "plist_9" })]
  );
  assert.equal(findings[0].source_type, "price_list");
  assert.equal(findings[0].source_id, "plist_9");
});

test("multiple contexts only flags the mismatched one", () => {
  const okPref = { attribute: "region_id", value: "reg_ok", is_tax_inclusive: true };
  const okCtx = context({ source_id: "so_ok", region_id: "reg_ok", currency_code: "usd" });
  const badCtx = context({ source_id: "so_bad" });
  const findings = findTaxInclusivityMismatches([REGION_PREF_TRUE, CURRENCY_PREF_FALSE, okPref], [okCtx, badCtx]);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].source_id, "so_bad");
});

Case studies

Shipping option drift

The region switched to tax-inclusive, the shipping rate did not

A store turned on tax-inclusive pricing for its EU region so displayed prices matched local expectations. The switch updated the region's own setting and the base product prices, but a shipping option that had been priced months earlier kept its old, exclusive assumption, because nothing recalculated it automatically. Carts in that region started showing a subtotal and tax total that did not add up to the total whenever a customer picked that shipping method.

Running the flag script surfaced the shipping option as a region/currency preference conflict in the very first pass. The team fixed the shipping option's own price setup in the admin, then confirmed the region's PricePreference was the correct source of truth going forward.

Missing preference

A new currency never got an explicit tax-inclusive choice

A merchant added a new currency for a market expansion and set up prices for it, but never visited the tax settings screen for that currency specifically. There was no error, no warning, just an unconfigured default that could drift either way depending on which code path resolved it first, and support started getting occasional reports of totals looking "off" only for that currency.

The audit flagged every price list and shipping option context tied to that currency as no preference configured, defaults may drift. The merchant made an explicit choice, wrote the PricePreference once with the script's guarded write path, and the reports stopped.

What good looks like

After this runs on a schedule, every region and currency has one explicit, reviewed PricePreference, and every price list and shipping option price agrees with it. Cart and order totals reconcile because every line item resolves tax-inclusivity the same way. No order total or price amount was ever rewritten to force the numbers to match. Instead, the actual setting that was inconsistent got fixed, once, by a human.

FAQ

Why does my Medusa cart show a subtotal, tax total, and total that do not add up?

Whether a price is tax-inclusive is not one global flag in Medusa v2. It is decided per calculation context by a PricePreference keyed on region_id or currency_code, and the same concept is set again independently on Region, Currency, PriceList, and ShippingOption. When those settings disagree, calculatePrices marks some line items tax-inclusive and others tax-exclusive, so the totals engine backs out tax from some amounts but not others and subtotal plus tax_total stops equaling total.

Is it safe to auto-fix a tax-inclusive mismatch in Medusa?

Not by rewriting order totals or existing price amounts directly. Those must be recalculated by Medusa's own totals workflow, never overwritten. This is a cross-entity configuration inconsistency, so the safe pattern is to report every mismatched context to a human, and the only approved write is creating or updating the specific PricePreference for a region_id or currency_code so it matches the intended setting.

What is a PricePreference in Medusa v2?

A PricePreference is a record in the Pricing module keyed on either a region_id or a currency_code with an is_tax_inclusive boolean. It is the source calculatePrices reads to decide whether a price in that context should be treated as already including tax. It exists separately from the includes_tax style flags on Region, Currency, PriceList, and ShippingOption, and nothing forces all of them to agree.

Related field notes

Citations

On the problem:

  1. Tax inclusive pricing for shipping options not respecting region's settings. Medusa GitHub Issue #9906. github.com/medusajs/medusa/issues/9906
  2. Region Tax Inclusive Feature and Discount. Medusa GitHub Issue #5776. github.com/medusajs/medusa/issues/5776
  3. [Bug]: Order Summary Tax Calculation Incorrect in v2.10.1. Medusa GitHub Issue #13405. github.com/medusajs/medusa/issues/13405

On the solution:

  1. Medusa Documentation: Tax-Inclusive Pricing, Pricing module. docs.medusajs.com/resources/commerce-modules/pricing/tax-inclusive-pricing
  2. Medusa Documentation: Prices Calculation. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
  3. Medusa Documentation: Tax Lines in Cart Module. docs.medusajs.com/resources/commerce-modules/cart/tax-lines

Stuck on a tricky one?

If you have a problem in Medusa storefront access, 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 untangle a totals mystery?

If this saved you from a support ticket about totals that would not add up, or from rewriting a real order's numbers by hand, 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