Rabatte in Magento 2 lesbar machen: eine Zeile pro Warenkorbregel
In einem Shop, den ich betreue, stand im Warenkorb eine Notiz, die nie ein Kunde lesen sollte. Der Text stammte aus dem Feld „Beschreibung“ einer Regel, das im Backend als Notizfeld des Teams gedacht ist.
Was Magento speichert und was nicht
Magento fasst alle angewendeten Rabatte in einer Summenzeile zusammen:
„Rabatt (…)“. Der Text in der Klammer entsteht aus den Labels der Regeln. Die
Kette steht in
RulesApplier::getRuleLabel():
zuerst das Store-Label der Regel, dann, sobald ein Gutschein im Spiel ist, die
Regelbeschreibung, dann der Gutscheincode.
Das Feld „Beschreibung“ ist im Backend ein Notizfeld. Dort steht, was das Team sich notiert hat: zu welcher Kampagne eine Regel gehört, aus welchem Ticket sie stammt. Genau dieser Text stand im Warenkorb.
Eine Korrektur im Template kann keine Zeile pro Regel erzeugen, weil die Beträge nicht gespeichert sind. In der Bestellung speichert Magento drei Felder:
coupon_code, ein einzelner Codediscount_description, ein Textfeldapplied_rule_ids, eine Liste von IDs
Mehr ist es nicht. sales_order in
Magento/Sales/etc/db_schema.xml
führt coupon_code und discount_description als varchar(255),
applied_rule_ids als varchar(128), und keine Spalte für einen Betrag pro
Regel. Geprüft an Magento 2.4.7.
Keine Beträge pro Regel. Sobald die Bestellung existiert, ist die Aufteilung der Rabattsumme auf die Regeln verloren. Jede Lösung, die eine Zeile pro Regel zeigen will, muss die Beträge in dem Moment speichern, in dem die Bestellung entsteht.
Denselben Text habe ich auch in der Versandzeile gesehen, „Versand und
Bearbeitung (…)“, bei einer Bestellung, deren einzige Regel Gratisversand war.
Eine Gratisversandregel läuft durch denselben Beschreibungs-Code, in
Validator::processShippingAmount(). Die Stelle im Core, die den Text in diese
Zeile schreibt, habe ich nicht gefunden: die Versandzeile ist also meine
Beobachtung aus der Testumgebung, die Rabattzeile der Weg, den der Quelltext
zeigt.
Die E-Mail selbst ist nicht die von Magento. Dieses Layout kommt aus einem zweiten Modul, das der Schwesterbeitrag beschreibt, und keines der beiden Module braucht das andere.
Das Vorbild war Shopify
Shopify macht das besser, und das war der Anlass für das Modul. Dort erscheint jeder angewendete Rabatt als eigene Zeile in der Bestellübersicht, auch wenn mehrere Rabatte kombiniert werden (Discount Combinations im Changelog). Dass die Zeilen dort getrennt stehen, ist meine eigene Beobachtung an einem Shopify-Checkout und keine dokumentierte Zusage. Ich habe nach einer Seite in Shopifys Dokumentation gesucht, die das festhält, und keine gefunden.
Shopify hat dabei eine eigene Schwäche: die Zeile trägt den Code, den der Kunde eingegeben hat, und ein Shop kann keinen sprechenden Namen hinterlegen (Shopify Community). Was Shopify bei einem automatischen Rabatt zeigt, der gar keinen Code hat, konnte ich aus keiner zitierfähigen Quelle belegen.
Das Ziel war deshalb beides: eine Zeile pro Regel und eine Beschriftung aus einem Feld, das für Kunden geschrieben ist, auch bei einer Regel, für die der Kunde nichts eingegeben hat.
Die Beschriftungsregel
| Art | Beschriftung | Betragsspalte |
|---|---|---|
coupon | der Gutscheincode | -Betrag |
automatic | das Store-Label der Regel, sonst ein übersetztes „Rabatt“ | -Betrag |
free_shipping | der Code oder das Store-Label, sonst „Gratisversand“ | -Versandrabatt oder „Gratis“ |
gift | der Gutscheincode der Aktion | „Geschenk“ ohne Betrag, sonst der Betrag |
gift_card | „Geschenkkarte ••••1234“, maskiert | -Betrag |
Regelbeschreibung und Regelname kommen nie vor. Beschriftungen und Beträge werden beim Anlegen der Bestellung festgehalten, also ändert ein späteres Umbenennen oder Löschen einer Regel alte Bestellungen nicht.
Wie die Beträge den Checkout überleben
Speicherung an der Quote-Adresse bei der Summenberechnung. Nachdem die
Summen einer Adresse mit collectTotals berechnet sind, werden die Zeilen pro Regel aufgelöst und an der
Quote-Adresse gespeichert (quote_address.pp_discount_lines). Die Auflösung
übernimmt ein generischer Regel-Resolver, der die regelweise Aufschlüsselung
des Kerns ausliest und damit jede simple_action abdeckt, darunter
by_percent, by_fixed, cart_fixed und buy_x_get_y. Dazu kommt ein
Resolver für Gratisversand. Beide stecken in einem Composite, und dieses Composite ist der
Erweiterungspunkt: ein Adapter hängt sich per di.xml als benannter Resolver
ein. Eine fehlgeschlagene Summenberechnung löscht die gespeicherten Zeilen.
Extension Attribute am Summensegment. Ein Plugin auf
CartTotalRepositoryInterface hängt die Zeilen als
extension_attributes.discount_lines an das Segment discount an und ersetzt
dessen Titel, der standardmäßig die Regelbeschreibung trägt. Eine Quote mit
ausschließlich betragslosen Zeilen, also Geschenk oder Gratisversand, bekommt
ein Segment discount mit dem Wert 0, damit die Renderer die Zeilen trotzdem
erhalten. Gespeicherte Zeilen, die nicht zum Segmentwert passen, werden gar
nicht erst ausgegeben, und der Titel bleibt das schlichte „Rabatt“.
Persistenz beim Anlegen der Bestellung. Bei
sales_model_service_quote_submit_success werden die Zeilen in die Tabelle
pp_discount_line geschrieben: Zeilen auf Bestellebene plus Beträge pro Regel
auf Positionsebene, und genau die machen die Aufteilung einer Gutschrift
möglich. Eine rabattierte Bestellung ohne zuordenbare Zeilen bekommt eine
allgemeine automatic-Zeile „Rabatt“ über den vollen Betrag, damit eine
Bestellung nie auf die Beschriftung von Magento zurückfällt.
Die Schnittstelle für eigene Renderer
Jede Zeile ist eine eigene Summenzeile in den Standard-Totals-Blöcken von
Magento, mit dem Code pp_discount_line_<n> anstelle der Standardzeile discount.
Die Zeile trägt den Datenschlüssel discount_line:
discount_line = {
kind: "coupon" | "automatic" | "gift" | "free_shipping" | "gift_card",
rule_id: int|null, // null bei gift_card
code: string|null, // Gutschein- oder Kartencode, bei gift_card maskiert
label: string, // von der Beschriftungsregel aufgelöst, sicher anzeigbar
amount: float, // Bestellwährung, positive Zahl = Rabatt
base_amount: float
}
Du erkennst eine Rabattzeile an diesem Datenschlüssel, nicht am Zeilencode.
Der Mageplaza-Adapter behält die eigenen Zeilencodes von Mageplaza und trägt
discount_line trotzdem, mit kind gleich gift_card. label und value der
Zeile bleiben gefüllt, wie Magento es erwartet, also gibt auch eine Vorlage,
die den Datenschlüssel ignoriert, eine korrekte schlichte Zeile aus.
Ein Basispaket, kleine Renderer
Das Basispaket kennt kein Frontend. Es verantwortet die Daten, die Speicherung, die Beschriftungsregel und alle Bestelldokumente. Die Storefront-Darstellung liegt in eigenen Paketen, damit du nur installierst, was du betreibst.
| Paket | Was es ergänzt |
|---|---|
magento2-module-discount-lines | Daten, Speicherung, Beschriftung, Dokumente, Konfiguration |
-hyva-checkout | Preisübersicht in Hyvä Checkout |
-hyva-theme | Warenkorbseite im Hyvä-Standardtheme |
-luma | Warenkorb und Checkout-Sidebar in Luma |
-amasty-promo | Amasty Free Gift als Geschenkzeilen |
-mageplaza-giftcard | Mageplaza-Geschenkkarten, maskiert |
Alle sechs sind am 1. November 2026 als 1.0.0 erschienen, MIT-lizenziert, auf GitHub und Packagist, die Renderer für Hyvä und Luma existieren also heute.
Die beiden Hyvä-Pakete sind keine Alternativen zueinander. -hyva-theme
rendert die Warenkorbseite des Hyvä-Standardthemes, -hyva-checkout die
Preisübersicht in Hyvä Checkout. Betreibst du beides, brauchst du beide.
Für Loki Checkout gibt es keinen Renderer. Weil das Basispaket kein Frontend kennt, kann jeder weitere Checkout einen bekommen, nur existiert heute keiner.
Warum Amasty und Mageplaza, und wie du einen eigenen Adapter baust
Diese beiden Adapter gibt es, weil diese beiden Erweiterungen in der Installation stecken, mit der ich arbeite, nicht weil ich den Markt gesichtet hätte. Mageplaza ist die Geschenkkarten-Erweiterung, die dort läuft.
Für jeden anderen Anbieter schreibst du den Adapter selbst. Es ist eine Klasse mit einer Methode:
interface DiscountLineResolverInterface
{
/**
* Resolve discount lines for a collected quote address.
*
* @return DiscountLineInterface[]
*/
public function resolve(Address $address, Quote $quote): array;
}
Danach übergibst du den Resolver dem Composite unter einem eigenen Namen, in di.xml:
<type name="PixelPerfect\DiscountLines\Service\Resolver\CompositeResolver">
<arguments>
<argument name="resolvers" xsi:type="array">
<item name="your_provider" xsi:type="object">
Your\Module\Service\Resolver\YourResolver
</item>
</argument>
</arguments>
</type>
Ein Eintrag, der die Schnittstelle nicht implementiert, wird übersprungen und protokolliert, statt eine Exception auszulösen. Ein kaputter Adapter fällt also auf die Standardzeile zurück, statt den Warenkorb lahmzulegen.
Vier Varianten je Store View
Unter Stores > Configuration > Sales > Discount Lines: A setzt den Gutschein-Chip unter die Beschriftung, B macht den Chip zur Beschriftung und ist der Standard, C gruppiert die Zeilen mit einer Summe „Sie sparen“, und D zeigt ein Icon und den Code in Monospace, ohne Chip.
Bestelldokumente verwenden immer die schlichte Textform, zum Beispiel „Gutschein CODE -2,25“, weil Outlook und PDF-Renderer keinen Chip verlässlich zeichnen.
Was die Zeilen zusammen ergeben
Die Zeilen ergeben zusammen immer die Rabattsumme des Dokuments, auf den Cent genau.
Rundungsdifferenzen von bis zu einem Cent pro Zeile übernimmt die größte Zeile. Ein größerer Rest, der zu keiner Regel gehört, wird zu einer allgemeinen Zeile „Rabatt“, statt einer Regel zugeschrieben zu werden, weil eine falsche Zuordnung schwerer auffällt als eine unbestimmte. Eine Rechnung skaliert die gespeicherten Zeilen auf den Rabattbetrag der Rechnung, eine Gutschrift teilt jede Regel im Verhältnis der erstatteten Mengen auf.
Grenzen
- Bestellungen aus der Zeit vor der Installation behalten die zusammengefasste Standardzeile. Die Beträge pro Regel lassen sich nicht rekonstruieren, weil Magento sie nie gespeichert hat.
- Version 1.0 unterstützt kein Multishipping.
- Über mehrere Rechnungen einer Bestellung können die Beträge pro Regel um einen Cent pro Regel abweichen. Die Rabattsumme jedes Dokuments ist exakt.
- Bei Amasty bekommt die Geschenkzeile zu viel vom Betrag, wenn eine automatische Regel auch den Geschenkartikel rabattiert. Die Summe bleibt richtig. Abhilfe ist die Amasty-Bedingung „is promo item = No“ an den automatischen Regeln.
- Bestehende Warenkörbe zeigen die Standardzeile bis zur nächsten Summenberechnung.
Installation
composer require pixelperfectat/magento2-module-discount-lines
composer require pixelperfectat/magento2-module-discount-lines-hyva-checkout
bin/magento setup:upgrade
Die zweite Zeile ist ein Renderer. Installiere einen pro Frontend, das du
betreibst, nicht einen insgesamt: -hyva-checkout für Hyvä Checkout,
-hyva-theme für die Warenkorbseite des Hyvä-Standardthemes, -luma für Luma.
Betreibst du Hyvä-Theme und Hyvä Checkout, installierst du beide. Dazu
-amasty-promo oder -mageplaza-giftcard, falls du diese Erweiterungen
einsetzt.
Qualität in Zahlen
Ich habe alles in einer frischen Mage-OS-3.1.0-Installation auf PHP 8.5
gebaut und getestet, ausschließlich mit Demodaten. Jedes Paket durchläuft in der CI composer check: composer
validate, PHP-Lint, PHP_CodeSniffer, PHPStan und PHPUnit. Vier der sechs Pakete haben
zusätzlich Mutationstests mit Infection, die den Build unter einem MSI von 85
oder einem covered-code MSI von 90 scheitern lassen.
Mutationstests sind absichtlich kaputte Kopien des Codes, und der Score ist der Anteil, den die Testsuite davon bemerkt. Jede Zahl unten stammt aus dem öffentlichen CI-Log des daneben verlinkten Laufs, nicht aus einer Release-Notiz.
| Paket | MSI / covered-code MSI | Lauf |
|---|---|---|
magento2-module-discount-lines | 93 / 93 | 42242224997, 06.11.2026 |
-amasty-promo | 100 / 100 | 42242248810, 06.11.2026 |
-mageplaza-giftcard | 99 / 99 | 42242346501, 06.11.2026 |
-luma | 93 / 93 | 42242324118, 06.11.2026 |
-hyva-theme | kein Infection-Schritt | validate, lint, cs, stan, test |
-hyva-checkout | kein Infection-Schritt | validate, lint, cs, stan, test |
Die beiden Hyvä-Renderer haben keinen Infection-Schritt und tragen deshalb keinen Score. Ein fehlender Score gilt für mich nicht als bestandene Prüfung.
Mutationstests haben vor dem Release einen echten Fehler gefunden: eine Mageplaza-Geschenkkartennummer aus reinen Ziffern brachte die Maskierung zum Absturz.
Bitte mach es kaputt
Die Module sind neu, und eine Testumgebung ist ein schmaler Ausschnitt dessen, was Magento-Shops an Regelkombinationen, Währungen, Steuerkonfigurationen und weiteren Erweiterungen auf die Summen loslassen.
Installiere sie in einer Testumgebung und kombiniere Rabatte. Mehrere Gutscheine gleichzeitig, Gratisversand mit einem Geschenk, eine Teilgutschrift über zwei Rechnungen, ein Währungswechsel zwischen Bestell- und Basiswährung. Wenn die Zahlen nicht zusammenpassen oder eine Zeile falsch aussieht, ist das ein Issue auf GitHub wert, auch dann, wenn es nur hässlich ist und nicht falsch rechnet. Ich lese jedes.
Die E-Mails sind ein eigenes Thema
Die Zeilen stehen auch in der Bestellbestätigung, aber die E-Mail selbst bleibt die von Magento. Ein zweites Modul ersetzt die E-Mail-Vorlagen von Magento, und dieser Beitrag beschreibt es. Keines der beiden Module braucht das andere.
Fragen
Warum zeigt Magento nur eine Rabattzeile?
Magento fasst alle angewendeten Rabatte in der Summenzeile „Rabatt (…)“ zusammen. In der Bestellung speichert es nur `coupon_code`, `discount_description` und `applied_rule_ids`, also keine Beträge pro Regel. Nach dem Checkout lässt sich die Aufteilung nicht mehr rekonstruieren, weshalb E-Mails, Rechnungen und Gutschriften sie nicht anzeigen können.
Kann die Beschreibung einer Warenkorbregel beim Kunden landen?
Ja. `RulesApplier::getRuleLabel()` nimmt in Magento 2.4.7 zuerst das Store-Label der Regel, und wenn ein Gutschein im Spiel ist und kein Store-Label gesetzt ist, nimmt es die Regelbeschreibung, die im Backend ein Notizfeld ist. Eine interne Notiz steht damit in der Rabattzeile des Warenkorbs, der Bestellbestätigung und der Rechnung. Denselben Text habe ich auch in der Versandzeile gesehen, bei einer Bestellung, deren einzige Regel Gratisversand war.
Wie verhalten sich die Zeilen zur Rabattsumme von Magento?
Die Rabattzeilen ergeben zusammen immer genau die Rabattsumme von Magento, auf den Cent genau. Rundungsdifferenzen von bis zu einem Cent pro Zeile übernimmt die größte Zeile. Ein größerer Rest, der zu keiner Regel gehört, wird zu einer allgemeinen Zeile „Rabatt“, statt einer Regel zugeschrieben zu werden. Liegen die Zeilen zusammen über der Rabattsumme, oder widersprechen sich Bestell- und Basiswährung, werden die Zeilen verworfen und es erscheint die Standardzeile von Magento mit der schlichten Beschriftung „Rabatt“.
Welche Frontends werden unterstützt?
Hyvä Checkout, das Hyvä-Standardtheme und Luma, jeweils als eigenes Paket, alle am 1. November 2026 als 1.0.0 veröffentlicht. Das Basispaket kennt kein Frontend, ein weiterer Checkout kann also einen Renderer bekommen, nur existiert heute keiner.
Wo steht dein Magento-Projekt gerade?
Schildere mir kurz, woran du arbeitest, wo du stehst und was bereits versucht wurde. Ich sage dir ehrlich, ob ich der richtige Entwickler dafür bin.