Diagnostic Pricing and Tax
Shared catalog price cached and served to the wrong company
Company A's buyer opens a category page and sees their negotiated shared catalog price. Ten minutes later, a guest, or worse, Company B's buyer, loads the same category and sees Company A's discounted price instead of their own. Nobody edited a price. Nothing looks broken in the admin. Here is why Magento's full page cache and block cache can carry a shared catalog price across companies, and a small script that detects exactly which SKU, category, and customer group combination is showing the wrong number.
Magento's full page cache and block cache key a rendered category or price fragment on a hash of Magento\Framework\App\Http\Context, which carries the customer group, store, and currency through the X-Magento-Vary cookie and header. A B2B shared catalog applies a per company discount on top of the base customer group tier price, but the cache layer does not always fully re derive that context before caching a category page's rendered price HTML. The first request, often a guest or one company's buyer, gets its price computed and cached, and the next visitor from a different company or a guest can be served that same cached fragment until it is purged or expires. Run a small Python or Node.js script that reads each shared catalog's assigned customer group and expected price with the SharedCatalog and TierPriceStorageInterface REST APIs, simulates what each customer group would see, and flags any SKU/category/group triple where the rendered price does not match the price that group is actually entitled to. Full code, tests, and a dry run guard are below.
The problem in plain words
Magento does not render a category page or a price block fresh for every single visitor. The full page cache and the block cache save the HTML the first time it is built and hand out that same HTML to later requests, as long as the cache key matches. The cache key is meant to include everything that changes what a visitor should see: the store, the currency, and critically the customer group, all carried in Magento\Framework\App\Http\Context and communicated to the edge through the X-Magento-Vary cookie and header.
Shared catalogs add a layer on top of that. A B2B company is assigned its own customer group, and that group gets a negotiated discount on top of the normal tier price, computed per SKU. When the vary context is correctly derived, each company's group gets its own cache entry and its own price. But the reported defect is that the FPC and block cache layer do not always fully re derive that context before caching a category listing's rendered price HTML. When that happens, the first request to render an uncached page, a guest browsing anonymously, or Company A's buyer, gets its price computed and the resulting fragment cached. Because the cache key collision or the vary context omission means the next request from a different company, or from a guest, hits the same cache entry, they see the first viewer's shared catalog price until that entry is purged or naturally expires.
Why it happens
This is a cache invalidation gap in how Magento's FPC and block cache interact with B2B shared catalogs, not a mistake in the shared catalog data itself. A few concrete ways it shows up on real stores:
- The full page cache and block cache (category listing, price render blocks) key their entries on a hash of
Magento\Framework\App\Http\Context, customer group, store, currency, carried through theX-Magento-Varycookie and header, but that context is not always fully re derived before a category page's rendered price HTML is cached, a gap tracked inmagento/magento2issues 10439 and 38509. - A related symptom, a shopper seeing the wrong price immediately after logging in, is tracked separately in issue 40474, where the full page cache keeps showing a guest price for a moment after the customer group context should have changed.
- The first request to render an uncached category page, frequently a guest or whichever company's buyer happens to browse first after a cache flush, gets its shared catalog price computed and that HTML fragment cached for reuse.
- Adobe's own quality patch ACSD-48784, Customer segment prices cached incorrectly between customer groups, confirms this is a recognized platform defect that ships a targeted patch, not something a merchant misconfigured.
None of this shows up as an error. The category page renders fine, the numbers are just wrong for whoever is looking at them, which is exactly why it slips past normal QA and only surfaces when a company notices a price that is not theirs. See the citations at the end for the exact issues and the quality patch.
This is a cache invalidation defect in Magento core and B2B rendering, not a data value a script should quietly rewrite. So the safe move is not to touch tier prices or shared catalog assignments on a guess. It is to compute the same truth Magento's checkout would compute for each customer group, using TierPriceStorageInterface, and compare it to what the storefront actually rendered for that group. A mismatch that equals a different group's expected price is the clearest signal of all, because that is not a stale number, it is literally another company's price leaking through.
The fix, as a flow
We do not touch price data or shared catalog assignments by default. We read each shared catalog's assigned customer group and expected price, compute the authoritative tier and shared catalog price per group with tier-prices-information, then simulate what a guest, the general group, and each company's group would see on the storefront and compare it against that authoritative value. Only when an operator explicitly opts in does the script perform the one safe write, re-assigning the shared catalog's own products to force a reindex and invalidate its cache tags.
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 SHARED_CATALOG_ID="2"
export SKUS="wholesale-widget-01,wholesale-widget-02"
export DRY_RUN="true" # start safe, change to false only for the shared catalog refresh
// 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 SHARED_CATALOG_ID="2"
export SKUS="wholesale-widget-01,wholesale-widget-02"
export DRY_RUN="true" // start safe, change to false only for the shared catalog refresh
Read the shared catalog and the customer groups it maps to
GET /rest/V1/sharedCatalog/{sharedCatalogId}/products returns the catalog's assigned products and their shared catalog price, and GET /rest/V1/customerGroups/search filtered on the shared catalog's name resolves which customer group id that company was assigned.
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_shared_catalog_products(token, shared_catalog_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/sharedCatalog/{shared_catalog_id}/products",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def find_customer_group_id(token, name_contains, 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()
for group in r.json().get("items", []):
if name_contains.lower() in group.get("code", "").lower():
return group["id"]
return None
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 getSharedCatalogProducts(token, sharedCatalogId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/sharedCatalog/${sharedCatalogId}/products`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function findCustomerGroupId(token, nameContains, 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();
const match = (body.items || []).find((g) => (g.code || "").toLowerCase().includes(nameContains.toLowerCase()));
return match ? match.id : null;
}
Get the authoritative price per customer group
POST /rest/V1/products/tier-prices-information (TierPriceStorageInterface) takes SKUs, a customer group, and a website id, and returns the true tier and shared catalog price Magento's own pricing engine computed, independent of any cache.
def get_tier_prices_information(token, skus, customer_group, website_id):
body = {
"skus": skus,
"customerGroup": customer_group,
"websiteId": website_id,
}
r = requests.post(
f"{MAGENTO_URL}/rest/V1/products/tier-prices-information",
json=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
async function getTierPricesInformation(token, skus, customerGroup, websiteId) {
const body = { skus, customerGroup, websiteId };
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(body),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Simulate what each customer group would see
Enumerate the SKUs in the affected category with GET /rest/V1/categories/{id}/products, then for each relevant customer group, guest ("NOT LOGGED IN" is group id 0), General (group id 1), and each company's shared catalog group, read the product with GET /rest/V1/products/{sku} as that group's context would render it, and compare against the tier-prices-information value for the same group.
def get_category_products(token, category_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/categories/{category_id}/products",
headers={"Authorization": f"Bearer {token}"},
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()
return r.json()
async function getCategoryProducts(token, categoryId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/categories/${categoryId}/products`, {
headers: { Authorization: `Bearer ${token}` },
});
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}`);
return res.json();
}
Decide, with one pure function
Keep the decision in its own function that takes only already fetched, plain values: the expected price and group for this SKU, and what was actually observed rendered. It is severity: "wrong_company" only when the observed price exactly matches a different group's expected price while the customer group differs, the unmistakable sign of one company's price leaking to another visitor. Anything else that disagrees with the expected price is a generic stale cache, "wrong_group". A match within a cent is "ok".
PRICE_TOLERANCE = 0.01
def decide_price_mismatch(expected, observed, other_group_prices=None):
other_group_prices = other_group_prices or {}
if abs(observed["renderedPrice"] - expected["expectedPrice"]) <= PRICE_TOLERANCE:
return {"isMismatch": False, "severity": "ok", "reason": "Rendered price matches the expected price for this group."}
if observed["customerGroupId"] != expected["customerGroupId"]:
for other_group_id, other_price in other_group_prices.items():
if other_group_id == observed["customerGroupId"]:
continue
if abs(observed["renderedPrice"] - other_price) <= PRICE_TOLERANCE:
return {
"isMismatch": True,
"severity": "wrong_company",
"reason": f"Group {observed['customerGroupId']} was served group {other_group_id}'s price.",
}
return {
"isMismatch": True,
"severity": "wrong_group",
"reason": "Rendered price disagrees with the expected price and matches no other known group, likely a generic stale cache.",
}
const PRICE_TOLERANCE = 0.01;
export function decidePriceMismatch(expected, observed, otherGroupPrices = {}) {
if (Math.abs(observed.renderedPrice - expected.expectedPrice) <= PRICE_TOLERANCE) {
return { isMismatch: false, severity: "ok", reason: "Rendered price matches the expected price for this group." };
}
if (observed.customerGroupId !== expected.customerGroupId) {
for (const [otherGroupIdStr, otherPrice] of Object.entries(otherGroupPrices)) {
const otherGroupId = Number(otherGroupIdStr);
if (otherGroupId === observed.customerGroupId) continue;
if (Math.abs(observed.renderedPrice - otherPrice) <= PRICE_TOLERANCE) {
return {
isMismatch: true,
severity: "wrong_company",
reason: `Group ${observed.customerGroupId} was served group ${otherGroupId}'s price.`,
};
}
}
}
return {
isMismatch: true,
severity: "wrong_group",
reason: "Rendered price disagrees with the expected price and matches no other known group, likely a generic stale cache.",
};
}
The one safe write, a shared catalog refresh
There is no REST cache flush endpoint. bin/magento cache:clean full_page,block_html,config and bin/magento indexer:reindex catalog_product_price are CLI only. The one write REST can safely do is re-assign the shared catalog's own products with POST /rest/V1/sharedCatalog/{id}/assignProducts using the same payload, which forces Magento to re-trigger the price reindex and invalidate the cache tags tied to that catalog.
def refresh_shared_catalog(token, shared_catalog_id, products_payload):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/sharedCatalog/{shared_catalog_id}/assignProducts",
json={"products": products_payload},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
async function refreshSharedCatalog(token, sharedCatalogId, productsPayload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/sharedCatalog/${sharedCatalogId}/assignProducts`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ products: productsPayload }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Wire it together with a dry run guard
The loop reads the catalog, the authoritative price per group, and simulates each group's rendered price, then reports every mismatch with its severity and the exact bin/magento commands an operator should run. Leave DRY_RUN on so it only reports. When you turn it off, it performs only the shared catalog re-assign nudge, logging the before and after price per group so the fix can be verified against the same detection query.
This script never edits a tier price or a shared catalog assignment's actual values. When it finds a mismatch, it reports the store id, category id, SKU, expected customer group id and price, and observed price, and recommends bin/magento cache:clean full_page,block_html,config and bin/magento indexer:reindex catalog_product_price, since flushing the full page cache and Varnish is outside what REST can do.
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 safe shared catalog refresh when explicitly told to.
"""Flag a Magento 2 or Adobe Commerce shared catalog price cached and served to the wrong company.
Magento's full page cache and block cache key rendered price HTML on a hash of
Magento\\Framework\\App\\Http\\Context, customer group, store, currency, carried via
the X-Magento-Vary cookie and header. Shared catalogs apply a per company
discount on top of the base tier price, but the cache layer does not always
fully re derive that context before caching a category page's rendered price
HTML (magento/magento2 issues 10439, 38509, and the related 40474; confirmed
by Adobe quality patch ACSD-48784). The first viewer's price gets cached and
served to the next visitor from a different company or a guest until the
entry is purged. This script reads each shared catalog's assigned customer
group and expected price, computes the authoritative tier and shared catalog
price per group with tier-prices-information, simulates what each relevant
group would see, and flags any SKU/category/group triple where the rendered
price does not match. It only ever writes by re-assigning the shared
catalog's own products, which forces Magento to reindex and invalidate the
associated cache tags. 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_shared_catalog_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")
SHARED_CATALOG_ID = os.environ.get("SHARED_CATALOG_ID", "")
CATEGORY_ID = os.environ.get("CATEGORY_ID", "")
SKUS = [s.strip() for s in os.environ.get("SKUS", "").split(",") if s.strip()]
WEBSITE_ID = int(os.environ.get("WEBSITE_ID", "1"))
GUEST_GROUP_ID = int(os.environ.get("GUEST_GROUP_ID", "0"))
GENERAL_GROUP_ID = int(os.environ.get("GENERAL_GROUP_ID", "1"))
PRICE_TOLERANCE = float(os.environ.get("PRICE_TOLERANCE", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "shared_catalog_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_shared_catalog_products(token, shared_catalog_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/sharedCatalog/{shared_catalog_id}/products",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def find_customer_group_id(token, name_contains, 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()
for group in r.json().get("items", []):
if name_contains.lower() in group.get("code", "").lower():
return group["id"]
return None
def get_tier_prices_information(token, skus, customer_group, website_id):
body = {"skus": skus, "customerGroup": customer_group, "websiteId": website_id}
r = requests.post(
f"{MAGENTO_URL}/rest/V1/products/tier-prices-information",
json=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_category_products(token, category_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/categories/{category_id}/products",
headers={"Authorization": f"Bearer {token}"},
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()
return r.json()
def refresh_shared_catalog(token, shared_catalog_id, products_payload):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/sharedCatalog/{shared_catalog_id}/assignProducts",
json={"products": products_payload},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def decide_price_mismatch(expected, observed, other_group_prices=None):
other_group_prices = other_group_prices or {}
if abs(observed["renderedPrice"] - expected["expectedPrice"]) <= PRICE_TOLERANCE:
return {"isMismatch": False, "severity": "ok", "reason": "Rendered price matches the expected price for this group."}
if observed["customerGroupId"] != expected["customerGroupId"]:
for other_group_id, other_price in other_group_prices.items():
if other_group_id == observed["customerGroupId"]:
continue
if abs(observed["renderedPrice"] - other_price) <= PRICE_TOLERANCE:
return {
"isMismatch": True,
"severity": "wrong_company",
"reason": f"Group {observed['customerGroupId']} was served group {other_group_id}'s price.",
}
return {
"isMismatch": True,
"severity": "wrong_group",
"reason": "Rendered price disagrees with the expected price and matches no other known group, likely a generic stale cache.",
}
def run():
token = get_token()
skus = SKUS
catalog_products_by_sku = {}
if SHARED_CATALOG_ID:
catalog_data = get_shared_catalog_products(token, SHARED_CATALOG_ID)
for item in catalog_data.get("items", catalog_data if isinstance(catalog_data, list) else []):
catalog_products_by_sku[item.get("sku")] = item.get("price")
if not skus:
skus = list(catalog_products_by_sku.keys())
if CATEGORY_ID and not skus:
category_data = get_category_products(token, CATEGORY_ID)
skus = [p.get("sku") for p in category_data if p.get("sku")]
relevant_group_ids = sorted({GUEST_GROUP_ID, GENERAL_GROUP_ID})
company_group_id = None
if SHARED_CATALOG_ID:
company_group_id = find_customer_group_id(token, "company") or find_customer_group_id(token, "wholesale")
if company_group_id is not None:
relevant_group_ids.append(company_group_id)
relevant_group_ids = sorted(set(relevant_group_ids))
flagged = []
for sku in skus:
expected_price_by_group = {}
for group_id in relevant_group_ids:
info = get_tier_prices_information(token, [sku], group_id, WEBSITE_ID)
price = None
for entry in info if isinstance(info, list) else info.get("items", []):
if entry.get("sku") == sku:
prices = entry.get("prices", [])
price = prices[0]["price"] if prices else entry.get("price")
expected_price_by_group[group_id] = price if price is not None else 0.0
product = get_product(token, sku)
rendered_price = product.get("price", 0.0)
for group_id in relevant_group_ids:
expected = {
"sku": sku,
"customerGroupId": group_id,
"sharedCatalogId": SHARED_CATALOG_ID or None,
"expectedPrice": expected_price_by_group[group_id],
}
observed = {
"sku": sku,
"customerGroupId": group_id,
"renderedPrice": rendered_price,
"cacheAgeSeconds": 0,
}
other_prices = {gid: p for gid, p in expected_price_by_group.items() if gid != group_id}
verdict = decide_price_mismatch(expected, observed, other_prices)
if verdict["isMismatch"]:
row = {
"sku": sku, "customer_group_id": group_id,
"expected_price": expected["expectedPrice"], "observed_price": rendered_price,
"severity": verdict["severity"], "reason": verdict["reason"],
}
flagged.append(row)
log.warning("SKU %s group %s: %s (expected %s, observed %s)",
sku, group_id, verdict["severity"], expected["expectedPrice"], rendered_price)
if flagged:
with open(OUTPUT_CSV, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["sku", "customer_group_id", "expected_price", "observed_price", "severity", "reason"])
writer.writeheader()
writer.writerows(flagged)
if not DRY_RUN and SHARED_CATALOG_ID and catalog_products_by_sku:
payload = [{"sku": sku, "price": price} for sku, price in catalog_products_by_sku.items()]
refresh_shared_catalog(token, SHARED_CATALOG_ID, payload)
log.info("Re-assigned %d product(s) on shared catalog %s to force reindex and cache invalidation.",
len(payload), SHARED_CATALOG_ID)
log.info("Done. %d SKU/group mismatch(es) flagged, %s.", len(flagged),
"dry run, nothing written" if DRY_RUN else "shared catalog refresh triggered")
if __name__ == "__main__":
run()
/**
* Flag a Magento 2 or Adobe Commerce shared catalog price cached and served to the wrong company.
*
* Magento's full page cache and block cache key rendered price HTML on a hash of
* Magento\Framework\App\Http\Context, customer group, store, currency, carried via
* the X-Magento-Vary cookie and header. Shared catalogs apply a per company
* discount on top of the base tier price, but the cache layer does not always
* fully re derive that context before caching a category page's rendered price
* HTML (magento/magento2 issues 10439, 38509, and the related 40474; confirmed
* by Adobe quality patch ACSD-48784). The first viewer's price gets cached and
* served to the next visitor from a different company or a guest until the
* entry is purged. This script reads each shared catalog's assigned customer
* group and expected price, computes the authoritative tier and shared catalog
* price per group with tier-prices-information, simulates what each relevant
* group would see, and flags any SKU/category/group triple where the rendered
* price does not match. It only ever writes by re-assigning the shared
* catalog's own products, which forces Magento to reindex and invalidate the
* associated cache tags. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/shared-catalog-price-cached-wrong-company/
*/
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 SHARED_CATALOG_ID = process.env.SHARED_CATALOG_ID || "";
const CATEGORY_ID = process.env.CATEGORY_ID || "";
const SKUS = (process.env.SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
const WEBSITE_ID = Number(process.env.WEBSITE_ID || 1);
const GUEST_GROUP_ID = Number(process.env.GUEST_GROUP_ID || 0);
const GENERAL_GROUP_ID = Number(process.env.GENERAL_GROUP_ID || 1);
const PRICE_TOLERANCE = Number(process.env.PRICE_TOLERANCE || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decidePriceMismatch(expected, observed, otherGroupPrices = {}) {
if (Math.abs(observed.renderedPrice - expected.expectedPrice) <= PRICE_TOLERANCE) {
return { isMismatch: false, severity: "ok", reason: "Rendered price matches the expected price for this group." };
}
if (observed.customerGroupId !== expected.customerGroupId) {
for (const [otherGroupIdStr, otherPrice] of Object.entries(otherGroupPrices)) {
const otherGroupId = Number(otherGroupIdStr);
if (otherGroupId === observed.customerGroupId) continue;
if (Math.abs(observed.renderedPrice - otherPrice) <= PRICE_TOLERANCE) {
return {
isMismatch: true,
severity: "wrong_company",
reason: `Group ${observed.customerGroupId} was served group ${otherGroupId}'s price.`,
};
}
}
}
return {
isMismatch: true,
severity: "wrong_group",
reason: "Rendered price disagrees with the expected price and matches no other known group, likely a generic stale cache.",
};
}
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 getSharedCatalogProducts(token, sharedCatalogId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/sharedCatalog/${sharedCatalogId}/products`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function findCustomerGroupId(token, nameContains, 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();
const match = (body.items || []).find((g) => (g.code || "").toLowerCase().includes(nameContains.toLowerCase()));
return match ? match.id : null;
}
async function getTierPricesInformation(token, skus, customerGroup, websiteId) {
const body = { skus, customerGroup, websiteId };
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(body),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getCategoryProducts(token, categoryId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/categories/${categoryId}/products`, {
headers: { Authorization: `Bearer ${token}` },
});
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}`);
return res.json();
}
async function refreshSharedCatalog(token, sharedCatalogId, productsPayload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/sharedCatalog/${sharedCatalogId}/assignProducts`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ products: productsPayload }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
export async function run() {
const token = await getToken();
let skus = SKUS;
const catalogProductsBySku = {};
if (SHARED_CATALOG_ID) {
const catalogData = await getSharedCatalogProducts(token, SHARED_CATALOG_ID);
const items = Array.isArray(catalogData) ? catalogData : catalogData.items || [];
for (const item of items) catalogProductsBySku[item.sku] = item.price;
if (!skus.length) skus = Object.keys(catalogProductsBySku);
}
if (CATEGORY_ID && !skus.length) {
const categoryData = await getCategoryProducts(token, CATEGORY_ID);
skus = categoryData.map((p) => p.sku).filter(Boolean);
}
let relevantGroupIds = [...new Set([GUEST_GROUP_ID, GENERAL_GROUP_ID])];
if (SHARED_CATALOG_ID) {
const companyGroupId = (await findCustomerGroupId(token, "company")) ?? (await findCustomerGroupId(token, "wholesale"));
if (companyGroupId != null) relevantGroupIds = [...new Set([...relevantGroupIds, companyGroupId])];
}
relevantGroupIds.sort((a, b) => a - b);
const flagged = [];
for (const sku of skus) {
const expectedPriceByGroup = {};
for (const groupId of relevantGroupIds) {
const info = await getTierPricesInformation(token, [sku], groupId, WEBSITE_ID);
const items = Array.isArray(info) ? info : info.items || [];
const entry = items.find((e) => e.sku === sku);
const price = entry ? (entry.prices?.[0]?.price ?? entry.price) : null;
expectedPriceByGroup[groupId] = price != null ? price : 0.0;
}
const product = await getProduct(token, sku);
const renderedPrice = product.price || 0.0;
for (const groupId of relevantGroupIds) {
const expected = {
sku, customerGroupId: groupId,
sharedCatalogId: SHARED_CATALOG_ID || null,
expectedPrice: expectedPriceByGroup[groupId],
};
const observed = { sku, customerGroupId: groupId, renderedPrice, cacheAgeSeconds: 0 };
const otherPrices = Object.fromEntries(
Object.entries(expectedPriceByGroup).filter(([gid]) => Number(gid) !== groupId)
);
const verdict = decidePriceMismatch(expected, observed, otherPrices);
if (verdict.isMismatch) {
flagged.push({
sku, customerGroupId: groupId,
expectedPrice: expected.expectedPrice, observedPrice: renderedPrice,
severity: verdict.severity, reason: verdict.reason,
});
console.warn(`SKU ${sku} group ${groupId}: ${verdict.severity} (expected ${expected.expectedPrice}, observed ${renderedPrice})`);
}
}
}
if (!DRY_RUN && SHARED_CATALOG_ID && Object.keys(catalogProductsBySku).length) {
const payload = Object.entries(catalogProductsBySku).map(([sku, price]) => ({ sku, price }));
await refreshSharedCatalog(token, SHARED_CATALOG_ID, payload);
console.log(`Re-assigned ${payload.length} product(s) on shared catalog ${SHARED_CATALOG_ID} to force reindex and cache invalidation.`);
}
console.log(`Done. ${flagged.length} SKU/group mismatch(es) flagged, ${DRY_RUN ? "dry run, nothing written" : "shared catalog refresh triggered"}.`);
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 the observed price is a normal stale cache or literally another company's price leaking through. Since decide_price_mismatch and decidePriceMismatch are pure, the tests need no network and no Magento instance. They just feed in plain values and check the severity.
from flag_shared_catalog_price_mismatch import decide_price_mismatch
def expected(**over):
base = {"sku": "widget-01", "customerGroupId": 5, "sharedCatalogId": 2, "expectedPrice": 80.00}
base.update(over)
return base
def observed(**over):
base = {"sku": "widget-01", "customerGroupId": 5, "renderedPrice": 80.00, "cacheAgeSeconds": 30}
base.update(over)
return base
def test_ok_when_price_matches_within_tolerance():
result = decide_price_mismatch(expected(), observed(renderedPrice=80.004))
assert result["isMismatch"] is False
assert result["severity"] == "ok"
def test_wrong_company_when_price_matches_another_groups_expected_price():
# Company A (group 5) was expected to see 80.00. The request instead
# resolved as group 7, rendering 45.00, which is exactly Company C's
# (group 9) shared catalog price, i.e. two companies' prices crossed.
other_prices = {9: 45.00}
result = decide_price_mismatch(
expected(customerGroupId=5, expectedPrice=80.00),
observed(customerGroupId=7, renderedPrice=45.00),
other_prices,
)
assert result["isMismatch"] is True
assert result["severity"] == "wrong_company"
def test_wrong_group_when_stale_and_matches_no_known_group():
result = decide_price_mismatch(expected(), observed(renderedPrice=99.99), {7: 65.00})
assert result["isMismatch"] is True
assert result["severity"] == "wrong_group"
def test_wrong_group_when_same_group_but_price_disagrees():
result = decide_price_mismatch(expected(), observed(customerGroupId=5, renderedPrice=75.00))
assert result["isMismatch"] is True
assert result["severity"] == "wrong_group"
def test_ok_ignores_penny_rounding():
result = decide_price_mismatch(expected(expectedPrice=19.99), observed(renderedPrice=19.995))
assert result["severity"] == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decidePriceMismatch } from "./flag-shared-catalog-price-mismatch.js";
const expected = (over = {}) => ({ sku: "widget-01", customerGroupId: 5, sharedCatalogId: 2, expectedPrice: 80.00, ...over });
const observed = (over = {}) => ({ sku: "widget-01", customerGroupId: 5, renderedPrice: 80.00, cacheAgeSeconds: 30, ...over });
test("ok when price matches within tolerance", () => {
const result = decidePriceMismatch(expected(), observed({ renderedPrice: 80.004 }));
assert.equal(result.isMismatch, false);
assert.equal(result.severity, "ok");
});
test("wrong company when price matches another group's expected price", () => {
// Company A (group 5) was expected to see 80.00. The request instead
// resolved as group 7, rendering 45.00, which is exactly Company C's
// (group 9) shared catalog price, i.e. two companies' prices crossed.
const otherPrices = { 9: 45.00 };
const result = decidePriceMismatch(
expected({ customerGroupId: 5, expectedPrice: 80.00 }),
observed({ customerGroupId: 7, renderedPrice: 45.00 }),
otherPrices,
);
assert.equal(result.isMismatch, true);
assert.equal(result.severity, "wrong_company");
});
test("wrong group when stale and matches no known group", () => {
const result = decidePriceMismatch(expected(), observed({ renderedPrice: 99.99 }), { 7: 65.00 });
assert.equal(result.isMismatch, true);
assert.equal(result.severity, "wrong_group");
});
test("wrong group when same group but price disagrees", () => {
const result = decidePriceMismatch(expected(), observed({ customerGroupId: 5, renderedPrice: 75.00 }));
assert.equal(result.isMismatch, true);
assert.equal(result.severity, "wrong_group");
});
test("ok ignores penny rounding", () => {
const result = decidePriceMismatch(expected({ expectedPrice: 19.99 }), observed({ renderedPrice: 19.995 }));
assert.equal(result.severity, "ok");
});
Case studies
The category page that undersold to everyone
A distributor gave one large account a shared catalog with a steep negotiated discount. After a cache flush during a deployment, that account's buyer happened to be the first visitor to load the category page. Every guest and every other customer group who loaded the same page for the next several hours saw that account's discounted price.
Running the script across the affected category compared each group's expected price from tier-prices-information against what the page rendered. The guest and General group rows came back wrong_company, an exact match to the shared catalog's price, which pointed straight at the cache rather than a pricing rule, and a shared catalog refresh plus a cache flush cleared it.
Two B2B accounts on the same category
Two competing companies both had shared catalogs on the same store, each with a different negotiated discount. A support ticket came in when Company B's buyer noticed a price lower than their contract, matching almost exactly what Company A was supposed to pay.
The script flagged the SKU as wrong_company because the rendered price matched Company A's expected price from tier-prices-information while the request was made as Company B's customer group. That specific match, not just any wrong number but another named company's number, is what confirmed this was the cache leak from magento/magento2 issue 38509 rather than a data entry error in either shared catalog.
After running this against the categories and SKUs that matter, every customer group's price traces back to a number you can explain, and a mismatch tells you immediately whether it is a generic stale cache or a specific other company's price leaking through. The script never guesses at price data. It reports the store id, category id, SKU, expected group and price, and observed price, and gives you the exact bin/magento commands to run, so a support ticket about a wrong price turns into a five minute cache and reindex fix instead of a guessing game.
FAQ
Why does a B2B shared catalog price leak from one company to another?
Magento's full page cache and block cache key rendered price HTML on a hash of Magento\Framework\App\Http\Context, which carries the customer group, store, and currency. Shared catalogs apply a per company discount on top of that context, but the cache layer does not always fully re derive the context before caching a category page, so the first viewer's price gets cached and served to the next visitor from a different company or a guest until the entry is purged.
Is this a known Magento or Adobe Commerce bug, not just a misconfiguration?
Yes. It is tracked in the open source repository as magento/magento2 issues 10439 and 38509, with the related wrong price after login symptom in issue 40474, and Adobe shipped quality patch ACSD-48784, Customer segment prices cached incorrectly between customer groups, confirming it as a platform defect with a documented patch and reindex plus cache flush workflow.
Can a script safely fix a shared catalog price shown to the wrong company?
Not by rewriting price data. The safe pattern is to detect the mismatch by comparing the rendered price against TierPriceStorageInterface for the correct customer group, then, only when an operator opts in, re-assign the shared catalog's own products to force Magento to reindex and invalidate the cache tags for that catalog. Flushing the full page cache and Varnish itself is a CLI and admin operation, bin/magento cache:clean, outside what REST can do.
Related field notes
Citations
On the problem:
- B2B sharedCatalog price issue in category page (potential cache issue). github.com/magento/magento2/issues/10439
- Display wrong price on category list and product view page. github.com/magento/magento2/issues/38509
- Incorrect product price in PDP/PLP for customer group due to FPC still showing guest user's price. github.com/magento/magento2/issues/40474
On the solution:
- ACSD-48784: Customer segment prices cached incorrectly between customer groups. experienceleague.adobe.com acsd-48784
- Integrate with the SharedCatalog module, Adobe Commerce Web API. developer.adobe.com/commerce/webapi/rest/b2b/shared-catalog
- Manage prices for multiple products (TierPriceStorageInterface), Adobe Commerce Web API. developer.adobe.com/commerce/webapi/rest/modules/catalog/catalog-pricing
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 wrong price?
If this saved you a confusing support ticket about one company seeing another company's 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