Skip to content

Diagnostic Pricing and Tax

Catalog price rule discounts base price instead of group price

You built a catalog price rule for one customer group, a wholesale tier or a VIP segment, expecting it to shave a percentage off the price that group already negotiated. Instead the storefront shows a discount off the plain base price, or worse, shoppers in a group the rule was never meant for get the markdown too. Here is why the catalog price rule indexer discounts the wrong starting amount and a small script that finds the SKUs where it happened.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
A grocery store
Photo by Brad on Unsplash
The short answer

Magento's catalog price rule indexer, Magento\CatalogRule\Model\Indexer\IndexBuilder, computes rule_price in catalogrule_product_price by applying the rule's discount action to the product's base or website price row, not the customer group specific tier price row in catalog_product_entity_tier_price. A rule scoped to one group ends up discounting the wrong starting amount. Separately, reindexById and assignProductToRule evaluate rule conditions using the scope active at product save time and apply that one result to every website and group, which can leak the discount outside the rule's configured customer_group_ids. Run a small Python or Node.js script that reads each SKU's base price and tier prices from tier-prices-information, computes the expected price from the tier price the rule's target group should get, and compares it against the actual price. Full code, tests, and a dry run guard are below.

The problem in plain words

A catalog price rule is supposed to answer one question for a given customer group: starting from whatever price that group already sees, take off the rule's discount. For a general customer that starting point is the base or website price. For a wholesale or VIP group, it should be their negotiated tier price instead, the row that already lives in catalog_product_entity_tier_price.

The indexer that fills in catalogrule_product_price does not make that distinction. It applies the rule's discount action directly to the product's base or website price row and writes the result as rule_price, without first looking up the tier price row for the group the rule targets. So a ten percent rule aimed at a wholesale group that already pays eighty percent of list price ends up discounting the full list price instead, either overshooting the intended final price or undershooting it, depending on which way the numbers land.

Rule targets wholesale group tier price already set tier price never looked up Indexer reads base price row wrong starting point Discount applied to that number rule_price wrong for that group The rule never checks what the target group's own tier price already was.
The indexer discounts the base price row directly. It never looks up the customer group's own tier price first.

Why it happens

None of this needs a misconfigured rule to show up. It is a property of how the indexer resolves a starting price and how eligibility gets applied across scopes. A few concrete ways it shows up on real stores:

None of this throws an error a merchant would notice in the Admin. The rule looks saved correctly, the discount percent looks right, and the only sign something is off is a final price that does not match what the operator expected for that customer group. See the citations at the end for the exact issue threads that describe this behavior.

The key insight

There is no catalogRule/save REST endpoint, and catalogrule_product_price rows are generated by the indexer, so writing to them directly is pointless, the next cron run overwrites whatever you wrote. The honest move is to compute what the price should have been, using the tier price row the rule's target group actually has, compare that to the live price, and report the SKUs where they disagree, along with whether the mismatch looks like a base price substitution or a scope leak to another group.

The fix, as a flow

We never call a catalog price rule save endpoint or write to indexer tables. We read each SKU's base price with GET /rest/V1/products/{sku}, its tier price rows with POST /rest/V1/products/tier-prices-information, and the actual price the storefront returns. We resolve the tier price row that matches the rule's target customer_group_id, apply the rule's discount to that number to get the expected price, and compare it to the actual price. A mismatch that lands on the base price discounted instead is flagged as base_price_used. A mismatch that lands on another group's discounted tier price is flagged as scope_leak.

Read base, tier, actual per SKU, via REST Resolve tier price for rule's target group Compute expected tier price minus discount Matches actual price? yes no action needed pricing is correct no report mismatch base_price_used or scope_leak
The script only ever reports. It classifies a mismatch as the base price being used instead of the tier price, or the discount leaking to another group.

Build it step by step

1

Get an admin token

Authenticate against POST /rest/V1/integration/admin/token 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="SKU-1,SKU-2"
export RULE_CUSTOMER_GROUP_ID="3"
export RULE_DISCOUNT_PERCENT="10"
export DRY_RUN="true"   # this script only ever reports, it never writes a rule
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="SKU-1,SKU-2"
export RULE_CUSTOMER_GROUP_ID="3"
export RULE_DISCOUNT_PERCENT="10"
export DRY_RUN="true"   // this script only ever reports, it never writes a rule
2

Read the base price and the tier price rows

There is no public /V1/catalogRule REST endpoint, so the rule's target customer_group_id and discount percent must be supplied out of band, for example from an admin export or a config file. Read each SKU's base price with GET /rest/V1/products/{sku}, and its tier price rows in one call with POST /rest/V1/products/tier-prices-information, which returns price, price_type, website_id, and customer_group for every SKU, where customer_group_id 32000 means ALL GROUPS.

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 base_price(token, sku):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/products/{sku}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["price"]

def tier_prices(token, skus):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/products/tier-prices-information",
        json={"skus": skus},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
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 basePrice(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();
  return body.price;
}

async function tierPricesFor(token, skus) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/tier-prices-information`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ skus }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Read the actual price the storefront serves

Fetch the same product again and read the special_price custom attribute, falling back to price if the rule has not written one. This is the number a shopper in that customer group actually sees, and it is the number we compare against what the rule should have produced.

step3.py
def actual_price(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()
    for attr in body.get("custom_attributes", []):
        if attr.get("attribute_code") == "special_price" and attr.get("value"):
            return float(attr["value"])
    return body["price"]
step3.js
async function actualPrice(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 specialPrice = (body.custom_attributes || []).find((a) => a.attribute_code === "special_price");
  if (specialPrice && specialPrice.value) return Number(specialPrice.value);
  return body.price;
}
4

Decide, with one pure function

Keep the decision in its own function that takes only already fetched values: the base price, the tier price rows, the rule's target customer group and discount percent, and the actual price. It resolves the qty=1 tier price row matching the rule's group, falling back to group 32000, ALL GROUPS, if no group specific row exists, computes the expected price from that starting number, and compares it to the actual price within a one cent tolerance. When they disagree, it classifies the mismatch as base_price_used when the actual price matches the base price discounted instead, or scope_leak when the actual price reflects the discount computed for a different customer group.

decide.py
ALL_GROUPS_ID = 32000

def evaluate_rule_price_mismatch(base_price, tier_prices, rule_customer_group_id,
                                  rule_discount_percent, actual_price, tolerance=0.01):
    starting_price = _resolve_tier_price(base_price, tier_prices, rule_customer_group_id)
    expected_price = starting_price * (1 - rule_discount_percent / 100)
    is_mismatch = abs(expected_price - actual_price) > tolerance

    mismatch_type = None
    if is_mismatch:
        base_discounted = base_price * (1 - rule_discount_percent / 100)
        if abs(actual_price - base_discounted) <= tolerance and abs(starting_price - base_price) > tolerance:
            mismatch_type = "base_price_used"
        else:
            mismatch_type = _detect_scope_leak(
                base_price, tier_prices, rule_customer_group_id, rule_discount_percent, actual_price, tolerance
            )

    return {"expectedPrice": expected_price, "isMismatch": is_mismatch, "mismatchType": mismatch_type}


def _resolve_tier_price(base_price, tier_prices, rule_customer_group_id):
    qty1_rows = [tp for tp in tier_prices if tp.get("qty", 1) == 1]
    for tp in qty1_rows:
        if tp.get("customerGroupId") == rule_customer_group_id:
            return _apply_price_type(base_price, tp)
    for tp in qty1_rows:
        if tp.get("customerGroupId") == ALL_GROUPS_ID:
            return _apply_price_type(base_price, tp)
    return base_price
decide.js
const ALL_GROUPS_ID = 32000;

export function evaluateRulePriceMismatch(basePrice, tierPrices, ruleCustomerGroupId, ruleDiscountPercent, actualPrice, tolerance = 0.01) {
  const startingPrice = resolveTierPrice(basePrice, tierPrices, ruleCustomerGroupId);
  const expectedPrice = startingPrice * (1 - ruleDiscountPercent / 100);
  const isMismatch = Math.abs(expectedPrice - actualPrice) > tolerance;

  let mismatchType = null;
  if (isMismatch) {
    const baseDiscounted = basePrice * (1 - ruleDiscountPercent / 100);
    if (Math.abs(actualPrice - baseDiscounted) <= tolerance && Math.abs(startingPrice - basePrice) > tolerance) {
      mismatchType = "base_price_used";
    } else {
      mismatchType = detectScopeLeak(basePrice, tierPrices, ruleCustomerGroupId, ruleDiscountPercent, actualPrice, tolerance);
    }
  }

  return { expectedPrice, isMismatch, mismatchType };
}

function resolveTierPrice(basePrice, tierPrices, ruleCustomerGroupId) {
  const qty1Rows = tierPrices.filter((tp) => (tp.qty ?? 1) === 1);
  const groupRow = qty1Rows.find((tp) => tp.customerGroupId === ruleCustomerGroupId);
  if (groupRow) return applyPriceType(basePrice, groupRow);
  const allGroupsRow = qty1Rows.find((tp) => tp.customerGroupId === ALL_GROUPS_ID);
  if (allGroupsRow) return applyPriceType(basePrice, allGroupsRow);
  return basePrice;
}
5

There is no safe write, so we report

Catalog price rules cannot be safely rewritten over REST, there is no catalogRule/save endpoint, and editing catalogrule_product_price rows directly is unsafe because the indexer regenerates them on the next cron run. So the script's only output is a report, guarded by DRY_RUN, listing the SKU, the rule's target customer group id, the expected price, the actual price, the delta, and the mismatch type.

report.py
def build_report_entry(sku, rule_customer_group_id, result, actual_price):
    return {
        "sku": sku,
        "customerGroupId": rule_customer_group_id,
        "expectedPrice": round(result["expectedPrice"], 2),
        "actualPrice": actual_price,
        "delta": round(actual_price - result["expectedPrice"], 2),
        "mismatchType": result["mismatchType"],
    }
report.js
function buildReportEntry(sku, ruleCustomerGroupId, result, actualPrice) {
  return {
    sku,
    customerGroupId: ruleCustomerGroupId,
    expectedPrice: Math.round(result.expectedPrice * 100) / 100,
    actualPrice,
    delta: Math.round((actualPrice - result.expectedPrice) * 100) / 100,
    mismatchType: result.mismatchType,
  };
}
6

Wire it together with a dry run guard

The loop authenticates once, reads base price, tier prices, and actual price for your configured SKUs, calls the pure detection function, and writes a JSON report. Notice the dry run guard. This script only ever reports, by design, since re-saving the rule and forcing a full reindex are Admin and CLI operations that belong to an operator, not to a scheduled job running against production.

Run it safe

This script never calls a catalog price rule save endpoint and never writes to catalogrule_product_price itself. It only reports the SKU, customer group, expected price, actual price, delta, and mismatch type. Re-saving the rule scoped strictly to the intended customer group and website, then running bin/magento indexer:reindex catalogrule_rule catalogrule_product catalog_product_price, is a manual step an operator takes after reading the report, not an automatic action.

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 never attempts a corrective write over REST, it only tells you exactly which SKUs and customer groups are affected and how.

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.
detect_rule_price_mismatch.py
"""Detect a Magento 2 or Adobe Commerce catalog price rule discounting the
wrong starting price.

The catalog price rule indexer (Magento\\CatalogRule\\Model\\Indexer\\IndexBuilder)
computes rule_price in catalogrule_product_price by applying the rule's discount
action to the product's base/website price row, rather than looking up the
customer-group-specific tier price row in catalog_product_entity_tier_price. So
a rule scoped to one customer group can discount the wrong starting amount, or
leak its discount to a customer group outside its configured customer_group_ids
scope. This script has no write path: catalog price rules have no public
catalogRule/save REST endpoint, and catalogrule_product_price rows are
indexer-generated and get overwritten on the next cron run, so directly editing
them is unsafe. It only detects and reports. Safe to run again and again.
"""
import os
import json
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_rule_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")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_JSON = os.environ.get("OUTPUT_JSON", "rule_price_mismatch_report.json")

# There is no public /V1/catalogRule REST endpoint, so the rule's target
# customer group and discount percent must be supplied out of band, for
# example from an admin export or a config file.
SKUS = [s.strip() for s in os.environ.get("SKUS", "").split(",") if s.strip()]
RULE_CUSTOMER_GROUP_ID = int(os.environ.get("RULE_CUSTOMER_GROUP_ID", "1"))
RULE_DISCOUNT_PERCENT = float(os.environ.get("RULE_DISCOUNT_PERCENT", "10"))

ALL_GROUPS_ID = 32000


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 base_price(token, sku):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/products/{sku}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["price"]


def tier_prices(token, skus):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/products/tier-prices-information",
        json={"skus": skus},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def actual_price(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()
    for attr in body.get("custom_attributes", []):
        if attr.get("attribute_code") == "special_price" and attr.get("value"):
            return float(attr["value"])
    return body["price"]


def evaluate_rule_price_mismatch(base_price_value, tier_prices_list, rule_customer_group_id,
                                  rule_discount_percent, actual_price_value, tolerance=0.01):
    """Pure function. No network or DB I/O.

    Resolves the qty=1 tier price row matching rule_customer_group_id (falling
    back to group 32000, ALL GROUPS, if no group-specific row exists), computes
    expected_price = tier_or_base_price * (1 - rule_discount_percent / 100),
    compares it to actual_price within tolerance, and classifies the failure as
    base_price_used when actual_price matches base_price * (1 - discount)
    instead of the tier price, or scope_leak when actual_price reflects the
    discount for a customer_group_id outside rule_customer_group_id.
    """
    starting_price = _resolve_tier_price(base_price_value, tier_prices_list, rule_customer_group_id)
    expected_price = starting_price * (1 - rule_discount_percent / 100)
    is_mismatch = abs(expected_price - actual_price_value) > tolerance

    mismatch_type = None
    if is_mismatch:
        base_discounted = base_price_value * (1 - rule_discount_percent / 100)
        if abs(actual_price_value - base_discounted) <= tolerance and abs(starting_price - base_price_value) > tolerance:
            mismatch_type = "base_price_used"
        else:
            mismatch_type = _detect_scope_leak(
                base_price_value, tier_prices_list, rule_customer_group_id, rule_discount_percent, actual_price_value, tolerance
            )

    return {
        "expectedPrice": expected_price,
        "isMismatch": is_mismatch,
        "mismatchType": mismatch_type,
    }


def _resolve_tier_price(base_price_value, tier_prices_list, rule_customer_group_id):
    qty1_rows = [tp for tp in tier_prices_list if tp.get("qty", 1) == 1]

    for tp in qty1_rows:
        if tp.get("customerGroupId") == rule_customer_group_id:
            return _apply_price_type(base_price_value, tp)

    for tp in qty1_rows:
        if tp.get("customerGroupId") == ALL_GROUPS_ID:
            return _apply_price_type(base_price_value, tp)

    return base_price_value


def _apply_price_type(base_price_value, tier_price_row):
    if tier_price_row.get("priceType") == "discount":
        return base_price_value * (1 - tier_price_row["price"] / 100)
    return tier_price_row["price"]


def _detect_scope_leak(base_price_value, tier_prices_list, rule_customer_group_id,
                        rule_discount_percent, actual_price_value, tolerance):
    qty1_rows = [tp for tp in tier_prices_list if tp.get("qty", 1) == 1]
    for tp in qty1_rows:
        other_group = tp.get("customerGroupId")
        if other_group == rule_customer_group_id:
            continue
        other_starting_price = _apply_price_type(base_price_value, tp)
        other_expected = other_starting_price * (1 - rule_discount_percent / 100)
        if abs(other_expected - actual_price_value) <= tolerance:
            return "scope_leak"
    return "base_price_used"


def run():
    token = get_token()

    if not SKUS:
        log.warning("No SKUS configured. Set SKUS to a comma separated list to check.")
        return

    tier_info = tier_prices(token, SKUS)
    tier_by_sku = {}
    for row in tier_info:
        tier_by_sku.setdefault(row["sku"], []).append({
            "customerGroupId": row.get("customer_group_id", ALL_GROUPS_ID),
            "price": row["price"],
            "priceType": row.get("price_type", "fixed"),
            "qty": row.get("qty", 1),
        })

    report = []
    for sku in SKUS:
        base = base_price(token, sku)
        rows = tier_by_sku.get(sku, [])
        actual = actual_price(token, sku)

        result = evaluate_rule_price_mismatch(
            base, rows, RULE_CUSTOMER_GROUP_ID, RULE_DISCOUNT_PERCENT, actual
        )

        if result["isMismatch"]:
            entry = {
                "sku": sku,
                "customerGroupId": RULE_CUSTOMER_GROUP_ID,
                "expectedPrice": round(result["expectedPrice"], 2),
                "actualPrice": actual,
                "delta": round(actual - result["expectedPrice"], 2),
                "mismatchType": result["mismatchType"],
            }
            report.append(entry)
            log.warning(
                "MISMATCH sku=%s group=%s expected=%.2f actual=%.2f type=%s",
                sku, RULE_CUSTOMER_GROUP_ID, result["expectedPrice"], actual, result["mismatchType"],
            )

    with open(OUTPUT_JSON, "w") as fh:
        json.dump(report, fh, indent=2)

    if report and not DRY_RUN:
        log.warning(
            "DRY_RUN is false, but this script never rewrites catalog price rules or "
            "catalogrule_product_price rows itself. Review %s and, if confirmed, re-save "
            "the rule scoped strictly to the intended customer group(s)/websites, then run "
            "bin/magento indexer:reindex catalogrule_rule catalogrule_product catalog_product_price.",
            OUTPUT_JSON,
        )

    log.info("Done. %d mismatch(es) written to %s.", len(report), OUTPUT_JSON)


if __name__ == "__main__":
    run()
detect-rule-price-mismatch.js
/**
 * Detect a Magento 2 or Adobe Commerce catalog price rule discounting the
 * wrong starting price.
 *
 * The catalog price rule indexer (Magento\CatalogRule\Model\Indexer\IndexBuilder)
 * computes rule_price in catalogrule_product_price by applying the rule's
 * discount action to the product's base/website price row, rather than
 * looking up the customer-group-specific tier price row in
 * catalog_product_entity_tier_price. So a rule scoped to one customer group
 * can discount the wrong starting amount, or leak its discount to a customer
 * group outside its configured customer_group_ids scope. This script has no
 * write path: catalog price rules have no public catalogRule/save REST
 * endpoint, and catalogrule_product_price rows are indexer-generated and get
 * overwritten on the next cron run, so directly editing them is unsafe. It
 * only detects and reports. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/catalog-price-rule-wrong-base-price/
 */
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";

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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OUTPUT_JSON = process.env.OUTPUT_JSON || "rule_price_mismatch_report.json";

const SKUS = (process.env.SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
const RULE_CUSTOMER_GROUP_ID = Number(process.env.RULE_CUSTOMER_GROUP_ID || 1);
const RULE_DISCOUNT_PERCENT = Number(process.env.RULE_DISCOUNT_PERCENT || 10);

const ALL_GROUPS_ID = 32000;

function applyPriceType(basePrice, tierPriceRow) {
  if (tierPriceRow.priceType === "discount") return basePrice * (1 - tierPriceRow.price / 100);
  return tierPriceRow.price;
}

function resolveTierPrice(basePrice, tierPrices, ruleCustomerGroupId) {
  const qty1Rows = tierPrices.filter((tp) => (tp.qty ?? 1) === 1);

  const groupRow = qty1Rows.find((tp) => tp.customerGroupId === ruleCustomerGroupId);
  if (groupRow) return applyPriceType(basePrice, groupRow);

  const allGroupsRow = qty1Rows.find((tp) => tp.customerGroupId === ALL_GROUPS_ID);
  if (allGroupsRow) return applyPriceType(basePrice, allGroupsRow);

  return basePrice;
}

function detectScopeLeak(basePrice, tierPrices, ruleCustomerGroupId, ruleDiscountPercent, actualPrice, tolerance) {
  const qty1Rows = tierPrices.filter((tp) => (tp.qty ?? 1) === 1);
  for (const tp of qty1Rows) {
    if (tp.customerGroupId === ruleCustomerGroupId) continue;
    const otherStartingPrice = applyPriceType(basePrice, tp);
    const otherExpected = otherStartingPrice * (1 - ruleDiscountPercent / 100);
    if (Math.abs(otherExpected - actualPrice) <= tolerance) return "scope_leak";
  }
  return "base_price_used";
}

/**
 * Pure function. No network or DB I/O.
 *
 * Resolves the qty=1 tier price row matching ruleCustomerGroupId (falling
 * back to group 32000, ALL GROUPS, if no group-specific row exists), computes
 * expectedPrice = tierOrBasePrice * (1 - ruleDiscountPercent / 100), compares
 * it to actualPrice within tolerance, and classifies the failure as
 * base_price_used when actualPrice matches basePrice * (1 - discount) instead
 * of the tier price, or scope_leak when actualPrice reflects the discount for
 * a customerGroupId outside ruleCustomerGroupId.
 */
export function evaluateRulePriceMismatch(basePrice, tierPrices, ruleCustomerGroupId, ruleDiscountPercent, actualPrice, tolerance = 0.01) {
  const startingPrice = resolveTierPrice(basePrice, tierPrices, ruleCustomerGroupId);
  const expectedPrice = startingPrice * (1 - ruleDiscountPercent / 100);
  const isMismatch = Math.abs(expectedPrice - actualPrice) > tolerance;

  let mismatchType = null;
  if (isMismatch) {
    const baseDiscounted = basePrice * (1 - ruleDiscountPercent / 100);
    if (Math.abs(actualPrice - baseDiscounted) <= tolerance && Math.abs(startingPrice - basePrice) > tolerance) {
      mismatchType = "base_price_used";
    } else {
      mismatchType = detectScopeLeak(basePrice, tierPrices, ruleCustomerGroupId, ruleDiscountPercent, actualPrice, tolerance);
    }
  }

  return { expectedPrice, isMismatch, mismatchType };
}

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 basePrice(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();
  return body.price;
}

async function tierPricesFor(token, skus) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/tier-prices-information`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ skus }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function actualPrice(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 specialPrice = (body.custom_attributes || []).find((a) => a.attribute_code === "special_price");
  if (specialPrice && specialPrice.value) return Number(specialPrice.value);
  return body.price;
}

export async function run() {
  const token = await getToken();

  if (SKUS.length === 0) {
    console.warn("No SKUS configured. Set SKUS to a comma separated list to check.");
    return;
  }

  const tierInfo = await tierPricesFor(token, SKUS);
  const tierBySku = {};
  for (const row of tierInfo) {
    const list = tierBySku[row.sku] || (tierBySku[row.sku] = []);
    list.push({
      customerGroupId: row.customer_group_id ?? ALL_GROUPS_ID,
      price: row.price,
      priceType: row.price_type || "fixed",
      qty: row.qty ?? 1,
    });
  }

  const report = [];
  for (const sku of SKUS) {
    const base = await basePrice(token, sku);
    const rows = tierBySku[sku] || [];
    const actual = await actualPrice(token, sku);

    const result = evaluateRulePriceMismatch(base, rows, RULE_CUSTOMER_GROUP_ID, RULE_DISCOUNT_PERCENT, actual);

    if (result.isMismatch) {
      const entry = {
        sku,
        customerGroupId: RULE_CUSTOMER_GROUP_ID,
        expectedPrice: Math.round(result.expectedPrice * 100) / 100,
        actualPrice: actual,
        delta: Math.round((actual - result.expectedPrice) * 100) / 100,
        mismatchType: result.mismatchType,
      };
      report.push(entry);
      console.warn(
        `MISMATCH sku=${sku} group=${RULE_CUSTOMER_GROUP_ID} expected=${result.expectedPrice.toFixed(2)} actual=${actual.toFixed(2)} type=${result.mismatchType}`,
      );
    }
  }

  writeFileSync(OUTPUT_JSON, JSON.stringify(report, null, 2));

  if (report.length > 0 && !DRY_RUN) {
    console.warn(
      `DRY_RUN is false, but this script never rewrites catalog price rules or catalogrule_product_price rows itself. Review ${OUTPUT_JSON} and, if confirmed, re-save the rule scoped strictly to the intended customer group(s)/websites, then run bin/magento indexer:reindex catalogrule_rule catalogrule_product catalog_product_price.`,
    );
  }

  console.log(`Done. ${report.length} mismatch(es) written to ${OUTPUT_JSON}.`);
  return report;
}

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 price is actually wrong versus correctly discounted off the right starting point. Since evaluate_rule_price_mismatch and evaluateRulePriceMismatch are pure, the tests need no network and no Magento instance. They just feed in plain base price, tier price, and actual price values and check the verdict.

test_catalog_rule_mismatch.py
from detect_rule_price_mismatch import evaluate_rule_price_mismatch


def tier(**over):
    base = {"customerGroupId": 3, "price": 80.0, "priceType": "fixed", "qty": 1}
    base.update(over)
    return base


def test_no_mismatch_when_tier_price_correctly_discounted():
    # base=100, group 3 has a fixed tier price of 80, rule discounts 10% off that tier price
    result = evaluate_rule_price_mismatch(100.0, [tier()], 3, 10, 72.0)
    assert result["isMismatch"] is False
    assert result["mismatchType"] is None
    assert round(result["expectedPrice"], 2) == 72.0


def test_base_price_used_instead_of_tier_price():
    # base=100, group 3 tier price is 80, but actual price is base*(1-10%) = 90
    result = evaluate_rule_price_mismatch(100.0, [tier()], 3, 10, 90.0)
    assert result["isMismatch"] is True
    assert result["mismatchType"] == "base_price_used"


def test_scope_leak_to_other_customer_group():
    # rule targets group 3 (tier 80), but actual price matches group 4's tier (60) discounted
    rows = [tier(customerGroupId=3, price=80.0), tier(customerGroupId=4, price=60.0)]
    actual = 60.0 * (1 - 10 / 100)  # 54.0, discount leaked onto group 4's price
    result = evaluate_rule_price_mismatch(100.0, rows, 3, 10, actual)
    assert result["isMismatch"] is True
    assert result["mismatchType"] == "scope_leak"


def test_falls_back_to_all_groups_row_when_no_group_specific_row():
    rows = [tier(customerGroupId=32000, price=90.0)]
    result = evaluate_rule_price_mismatch(100.0, rows, 3, 10, 81.0)
    assert result["isMismatch"] is False
    assert round(result["expectedPrice"], 2) == 81.0


def test_falls_back_to_base_price_when_no_tier_rows_at_all():
    result = evaluate_rule_price_mismatch(100.0, [], 3, 10, 90.0)
    assert result["isMismatch"] is False
    assert round(result["expectedPrice"], 2) == 90.0


def test_discount_type_tier_price_is_applied_to_base():
    # tier row itself is a percent discount off base, not a fixed price
    rows = [tier(customerGroupId=3, price=15.0, priceType="discount")]
    # starting price = 100 * (1 - 15/100) = 85, then rule discounts another 10%
    result = evaluate_rule_price_mismatch(100.0, rows, 3, 10, 76.5)
    assert result["isMismatch"] is False
    assert round(result["expectedPrice"], 2) == 76.5
rule-price-mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateRulePriceMismatch } from "./detect-rule-price-mismatch.js";

const tier = (over = {}) => ({ customerGroupId: 3, price: 80.0, priceType: "fixed", qty: 1, ...over });

test("no mismatch when tier price correctly discounted", () => {
  const result = evaluateRulePriceMismatch(100.0, [tier()], 3, 10, 72.0);
  assert.equal(result.isMismatch, false);
  assert.equal(result.mismatchType, null);
  assert.equal(Math.round(result.expectedPrice * 100) / 100, 72.0);
});

test("base price used instead of tier price", () => {
  const result = evaluateRulePriceMismatch(100.0, [tier()], 3, 10, 90.0);
  assert.equal(result.isMismatch, true);
  assert.equal(result.mismatchType, "base_price_used");
});

test("scope leak to other customer group", () => {
  const rows = [tier({ customerGroupId: 3, price: 80.0 }), tier({ customerGroupId: 4, price: 60.0 })];
  const actual = 60.0 * (1 - 10 / 100); // 54.0, discount leaked onto group 4's price
  const result = evaluateRulePriceMismatch(100.0, rows, 3, 10, actual);
  assert.equal(result.isMismatch, true);
  assert.equal(result.mismatchType, "scope_leak");
});

test("falls back to ALL GROUPS row when no group specific row", () => {
  const rows = [tier({ customerGroupId: 32000, price: 90.0 })];
  const result = evaluateRulePriceMismatch(100.0, rows, 3, 10, 81.0);
  assert.equal(result.isMismatch, false);
  assert.equal(Math.round(result.expectedPrice * 100) / 100, 81.0);
});

test("falls back to base price when no tier rows at all", () => {
  const result = evaluateRulePriceMismatch(100.0, [], 3, 10, 90.0);
  assert.equal(result.isMismatch, false);
  assert.equal(Math.round(result.expectedPrice * 100) / 100, 90.0);
});

test("discount type tier price is applied to base", () => {
  const rows = [tier({ customerGroupId: 3, price: 15.0, priceType: "discount" })];
  const result = evaluateRulePriceMismatch(100.0, rows, 3, 10, 76.5);
  assert.equal(result.isMismatch, false);
  assert.equal(Math.round(result.expectedPrice * 100) / 100, 76.5);
});

Case studies

Wholesale group

The B2B tier that got double discounted the wrong way

A distributor set up a wholesale customer group with a negotiated tier price at eighty percent of list, then added a seasonal catalog price rule promising an extra ten percent off for that same group during a clearance window. The finance team expected the final price to be the tier price minus ten percent. Instead, the storefront showed a price that only made sense as the full list price minus ten percent, a higher number than intended, and wholesale buyers started asking why the sale barely moved the price they were used to.

Running the script against the clearance SKUs with the wholesale group's id and the rule's discount percent showed every affected SKU flagged base_price_used, with the expected price computed from the tier price consistently lower than what the storefront actually charged. The team re-saved the rule scoped strictly to the wholesale group and website, then ran a full catalog_product_price reindex from the CLI, and the tier price finally became the starting point for the discount.

Scope leak

A VIP discount that showed up for everyone

A retailer built a catalog price rule meant only for a VIP customer group, offering a deeper markdown as a loyalty perk. Within a day, general customers were asking support why an item they had priced days earlier was suddenly cheaper, even though they had never joined the VIP program.

The script's tier price comparison showed general customer accounts receiving a price that matched the VIP group's discounted tier price rather than their own, a clear scope_leak. The report named the exact SKUs and the group the discount had leaked to, which let the merchandising team confirm the rule's customer_group_ids configuration, re-save it scoped correctly, and run a full reindex to stop the leak from the next indexer pass onward.

What good looks like

After running this on a schedule, a mis-scoped catalog price rule stops being invisible. You get a precise report naming the affected SKUs, the customer group the rule was meant for, the expected price computed from that group's own tier price, the actual price, and whether the failure looks like the base price being substituted or the discount leaking to another group, so an operator can re-save the rule correctly and force the one full reindex that actually fixes it, rather than guessing at which SKUs are wrong.

FAQ

Why does my catalog price rule discount the wrong starting price?

The catalog price rule indexer, Magento CatalogRule Model Indexer IndexBuilder, computes rule_price in catalogrule_product_price by applying the rule's discount action to the product's base or website price row, rather than looking up the customer group specific tier price row in catalog_product_entity_tier_price. So a rule scoped to a wholesale or VIP group discounts the base price instead of that group's already negotiated tier price, producing a final price the group never agreed to.

Can a catalog price rule leak its discount to customer groups it was not scoped to?

Yes. reindexById and assignProductToRule evaluate the rule's condition attributes using the scope that was active at product save time, then apply that single eligibility result to all websites and groups. That can let a rule leak its discount outside the customer_group_ids it was configured for, and partial or stale reindexing after the rule is saved compounds the mismatch until a full reindex runs.

Can I fix a wrong catalog price rule price through the REST API?

Not directly. There is no catalogRule save endpoint in the REST API, and catalogrule_product_price rows are generated by the indexer and get overwritten on the next cron run, so editing them directly does not hold. A script can detect the mismatch by comparing the tier price adjusted expected price to the actual price per SKU and customer group, then report it. The real fix is to re-save the rule scoped strictly to the intended customer group and website, then run bin/magento indexer:reindex catalogrule_rule catalogrule_product catalog_product_price from the CLI to force a full, non-partial reindex.

Related field notes

Citations

On the problem:

  1. Catalog Price Rule does not apply to customer group pricing. github.com/magento/magento2/issues/40906
  2. Catalog Price Rule single product reindex works incorrectly. github.com/magento/magento2/issues/36049
  3. [Issue] Fixed catalog rule price processing when only website-scoped prices are used. github.com/magento/magento2/issues/40370

On the solution:

  1. Manage prices for multiple products, tier-prices and tier-prices-information REST API. developer.adobe.com commerce webapi catalog-pricing
  2. Catalog Price Rules, Adobe Commerce / Magento 2 User Guide. experienceleague.adobe.com commerce-admin price-rules-catalog
  3. Search using REST endpoints, searchCriteria filter groups reference. developer.adobe.com commerce webapi performing-searches

Stuck on a tricky one?

If you have a problem in Magento pricing, tax, indexing, or order data that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a wrong price for you?

If this saved you a confusing dispute with a wholesale buyer or a VIP customer, 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