Getting B2B net pricing, price gating and cache pitfalls right
Anyone showing different prices per customer group has to think about full page cache, GraphQL context and private content together. Otherwise a guest suddenly sees another customer's B2B net price because a cached page preserved the wrong figure.
Table of Contents
- 1. 1. Customer groups in Magento: separating tier price, group price and catalog price rules
- 2. 2. Price resolution in the frontend context: why a fragment cannot simply be cached identically for every group
- 3. 3. Displaying B2B net prices: tax display configuration per customer group
- 4. 4. Price gating for guests: the login for price pattern
- 5. 5. GraphQL price resolution by context: customer group context and price_range
- 6. 6. Full page cache and X-Magento-Vary: concrete pricing pitfalls
- 7. 7. ViewModel and Alpine pattern for price placeholders until private content has loaded
- 8. 8. Setting vary headers and context correctly: GraphQL caching with Varnish and PWA setups
- 9. 9. Testing strategy: systematically checking customer groups against broken caching
- 10. Summary
- 11. FAQ
1. 1. Customer groups in Magento: separating tier price, group price and catalog price rules
Every customer in Magento belongs to exactly one customer group: NOT LOGGED IN for guests, General for regular retail customers, plus any number of custom groups for B2B segments, wholesalers or contract customers. This group membership is the central axis along which Magento can compute different prices, and it is the reason the same product slug has to serve two completely different prices to two different visitors.
Three mechanisms drive that price differentiation, and confusing them causes real bugs. Tier price is a quantity based discount that kicks in from a defined minimum quantity and can have its own steps per customer group. Group price is technically nothing more than a tier price with a quantity threshold of one, so it behaves like a fixed price per group, yet it is often treated as a separate feature in practice. Catalog price rules, on the other hand, are condition based discounts defined against customer group, website or category, and they only reach the displayed price through the price index and the cron reindex.
For the theme it ultimately does not matter which of these three mechanisms produced the final price. What matters is the consequence: the price depends on the requesting visitor's customer group at request time, and that dependency makes it one of the most delicate candidates for caching bugs anywhere in the store.
2. 2. Price resolution in the frontend context: why a fragment cannot simply be cached identically for every group
Full page cache stores a rendered page per URL, not per visitor. If the price block is rendered server side with the price of the currently requesting customer group and the whole page is then stored as a public cache entry, exactly that price ends up in the cache and gets served to every subsequent visitor of the same URL, regardless of their actual customer group.
Magento's answer is the split between public, cacheable content and private, personalized content. Public blocks are allowed to sit in full page cache because they are supposed to look identical for every visitor. Private blocks, which customer group dependent prices almost always are, must either be explicitly excluded from caching or fetched through a separate request after the cached page has already been delivered.
In practice this is the exact mistake that sabotages price display in Hyvä most often: a developer places the price directly inside a plain .phtml template without checking whether the surrounding block is cacheable. As long as testing only ever happens with a single customer group, nothing looks wrong. Only once Varnish or the built in full page cache has warmed up and a second customer group requests the same URL does it become visible that the wrong price was frozen into the cache.
3. 3. Displaying B2B net prices: tax display configuration per customer group
Many B2B customers expect net prices, while retail customers expect groß prices including VAT. Magento controls that through the combination of a tax class per customer group and the global tax configuration for price display. In practice a B2B customer group often gets its own tax class, for example for reverse charge cases, and the theme additionally has to decide whether the price is shown with or without a tax hint.
That decision does not belong scattered across individual templates, it belongs in a single ViewModel. That keeps it testable and lets it be extended in one place whenever a new B2B group is added. The example below shows a ViewModel that decides, based on the current customer group, whether a net or groß price with a matching label is rendered.
It is worth keeping the group list configurable instead of hardcoding group IDs. The list of net price groups can be injected as a constructor argument through dependency injection configuration, so a new B2B customer can be added through a di.xml adjustment or a configuration option instead of a deployment.
<?php
declare(strict_types=1);
namespace Mironsoft\PriceDisplay\ViewModel;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* Determines whether the current customer group should see net or groß prices.
*/
class CustomerGroupPriceDisplay implements ArgumentInterface
{
/**
* @param CustomerSession $customerSession Current customer session used to resolve the group.
* @param int[] $netPriceGroupIds Customer group IDs that generally see net prices.
*/
public function __construct(
private readonly CustomerSession $customerSession,
private readonly array $netPriceGroupIds = [],
) {
}
/**
* Checks whether the current customer group should see net prices.
*
* @return bool
*/
public function shouldShowNetPrice(): bool
{
$groupId = (int) $this->customerSession->getCustomerGroupId();
return in_array($groupId, $this->netPriceGroupIds, true);
}
/**
* Returns the price label matching the tax display of the customer group.
*
* @return string
*/
public function getPriceLabel(): string
{
return $this->shouldShowNetPrice() ? __('excl. VAT') : __('incl. VAT');
}
}
4. 4. Price gating for guests: the login for price pattern
Some B2B stores show prices exclusively to logged in customers. For the NOT LOGGED IN customer group, a login prompt or a call to action such as Login for price appears in place of the price. The product itself stays discoverable through category and search, only the actual amount is withheld, which can have both sales and contractual reasons.
Technically this creates the same customer group dependency as any other price, with the extra requirement that structured data such as the Product schema still needs to stay valid even when no price is rendered. The visibility decision itself must not be hardcoded into a publicly cached block, as described in the previous section, it has to run either through cacheable="false" or be resolved client side after the page has loaded.
The template fragment below shows the pattern with Alpine.js: the visibility state comes from a ViewModel and toggles between price and login link through simple x-if templates, without an extra JavaScript framework and without any Knockout dependency.
<?php
/** @var \Magento\Catalog\Block\Product\ListProduct $block */
/** @var \Mironsoft\PriceDisplay\ViewModel\PriceVisibility $priceViewModel */
$priceViewModel = $block->getData('price_visibility_view_model');
?>
<div class="price-box"
x-data="{ visible: <?= $priceViewModel->isPriceVisibleForCurrentGroup() ? 'true' : 'false' ?> }">
<template x-if="visible">
<span class="text-lg font-semibold text-gray-900">
<?= $block->getProductPriceHtml($product) ?>
</span>
</template>
<template x-if="!visible">
<a href="<?= $block->escapeUrl($block->getUrl('customer/account/login')) ?>"
class="inline-flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
<?= $block->escapeHtml(__('Login for price')) ?>
</a>
</template>
</div>
5. 5. GraphQL price resolution by context: customer group context and price_range
The Magento GraphQL API has no header that lets a client simply request an arbitrary customer group. The group is implicit: without an Authorization header the API treats the request as a guest and returns prices for the NOT LOGGED IN group. When a valid customer token is sent, Magento derives the customer group from the associated account and resolves price_range accordingly.
On top of that, the Store header is mandatory because it determines store view, currency and price display configuration. For testing this means a B2B price request cannot be simulated with a made up header, only with a real customer token for that specific group, something automated tests frequently overlook, which leads to false green results.
The price_range field returns both minimum_price and maximum_price, each with regular_price and final_price, already fully resolved for the requesting context. Discount logic, tier price and catalog price rules do not need to be reimplemented in the frontend as long as the correct context is passed through the store header and the authorization token.
# The Store header is mandatory, the customer group is derived from the customer token
query ProductPrice($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
sku
price_range {
minimum_price {
regular_price {
value
currency
}
final_price {
value
currency
}
}
}
}
}
}
# Request headers:
# Store: default
# Authorization: Bearer <customer_token>
6. 6. Full page cache and X-Magento-Vary: concrete pricing pitfalls
The HTML response of a category or product page is stored by Varnish or the built in full page cache per URL, not per customer group. For a customer group dependent price display to avoid ending up inside that shared HTML variant, the affected block has to be explicitly marked as non cacheable, either through the cacheable="false" attribute in layout XML or by loading the price afterwards through Ajax or GraphQL.
The X-Magento-Vary cookie is Magento's signal that a visitor carries a private context that must not be served the anonymous, publicly cached page variant. If that signal is ignored for a price relevant block because the block was mistakenly configured as cacheable, exactly the leak from section two occurs: the first price ever rendered, usually that of the NOT LOGGED IN group, gets frozen for every visitor after it.
The layout XML fragment below shows how a price block inside the product info area gets explicitly excluded from caching. This configuration alone is not enough in Hyvä, it has to be combined with the reload pattern from the next section so the block still stays performant.
<referenceContainer name="product.info.price">
<block class="Mironsoft\PriceDisplay\Block\CustomerGroupPrice"
name="customer.group.price"
template="Mironsoft_PriceDisplay::product/price.phtml"
cacheable="false" />
</referenceContainer>
7. 7. ViewModel and Alpine pattern for price placeholders until private content has loaded
A block with cacheable="false" solves the correctness problem, but it gives up the cache hit rate of the whole page if it is scoped too broadly. The common Hyvä approach is therefore to render a placeholder price or a skeleton state inside the cacheable HTML and only load the actual price after the page has loaded, through a small Alpine component calling GraphQL.
For guests, whose public NOT LOGGED IN price is already correct, the reload can simply be skipped. For logged in customers, the component replaces the placeholder with the customer group specific price resolved through price_range as soon as the response arrives, without ever pushing the rest of the page out of full page cache.
A clean loading state matters so no layout shift occurs: the price area keeps its size while loading, a subtle skeleton state signals that resolution is still in progress, and only once the response arrives is the final formatted amount inserted.
document.addEventListener('alpine:init', () => {
Alpine.data('customerGroupPrice', (sku, fallbackPriceHtml) => ({
priceHtml: fallbackPriceHtml,
loading: true,
async init() {
// Guests skip the reload, the public price is already valid
if (!window.customerAuthToken) {
this.loading = false;
return;
}
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Store': window.storeCode,
'Authorization': `Bearer ${window.customerAuthToken}`,
},
body: JSON.stringify({
query: `query($sku:String!){ products(filter:{sku:{eq:$sku}}) { items { price_range { minimum_price { final_price { value currency } } } } } }`,
variables: { sku },
}),
});
const { data } = await response.json();
const price = data?.products?.items?.[0]?.price_range?.minimum_price?.final_price;
if (price) {
this.priceHtml = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: price.currency,
}).format(price.value);
}
this.loading = false;
},
}));
});
8. 8. Setting vary headers and context correctly: GraphQL caching with Varnish and PWA setups
GraphQL responses can also be cached at the Varnish layer. Magento sets the response header X-Magento-Cache-Id for that purpose, which already accounts for store, currency and customer group context. A Varnish VCL that caches GraphQL responses only by URL or query body while ignoring that header can accidentally serve a guest the cached B2B price of another customer, because two different customer groups look identical from the VCL's point of view.
In headless setups with a CDN or edge cache in front, the same rule applies even more strictly: authenticated GraphQL price queries generally should not be cached at the CDN at all, they should be answered with Cache-Control: private, while only anonymous, public price queries may be publicly cached for a short window. The Authorization header either has to feed into the cache key or caching has to be disabled entirely for those requests.
In practice this is quick to verify with curl, by issuing the same price query once as a guest and once with a valid customer token and comparing the relevant cache headers. If X-Magento-Cache-Id or Cache-Control do not differ between the two requests, that is a strong signal of a broken cache configuration.
# Query the price as a guest and inspect the cache headers
curl -s -D - -o /dev/null https://shop.example.com/graphql \
-H 'Content-Type: application/json' \
-H 'Store: default' \
-d '{"query":"{ products(filter:{sku:{eq:\"24-MB01\"}}) { items { price_range { minimum_price { final_price { value } } } } } }"}' \
| grep -i 'x-magento-cache-id\|cache-control'
9. 9. Testing strategy: systematically checking customer groups against broken caching
Bugs in customer group dependent prices almost never show up in a local development environment without full page cache. They only appear once Varnish or the built in cache has warmed up and a second customer group requests the same URL. Any test strategy for price display should therefore include a real cache layer, not just the PHP development server.
A useful test matrix requests the same product or category URL for every relevant customer group, meaning at minimum guest, standard customer and the most important B2B groups, twice: once against a cold cache and once against an already warm one. What gets checked is the actual price value, the tax display and relevant cache headers such as Age or X-Magento-Cache-Id.
A simple manual smoke test before every release helps as well: clear the cache, open a product as a B2B customer in one browser, load the same URL as a guest in an incognito window in parallel, compare prices, then reload the B2B session a second time after the anonymous request has already populated the cache. If the prices deviate unexpectedly at that point, a misconfigured cacheable block is almost always the underlying cause.
#!/usr/bin/env bash
# Checks whether the same product URL returns correct prices for every customer group
set -euo pipefail
declare -A TOKENS=(
["guest"]=""
["general"]="$GENERAL_TOKEN"
["wholesale"]="$WHOLESALE_TOKEN"
)
for group in "${!TOKENS[@]}"; do
token="${TOKENS[$group]}"
header=()
if [[ -n "$token" ]]; then
header=(-H "Authorization: Bearer $token")
fi
price=$(curl -s "${header[@]}" -H 'Store: default' -H 'Content-Type: application/json' \
-d '{"query":"{ products(filter:{sku:{eq:\"24-MB01\"}}) { items { price_range { minimum_price { final_price { value } } } } } }"}' \
https://shop.example.com/graphql | jq -r '.data.products.items[0].price_range.minimum_price.final_price.value')
echo "Group: $group -> Price: $price"
done
| Mechanism | Price Source | Caching Layer | Customer Group Relation | Typical Pitfall |
|---|---|---|---|---|
Tier Price |
quantity based discount per group | product and price index | bound directly to group_id | forgetting to reindex after a price change |
Group Price |
fixed price per group, tier price with quantity one | product and price index | bound directly to group_id | mistaken for a genuine quantity discount |
| Catalog Price Rule | condition based discount | cron reindex, scheduled | tied to group through rule conditions | rule only applies after the next cron run |
GraphQL price_range |
resolved via store header and customer token | Varnish/CDN via X-Magento-Cache-Id |
implicit through the authorization token | cache id ignored at the CDN |
| phtml price block | server rendered price block | full page cache, depends on the cacheable flag | depends on correct layout configuration | block mistakenly marked as cacheable |
| Private content placeholder | client side reloaded price | no full page cache, own Ajax or GraphQL call | resolved fresh per request via session or token | placeholder never replaced with JavaScript disabled |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Customer Group Pricing
Separate the price source
Tier price, group price and catalog price rules produce the price differently, but for the theme all that matters is that the value depends on the customer group.
Draw a cache boundary
Customer group dependent price blocks do not belong in publicly cached HTML, they belong behind cacheable false or a client side reload.
Respect the GraphQL context
The store header and the authorization token determine the resolved customer group, a made up header never replaces a real customer token.
Test against a warm cache
Pricing bugs only surface once Varnish or full page cache is warm, so the test matrix has to check every customer group against a real cache layer.