Skip to content

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.

Python and Node.js Magento REST API Safe by default (report only)
A person with headphones at a computer
Photo by ELLA DON on Unsplash
The short answer

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.

New group added Wholesale Tax class mapping never set or never ruled no confident match Falls back to default class Retail Customer Checkout total differs by group Same tier price, same SKU, different rate resolved for each customer group.
The group is not broken and neither is the rule. They simply never got wired together, so Magento resolves a different rate than the merchant intended.

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:

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.

The key insight

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.

Read tier prices per customer group Resolve group tax class and matching tax rule Compute expected final price per group Matches actual storefront price? yes no action group is correct no flag mismatch or fix if confident
The script reports by default. It only writes a customer group's tax class when the fix is unambiguous, an orphaned group with exactly one confident rule match.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

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

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.

step3.py
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", [])
step3.js
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 || [];
}
4

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

step4.py
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")
step4.js
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;
}
5

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.

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

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.

repair.py
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()
repair.js
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();
}
7

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
flag_tax_price_mismatch.py
"""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-tax-price-mismatch.js
/**
 * 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.

test_wrong_tax_price.py
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
tax-price.test.js
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

New wholesale group

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.

Stale index, not a rule bug

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.

What good looks like

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:

  1. Incorrect product prices and taxes at checkout when using customer groups and special pricing. github.com/magento/magento2/issues/14127
  2. Magento 2 Tax Configuration: Classes, Rates and Rules. magefan.com/blog/magento-2-tax-configuration
  3. Magento 2 Customer Tax Class: Types, Setup, and Configuration. mgt-commerce.com/tutorial/magento-2-customer-tax-class

On the solution:

  1. REST API reference, Adobe Commerce and Magento 2 Web API. developer.adobe.com/commerce/webapi/rest/reference
  2. Tax rules, Adobe Commerce Admin Guide. experienceleague.adobe.com commerce-admin tax-rules
  3. 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.

Contact me on LinkedIn

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

Back to all Magento field notes