Customer Group Specific Price Display in the Hyvä Theme: B2B Pricing, Login Gating and Cache Pitfalls
AI generated
Hyvä
phtml
Hyvä Theme
Customer Group Specific Price Display in the Hyvä Theme
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.

12 min read B2B Pricing Full Page Cache GraphQL Context Login For Price

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.

11. FAQ: Customer Group Pricing

1Can a full page cache page show different prices for different customer groups?
Not directly inside the cached HTML file itself. The cached page contains either a public placeholder price or no price at all, the actual customer group specific price display is reloaded through a separate, uncached request, for example via GraphQL or through a block marked cacheable false.
2What is the difference between tier price and group price in Magento?
Tier price is a quantity based discount that applies from a certain minimum quantity and can be configured differently per customer group. Group price is technically a tier price with a quantity threshold of one, so it behaves like a fixed price per group without a quantity discount.
3How do catalog price rules differ from tier price?
Catalog price rules are condition based discounts defined through conditions such as customer group, website or category, and they reach the price index through the cron reindex. Tier price, in contrast, is stored directly on the product for a customer group and applies without a separate rule evaluation.
4How do you display net prices to B2B customers in the Hyvä theme?
Through a combination of tax classes per customer group and ViewModel logic that decides, based on the current customer group, whether the price is shown including or excluding tax. That decision should live centrally in the ViewModel rather than scattered across individual templates.
5How does the login for price pattern work technically?
The price is not rendered for the NOT LOGGED IN customer group, a login link appears instead. Since this decision depends on the customer group, it must not live inside a publicly cached block, it has to go through cacheable false or a client side reload.
6How does the GraphQL API know which customer group is requesting?
For logged in customers the customer group is implicitly derived from the customer token sent in the authorization header. Without that token the API treats the request as a guest and returns prices for the NOT LOGGED IN group.
7What does the X-Magento-Vary cookie have to do with prices?
It signals that a visitor carries a private context that must not be served the anonymous, publicly cached page variant. If that context is ignored for price relevant blocks, incorrectly cached prices can leak between customer groups.
8Why should GraphQL price queries not be cached at the CDN by default?
Because the result depends on the authorization token and therefore on the customer group. A CDN that caches only by query body and ignores the token might serve a guest the cached B2B price of another customer.
9How do you test customer group dependent prices against caching bugs?
With a test matrix that checks every relevant customer group twice against the same URL, once against a cold and once against a warm cache, comparing the expected price and relevant cache headers. It is important to test against a real Varnish or full page cache environment, not just the PHP development server.
10Does an Alpine.js price placeholder remove the need for cacheable false?
No, the two mechanisms complement each other. The placeholder provides a layout stable reload on the frontend, while cacheable false or private content prevents the customer group specific price from ever reaching the publicly cached HTML response in the first place.