B2B-Nettopreise, gesperrte Preise und Cache-Fallstricke sauber lösen
Wer Preise je Kundengruppe unterschiedlich anzeigt, muss Full Page Cache, GraphQL-Kontext und private content gemeinsam denken. Sonst sieht ein Gast plötzlich den B2B-Nettopreis eines fremden Kunden, weil eine gecachte Seite den falschen Preis konserviert hat.
Inhaltsverzeichnis
- 1. 1. Kundengruppen in Magento: Tier Price, Group Price und Catalog Price Rules einordnen
- 2. 2. Preisauflösung im Frontend-Kontext: warum ein Fragment nicht für alle Kundengruppen identisch gecacht werden darf
- 3. 3. B2B-Nettopreise darstellen: Steueranzeige-Konfiguration je Kundengruppe
- 4. 4. Gesperrte Preise für Gäste: das Login-für-Preis-Pattern
- 5. 5. GraphQL-Preisauflösung je nach Kontext: Customer Group Context und price_range
- 6. 6. Full Page Cache und X-Magento-Vary: konkrete Fallstricke bei Preisen
- 7. 7. ViewModel- und Alpine-Pattern für Preis-Platzhalter bis private content geladen ist
- 8. 8. Vary-Header und Context korrekt setzen: GraphQL-Caching mit Varnish und PWA
- 9. 9. Testing-Strategie: verschiedene Kundengruppen systematisch gegen falsches Caching prüfen
- 10. Zusammenfassung
- 11. FAQ
1. 1. Kundengruppen in Magento: Tier Price, Group Price und Catalog Price Rules einordnen
Jeder Kunde in Magento gehört zu genau einer Kundengruppe: NOT LOGGED IN für Gäste, General für normale Endkunden, dazu beliebig viele eigene Gruppen für B2B-Segmente, Großhändler oder Vertragskunden. Diese Gruppenzugehörigkeit ist die zentrale Achse, entlang der Magento Preise unterschiedlich berechnen kann, und sie ist der Grund, warum ein und derselbe Produkt-Slug für zwei Besucher zwei völlig verschiedene Preise ausliefern muss.
Für die Preisdifferenzierung stehen drei Mechanismen zur Verfügung, die man als Frontend-Entwickler nicht verwechseln sollte. Tier Price ist ein Staffelpreis, der ab einer definierten Mindestmenge greift und je Kundengruppe eigene Stufen haben kann. Group Price ist technisch nichts anderes als ein Tier Price mit Mengenschwelle eins, wirkt also wie ein Fixpreis je Gruppe, wird in der Praxis aber oft fälschlich als eigenständiges Feature behandelt. Catalog Price Rules sind dagegen regelbasierte Rabatte, die über Bedingungen wie Kundengruppe, Website oder Kategorie definiert werden und erst durch den Preisindex beziehungsweise den Cron-Reindex in die tatsächlich angezeigten Preise einfließen.
Für das Theme spielt es letztlich keine Rolle, welcher dieser drei Mechanismen den finalen Preis erzeugt hat. Wichtig ist nur die Konsequenz: der Preis ist zur Laufzeit von der Kundengruppe des anfragenden Besuchers abhängig, und genau diese Abhängigkeit macht ihn zu einem der heikelsten Kandidaten für Caching-Fehler im gesamten Shop.
2. 2. Preisauflösung im Frontend-Kontext: warum ein Fragment nicht für alle Kundengruppen identisch gecacht werden darf
Der Full Page Cache speichert eine gerenderte Seite pro URL, nicht pro Besucher. Wird der Preis-Block serverseitig mit dem Preis der gerade anfragenden Kundengruppe gerendert und die komplette Seite anschließend als öffentlicher Cache-Eintrag abgelegt, landet genau dieser Preis im Cache und wird an jeden folgenden Besucher derselben URL ausgeliefert, unabhängig von dessen tatsächlicher Kundengruppe.
Magentos Antwort darauf ist die Trennung zwischen öffentlichem, cachebarem Inhalt und privatem, personalisiertem Inhalt. Öffentliche Blöcke dürfen im Full Page Cache landen, weil sie für jeden Besucher gleich aussehen sollen. Private Blöcke, zu denen kundengruppen-abhängige Preise fast immer gehören, müssen entweder explizit von der Cachebarkeit ausgenommen oder erst nach dem Ausliefern der gecachten Seite über einen separaten Request nachgeladen werden.
In der Praxis ist genau das der Fehler, der Preisanzeigen im Hyvä-Theme am häufigsten sabotiert: ein Entwickler baut den Preis direkt in ein normales .phtml-Template ein, ohne zu prüfen, ob der umgebende Block cachebar ist. Solange nur mit einer einzigen Kundengruppe getestet wird, fällt das nicht auf. Erst wenn Varnish oder der Built-in Full Page Cache warmgelaufen ist und eine zweite Kundengruppe dieselbe URL aufruft, wird sichtbar, dass der falsche Preis konserviert wurde.
3. 3. B2B-Nettopreise darstellen: Steueranzeige-Konfiguration je Kundengruppe
Viele B2B-Kunden erwarten Nettopreise, während Endkunden Bruttopreise inklusive Mehrwertsteuer sehen wollen. Magento steuert das über die Kombination aus Steuerklasse je Kundengruppe und der globalen Tax-Konfiguration für die Preisanzeige. In der Praxis bekommt eine B2B-Kundengruppe häufig eine eigene Steuerklasse, etwa für Reverse-Charge-Fälle, und das Theme muss zusätzlich entscheiden, ob der Preis mit oder ohne Steuerhinweis angezeigt wird.
Diese Entscheidung gehört nicht verstreut in einzelne Templates, sondern zentral in ein ViewModel. So bleibt sie testbar und lässt sich an einer einzigen Stelle erweitern, wenn eine neue B2B-Gruppe hinzukommt. Das folgende Beispiel zeigt ein ViewModel, das anhand der aktuellen Kundengruppe entscheidet, ob Netto- oder Bruttopreis samt passendem Label ausgegeben wird.
Wichtig ist, die Gruppenliste konfigurierbar zu halten statt Gruppen-IDs im Code zu hinterlegen. In der Dependency-Injection-Konfiguration lässt sich die Liste der Netto-Gruppen als Argument injizieren, sodass ein neuer B2B-Kunde ohne Deployment über di.xml-Anpassung oder eine Konfigurationsoption ergänzt werden kann.
<?php
declare(strict_types=1);
namespace Mironsoft\PriceDisplay\ViewModel;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* Ermittelt, ob für die aktuelle Kundengruppe Netto- oder Bruttopreise angezeigt werden.
*/
class CustomerGroupPriceDisplay implements ArgumentInterface
{
/**
* @param CustomerSession $customerSession Aktuelle Kundensession für die Gruppenermittlung.
* @param int[] $netPriceGroupIds Kundengruppen-IDs, die grundsätzlich Nettopreise sehen.
*/
public function __construct(
private readonly CustomerSession $customerSession,
private readonly array $netPriceGroupIds = [],
) {
}
/**
* Prueft, ob die aktuelle Kundengruppe Nettopreise sehen soll.
*
* @return bool
*/
public function shouldShowNetPrice(): bool
{
$groupId = (int) $this->customerSession->getCustomerGroupId();
return in_array($groupId, $this->netPriceGroupIds, true);
}
/**
* Liefert das passende Preislabel zur Steueranzeige der Kundengruppe.
*
* @return string
*/
public function getPriceLabel(): string
{
return $this->shouldShowNetPrice() ? __('zzgl. MwSt.') : __('inkl. MwSt.');
}
}
4. 4. Gesperrte Preise für Gäste: das Login-für-Preis-Pattern
Manche B2B-Shops zeigen Preise ausschließlich angemeldeten Kunden. Für die Kundengruppe NOT LOGGED IN erscheint an der Stelle des Preises stattdessen ein Login-Hinweis oder ein Call-to-Action wie Login für Preis. Das Produkt selbst bleibt über Kategorie und Suche auffindbar, nur der konkrete Betrag wird zurückgehalten, was sowohl vertriebliche als auch vertragsrechtliche Gründe haben kann.
Technisch entsteht dieselbe Kundengruppen-Abhängigkeit wie bei jedem anderen Preis, mit der zusätzlichen Anforderung, dass strukturierte Daten wie das Product-Schema weiterhin valide bleiben müssen, auch wenn kein Preis ausgegeben wird. Die Sichtbarkeitsentscheidung selbst darf, wie im vorherigen Abschnitt beschrieben, nicht in einem öffentlich gecachten Block hartkodiert sein, sondern muss entweder über cacheable="false" laufen oder clientseitig nach dem Laden der Seite aufgelöst werden.
Das folgende Template-Fragment zeigt das Pattern mit Alpine.js: der Sichtbarkeitszustand wird aus einem ViewModel übernommen und über einfache x-if-Templates zwischen Preis und Login-Link umgeschaltet, ohne zusätzliches JavaScript-Framework und ohne Knockout-Abhängigkeit.
<?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 für Preis')) ?>
</a>
</template>
</div>
5. 5. GraphQL-Preisauflösung je nach Kontext: Customer Group Context und price_range
Bei der Magento-GraphQL-API gibt es keinen Header, mit dem ein Client einfach eine beliebige Kundengruppe anfordern kann. Die Kundengruppe ergibt sich implizit: ohne Authorization-Header behandelt die API die Anfrage als Gast und liefert Preise der Gruppe NOT LOGGED IN. Wird ein gültiges Customer-Token mitgesendet, leitet Magento die Kundengruppe aus dem zugehörigen Kundenkonto ab und löst price_range entsprechend auf.
Zusätzlich ist der Store-Header verpflichtend, weil er Store-View, Währung und Preisanzeige-Konfiguration festlegt. Für Testzwecke bedeutet das: eine B2B-Preisanfrage lässt sich nicht durch einen frei erfundenen Header simulieren, sondern nur durch ein echtes Customer-Token der jeweiligen Kundengruppe, was bei automatisierten Tests häufig übersehen wird und zu falschen Grün-Ergebnissen führt.
Das price_range-Feld liefert sowohl minimum_price als auch maximum_price mit jeweils regular_price und final_price, bereits vollständig für den anfragenden Kontext aufgelöst. Rabattlogik, Tier Price und Catalog Price Rules müssen im Frontend nicht mehr nachgebildet werden, solange der richtige Kontext über Store-Header und Authorization-Token mitgegeben wird.
# Store-Header ist Pflicht, die Kundengruppe wird über den Customer-Token abgeleitet
query ProductPrice($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
sku
price_range {
minimum_price {
regular_price {
value
currency
}
final_price {
value
currency
}
}
}
}
}
}
# HTTP-Header für den Request:
# Store: default
# Authorization: Bearer <customer_token>
6. 6. Full Page Cache und X-Magento-Vary: konkrete Fallstricke bei Preisen
Die HTML-Antwort einer Kategorie- oder Produktseite wird von Varnish oder dem Built-in Full Page Cache pro URL gespeichert, nicht pro Kundengruppe. Damit eine kundengruppen-abhängige Preisanzeige nicht in dieser gemeinsamen HTML-Variante landet, muss der betroffene Block explizit als nicht cachebar markiert werden, entweder über das Attribut cacheable="false" im Layout-XML oder indem der Preis erst nachträglich per Ajax oder GraphQL nachgeladen wird.
Das X-Magento-Vary-Cookie ist Magentos Signal dafür, dass ein Besucher einen privaten Kontext besitzt, der nicht mit der anonymen, öffentlich gecachten Seitenvariante bedient werden darf. Wird dieses Signal bei einem preisrelevanten Block ignoriert, weil der Block fälschlich als cachebar konfiguriert wurde, entsteht genau das Leck aus Abschnitt zwei: der zuerst gerenderte Preis, meist der der Gruppe NOT LOGGED IN, wird für alle folgenden Besucher eingefroren.
Das folgende Layout-XML-Fragment zeigt, wie ein Preis-Block innerhalb des Produktinfobereichs explizit von der Cachebarkeit ausgenommen wird. Diese Konfiguration allein reicht in Hyvä nicht aus, sie muss mit dem Nachlade-Pattern aus dem folgenden Abschnitt kombiniert werden, damit der Block trotzdem performant bleibt.
<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- und Alpine-Pattern für Preis-Platzhalter bis private content geladen ist
Ein Block mit cacheable="false" löst das Korrektheitsproblem, verschenkt aber die Cache-Trefferquote der gesamten Seite, wenn er zu grob geschnitten ist. Der in Hyvä übliche Weg ist deshalb, in der cachebaren HTML einen Platzhalterpreis oder einen Skeleton-Zustand auszugeben und den tatsächlichen Preis erst nach dem Laden der Seite über eine kleine Alpine-Komponente per GraphQL nachzuladen.
Für Gäste, bei denen der öffentliche Preis der Gruppe NOT LOGGED IN ohnehin korrekt ist, kann das Nachladen entfallen. Für angemeldete Kunden ersetzt die Komponente den Platzhalter durch den über price_range aufgelösten, kundengruppen-spezifischen Preis, sobald die Antwort eintrifft, ohne dass die restliche Seite aus dem Full Page Cache verdrängt werden muss.
Wichtig ist ein sauberer Ladezustand, damit kein Layout-Sprung entsteht: die Preis-Fläche behält während des Nachladens ihre Größe, ein dezenter Skeleton-Zustand signalisiert, dass noch aufgelöst wird, und erst mit der Antwort wird der endgültige Betrag formatiert eingesetzt.
document.addEventListener('alpine:init', () => {
Alpine.data('customerGroupPrice', (sku, fallbackPriceHtml) => ({
priceHtml: fallbackPriceHtml,
loading: true,
async init() {
// Für Gäste keine Nachladung, der öffentliche Preis bleibt gültig
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('de-DE', {
style: 'currency',
currency: price.currency,
}).format(price.value);
}
this.loading = false;
},
}));
});
8. 8. Vary-Header und Context korrekt setzen: GraphQL-Caching mit Varnish und PWA
Auch GraphQL-Antworten können auf Varnish-Ebene gecacht werden. Magento setzt dafür den Response-Header X-Magento-Cache-Id, der Store, Währung und Kundengruppen-Kontext bereits einrechnet. Eine Varnish-VCL, die GraphQL-Antworten nur nach URL oder Query-Body cached und diesen Header ignoriert, kann einem Gast versehentlich den zwischengespeicherten B2B-Preis eines anderen Kunden ausliefern, weil zwei unterschiedliche Kundengruppen aus Sicht der VCL identisch aussehen.
Bei headless-Aufbauten mit vorgeschaltetem CDN oder Edge-Cache gilt dieselbe Regel verschärft: authentifizierte GraphQL-Preisabfragen sollten am CDN grundsätzlich nicht gecacht werden, sondern mit Cache-Control: private beantwortet werden, während nur anonyme, öffentliche Preisabfragen für eine kurze Zeitspanne öffentlich gecacht werden dürfen. Der Authorization-Header muss entweder in den Cache-Key einfließen oder das Caching für diese Anfragen vollständig deaktivieren.
In der Praxis lässt sich das schnell mit curl überprüfen, indem man dieselbe Preisabfrage einmal als Gast und einmal mit einem gültigen Customer-Token stellt und die relevanten Cache-Header vergleicht. Unterscheiden sich X-Magento-Cache-Id oder Cache-Control nicht zwischen beiden Anfragen, ist das ein starkes Indiz für eine fehlerhafte Cache-Konfiguration.
# Preis für einen Gast abfragen und Cache-Header prüfen
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-Strategie: verschiedene Kundengruppen systematisch gegen falsches Caching prüfen
Fehler bei kundengruppen-abhängigen Preisen zeigen sich fast nie in einer lokalen Entwicklungsumgebung ohne Full Page Cache. Sie treten erst auf, wenn Varnish oder der Built-in Cache warmgelaufen ist und eine zweite Kundengruppe dieselbe URL aufruft. Deshalb sollte jede Teststrategie für Preisanzeige eine echte Cache-Schicht einschließen, nicht nur den PHP-Entwicklungsserver.
Sinnvoll ist eine Testmatrix, die für jede relevante Kundengruppe, also mindestens Gast, Standardkunde und die wichtigsten B2B-Gruppen, dieselbe Produkt- oder Kategorie-URL zweimal aufruft: einmal bei kaltem und einmal bei bereits warmem Cache. Geprüft werden der tatsächliche Preiswert, die Steueranzeige und relevante Cache-Header wie Age oder X-Magento-Cache-Id.
Ergänzend hilft ein einfacher manueller Smoke-Test vor jedem Release: Cache leeren, als B2B-Kunde in einem Browser ein Produkt öffnen, dieselbe URL parallel in einem Inkognito-Fenster als Gast laden, Preise vergleichen, und anschließend die B2B-Session ein zweites Mal aufrufen, nachdem der anonyme Aufruf den Cache gefüllt hat. Weichen die Preise dabei unerwartet an, liegt fast immer ein fehlkonfigurierter cachebarer Block zugrunde.
#!/usr/bin/env bash
# Prüft, ob dieselbe Produkt-URL für verschiedene Kundengruppen korrekte Preise liefert
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 "Gruppe: $group -> Preis: $price"
done
| Mechanismus | Preis-Quelle | Caching-Ebene | Kundengruppen-Bezug | Typischer Fallstrick |
|---|---|---|---|---|
Tier Price |
Staffelpreis je Menge und Gruppe | Produkt- und Preisindex | direkt an group_id gebunden | Reindex nach Preisänderung vergessen |
Group Price |
Fixpreis je Gruppe, Tier Price mit Menge eins | Produkt- und Preisindex | direkt an group_id gebunden | mit echtem Staffelpreis verwechselt |
| Catalog Price Rule | regelbasierter Rabatt nach Bedingungen | Cron-Reindex, zeitgesteuert | über Regel-Bedingungen an Gruppe geknüpft | Regel wirkt erst nach dem nächsten Cronlauf |
GraphQL price_range |
über Store-Header und Customer-Token aufgelöst | Varnish/CDN via X-Magento-Cache-Id |
implizit über Authorization-Token | Cache-Id am CDN ignoriert |
| phtml-Preis-Block | serverseitig gerenderter Preis-Block | Full Page Cache, abhängig vom cacheable-Flag | abhängig von korrekter Layout-Konfiguration | Block fälschlich als cachebar markiert |
| Private-Content-Platzhalter | clientseitig nachgeladener Preis | kein Full Page Cache, eigener Ajax- oder GraphQL-Call | pro Request neu über Session oder Token | Platzhalter bleibt bei deaktiviertem JavaScript stehen |
Mironsoft
Hyvä-Theme-Entwicklung und Luma-Migration
Noch auf Luma unterwegs oder ein Hyvä-Theme, das nicht rund läuft?
Wir entwickeln Hyvä-Themes für Magento von Grund auf oder migrieren bestehende Luma-Shops sauber, mit Tailwind CSS, Alpine.js und ohne unnötiges JavaScript-Gepäck.
Luma-zu-Hyvä-Migration
Bestehenden Shop strukturiert und ohne Funktionsverlust auf Hyvä umstellen.
Custom-Theme-Entwicklung
Individuelles Hyvä-Theme nach Design-Vorgaben von Grund auf umsetzen.
Performance-Optimierung
Core Web Vitals und Ladezeiten im Hyvä-Frontend gezielt verbessern.
10. Zusammenfassung
Kundengruppen-Preisanzeige
Preisquelle trennen
Tier Price, Group Price und Catalog Price Rules erzeugen den Preis unterschiedlich, für das Theme zählt am Ende nur, dass der Wert kundengruppen-abhängig ist.
Cache-Grenze ziehen
Kundengruppen-abhängige Preis-Blöcke gehören nicht in öffentlich gecachte HTML, sondern hinter cacheable false oder eine clientseitige Nachladung.
GraphQL-Kontext respektieren
Store-Header und Authorization-Token bestimmen die aufgelöste Kundengruppe, ein frei erfundener Header ersetzt kein echtes Customer-Token.
Mit warmem Cache testen
Preisfehler zeigen sich erst bei warmem Varnish oder Full Page Cache, deshalb muss die Testmatrix jede Kundengruppe gegen eine echte Cache-Schicht prüfen.