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.
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.
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:
- A region switches to tax-inclusive pricing, but a shipping option's price for that region was created before the switch and keeps its old inclusivity, exactly the class of bug reported in the Medusa issue tracker for shipping options ignoring a region's tax-inclusive setting.
- A currency-level
PricePreferenceand a region-levelPricePreferenceexist for the same context and disagree, so which onecalculatePriceshonors depends on which context a given line item resolves through. - A price list override for a region was created before that region's tax-inclusive preference existed or changed, so the price list's prices carry a different assumption than the base prices, which is the same mixed inclusive and exclusive pattern reported alongside discounts.
- No
PricePreferencewas ever created for a given region or currency at all, so that context silently falls back to an unconfigured default instead of an explicit, reviewed choice.
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.
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.
Build it step by step
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.
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
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
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.
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
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;
}
}
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.
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
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;
}
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.
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
/**
* 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;
}
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.
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
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;
});
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- Tax inclusive pricing for shipping options not respecting region's settings. Medusa GitHub Issue #9906. github.com/medusajs/medusa/issues/9906
- Region Tax Inclusive Feature and Discount. Medusa GitHub Issue #5776. github.com/medusajs/medusa/issues/5776
- [Bug]: Order Summary Tax Calculation Incorrect in v2.10.1. Medusa GitHub Issue #13405. github.com/medusajs/medusa/issues/13405
On the solution:
- Medusa Documentation: Tax-Inclusive Pricing, Pricing module. docs.medusajs.com/resources/commerce-modules/pricing/tax-inclusive-pricing
- Medusa Documentation: Prices Calculation. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
- 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.
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