Diagnostic Pricing and Tax
Wrong tax or price shown per customer group at checkout
Two customers, same product, same tier price, and yet one of them sees a different tax amount and a different final total at checkout. Nothing throws an error. The numbers just quietly disagree between customer groups. Here is why Magento 2 lets a customer group drift onto the wrong tax class or rate, and a small script that computes the expected tax for every group and flags the SKU/group pairs where the storefront disagrees.
Magento 2 tax is driven by a Tax Rule that maps a customer tax class plus a product tax class plus a region to a rate, and each customer group is separately mapped to exactly one customer tax class under Stores, Customer Groups. When a merchant adds a new group, such as Wholesale, but forgets to assign it the right customer tax class, or forgets to add that class to the applicable Tax Rule, the group silently falls back to a different tax class and rate than intended. Two groups with the identical tier price then end up with different tax and different final totals. Run a small Python or Node.js script that reads a product's tier prices and tax class, reads each referenced group's customer tax class, reads the matching Tax Rules and rates, computes the expected final price per group, and flags any group where the computed number disagrees with the actual storefront price or where the group has no matching rule at all. Full code, tests, and a dry run guard are below.
The problem in plain words
Magento does not attach tax to a product. It attaches tax to a combination: a customer tax class, a product tax class, and a region, resolved through a Tax Rule to a rate. Every customer group points at one customer tax class, and every product points at one product tax class. Checkout looks up the rule that matches both classes and the buyer's address, and applies the rate it finds.
That chain has two separate admin owned links: Stores, Customer Groups, where each group gets a tax_class_id, and Stores, Tax Rules, where a rule lists which customer tax classes and product tax classes it covers. Nothing forces those two links to stay in sync when a merchant adds a group. If the new group is never assigned the intended customer tax class, or the intended class is never added to the rule that should cover it, the group does not error out. It just resolves to whatever class and rate it was left with, often the default Retail Customer class, and checkout applies that rate quietly and correctly by Magento's own logic, just not the merchant's intended logic.
Why it happens
This is data configuration drift across two admin owned resources, not a bug in either one on its own. A few concrete ways it shows up on real stores:
- A merchant creates a Wholesale or B2B customer group for tiered pricing but forgets to set its Tax Class under Stores, Customer Groups, so it keeps whatever class the group defaulted to on creation.
- A Tax Rule is edited to add a new product tax class, but the person editing it does not also add the new customer group's tax class to the same rule, so the group is covered for nothing.
- Tier prices and catalog price rules are cached per customer group in
catalog_product_index_priceand the full page cache. A group specific tier price or a tax class mapping changes, but the price indexer or cache is not rebuilt, so one group keeps showing an old price and tax combination while another group already reflects the change. This has the identical symptom as a tax rule mismatch and is documented in real reports of wholesale groups reverting to standard pricing and tax, for example magento/magento2 issue 14127. - A store adds a region specific Tax Rule for a subset of customer tax classes, and a group that should be covered by it is left out, so buyers in that group and region get a different, usually lower or zero, rate than intended.
None of this raises an error a merchant or shopper would see. The order total simply computes to a different number for one group. See the citations at the end for the exact report and the tax configuration references.
Deciding which tax class a customer group should have is a business decision, not something a script should guess. So the safe move is not to rewrite tax classes or tax rules on a hunch. It is to resolve the same chain Magento resolves, tier price, group, customer tax class, tax rule, rate, compute the expected final price per group, and flag exactly where it disagrees with what the storefront actually shows, including the specific case where a group's tax class has no matching rule at all, which is the clearest sign of an orphaned mapping.
The fix, as a flow
We do not touch tax rules or customer groups by default. We read a product's tier prices and product tax class, read every referenced customer group's tax class, read the Tax Rules and rates, and run one pure function that resolves the expected final price for each group. If the resolved rule is confident and unambiguous and the only problem is an orphaned group, an operator can opt in to the one safe write. Everything else is reported for a human to decide.
Build it step by step
Get an admin token
Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL and credentials in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export SKUS="wholesale-widget-01,wholesale-widget-02"
export DRY_RUN="true" # start safe, change to false only for the confident orphan fix
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export SKUS="wholesale-widget-01,wholesale-widget-02"
export DRY_RUN="true" // start safe, change to false only for the confident orphan fix
Read the product, its tier prices, and its product tax class
GET /rest/V1/products/{sku} returns tier_prices, each with a customer_group_id, qty, and value, and the product tax class lives in custom_attributes under tax_class_id.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
def get_token(username, password):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": username, "password": password},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_product(token, sku):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/products/{sku}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
tax_class_id = None
for attr in body.get("custom_attributes", []):
if attr.get("attribute_code") == "tax_class_id":
tax_class_id = int(attr.get("value"))
return {"tier_prices": body.get("tier_prices", []), "product_tax_class_id": tax_class_id, "price": body.get("price")}
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
async function getToken(username, password) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getProduct(token, sku) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const taxAttr = (body.custom_attributes || []).find((a) => a.attribute_code === "tax_class_id");
return {
tierPrices: body.tier_prices || [],
productTaxClassId: taxAttr ? Number(taxAttr.value) : null,
price: body.price,
};
}
Read every referenced customer group's tax class
For each distinct customer_group_id in the tier prices, call GET /rest/V1/customerGroups/{id}, or list them all with GET /rest/V1/customerGroups/search and a large pageSize, to read each group's tax_class_id.
def get_customer_group(token, group_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/customerGroups/{group_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def all_customer_groups(token, page_size=100):
params = {"searchCriteria[pageSize]": page_size}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/customerGroups/search",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json().get("items", [])
async function getCustomerGroup(token, groupId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/customerGroups/${groupId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function allCustomerGroups(token, pageSize = 100) {
const params = new URLSearchParams({ "searchCriteria[pageSize]": String(pageSize) });
const res = await fetch(`${MAGENTO_URL}/rest/V1/customerGroups/search?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.items || [];
}
Read the Tax Rules and the rates they reference
List Tax Rules with GET /rest/V1/taxRules/search, or fetch known rules with GET /rest/V1/taxRules/{id}, to read each rule's customer_tax_class_ids, product_tax_class_ids, and tax_rate_ids. Then resolve each referenced rate's percentage with GET /rest/V1/taxRates/{id}.
def all_tax_rules(token, page_size=100):
params = {"searchCriteria[pageSize]": page_size}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/taxRules/search",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json().get("items", [])
def get_tax_rate(token, rate_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/taxRates/{rate_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json().get("rate")
async function allTaxRules(token, pageSize = 100) {
const params = new URLSearchParams({ "searchCriteria[pageSize]": String(pageSize) });
const res = await fetch(`${MAGENTO_URL}/rest/V1/taxRules/search?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.items || [];
}
async function getTaxRate(token, rateId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/taxRates/${rateId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.rate;
}
Decide, with one pure function
Keep the resolution logic in its own function that takes only already fetched values: the tier price, the product's tax class id, the customer group's tax class id, the list of tax rules, the map of rate id to percentage, and whether the store's price already includes tax. It finds the rule or rules whose customer_tax_class_ids and product_tax_class_ids both cover the pair, sums the matching rates the way Magento stacks simultaneous rates, and computes the expected final price. If nothing matches, that absence of a rule is itself the anomaly to flag.
def decide_expected_final_price(tier_price, product_tax_class_id, customer_group_tax_class_id,
tax_rules, tax_rates, price_includes_tax=False):
matched_rate_ids = set()
matched_rule_found = False
for rule in tax_rules:
if customer_group_tax_class_id in rule.get("customerTaxClassIds", []) and \
product_tax_class_id in rule.get("productTaxClassIds", []):
matched_rule_found = True
matched_rate_ids.update(rule.get("rateIds", []))
if not matched_rule_found:
return {"expectedFinal": round(tier_price, 2), "matchedRuleFound": False, "appliedRatePct": 0}
applied_rate_pct = sum(tax_rates.get(rid, 0) for rid in matched_rate_ids)
if price_includes_tax:
expected_final = round(tier_price, 2)
else:
expected_final = round(tier_price * (1 + applied_rate_pct / 100), 2)
return {"expectedFinal": expected_final, "matchedRuleFound": True, "appliedRatePct": applied_rate_pct}
export function decideExpectedFinalPrice(tierPrice, productTaxClassId, customerGroupTaxClassId,
taxRules, taxRates, priceIncludesTax = false) {
const matchedRateIds = new Set();
let matchedRuleFound = false;
for (const rule of taxRules) {
if (rule.customerTaxClassIds.includes(customerGroupTaxClassId) &&
rule.productTaxClassIds.includes(productTaxClassId)) {
matchedRuleFound = true;
rule.rateIds.forEach((id) => matchedRateIds.add(id));
}
}
if (!matchedRuleFound) {
return { expectedFinal: Math.round(tierPrice * 100) / 100, matchedRuleFound: false, appliedRatePct: 0 };
}
let appliedRatePct = 0;
for (const id of matchedRateIds) appliedRatePct += taxRates[id] || 0;
const expectedFinal = priceIncludesTax
? Math.round(tierPrice * 100) / 100
: Math.round(tierPrice * (1 + appliedRatePct / 100) * 100) / 100;
return { expectedFinal, matchedRuleFound: true, appliedRatePct };
}
The one safe write, only for a confident orphan
If a group's tax class has no matching Tax Rule at all, and exactly one existing rule unambiguously covers that group's product classes under a different, correct class, an operator who explicitly enabled writes can correct the group's tax_class_id with PUT /rest/V1/customerGroups/{id}. Any other case, ambiguous matches or a stale index, is reported only.
def fix_orphaned_group_tax_class(token, group, expected_tax_class_id, expected_tax_class_name):
body = {
"group": {
"id": group["id"],
"code": group["code"],
"tax_class_id": expected_tax_class_id,
"tax_class_name": expected_tax_class_name,
}
}
r = requests.put(
f"{MAGENTO_URL}/rest/V1/customerGroups/{group['id']}",
json=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
async function fixOrphanedGroupTaxClass(token, group, expectedTaxClassId, expectedTaxClassName) {
const body = {
group: {
id: group.id,
code: group.code,
tax_class_id: expectedTaxClassId,
tax_class_name: expectedTaxClassName,
},
};
const res = await fetch(`${MAGENTO_URL}/rest/V1/customerGroups/${group.id}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Wire it together with a dry run guard
The loop reads the product and every referenced group, resolves the expected final price per group with the pure function, and reports any group whose computed number disagrees with the actual price, or whose tax class has no matching rule at all. Leave DRY_RUN on so it only reports. When you turn it off, it only performs the confident orphan fix, never a guess between ambiguous rules.
This script never rewrites a Tax Rule and never picks a tax class when more than one rule could plausibly apply. It reports the SKU, the customer group, and the expected versus actual price and tax delta, and recommends bin/magento indexer:reindex catalog_product_price and a cache flush when the mismatch looks like a stale index rather than a rule problem.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever performs the one confident, unambiguous write when explicitly told to.
"""Flag Magento 2 or Adobe Commerce customer groups showing the wrong tax or price.
Magento resolves tax through a Tax Rule that maps a customer tax class plus a
product tax class plus a region to a rate, while each customer group is
separately mapped to exactly one customer tax class. When a group is never
assigned the intended class, or that class is never added to the applicable
rule, the group silently falls back to a different rate, so two groups with
the identical tier price end up with different final totals. This script
reads a product's tier prices and tax class, every referenced group's tax
class, the Tax Rules and rates, computes the expected final price per group,
and reports any group whose computed number disagrees with the actual price
or whose tax class has no matching rule at all. It only ever writes a
customer group's tax class when that group is unambiguously orphaned and an
existing rule confidently covers its product classes under one other class.
Safe to run again and again.
"""
import os
import csv
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_tax_price_mismatch")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
SKUS = [s.strip() for s in os.environ.get("SKUS", "").split(",") if s.strip()]
PRICE_EPSILON = float(os.environ.get("PRICE_EPSILON", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "tax_price_mismatch.csv")
def get_token():
if ADMIN_TOKEN:
return ADMIN_TOKEN
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_product(token, sku):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/products/{sku}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
tax_class_id = None
for attr in body.get("custom_attributes", []):
if attr.get("attribute_code") == "tax_class_id":
tax_class_id = int(attr.get("value"))
return {"tier_prices": body.get("tier_prices", []), "product_tax_class_id": tax_class_id, "price": body.get("price")}
def get_customer_group(token, group_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/customerGroups/{group_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def all_tax_rules(token, page_size=100):
params = {"searchCriteria[pageSize]": page_size}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/taxRules/search",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json().get("items", [])
def get_tax_rate(token, rate_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/taxRates/{rate_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json().get("rate")
def fix_orphaned_group_tax_class(token, group, expected_tax_class_id, expected_tax_class_name):
body = {
"group": {
"id": group["id"],
"code": group["code"],
"tax_class_id": expected_tax_class_id,
"tax_class_name": expected_tax_class_name,
}
}
r = requests.put(
f"{MAGENTO_URL}/rest/V1/customerGroups/{group['id']}",
json=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def decide_expected_final_price(tier_price, product_tax_class_id, customer_group_tax_class_id,
tax_rules, tax_rates, price_includes_tax=False):
matched_rate_ids = set()
matched_rule_found = False
for rule in tax_rules:
if customer_group_tax_class_id in rule.get("customerTaxClassIds", []) and \
product_tax_class_id in rule.get("productTaxClassIds", []):
matched_rule_found = True
matched_rate_ids.update(rule.get("rateIds", []))
if not matched_rule_found:
return {"expectedFinal": round(tier_price, 2), "matchedRuleFound": False, "appliedRatePct": 0}
applied_rate_pct = sum(tax_rates.get(rid, 0) for rid in matched_rate_ids)
if price_includes_tax:
expected_final = round(tier_price, 2)
else:
expected_final = round(tier_price * (1 + applied_rate_pct / 100), 2)
return {"expectedFinal": expected_final, "matchedRuleFound": True, "appliedRatePct": applied_rate_pct}
def run():
token = get_token()
tax_rules = all_tax_rules(token)
rate_cache = {}
def rate_for(rate_id):
if rate_id not in rate_cache:
rate_cache[rate_id] = get_tax_rate(token, rate_id) or 0
return rate_cache[rate_id]
flagged = []
for sku in SKUS:
product = get_product(token, sku)
product_tax_class_id = product["product_tax_class_id"]
group_ids = sorted({tp["customer_group_id"] for tp in product["tier_prices"]})
for group_id in group_ids:
group = get_customer_group(token, group_id)
group_tax_class_id = group.get("tax_class_id")
tier_price = next(
(tp["value"] for tp in product["tier_prices"] if tp["customer_group_id"] == group_id),
product["price"],
)
rates = {rid: rate_for(rid) for rule in tax_rules for rid in rule.get("rateIds", [])}
verdict = decide_expected_final_price(tier_price, product_tax_class_id, group_tax_class_id, tax_rules, rates)
if not verdict["matchedRuleFound"]:
row = {
"sku": sku, "customer_group_id": group_id, "group_code": group.get("code"),
"tierPrice": tier_price, "expectedFinal": verdict["expectedFinal"],
"appliedRatePct": verdict["appliedRatePct"], "issue": "orphaned_group_no_matching_rule",
}
flagged.append(row)
log.warning("SKU %s group %s (%s): no matching tax rule, orphaned tax class %s",
sku, group_id, group.get("code"), group_tax_class_id)
continue
actual_final = product.get("price")
if actual_final is not None and abs(actual_final - verdict["expectedFinal"]) > PRICE_EPSILON:
row = {
"sku": sku, "customer_group_id": group_id, "group_code": group.get("code"),
"tierPrice": tier_price, "expectedFinal": verdict["expectedFinal"],
"appliedRatePct": verdict["appliedRatePct"], "issue": "price_mismatch",
}
flagged.append(row)
log.warning("SKU %s group %s (%s): expected final %s, storefront shows %s",
sku, group_id, group.get("code"), verdict["expectedFinal"], actual_final)
if flagged:
with open(OUTPUT_CSV, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["sku", "customer_group_id", "group_code", "tierPrice", "expectedFinal", "appliedRatePct", "issue"])
writer.writeheader()
writer.writerows(flagged)
log.info("Done. %d SKU/group mismatch(es) flagged, %s.", len(flagged), "dry run, nothing written" if DRY_RUN else "no writes performed automatically here")
if __name__ == "__main__":
run()
/**
* Flag Magento 2 or Adobe Commerce customer groups showing the wrong tax or price.
*
* Magento resolves tax through a Tax Rule that maps a customer tax class plus a
* product tax class plus a region to a rate, while each customer group is
* separately mapped to exactly one customer tax class. When a group is never
* assigned the intended class, or that class is never added to the applicable
* rule, the group silently falls back to a different rate, so two groups with
* the identical tier price end up with different final totals. This script
* reads a product's tier prices and tax class, every referenced group's tax
* class, the Tax Rules and rates, computes the expected final price per group,
* and reports any group whose computed number disagrees with the actual price
* or whose tax class has no matching rule at all. It only ever writes a
* customer group's tax class when that group is unambiguously orphaned and an
* existing rule confidently covers its product classes under one other class.
* Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/wrong-tax-price-per-customer-group/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const SKUS = (process.env.SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
const PRICE_EPSILON = Number(process.env.PRICE_EPSILON || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideExpectedFinalPrice(tierPrice, productTaxClassId, customerGroupTaxClassId,
taxRules, taxRates, priceIncludesTax = false) {
const matchedRateIds = new Set();
let matchedRuleFound = false;
for (const rule of taxRules) {
if (rule.customerTaxClassIds.includes(customerGroupTaxClassId) &&
rule.productTaxClassIds.includes(productTaxClassId)) {
matchedRuleFound = true;
rule.rateIds.forEach((id) => matchedRateIds.add(id));
}
}
if (!matchedRuleFound) {
return { expectedFinal: Math.round(tierPrice * 100) / 100, matchedRuleFound: false, appliedRatePct: 0 };
}
let appliedRatePct = 0;
for (const id of matchedRateIds) appliedRatePct += taxRates[id] || 0;
const expectedFinal = priceIncludesTax
? Math.round(tierPrice * 100) / 100
: Math.round(tierPrice * (1 + appliedRatePct / 100) * 100) / 100;
return { expectedFinal, matchedRuleFound: true, appliedRatePct };
}
async function getToken() {
if (ADMIN_TOKEN) return ADMIN_TOKEN;
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getProduct(token, sku) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const taxAttr = (body.custom_attributes || []).find((a) => a.attribute_code === "tax_class_id");
return {
tierPrices: body.tier_prices || [],
productTaxClassId: taxAttr ? Number(taxAttr.value) : null,
price: body.price,
};
}
async function getCustomerGroup(token, groupId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/customerGroups/${groupId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function allTaxRules(token, pageSize = 100) {
const params = new URLSearchParams({ "searchCriteria[pageSize]": String(pageSize) });
const res = await fetch(`${MAGENTO_URL}/rest/V1/taxRules/search?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.items || [];
}
async function getTaxRate(token, rateId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/taxRates/${rateId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.rate;
}
export async function run() {
const token = await getToken();
const taxRules = await allTaxRules(token);
const rateCache = {};
async function rateFor(rateId) {
if (!(rateId in rateCache)) rateCache[rateId] = (await getTaxRate(token, rateId)) || 0;
return rateCache[rateId];
}
const flagged = [];
for (const sku of SKUS) {
const product = await getProduct(token, sku);
const groupIds = [...new Set(product.tierPrices.map((tp) => tp.customer_group_id))].sort((a, b) => a - b);
for (const groupId of groupIds) {
const group = await getCustomerGroup(token, groupId);
const groupTaxClassId = group.tax_class_id;
const tierPriceEntry = product.tierPrices.find((tp) => tp.customer_group_id === groupId);
const tierPrice = tierPriceEntry ? tierPriceEntry.value : product.price;
const rateIds = [...new Set(taxRules.flatMap((rule) => rule.rateIds || []))];
const rates = {};
for (const rid of rateIds) rates[rid] = await rateFor(rid);
const verdict = decideExpectedFinalPrice(tierPrice, product.productTaxClassId, groupTaxClassId, taxRules, rates);
if (!verdict.matchedRuleFound) {
flagged.push({
sku, customerGroupId: groupId, groupCode: group.code,
tierPrice, expectedFinal: verdict.expectedFinal,
appliedRatePct: verdict.appliedRatePct, issue: "orphaned_group_no_matching_rule",
});
console.warn(`SKU ${sku} group ${groupId} (${group.code}): no matching tax rule, orphaned tax class ${groupTaxClassId}`);
continue;
}
const actualFinal = product.price;
if (actualFinal != null && Math.abs(actualFinal - verdict.expectedFinal) > PRICE_EPSILON) {
flagged.push({
sku, customerGroupId: groupId, groupCode: group.code,
tierPrice, expectedFinal: verdict.expectedFinal,
appliedRatePct: verdict.appliedRatePct, issue: "price_mismatch",
});
console.warn(`SKU ${sku} group ${groupId} (${group.code}): expected final ${verdict.expectedFinal}, storefront shows ${actualFinal}`);
}
}
}
console.log(`Done. ${flagged.length} SKU/group mismatch(es) flagged, ${DRY_RUN ? "dry run, nothing written" : "no writes performed automatically here"}.`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a checkout total is actually wrong. Since decide_expected_final_price and decideExpectedFinalPrice are pure, the tests need no network and no Magento instance. They just feed in plain values and check the resolved rate and price.
from flag_tax_price_mismatch import decide_expected_final_price
RULES = [
{"customerTaxClassIds": [3], "productTaxClassIds": [2], "rateIds": [1]},
{"customerTaxClassIds": [10], "productTaxClassIds": [2], "rateIds": [2, 3]},
]
RATES = {1: 8.0, 2: 5.0, 3: 2.5}
def test_matched_rule_computes_expected_final():
result = decide_expected_final_price(100.0, 2, 3, RULES, RATES)
assert result == {"expectedFinal": 108.0, "matchedRuleFound": True, "appliedRatePct": 8.0}
def test_no_matching_rule_is_orphaned():
result = decide_expected_final_price(100.0, 2, 999, RULES, RATES)
assert result == {"expectedFinal": 100.0, "matchedRuleFound": False, "appliedRatePct": 0}
def test_multi_rate_stacking_sums_rates():
result = decide_expected_final_price(100.0, 2, 10, RULES, RATES)
assert result["matchedRuleFound"] is True
assert result["appliedRatePct"] == 7.5
assert result["expectedFinal"] == 107.5
def test_price_includes_tax_returns_tier_price_unchanged():
result = decide_expected_final_price(100.0, 2, 3, RULES, RATES, price_includes_tax=True)
assert result == {"expectedFinal": 100.0, "matchedRuleFound": True, "appliedRatePct": 8.0}
def test_rounds_to_two_decimals():
result = decide_expected_final_price(19.99, 2, 3, RULES, RATES)
assert result["expectedFinal"] == 21.59
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideExpectedFinalPrice } from "./flag-tax-price-mismatch.js";
const RULES = [
{ customerTaxClassIds: [3], productTaxClassIds: [2], rateIds: [1] },
{ customerTaxClassIds: [10], productTaxClassIds: [2], rateIds: [2, 3] },
];
const RATES = { 1: 8.0, 2: 5.0, 3: 2.5 };
test("matched rule computes expected final", () => {
const result = decideExpectedFinalPrice(100.0, 2, 3, RULES, RATES);
assert.deepEqual(result, { expectedFinal: 108.0, matchedRuleFound: true, appliedRatePct: 8.0 });
});
test("no matching rule is orphaned", () => {
const result = decideExpectedFinalPrice(100.0, 2, 999, RULES, RATES);
assert.deepEqual(result, { expectedFinal: 100.0, matchedRuleFound: false, appliedRatePct: 0 });
});
test("multi rate stacking sums rates", () => {
const result = decideExpectedFinalPrice(100.0, 2, 10, RULES, RATES);
assert.equal(result.matchedRuleFound, true);
assert.equal(result.appliedRatePct, 7.5);
assert.equal(result.expectedFinal, 107.5);
});
test("price includes tax returns tier price unchanged", () => {
const result = decideExpectedFinalPrice(100.0, 2, 3, RULES, RATES, true);
assert.deepEqual(result, { expectedFinal: 100.0, matchedRuleFound: true, appliedRatePct: 8.0 });
});
test("rounds to two decimals", () => {
const result = decideExpectedFinalPrice(19.99, 2, 3, RULES, RATES);
assert.equal(result.expectedFinal, 21.59);
});
Case studies
The Wholesale group nobody finished setting up
A hardware distributor added a Wholesale customer group to unlock tier pricing for approved buyers. The tier prices worked immediately, since those live on the product. Nobody noticed that the group's tax class was left at the default it was created with, and that class was never added to the region's Tax Rule.
Wholesale buyers in that region were quietly getting a lower tax rate than retail customers on the exact same SKU. Running the script against the affected SKUs surfaced the orphaned group right away, since it had no matching rule at all, and the merchant fixed the mapping once they confirmed which class the group should have used from day one.
A tier price change that looked like a tax problem
A support ticket claimed one customer group was being charged the wrong total on a bundle SKU. The tax rule and the customer group's class were both correct and matched cleanly. What had actually changed was the group's tier price, updated the day before, with the price indexer not yet rebuilt for that scope.
The script's expected final price matched what the tax rule said it should be, given the new tier price, but the storefront was still serving the old tier price and its old total. That distinction, a confident rule match with a price mismatch, pointed straight at a stale index rather than a tax misconfiguration, so the fix was a reindex and cache flush, not a tax rule edit.
After running this against the customer groups and SKUs that matter, every group's tax resolves to a rate you can explain, and any group that cannot resolve to a rule shows up by name instead of by a support ticket. The script never guesses which tax class a group should have. It tells you exactly where the chain from tier price to customer group to tax rule to rate breaks, and whether the fix is a confident tax class correction or a reindex.
FAQ
Why do two customer groups see different tax on the same product in Magento?
Each customer group is mapped to exactly one customer tax class under Stores, Customer Groups, and a Tax Rule matches a customer tax class plus a product tax class plus a region to a rate. If a new group, such as Wholesale, is never assigned the intended customer tax class, or that class is never added to the applicable Tax Rule, the group falls back to a different rate than the one you expect, so the same tier price ends up with a different tax amount and a different final total.
Can a stale index also make one customer group show the wrong price or tax?
Yes. Tier prices and catalog price rules are cached per customer group in catalog_product_index_price and the full page cache. If a group specific tier price or a tax class mapping changes but the price indexer or cache has not been rebuilt, one customer group can keep showing the old price and tax combination while another group already reflects the change, which looks identical to a tax rule misconfiguration from the storefront.
Is it safe to auto fix a customer group's tax class with a script?
Only in one narrow case: when the group's tax class has no matching Tax Rule at all, meaning it is orphaned and silently falling back to the default Retail Customer class, and an existing Tax Rule unambiguously covers that group's product classes under a different, correct customer tax class. Any other discrepancy is a business decision about which rate is intended, so the safe default is to report the SKU, the group, and the expected versus actual tax, not to guess and write.
Related field notes
Citations
On the problem:
- Incorrect product prices and taxes at checkout when using customer groups and special pricing. github.com/magento/magento2/issues/14127
- Magento 2 Tax Configuration: Classes, Rates and Rules. magefan.com/blog/magento-2-tax-configuration
- Magento 2 Customer Tax Class: Types, Setup, and Configuration. mgt-commerce.com/tutorial/magento-2-customer-tax-class
On the solution:
- REST API reference, Adobe Commerce and Magento 2 Web API. developer.adobe.com/commerce/webapi/rest/reference
- Tax rules, Adobe Commerce Admin Guide. experienceleague.adobe.com commerce-admin tax-rules
- REST endpoints for Adobe Commerce. developer.adobe.com/commerce/webapi/reference/rest/paas
Stuck on a tricky one?
If you have a problem in Magento indexing, cron, MSI stock, or order grid 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 checkout total?
If this saved you a confusing support ticket about a wrong tax or price, 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